mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Fix mobile agent activity boundaries
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import 'observer_subscription.dart';
|
||||
|
||||
const _defaultLivenessTimeout = Duration(seconds: 30);
|
||||
const _activeTurnClockInterval = Duration(seconds: 5);
|
||||
const _terminalComposerRetention = Duration(seconds: 30);
|
||||
// Matches buzz-acp's MAX_TURN_DURATION_CEILING_SECS. A disabled or unusually
|
||||
// sparse liveness cadence must not expire before any legal turn can finish.
|
||||
const _maximumTurnDuration = Duration(days: 7);
|
||||
@@ -264,6 +265,30 @@ final activeAgentTurnsProvider = Provider<List<AgentTurnState>>((ref) {
|
||||
];
|
||||
});
|
||||
|
||||
/// Working turns plus recent explicit outcomes that remain actionable beside
|
||||
/// the composer for a short, bounded window.
|
||||
final composerAgentTurnStatesProvider = Provider<List<AgentTurnState>>((ref) {
|
||||
final now =
|
||||
ref.watch(_activeAgentTurnClockProvider).value ?? DateTime.now().toUtc();
|
||||
return composerAgentTurnStates(ref.watch(agentTurnStatesProvider), now: now);
|
||||
});
|
||||
|
||||
/// Filters turn states to those that should remain visible by the composer.
|
||||
@visibleForTesting
|
||||
List<AgentTurnState> composerAgentTurnStates(
|
||||
Iterable<AgentTurnState> states, {
|
||||
required DateTime now,
|
||||
}) => List.unmodifiable([
|
||||
for (final state in states)
|
||||
if (state.isWorking ||
|
||||
!now.isAfter(
|
||||
(state.terminalAt ?? state.lastActivityAt).add(
|
||||
_terminalComposerRetention,
|
||||
),
|
||||
))
|
||||
state,
|
||||
]);
|
||||
|
||||
DateTime _frameTimestamp(ObserverFrame frame) =>
|
||||
DateTime.tryParse(frame.timestamp)?.toUtc() ??
|
||||
DateTime.fromMillisecondsSinceEpoch(frame.seq, isUtc: true);
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../shared/theme/theme.dart';
|
||||
import '../../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../profile/user_cache_provider.dart';
|
||||
import '../../../shared/profile/user_cache_provider.dart';
|
||||
import '../date_formatters.dart';
|
||||
import 'observer_models.dart';
|
||||
import 'observer_subscription.dart';
|
||||
|
||||
@@ -11,8 +11,8 @@ import '../../../shared/theme/theme.dart';
|
||||
import '../../../shared/utils/string_utils.dart';
|
||||
import '../../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../profile/user_cache_provider.dart';
|
||||
import '../../profile/user_profile.dart';
|
||||
import '../../../shared/profile/user_cache_provider.dart';
|
||||
import '../../../shared/profile/user_profile.dart';
|
||||
import '../channel_typing_indicator.dart';
|
||||
import '../small_avatar.dart';
|
||||
import 'active_agent_turns.dart';
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../profile/user_cache_provider.dart';
|
||||
import '../../../shared/profile/user_cache_provider.dart';
|
||||
import '../channel_management_provider.dart';
|
||||
import '../channel_typing_provider.dart';
|
||||
import 'active_agent_turns.dart';
|
||||
@@ -70,9 +70,9 @@ final composerActivityStateProvider = Provider.autoDispose
|
||||
final ownerByAgent =
|
||||
ref.watch(agentOwnersProvider).asData?.value ??
|
||||
const <String, String>{};
|
||||
final activeTurns = ref.watch(activeAgentTurnsProvider);
|
||||
final composerTurns = ref.watch(composerAgentTurnStatesProvider);
|
||||
final activeByAgent = <String, AgentTurnState>{};
|
||||
for (final turn in activeTurns) {
|
||||
for (final turn in composerTurns) {
|
||||
if (turn.channelId != key.channelId) continue;
|
||||
final existing = activeByAgent[turn.agentPubkey];
|
||||
if (existing == null ||
|
||||
@@ -95,6 +95,7 @@ final composerActivityStateProvider = Provider.autoDispose
|
||||
for (final entry in activeByAgent.entries) {
|
||||
if (!channelAgents.contains(entry.key)) continue;
|
||||
final turn = entry.value;
|
||||
if (!turn.isWorking && !canView(entry.key)) continue;
|
||||
signals[entry.key] = WorkingAgentSignal(
|
||||
pubkey: entry.key,
|
||||
source: AgentWorkingSource.observer,
|
||||
|
||||
@@ -1,90 +1,2 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/crypto/nip_oa.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import 'user_profile.dart';
|
||||
|
||||
/// In-memory cache of user profiles, fetched in batches from the relay.
|
||||
///
|
||||
/// Lookups requested via [get] or [preload] are coalesced into a single
|
||||
/// kind:0 batch query (NIP-01 `authors` filter) every 50ms.
|
||||
class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
|
||||
final Set<String> _pending = {};
|
||||
Timer? _batchTimer;
|
||||
|
||||
@override
|
||||
Map<String, UserProfile> build() {
|
||||
ref.watch(relayConfigProvider);
|
||||
ref.onDispose(() {
|
||||
_batchTimer?.cancel();
|
||||
_batchTimer = null;
|
||||
});
|
||||
return {};
|
||||
}
|
||||
|
||||
/// Request a profile for [pubkey]. Returns immediately from cache if
|
||||
/// available, otherwise schedules a batch fetch.
|
||||
UserProfile? get(String pubkey) {
|
||||
final cached = state[pubkey.toLowerCase()];
|
||||
if (cached != null) return cached;
|
||||
_scheduleFetch(pubkey.toLowerCase());
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Preload profiles for a list of pubkeys (e.g. channel members).
|
||||
void preload(List<String> pubkeys) {
|
||||
final uncached = pubkeys
|
||||
.map((pk) => pk.toLowerCase())
|
||||
.where((pk) => !state.containsKey(pk) && !_pending.contains(pk))
|
||||
.toList();
|
||||
if (uncached.isEmpty) return;
|
||||
_pending.addAll(uncached);
|
||||
_batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending);
|
||||
}
|
||||
|
||||
void _scheduleFetch(String pubkey) {
|
||||
if (state.containsKey(pubkey) || _pending.contains(pubkey)) return;
|
||||
_pending.add(pubkey);
|
||||
_batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending);
|
||||
}
|
||||
|
||||
Future<void> _flushPending() async {
|
||||
_batchTimer = null;
|
||||
if (_pending.isEmpty) return;
|
||||
|
||||
final pubkeys = _pending.toList();
|
||||
_pending.clear();
|
||||
|
||||
try {
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final events = await session.fetchHistory(
|
||||
NostrFilters.profilesBatch(pubkeys),
|
||||
);
|
||||
|
||||
final updated = Map<String, UserProfile>.from(state);
|
||||
for (final event in events) {
|
||||
final data = ProfileData.fromEvent(event);
|
||||
final pk = data.pubkey.toLowerCase();
|
||||
updated[pk] = UserProfile(
|
||||
pubkey: pk,
|
||||
displayName: data.displayName,
|
||||
avatarUrl: data.avatarUrl,
|
||||
about: data.about,
|
||||
nip05Handle: data.nip05,
|
||||
ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey),
|
||||
);
|
||||
}
|
||||
|
||||
state = updated;
|
||||
} catch (_) {
|
||||
// Silently fail — we'll just show pubkeys.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final userCacheProvider =
|
||||
NotifierProvider<UserCacheNotifier, Map<String, UserProfile>>(
|
||||
UserCacheNotifier.new,
|
||||
);
|
||||
// Compatibility export for profile-feature callers.
|
||||
export '../../shared/profile/user_cache_provider.dart';
|
||||
|
||||
@@ -1,48 +1,2 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class UserProfile {
|
||||
final String pubkey;
|
||||
final String? displayName;
|
||||
final String? avatarUrl;
|
||||
final String? about;
|
||||
final String? nip05Handle;
|
||||
|
||||
/// NIP-OA verified owner pubkey from the profile's `auth` tag; non-null
|
||||
/// means this identity is an agent (mirrors desktop's `ownerPubkey`).
|
||||
final String? ownerPubkey;
|
||||
|
||||
const UserProfile({
|
||||
required this.pubkey,
|
||||
this.displayName,
|
||||
this.avatarUrl,
|
||||
this.about,
|
||||
this.nip05Handle,
|
||||
this.ownerPubkey,
|
||||
});
|
||||
|
||||
factory UserProfile.fromJson(Map<String, dynamic> json) => UserProfile(
|
||||
pubkey: json['pubkey'] as String,
|
||||
displayName: json['display_name'] as String?,
|
||||
avatarUrl: json['avatar_url'] as String?,
|
||||
about: json['about'] as String?,
|
||||
nip05Handle: json['nip05_handle'] as String?,
|
||||
);
|
||||
|
||||
/// Short label: display name, or first 8 chars of pubkey.
|
||||
String get label =>
|
||||
displayName ??
|
||||
'${pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey}...';
|
||||
|
||||
/// First letter for fallback avatar.
|
||||
String get initial =>
|
||||
(displayName?.isNotEmpty == true ? displayName! : pubkey)[0]
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/// Optional profile handle shown beside a message author's display name.
|
||||
String? messageUsernameLabel(UserProfile? profile) {
|
||||
final handle = profile?.nip05Handle?.trim();
|
||||
if (handle != null && handle.isNotEmpty) return handle;
|
||||
return null;
|
||||
}
|
||||
// Compatibility export for profile-feature callers.
|
||||
export '../../shared/profile/user_profile.dart';
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../crypto/nip_oa.dart';
|
||||
import '../relay/relay.dart';
|
||||
import 'user_profile.dart';
|
||||
|
||||
/// In-memory cache of user profiles, fetched in batches from the relay.
|
||||
///
|
||||
/// Lookups requested via [get] or [preload] are coalesced into a single
|
||||
/// kind:0 batch query (NIP-01 `authors` filter) every 50ms.
|
||||
class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
|
||||
final Set<String> _pending = {};
|
||||
Timer? _batchTimer;
|
||||
|
||||
@override
|
||||
Map<String, UserProfile> build() {
|
||||
ref.watch(relayConfigProvider);
|
||||
ref.onDispose(() {
|
||||
_batchTimer?.cancel();
|
||||
_batchTimer = null;
|
||||
});
|
||||
return {};
|
||||
}
|
||||
|
||||
/// Request a profile for [pubkey]. Returns immediately from cache if
|
||||
/// available, otherwise schedules a batch fetch.
|
||||
UserProfile? get(String pubkey) {
|
||||
final cached = state[pubkey.toLowerCase()];
|
||||
if (cached != null) return cached;
|
||||
_scheduleFetch(pubkey.toLowerCase());
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Preload profiles for a list of pubkeys (e.g. channel members).
|
||||
void preload(List<String> pubkeys) {
|
||||
final uncached = pubkeys
|
||||
.map((pk) => pk.toLowerCase())
|
||||
.where((pk) => !state.containsKey(pk) && !_pending.contains(pk))
|
||||
.toList();
|
||||
if (uncached.isEmpty) return;
|
||||
_pending.addAll(uncached);
|
||||
_batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending);
|
||||
}
|
||||
|
||||
void _scheduleFetch(String pubkey) {
|
||||
if (state.containsKey(pubkey) || _pending.contains(pubkey)) return;
|
||||
_pending.add(pubkey);
|
||||
_batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending);
|
||||
}
|
||||
|
||||
Future<void> _flushPending() async {
|
||||
_batchTimer = null;
|
||||
if (_pending.isEmpty) return;
|
||||
|
||||
final pubkeys = _pending.toList();
|
||||
_pending.clear();
|
||||
|
||||
try {
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final events = await session.fetchHistory(
|
||||
NostrFilters.profilesBatch(pubkeys),
|
||||
);
|
||||
|
||||
final updated = Map<String, UserProfile>.from(state);
|
||||
for (final event in events) {
|
||||
final data = ProfileData.fromEvent(event);
|
||||
final pk = data.pubkey.toLowerCase();
|
||||
updated[pk] = UserProfile(
|
||||
pubkey: pk,
|
||||
displayName: data.displayName,
|
||||
avatarUrl: data.avatarUrl,
|
||||
about: data.about,
|
||||
nip05Handle: data.nip05,
|
||||
ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey),
|
||||
);
|
||||
}
|
||||
|
||||
state = updated;
|
||||
} catch (_) {
|
||||
// Silently fail — we'll just show pubkeys.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared relay-backed profile cache for cross-feature identity presentation.
|
||||
final userCacheProvider =
|
||||
NotifierProvider<UserCacheNotifier, Map<String, UserProfile>>(
|
||||
UserCacheNotifier.new,
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Relay-backed user identity metadata shared across product features.
|
||||
@immutable
|
||||
class UserProfile {
|
||||
final String pubkey;
|
||||
final String? displayName;
|
||||
final String? avatarUrl;
|
||||
final String? about;
|
||||
final String? nip05Handle;
|
||||
|
||||
/// NIP-OA verified owner pubkey from the profile's `auth` tag; non-null
|
||||
/// means this identity is an agent (mirrors desktop's `ownerPubkey`).
|
||||
final String? ownerPubkey;
|
||||
|
||||
const UserProfile({
|
||||
required this.pubkey,
|
||||
this.displayName,
|
||||
this.avatarUrl,
|
||||
this.about,
|
||||
this.nip05Handle,
|
||||
this.ownerPubkey,
|
||||
});
|
||||
|
||||
factory UserProfile.fromJson(Map<String, dynamic> json) => UserProfile(
|
||||
pubkey: json['pubkey'] as String,
|
||||
displayName: json['display_name'] as String?,
|
||||
avatarUrl: json['avatar_url'] as String?,
|
||||
about: json['about'] as String?,
|
||||
nip05Handle: json['nip05_handle'] as String?,
|
||||
);
|
||||
|
||||
/// Short label: display name, or first 8 chars of pubkey.
|
||||
String get label =>
|
||||
displayName ??
|
||||
'${pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey}...';
|
||||
|
||||
/// First letter for fallback avatar.
|
||||
String get initial =>
|
||||
(displayName?.isNotEmpty == true ? displayName! : pubkey)[0]
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/// Optional profile handle shown beside a message author's display name.
|
||||
String? messageUsernameLabel(UserProfile? profile) {
|
||||
final handle = profile?.nip05Handle?.trim();
|
||||
if (handle != null && handle.isNotEmpty) return handle;
|
||||
return null;
|
||||
}
|
||||
@@ -203,6 +203,41 @@ void main() {
|
||||
expect(turns.single.phase, AgentTurnPhase.error);
|
||||
expect(turns.single.errorMessage, 'Process exited');
|
||||
});
|
||||
|
||||
test(
|
||||
'retains terminal outcomes beside the composer for a bounded window',
|
||||
() {
|
||||
final terminalAt = DateTime.utc(2026, 8, 16, 12, 0, 2);
|
||||
final states = [
|
||||
AgentTurnState(
|
||||
agentPubkey: 'agent-a',
|
||||
channelId: 'channel-1',
|
||||
turnId: 'turn-a',
|
||||
startedAt: DateTime.utc(2026, 8, 16, 12),
|
||||
lastActivityAt: terminalAt,
|
||||
livenessTimeout: const Duration(seconds: 30),
|
||||
phase: AgentTurnPhase.error,
|
||||
terminalAt: terminalAt,
|
||||
errorMessage: 'Agent timed out',
|
||||
),
|
||||
];
|
||||
|
||||
expect(
|
||||
composerAgentTurnStates(
|
||||
states,
|
||||
now: terminalAt.add(const Duration(seconds: 30)),
|
||||
),
|
||||
hasLength(1),
|
||||
);
|
||||
expect(
|
||||
composerAgentTurnStates(
|
||||
states,
|
||||
now: terminalAt.add(const Duration(seconds: 31)),
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ObserverFrame _frame({
|
||||
|
||||
+50
-2
@@ -9,8 +9,8 @@ import 'package:buzz/features/channels/agent_activity/observer_models.dart';
|
||||
import 'package:buzz/features/channels/agent_activity/observer_subscription.dart';
|
||||
import 'package:buzz/features/channels/agent_activity/working_bots_provider.dart';
|
||||
import 'package:buzz/features/channels/channel_typing_provider.dart';
|
||||
import 'package:buzz/features/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/shared/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
|
||||
const _channelId = 'channel-1';
|
||||
@@ -158,6 +158,54 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('keeps a collapsed terminal error openable', (tester) async {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
composerActivityStateProvider(_scope).overrideWithValue(
|
||||
const ComposerActivityState(
|
||||
agents: [
|
||||
WorkingAgentSignal(
|
||||
pubkey: _agentPubkey,
|
||||
source: AgentWorkingSource.observer,
|
||||
canViewActivity: true,
|
||||
turnId: _turnId,
|
||||
),
|
||||
],
|
||||
humanTyping: [],
|
||||
),
|
||||
),
|
||||
agentTurnStatesProvider.overrideWithValue([
|
||||
_turn(AgentTurnPhase.error),
|
||||
]),
|
||||
observerTurnSubscriptionProvider(
|
||||
_turnKey,
|
||||
).overrideWithValue(_observerState),
|
||||
userCacheProvider.overrideWith(_FakeUserCacheNotifier.new),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await tester.pumpWidget(_app(container));
|
||||
await tester.pump();
|
||||
|
||||
final control = find.byKey(
|
||||
const ValueKey('composer-agent-activity-control'),
|
||||
);
|
||||
expect(control, findsOneWidget);
|
||||
expect(find.text('Pollen stopped with an error'), findsOneWidget);
|
||||
|
||||
await tester.tap(control);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 240));
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('composer-agent-activity-panel')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Error'), findsOneWidget);
|
||||
expect(find.text('Thinking'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('reduced motion makes inline size changes immediate', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -6,9 +6,9 @@ import 'package:buzz/features/channels/agent_activity/observer_subscription.dart
|
||||
import 'package:buzz/features/channels/agent_activity/working_bots_provider.dart';
|
||||
import 'package:buzz/features/channels/channel_management_provider.dart';
|
||||
import 'package:buzz/features/channels/channel_typing_provider.dart';
|
||||
import 'package:buzz/features/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
|
||||
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/shared/profile/user_profile.dart';
|
||||
|
||||
const _channelId = 'channel-1';
|
||||
|
||||
@@ -40,7 +40,7 @@ void main() {
|
||||
'agent-a': [_observerFrame('agent-a')],
|
||||
}),
|
||||
),
|
||||
activeAgentTurnsProvider.overrideWithValue([observerTurn]),
|
||||
composerAgentTurnStatesProvider.overrideWithValue([observerTurn]),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
@@ -96,7 +96,7 @@ void main() {
|
||||
'agent-a': [_observerFrame('agent-a')],
|
||||
}),
|
||||
),
|
||||
activeAgentTurnsProvider.overrideWithValue([_turn('agent-a')]),
|
||||
composerAgentTurnStatesProvider.overrideWithValue([_turn('agent-a')]),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
@@ -113,16 +113,64 @@ void main() {
|
||||
expect(state.humanTyping.single.pubkey, 'human');
|
||||
},
|
||||
);
|
||||
|
||||
test('keeps a recent owned error reachable after typing stops', () {
|
||||
final failedTurn = _turn('agent-a', phase: AgentTurnPhase.error);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
currentPubkeyProvider.overrideWith((ref) => 'owner'),
|
||||
channelMembersProvider(
|
||||
_channelId,
|
||||
).overrideWith((ref) async => const <ChannelMember>[]),
|
||||
channelTypingProvider(
|
||||
_channelId,
|
||||
).overrideWith(() => _FakeTypingNotifier(const [])),
|
||||
agentMentionPubkeysProvider(
|
||||
_channelId,
|
||||
).overrideWith((ref) => const {'agent-a'}),
|
||||
agentOwnersProvider.overrideWithValue(
|
||||
const AsyncData({'agent-a': 'owner'}),
|
||||
),
|
||||
userCacheProvider.overrideWith(_FakeUserCacheNotifier.new),
|
||||
observerRelayProvider.overrideWith(
|
||||
() => _FakeObserverRelayNotifier({
|
||||
'agent-a': [_observerFrame('agent-a')],
|
||||
}),
|
||||
),
|
||||
composerAgentTurnStatesProvider.overrideWithValue([failedTurn]),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final state = container.read(
|
||||
composerActivityStateProvider((
|
||||
channelId: _channelId,
|
||||
threadHeadId: null,
|
||||
)),
|
||||
);
|
||||
|
||||
expect(state.agents, hasLength(1));
|
||||
expect(state.agents.single.pubkey, 'agent-a');
|
||||
expect(state.agents.single.turnId, failedTurn.turnId);
|
||||
expect(state.agents.single.canViewActivity, isTrue);
|
||||
expect(state.humanTyping, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
AgentTurnState _turn(String pubkey) => AgentTurnState(
|
||||
AgentTurnState _turn(
|
||||
String pubkey, {
|
||||
AgentTurnPhase phase = AgentTurnPhase.working,
|
||||
}) => AgentTurnState(
|
||||
agentPubkey: pubkey,
|
||||
channelId: _channelId,
|
||||
turnId: 'turn-$pubkey',
|
||||
startedAt: DateTime.utc(2026, 8, 16, 12),
|
||||
lastActivityAt: DateTime.utc(2026, 8, 16, 12),
|
||||
livenessTimeout: const Duration(seconds: 30),
|
||||
phase: AgentTurnPhase.working,
|
||||
phase: phase,
|
||||
terminalAt: phase == AgentTurnPhase.working
|
||||
? null
|
||||
: DateTime.utc(2026, 8, 16, 12, 0, 5),
|
||||
);
|
||||
|
||||
ObserverFrame _observerFrame(String pubkey) => ObserverFrame(
|
||||
|
||||
Reference in New Issue
Block a user