From 7af71e9ce710cfa5f57f9989abfd6f606be18fdc Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 16 Aug 2026 16:40:23 +0100 Subject: [PATCH 01/17] Add inline mobile agent activity Signed-off-by: kenny lopez --- .../agent_activity/active_agent_turns.dart | 313 +++++ .../composer_agent_activity_indicator.dart | 958 +++++++++++++++ .../agent_activity_controls.dart | 458 +++++++ .../compact_activity_item.dart | 66 + .../agent_activity/observer_models.dart | 11 +- .../agent_activity/observer_subscription.dart | 38 +- .../agent_activity/transcript_builder.dart | 14 + .../agent_activity/working_bots_provider.dart | 161 ++- .../channels/channel_detail_page.dart | 60 +- mobile/lib/features/channels/compose_bar.dart | 10 + .../compose_bar/activity_handoff.dart | 36 + .../compose_bar/compose_bar_widget.dart | 81 +- .../features/channels/compose_bar/dock.dart | 21 +- .../channels/thread_detail_helpers.dart | 48 +- .../features/channels/thread_detail_page.dart | 42 +- .../active_agent_turns_test.dart | 126 ++ ...omposer_agent_activity_indicator_test.dart | 1064 +++++++++++++++++ .../observer_subscription_test.dart | 80 ++ .../transcript_builder_test.dart | 23 + .../working_bots_provider_test.dart | 163 +++ .../channels/channel_detail_page_test.dart | 241 ++++ 21 files changed, 3897 insertions(+), 117 deletions(-) create mode 100644 mobile/lib/features/channels/agent_activity/active_agent_turns.dart create mode 100644 mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart create mode 100644 mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart create mode 100644 mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/compact_activity_item.dart create mode 100644 mobile/lib/features/channels/compose_bar/activity_handoff.dart create mode 100644 mobile/test/features/channels/agent_activity/active_agent_turns_test.dart create mode 100644 mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart create mode 100644 mobile/test/features/channels/agent_activity/working_bots_provider_test.dart diff --git a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart new file mode 100644 index 00000000000..0de0cd8b48a --- /dev/null +++ b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart @@ -0,0 +1,313 @@ +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../shared/relay/relay.dart'; +import 'observer_models.dart'; +import 'observer_subscription.dart'; + +const _defaultLivenessTimeout = Duration(seconds: 30); +const _activeTurnClockInterval = Duration(seconds: 5); +const _maximumLivenessInterval = Duration(hours: 24); +const _livenessTimeoutSlack = Duration(seconds: 30); + +/// Lifecycle state reconstructed from owner-scoped observer frames. +enum AgentTurnPhase { working, finished, error } + +/// One observed agent turn, including its explicit terminal outcome when known. +@immutable +class AgentTurnState { + final String agentPubkey; + final String channelId; + final String turnId; + final DateTime startedAt; + final DateTime lastActivityAt; + final Duration livenessTimeout; + final AgentTurnPhase phase; + final DateTime? terminalAt; + final String? errorMessage; + final String? triggeringEventId; + + const AgentTurnState({ + required this.agentPubkey, + required this.channelId, + required this.turnId, + required this.startedAt, + required this.lastActivityAt, + required this.livenessTimeout, + required this.phase, + this.terminalAt, + this.errorMessage, + this.triggeringEventId, + }); + + bool get isWorking => phase == AgentTurnPhase.working; + + AgentTurnState withActivity({required DateTime at, Duration? timeout}) => + AgentTurnState( + agentPubkey: agentPubkey, + channelId: channelId, + turnId: turnId, + startedAt: startedAt, + lastActivityAt: at, + livenessTimeout: timeout ?? livenessTimeout, + phase: AgentTurnPhase.working, + triggeringEventId: triggeringEventId, + ); + + AgentTurnState withTerminal({ + required AgentTurnPhase phase, + required DateTime at, + String? errorMessage, + }) => AgentTurnState( + agentPubkey: agentPubkey, + channelId: channelId, + turnId: turnId, + startedAt: startedAt, + lastActivityAt: at, + livenessTimeout: livenessTimeout, + phase: phase, + terminalAt: at, + errorMessage: errorMessage, + triggeringEventId: triggeringEventId, + ); +} + +/// Reconstructs live and explicitly terminal turns from the retained frame +/// buffer. Silent working turns expire instead of being mislabeled finished. +List reduceAgentTurnStates( + Map> framesByAgent, { + required DateTime now, +}) { + final states = []; + + for (final entry in framesByAgent.entries) { + final agentPubkey = entry.key.toLowerCase(); + final frames = [...entry.value]..sort(_compareFrames); + final turnsById = {}; + final terminalOrderById = {}; + + for (final frame in frames) { + final frameOrderAt = _frameTimestamp(frame); + final frameAt = frame.receivedAt ?? frameOrderAt; + switch (frame.kind) { + case 'turn_started': + final channelId = frame.channelId; + if (channelId == null) continue; + final turnId = frame.turnId ?? 'seq-${frame.seq}'; + turnsById[turnId] = AgentTurnState( + agentPubkey: agentPubkey, + channelId: channelId, + turnId: turnId, + startedAt: _safeStartedAt(frame, frameAt), + lastActivityAt: frameAt, + livenessTimeout: _livenessTimeout(frame.payload), + phase: AgentTurnPhase.working, + triggeringEventId: _triggeringEventId(frame.payload), + ); + case 'turn_completed': + case 'turn_error': + case 'agent_panic': + final terminalPhase = frame.kind == 'turn_completed' + ? AgentTurnPhase.finished + : AgentTurnPhase.error; + final turnId = frame.turnId; + if (turnId != null) { + terminalOrderById[turnId] = frameOrderAt; + final existing = turnsById[turnId]; + final channelId = existing?.channelId ?? frame.channelId; + if (channelId == null) continue; + turnsById[turnId] = + existing?.withTerminal( + phase: terminalPhase, + at: frameAt, + errorMessage: _turnError(frame.payload), + ) ?? + AgentTurnState( + agentPubkey: agentPubkey, + channelId: channelId, + turnId: turnId, + startedAt: _safeStartedAt(frame, frameAt), + lastActivityAt: frameAt, + livenessTimeout: _livenessTimeout(frame.payload), + phase: terminalPhase, + terminalAt: frameAt, + errorMessage: _turnError(frame.payload), + ); + continue; + } + + final channelId = frame.channelId; + if (channelId == null) continue; + final matching = turnsById.values + .where((turn) => turn.channelId == channelId && turn.isWorking) + .fold( + null, + (latest, turn) => + latest == null || + turn.lastActivityAt.isAfter(latest.lastActivityAt) + ? turn + : latest, + ); + if (matching == null) continue; + terminalOrderById[matching.turnId] = frameOrderAt; + turnsById[matching.turnId] = matching.withTerminal( + phase: terminalPhase, + at: frameAt, + errorMessage: _turnError(frame.payload), + ); + case 'acp_read': + case 'acp_write': + case 'turn_liveness': + final turnId = frame.turnId; + if (turnId == null) continue; + final existing = turnsById[turnId]; + if (existing?.isWorking == true) { + turnsById[turnId] = existing!.withActivity( + at: frameAt, + timeout: frame.kind == 'turn_liveness' + ? _livenessTimeout(frame.payload) + : null, + ); + continue; + } + + final terminalOrder = terminalOrderById[turnId]; + if (terminalOrder != null && !frameOrderAt.isAfter(terminalOrder)) { + continue; + } + final channelId = frame.channelId; + if (channelId == null) continue; + turnsById[turnId] = AgentTurnState( + agentPubkey: agentPubkey, + channelId: channelId, + turnId: turnId, + startedAt: _safeStartedAt(frame, frameAt), + lastActivityAt: frameAt, + livenessTimeout: _livenessTimeout(frame.payload), + phase: AgentTurnPhase.working, + ); + } + } + + states.addAll( + turnsById.values.where( + (turn) => + !turn.isWorking || + now.difference(turn.lastActivityAt) <= turn.livenessTimeout, + ), + ); + } + + states.sort((a, b) { + final started = a.startedAt.compareTo(b.startedAt); + if (started != 0) return started; + final agent = a.agentPubkey.compareTo(b.agentPubkey); + if (agent != 0) return agent; + return a.turnId.compareTo(b.turnId); + }); + return List.unmodifiable(states); +} + +/// Most recent state for one agent in one channel. +AgentTurnState? latestAgentTurnState( + Iterable states, { + required String agentPubkey, + required String channelId, + String? turnId, +}) { + final normalizedAgent = agentPubkey.toLowerCase(); + AgentTurnState? latest; + for (final state in states) { + if (state.agentPubkey != normalizedAgent || + state.channelId != channelId || + (turnId != null && state.turnId != turnId)) { + continue; + } + if (latest == null || state.lastActivityAt.isAfter(latest.lastActivityAt)) { + latest = state; + } + } + return latest; +} + +final _activeAgentTurnClockProvider = StreamProvider((ref) async* { + yield DateTime.now().toUtc(); + yield* Stream.periodic( + _activeTurnClockInterval, + (_) => DateTime.now().toUtc(), + ); +}); + +/// Observer-derived turn states, including buffered terminal outcomes. +final agentTurnStatesProvider = Provider>((ref) { + final observerState = ref.watch(observerRelayProvider); + if (observerState.framesByAgent.isEmpty) return const []; + ref.watch(appLifecycleProvider); + final now = + ref.watch(_activeAgentTurnClockProvider).value ?? DateTime.now().toUtc(); + return reduceAgentTurnStates(observerState.framesByAgent, now: now); +}); + +/// Observer-derived turns that are still live according to local receipt time. +final activeAgentTurnsProvider = Provider>((ref) { + return [ + for (final turn in ref.watch(agentTurnStatesProvider)) + if (turn.isWorking) turn, + ]; +}); + +DateTime _frameTimestamp(ObserverFrame frame) => + DateTime.tryParse(frame.timestamp)?.toUtc() ?? + DateTime.fromMillisecondsSinceEpoch(frame.seq, isUtc: true); + +DateTime _safeStartedAt(ObserverFrame frame, DateTime frameAt) { + final hostFrameAt = DateTime.tryParse(frame.timestamp)?.toUtc(); + final hostStartedAt = DateTime.tryParse(frame.startedAt ?? '')?.toUtc(); + if (hostFrameAt == null || + hostStartedAt == null || + hostStartedAt.isAfter(hostFrameAt)) { + return frameAt; + } + return frameAt.subtract(hostFrameAt.difference(hostStartedAt)); +} + +String? _triggeringEventId(dynamic payload) { + if (payload is! Map) return null; + final eventIds = payload['triggeringEventIds']; + if (eventIds is! List) return null; + for (final eventId in eventIds) { + if (eventId is String && eventId.isNotEmpty) return eventId; + } + return null; +} + +String? _turnError(dynamic payload) { + if (payload is! Map) return null; + final error = payload['error']; + return error is String && error.trim().isNotEmpty ? error.trim() : null; +} + +Duration _livenessTimeout(dynamic payload) { + final rawInterval = payload is Map ? payload['livenessIntervalSecs'] : null; + if (rawInterval is! num) return _defaultLivenessTimeout; + + final intervalSeconds = rawInterval.toInt(); + if (intervalSeconds <= 0) { + return _maximumLivenessInterval + _livenessTimeoutSlack; + } + final boundedInterval = intervalSeconds.clamp( + 5, + _maximumLivenessInterval.inSeconds, + ); + final timeoutSeconds = boundedInterval + _livenessTimeoutSlack.inSeconds; + return Duration( + seconds: timeoutSeconds < _defaultLivenessTimeout.inSeconds + ? _defaultLivenessTimeout.inSeconds + : timeoutSeconds, + ); +} + +int _compareFrames(ObserverFrame a, ObserverFrame b) { + final timestamp = _frameTimestamp(a).compareTo(_frameTimestamp(b)); + return timestamp != 0 ? timestamp : a.seq.compareTo(b.seq); +} diff --git a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart new file mode 100644 index 00000000000..48eb57bf9e8 --- /dev/null +++ b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart @@ -0,0 +1,958 @@ +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +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 '../../../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 '../channel_typing_indicator.dart'; +import '../small_avatar.dart'; +import 'active_agent_turns.dart'; +import 'observer_models.dart'; +import 'observer_subscription.dart'; +import 'working_bots_provider.dart'; + +part 'composer_agent_activity_indicator/agent_activity_controls.dart'; +part 'composer_agent_activity_indicator/compact_activity_item.dart'; + +/// Composer-adjacent agent status that expands upward into a bounded activity +/// stream without moving or covering the composer itself. +class ComposerAgentActivityIndicator extends HookConsumerWidget { + static const _compactSurfaceHeight = 52.0; + static const _expandedTargetHeight = 328.0; + static const _surfaceMorphDuration = Duration(milliseconds: 220); + static const _surfaceMorphCurve = Cubic(0.77, 0, 0.175, 1); + + final String channelId; + final String? threadHeadId; + final bool animated; + final double horizontalInset; + final double? overlayTopBoundary; + final double compactWidthFactor; + final Animation? composerWidthAnimation; + final FocusNode? composerFocusNode; + final ValueNotifier? composerInteractionLock; + final ValueNotifier? composerActivationRequests; + final VoidCallback? onRestoreComposerFocus; + + const ComposerAgentActivityIndicator({ + super.key, + required this.channelId, + this.threadHeadId, + this.animated = true, + this.horizontalInset = Grid.twelve, + this.overlayTopBoundary, + this.compactWidthFactor = 1, + this.composerWidthAnimation, + this.composerFocusNode, + this.composerInteractionLock, + this.composerActivationRequests, + this.onRestoreComposerFocus, + }) : assert(compactWidthFactor > 0 && compactWidthFactor <= 1); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final activity = ref.watch( + composerActivityStateProvider(( + channelId: channelId, + threadHeadId: threadHeadId, + )), + ); + final turnStates = ref.watch(agentTurnStatesProvider); + final profiles = ref.watch(userCacheProvider); + final expanded = useState(false); + final pendingExpansion = useState(false); + final selectedAgent = useState(null); + final pinnedTurnId = useState(null); + final rememberedAgents = useState>(const []); + final activityAnchorKey = useMemoized(GlobalKey.new); + final activityOverlayController = useMemoized(OverlayPortalController.new); + final compactActivityWidth = useState(0.0); + final expandedHeight = useState(_expandedTargetHeight); + final expansionController = useAnimationController( + duration: _surfaceMorphDuration, + ); + final scrollController = useScrollController(); + final followsTail = useRef(true); + final composerFocusToRestore = useRef(null); + final restoreComposerFocus = useRef(false); + final previousTranscriptVersion = useRef(''); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final motionDisabled = !animated || reducedMotion; + final mediaSize = MediaQuery.sizeOf(context); + final viewInsets = MediaQuery.viewInsetsOf(context); + final viewPadding = MediaQuery.viewPaddingOf(context); + final platformView = View.of(context); + final keyboardInsetBottom = math.max( + viewInsets.bottom, + platformView.viewInsets.bottom / platformView.devicePixelRatio, + ); + final widthAnimation = composerWidthAnimation ?? kAlwaysDismissedAnimation; + useListenable(widthAnimation); + final composerWidthProgress = widthAnimation.value + .clamp(0.0, 1.0) + .toDouble(); + + void updateOverlayGeometry() { + final renderObject = activityAnchorKey.currentContext?.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.hasSize) return; + final anchorBottom = renderObject + .localToGlobal(Offset(0, renderObject.size.height)) + .dy; + final topBoundary = overlayTopBoundary ?? frostedAppBarHeight(context); + final availableHeight = math.max( + _compactSurfaceHeight, + anchorBottom - topBoundary - Grid.xxs, + ); + final nextExpandedHeight = math.min( + _expandedTargetHeight, + availableHeight, + ); + final nextCompactWidth = renderObject.size.width; + if ((compactActivityWidth.value - nextCompactWidth).abs() >= 0.5) { + compactActivityWidth.value = nextCompactWidth; + } + if ((expandedHeight.value - nextExpandedHeight).abs() >= 0.5) { + expandedHeight.value = nextExpandedHeight; + } + } + + void releaseComposerFocusLock() { + final interactionLock = composerInteractionLock; + if (interactionLock != null && interactionLock.value) { + interactionLock.value = false; + } + } + + void restoreComposerAfterCollapse() { + final focusNode = composerFocusToRestore.value; + final shouldRestore = restoreComposerFocus.value; + composerFocusToRestore.value = null; + restoreComposerFocus.value = false; + releaseComposerFocusLock(); + if (!shouldRestore || focusNode == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + if (onRestoreComposerFocus != null) { + onRestoreComposerFocus!(); + } else if (focusNode.canRequestFocus) { + focusNode.requestFocus(); + } + }); + } + + useEffect(() { + return () { + final interactionLock = composerInteractionLock; + if (interactionLock != null && interactionLock.value) { + interactionLock.value = false; + } + }; + }, [composerInteractionLock]); + + void hideActivityOverlay() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted && activityOverlayController.isShowing) { + activityOverlayController.hide(); + } + }); + } + + useEffect( + () { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) updateOverlayGeometry(); + }); + return null; + }, + [ + mediaSize.height, + keyboardInsetBottom, + viewPadding.top, + overlayTopBoundary, + expanded.value, + ], + ); + + useEffect(() { + if (!pendingExpansion.value || + keyboardInsetBottom > 0.5 || + composerWidthProgress > 0.001) { + return null; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted || !pendingExpansion.value) return; + updateOverlayGeometry(); + activityOverlayController.show(); + pendingExpansion.value = false; + expanded.value = true; + }); + return null; + }, [pendingExpansion.value, keyboardInsetBottom, composerWidthProgress]); + + useEffect(() { + if (motionDisabled) { + expansionController.value = expanded.value ? 1 : 0; + } else if (expanded.value) { + expansionController.forward(); + } else { + expansionController.reverse(); + } + return null; + }, [expanded.value, motionDisabled, expansionController]); + + useEffect(() { + void clearSelectionAfterCollapse(AnimationStatus status) { + if (status != AnimationStatus.dismissed || expanded.value) return; + selectedAgent.value = null; + pinnedTurnId.value = null; + rememberedAgents.value = const []; + hideActivityOverlay(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted || + expanded.value || + !expansionController.isDismissed) { + return; + } + restoreComposerAfterCollapse(); + }); + } + + expansionController.addStatusListener(clearSelectionAfterCollapse); + return () => + expansionController.removeStatusListener(clearSelectionAfterCollapse); + }, [expansionController]); + + final viewableWorking = [ + for (final signal in activity.agents) + if (signal.canViewActivity) signal, + ]; + final statusOnlyAgents = [ + for (final signal in activity.agents) + if (!signal.canViewActivity) signal, + ]; + final effectiveSelectedAgent = + selectedAgent.value ?? viewableWorking.firstOrNull?.pubkey; + final selectedSignal = viewableWorking + .where((signal) => signal.pubkey == effectiveSelectedAgent) + .firstOrNull; + final effectiveTurnId = pinnedTurnId.value ?? selectedSignal?.turnId; + final selectedTurn = + effectiveSelectedAgent == null || effectiveTurnId == null + ? null + : latestAgentTurnState( + turnStates, + agentPubkey: effectiveSelectedAgent, + channelId: channelId, + turnId: effectiveTurnId, + ); + final ObserverState? observerState; + if (effectiveSelectedAgent == null || effectiveTurnId == null) { + observerState = null; + } else { + observerState = ref.watch( + observerTurnSubscriptionProvider(( + channelId: channelId, + agentPubkey: effectiveSelectedAgent, + turnId: effectiveTurnId, + )), + ); + } + final transcript = compactActivityItems( + observerState?.transcript ?? const [], + ); + + String nameFor(String pubkey) => + profiles[pubkey.toLowerCase()]?.label ?? shortPubkey(pubkey); + + useEffect(() { + final pubkeys = { + ...activity.agents.map((signal) => signal.pubkey), + ?effectiveSelectedAgent, + }; + if (pubkeys.isNotEmpty) { + ref.read(userCacheProvider.notifier).preload(pubkeys.toList()); + } + return null; + }, [activity.agents, effectiveSelectedAgent]); + + useEffect(() { + final turnId = selectedSignal?.turnId; + if (!expanded.value || pinnedTurnId.value != null || turnId == null) { + return null; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted && expanded.value && pinnedTurnId.value == null) { + pinnedTurnId.value = turnId; + } + }); + return null; + }, [expanded.value, selectedSignal?.turnId]); + + final transcriptVersion = _transcriptVersion(transcript); + useEffect(() { + if (transcriptVersion == previousTranscriptVersion.value) return null; + previousTranscriptVersion.value = transcriptVersion; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (scrollController.hasClients && followsTail.value) { + scrollController.jumpTo(scrollController.position.maxScrollExtent); + } + }); + return null; + }, [transcriptVersion, effectiveSelectedAgent]); + + void collapse() { + pendingExpansion.value = false; + expanded.value = false; + followsTail.value = true; + if (expansionController.isDismissed) { + selectedAgent.value = null; + pinnedTurnId.value = null; + rememberedAgents.value = const []; + hideActivityOverlay(); + restoreComposerAfterCollapse(); + } + } + + useEffect( + () { + final requests = composerActivationRequests; + if (requests == null) return null; + void handOffToComposer() { + if (!expanded.value && !pendingExpansion.value) return; + if (composerInteractionLock != null) { + composerInteractionLock!.value = true; + } + final focusNode = composerFocusNode; + if (focusNode != null) { + restoreComposerFocus.value = true; + composerFocusToRestore.value = focusNode; + } + collapse(); + } + + requests.addListener(handOffToComposer); + return () => requests.removeListener(handOffToComposer); + }, + [ + composerActivationRequests, + composerFocusNode, + expanded.value, + pendingExpansion.value, + ], + ); + + void selectAgent(String pubkey) { + final signal = viewableWorking + .where((candidate) => candidate.pubkey == pubkey) + .firstOrNull; + final latestTurn = latestAgentTurnState( + turnStates, + agentPubkey: pubkey, + channelId: channelId, + ); + selectedAgent.value = pubkey; + pinnedTurnId.value = signal?.turnId ?? latestTurn?.turnId; + followsTail.value = true; + } + + void toggleExpanded() { + if (expanded.value || pendingExpansion.value) { + collapse(); + return; + } + final initial = effectiveSelectedAgent; + if (initial == null) return; + selectedAgent.value = initial; + pinnedTurnId.value = selectedSignal?.turnId; + rememberedAgents.value = [ + for (final signal in viewableWorking) signal.pubkey, + ]; + followsTail.value = true; + final wasKeyboardOpen = keyboardInsetBottom > 0.5; + final focusedNode = + composerFocusNode ?? FocusManager.instance.primaryFocus; + restoreComposerFocus.value = + wasKeyboardOpen && focusedNode?.hasFocus == true; + composerFocusToRestore.value = restoreComposerFocus.value + ? focusedNode + : null; + if (composerInteractionLock != null) { + composerInteractionLock!.value = true; + } + if (composerFocusNode != null) { + composerFocusNode!.unfocus( + disposition: UnfocusDisposition.previouslyFocusedChild, + ); + } else if (focusedNode != null) { + focusedNode.unfocus( + disposition: UnfocusDisposition.previouslyFocusedChild, + ); + } + if (wasKeyboardOpen || composerWidthProgress > 0.001) { + pendingExpansion.value = true; + } else { + updateOverlayGeometry(); + activityOverlayController.show(); + expanded.value = true; + } + } + + final selectorAgents = { + ...rememberedAgents.value, + ...viewableWorking.map((signal) => signal.pubkey), + ?effectiveSelectedAgent, + }.toList(); + final hasContent = + expanded.value || + rememberedAgents.value.isNotEmpty || + activity.agents.isNotEmpty || + activity.humanTyping.isNotEmpty; + final child = hasContent + ? Column( + key: const ValueKey('composer-activity-region'), + mainAxisSize: MainAxisSize.min, + children: [ + if ((viewableWorking.isNotEmpty || + rememberedAgents.value.isNotEmpty) && + effectiveSelectedAgent != null) + OverlayPortal.overlayChildLayoutBuilder( + controller: activityOverlayController, + overlayChildBuilder: (context, layoutInfo) { + final anchorOrigin = MatrixUtils.transformPoint( + layoutInfo.childPaintTransform, + Offset.zero, + ); + final availableWidth = math.max( + layoutInfo.childSize.width, + layoutInfo.overlaySize.width - Grid.twelve * 2, + ); + final expandedActivityWidth = math.max( + layoutInfo.childSize.width, + math.min( + availableWidth, + layoutInfo.childSize.width / compactWidthFactor, + ), + ); + return Stack( + children: [ + AnimatedBuilder( + animation: expansionController, + builder: (context, _) { + final progress = _surfaceMorphCurve.transform( + expansionController.value, + ); + final panelViewportHeight = + _compactSurfaceHeight + + (expandedHeight.value - _compactSurfaceHeight) * + progress; + final panelWidth = + compactActivityWidth.value + + (expandedActivityWidth - + compactActivityWidth.value) * + progress; + if (panelWidth <= 0) { + return const SizedBox.shrink(); + } + return Positioned( + left: + (layoutInfo.overlaySize.width - panelWidth) / + 2, + bottom: + layoutInfo.overlaySize.height - + anchorOrigin.dy - + layoutInfo.childSize.height, + child: SizedBox( + key: const ValueKey( + 'composer-agent-activity-overlay', + ), + width: panelWidth, + height: panelViewportHeight, + child: IgnorePointer( + ignoring: expansionController.isDismissed, + child: ExcludeSemantics( + excluding: expansionController.isDismissed, + child: ClipRect( + child: _InlineActivityPanel( + height: panelViewportHeight, + expandedHeight: expandedHeight.value, + morphProgress: progress, + targetExpanded: expanded.value, + agentName: nameFor( + effectiveSelectedAgent, + ), + selectedTurn: selectedTurn, + isFallbackWorking: + selectedSignal != null, + observerState: observerState, + transcript: transcript, + profiles: profiles, + selectorAgents: selectorAgents, + selectedAgent: effectiveSelectedAgent, + nameFor: nameFor, + onSelectAgent: selectAgent, + onToggleExpanded: toggleExpanded, + horizontalInset: horizontalInset, + scrollController: scrollController, + onScroll: (notification) { + if (notification + is ScrollUpdateNotification && + notification.dragDetails != + null) { + followsTail.value = + notification + .metrics + .maxScrollExtent - + notification + .metrics + .pixels <= + 24; + } + return false; + }, + ), + ), + ), + ), + ), + ); + }, + ), + ], + ); + }, + child: SizedBox( + key: activityAnchorKey, + width: double.infinity, + height: _compactSurfaceHeight, + child: Semantics( + key: const ValueKey('composer-agent-activity-surface'), + container: true, + child: AnimatedBuilder( + animation: expansionController, + builder: (context, _) { + final progress = _surfaceMorphCurve.transform( + expansionController.value, + ); + if (progress >= 1 || viewableWorking.isEmpty) { + return const SizedBox.expand(); + } + return Opacity( + opacity: 1 - progress, + child: IgnorePointer( + ignoring: + expanded.value || + !expansionController.isDismissed, + child: _AgentActivityControl( + signals: viewableWorking, + selectedAgent: effectiveSelectedAgent, + selectedTurn: selectedTurn, + transcript: transcript, + profiles: profiles, + nameFor: nameFor, + onTap: toggleExpanded, + horizontalInset: horizontalInset, + ), + ), + ); + }, + ), + ), + ), + ), + if (statusOnlyAgents.isNotEmpty) + _AgentStatusRow( + signals: statusOnlyAgents, + profiles: profiles, + nameFor: nameFor, + horizontalInset: horizontalInset, + ), + if (activity.humanTyping.isNotEmpty) + ChannelTypingIndicator(entries: activity.humanTyping), + ], + ) + : const SizedBox.shrink(); + if (motionDisabled) return child; + return AnimatedSize( + duration: _surfaceMorphDuration, + curve: _surfaceMorphCurve, + alignment: Alignment.bottomCenter, + child: child, + ); + } +} + +@visibleForTesting +List compactActivityItems(List transcript) { + final useful = []; + for (final item in transcript) { + if (item is MetadataItem) continue; + if (item is LifecycleItem && + (item.title == 'Turn started' || item.title == 'Session ready')) { + continue; + } + useful.add(item); + } + return List.unmodifiable( + useful.length <= 40 ? useful : useful.sublist(useful.length - 40), + ); +} + +class _InlineActivityPanel extends StatelessWidget { + static const _footerHeight = 44.0; + + final double height; + final double expandedHeight; + final double morphProgress; + final bool targetExpanded; + final String agentName; + final AgentTurnState? selectedTurn; + final bool isFallbackWorking; + final ObserverState? observerState; + final List transcript; + final Map profiles; + final List selectorAgents; + final String selectedAgent; + final String Function(String) nameFor; + final ValueChanged onSelectAgent; + final VoidCallback onToggleExpanded; + final double horizontalInset; + final ScrollController scrollController; + final bool Function(ScrollNotification) onScroll; + + const _InlineActivityPanel({ + required this.height, + required this.expandedHeight, + required this.morphProgress, + required this.targetExpanded, + required this.agentName, + required this.selectedTurn, + required this.isFallbackWorking, + required this.observerState, + required this.transcript, + required this.profiles, + required this.selectorAgents, + required this.selectedAgent, + required this.nameFor, + required this.onSelectAgent, + required this.onToggleExpanded, + required this.horizontalInset, + required this.scrollController, + required this.onScroll, + }); + + @override + Widget build(BuildContext context) { + final status = _activityStatus(selectedTurn, isFallbackWorking); + final headline = _selectedActivityHeadline(selectedTurn, transcript); + final compactLabel = _agentActivityLabel( + pubkeys: selectorAgents, + selectedTurn: selectedTurn, + transcript: transcript, + nameFor: nameFor, + expanded: false, + ).visibleLabel; + final topInset = Grid.xxs * morphProgress; + final panelHeight = math.max(_footerHeight, height - Grid.xxs - topInset); + final expandedDetailHeight = math.max( + 0.0, + expandedHeight - Grid.xxs * 2 - _footerHeight, + ); + final detailsOpacity = Curves.easeOut.transform( + ((morphProgress - 0.16) / 0.84).clamp(0.0, 1.0), + ); + + return Padding( + padding: EdgeInsets.fromLTRB( + 0, + topInset, + 0, + Grid.xxs, + ).add(EdgeInsets.symmetric(horizontal: horizontalInset)), + child: Semantics( + container: true, + label: 'Live activity for $agentName', + child: Material( + key: const ValueKey('composer-agent-activity-panel'), + color: context.colors.surfaceContainerHighest, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.dialog), + side: BorderSide(color: Colors.black.withValues(alpha: 0.04)), + ), + clipBehavior: Clip.antiAlias, + child: SizedBox( + height: panelHeight, + width: double.infinity, + child: Stack( + children: [ + Positioned.fill( + bottom: _footerHeight, + child: ClipRect( + child: IgnorePointer( + ignoring: morphProgress < 0.95, + child: Opacity( + key: const ValueKey('composer-agent-activity-details'), + opacity: detailsOpacity, + child: OverflowBox( + alignment: Alignment.topCenter, + minHeight: expandedDetailHeight, + maxHeight: expandedDetailHeight, + child: Column( + children: [ + if (selectorAgents.length > 1) + _AgentSegmentedControl( + agents: selectorAgents, + selectedAgent: selectedAgent, + nameFor: nameFor, + onSelectAgent: onSelectAgent, + ) + else + const SizedBox(height: Grid.xxs), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Live activity may be partial.', + style: context.textTheme.labelSmall + ?.copyWith( + color: + context.colors.onSurfaceVariant, + ), + ), + ), + ), + Expanded( + child: transcript.isEmpty + ? _ActivityEmptyState( + status: status, + observerState: observerState, + ) + : NotificationListener( + onNotification: onScroll, + child: ListView.builder( + key: const ValueKey( + 'composer-agent-activity-transcript', + ), + controller: scrollController, + padding: const EdgeInsets.fromLTRB( + Grid.xxs, + Grid.half, + Grid.xxs, + Grid.xxs, + ), + itemCount: transcript.length, + itemBuilder: (context, index) => + _CompactActivityItem( + item: transcript[index], + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + height: _footerHeight, + child: Semantics( + button: true, + label: + '$agentName $headline. ${targetExpanded ? 'Collapse' : 'Expand'} live activity.', + child: ExcludeSemantics( + child: InkWell( + key: const ValueKey('composer-agent-activity-collapse'), + onTap: onToggleExpanded, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs, + ), + child: Row( + children: [ + _AgentAvatarStack( + pubkeys: selectorAgents, + profiles: profiles, + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Stack( + alignment: Alignment.centerLeft, + children: [ + Opacity( + opacity: 1 - morphProgress, + child: Text( + compactLabel, + style: context.textTheme.labelSmall + ?.copyWith( + color: context + .colors + .onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Opacity( + opacity: morphProgress, + child: Text( + 'Live activity', + style: context.textTheme.labelSmall + ?.copyWith( + color: context + .colors + .onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ClipRect( + child: Align( + widthFactor: morphProgress, + child: Opacity( + opacity: morphProgress, + child: _ActivityStatusBadge(status: status), + ), + ), + ), + SizedBox(width: Grid.half * morphProgress), + Transform.rotate( + angle: math.pi * morphProgress, + child: Icon( + LucideIcons.chevronUp, + size: 18, + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _ActivityStatusBadge extends StatelessWidget { + final _ActivityStatus status; + + const _ActivityStatusBadge({required this.status}); + + @override + Widget build(BuildContext context) { + final color = switch (status) { + _ActivityStatus.working => context.appColors.success, + _ActivityStatus.finished => context.colors.onSurfaceVariant, + _ActivityStatus.error => context.colors.error, + _ActivityStatus.waiting => context.appColors.warning, + }; + final label = switch (status) { + _ActivityStatus.working => 'Working', + _ActivityStatus.finished => 'Finished', + _ActivityStatus.error => 'Error', + _ActivityStatus.waiting => 'Waiting', + }; + return Container( + key: const ValueKey('composer-agent-activity-status'), + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs, + vertical: Grid.quarter, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: context.textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class _ActivityEmptyState extends StatelessWidget { + final _ActivityStatus status; + final ObserverState? observerState; + + const _ActivityEmptyState({ + required this.status, + required this.observerState, + }); + + @override + Widget build(BuildContext context) { + if (status == _ActivityStatus.error) { + return Center( + child: Text( + 'The agent stopped with an error.', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ); + } + if (observerState?.connection == ObserverConnectionState.error) { + return Center( + child: Text( + 'Live activity is unavailable.', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ); + } + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (status == _ActivityStatus.working || + status == _ActivityStatus.waiting) + BuzzLoadingIndicator( + size: 18, + color: context.colors.onSurfaceVariant, + semanticLabel: 'Waiting for agent activity', + ), + const SizedBox(height: Grid.half), + Text( + status == _ActivityStatus.finished + ? 'No activity rows were captured for this turn.' + : 'Waiting for live activity…', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart new file mode 100644 index 00000000000..dfda077f48d --- /dev/null +++ b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart @@ -0,0 +1,458 @@ +part of '../composer_agent_activity_indicator.dart'; + +class _AgentSegmentedControl extends StatelessWidget { + static const _inset = Grid.xxs; + static const _cornerRadius = Radii.dialog - _inset; + + final List agents; + final String selectedAgent; + final String Function(String) nameFor; + final ValueChanged onSelectAgent; + + const _AgentSegmentedControl({ + required this.agents, + required this.selectedAgent, + required this.nameFor, + required this.onSelectAgent, + }); + + @override + Widget build(BuildContext context) { + final isIos = defaultTargetPlatform == TargetPlatform.iOS; + + return SizedBox( + height: Grid.xl, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: _inset, + vertical: Grid.half + Grid.quarter, + ), + child: LayoutBuilder( + builder: (context, constraints) { + final control = isIos + ? ClipRSuperellipse( + key: const ValueKey('composer-agent-segmented-frame'), + borderRadius: BorderRadius.circular(_cornerRadius), + clipBehavior: Clip.antiAlias, + child: DecoratedBox( + decoration: ShapeDecoration( + color: context.colors.surface, + shape: RoundedSuperellipseBorder( + borderRadius: BorderRadius.circular(_cornerRadius), + side: BorderSide( + color: context.colors.outlineVariant, + ), + ), + ), + child: CupertinoSlidingSegmentedControl( + key: const ValueKey( + 'composer-agent-segmented-container', + ), + groupValue: selectedAgent, + thumbColor: context.colors.primary, + backgroundColor: Colors.transparent, + proportionalWidth: false, + children: { + for (final agent in agents) + agent: _IosAgentSegment( + pubkey: agent, + label: nameFor(agent), + selected: agent == selectedAgent, + ), + }, + onValueChanged: (agent) { + if (agent == null || agent == selectedAgent) return; + onSelectAgent(agent); + }, + ), + ), + ) + : Material( + key: const ValueKey('composer-agent-segmented-container'), + color: context.colors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.md), + side: BorderSide(color: context.colors.outlineVariant), + ), + clipBehavior: Clip.antiAlias, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < agents.length; index++) ...[ + if (index > 0) + ColoredBox( + color: context.colors.outlineVariant, + child: const SizedBox(width: 1), + ), + _AgentSegment( + pubkey: agents[index], + label: nameFor(agents[index]), + selected: agents[index] == selectedAgent, + onTap: () => onSelectAgent(agents[index]), + ), + ], + ], + ), + ); + + return SingleChildScrollView( + key: const ValueKey('composer-agent-activity-selector'), + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: control, + ), + ); + }, + ), + ), + ); + } +} + +class _IosAgentSegment extends StatelessWidget { + final String pubkey; + final String label; + final bool selected; + + const _IosAgentSegment({ + required this.pubkey, + required this.label, + required this.selected, + }); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + key: ValueKey('composer-agent-segment-$pubkey'), + constraints: const BoxConstraints(minWidth: 96, minHeight: Grid.md), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), + child: Center( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelMedium?.copyWith( + color: selected + ? context.colors.onPrimary + : context.colors.onSurfaceVariant, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ), + ); + } +} + +class _AgentSegment extends StatelessWidget { + final String pubkey; + final String label; + final bool selected; + final VoidCallback onTap; + + const _AgentSegment({ + required this.pubkey, + required this.label, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + selected: selected, + label: label, + child: ExcludeSemantics( + child: Material( + key: ValueKey('composer-agent-segment-$pubkey'), + color: selected ? context.colors.primary : Colors.transparent, + child: InkWell( + onTap: onTap, + child: ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 96, + minHeight: Grid.md, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), + child: Center( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelMedium?.copyWith( + color: selected + ? context.colors.onPrimary + : context.colors.onSurfaceVariant, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _AgentActivityControl extends StatelessWidget { + final List signals; + final String? selectedAgent; + final AgentTurnState? selectedTurn; + final List transcript; + final Map profiles; + final String Function(String) nameFor; + final VoidCallback onTap; + final double horizontalInset; + + const _AgentActivityControl({ + required this.signals, + required this.selectedAgent, + required this.selectedTurn, + required this.transcript, + required this.profiles, + required this.nameFor, + required this.onTap, + required this.horizontalInset, + }); + + @override + Widget build(BuildContext context) { + final pubkeys = signals.isNotEmpty + ? [for (final signal in signals) signal.pubkey] + : [?selectedAgent]; + final label = _agentActivityLabel( + pubkeys: pubkeys, + selectedTurn: selectedTurn, + transcript: transcript, + nameFor: nameFor, + expanded: false, + ); + + return Padding( + padding: const EdgeInsets.only( + left: 0, + right: 0, + bottom: Grid.xxs, + ).add(EdgeInsets.symmetric(horizontal: horizontalInset)), + child: Semantics( + button: true, + toggled: false, + label: label.semanticLabel, + child: ExcludeSemantics( + child: Material( + color: context.colors.surfaceContainerHighest, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.dialog), + side: BorderSide(color: Colors.black.withValues(alpha: 0.04)), + ), + child: InkWell( + key: const ValueKey('composer-agent-activity-control'), + onTap: onTap, + borderRadius: BorderRadius.circular(Radii.dialog), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 44), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs, + vertical: Grid.half, + ), + child: Row( + children: [ + _AgentAvatarStack(pubkeys: pubkeys, profiles: profiles), + const SizedBox(width: Grid.xxs), + Expanded( + child: Text( + label.visibleLabel, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + LucideIcons.chevronUp, + size: 16, + color: context.colors.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _AgentStatusRow extends StatelessWidget { + final List signals; + final Map profiles; + final String Function(String) nameFor; + final double horizontalInset; + + const _AgentStatusRow({ + required this.signals, + required this.profiles, + required this.nameFor, + required this.horizontalInset, + }); + + @override + Widget build(BuildContext context) { + final pubkeys = [for (final signal in signals) signal.pubkey]; + final text = switch (signals.length) { + 1 => '${nameFor(signals.single.pubkey)} is working…', + _ => '${signals.length} agents are working…', + }; + return Padding( + padding: const EdgeInsets.only( + left: 0, + right: 0, + bottom: Grid.xxs, + ).add(EdgeInsets.symmetric(horizontal: horizontalInset)), + child: Container( + key: const ValueKey('composer-agent-status-only'), + width: double.infinity, + constraints: const BoxConstraints(minHeight: 44), + padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.dialog), + border: Border.all(color: Colors.black.withValues(alpha: 0.04)), + ), + child: Row( + children: [ + _AgentAvatarStack(pubkeys: pubkeys, profiles: profiles), + const SizedBox(width: Grid.xxs), + Expanded( + child: Text( + text, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } +} + +class _AgentAvatarStack extends StatelessWidget { + final List pubkeys; + final Map profiles; + + const _AgentAvatarStack({required this.pubkeys, required this.profiles}); + + @override + Widget build(BuildContext context) { + final visible = pubkeys.take(3).toList(); + return SizedBox( + width: 24.0 + math.max(0, visible.length - 1) * 14.0, + height: 24, + child: Stack( + children: [ + for (var index = 0; index < visible.length; index++) + Positioned( + left: index * 14.0, + child: SmallAvatar( + pubkey: visible[index], + userCache: profiles, + size: 24, + ), + ), + ], + ), + ); + } +} + +enum _ActivityStatus { working, finished, error, waiting } + +_ActivityStatus _activityStatus(AgentTurnState? turn, bool isFallbackWorking) { + return switch (turn?.phase) { + AgentTurnPhase.working => _ActivityStatus.working, + AgentTurnPhase.finished => _ActivityStatus.finished, + AgentTurnPhase.error => _ActivityStatus.error, + null => + isFallbackWorking ? _ActivityStatus.working : _ActivityStatus.waiting, + }; +} + +({String visibleLabel, String semanticLabel}) _agentActivityLabel({ + required List pubkeys, + required AgentTurnState? selectedTurn, + required List transcript, + required String Function(String) nameFor, + required bool expanded, +}) { + final action = expanded ? 'Collapse live activity.' : 'Show live activity.'; + if (pubkeys.length > 1) { + return ( + visibleLabel: '${pubkeys.length} agents are working…', + semanticLabel: '${pubkeys.length} agents are working. $action', + ); + } + final name = pubkeys.isEmpty ? 'Agent' : nameFor(pubkeys.single); + final status = _selectedActivityHeadline(selectedTurn, transcript); + return ( + visibleLabel: '$name $status', + semanticLabel: '$name $status. $action', + ); +} + +String _selectedActivityHeadline( + AgentTurnState? selectedTurn, + List transcript, +) => switch (selectedTurn?.phase) { + AgentTurnPhase.finished => 'finished', + AgentTurnPhase.error => 'stopped with an error', + _ => + transcript.isNotEmpty ? _compactHeadline(transcript.last) : 'is working…', +}; + +String _compactHeadline(TranscriptItem item) => switch (item) { + final ToolItem tool => _toolActivityLabel(tool), + final ThoughtItem thought => thought.title.toLowerCase(), + final MessageItem message => + message.role == 'assistant' ? 'is responding…' : 'is reading the prompt…', + final LifecycleItem lifecycle => lifecycle.title.toLowerCase(), + MetadataItem() => 'is working…', +}; + +String _toolActivityLabel(ToolItem tool) { + final title = tool.title.trim().isEmpty ? tool.toolName : tool.title.trim(); + return switch (tool.status) { + ToolStatus.completed => 'Finished $title', + ToolStatus.failed => '$title failed', + ToolStatus.pending => 'Queued $title', + ToolStatus.executing => 'Running $title', + }; +} + +String _oneLine(String value) => value.replaceAll(RegExp(r'\s+'), ' ').trim(); + +String _transcriptVersion(List transcript) { + if (transcript.isEmpty) return 'empty'; + final last = transcript.last; + final contentLength = switch (last) { + final MessageItem item => item.text.length, + final ThoughtItem item => item.text.length, + final LifecycleItem item => item.text.length, + final MetadataItem item => item.sections.length, + final ToolItem item => item.result.length + item.args.length, + }; + return '${transcript.length}:${last.id}:$contentLength'; +} diff --git a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/compact_activity_item.dart b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/compact_activity_item.dart new file mode 100644 index 00000000000..8ed37991595 --- /dev/null +++ b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/compact_activity_item.dart @@ -0,0 +1,66 @@ +part of '../composer_agent_activity_indicator.dart'; + +class _CompactActivityItem extends StatelessWidget { + final TranscriptItem item; + + const _CompactActivityItem({required this.item}); + + @override + Widget build(BuildContext context) { + final (icon, label, color) = switch (item) { + final ToolItem tool => ( + tool.isError || tool.status == ToolStatus.failed + ? LucideIcons.circleX + : tool.status == ToolStatus.completed + ? LucideIcons.circleCheck + : LucideIcons.wrench, + _toolActivityLabel(tool), + tool.isError || tool.status == ToolStatus.failed + ? context.colors.error + : context.colors.onSurfaceVariant, + ), + final ThoughtItem thought => ( + LucideIcons.brain, + thought.title, + context.colors.onSurfaceVariant, + ), + final MessageItem message => ( + LucideIcons.messageSquare, + '${message.role == 'assistant' ? 'Response' : 'Prompt'}: ${_oneLine(message.text)}', + context.colors.onSurfaceVariant, + ), + final LifecycleItem lifecycle => ( + lifecycle.title.toLowerCase().contains('error') + ? LucideIcons.circleX + : LucideIcons.activity, + '${lifecycle.title}${lifecycle.text.isEmpty ? '' : ': ${_oneLine(lifecycle.text)}'}', + lifecycle.title.toLowerCase().contains('error') + ? context.colors.error + : context.colors.onSurfaceVariant, + ), + MetadataItem() => ( + LucideIcons.activity, + 'Activity', + context.colors.onSurfaceVariant, + ), + }; + return Padding( + key: ValueKey('compact-activity-${item.id}'), + padding: const EdgeInsets.symmetric(vertical: Grid.quarter), + child: Row( + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: Grid.half), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodySmall?.copyWith(color: color), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/channels/agent_activity/observer_models.dart b/mobile/lib/features/channels/agent_activity/observer_models.dart index b1cc8fe22cd..4ad8f5f39dc 100644 --- a/mobile/lib/features/channels/agent_activity/observer_models.dart +++ b/mobile/lib/features/channels/agent_activity/observer_models.dart @@ -16,6 +16,8 @@ class ObserverFrame { final String? channelId; final String? sessionId; final String? turnId; + final String? startedAt; + final DateTime? receivedAt; final dynamic payload; const ObserverFrame({ @@ -26,10 +28,15 @@ class ObserverFrame { this.channelId, this.sessionId, this.turnId, + this.startedAt, + this.receivedAt, this.payload, }); - factory ObserverFrame.fromJson(Map json) => ObserverFrame( + factory ObserverFrame.fromJson( + Map json, { + DateTime? receivedAt, + }) => ObserverFrame( seq: json['seq'] as int? ?? 0, timestamp: json['timestamp'] as String? ?? '', kind: json['kind'] as String? ?? '', @@ -37,6 +44,8 @@ class ObserverFrame { channelId: json['channelId'] as String?, sessionId: json['sessionId'] as String?, turnId: json['turnId'] as String?, + startedAt: json['startedAt'] as String?, + receivedAt: receivedAt, payload: json['payload'], ); } diff --git a/mobile/lib/features/channels/agent_activity/observer_subscription.dart b/mobile/lib/features/channels/agent_activity/observer_subscription.dart index bc469c5b32e..2d6f4bd9a49 100644 --- a/mobile/lib/features/channels/agent_activity/observer_subscription.dart +++ b/mobile/lib/features/channels/agent_activity/observer_subscription.dart @@ -16,6 +16,13 @@ const _observerBatchKind = 'batch'; /// Key for channel-scoped transcript reads. typedef ObserverKey = ({String channelId, String agentPubkey}); +/// Key for a transcript constrained to one agent turn. +typedef ObserverTurnKey = ({ + String channelId, + String agentPubkey, + String turnId, +}); + /// State emitted by the channel-scoped observer transcript provider. @immutable class ObserverState { @@ -246,7 +253,8 @@ class ObserverRelayNotifier extends Notifier { ); final plaintext = nip44Decrypt(conversationKey, event.content); final json = jsonDecode(plaintext) as Map; - final frame = ObserverFrame.fromJson(json); + final receivedAt = DateTime.now().toUtc(); + final frame = ObserverFrame.fromJson(json, receivedAt: receivedAt); if (frame.kind != _observerBatchKind) { return [frame]; } @@ -261,7 +269,10 @@ class ObserverRelayNotifier extends Notifier { return [ for (final inner in events) - ObserverFrame.fromJson(inner as Map), + ObserverFrame.fromJson( + inner as Map, + receivedAt: receivedAt, + ), ]; } catch (error) { _errorMessage = 'Observer event decrypt failed: $error'; @@ -371,3 +382,26 @@ final observerSubscriptionProvider = errorMessage: relayState.errorMessage, ); }); + +/// Live transcript for one exact turn. +/// +/// The narrower key keeps compact composer activity from merging concurrent or +/// unrelated turns that happen to share an agent and channel. +final observerTurnSubscriptionProvider = + Provider.family((ref, key) { + final relayState = ref.watch(observerRelayProvider); + final normalizedAgent = key.agentPubkey.toLowerCase(); + final frames = relayState.framesByAgent[normalizedAgent] ?? const []; + final turnFrames = [ + for (final frame in frames) + if ((frame.channelId == key.channelId || frame.channelId == null) && + frame.turnId == key.turnId) + frame, + ]; + + return ObserverState( + connection: relayState.connection, + transcript: buildTranscript(turnFrames), + errorMessage: relayState.errorMessage, + ); + }); diff --git a/mobile/lib/features/channels/agent_activity/transcript_builder.dart b/mobile/lib/features/channels/agent_activity/transcript_builder.dart index 2435353dec3..c4b3031c5e9 100644 --- a/mobile/lib/features/channels/agent_activity/transcript_builder.dart +++ b/mobile/lib/features/channels/agent_activity/transcript_builder.dart @@ -586,6 +586,20 @@ List buildTranscript(List events) { continue; } + if (event.kind == 'turn_error' || event.kind == 'agent_panic') { + final payload = _asRecord(event.payload); + final error = _asString(payload['error']) ?? 'Unknown error'; + final outcome = _asString(payload['outcome']) ?? 'error'; + upsertTextItem( + '${event.kind}:${event.turnId ?? event.seq}', + 'lifecycle', + event.kind == 'agent_panic' ? 'Agent error' : 'Turn error', + '$outcome: $error', + event.timestamp, + ); + continue; + } + if (event.kind != 'acp_read' && event.kind != 'acp_write') { continue; } diff --git a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart index 8730908adaf..741535b32a3 100644 --- a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart +++ b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart @@ -1,27 +1,152 @@ +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 '../channel_management_provider.dart'; import '../channel_typing_provider.dart'; +import 'active_agent_turns.dart'; +import 'observer_subscription.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.autoDispose - .family, String>((ref, channelId) { - final typingEntries = ref.watch(channelTypingProvider(channelId)); - final membersAsync = ref.watch(channelMembersProvider(channelId)); - final allMembers = membersAsync.asData?.value ?? const []; +/// Composer activity is scoped either to a channel or to one thread. +typedef ComposerActivityKey = ({String channelId, String? threadHeadId}); - final botPubkeys = { - for (final m in allMembers) - if (m.isBot) m.pubkey.toLowerCase(), - }; +enum AgentWorkingSource { observer, typing } + +/// One agent currently surfaced beside the composer. +@immutable +class WorkingAgentSignal { + final String pubkey; + final AgentWorkingSource source; + final bool canViewActivity; + final String? turnId; + final DateTime? startedAt; - return { - for (final e in typingEntries) - if (botPubkeys.contains(e.pubkey.toLowerCase())) - e.pubkey.toLowerCase(), + const WorkingAgentSignal({ + required this.pubkey, + required this.source, + required this.canViewActivity, + this.turnId, + this.startedAt, + }); +} + +/// Agent work and human typing are intentionally separate presentation lanes. +@immutable +class ComposerActivityState { + final List agents; + final List humanTyping; + + const ComposerActivityState({ + required this.agents, + required this.humanTyping, + }); +} + +/// Unified composer state. Observer activity is authoritative in channels; +/// kind:20002 typing fills gaps and is the only thread-scoped signal because +/// observer frames do not carry a thread id. +final composerActivityStateProvider = Provider.autoDispose + .family((ref, key) { + final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase(); + final typing = [ + for (final entry in ref.watch(channelTypingProvider(key.channelId))) + if (entry.threadHeadId == key.threadHeadId && + entry.pubkey.toLowerCase() != currentPubkey) + entry, + ]; + final profiles = ref.watch(userCacheProvider); + final channelMembers = + ref.watch(channelMembersProvider(key.channelId)).asData?.value ?? + const []; + final channelAgents = { + ...ref.watch(agentMentionPubkeysProvider(key.channelId)), + for (final member in channelMembers) + if (member.isBot) member.pubkey.toLowerCase(), + for (final entry in profiles.entries) + if (entry.value.ownerPubkey != null) entry.key.toLowerCase(), }; + final observerState = ref.watch(observerRelayProvider); + final ownerByAgent = + ref.watch(agentOwnersProvider).asData?.value ?? + const {}; + final activeTurns = ref.watch(activeAgentTurnsProvider); + final activeByAgent = {}; + for (final turn in activeTurns) { + if (turn.channelId != key.channelId) continue; + final existing = activeByAgent[turn.agentPubkey]; + if (existing == null || + turn.lastActivityAt.isAfter(existing.lastActivityAt)) { + activeByAgent[turn.agentPubkey] = turn; + } + } + + bool canView(String pubkey) { + if (currentPubkey == null) return false; + final normalized = pubkey.toLowerCase(); + return ownerByAgent[normalized]?.toLowerCase() == currentPubkey || + profiles[normalized]?.ownerPubkey?.toLowerCase() == currentPubkey; + } + + final signals = {}; + // A channel can trust observer activity directly. A thread cannot: the + // observer protocol has no thread id, so a thread requires typing first. + if (key.threadHeadId == null) { + for (final entry in activeByAgent.entries) { + if (!channelAgents.contains(entry.key)) continue; + final turn = entry.value; + signals[entry.key] = WorkingAgentSignal( + pubkey: entry.key, + source: AgentWorkingSource.observer, + canViewActivity: canView(entry.key), + turnId: turn.turnId, + startedAt: turn.startedAt, + ); + } + } + + final humans = []; + for (final entry in typing) { + final pubkey = entry.pubkey.toLowerCase(); + if (!channelAgents.contains(pubkey) && + !observerState.framesByAgent.containsKey(pubkey)) { + humans.add(entry); + continue; + } + if (signals.containsKey(pubkey)) continue; + final turn = activeByAgent[pubkey]; + signals[pubkey] = WorkingAgentSignal( + pubkey: pubkey, + source: AgentWorkingSource.typing, + canViewActivity: canView(pubkey), + turnId: turn?.turnId, + startedAt: turn?.startedAt, + ); + } + + final agents = signals.values.toList() + ..sort((a, b) { + final aAt = a.startedAt; + final bAt = b.startedAt; + if (aAt != null && bAt != null) return aAt.compareTo(bAt); + if (aAt != null) return -1; + if (bAt != null) return 1; + return a.pubkey.compareTo(b.pubkey); + }); + return ComposerActivityState( + agents: List.unmodifiable(agents), + humanTyping: List.unmodifiable(humans), + ); + }); + +/// Agent pubkeys working in a channel, for the members badge and sheet. +final workingBotPubkeysProvider = Provider.autoDispose + .family, String>((ref, channelId) { + final activity = ref.watch( + composerActivityStateProvider(( + channelId: channelId, + threadHeadId: null, + )), + ); + return Set.unmodifiable(activity.agents.map((agent) => agent.pubkey)); }); diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 8cef1a78ff7..a350ee67781 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -30,12 +30,11 @@ import 'android_ime_lift.dart'; import 'channel.dart'; import 'channel_actions_sheet.dart'; import 'channel_link_navigation.dart'; +import 'agent_activity/composer_agent_activity_indicator.dart'; import 'agent_activity/working_bots_provider.dart'; import 'channel_management_provider.dart'; import 'channel_sections/channel_sections_provider.dart'; import 'channel_messages_provider.dart'; -import 'channel_typing_provider.dart'; -import 'channel_typing_indicator.dart'; import 'channels_provider.dart'; import 'unread_badge/observed_unread_event.dart'; import 'compose_bar.dart'; @@ -193,16 +192,6 @@ class ChannelDetailPage extends HookConsumerWidget { .watch(profileProvider) .whenData((value) => value?.pubkey) .value; - // Only show channel-level typing (exclude thread-scoped entries and self). - final typingEntries = ref - .watch(channelTypingProvider(channel.id)) - .where((e) => e.threadHeadId == null) - .where( - (e) => - currentPubkey == null || - e.pubkey.toLowerCase() != currentPubkey.toLowerCase(), - ) - .toList(); final baseChannel = channelsAsync .whenData( @@ -480,15 +469,12 @@ class ChannelDetailPage extends HookConsumerWidget { if (!resolvedChannel.isForum && (!resolvedChannel.isMember || resolvedChannel.isArchived)) ...[ - AnimatedSize( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - alignment: Alignment.bottomCenter, - child: typingEntries.isEmpty - ? const SizedBox.shrink() - : ChannelTypingIndicator(entries: typingEntries), + ComposerAgentActivityIndicator( + channelId: channel.id, + overlayTopBoundary: frostedAppBarHeight( + context, + titleContentHeight: appBarTitleContentHeight, + ), ), if (!resolvedChannel.isDm) _ReadOnlyNotice(channel: resolvedChannel), @@ -508,18 +494,30 @@ class ChannelDetailPage extends HookConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - AnimatedSize( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - alignment: Alignment.bottomCenter, - child: typingEntries.isEmpty - ? const SizedBox.shrink() - : ChannelTypingIndicator(entries: typingEntries), - ), ComposeBar( channelId: channel.id, + activityIndicatorBuilder: + ( + composerWidthAnimation, + composerFocusNode, + composerInteractionLock, + composerActivationRequests, + restoreComposerFocus, + ) => ComposerAgentActivityIndicator( + channelId: channel.id, + horizontalInset: 0, + overlayTopBoundary: frostedAppBarHeight( + context, + titleContentHeight: appBarTitleContentHeight, + ), + compactWidthFactor: 0.85, + composerWidthAnimation: composerWidthAnimation, + composerFocusNode: composerFocusNode, + composerInteractionLock: composerInteractionLock, + composerActivationRequests: + composerActivationRequests, + onRestoreComposerFocus: restoreComposerFocus, + ), channelName: resolvedChannel.isDm ? '' : resolvedChannel.name, diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 9c29c67a996..83ab45b0414 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -57,6 +57,7 @@ part 'compose_bar/camera_preview.dart'; part 'compose_bar/send_button.dart'; part 'compose_bar/layout.dart'; part 'compose_bar/dock.dart'; +part 'compose_bar/activity_handoff.dart'; part 'compose_bar/compose_bar_widget.dart'; /// Callback used by channels and threads to submit composer content. @@ -66,3 +67,12 @@ typedef ComposeBarOnSend = List mentionPubkeys, { List> mediaTags, }); + +typedef ComposeBarActivityIndicatorBuilder = + Widget Function( + Animation composerWidthAnimation, + FocusNode composerFocusNode, + ValueNotifier composerInteractionLock, + ValueNotifier composerActivationRequests, + VoidCallback restoreComposerFocus, + ); diff --git a/mobile/lib/features/channels/compose_bar/activity_handoff.dart b/mobile/lib/features/channels/compose_bar/activity_handoff.dart new file mode 100644 index 00000000000..b4c04deda55 --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/activity_handoff.dart @@ -0,0 +1,36 @@ +part of '../compose_bar.dart'; + +void _useComposerActivityFocusHandoff({ + required FocusNode focusNode, + required ValueNotifier activityInteractionLock, + required ValueNotifier composerActivationRequests, + required ValueNotifier isEmojiPickerOpen, + required VoidCallback collapseComposer, +}) { + useEffect(() { + void coordinateFocus() { + if (focusNode.hasFocus) { + composerActivationRequests.value += 1; + if (activityInteractionLock.value) { + focusNode.unfocus( + disposition: UnfocusDisposition.previouslyFocusedChild, + ); + } + } else if (!focusNode.hasFocus && !isEmojiPickerOpen.value) { + collapseComposer(); + } + } + + focusNode.addListener(coordinateFocus); + WidgetsBinding.instance.addPostFrameCallback((_) => coordinateFocus()); + return () => focusNode.removeListener(coordinateFocus); + }, [focusNode, activityInteractionLock, composerActivationRequests]); +} + +bool _requestComposerHandoff( + ValueNotifier activityInteractionLock, + ValueNotifier composerActivationRequests, +) { + composerActivationRequests.value += 1; + return activityInteractionLock.value; +} diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index c6a26cc59cd..13932cdaafe 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -5,9 +5,9 @@ class ComposeBar extends HookConsumerWidget { final String channelName; final String? hintText; final ComposeBarOnSend onSend; + final ComposeBarActivityIndicatorBuilder? activityIndicatorBuilder; - /// Runs immediately before the editor requests focus, allowing a parent to - /// prepare focus-dependent layout (for example, following a thread tail). + /// Runs before editor focus so a parent can prepare focus-dependent layout. final VoidCallback? onFocusRequested; /// Optional thread IDs for thread-scoped typing indicators. @@ -21,6 +21,7 @@ class ComposeBar extends HookConsumerWidget { this.threadHeadId, this.rootId, this.onFocusRequested, + this.activityIndicatorBuilder, required this.onSend, }); @override @@ -31,15 +32,8 @@ class ComposeBar extends HookConsumerWidget { () => controller.text, ); useEffect(() => controller.dispose, [controller]); - // Restore and persist unsent text as a local draft so the Activity - // inbox Drafts filter reflects real composer state. - // - // The effect is additionally keyed on the active relay + pubkey identity: - // provider-level namespacing alone cannot protect a composer that stays - // mounted through an in-place community/account switch — the controller - // would retain the old identity's text and the next edit would persist it - // into the new identity's store. On identity change we replace the - // controller content with the new identity's own saved draft (or clear). + // Key drafts by relay + pubkey so in-place identity switches cannot + // persist the previous identity's mounted controller text. final draftKey = composeDraftKey(channelId, threadHeadId: threadHeadId); final draftRevision = useRef(0); final draftIdentity = @@ -51,6 +45,8 @@ class ComposeBar extends HookConsumerWidget { ); final androidImeFallbackTimer = useRef(null); final focusNode = useFocusNode(); + final activityInteractionLock = useState(false); + final composerActivationRequests = useState(0); useEffect( () => () => androidImeFallbackTimer.value?.cancel(), @@ -112,16 +108,13 @@ class ComposeBar extends HookConsumerWidget { isComposerExpanded.value = false; } - useEffect(() { - void collapseWhenUnfocused() { - if (!focusNode.hasFocus && !isEmojiPickerOpen.value) { - collapseComposer(); - } - } - - focusNode.addListener(collapseWhenUnfocused); - return () => focusNode.removeListener(collapseWhenUnfocused); - }, [focusNode]); + _useComposerActivityFocusHandoff( + focusNode: focusNode, + activityInteractionLock: activityInteractionLock, + composerActivationRequests: composerActivationRequests, + isEmojiPickerOpen: isEmojiPickerOpen, + collapseComposer: collapseComposer, + ); final appView = View.of(context); useEffect(() { @@ -205,7 +198,6 @@ class ComposeBar extends HookConsumerWidget { }; }, [focusNode]); - // Mention state -------------------------------------------------------- final mentionQuery = useState(null); final mentionStartIdx = useState(-1); // Map of displayName → selected mention candidate built as the user selects @@ -213,7 +205,6 @@ class ComposeBar extends HookConsumerWidget { // selected non-member agents before the message is published. final mentionMap = useRef({}); - // Channel autocomplete state ---------------------------------------------- final channelQuery = useState(null); final channelStartIdx = useState(-1); final channelsAsync = ref.watch(channelsProvider); @@ -852,16 +843,24 @@ class ComposeBar extends HookConsumerWidget { return null; }, [suggestionOverlayController]); - void expandComposer() => _expandComposer( - context: context, - isExpanded: isComposerExpanded, - attachmentSurface: attachmentSurface, - onFocusRequested: onFocusRequested, - focusNode: focusNode, - view: appView, - androidImeTransitionStarted: androidImeTransitionStarted, - androidImeFallbackTimer: androidImeFallbackTimer, - ); + void expandComposer() { + if (_requestComposerHandoff( + activityInteractionLock, + composerActivationRequests, + )) { + return; + } + _expandComposer( + context: context, + isExpanded: isComposerExpanded, + attachmentSurface: attachmentSurface, + onFocusRequested: onFocusRequested, + focusNode: focusNode, + view: appView, + androidImeTransitionStarted: androidImeTransitionStarted, + androidImeFallbackTimer: androidImeFallbackTimer, + ); + } final suggestionPanel = _composerSuggestionPanel( channelSuggestions: channelSuggestions, @@ -912,13 +911,20 @@ class ComposeBar extends HookConsumerWidget { ); } - // Suggestions and attachments live in the overlay. final hasPendingUploads = uploadingCount.value > 0; + final activityIndicator = activityIndicatorBuilder?.call( + composerExpansionController, + focusNode, + activityInteractionLock, + composerActivationRequests, + expandComposer, + ); return _ComposerDockFrame( expansionAnimation: composerExpansionController, child: Column( mainAxisSize: MainAxisSize.min, children: [ + ?activityIndicator, _UploadProgressMotion( visible: hasPendingUploads, progress: uploadProgress.value, @@ -934,10 +940,11 @@ class ComposeBar extends HookConsumerWidget { controller: suggestionOverlayController, attachmentSurface: attachmentSurface, reducedMotion: reducedMotion, + interactionLocked: activityInteractionLock.value, + onInteractionLockedTap: () => composerActivationRequests.value += 1, buildOverlayPanel: buildOverlayPanel, - onDismissAttachmentSurface: () { - attachmentSurface.value = _AttachmentSurface.closed; - }, + onDismissAttachmentSurface: () => + attachmentSurface.value = _AttachmentSurface.closed, child: _ComposeBarLayout( attachments: attachments.value, onRemoveAttachment: removeAttachment, diff --git a/mobile/lib/features/channels/compose_bar/dock.dart b/mobile/lib/features/channels/compose_bar/dock.dart index 7a25f4cfe55..3939d4c20d8 100644 --- a/mobile/lib/features/channels/compose_bar/dock.dart +++ b/mobile/lib/features/channels/compose_bar/dock.dart @@ -58,6 +58,8 @@ class _ComposerOverlayPortal extends StatelessWidget { final OverlayPortalController controller; final ValueListenable<_AttachmentSurface> attachmentSurface; final bool reducedMotion; + final bool interactionLocked; + final VoidCallback onInteractionLockedTap; final Widget Function(_AttachmentSurface surface) buildOverlayPanel; final VoidCallback onDismissAttachmentSurface; final Widget child; @@ -66,6 +68,8 @@ class _ComposerOverlayPortal extends StatelessWidget { required this.controller, required this.attachmentSurface, required this.reducedMotion, + required this.interactionLocked, + required this.onInteractionLockedTap, required this.buildOverlayPanel, required this.onDismissAttachmentSurface, required this.child, @@ -144,7 +148,22 @@ class _ComposerOverlayPortal extends StatelessWidget { }, ); }, - child: child, + child: Stack( + children: [ + AbsorbPointer(absorbing: interactionLocked, child: child), + if (interactionLocked) + Positioned.fill( + child: Semantics( + button: true, + label: 'Open composer', + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onInteractionLockedTap, + ), + ), + ), + ], + ), ); } } diff --git a/mobile/lib/features/channels/thread_detail_helpers.dart b/mobile/lib/features/channels/thread_detail_helpers.dart index 4e13e1f3928..9fba4df78d2 100644 --- a/mobile/lib/features/channels/thread_detail_helpers.dart +++ b/mobile/lib/features/channels/thread_detail_helpers.dart @@ -90,24 +90,48 @@ class _ThreadTailIntent { } } -/// Thread-scoped typing status with optional size animation. +/// Thread-scoped composer activity with optional size animation. class _ThreadTypingIndicator extends StatelessWidget { - final List entries; + final String channelId; + final String threadHeadId; final bool animated; + final double horizontalInset; + final double? overlayTopBoundary; + final double compactWidthFactor; + final Animation? composerWidthAnimation; + final FocusNode? composerFocusNode; + final ValueNotifier? composerInteractionLock; + final ValueNotifier? composerActivationRequests; + final VoidCallback? onRestoreComposerFocus; - const _ThreadTypingIndicator({required this.entries, this.animated = true}); + const _ThreadTypingIndicator({ + required this.channelId, + required this.threadHeadId, + this.animated = true, + this.horizontalInset = Grid.twelve, + this.overlayTopBoundary, + this.compactWidthFactor = 1, + this.composerWidthAnimation, + this.composerFocusNode, + this.composerInteractionLock, + this.composerActivationRequests, + this.onRestoreComposerFocus, + }); @override Widget build(BuildContext context) { - final child = entries.isEmpty - ? const SizedBox.shrink() - : ChannelTypingIndicator(entries: entries); - if (!animated || MediaQuery.disableAnimationsOf(context)) return child; - return AnimatedSize( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - alignment: Alignment.bottomCenter, - child: child, + return ComposerAgentActivityIndicator( + channelId: channelId, + threadHeadId: threadHeadId, + animated: animated, + horizontalInset: horizontalInset, + overlayTopBoundary: overlayTopBoundary, + compactWidthFactor: compactWidthFactor, + composerWidthAnimation: composerWidthAnimation, + composerFocusNode: composerFocusNode, + composerInteractionLock: composerInteractionLock, + composerActivationRequests: composerActivationRequests, + onRestoreComposerFocus: onRestoreComposerFocus, ); } } diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 6ab3ddd9b63..d3e328b268f 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -13,11 +13,10 @@ import '../../shared/widgets/keyboard_dismiss_on_drag.dart'; import '../../shared/widgets/message_author_meta.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; +import 'agent_activity/composer_agent_activity_indicator.dart'; import 'android_ime_lift.dart'; import 'channel_link_navigation.dart'; import 'channel_messages_provider.dart'; -import 'channel_typing_provider.dart'; -import 'channel_typing_indicator.dart'; import 'thread_replies_provider.dart'; import 'channels_provider.dart'; import 'compose_bar.dart'; @@ -407,17 +406,6 @@ class ThreadDetailPage extends HookConsumerWidget { return null; }, [threadHead.id, readState.isReady, visibleReplyReadKey]); - // Thread-scoped typing indicators (exclude self). - final allTyping = ref.watch(channelTypingProvider(channelId)); - final threadTyping = allTyping - .where((e) => e.threadHeadId == threadHead.id) - .where( - (e) => - currentPubkey == null || - e.pubkey.toLowerCase() != currentPubkey?.toLowerCase(), - ) - .toList(); - // Resolve thread head from live data (reactions/edits may have changed). final liveHead = allMsgs.where((m) => m.id == threadHead.id).firstOrNull ?? threadHead; @@ -734,7 +722,12 @@ class ThreadDetailPage extends HookConsumerWidget { ), ), if (!isMember || isArchived) - _ThreadTypingIndicator(entries: threadTyping, animated: false), + _ThreadTypingIndicator( + channelId: channelId, + threadHeadId: threadHead.id, + animated: false, + overlayTopBoundary: frostedAppBarHeight(context), + ), ], ), if (isMember && !isArchived) @@ -747,9 +740,28 @@ class ThreadDetailPage extends HookConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - _ThreadTypingIndicator(entries: threadTyping), ComposeBar( channelId: channelId, + activityIndicatorBuilder: + ( + composerWidthAnimation, + composerFocusNode, + composerInteractionLock, + composerActivationRequests, + restoreComposerFocus, + ) => _ThreadTypingIndicator( + channelId: channelId, + threadHeadId: threadHead.id, + horizontalInset: 0, + overlayTopBoundary: frostedAppBarHeight(context), + compactWidthFactor: 0.85, + composerWidthAnimation: composerWidthAnimation, + composerFocusNode: composerFocusNode, + composerInteractionLock: composerInteractionLock, + composerActivationRequests: + composerActivationRequests, + onRestoreComposerFocus: restoreComposerFocus, + ), hintText: 'Reply in thread\u2026', threadHeadId: threadHead.id, rootId: effectiveRootId, diff --git a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart new file mode 100644 index 00000000000..3061465d5c7 --- /dev/null +++ b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/features/channels/agent_activity/active_agent_turns.dart'; +import 'package:buzz/features/channels/agent_activity/observer_models.dart'; + +void main() { + test('tracks and refreshes a live turn using device receipt time', () { + final turns = reduceAgentTurnStates({ + 'AGENT-A': [ + _frame( + seq: 1, + second: 1, + kind: 'turn_started', + receivedSecond: 10, + payload: { + 'triggeringEventIds': ['message-1'], + }, + ), + _frame(seq: 2, second: 11, kind: 'turn_liveness', receivedSecond: 20), + ], + }, now: DateTime.utc(2026, 8, 16, 12, 0, 25)); + + expect(turns, hasLength(1)); + expect(turns.single.agentPubkey, 'agent-a'); + expect(turns.single.phase, AgentTurnPhase.working); + expect(turns.single.triggeringEventId, 'message-1'); + expect(turns.single.lastActivityAt, DateTime.utc(2026, 8, 16, 12, 0, 20)); + }); + + test('preserves explicit completion and error outcomes', () { + final turns = reduceAgentTurnStates({ + 'agent-a': [ + _frame(seq: 1, second: 1, kind: 'turn_started'), + _frame(seq: 2, second: 2, kind: 'turn_completed'), + ], + 'agent-b': [ + _frame(seq: 1, second: 1, kind: 'turn_started', turnId: 'turn-b'), + _frame( + seq: 2, + second: 2, + kind: 'turn_error', + turnId: 'turn-b', + payload: {'error': 'Tool permission denied'}, + ), + ], + }, now: DateTime.utc(2026, 8, 16, 12, 1)); + + expect(turns, hasLength(2)); + expect(turns[0].phase, AgentTurnPhase.finished); + expect(turns[1].phase, AgentTurnPhase.error); + expect(turns[1].errorMessage, 'Tool permission denied'); + }); + + test('expires silence without claiming the turn finished', () { + final turns = reduceAgentTurnStates({ + 'agent-a': [_frame(seq: 1, second: 1, kind: 'turn_started')], + }, now: DateTime.utc(2026, 8, 16, 12, 0, 32)); + + expect(turns, isEmpty); + }); + + test('recovers a missed start and rejects stale post-terminal liveness', () { + final turns = reduceAgentTurnStates({ + 'agent-a': [ + _frame( + seq: 1, + second: 1, + kind: 'turn_liveness', + startedAt: DateTime.utc(2026, 8, 16, 11, 59).toIso8601String(), + ), + _frame(seq: 2, second: 2, kind: 'turn_completed'), + _frame( + seq: 0, + second: 1, + kind: 'turn_liveness', + startedAt: DateTime.utc(2026, 8, 16, 11, 59).toIso8601String(), + ), + ], + }, now: DateTime.utc(2026, 8, 16, 12, 0, 20)); + + expect(turns, hasLength(1)); + expect(turns.single.phase, AgentTurnPhase.finished); + expect(turns.single.startedAt, DateTime.utc(2026, 8, 16, 11, 59)); + }); + + test('terminal without a turn id updates the latest turn in its channel', () { + final turns = reduceAgentTurnStates({ + 'agent-a': [ + _frame(seq: 1, second: 1, kind: 'turn_started'), + _frame( + seq: 2, + second: 2, + kind: 'agent_panic', + turnId: null, + payload: {'error': 'Process exited'}, + ), + ], + }, now: DateTime.utc(2026, 8, 16, 12, 0, 20)); + + expect(turns.single.phase, AgentTurnPhase.error); + expect(turns.single.errorMessage, 'Process exited'); + }); +} + +ObserverFrame _frame({ + required int seq, + required int second, + required String kind, + String? turnId = 'turn-1', + String channelId = 'channel-1', + int? receivedSecond, + String? startedAt, + dynamic payload = const {}, +}) { + return ObserverFrame( + seq: seq, + timestamp: DateTime.utc(2026, 8, 16, 12, 0, second).toIso8601String(), + kind: kind, + channelId: channelId, + turnId: turnId, + startedAt: startedAt, + receivedAt: receivedSecond == null + ? null + : DateTime.utc(2026, 8, 16, 12, 0, receivedSecond), + payload: payload, + ); +} diff --git a/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart b/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart new file mode 100644 index 00000000000..c87e5297588 --- /dev/null +++ b/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart @@ -0,0 +1,1064 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:buzz/features/channels/agent_activity/active_agent_turns.dart'; +import 'package:buzz/features/channels/agent_activity/composer_agent_activity_indicator.dart'; +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/theme/theme.dart'; + +const _channelId = 'channel-1'; +const _agentPubkey = 'agent-a'; +const _turnId = 'turn-a'; +const _secondAgentPubkey = 'agent-b'; +const _secondTurnId = 'turn-b'; +const _scope = (channelId: _channelId, threadHeadId: null); +const _turnKey = ( + channelId: _channelId, + agentPubkey: _agentPubkey, + turnId: _turnId, +); +const _secondTurnKey = ( + channelId: _channelId, + agentPubkey: _secondAgentPubkey, + turnId: _secondTurnId, +); + +void main() { + testWidgets( + 'expands inline, keeps human typing separate, and preserves completion', + (tester) async { + final container = ProviderContainer( + overrides: [ + composerActivityStateProvider( + _scope, + ).overrideWith((ref) => ref.watch(_testComposerActivityProvider)), + agentTurnStatesProvider.overrideWith( + (ref) => ref.watch(_testTurnStatesProvider), + ), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + container + .read(_testComposerActivityProvider.notifier) + .setState( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: _agentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + turnId: _turnId, + ), + ], + humanTyping: [ + TypingEntry(pubkey: 'human-a', expiresAtMs: 9999999999999), + ], + ), + ); + container.read(_testTurnStatesProvider.notifier).setStates([ + _turn(AgentTurnPhase.working), + ]); + + await tester.pumpWidget(_app(container)); + await tester.pump(); + + expect( + find.byKey(const ValueKey('composer-agent-activity-control')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('channel-typing-indicator')), + findsOneWidget, + ); + expect(find.byType(BottomSheet), findsNothing); + + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 240)); + + final panel = find.byKey(const ValueKey('composer-agent-activity-panel')); + final control = find.byKey( + const ValueKey('composer-agent-activity-control'), + ); + final collapse = find.byKey( + const ValueKey('composer-agent-activity-collapse'), + ); + final composer = find.byKey(const ValueKey('fake-composer')); + expect(panel, findsOneWidget); + expect(control, findsNothing); + expect( + find.byKey(const ValueKey('composer-agent-activity-surface')), + findsOneWidget, + ); + expect(find.byType(BottomSheet), findsNothing); + expect( + tester.getRect(panel).bottom, + lessThanOrEqualTo(tester.getRect(composer).top), + ); + expect( + tester.getRect(collapse).bottom, + closeTo(tester.getRect(panel).bottom, 0.1), + ); + expect(find.text('Live activity may be partial.'), findsOneWidget); + expect( + find.byKey(const ValueKey('composer-agent-segmented-container')), + findsNothing, + ); + expect(find.text('Pollen'), findsNothing); + expect(find.text('Thinking'), findsOneWidget); + expect(find.text('private chain of thought'), findsNothing); + expect(find.textContaining('/private/path'), findsNothing); + + await tester.tap(collapse); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 240)); + await tester.pump(); + expect(panel, findsNothing); + expect(control, findsOneWidget); + + await tester.tap(control); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 240)); + expect(panel, findsOneWidget); + expect(control, findsNothing); + + container + .read(_testComposerActivityProvider.notifier) + .setState(const ComposerActivityState(agents: [], humanTyping: [])); + container.read(_testTurnStatesProvider.notifier).setStates([ + _turn(AgentTurnPhase.finished), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 240)); + + expect(panel, findsOneWidget); + expect(find.text('Finished'), findsWidgets); + + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-collapse')), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 240)); + await tester.pump(); + expect(panel, findsNothing); + expect(control, findsNothing); + }, + ); + + testWidgets('reduced motion makes inline size changes immediate', ( + 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.working), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + _app(container, disableAnimations: true, compactWidthFactor: 0.85), + ); + await tester.pump(); + + expect( + find.descendant( + of: find.byType(ComposerAgentActivityIndicator), + matching: find.byType(AnimatedSize), + ), + findsNothing, + ); + final compactWidth = tester + .getSize(find.byKey(const ValueKey('composer-agent-activity-surface'))) + .width; + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pump(); + await tester.pump(); + expect( + tester + .getSize( + find.byKey(const ValueKey('composer-agent-activity-overlay')), + ) + .width, + closeTo(376, 0.1), + ); + expect( + tester + .getSize( + find.byKey(const ValueKey('composer-agent-activity-overlay')), + ) + .width, + greaterThan(compactWidth), + ); + }); + + testWidgets('width and height morph together and reverse mid-expansion', ( + tester, + ) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + 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.working), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget(_app(container, compactWidthFactor: 0.85)); + await tester.pump(); + final compactWidth = tester + .getSize(find.byKey(const ValueKey('composer-agent-activity-surface'))) + .width; + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 60)); + + final overlay = find.byKey( + const ValueKey('composer-agent-activity-overlay'), + ); + final panel = find.byKey(const ValueKey('composer-agent-activity-panel')); + final expandingSize = tester.getSize(overlay); + final expandingPanelSize = tester.getSize(panel); + expect(tester.getCenter(overlay).dx, closeTo(200, 0.1)); + expect(expandingSize.width, greaterThan(compactWidth)); + expect(expandingSize.width, lessThan(376)); + expect(expandingSize.height, greaterThan(52)); + expect(expandingSize.height, lessThan(328)); + expect( + expandingPanelSize.width, + closeTo(expandingSize.width - Grid.twelve * 2, 0.1), + ); + expect(expandingPanelSize.height, greaterThan(44)); + expect(expandingPanelSize.height, lessThan(312)); + + tester + .widget( + find.byKey(const ValueKey('composer-agent-activity-collapse')), + ) + .onTap!(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getCenter(overlay).dx, closeTo(200, 0.1)); + expect(tester.getSize(overlay).width, lessThan(expandingSize.width)); + expect(tester.getSize(overlay).height, lessThan(expandingSize.height)); + expect(tester.getSize(panel).height, lessThan(expandingPanelSize.height)); + + final reversingSize = tester.getSize(overlay); + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-collapse')), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(overlay).width, greaterThan(reversingSize.width)); + expect(tester.getSize(overlay).height, greaterThan(reversingSize.height)); + + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-collapse')), + ); + await tester.pump(); + + await tester.pumpAndSettle(); + expect(overlay, findsNothing); + expect( + tester + .getSize( + find.byKey(const ValueKey('composer-agent-activity-surface')), + ) + .width, + closeTo(compactWidth, 0.1), + ); + }); + + testWidgets('multiple agents use a top segmented selector', (tester) async { + tester.view.physicalSize = const Size(220, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = _multiAgentContainer(); + addTearDown(container.dispose); + + await tester.pumpWidget(_app(container)); + await tester.pump(); + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('composer-agent-activity-selector')), + findsOneWidget, + ); + final selector = find.byKey( + const ValueKey('composer-agent-segmented-container'), + ); + expect(selector, findsOneWidget); + expect(find.text('Pollen'), findsOneWidget); + expect(find.text('Sprig'), findsOneWidget); + expect(find.text('Running Search messages'), findsNothing); + final sprigSegment = find.byKey( + const ValueKey('composer-agent-segment-agent-b'), + ); + final selectorScrollView = find.descendant( + of: find.byKey(const ValueKey('composer-agent-activity-selector')), + matching: find.byType(Scrollable), + ); + expect( + tester + .state(selectorScrollView) + .position + .maxScrollExtent, + greaterThan(0), + ); + expect( + tester + .getSize(find.byKey(const ValueKey('composer-agent-segment-agent-a'))) + .height, + closeTo(tester.getSize(sprigSegment).height, 0.1), + ); + expect( + tester + .widget( + find.byKey(const ValueKey('composer-agent-segment-agent-a')), + ) + .color, + AppTheme.light().colorScheme.primary, + ); + expect(find.bySemanticsLabel('Pollen'), findsOneWidget); + expect( + tester.getTopLeft(selector).dy, + lessThan( + tester + .getTopLeft( + find.byKey(const ValueKey('composer-agent-activity-transcript')), + ) + .dy, + ), + ); + + await tester.ensureVisible(sprigSegment); + await tester.tap(sprigSegment); + await tester.pumpAndSettle(); + + expect( + tester.widget(sprigSegment).color, + AppTheme.light().colorScheme.primary, + ); + expect(find.text('Sprig'), findsOneWidget); + expect(find.text('Running Search messages'), findsOneWidget); + expect(find.text('Running Read file'), findsNothing); + }); + + testWidgets('iOS uses the native sliding segmented control', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(400, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = _multiAgentContainer(); + addTearDown(container.dispose); + + await tester.pumpWidget(_app(container)); + await tester.pump(); + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pumpAndSettle(); + + final selector = find.byKey( + const ValueKey('composer-agent-segmented-container'), + ); + final control = tester.widget>( + selector, + ); + final frame = find.byKey(const ValueKey('composer-agent-segmented-frame')); + final frameWidget = tester.widget(frame); + final panel = find.byKey(const ValueKey('composer-agent-activity-panel')); + expect(control.groupValue, _agentPubkey); + expect(control.thumbColor, AppTheme.light().colorScheme.primary); + expect( + frameWidget.borderRadius, + BorderRadius.circular(Radii.dialog - Grid.xxs), + ); + expect( + tester.getRect(frame).left, + closeTo(tester.getRect(panel).left + Grid.xxs, 0.1), + ); + expect( + tester.getRect(frame).right, + closeTo(tester.getRect(panel).right - Grid.xxs, 0.1), + ); + expect(find.bySemanticsLabel('Pollen'), findsOneWidget); + + final sprigSegment = find.byKey( + const ValueKey('composer-agent-segment-agent-b'), + ); + await tester.ensureVisible(sprigSegment); + await tester.tap(sprigSegment); + await tester.pumpAndSettle(); + + expect( + tester + .widget>(selector) + .groupValue, + _secondAgentPubkey, + ); + expect(find.text('Running Search messages'), findsOneWidget); + expect(find.text('Running Read file'), findsNothing); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets( + 'overlays upward at the taller target without changing layout height', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + 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.working), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + _app(container, disableAnimations: true, overlayTopBoundary: 100), + ); + await tester.pump(); + final indicator = find.byType(ComposerAgentActivityIndicator); + final compactHeight = tester.getSize(indicator).height; + final composerTop = tester.getTopLeft( + find.byKey(const ValueKey('fake-composer')), + ); + + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pump(); + + final overlay = find.byKey( + const ValueKey('composer-agent-activity-overlay'), + ); + expect(tester.getSize(indicator).height, closeTo(compactHeight, 0.1)); + expect( + tester.getTopLeft(find.byKey(const ValueKey('fake-composer'))), + composerTop, + ); + expect(tester.getSize(overlay).height, closeTo(328, 0.1)); + expect(tester.getRect(overlay).top, greaterThanOrEqualTo(100)); + expect( + find.byKey(const ValueKey('composer-agent-activity-transcript')), + findsOneWidget, + ); + }, + ); + + testWidgets('caps the overlay below the supplied top navigation boundary', ( + tester, + ) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + 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.working), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + _app(container, disableAnimations: true, overlayTopBoundary: 520), + ); + await tester.pump(); + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pump(); + + final overlay = find.byKey( + const ValueKey('composer-agent-activity-overlay'), + ); + expect(tester.getRect(overlay).top, greaterThanOrEqualTo(520)); + expect(tester.getSize(overlay).height, lessThan(328)); + }); + + testWidgets( + 'dismisses an open keyboard and restores it after collapse with draft intact', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final focusNode = FocusNode(); + final draftController = TextEditingController(text: 'Keep this draft'); + final interactionLock = ValueNotifier(false); + addTearDown(focusNode.dispose); + addTearDown(draftController.dispose); + addTearDown(interactionLock.dispose); + 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.working), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + _app( + container, + disableAnimations: true, + composerFocusNode: focusNode, + composerInteractionLock: interactionLock, + draftController: draftController, + viewInsets: const EdgeInsets.only(bottom: 300), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + expect( + MediaQuery.viewInsetsOf( + tester.element(find.byType(ComposerAgentActivityIndicator)), + ).bottom, + 300, + ); + + tester + .widget( + find.byKey(const ValueKey('composer-agent-activity-control')), + ) + .onTap!(); + await tester.pump(); + expect(focusNode.hasFocus, isFalse); + expect(interactionLock.value, isTrue); + expect( + find.byKey(const ValueKey('composer-agent-activity-panel')), + findsNothing, + ); + + await tester.pumpWidget( + _app( + container, + disableAnimations: true, + composerFocusNode: focusNode, + composerInteractionLock: interactionLock, + draftController: draftController, + ), + ); + await tester.pump(); + expect( + find.byKey(const ValueKey('composer-agent-activity-panel')), + findsOneWidget, + ); + expect(focusNode.canRequestFocus, isTrue); + + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-collapse')), + ); + await tester.pump(); + await tester.pump(); + expect(focusNode.canRequestFocus, isTrue); + expect(focusNode.hasFocus, isTrue); + expect(interactionLock.value, isFalse); + expect(draftController.text, 'Keep this draft'); + }, + ); + + testWidgets('collapse leaves a previously closed keyboard closed', ( + tester, + ) async { + final focusNode = FocusNode(); + final interactionLock = ValueNotifier(false); + addTearDown(focusNode.dispose); + addTearDown(interactionLock.dispose); + 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.working), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + _app( + container, + disableAnimations: true, + composerFocusNode: focusNode, + composerInteractionLock: interactionLock, + ), + ); + await tester.pump(); + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pump(); + expect(focusNode.canRequestFocus, isTrue); + expect(interactionLock.value, isTrue); + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-collapse')), + ); + await tester.pump(); + await tester.pump(); + + expect(focusNode.canRequestFocus, isTrue); + expect(focusNode.hasFocus, isFalse); + expect(interactionLock.value, isFalse); + }); + + testWidgets( + 'composer activation hands off from activity before restoring focus', + (tester) async { + final focusNode = FocusNode(); + final interactionLock = ValueNotifier(false); + final activationRequests = ValueNotifier(0); + addTearDown(focusNode.dispose); + addTearDown(interactionLock.dispose); + addTearDown(activationRequests.dispose); + 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.working), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + _app( + container, + disableAnimations: true, + composerFocusNode: focusNode, + composerInteractionLock: interactionLock, + composerActivationRequests: activationRequests, + ), + ); + await tester.pump(); + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pump(); + expect( + find.byKey(const ValueKey('composer-agent-activity-panel')), + findsOneWidget, + ); + expect(interactionLock.value, isTrue); + expect(focusNode.hasFocus, isFalse); + + activationRequests.value += 1; + await tester.pump(); + await tester.pump(); + + expect( + find.byKey(const ValueKey('composer-agent-activity-panel')), + findsNothing, + ); + expect(interactionLock.value, isFalse); + expect(focusNode.hasFocus, isTrue); + }, + ); + + testWidgets('an unowned agent is status-only and cannot open activity', ( + tester, + ) async { + final container = ProviderContainer( + overrides: [ + composerActivityStateProvider(_scope).overrideWithValue( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: _secondAgentPubkey, + source: AgentWorkingSource.typing, + canViewActivity: false, + ), + ], + humanTyping: [], + ), + ), + agentTurnStatesProvider.overrideWithValue(const []), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget(_app(container)); + await tester.pump(); + + expect( + find.byKey(const ValueKey('composer-agent-status-only')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('composer-agent-activity-control')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('composer-agent-activity-panel')), + findsNothing, + ); + }); +} + +ProviderContainer _multiAgentContainer() => ProviderContainer( + overrides: [ + composerActivityStateProvider(_scope).overrideWithValue( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: _agentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + turnId: _turnId, + ), + WorkingAgentSignal( + pubkey: _secondAgentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + turnId: _secondTurnId, + ), + ], + humanTyping: [], + ), + ), + agentTurnStatesProvider.overrideWithValue([ + _turn(AgentTurnPhase.working), + _turnFor( + pubkey: _secondAgentPubkey, + turnId: _secondTurnId, + phase: AgentTurnPhase.working, + ), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + observerTurnSubscriptionProvider( + _secondTurnKey, + ).overrideWithValue(_secondObserverState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], +); + +Widget _app( + ProviderContainer container, { + bool disableAnimations = false, + double? overlayTopBoundary, + FocusNode? composerFocusNode, + TextEditingController? draftController, + EdgeInsets viewInsets = EdgeInsets.zero, + double compactWidthFactor = 1, + Animation? composerWidthAnimation, + ValueNotifier? composerInteractionLock, + ValueNotifier? composerActivationRequests, +}) { + Widget activityIndicator = ComposerAgentActivityIndicator( + channelId: _channelId, + overlayTopBoundary: overlayTopBoundary, + compactWidthFactor: compactWidthFactor, + composerWidthAnimation: composerWidthAnimation, + composerFocusNode: composerFocusNode, + composerInteractionLock: composerInteractionLock, + composerActivationRequests: composerActivationRequests, + ); + if (compactWidthFactor < 1) { + activityIndicator = Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), + child: Align( + child: FractionallySizedBox( + widthFactor: compactWidthFactor, + child: activityIndicator, + ), + ), + ); + } + Widget fakeComposer = SizedBox( + key: const ValueKey('fake-composer'), + height: 64, + child: TextField(focusNode: composerFocusNode, controller: draftController), + ); + if (composerInteractionLock != null) { + fakeComposer = ValueListenableBuilder( + valueListenable: composerInteractionLock, + builder: (context, interactionLocked, child) => + AbsorbPointer(absorbing: interactionLocked, child: child), + child: fakeComposer, + ); + } + return UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: AppTheme.light(), + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith( + disableAnimations: disableAnimations, + viewInsets: viewInsets, + ), + child: Scaffold( + resizeToAvoidBottomInset: false, + body: Column( + children: [ + const Expanded(child: SizedBox()), + activityIndicator, + fakeComposer, + ], + ), + ), + ), + ), + ), + ); +} + +AgentTurnState _turn(AgentTurnPhase phase) => AgentTurnState( + agentPubkey: _agentPubkey, + channelId: _channelId, + turnId: _turnId, + startedAt: DateTime.utc(2026, 8, 16, 12), + lastActivityAt: DateTime.utc(2026, 8, 16, 12, 0, 5), + livenessTimeout: const Duration(seconds: 30), + phase: phase, + terminalAt: phase == AgentTurnPhase.working + ? null + : DateTime.utc(2026, 8, 16, 12, 0, 6), +); + +AgentTurnState _turnFor({ + required String pubkey, + required String turnId, + required AgentTurnPhase phase, +}) => AgentTurnState( + agentPubkey: pubkey, + channelId: _channelId, + turnId: turnId, + startedAt: DateTime.utc(2026, 8, 16, 12), + lastActivityAt: DateTime.utc(2026, 8, 16, 12, 0, 5), + livenessTimeout: const Duration(seconds: 30), + phase: phase, +); + +final _observerState = ObserverState( + connection: ObserverConnectionState.open, + transcript: [ + ThoughtItem( + id: 'thought-1', + title: 'Thinking', + text: 'private chain of thought', + timestamp: '2026-08-16T12:00:01Z', + ), + ToolItem( + id: 'tool-1', + title: 'Read file', + toolName: 'read_file', + status: ToolStatus.executing, + args: const {'path': '/private/path'}, + result: '', + isError: false, + timestamp: '2026-08-16T12:00:02Z', + ), + ], +); + +final _secondObserverState = ObserverState( + connection: ObserverConnectionState.open, + transcript: [ + ToolItem( + id: 'tool-2', + title: 'Search messages', + toolName: 'search', + status: ToolStatus.executing, + args: const {}, + result: '', + isError: false, + timestamp: '2026-08-16T12:00:02Z', + ), + ], +); + +final _testComposerActivityProvider = + NotifierProvider<_TestComposerActivityNotifier, ComposerActivityState>( + _TestComposerActivityNotifier.new, + ); + +class _TestComposerActivityNotifier extends Notifier { + @override + ComposerActivityState build() => + const ComposerActivityState(agents: [], humanTyping: []); + + void setState(ComposerActivityState next) => state = next; +} + +final _testTurnStatesProvider = + NotifierProvider<_TestTurnStatesNotifier, List>( + _TestTurnStatesNotifier.new, + ); + +class _TestTurnStatesNotifier extends Notifier> { + @override + List build() => const []; + + void setStates(List next) => state = next; +} + +class _FakeUserCacheNotifier extends UserCacheNotifier { + @override + Map build() => const { + _agentPubkey: UserProfile( + pubkey: _agentPubkey, + displayName: 'Pollen', + ownerPubkey: 'owner', + ), + 'human-a': UserProfile(pubkey: 'human-a', displayName: 'Alice'), + _secondAgentPubkey: UserProfile( + pubkey: _secondAgentPubkey, + displayName: 'Sprig', + ownerPubkey: 'owner', + ), + }; + + @override + void preload(List pubkeys) {} +} diff --git a/mobile/test/features/channels/agent_activity/observer_subscription_test.dart b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart index f54b0594ef2..a9a55d080fe 100644 --- a/mobile/test/features/channels/agent_activity/observer_subscription_test.dart +++ b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart @@ -10,6 +10,43 @@ import 'package:buzz/shared/crypto/nip44.dart'; import 'package:buzz/shared/relay/relay.dart'; void main() { + test('turn-scoped provider does not merge concurrent agent turns', () { + final container = ProviderContainer( + overrides: [ + observerRelayProvider.overrideWith( + () => _StaticObserverRelay({ + 'agent-a': [ + _turnMessageFrame(seq: 1, turnId: 'turn-1', text: 'First turn'), + _turnMessageFrame(seq: 2, turnId: 'turn-2', text: 'Second turn'), + ObserverFrame( + seq: 3, + timestamp: '2026-04-30T12:00:03.000Z', + kind: 'agent_panic', + turnId: 'turn-1', + payload: const {'error': 'Process exited'}, + ), + ], + }), + ), + ], + ); + addTearDown(container.dispose); + + final state = container.read( + observerTurnSubscriptionProvider(( + channelId: 'test-channel', + agentPubkey: 'agent-a', + turnId: 'turn-1', + )), + ); + + expect(state.transcript.whereType().single.text, 'First turn'); + expect( + state.transcript.whereType().single.title, + 'Agent error', + ); + }); + 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". @@ -254,6 +291,7 @@ void main() { 'kind': 'turn_started', 'channelId': channelId, 'turnId': 'turn-1', + 'startedAt': '2026-04-30T11:59:00.000Z', 'payload': { 'triggeringEventIds': ['0123456789abcdef'], }, @@ -279,6 +317,12 @@ void main() { final item = state.transcript.single; expect(item, isA()); expect((item as LifecycleItem).title, 'Turn started'); + final storedFrame = container + .read(observerRelayProvider) + .framesByAgent[agentKeychain.public]! + .single; + expect(storedFrame.startedAt, '2026-04-30T11:59:00.000Z'); + expect(storedFrame.receivedAt, isNotNull); final otherChannelState = container.read( observerSubscriptionProvider(( @@ -518,6 +562,30 @@ void main() { ); } +ObserverFrame _turnMessageFrame({ + required int seq, + required String turnId, + required String text, +}) => ObserverFrame( + seq: seq, + timestamp: '2026-04-30T12:00:0$seq.000Z', + kind: 'acp_read', + channelId: 'test-channel', + turnId: turnId, + payload: { + 'method': 'session/update', + 'params': { + 'update': { + 'sessionUpdate': 'agent_message_chunk', + 'messageId': 'message-$turnId', + 'content': [ + {'type': 'text', 'text': text}, + ], + }, + }, + }, +); + Map _observerFrameJson({ required int seq, required String channelId, @@ -614,6 +682,18 @@ class _RecordingRelaySession extends RelaySessionNotifier { } } +class _StaticObserverRelay extends ObserverRelayNotifier { + final Map> frames; + + _StaticObserverRelay(this.frames); + + @override + ObserverRelayState build() => ObserverRelayState( + connection: ObserverConnectionState.open, + framesByAgent: frames, + ); +} + class _FakeRelayConfigNotifier extends RelayConfigNotifier { String? _nsec; diff --git a/mobile/test/features/channels/agent_activity/transcript_builder_test.dart b/mobile/test/features/channels/agent_activity/transcript_builder_test.dart index fd907353f94..46abdafcb6a 100644 --- a/mobile/test/features/channels/agent_activity/transcript_builder_test.dart +++ b/mobile/test/features/channels/agent_activity/transcript_builder_test.dart @@ -122,6 +122,29 @@ void main() { expect(items[1], isA()); expect((items[1] as MetadataItem).sections, hasLength(2)); }); + + test('surfaces explicit turn errors without inventing completion rows', () { + final items = buildTranscript([ + ObserverFrame( + seq: 1, + timestamp: _timestamp(1), + kind: 'turn_error', + turnId: 'turn-1', + payload: const {'outcome': 'failed', 'error': 'Permission denied'}, + ), + ObserverFrame( + seq: 2, + timestamp: _timestamp(2), + kind: 'turn_completed', + turnId: 'turn-2', + ), + ]); + + expect(items, hasLength(1)); + expect(items.single, isA()); + expect((items.single as LifecycleItem).title, 'Turn error'); + expect((items.single as LifecycleItem).text, 'failed: Permission denied'); + }); } ObserverFrame _updateFrame({ diff --git a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart new file mode 100644 index 00000000000..eed024eb1b1 --- /dev/null +++ b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:buzz/features/channels/agent_activity/active_agent_turns.dart'; +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_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'; + +const _channelId = 'channel-1'; + +void main() { + test('prefers observer work and keeps human typing separate', () { + final observerTurn = _turn('agent-a'); + final container = ProviderContainer( + overrides: [ + currentPubkeyProvider.overrideWith((ref) => 'owner'), + channelMembersProvider( + _channelId, + ).overrideWith((ref) async => const []), + channelTypingProvider(_channelId).overrideWith( + () => _FakeTypingNotifier(const [ + TypingEntry(pubkey: 'agent-a', expiresAtMs: 9999999999999), + TypingEntry(pubkey: 'agent-b', expiresAtMs: 9999999999999), + TypingEntry(pubkey: 'human', expiresAtMs: 9999999999999), + ]), + ), + agentMentionPubkeysProvider( + _channelId, + ).overrideWith((ref) => const {'agent-a', 'agent-b'}), + agentOwnersProvider.overrideWithValue( + const AsyncData({'agent-a': 'owner', 'agent-b': 'owner'}), + ), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + observerRelayProvider.overrideWith( + () => _FakeObserverRelayNotifier({ + 'agent-a': [_observerFrame('agent-a')], + }), + ), + activeAgentTurnsProvider.overrideWithValue([observerTurn]), + ], + ); + addTearDown(container.dispose); + + final state = container.read( + composerActivityStateProvider(( + channelId: _channelId, + threadHeadId: null, + )), + ); + + expect(state.agents, hasLength(2)); + expect(state.agents[0].pubkey, 'agent-a'); + expect(state.agents[0].source, AgentWorkingSource.observer); + expect(state.agents[0].canViewActivity, isTrue); + expect(state.agents[1].source, AgentWorkingSource.typing); + expect(state.agents[1].canViewActivity, isTrue); + expect(state.humanTyping.single.pubkey, 'human'); + }); + + test( + 'thread scope requires typing and does not surface observer-only work', + () { + final container = ProviderContainer( + overrides: [ + currentPubkeyProvider.overrideWith((ref) => 'owner'), + channelMembersProvider( + _channelId, + ).overrideWith((ref) async => const []), + channelTypingProvider(_channelId).overrideWith( + () => _FakeTypingNotifier(const [ + TypingEntry( + pubkey: 'agent-b', + threadHeadId: 'thread-1', + expiresAtMs: 9999999999999, + ), + TypingEntry( + pubkey: 'human', + threadHeadId: 'thread-1', + expiresAtMs: 9999999999999, + ), + ]), + ), + agentMentionPubkeysProvider( + _channelId, + ).overrideWith((ref) => const {'agent-a', 'agent-b'}), + agentOwnersProvider.overrideWithValue( + const AsyncData({'agent-b': 'owner'}), + ), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + observerRelayProvider.overrideWith( + () => _FakeObserverRelayNotifier({ + 'agent-a': [_observerFrame('agent-a')], + }), + ), + activeAgentTurnsProvider.overrideWithValue([_turn('agent-a')]), + ], + ); + addTearDown(container.dispose); + + final state = container.read( + composerActivityStateProvider(( + channelId: _channelId, + threadHeadId: 'thread-1', + )), + ); + + expect(state.agents.single.pubkey, 'agent-b'); + expect(state.agents.single.source, AgentWorkingSource.typing); + expect(state.humanTyping.single.pubkey, 'human'); + }, + ); +} + +AgentTurnState _turn(String pubkey) => 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, +); + +ObserverFrame _observerFrame(String pubkey) => ObserverFrame( + seq: 1, + timestamp: DateTime.utc(2026, 8, 16, 12).toIso8601String(), + kind: 'turn_started', + channelId: _channelId, + turnId: 'turn-$pubkey', +); + +class _FakeTypingNotifier extends ChannelTypingNotifier { + final List entries; + + _FakeTypingNotifier(this.entries) : super(_channelId); + + @override + List build() => entries; +} + +class _FakeUserCacheNotifier extends UserCacheNotifier { + @override + Map build() => const {}; + + @override + void preload(List pubkeys) {} +} + +class _FakeObserverRelayNotifier extends ObserverRelayNotifier { + final Map> frames; + + _FakeObserverRelayNotifier(this.frames); + + @override + ObserverRelayState build() => ObserverRelayState( + connection: ObserverConnectionState.open, + framesByAgent: frames, + ); +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 79e385452bb..df44a0efb57 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -7,11 +7,16 @@ import 'package:flutter/rendering.dart' show ScrollDirection; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:hooks_riverpod/misc.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/agent_activity/active_agent_turns.dart'; +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_detail_page.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channel_messages_provider.dart'; @@ -193,6 +198,7 @@ Widget _buildTestable({ bool disableAnimations = false, RelaySessionNotifier? relaySessionNotifier, http.Client? mediaClient, + List extraOverrides = const [], }) { final resolvedChannel = channel ?? _testChannel; final fakeChannelsNotifier = @@ -254,6 +260,7 @@ Widget _buildTestable({ relaySessionProvider.overrideWith(() => relaySessionNotifier), // Compose bar drafts persist through SharedPreferences. savedPrefsProvider.overrideWithValue(_testPrefs), + ...extraOverrides, ], child: MaterialApp( theme: AppTheme.light(), @@ -2305,6 +2312,240 @@ void main() { }, ); + testWidgets( + 'agent activity follows composer width and overlays without reflowing the tail', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + const agentPubkey = 'agent-a'; + const turnId = 'turn-a'; + final messages = [ + for (var i = 0; i < 20; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i, + ), + ]; + final turn = AgentTurnState( + agentPubkey: agentPubkey, + channelId: _channelId, + turnId: turnId, + startedAt: DateTime.utc(2026, 8, 16, 12), + lastActivityAt: DateTime.utc(2026, 8, 16, 12, 0, 5), + livenessTimeout: const Duration(seconds: 30), + phase: AgentTurnPhase.working, + ); + + await tester.pumpWidget( + _buildTestable( + messages: messages, + disableAnimations: false, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + agentPubkey: UserProfile( + pubkey: agentPubkey, + displayName: 'Pollen', + ownerPubkey: 'self', + ), + }, + extraOverrides: [ + composerActivityStateProvider(( + channelId: _channelId, + threadHeadId: null, + )).overrideWithValue( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: agentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + turnId: turnId, + ), + ], + humanTyping: [], + ), + ), + agentTurnStatesProvider.overrideWithValue([turn]), + observerTurnSubscriptionProvider(( + channelId: _channelId, + agentPubkey: agentPubkey, + turnId: turnId, + )).overrideWithValue( + ObserverState( + connection: ObserverConnectionState.open, + transcript: [ + ToolItem( + id: 'tool-1', + title: 'Read channel', + toolName: 'get_messages', + status: ToolStatus.executing, + args: const {}, + result: '', + isError: false, + timestamp: '2026-08-16T12:00:05Z', + ), + ], + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + final latestMessage = find.byKey( + const ValueKey('channel-message-group-msg19'), + ); + final composerDock = find.byKey( + const ValueKey('channel-composer-dock'), + ); + final activitySurface = find.byKey( + const ValueKey('composer-agent-activity-surface'), + ); + final composerWidth = find.byKey( + const ValueKey('composer-width-transition'), + ); + final compactActivityWidth = tester.getSize(activitySurface).width; + final compactComposerWidth = tester.getSize(composerWidth).width; + expect(compactActivityWidth, closeTo(compactComposerWidth, 0.1)); + expect( + tester.getCenter(activitySurface).dx, + closeTo(tester.getCenter(composerWidth).dx, 0.1), + ); + + await tester.tap(find.text('Message #general')); + await tester.pumpAndSettle(); + final textField = tester.widget(find.byType(TextField)); + expect(textField.focusNode?.hasFocus, isTrue); + final expandedActivityWidth = tester.getSize(activitySurface).width; + final expandedComposerWidth = tester.getSize(composerWidth).width; + expect(expandedActivityWidth, greaterThan(compactActivityWidth)); + expect(expandedActivityWidth, closeTo(expandedComposerWidth, 0.1)); + + textField.focusNode?.unfocus(); + await tester.pumpAndSettle(); + final dockHeightBeforeActivity = tester.getSize(composerDock).height; + final latestMessageBottomBeforeActivity = tester + .getBottomLeft(latestMessage) + .dy; + + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('composer-agent-activity-panel')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('composer-agent-segmented-container')), + findsNothing, + ); + expect(find.text('Pollen'), findsNothing); + expect( + tester + .getCenter( + find.byKey(const ValueKey('composer-agent-activity-panel')), + ) + .dx, + closeTo(tester.getCenter(composerWidth).dx, 0.1), + ); + expect( + tester + .getSize( + find.byKey(const ValueKey('composer-agent-activity-panel')), + ) + .width, + closeTo(expandedComposerWidth, 0.1), + ); + expect(find.byType(BottomSheet), findsNothing); + expect(textField.focusNode?.hasFocus, isFalse); + expect(textField.focusNode?.canRequestFocus, isTrue); + await tester.tap(find.text('Message #general'), warnIfMissed: false); + await tester.pump(); + expect(textField.focusNode?.hasFocus, isFalse); + expect(find.byType(TextField), findsNothing); + expect( + tester.getSize(composerDock).height, + closeTo(dockHeightBeforeActivity, 0.1), + ); + expect( + tester.getBottomLeft(latestMessage).dy, + closeTo(latestMessageBottomBeforeActivity, 0.1), + ); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsNothing, + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('composer-agent-activity-panel')), + findsNothing, + ); + final activatedComposer = tester.widget( + find.byType(TextField), + ); + expect(activatedComposer.focusNode?.canRequestFocus, isTrue); + expect(activatedComposer.focusNode?.hasFocus, isTrue); + + activatedComposer.focusNode?.unfocus(); + await tester.pumpAndSettle(); + expect( + tester + .getSize( + find.byKey(const ValueKey('composer-agent-activity-surface')), + ) + .width, + closeTo(compactActivityWidth, 0.1), + ); + + await tester.tap(find.text('Message #general')); + await tester.pumpAndSettle(); + final reopenedTextField = tester.widget( + find.byType(TextField), + ); + expect(reopenedTextField.focusNode?.hasFocus, isTrue); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pump(); + expect(reopenedTextField.focusNode?.hasFocus, isFalse); + expect( + find.byKey(const ValueKey('composer-agent-activity-panel')), + findsNothing, + ); + + tester.view.viewInsets = FakeViewPadding.zero; + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('composer-agent-activity-panel')), + findsOneWidget, + ); + expect( + tester + .getSize( + find.byKey(const ValueKey('composer-agent-activity-panel')), + ) + .width, + closeTo(expandedComposerWidth, 0.1), + ); + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-collapse')), + ); + await tester.pumpAndSettle(); + final restoredTextField = tester.widget( + find.byType(TextField), + ); + expect(restoredTextField.focusNode?.hasFocus, isTrue); + }, + ); + testWidgets( 'seeds an already-visible Android keyboard into the channel tail layout', (tester) async { From 1ca9793e7d5037372387a0c46f5b6ad83496a55d Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 17 Aug 2026 07:06:01 +0100 Subject: [PATCH 02/17] Fix mobile agent liveness expiry Signed-off-by: kenny lopez --- crates/buzz-acp/src/pool.rs | 7 ++- .../active_agent_turns_test.dart | 55 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 2efacce2b19..923d19a3e7d 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -1511,6 +1511,7 @@ pub async fn run_prompt_task( PromptSource::Heartbeat => "heartbeat", }, "triggeringEventIds": triggering_event_ids, + "livenessIntervalSecs": ctx.turn_liveness_interval.as_secs(), }), ); @@ -3902,7 +3903,9 @@ async fn run_turn_liveness( "turn_liveness", agent_index, &context, - serde_json::json!({}), + serde_json::json!({ + "livenessIntervalSecs": interval.as_secs(), + }), ); drop(guard); } @@ -6831,7 +6834,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" .all(|event| event.started_at.as_deref() == Some(&started_at))); assert!(pings .iter() - .all(|event| event.payload == serde_json::json!({}))); + .all(|event| { event.payload == serde_json::json!({ "livenessIntervalSecs": 10 }) })); assert_eq!( serde_json::to_value(&pings[0]).unwrap()["startedAt"], started_at, diff --git a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart index 3061465d5c7..fa5c8b60b66 100644 --- a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart +++ b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart @@ -58,6 +58,61 @@ void main() { expect(turns, isEmpty); }); + test('uses the advertised liveness interval to expire quiet turns', () { + final frames = { + 'agent-a': [ + _frame( + seq: 1, + second: 1, + kind: 'turn_started', + payload: {'livenessIntervalSecs': 120}, + ), + ], + }; + + final beforeTimeout = reduceAgentTurnStates( + frames, + now: DateTime.utc(2026, 8, 16, 12, 2, 30), + ); + final afterTimeout = reduceAgentTurnStates( + frames, + now: DateTime.utc(2026, 8, 16, 12, 2, 32), + ); + + expect(beforeTimeout, hasLength(1)); + expect(beforeTimeout.single.livenessTimeout, const Duration(seconds: 150)); + expect(afterTimeout, isEmpty); + }); + + test('keeps liveness-disabled turns until the bounded crash backstop', () { + final frames = { + 'agent-a': [ + _frame( + seq: 1, + second: 1, + kind: 'turn_started', + payload: {'livenessIntervalSecs': 0}, + ), + ], + }; + + final longRunning = reduceAgentTurnStates( + frames, + now: DateTime.utc(2026, 8, 17, 12), + ); + final pastBackstop = reduceAgentTurnStates( + frames, + now: DateTime.utc(2026, 8, 17, 12, 0, 32), + ); + + expect(longRunning, hasLength(1)); + expect( + longRunning.single.livenessTimeout, + const Duration(hours: 24, seconds: 30), + ); + expect(pastBackstop, isEmpty); + }); + test('recovers a missed start and rejects stale post-terminal liveness', () { final turns = reduceAgentTurnStates({ 'agent-a': [ From 638320c0073f987c2a4ef8ef3a216079a04f28c5 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 17 Aug 2026 10:32:52 +0100 Subject: [PATCH 03/17] Fix mobile agent terminal state handling Signed-off-by: kenny lopez --- .../agent_activity/active_agent_turns.dart | 14 ++++- .../active_agent_turns_test.dart | 55 ++++++++++++++++++- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart index 0de0cd8b48a..d13a17dcaa6 100644 --- a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart +++ b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart @@ -7,7 +7,9 @@ import 'observer_subscription.dart'; const _defaultLivenessTimeout = Duration(seconds: 30); const _activeTurnClockInterval = Duration(seconds: 5); -const _maximumLivenessInterval = Duration(hours: 24); +// 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); const _livenessTimeoutSlack = Duration(seconds: 30); /// Lifecycle state reconstructed from owner-scoped observer frames. @@ -114,6 +116,12 @@ List reduceAgentTurnStates( if (turnId != null) { terminalOrderById[turnId] = frameOrderAt; final existing = turnsById[turnId]; + // The harness's generic completion guard can run after its result + // handler emits the specific failure outcome. + if (frame.kind == 'turn_completed' && + existing?.phase == AgentTurnPhase.error) { + continue; + } final channelId = existing?.channelId ?? frame.channelId; if (channelId == null) continue; turnsById[turnId] = @@ -293,11 +301,11 @@ Duration _livenessTimeout(dynamic payload) { final intervalSeconds = rawInterval.toInt(); if (intervalSeconds <= 0) { - return _maximumLivenessInterval + _livenessTimeoutSlack; + return _maximumTurnDuration + _livenessTimeoutSlack; } final boundedInterval = intervalSeconds.clamp( 5, - _maximumLivenessInterval.inSeconds, + _maximumTurnDuration.inSeconds, ); final timeoutSeconds = boundedInterval + _livenessTimeoutSlack.inSeconds; return Duration( diff --git a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart index fa5c8b60b66..5cf022a6566 100644 --- a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart +++ b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart @@ -50,6 +50,26 @@ void main() { expect(turns[1].errorMessage, 'Tool permission denied'); }); + test('keeps an error terminal when generic completion arrives later', () { + final turns = reduceAgentTurnStates({ + 'agent-a': [ + _frame(seq: 1, second: 1, kind: 'turn_started'), + _frame( + seq: 2, + second: 2, + kind: 'turn_error', + payload: {'error': 'Agent timed out'}, + ), + _frame(seq: 3, second: 3, kind: 'turn_completed'), + ], + }, now: DateTime.utc(2026, 8, 16, 12, 1)); + + expect(turns, hasLength(1)); + expect(turns.single.phase, AgentTurnPhase.error); + expect(turns.single.errorMessage, 'Agent timed out'); + expect(turns.single.terminalAt, DateTime.utc(2026, 8, 16, 12, 0, 2)); + }); + test('expires silence without claiming the turn finished', () { final turns = reduceAgentTurnStates({ 'agent-a': [_frame(seq: 1, second: 1, kind: 'turn_started')], @@ -84,6 +104,35 @@ void main() { expect(afterTimeout, isEmpty); }); + test('honors advertised liveness intervals longer than one day', () { + final frames = { + 'agent-a': [ + _frame( + seq: 1, + second: 1, + kind: 'turn_started', + payload: {'livenessIntervalSecs': 48 * 60 * 60}, + ), + ], + }; + + final beforeTimeout = reduceAgentTurnStates( + frames, + now: DateTime.utc(2026, 8, 18, 12, 0, 30), + ); + final afterTimeout = reduceAgentTurnStates( + frames, + now: DateTime.utc(2026, 8, 18, 12, 0, 32), + ); + + expect(beforeTimeout, hasLength(1)); + expect( + beforeTimeout.single.livenessTimeout, + const Duration(hours: 48, seconds: 30), + ); + expect(afterTimeout, isEmpty); + }); + test('keeps liveness-disabled turns until the bounded crash backstop', () { final frames = { 'agent-a': [ @@ -98,17 +147,17 @@ void main() { final longRunning = reduceAgentTurnStates( frames, - now: DateTime.utc(2026, 8, 17, 12), + now: DateTime.utc(2026, 8, 23, 12), ); final pastBackstop = reduceAgentTurnStates( frames, - now: DateTime.utc(2026, 8, 17, 12, 0, 32), + now: DateTime.utc(2026, 8, 23, 12, 0, 32), ); expect(longRunning, hasLength(1)); expect( longRunning.single.livenessTimeout, - const Duration(hours: 24, seconds: 30), + const Duration(days: 7, seconds: 30), ); expect(pastBackstop, isEmpty); }); From 63a1829f699430e32b74f89a44839ec83e2fdbee Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 17 Aug 2026 14:26:54 +0100 Subject: [PATCH 04/17] Fix mobile agent activity boundaries Signed-off-by: kenny lopez --- .../agent_activity/active_agent_turns.dart | 25 +++++ .../agent_activity/agent_activity_sheet.dart | 2 +- .../composer_agent_activity_indicator.dart | 4 +- .../agent_activity/working_bots_provider.dart | 7 +- .../features/profile/user_cache_provider.dart | 92 +------------------ mobile/lib/features/profile/user_profile.dart | 50 +--------- .../shared/profile/user_cache_provider.dart | 91 ++++++++++++++++++ mobile/lib/shared/profile/user_profile.dart | 49 ++++++++++ .../active_agent_turns_test.dart | 35 +++++++ ...omposer_agent_activity_indicator_test.dart | 52 ++++++++++- .../working_bots_provider_test.dart | 60 ++++++++++-- 11 files changed, 315 insertions(+), 152 deletions(-) create mode 100644 mobile/lib/shared/profile/user_cache_provider.dart create mode 100644 mobile/lib/shared/profile/user_profile.dart diff --git a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart index d13a17dcaa6..0e178efa819 100644 --- a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart +++ b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart @@ -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>((ref) { ]; }); +/// Working turns plus recent explicit outcomes that remain actionable beside +/// the composer for a short, bounded window. +final composerAgentTurnStatesProvider = Provider>((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 composerAgentTurnStates( + Iterable 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); diff --git a/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart b/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart index 26a53f89038..e60fde07d01 100644 --- a/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart +++ b/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart @@ -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'; diff --git a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart index 48eb57bf9e8..59e17da6138 100644 --- a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart +++ b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.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'; diff --git a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart index 741535b32a3..7630f296932 100644 --- a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart +++ b/mobile/lib/features/channels/agent_activity/working_bots_provider.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 {}; - final activeTurns = ref.watch(activeAgentTurnsProvider); + final composerTurns = ref.watch(composerAgentTurnStatesProvider); final activeByAgent = {}; - 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, diff --git a/mobile/lib/features/profile/user_cache_provider.dart b/mobile/lib/features/profile/user_cache_provider.dart index e9363b80854..6ef805404af 100644 --- a/mobile/lib/features/profile/user_cache_provider.dart +++ b/mobile/lib/features/profile/user_cache_provider.dart @@ -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> { - final Set _pending = {}; - Timer? _batchTimer; - - @override - Map 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 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 _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.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.new, - ); +// Compatibility export for profile-feature callers. +export '../../shared/profile/user_cache_provider.dart'; diff --git a/mobile/lib/features/profile/user_profile.dart b/mobile/lib/features/profile/user_profile.dart index de58d955e3e..7f77f039416 100644 --- a/mobile/lib/features/profile/user_profile.dart +++ b/mobile/lib/features/profile/user_profile.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 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'; diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart new file mode 100644 index 00000000000..51498d14971 --- /dev/null +++ b/mobile/lib/shared/profile/user_cache_provider.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> { + final Set _pending = {}; + Timer? _batchTimer; + + @override + Map 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 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 _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.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.new, + ); diff --git a/mobile/lib/shared/profile/user_profile.dart b/mobile/lib/shared/profile/user_profile.dart new file mode 100644 index 00000000000..12d39aa91d9 --- /dev/null +++ b/mobile/lib/shared/profile/user_profile.dart @@ -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 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; +} diff --git a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart index 5cf022a6566..a76b3ef73f6 100644 --- a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart +++ b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart @@ -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({ diff --git a/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart b/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart index c87e5297588..510578ed392 100644 --- a/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart +++ b/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart @@ -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 { diff --git a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart index eed024eb1b1..4772c08a04a 100644 --- a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart +++ b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart @@ -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 []), + 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( From 3ff56f93a96e319fcc93794b9a81d30c61f52a03 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 17 Aug 2026 14:39:41 +0100 Subject: [PATCH 05/17] Fix mobile agent activity ordering Signed-off-by: kenny lopez --- .../agent_activity/observer_subscription.dart | 28 ++++++-- .../agent_activity/working_bots_provider.dart | 23 ++++++- .../observer_subscription_test.dart | 5 ++ .../working_bots_provider_test.dart | 65 ++++++++++++++++++- 4 files changed, 112 insertions(+), 9 deletions(-) diff --git a/mobile/lib/features/channels/agent_activity/observer_subscription.dart b/mobile/lib/features/channels/agent_activity/observer_subscription.dart index 2d6f4bd9a49..8a7c94bb962 100644 --- a/mobile/lib/features/channels/agent_activity/observer_subscription.dart +++ b/mobile/lib/features/channels/agent_activity/observer_subscription.dart @@ -267,11 +267,15 @@ class ObserverRelayNotifier extends Notifier { return [frame]; } - return [ + final innerFrames = [ for (final inner in events) - ObserverFrame.fromJson( - inner as Map, - receivedAt: receivedAt, + ObserverFrame.fromJson(inner as Map), + ]..sort(_compareObserverFrames); + return [ + for (var index = 0; index < innerFrames.length; index++) + _withReceivedAt( + innerFrames[index], + receivedAt.add(Duration(microseconds: index)), ), ]; } catch (error) { @@ -358,6 +362,22 @@ class ObserverRelayNotifier extends Notifier { if (tsA != tsB) return tsA.compareTo(tsB); return a.seq.compareTo(b.seq); } + + static ObserverFrame _withReceivedAt( + ObserverFrame frame, + DateTime receivedAt, + ) => ObserverFrame( + seq: frame.seq, + timestamp: frame.timestamp, + kind: frame.kind, + agentIndex: frame.agentIndex, + channelId: frame.channelId, + sessionId: frame.sessionId, + turnId: frame.turnId, + startedAt: frame.startedAt, + receivedAt: receivedAt, + payload: frame.payload, + ); } final observerRelayProvider = diff --git a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart index 7630f296932..c7a7fcaf02a 100644 --- a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart +++ b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart @@ -19,6 +19,9 @@ class WorkingAgentSignal { final String pubkey; final AgentWorkingSource source; final bool canViewActivity; + + /// Whether this signal represents live work rather than a retained outcome. + final bool isWorking; final String? turnId; final DateTime? startedAt; @@ -26,6 +29,7 @@ class WorkingAgentSignal { required this.pubkey, required this.source, required this.canViewActivity, + this.isWorking = true, this.turnId, this.startedAt, }); @@ -76,7 +80,7 @@ final composerActivityStateProvider = Provider.autoDispose if (turn.channelId != key.channelId) continue; final existing = activeByAgent[turn.agentPubkey]; if (existing == null || - turn.lastActivityAt.isAfter(existing.lastActivityAt)) { + _compareComposerTurnRecency(turn, existing) > 0) { activeByAgent[turn.agentPubkey] = turn; } } @@ -100,6 +104,7 @@ final composerActivityStateProvider = Provider.autoDispose pubkey: entry.key, source: AgentWorkingSource.observer, canViewActivity: canView(entry.key), + isWorking: turn.isWorking, turnId: turn.turnId, startedAt: turn.startedAt, ); @@ -120,6 +125,7 @@ final composerActivityStateProvider = Provider.autoDispose pubkey: pubkey, source: AgentWorkingSource.typing, canViewActivity: canView(pubkey), + isWorking: true, turnId: turn?.turnId, startedAt: turn?.startedAt, ); @@ -149,5 +155,18 @@ final workingBotPubkeysProvider = Provider.autoDispose threadHeadId: null, )), ); - return Set.unmodifiable(activity.agents.map((agent) => agent.pubkey)); + return Set.unmodifiable( + activity.agents + .where((agent) => agent.isWorking) + .map((agent) => agent.pubkey), + ); }); + +int _compareComposerTurnRecency(AgentTurnState a, AgentTurnState b) { + if (a.isWorking != b.isWorking) return a.isWorking ? 1 : -1; + final activity = a.lastActivityAt.compareTo(b.lastActivityAt); + if (activity != 0) return activity; + final started = a.startedAt.compareTo(b.startedAt); + if (started != 0) return started; + return a.turnId.compareTo(b.turnId); +} diff --git a/mobile/test/features/channels/agent_activity/observer_subscription_test.dart b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart index a9a55d080fe..2a775a109c0 100644 --- a/mobile/test/features/channels/agent_activity/observer_subscription_test.dart +++ b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart @@ -383,6 +383,11 @@ void main() { final relayState = container.read(observerRelayProvider); final frames = relayState.framesByAgent[agentKeychain.public]; expect(frames?.map((frame) => frame.seq), [1, 2]); + expect( + frames![0].receivedAt!.isBefore(frames[1].receivedAt!), + isTrue, + reason: 'batch receipt order must follow timestamp and sequence', + ); final state = container.read(observerSubscriptionProvider(key)); expect(state.connection, ObserverConnectionState.open); diff --git a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart index 4772c08a04a..fd264da3d9f 100644 --- a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart +++ b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart @@ -153,19 +153,78 @@ void main() { expect(state.agents.single.pubkey, 'agent-a'); expect(state.agents.single.turnId, failedTurn.turnId); expect(state.agents.single.canViewActivity, isTrue); + expect(state.agents.single.isWorking, isFalse); expect(state.humanTyping, isEmpty); + expect(container.read(workingBotPubkeysProvider(_channelId)), isEmpty); + }); + + test('prefers live work over retained terminal history', () { + final receiptAt = DateTime.utc(2026, 8, 16, 12, 0, 5); + final container = ProviderContainer( + overrides: [ + currentPubkeyProvider.overrideWith((ref) => 'owner'), + channelMembersProvider( + _channelId, + ).overrideWith((ref) async => const []), + 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([ + _turn( + 'agent-a', + phase: AgentTurnPhase.error, + turnId: 'turn-old', + startedAt: DateTime.utc(2026, 8, 16, 11, 59), + lastActivityAt: receiptAt, + ), + _turn( + 'agent-a', + turnId: 'turn-new', + startedAt: DateTime.utc(2026, 8, 16, 12, 0, 4), + lastActivityAt: receiptAt, + ), + ]), + ], + ); + addTearDown(container.dispose); + + final state = container.read( + composerActivityStateProvider(( + channelId: _channelId, + threadHeadId: null, + )), + ); + + expect(state.agents.single.turnId, 'turn-new'); + expect(state.agents.single.isWorking, isTrue); + expect(container.read(workingBotPubkeysProvider(_channelId)), {'agent-a'}); }); } AgentTurnState _turn( String pubkey, { AgentTurnPhase phase = AgentTurnPhase.working, + String? turnId, + DateTime? startedAt, + DateTime? lastActivityAt, }) => AgentTurnState( agentPubkey: pubkey, channelId: _channelId, - turnId: 'turn-$pubkey', - startedAt: DateTime.utc(2026, 8, 16, 12), - lastActivityAt: DateTime.utc(2026, 8, 16, 12), + turnId: turnId ?? 'turn-$pubkey', + startedAt: startedAt ?? DateTime.utc(2026, 8, 16, 12), + lastActivityAt: lastActivityAt ?? DateTime.utc(2026, 8, 16, 12), livenessTimeout: const Duration(seconds: 30), phase: phase, terminalAt: phase == AgentTurnPhase.working From 64ea2d430a12554409e7fffcc8933238049a6fa5 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 17 Aug 2026 15:13:37 +0100 Subject: [PATCH 06/17] Fix retained mobile activity labels Signed-off-by: kenny lopez --- .../composer_agent_activity_indicator.dart | 4 + .../agent_activity_controls.dart | 36 ++++++- .../agent_activity/working_bots_provider.dart | 11 ++- ...omposer_agent_activity_indicator_test.dart | 99 +++++++++++++++++++ .../working_bots_provider_test.dart | 50 ++++++++++ 5 files changed, 196 insertions(+), 4 deletions(-) diff --git a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart index 59e17da6138..8ab33933fa8 100644 --- a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart +++ b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart @@ -495,6 +495,7 @@ class ComposerAgentActivityIndicator extends HookConsumerWidget { observerState: observerState, transcript: transcript, profiles: profiles, + signals: viewableWorking, selectorAgents: selectorAgents, selectedAgent: effectiveSelectedAgent, nameFor: nameFor, @@ -619,6 +620,7 @@ class _InlineActivityPanel extends StatelessWidget { final ObserverState? observerState; final List transcript; final Map profiles; + final List signals; final List selectorAgents; final String selectedAgent; final String Function(String) nameFor; @@ -639,6 +641,7 @@ class _InlineActivityPanel extends StatelessWidget { required this.observerState, required this.transcript, required this.profiles, + required this.signals, required this.selectorAgents, required this.selectedAgent, required this.nameFor, @@ -655,6 +658,7 @@ class _InlineActivityPanel extends StatelessWidget { final headline = _selectedActivityHeadline(selectedTurn, transcript); final compactLabel = _agentActivityLabel( pubkeys: selectorAgents, + signals: signals, selectedTurn: selectedTurn, transcript: transcript, nameFor: nameFor, diff --git a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart index dfda077f48d..68b380ae14d 100644 --- a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart +++ b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart @@ -229,6 +229,7 @@ class _AgentActivityControl extends StatelessWidget { : [?selectedAgent]; final label = _agentActivityLabel( pubkeys: pubkeys, + signals: signals, selectedTurn: selectedTurn, transcript: transcript, nameFor: nameFor, @@ -393,16 +394,47 @@ _ActivityStatus _activityStatus(AgentTurnState? turn, bool isFallbackWorking) { ({String visibleLabel, String semanticLabel}) _agentActivityLabel({ required List pubkeys, + required List signals, required AgentTurnState? selectedTurn, required List transcript, required String Function(String) nameFor, required bool expanded, }) { final action = expanded ? 'Collapse live activity.' : 'Show live activity.'; + if (pubkeys.length > 1 && signals.length == pubkeys.length) { + final working = signals.where((signal) => signal.isWorking).length; + final errors = signals + .where((signal) => signal.phase == AgentTurnPhase.error) + .length; + final finished = signals.length - working - errors; + if (working == signals.length) { + return ( + visibleLabel: '${signals.length} agents are working…', + semanticLabel: '${signals.length} agents are working. $action', + ); + } + final visibleParts = [ + if (working > 0) '$working working', + if (finished > 0) '$finished finished', + if (errors > 0) '$errors ${errors == 1 ? 'error' : 'errors'}', + ]; + final semanticParts = [ + if (working > 0) + '$working ${working == 1 ? 'agent is' : 'agents are'} working', + if (finished > 0) + '$finished ${finished == 1 ? 'agent has' : 'agents have'} finished', + if (errors > 0) + '$errors ${errors == 1 ? 'agent stopped' : 'agents stopped'} with ${errors == 1 ? 'an error' : 'errors'}', + ]; + return ( + visibleLabel: visibleParts.join(' · '), + semanticLabel: '${semanticParts.join(', ')}. $action', + ); + } if (pubkeys.length > 1) { return ( - visibleLabel: '${pubkeys.length} agents are working…', - semanticLabel: '${pubkeys.length} agents are working. $action', + visibleLabel: '${pubkeys.length} agents have activity', + semanticLabel: '${pubkeys.length} agents have activity. $action', ); } final name = pubkeys.isEmpty ? 'Agent' : nameFor(pubkeys.single); diff --git a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart index c7a7fcaf02a..1a27d885a83 100644 --- a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart +++ b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart @@ -22,6 +22,9 @@ class WorkingAgentSignal { /// Whether this signal represents live work rather than a retained outcome. final bool isWorking; + + /// Known observer phase; typing-only signals have no lifecycle phase yet. + final AgentTurnPhase? phase; final String? turnId; final DateTime? startedAt; @@ -30,6 +33,7 @@ class WorkingAgentSignal { required this.source, required this.canViewActivity, this.isWorking = true, + this.phase, this.turnId, this.startedAt, }); @@ -105,6 +109,7 @@ final composerActivityStateProvider = Provider.autoDispose source: AgentWorkingSource.observer, canViewActivity: canView(entry.key), isWorking: turn.isWorking, + phase: turn.phase, turnId: turn.turnId, startedAt: turn.startedAt, ); @@ -121,13 +126,15 @@ final composerActivityStateProvider = Provider.autoDispose } if (signals.containsKey(pubkey)) continue; final turn = activeByAgent[pubkey]; + final liveTurn = turn?.isWorking == true ? turn : null; signals[pubkey] = WorkingAgentSignal( pubkey: pubkey, source: AgentWorkingSource.typing, canViewActivity: canView(pubkey), isWorking: true, - turnId: turn?.turnId, - startedAt: turn?.startedAt, + phase: liveTurn?.phase, + turnId: liveTurn?.turnId, + startedAt: liveTurn?.startedAt, ); } diff --git a/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart b/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart index 510578ed392..e8391689b8b 100644 --- a/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart +++ b/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart @@ -206,6 +206,105 @@ void main() { expect(find.text('Thinking'), findsOneWidget); }); + testWidgets('summarizes mixed working and terminal agent states', ( + tester, + ) async { + final container = ProviderContainer( + overrides: [ + composerActivityStateProvider(_scope).overrideWithValue( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: _agentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + phase: AgentTurnPhase.working, + turnId: _turnId, + ), + WorkingAgentSignal( + pubkey: _secondAgentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + isWorking: false, + phase: AgentTurnPhase.error, + turnId: _secondTurnId, + ), + ], + humanTyping: [], + ), + ), + agentTurnStatesProvider.overrideWithValue([ + _turn(AgentTurnPhase.working), + _turnFor( + pubkey: _secondAgentPubkey, + turnId: _secondTurnId, + phase: AgentTurnPhase.error, + ), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget(_app(container)); + await tester.pump(); + + expect(find.text('1 working · 1 error'), findsOneWidget); + expect(find.text('2 agents are working…'), findsNothing); + }); + + testWidgets('summarizes multiple retained terminal outcomes', (tester) async { + final container = ProviderContainer( + overrides: [ + composerActivityStateProvider(_scope).overrideWithValue( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: _agentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + isWorking: false, + phase: AgentTurnPhase.finished, + turnId: _turnId, + ), + WorkingAgentSignal( + pubkey: _secondAgentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + isWorking: false, + phase: AgentTurnPhase.error, + turnId: _secondTurnId, + ), + ], + humanTyping: [], + ), + ), + agentTurnStatesProvider.overrideWithValue([ + _turn(AgentTurnPhase.finished), + _turnFor( + pubkey: _secondAgentPubkey, + turnId: _secondTurnId, + phase: AgentTurnPhase.error, + ), + ]), + observerTurnSubscriptionProvider( + _turnKey, + ).overrideWithValue(_observerState), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget(_app(container)); + await tester.pump(); + + expect(find.text('1 finished · 1 error'), findsOneWidget); + expect(find.text('2 agents are working…'), findsNothing); + }); + testWidgets('reduced motion makes inline size changes immediate', ( tester, ) async { diff --git a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart index fd264da3d9f..7d42c78480b 100644 --- a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart +++ b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart @@ -114,6 +114,56 @@ void main() { }, ); + test('thread typing does not inherit a retained terminal turn', () { + final failedTurn = _turn('agent-a', phase: AgentTurnPhase.error); + final container = ProviderContainer( + overrides: [ + currentPubkeyProvider.overrideWith((ref) => 'owner'), + channelMembersProvider( + _channelId, + ).overrideWith((ref) async => const []), + channelTypingProvider(_channelId).overrideWith( + () => _FakeTypingNotifier(const [ + TypingEntry( + pubkey: 'agent-a', + threadHeadId: 'thread-1', + expiresAtMs: 9999999999999, + ), + ]), + ), + 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 signal = container + .read( + composerActivityStateProvider(( + channelId: _channelId, + threadHeadId: 'thread-1', + )), + ) + .agents + .single; + + expect(signal.source, AgentWorkingSource.typing); + expect(signal.isWorking, isTrue); + expect(signal.turnId, isNull); + expect(signal.startedAt, isNull); + }); + test('keeps a recent owned error reachable after typing stops', () { final failedTurn = _turn('agent-a', phase: AgentTurnPhase.error); final container = ProviderContainer( From 7da997a567e7217a8d405c9cd0c791724e044c84 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 17 Aug 2026 15:48:43 +0100 Subject: [PATCH 07/17] Scope mobile agent activity to threads Signed-off-by: kenny lopez --- crates/buzz-acp/src/acp.rs | 5 + crates/buzz-acp/src/lib.rs | 68 +++++++++-- crates/buzz-acp/src/observer.rs | 9 ++ crates/buzz-acp/src/pool.rs | 112 ++++++++++++++++-- .../features/agents/ui/agentSessionTypes.ts | 1 + docs/nips/NIP-AO.md | 15 ++- .../agent_activity/active_agent_turns.dart | 16 ++- .../composer_agent_activity_indicator.dart | 2 + .../agent_activity/observer_models.dart | 3 + .../agent_activity/observer_subscription.dart | 1 + .../agent_activity/working_bots_provider.dart | 41 ++++--- .../active_agent_turns_test.dart | 42 +++++++ .../observer_subscription_test.dart | 4 + .../working_bots_provider_test.dart | 53 +++++++++ 14 files changed, 323 insertions(+), 49 deletions(-) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index f04b8eeec0d..1c44e4afa01 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -577,6 +577,11 @@ impl AcpClient { self.observer_context = context; } + /// Return the observer metadata for the current turn. + pub(crate) fn observer_context(&self) -> ObserverContext { + self.observer_context.clone() + } + /// Return a clone of the observer handle, if attached. pub(crate) fn observer_handle(&self) -> Option { self.observer.clone() diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 7fd40b83db1..0e78ece9211 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -591,6 +591,7 @@ fn batch_envelope(events: &[observer::ObserverEvent]) -> observer::ObserverEvent kind: OBSERVER_BATCH_KIND.to_string(), agent_index: last.agent_index, channel_id: last.channel_id.clone(), + thread_head_id: last.thread_head_id.clone(), session_id: last.session_id.clone(), turn_id: last.turn_id.clone(), started_at: last.started_at.clone(), @@ -1259,6 +1260,7 @@ fn emit_project_owner_control_result( None, &observer::ObserverContext { channel_id: None, + thread_head_id: None, session_id: None, turn_id: None, started_at: None, @@ -1296,6 +1298,7 @@ fn handle_cancel_turn_control( None, &observer::ObserverContext { channel_id: Some(channel_id.to_string()), + thread_head_id: None, session_id: None, turn_id: None, started_at: None, @@ -1373,6 +1376,7 @@ fn handle_switch_model_control( None, &observer::ObserverContext { channel_id: Some(channel_id.to_string()), + thread_head_id: None, session_id: None, turn_id: None, started_at: None, @@ -3765,6 +3769,7 @@ fn dispatch_pending( pool::TaskMeta { agent_index, channel_id: Some(channel_id), + thread_head_id: typing_scope.root_event_id.clone(), turn_id, recoverable_batch, control_tx: Some(control_tx), @@ -4029,11 +4034,16 @@ fn handle_prompt_result( .to_string(); let harness_pid = std::process::id(); - let channel_id = match &result.source { - PromptSource::Channel(ch) => Some(*ch), - PromptSource::Heartbeat => None, - }; - let turn_id = result.turn_id.clone(); + let mut observer_context = result.agent.acp.observer_context(); + if observer_context.channel_id.is_none() { + observer_context.channel_id = match &result.source { + PromptSource::Channel(channel_id) => Some(channel_id.to_string()), + PromptSource::Heartbeat => None, + }; + } + if observer_context.turn_id.is_none() { + observer_context.turn_id = Some(result.turn_id.clone()); + } let emit_turn_error = |error_msg: &str, error_code: Option| { if let Some(ref observer) = observer { let mut payload = serde_json::json!({ @@ -4043,12 +4053,7 @@ fn handle_prompt_result( if let Some(code) = error_code { payload["code"] = serde_json::json!(code); } - observer.emit( - "turn_error", - Some(agent_index), - &observer::context_for(channel_id, None, Some(turn_id.clone())), - payload, - ); + observer.emit("turn_error", Some(agent_index), &observer_context, payload); } }; @@ -4275,7 +4280,13 @@ fn recover_panicked_agent( observer.emit( "agent_panic", Some(i), - &observer::context_for(meta.channel_id, None, Some(meta.turn_id)), + &observer::ObserverContext { + channel_id: meta.channel_id.map(|channel_id| channel_id.to_string()), + thread_head_id: meta.thread_head_id, + session_id: None, + turn_id: Some(meta.turn_id), + started_at: None, + }, serde_json::json!({ "outcome": "panic", "error": format!("Agent task panicked: {join_error}"), @@ -4401,6 +4412,7 @@ fn dispatch_heartbeat( pool::TaskMeta { agent_index, channel_id: None, + thread_head_id: None, turn_id, recoverable_batch: None, control_tx: None, @@ -5195,6 +5207,7 @@ mod owner_control_command_tests { pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + thread_head_id: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: Some(control_tx), @@ -5758,6 +5771,7 @@ mod observer_publish_queue_tests { kind: kind.to_string(), agent_index: Some(0), channel_id: channel.map(ToOwned::to_owned), + thread_head_id: None, session_id: Some("session-1".to_string()), turn_id: Some("turn-1".to_string()), started_at: None, @@ -6626,6 +6640,7 @@ mod observer_chunk_coalescer_tests { kind: "acp_read".to_string(), agent_index: Some(0), channel_id: Some("channel-1".to_string()), + thread_head_id: None, session_id: Some("session-1".to_string()), turn_id: Some("turn-1".to_string()), started_at: None, @@ -6654,6 +6669,7 @@ mod observer_chunk_coalescer_tests { kind: "turn_started".to_string(), agent_index: Some(0), channel_id: Some("channel-1".to_string()), + thread_head_id: None, session_id: Some("session-1".to_string()), turn_id: Some("turn-1".to_string()), started_at: None, @@ -7049,6 +7065,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + thread_head_id: None, turn_id: "test-turn-id".into(), recoverable_batch: None, control_tx: None, @@ -7121,6 +7138,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + thread_head_id: None, turn_id: "test-turn-id".into(), recoverable_batch: None, control_tx: None, @@ -7236,6 +7254,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + thread_head_id: None, turn_id: "test-turn-id".into(), recoverable_batch: None, control_tx: None, @@ -7288,7 +7307,11 @@ mod error_outcome_emission_tests { /// Drive one error outcome through `handle_prompt_result` and return how /// many `turn_error` events it emitted to the observer feed. async fn turn_errors_emitted_for(outcome: PromptOutcome) -> usize { - let agent = dummy_agent(0).await; + let mut agent = dummy_agent(0).await; + let mut observer_context = + crate::observer::context_for(None, None, Some("test-turn-id".to_string())); + observer_context.thread_head_id = Some("thread-1".to_string()); + agent.acp.set_observer_context(observer_context); let mut pool = AgentPool::from_slots(vec![None]); // `handle_prompt_result` asserts it removes exactly one in-flight task @@ -7301,6 +7324,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + thread_head_id: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7355,6 +7379,12 @@ mod error_outcome_emission_tests { .all(|event| event.turn_id.as_deref() == Some("test-turn-id")), "turn_error must retain the completed turn id" ); + assert!( + turn_errors + .iter() + .all(|event| event.thread_head_id.as_deref() == Some("thread-1")), + "turn_error must retain the completed turn thread" + ); turn_errors.len() } @@ -7378,6 +7408,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + thread_head_id: Some("thread-1".into()), turn_id: "panic-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7427,6 +7458,7 @@ mod error_outcome_emission_tests { Some(channel_id.to_string().as_str()) ); assert_eq!(panic.turn_id.as_deref(), Some("panic-turn-id")); + assert_eq!(panic.thread_head_id.as_deref(), Some("thread-1")); } #[tokio::test] @@ -7471,6 +7503,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + thread_head_id: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7563,6 +7596,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + thread_head_id: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7669,6 +7703,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + thread_head_id: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7746,6 +7781,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + thread_head_id: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7841,6 +7877,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + thread_head_id: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7958,6 +7995,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + thread_head_id: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8098,6 +8136,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + thread_head_id: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8287,6 +8326,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + thread_head_id: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8373,6 +8413,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + thread_head_id: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8437,6 +8478,7 @@ mod observer_payload_trim_tests { kind: kind.to_string(), agent_index: Some(0), channel_id: Some("11111111-1111-1111-1111-111111111111".to_string()), + thread_head_id: None, session_id: Some("sess-1".to_string()), turn_id: Some("turn-1".to_string()), started_at: None, diff --git a/crates/buzz-acp/src/observer.rs b/crates/buzz-acp/src/observer.rs index 7029e5af6d5..e4579524b96 100644 --- a/crates/buzz-acp/src/observer.rs +++ b/crates/buzz-acp/src/observer.rs @@ -22,6 +22,8 @@ const OBSERVER_BUFFER_CAP: usize = 1_000; pub struct ObserverContext { /// Buzz channel UUID for the current turn, when channel-scoped. pub channel_id: Option, + /// NIP-10 thread root for the current turn, when thread-scoped. + pub thread_head_id: Option, /// ACP session ID associated with the current turn, once known. pub session_id: Option, /// Local UUID for one prompt turn. @@ -67,6 +69,9 @@ pub struct ObserverEvent { pub agent_index: Option, /// Buzz channel UUID for channel-scoped events. pub channel_id: Option, + /// NIP-10 thread root for thread-scoped events. + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_head_id: Option, /// ACP session ID when known. pub session_id: Option, /// Local UUID for one prompt turn. @@ -114,6 +119,7 @@ impl ObserverHandle { kind: kind.into(), agent_index, channel_id: context.channel_id.clone(), + thread_head_id: context.thread_head_id.clone(), session_id: context.session_id.clone(), turn_id: context.turn_id.clone(), started_at: context.started_at.clone(), @@ -144,6 +150,7 @@ pub fn context_for( ) -> ObserverContext { ObserverContext { channel_id: channel_id.map(|id| id.to_string()), + thread_head_id: None, session_id, turn_id, started_at: None, @@ -153,12 +160,14 @@ pub fn context_for( /// Attach the authoritative start timestamp to every observer frame for a turn. pub fn context_for_turn( channel_id: Option, + thread_head_id: Option, session_id: Option, turn_id: String, started_at: String, ) -> ObserverContext { ObserverContext { channel_id: channel_id.map(|id| id.to_string()), + thread_head_id, session_id, turn_id: Some(turn_id), started_at: Some(started_at), diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 923d19a3e7d..45a1f2fbe07 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -59,6 +59,8 @@ pub struct SuccessfulSteerDelivery { pub struct TaskMeta { pub agent_index: usize, pub channel_id: Option, + /// NIP-10 thread root for panic recovery telemetry. + pub thread_head_id: Option, /// Identifies terminal events when the task panics before returning a result. pub turn_id: String, /// Clone of batch for Queue mode panic recovery. @@ -1462,6 +1464,12 @@ fn send_prompt_result( }); } +fn observer_thread_head_id(batch: Option<&FlushBatch>) -> Option { + batch + .and_then(|batch| batch.events.last()) + .and_then(|event| crate::queue::parse_thread_tags(&event.event).root_event_id) +} + /// Core async function spawned for each prompt. /// /// Lifecycle: @@ -1492,9 +1500,11 @@ pub async fn run_prompt_task( PromptSource::Channel(channel_id) => Some(*channel_id), PromptSource::Heartbeat => None, }; + let observer_thread_head_id = observer_thread_head_id(batch.as_ref()); let turn_started_at = chrono::Utc::now().to_rfc3339(); agent.acp.set_observer_context(observer::context_for_turn( observer_channel_id, + observer_thread_head_id.clone(), None, turn_id.clone(), turn_started_at.clone(), @@ -1523,6 +1533,7 @@ pub async fn run_prompt_task( agent.acp.observer_handle(), agent.acp.observer_agent_index(), observer_channel_id, + observer_thread_head_id.clone(), turn_id.clone(), ); @@ -1544,6 +1555,7 @@ pub async fn run_prompt_task( agent.acp.observer_agent_index(), observer::context_for_turn( observer_channel_id, + observer_thread_head_id.clone(), None, turn_id.clone(), turn_started_at.clone(), @@ -1814,6 +1826,7 @@ pub async fn run_prompt_task( }; agent.acp.set_observer_context(observer::context_for_turn( observer_channel_id, + observer_thread_head_id, Some(session_id.clone()), turn_id.clone(), turn_started_at, @@ -3979,6 +3992,7 @@ struct TurnCompletionGuard { observer: Option, agent_index: Option, channel_id: Option, + thread_head_id: Option, turn_id: String, } @@ -3987,12 +4001,14 @@ impl TurnCompletionGuard { observer: Option, agent_index: Option, channel_id: Option, + thread_head_id: Option, turn_id: String, ) -> Self { Self { observer, agent_index, channel_id, + thread_head_id, turn_id, } } @@ -4001,7 +4017,9 @@ impl TurnCompletionGuard { impl Drop for TurnCompletionGuard { fn drop(&mut self) { if let Some(observer) = self.observer.take() { - let context = observer::context_for(self.channel_id, None, Some(self.turn_id.clone())); + let mut context = + observer::context_for(self.channel_id, None, Some(self.turn_id.clone())); + context.thread_head_id = self.thread_head_id.clone(); observer.emit( "turn_completed", self.agent_index, @@ -6517,6 +6535,39 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" } } + #[test] + fn test_observer_thread_scope_uses_triggering_thread_root() { + let channel_id = Uuid::new_v4(); + let root = "a".repeat(64); + let root_tag = Tag::parse(vec![ + "e".to_string(), + root.clone(), + String::new(), + "root".to_string(), + ]) + .unwrap(); + let event = EventBuilder::new(Kind::Custom(9), "thread reply") + .tags([root_tag]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + let batch = FlushBatch { + channel_id, + events: vec![crate::queue::BatchEvent { + event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + + assert_eq!( + observer_thread_head_id(Some(&batch)).as_deref(), + Some(root.as_str()) + ); + assert_eq!(observer_thread_head_id(None), None); + } + #[test] fn test_requeue_cancelled_batch_maps_control_signal_to_cancel_reason() { let cases = [ @@ -6745,11 +6796,37 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" })) } + #[test] + fn test_completion_guard_preserves_thread_scope() { + let observer = observer::ObserverHandle::in_process(); + { + let _guard = TurnCompletionGuard::new( + Some(observer.clone()), + Some(0), + Some(Uuid::new_v4()), + Some("thread-1".into()), + "turn-1".into(), + ); + } + + let event = observer + .snapshot() + .into_iter() + .find(|event| event.kind == "turn_completed") + .expect("completion frame"); + assert_eq!(event.thread_head_id.as_deref(), Some("thread-1")); + } + #[tokio::test(start_paused = true)] async fn test_liveness_stops_before_completion_frame() { let observer = observer::ObserverHandle::in_process(); - let context = - observer::context_for_turn(None, None, "t-1".into(), "2026-07-14T21:00:00Z".into()); + let context = observer::context_for_turn( + None, + None, + None, + "t-1".into(), + "2026-07-14T21:00:00Z".into(), + ); let completion_context = observer::context_for(None, None, Some("t-1".into())); let completion_observer = observer.clone(); let completion_handle = tokio::spawn(async move { @@ -6802,7 +6879,13 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" async fn test_liveness_fires_until_guard_drops() { let observer = observer::ObserverHandle::in_process(); let started_at = "2026-07-14T21:00:00Z".to_string(); - let context = observer::context_for_turn(None, None, "t-1".into(), started_at.clone()); + let context = observer::context_for_turn( + None, + Some("thread-1".into()), + None, + "t-1".into(), + started_at.clone(), + ); let state = open_liveness_state(); let guard = LivenessGuard::new( tokio::spawn(run_turn_liveness( @@ -6832,6 +6915,9 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" assert!(pings .iter() .all(|event| event.started_at.as_deref() == Some(&started_at))); + assert!(pings + .iter() + .all(|event| event.thread_head_id.as_deref() == Some("thread-1"))); assert!(pings .iter() .all(|event| { event.payload == serde_json::json!({ "livenessIntervalSecs": 10 }) })); @@ -6853,8 +6939,13 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" #[tokio::test(start_paused = true)] async fn test_liveness_backfills_session_id_after_resolution() { let observer = observer::ObserverHandle::in_process(); - let context = - observer::context_for_turn(None, None, "t-1".into(), "2026-07-14T21:00:00Z".into()); + let context = observer::context_for_turn( + None, + None, + None, + "t-1".into(), + "2026-07-14T21:00:00Z".into(), + ); let state = open_liveness_state(); let guard = LivenessGuard::new( tokio::spawn(run_turn_liveness( @@ -6953,8 +7044,13 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" #[tokio::test(start_paused = true)] async fn test_liveness_emits_nothing_once_closed_flag_is_set() { let observer = observer::ObserverHandle::in_process(); - let context = - observer::context_for_turn(None, None, "t-1".into(), "2026-07-14T21:00:00Z".into()); + let context = observer::context_for_turn( + None, + None, + None, + "t-1".into(), + "2026-07-14T21:00:00Z".into(), + ); // Set directly, bypassing `LivenessGuard` — isolates the read side of // the contract: the check under the lock must gate the emit on its own. let state = Arc::new(Mutex::new(LivenessState { diff --git a/desktop/src/features/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts index 578f98076cd..cb73922d9f4 100644 --- a/desktop/src/features/agents/ui/agentSessionTypes.ts +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -6,6 +6,7 @@ export type ObserverEvent = { kind: string; agentIndex: number | null; channelId: string | null; + threadHeadId?: string | null; sessionId: string | null; turnId: string | null; startedAt?: string | null; diff --git a/docs/nips/NIP-AO.md b/docs/nips/NIP-AO.md index 36adea04871..340506986d4 100644 --- a/docs/nips/NIP-AO.md +++ b/docs/nips/NIP-AO.md @@ -85,21 +85,23 @@ The `content` field decrypts to an `ObserverEvent` JSON object: "kind": "", "agentIndex": | null, "channelId": "" | null, + "threadHeadId": "" | null, "sessionId": "" | null, "turnId": "" | null, "payload": { ... } } ``` -`seq`, `timestamp`, `kind`, and `payload` are REQUIRED. `agentIndex`, `channelId`, `sessionId`, -and `turnId` are OPTIONAL — they MAY be `null` when the value is not yet known -(e.g., `sessionId` before session establishment). Clients MUST handle `null` values -gracefully. +`seq`, `timestamp`, `kind`, and `payload` are REQUIRED. `agentIndex`, `channelId`, +`threadHeadId`, `sessionId`, and `turnId` are OPTIONAL — they MAY be `null` when +the value is not yet known (e.g., `sessionId` before session establishment). +Clients MUST handle `null` values gracefully. `seq` is monotonically increasing per session (drop detection). `timestamp` is an RFC 3339 datetime string with sub-second precision (e.g., `"2026-04-29T12:00:41.500Z"`). -`agentIndex` identifies the agent in multi-agent scenarios. `sessionId`/`turnId` -correlate frames across a session and turn. `payload` is kind-specific (MAY be `{}`). +`agentIndex` identifies the agent in multi-agent scenarios. `threadHeadId` is the +NIP-10 root event ID for a thread-scoped turn. `sessionId`/`turnId` correlate +frames across a session and turn. `payload` is kind-specific (MAY be `{}`). Unknown `kind` values MUST be ignored. ### Frame Kinds @@ -254,6 +256,7 @@ of decrypted payloads and MUST NOT log it at INFO level or above. "kind": "acp_write", "agentIndex": 0, "channelId": "52a85618-0f8f-4542-94ec-599e6e1c6f2e", + "threadHeadId": "9f0d...c2a1", "sessionId": "a1b2c3d4", "turnId": "e5f6g7h8", "payload": { diff --git a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart index 0e178efa819..f34181f7178 100644 --- a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart +++ b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart @@ -21,6 +21,7 @@ enum AgentTurnPhase { working, finished, error } class AgentTurnState { final String agentPubkey; final String channelId; + final String? threadHeadId; final String turnId; final DateTime startedAt; final DateTime lastActivityAt; @@ -33,6 +34,7 @@ class AgentTurnState { const AgentTurnState({ required this.agentPubkey, required this.channelId, + this.threadHeadId, required this.turnId, required this.startedAt, required this.lastActivityAt, @@ -49,6 +51,7 @@ class AgentTurnState { AgentTurnState( agentPubkey: agentPubkey, channelId: channelId, + threadHeadId: threadHeadId, turnId: turnId, startedAt: startedAt, lastActivityAt: at, @@ -64,6 +67,7 @@ class AgentTurnState { }) => AgentTurnState( agentPubkey: agentPubkey, channelId: channelId, + threadHeadId: threadHeadId, turnId: turnId, startedAt: startedAt, lastActivityAt: at, @@ -100,6 +104,7 @@ List reduceAgentTurnStates( turnsById[turnId] = AgentTurnState( agentPubkey: agentPubkey, channelId: channelId, + threadHeadId: frame.threadHeadId, turnId: turnId, startedAt: _safeStartedAt(frame, frameAt), lastActivityAt: frameAt, @@ -134,6 +139,7 @@ List reduceAgentTurnStates( AgentTurnState( agentPubkey: agentPubkey, channelId: channelId, + threadHeadId: frame.threadHeadId, turnId: turnId, startedAt: _safeStartedAt(frame, frameAt), lastActivityAt: frameAt, @@ -148,7 +154,12 @@ List reduceAgentTurnStates( final channelId = frame.channelId; if (channelId == null) continue; final matching = turnsById.values - .where((turn) => turn.channelId == channelId && turn.isWorking) + .where( + (turn) => + turn.channelId == channelId && + turn.threadHeadId == frame.threadHeadId && + turn.isWorking, + ) .fold( null, (latest, turn) => @@ -189,6 +200,7 @@ List reduceAgentTurnStates( turnsById[turnId] = AgentTurnState( agentPubkey: agentPubkey, channelId: channelId, + threadHeadId: frame.threadHeadId, turnId: turnId, startedAt: _safeStartedAt(frame, frameAt), lastActivityAt: frameAt, @@ -222,6 +234,7 @@ AgentTurnState? latestAgentTurnState( Iterable states, { required String agentPubkey, required String channelId, + required String? threadHeadId, String? turnId, }) { final normalizedAgent = agentPubkey.toLowerCase(); @@ -229,6 +242,7 @@ AgentTurnState? latestAgentTurnState( for (final state in states) { if (state.agentPubkey != normalizedAgent || state.channelId != channelId || + state.threadHeadId != threadHeadId || (turnId != null && state.turnId != turnId)) { continue; } diff --git a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart index 8ab33933fa8..b4c195d58f4 100644 --- a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart +++ b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart @@ -252,6 +252,7 @@ class ComposerAgentActivityIndicator extends HookConsumerWidget { turnStates, agentPubkey: effectiveSelectedAgent, channelId: channelId, + threadHeadId: threadHeadId, turnId: effectiveTurnId, ); final ObserverState? observerState; @@ -358,6 +359,7 @@ class ComposerAgentActivityIndicator extends HookConsumerWidget { turnStates, agentPubkey: pubkey, channelId: channelId, + threadHeadId: threadHeadId, ); selectedAgent.value = pubkey; pinnedTurnId.value = signal?.turnId ?? latestTurn?.turnId; diff --git a/mobile/lib/features/channels/agent_activity/observer_models.dart b/mobile/lib/features/channels/agent_activity/observer_models.dart index 4ad8f5f39dc..53c47882237 100644 --- a/mobile/lib/features/channels/agent_activity/observer_models.dart +++ b/mobile/lib/features/channels/agent_activity/observer_models.dart @@ -14,6 +14,7 @@ class ObserverFrame { final String kind; final int? agentIndex; final String? channelId; + final String? threadHeadId; final String? sessionId; final String? turnId; final String? startedAt; @@ -26,6 +27,7 @@ class ObserverFrame { required this.kind, this.agentIndex, this.channelId, + this.threadHeadId, this.sessionId, this.turnId, this.startedAt, @@ -42,6 +44,7 @@ class ObserverFrame { kind: json['kind'] as String? ?? '', agentIndex: json['agentIndex'] as int?, channelId: json['channelId'] as String?, + threadHeadId: json['threadHeadId'] as String?, sessionId: json['sessionId'] as String?, turnId: json['turnId'] as String?, startedAt: json['startedAt'] as String?, diff --git a/mobile/lib/features/channels/agent_activity/observer_subscription.dart b/mobile/lib/features/channels/agent_activity/observer_subscription.dart index 8a7c94bb962..7cb582ecc2b 100644 --- a/mobile/lib/features/channels/agent_activity/observer_subscription.dart +++ b/mobile/lib/features/channels/agent_activity/observer_subscription.dart @@ -372,6 +372,7 @@ class ObserverRelayNotifier extends Notifier { kind: frame.kind, agentIndex: frame.agentIndex, channelId: frame.channelId, + threadHeadId: frame.threadHeadId, sessionId: frame.sessionId, turnId: frame.turnId, startedAt: frame.startedAt, diff --git a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart index 1a27d885a83..f3cdb6c09bb 100644 --- a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart +++ b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart @@ -51,9 +51,9 @@ class ComposerActivityState { }); } -/// Unified composer state. Observer activity is authoritative in channels; -/// kind:20002 typing fills gaps and is the only thread-scoped signal because -/// observer frames do not carry a thread id. +/// Unified composer state. Scoped observer activity is authoritative; +/// kind:20002 typing fills gaps, including for legacy observer frames that do +/// not carry a thread id. final composerActivityStateProvider = Provider.autoDispose .family((ref, key) { final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase(); @@ -81,7 +81,10 @@ final composerActivityStateProvider = Provider.autoDispose final composerTurns = ref.watch(composerAgentTurnStatesProvider); final activeByAgent = {}; for (final turn in composerTurns) { - if (turn.channelId != key.channelId) continue; + if (turn.channelId != key.channelId || + turn.threadHeadId != key.threadHeadId) { + continue; + } final existing = activeByAgent[turn.agentPubkey]; if (existing == null || _compareComposerTurnRecency(turn, existing) > 0) { @@ -97,23 +100,19 @@ final composerActivityStateProvider = Provider.autoDispose } final signals = {}; - // A channel can trust observer activity directly. A thread cannot: the - // observer protocol has no thread id, so a thread requires typing first. - if (key.threadHeadId == null) { - 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, - canViewActivity: canView(entry.key), - isWorking: turn.isWorking, - phase: turn.phase, - turnId: turn.turnId, - startedAt: turn.startedAt, - ); - } + 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, + canViewActivity: canView(entry.key), + isWorking: turn.isWorking, + phase: turn.phase, + turnId: turn.turnId, + startedAt: turn.startedAt, + ); } final humans = []; diff --git a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart index a76b3ef73f6..a2b6b95b277 100644 --- a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart +++ b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart @@ -10,6 +10,7 @@ void main() { seq: 1, second: 1, kind: 'turn_started', + threadHeadId: 'thread-1', receivedSecond: 10, payload: { 'triggeringEventIds': ['message-1'], @@ -22,6 +23,7 @@ void main() { expect(turns, hasLength(1)); expect(turns.single.agentPubkey, 'agent-a'); expect(turns.single.phase, AgentTurnPhase.working); + expect(turns.single.threadHeadId, 'thread-1'); expect(turns.single.triggeringEventId, 'message-1'); expect(turns.single.lastActivityAt, DateTime.utc(2026, 8, 16, 12, 0, 20)); }); @@ -204,6 +206,44 @@ void main() { expect(turns.single.errorMessage, 'Process exited'); }); + test('terminal without a turn id stays within its observer thread scope', () { + final turns = reduceAgentTurnStates({ + 'agent-a': [ + _frame( + seq: 1, + second: 1, + kind: 'turn_started', + turnId: 'turn-a', + threadHeadId: 'thread-a', + ), + _frame( + seq: 2, + second: 2, + kind: 'turn_started', + turnId: 'turn-b', + threadHeadId: 'thread-b', + ), + _frame( + seq: 3, + second: 3, + kind: 'agent_panic', + turnId: null, + threadHeadId: 'thread-b', + payload: {'error': 'Process exited'}, + ), + ], + }, now: DateTime.utc(2026, 8, 16, 12, 0, 20)); + + expect( + turns.singleWhere((turn) => turn.turnId == 'turn-a').phase, + AgentTurnPhase.working, + ); + expect( + turns.singleWhere((turn) => turn.turnId == 'turn-b').phase, + AgentTurnPhase.error, + ); + }); + test( 'retains terminal outcomes beside the composer for a bounded window', () { @@ -246,6 +286,7 @@ ObserverFrame _frame({ required String kind, String? turnId = 'turn-1', String channelId = 'channel-1', + String? threadHeadId, int? receivedSecond, String? startedAt, dynamic payload = const {}, @@ -255,6 +296,7 @@ ObserverFrame _frame({ timestamp: DateTime.utc(2026, 8, 16, 12, 0, second).toIso8601String(), kind: kind, channelId: channelId, + threadHeadId: threadHeadId, turnId: turnId, startedAt: startedAt, receivedAt: receivedSecond == null diff --git a/mobile/test/features/channels/agent_activity/observer_subscription_test.dart b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart index 2a775a109c0..e343aaf75fb 100644 --- a/mobile/test/features/channels/agent_activity/observer_subscription_test.dart +++ b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart @@ -361,6 +361,7 @@ void main() { final earlierFrame = _observerFrameJson( seq: 1, channelId: channelId, + threadHeadId: 'thread-1', turnId: 'turn-1', ); relaySession.emit( @@ -388,6 +389,7 @@ void main() { isTrue, reason: 'batch receipt order must follow timestamp and sequence', ); + expect(frames[0].threadHeadId, 'thread-1'); final state = container.read(observerSubscriptionProvider(key)); expect(state.connection, ObserverConnectionState.open); @@ -594,12 +596,14 @@ ObserverFrame _turnMessageFrame({ Map _observerFrameJson({ required int seq, required String channelId, + String? threadHeadId, required String turnId, }) => { 'seq': seq, 'timestamp': '2026-04-30T12:00:0$seq.000Z', 'kind': 'turn_started', 'channelId': channelId, + 'threadHeadId': ?threadHeadId, 'turnId': turnId, 'payload': { 'triggeringEventIds': ['$seq'], diff --git a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart index 7d42c78480b..7d37c95be2c 100644 --- a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart +++ b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart @@ -164,6 +164,57 @@ void main() { expect(signal.startedAt, isNull); }); + test( + 'keeps a scoped terminal outcome reachable after thread typing stops', + () { + final failedTurn = _turn( + 'agent-a', + phase: AgentTurnPhase.error, + threadHeadId: 'thread-1', + ); + final container = ProviderContainer( + overrides: [ + currentPubkeyProvider.overrideWith((ref) => 'owner'), + channelMembersProvider( + _channelId, + ).overrideWith((ref) async => const []), + 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 signal = container + .read( + composerActivityStateProvider(( + channelId: _channelId, + threadHeadId: 'thread-1', + )), + ) + .agents + .single; + + expect(signal.source, AgentWorkingSource.observer); + expect(signal.isWorking, isFalse); + expect(signal.phase, AgentTurnPhase.error); + expect(signal.turnId, failedTurn.turnId); + }, + ); + test('keeps a recent owned error reachable after typing stops', () { final failedTurn = _turn('agent-a', phase: AgentTurnPhase.error); final container = ProviderContainer( @@ -267,11 +318,13 @@ AgentTurnState _turn( String pubkey, { AgentTurnPhase phase = AgentTurnPhase.working, String? turnId, + String? threadHeadId, DateTime? startedAt, DateTime? lastActivityAt, }) => AgentTurnState( agentPubkey: pubkey, channelId: _channelId, + threadHeadId: threadHeadId, turnId: turnId ?? 'turn-$pubkey', startedAt: startedAt ?? DateTime.utc(2026, 8, 16, 12), lastActivityAt: lastActivityAt ?? DateTime.utc(2026, 8, 16, 12), From d5b96f9a27652d20ae7af181571c92e931e98a7d Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 17 Aug 2026 17:20:02 +0100 Subject: [PATCH 08/17] Preserve agent activity across thread scopes Signed-off-by: kenny lopez --- .../agent_activity/working_bots_provider.dart | 33 ++++++++----- .../features/channels/thread_detail_page.dart | 4 +- .../working_bots_provider_test.dart | 46 +++++++++++++++++++ .../channels/channel_detail_page_test.dart | 6 +++ 4 files changed, 76 insertions(+), 13 deletions(-) diff --git a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart index f3cdb6c09bb..fbafe860d8c 100644 --- a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart +++ b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart @@ -155,17 +155,28 @@ final composerActivityStateProvider = Provider.autoDispose /// Agent pubkeys working in a channel, for the members badge and sheet. final workingBotPubkeysProvider = Provider.autoDispose .family, String>((ref, channelId) { - final activity = ref.watch( - composerActivityStateProvider(( - channelId: channelId, - threadHeadId: null, - )), - ); - return Set.unmodifiable( - activity.agents - .where((agent) => agent.isWorking) - .map((agent) => agent.pubkey), - ); + final threadScopes = { + null, + for (final entry in ref.watch(channelTypingProvider(channelId))) + entry.threadHeadId, + for (final turn in ref.watch(composerAgentTurnStatesProvider)) + if (turn.channelId == channelId) turn.threadHeadId, + }; + final working = {}; + for (final threadHeadId in threadScopes) { + final activity = ref.watch( + composerActivityStateProvider(( + channelId: channelId, + threadHeadId: threadHeadId, + )), + ); + working.addAll( + activity.agents + .where((agent) => agent.isWorking) + .map((agent) => agent.pubkey), + ); + } + return Set.unmodifiable(working); }); int _compareComposerTurnRecency(AgentTurnState a, AgentTurnState b) { diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index d3e328b268f..a3a771f16db 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -724,7 +724,7 @@ class ThreadDetailPage extends HookConsumerWidget { if (!isMember || isArchived) _ThreadTypingIndicator( channelId: channelId, - threadHeadId: threadHead.id, + threadHeadId: effectiveRootId, animated: false, overlayTopBoundary: frostedAppBarHeight(context), ), @@ -751,7 +751,7 @@ class ThreadDetailPage extends HookConsumerWidget { restoreComposerFocus, ) => _ThreadTypingIndicator( channelId: channelId, - threadHeadId: threadHead.id, + threadHeadId: effectiveRootId, horizontalInset: 0, overlayTopBoundary: frostedAppBarHeight(context), compactWidthFactor: 0.85, diff --git a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart index 7d37c95be2c..9e3901f9ffc 100644 --- a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart +++ b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart @@ -259,6 +259,52 @@ void main() { expect(container.read(workingBotPubkeysProvider(_channelId)), isEmpty); }); + test('channel working set includes agents from every thread scope', () { + final container = ProviderContainer( + overrides: [ + currentPubkeyProvider.overrideWith((ref) => 'owner'), + channelMembersProvider( + _channelId, + ).overrideWith((ref) async => const []), + channelTypingProvider(_channelId).overrideWith( + () => _FakeTypingNotifier(const [ + TypingEntry( + pubkey: 'agent-a', + threadHeadId: 'thread-1', + expiresAtMs: 9999999999999, + ), + TypingEntry( + pubkey: 'human', + threadHeadId: 'thread-3', + expiresAtMs: 9999999999999, + ), + ]), + ), + agentMentionPubkeysProvider( + _channelId, + ).overrideWith((ref) => const {'agent-a', 'agent-b'}), + agentOwnersProvider.overrideWithValue( + const AsyncData({'agent-a': 'owner', 'agent-b': 'owner'}), + ), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + observerRelayProvider.overrideWith( + () => _FakeObserverRelayNotifier({ + 'agent-b': [_observerFrame('agent-b')], + }), + ), + composerAgentTurnStatesProvider.overrideWithValue([ + _turn('agent-b', threadHeadId: 'thread-2'), + ]), + ], + ); + addTearDown(container.dispose); + + expect(container.read(workingBotPubkeysProvider(_channelId)), { + 'agent-a', + 'agent-b', + }); + }); + test('prefers live work over retained terminal history', () { final receiptAt = DateTime.utc(2026, 8, 16, 12, 0, 5); final container = ProviderContainer( diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index df44a0efb57..6d49910775b 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -14,6 +14,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/agent_activity/active_agent_turns.dart'; +import 'package:buzz/features/channels/agent_activity/composer_agent_activity_indicator.dart'; 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'; @@ -4561,6 +4562,11 @@ void main() { expect(threadPage.threadHead.id, 'parent'); expect(threadPage.initialMessageId, 'target'); + final activityIndicator = tester.widget( + find.byType(ComposerAgentActivityIndicator), + ); + expect(activityIndicator.threadHeadId, 'root'); + final highlighted = tester.widget( find.byKey(const ValueKey('thread-message-target')), ); From e7eb73bf53e4103203e2347779ae6441a5cbc6f7 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 17 Aug 2026 18:48:34 +0100 Subject: [PATCH 09/17] Preserve reconstructed and cancelled turn states Signed-off-by: kenny lopez --- crates/buzz-acp/src/pool.rs | 47 +++++++++++--- docs/nips/NIP-AO.md | 10 +++ .../agent_activity/active_agent_turns.dart | 33 ++++++++-- .../composer_agent_activity_indicator.dart | 11 +++- .../agent_activity_controls.dart | 12 +++- .../active_agent_turns_test.dart | 62 +++++++++++++++++++ ...omposer_agent_activity_indicator_test.dart | 47 ++++++++++++++ 7 files changed, 204 insertions(+), 18 deletions(-) diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 45a1f2fbe07..2fe32b538ea 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -1529,7 +1529,7 @@ pub async fn run_prompt_task( // metadata now, before the agent is moved into PromptResult. It must be // declared before `liveness_guard`: Rust drops locals in reverse order, so // liveness is aborted before completion makes the turn terminal. - let _turn_guard = TurnCompletionGuard::new( + let mut turn_guard = TurnCompletionGuard::new( agent.acp.observer_handle(), agent.acp.observer_agent_index(), observer_channel_id, @@ -2245,6 +2245,7 @@ pub async fn run_prompt_task( { Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); + turn_guard.mark_cancelled(); agent.state.invalidate(&source); let retry_batch = requeue_cancelled_batch(&ctx, control_signal, batch); @@ -2376,6 +2377,9 @@ pub async fn run_prompt_task( match prompt_result { Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); + if matches!(&stop_reason, StopReason::Cancelled) { + turn_guard.mark_cancelled(); + } if let PromptSource::Channel(cid) = &source { let standing_sent = !agent.has_system_prompt_support(); @@ -3994,6 +3998,7 @@ struct TurnCompletionGuard { channel_id: Option, thread_head_id: Option, turn_id: String, + cancelled: bool, } impl TurnCompletionGuard { @@ -4010,8 +4015,13 @@ impl TurnCompletionGuard { channel_id, thread_head_id, turn_id, + cancelled: false, } } + + fn mark_cancelled(&mut self) { + self.cancelled = true; + } } impl Drop for TurnCompletionGuard { @@ -4020,12 +4030,12 @@ impl Drop for TurnCompletionGuard { let mut context = observer::context_for(self.channel_id, None, Some(self.turn_id.clone())); context.thread_head_id = self.thread_head_id.clone(); - observer.emit( - "turn_completed", - self.agent_index, - &context, - serde_json::json!({}), - ); + let payload = if self.cancelled { + serde_json::json!({ "outcome": "cancelled" }) + } else { + serde_json::json!({}) + }; + observer.emit("turn_completed", self.agent_index, &context, payload); } } } @@ -6815,6 +6825,29 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" .find(|event| event.kind == "turn_completed") .expect("completion frame"); assert_eq!(event.thread_head_id.as_deref(), Some("thread-1")); + assert_eq!(event.payload, serde_json::json!({})); + } + + #[test] + fn test_completion_guard_reports_cancelled_outcome() { + let observer = observer::ObserverHandle::in_process(); + { + let mut guard = TurnCompletionGuard::new( + Some(observer.clone()), + Some(0), + Some(Uuid::new_v4()), + Some("thread-1".into()), + "turn-1".into(), + ); + guard.mark_cancelled(); + } + + let event = observer + .snapshot() + .into_iter() + .find(|event| event.kind == "turn_completed") + .expect("completion frame"); + assert_eq!(event.payload, serde_json::json!({ "outcome": "cancelled" })); } #[tokio::test(start_paused = true)] diff --git a/docs/nips/NIP-AO.md b/docs/nips/NIP-AO.md index 340506986d4..553f8d5211c 100644 --- a/docs/nips/NIP-AO.md +++ b/docs/nips/NIP-AO.md @@ -111,8 +111,18 @@ Unknown `kind` values MUST be ignored. | `acp_read` | Inbound ACP protocol frame (model → harness) | | `acp_write` | Outbound ACP protocol frame (harness → model) | | `turn_started` | A new agent turn has begun | +| `turn_liveness` | The current turn is still active | +| `turn_completed` | The current turn ended, optionally with an outcome | +| `turn_error` | The current turn stopped with an error | +| `agent_panic` | The agent task terminated unexpectedly | | `session_resolved` | Session completed or terminated | +`turn_completed.payload.outcome` MAY be `"cancelled"` when the turn was +explicitly cancelled. A missing outcome retains the legacy completion semantics +and SHOULD be treated as finished unless a more specific terminal frame exists. +`turn_started` and `turn_liveness` carry `livenessIntervalSecs`; other mid-turn +frames are not required to repeat it. + ### Control (`frame=control`) The `content` field decrypts to: diff --git a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart index f34181f7178..7de03414ae6 100644 --- a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart +++ b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart @@ -14,7 +14,7 @@ const _maximumTurnDuration = Duration(days: 7); const _livenessTimeoutSlack = Duration(seconds: 30); /// Lifecycle state reconstructed from owner-scoped observer frames. -enum AgentTurnPhase { working, finished, error } +enum AgentTurnPhase { working, finished, cancelled, error } /// One observed agent turn, including its explicit terminal outcome when known. @immutable @@ -116,7 +116,7 @@ List reduceAgentTurnStates( case 'turn_error': case 'agent_panic': final terminalPhase = frame.kind == 'turn_completed' - ? AgentTurnPhase.finished + ? _completionPhase(frame.payload) : AgentTurnPhase.error; final turnId = frame.turnId; if (turnId != null) { @@ -204,7 +204,13 @@ List reduceAgentTurnStates( turnId: turnId, startedAt: _safeStartedAt(frame, frameAt), lastActivityAt: frameAt, - livenessTimeout: _livenessTimeout(frame.payload), + // A live-only subscription can join after turn_started. Ordinary + // ACP frames do not repeat the configured cadence, so absence here + // means unknown rather than the legacy 30-second default. + livenessTimeout: _livenessTimeout( + frame.payload, + missingFallback: _maximumTurnDuration + _livenessTimeoutSlack, + ), phase: AgentTurnPhase.working, ); } @@ -214,7 +220,12 @@ List reduceAgentTurnStates( turnsById.values.where( (turn) => !turn.isWorking || - now.difference(turn.lastActivityAt) <= turn.livenessTimeout, + (now.difference(turn.lastActivityAt) <= turn.livenessTimeout && + !now.isAfter( + turn.startedAt.add( + _maximumTurnDuration + _livenessTimeoutSlack, + ), + )), ), ); } @@ -334,9 +345,19 @@ String? _turnError(dynamic payload) { return error is String && error.trim().isNotEmpty ? error.trim() : null; } -Duration _livenessTimeout(dynamic payload) { +AgentTurnPhase _completionPhase(dynamic payload) { + if (payload is Map && payload['outcome'] == 'cancelled') { + return AgentTurnPhase.cancelled; + } + return AgentTurnPhase.finished; +} + +Duration _livenessTimeout( + dynamic payload, { + Duration missingFallback = _defaultLivenessTimeout, +}) { final rawInterval = payload is Map ? payload['livenessIntervalSecs'] : null; - if (rawInterval is! num) return _defaultLivenessTimeout; + if (rawInterval is! num) return missingFallback; final intervalSeconds = rawInterval.toInt(); if (intervalSeconds <= 0) { diff --git a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart index b4c195d58f4..44ac2c3fdb7 100644 --- a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart +++ b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart @@ -875,12 +875,14 @@ class _ActivityStatusBadge extends StatelessWidget { final color = switch (status) { _ActivityStatus.working => context.appColors.success, _ActivityStatus.finished => context.colors.onSurfaceVariant, + _ActivityStatus.cancelled => context.colors.onSurfaceVariant, _ActivityStatus.error => context.colors.error, _ActivityStatus.waiting => context.appColors.warning, }; final label = switch (status) { _ActivityStatus.working => 'Working', _ActivityStatus.finished => 'Finished', + _ActivityStatus.cancelled => 'Cancelled', _ActivityStatus.error => 'Error', _ActivityStatus.waiting => 'Waiting', }; @@ -949,9 +951,12 @@ class _ActivityEmptyState extends StatelessWidget { ), const SizedBox(height: Grid.half), Text( - status == _ActivityStatus.finished - ? 'No activity rows were captured for this turn.' - : 'Waiting for live activity…', + switch (status) { + _ActivityStatus.finished => + 'No activity rows were captured for this turn.', + _ActivityStatus.cancelled => 'This turn was cancelled.', + _ => 'Waiting for live activity…', + }, style: context.textTheme.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, ), diff --git a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart index 68b380ae14d..691a109ba37 100644 --- a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart +++ b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart @@ -380,12 +380,13 @@ class _AgentAvatarStack extends StatelessWidget { } } -enum _ActivityStatus { working, finished, error, waiting } +enum _ActivityStatus { working, finished, cancelled, error, waiting } _ActivityStatus _activityStatus(AgentTurnState? turn, bool isFallbackWorking) { return switch (turn?.phase) { AgentTurnPhase.working => _ActivityStatus.working, AgentTurnPhase.finished => _ActivityStatus.finished, + AgentTurnPhase.cancelled => _ActivityStatus.cancelled, AgentTurnPhase.error => _ActivityStatus.error, null => isFallbackWorking ? _ActivityStatus.working : _ActivityStatus.waiting, @@ -406,7 +407,10 @@ _ActivityStatus _activityStatus(AgentTurnState? turn, bool isFallbackWorking) { final errors = signals .where((signal) => signal.phase == AgentTurnPhase.error) .length; - final finished = signals.length - working - errors; + final cancelled = signals + .where((signal) => signal.phase == AgentTurnPhase.cancelled) + .length; + final finished = signals.length - working - cancelled - errors; if (working == signals.length) { return ( visibleLabel: '${signals.length} agents are working…', @@ -416,6 +420,7 @@ _ActivityStatus _activityStatus(AgentTurnState? turn, bool isFallbackWorking) { final visibleParts = [ if (working > 0) '$working working', if (finished > 0) '$finished finished', + if (cancelled > 0) '$cancelled cancelled', if (errors > 0) '$errors ${errors == 1 ? 'error' : 'errors'}', ]; final semanticParts = [ @@ -423,6 +428,8 @@ _ActivityStatus _activityStatus(AgentTurnState? turn, bool isFallbackWorking) { '$working ${working == 1 ? 'agent is' : 'agents are'} working', if (finished > 0) '$finished ${finished == 1 ? 'agent has' : 'agents have'} finished', + if (cancelled > 0) + '$cancelled ${cancelled == 1 ? 'agent was' : 'agents were'} cancelled', if (errors > 0) '$errors ${errors == 1 ? 'agent stopped' : 'agents stopped'} with ${errors == 1 ? 'an error' : 'errors'}', ]; @@ -450,6 +457,7 @@ String _selectedActivityHeadline( List transcript, ) => switch (selectedTurn?.phase) { AgentTurnPhase.finished => 'finished', + AgentTurnPhase.cancelled => 'was cancelled', AgentTurnPhase.error => 'stopped with an error', _ => transcript.isNotEmpty ? _compactHeadline(transcript.last) : 'is working…', diff --git a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart index a2b6b95b277..7700fcf7dfb 100644 --- a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart +++ b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart @@ -52,6 +52,23 @@ void main() { expect(turns[1].errorMessage, 'Tool permission denied'); }); + test('reports cancelled completion separately from a finished turn', () { + final turns = reduceAgentTurnStates({ + 'agent-a': [ + _frame(seq: 1, second: 1, kind: 'turn_started'), + _frame( + seq: 2, + second: 2, + kind: 'turn_completed', + payload: {'outcome': 'cancelled'}, + ), + ], + }, now: DateTime.utc(2026, 8, 16, 12, 1)); + + expect(turns, hasLength(1)); + expect(turns.single.phase, AgentTurnPhase.cancelled); + }); + test('keeps an error terminal when generic completion arrives later', () { final turns = reduceAgentTurnStates({ 'agent-a': [ @@ -164,6 +181,51 @@ void main() { expect(pastBackstop, isEmpty); }); + test('does not assume a 30-second cadence when joining mid-turn', () { + final frames = { + 'agent-a': [ + _frame( + seq: 1, + second: const Duration(days: 6).inSeconds + 1, + kind: 'acp_read', + startedAt: DateTime.utc(2026, 8, 16, 12).toIso8601String(), + ), + ], + }; + + final afterLegacyTimeout = reduceAgentTurnStates( + frames, + now: DateTime.utc(2026, 8, 22, 12, 0, 32), + ); + final pastBackstop = reduceAgentTurnStates( + frames, + now: DateTime.utc(2026, 8, 23, 12, 0, 32), + ); + + expect(afterLegacyTimeout, hasLength(1)); + expect( + afterLegacyTimeout.single.livenessTimeout, + const Duration(days: 7, seconds: 30), + ); + expect(pastBackstop, isEmpty); + }); + + test('uses a liveness frame cadence when joining mid-turn', () { + final turns = reduceAgentTurnStates({ + 'agent-a': [ + _frame( + seq: 1, + second: 1, + kind: 'turn_liveness', + payload: {'livenessIntervalSecs': 120}, + ), + ], + }, now: DateTime.utc(2026, 8, 16, 12, 2, 30)); + + expect(turns, hasLength(1)); + expect(turns.single.livenessTimeout, const Duration(seconds: 150)); + }); + test('recovers a missed start and rejects stale post-terminal liveness', () { final turns = reduceAgentTurnStates({ 'agent-a': [ diff --git a/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart b/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart index e8391689b8b..b0d07181e71 100644 --- a/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart +++ b/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart @@ -206,6 +206,53 @@ void main() { expect(find.text('Thinking'), findsOneWidget); }); + testWidgets('labels a cancelled turn separately from a finished turn', ( + tester, + ) async { + final container = ProviderContainer( + overrides: [ + composerActivityStateProvider(_scope).overrideWithValue( + const ComposerActivityState( + agents: [ + WorkingAgentSignal( + pubkey: _agentPubkey, + source: AgentWorkingSource.observer, + canViewActivity: true, + isWorking: false, + phase: AgentTurnPhase.cancelled, + turnId: _turnId, + ), + ], + humanTyping: [], + ), + ), + agentTurnStatesProvider.overrideWithValue([ + _turn(AgentTurnPhase.cancelled), + ]), + 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 was cancelled'), findsOneWidget); + + await tester.tap(control); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 240)); + + expect(find.text('Cancelled'), findsOneWidget); + }); + testWidgets('summarizes mixed working and terminal agent states', ( tester, ) async { From d40c4ed8945441aa4a7f6eea8a539c865a6b315d Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 17 Aug 2026 19:03:04 +0100 Subject: [PATCH 10/17] Prefer newer agent turn outcomes Signed-off-by: kenny lopez --- .../agent_activity/working_bots_provider.dart | 2 +- .../working_bots_provider_test.dart | 61 ++++++++++++++++++- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart index fbafe860d8c..fc34f9677ad 100644 --- a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart +++ b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart @@ -180,10 +180,10 @@ final workingBotPubkeysProvider = Provider.autoDispose }); int _compareComposerTurnRecency(AgentTurnState a, AgentTurnState b) { - if (a.isWorking != b.isWorking) return a.isWorking ? 1 : -1; final activity = a.lastActivityAt.compareTo(b.lastActivityAt); if (activity != 0) return activity; final started = a.startedAt.compareTo(b.startedAt); if (started != 0) return started; + if (a.isWorking != b.isWorking) return a.isWorking ? 1 : -1; return a.turnId.compareTo(b.turnId); } diff --git a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart index 9e3901f9ffc..af5c1fc3810 100644 --- a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart +++ b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart @@ -305,8 +305,63 @@ void main() { }); }); - test('prefers live work over retained terminal history', () { + test('prefers a newer terminal outcome over stale working history', () { + final container = ProviderContainer( + overrides: [ + currentPubkeyProvider.overrideWith((ref) => 'owner'), + channelMembersProvider( + _channelId, + ).overrideWith((ref) async => const []), + 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([ + _turn( + 'agent-a', + turnId: 'turn-old', + startedAt: DateTime.utc(2026, 8, 16, 11, 59), + lastActivityAt: DateTime.utc(2026, 8, 16, 12, 0, 1), + ), + _turn( + 'agent-a', + phase: AgentTurnPhase.error, + turnId: 'turn-new', + startedAt: DateTime.utc(2026, 8, 16, 12), + lastActivityAt: DateTime.utc(2026, 8, 16, 12, 0, 5), + ), + ]), + ], + ); + addTearDown(container.dispose); + + final state = container.read( + composerActivityStateProvider(( + channelId: _channelId, + threadHeadId: null, + )), + ); + + expect(state.agents.single.turnId, 'turn-new'); + expect(state.agents.single.phase, AgentTurnPhase.error); + expect(state.agents.single.isWorking, isFalse); + expect(container.read(workingBotPubkeysProvider(_channelId)), isEmpty); + }); + + test('prefers live work when turn chronology is tied', () { final receiptAt = DateTime.utc(2026, 8, 16, 12, 0, 5); + final startedAt = DateTime.utc(2026, 8, 16, 12, 0, 4); final container = ProviderContainer( overrides: [ currentPubkeyProvider.overrideWith((ref) => 'owner'), @@ -333,13 +388,13 @@ void main() { 'agent-a', phase: AgentTurnPhase.error, turnId: 'turn-old', - startedAt: DateTime.utc(2026, 8, 16, 11, 59), + startedAt: startedAt, lastActivityAt: receiptAt, ), _turn( 'agent-a', turnId: 'turn-new', - startedAt: DateTime.utc(2026, 8, 16, 12, 0, 4), + startedAt: startedAt, lastActivityAt: receiptAt, ), ]), From 870f4b340c21d8388c96470077639947f263fa73 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 17 Aug 2026 19:15:25 +0100 Subject: [PATCH 11/17] Prefer fresh agent typing over retained outcomes Signed-off-by: kenny lopez --- .../agent_activity/working_bots_provider.dart | 2 +- .../working_bots_provider_test.dart | 107 ++++++++++-------- 2 files changed, 61 insertions(+), 48 deletions(-) diff --git a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart index fc34f9677ad..cebceddca05 100644 --- a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart +++ b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart @@ -123,7 +123,7 @@ final composerActivityStateProvider = Provider.autoDispose humans.add(entry); continue; } - if (signals.containsKey(pubkey)) continue; + if (signals[pubkey]?.isWorking == true) continue; final turn = activeByAgent[pubkey]; final liveTurn = turn?.isWorking == true ? turn : null; signals[pubkey] = WorkingAgentSignal( diff --git a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart index af5c1fc3810..23d85d0f7db 100644 --- a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart +++ b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart @@ -114,55 +114,68 @@ void main() { }, ); - test('thread typing does not inherit a retained terminal turn', () { - final failedTurn = _turn('agent-a', phase: AgentTurnPhase.error); - final container = ProviderContainer( - overrides: [ - currentPubkeyProvider.overrideWith((ref) => 'owner'), - channelMembersProvider( - _channelId, - ).overrideWith((ref) async => const []), - channelTypingProvider(_channelId).overrideWith( - () => _FakeTypingNotifier(const [ - TypingEntry( - pubkey: 'agent-a', - threadHeadId: 'thread-1', - expiresAtMs: 9999999999999, + for (final retainedThreadHeadId in [null, 'thread-1']) { + test( + 'thread typing replaces a retained terminal turn ' + '${retainedThreadHeadId == null ? 'from another scope' : 'in the same scope'}', + () { + final failedTurn = _turn( + 'agent-a', + phase: AgentTurnPhase.error, + threadHeadId: retainedThreadHeadId, + ); + final container = ProviderContainer( + overrides: [ + currentPubkeyProvider.overrideWith((ref) => 'owner'), + channelMembersProvider( + _channelId, + ).overrideWith((ref) async => const []), + channelTypingProvider(_channelId).overrideWith( + () => _FakeTypingNotifier(const [ + TypingEntry( + pubkey: 'agent-a', + threadHeadId: 'thread-1', + expiresAtMs: 9999999999999, + ), + ]), ), - ]), - ), - 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]), - ], + 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 signal = container + .read( + composerActivityStateProvider(( + channelId: _channelId, + threadHeadId: 'thread-1', + )), + ) + .agents + .single; + + expect(signal.source, AgentWorkingSource.typing); + expect(signal.isWorking, isTrue); + expect(signal.turnId, isNull); + expect(signal.startedAt, isNull); + expect(container.read(workingBotPubkeysProvider(_channelId)), { + 'agent-a', + }); + }, ); - addTearDown(container.dispose); - - final signal = container - .read( - composerActivityStateProvider(( - channelId: _channelId, - threadHeadId: 'thread-1', - )), - ) - .agents - .single; - - expect(signal.source, AgentWorkingSource.typing); - expect(signal.isWorking, isTrue); - expect(signal.turnId, isNull); - expect(signal.startedAt, isNull); - }); + } test( 'keeps a scoped terminal outcome reachable after thread typing stops', From 4cf8e98242334014584012c94c5a8428c88e5461 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Tue, 18 Aug 2026 06:57:34 +0100 Subject: [PATCH 12/17] Prefer fresh typing over stale agent turns Signed-off-by: kenny lopez --- .../agent_activity/working_bots_provider.dart | 32 ++++++- .../channels/channel_typing_provider.dart | 11 ++- .../working_bots_provider_test.dart | 88 +++++++++++++++++-- 3 files changed, 121 insertions(+), 10 deletions(-) diff --git a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart index cebceddca05..1fa8b995888 100644 --- a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart +++ b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart @@ -8,6 +8,9 @@ import '../channel_typing_provider.dart'; import 'active_agent_turns.dart'; import 'observer_subscription.dart'; +const _observerLivenessSlack = Duration(seconds: 30); +const _maximumTypingSupersedeGrace = Duration(seconds: 30); + /// Composer activity is scoped either to a channel or to one thread. typedef ComposerActivityKey = ({String channelId, String? threadHeadId}); @@ -123,9 +126,17 @@ final composerActivityStateProvider = Provider.autoDispose humans.add(entry); continue; } - if (signals[pubkey]?.isWorking == true) continue; final turn = activeByAgent[pubkey]; - final liveTurn = turn?.isWorking == true ? turn : null; + final supersedesWorkingTurn = + turn != null && + turn.isWorking && + _typingSupersedesWorkingTurn(entry, turn); + if (signals[pubkey]?.isWorking == true && !supersedesWorkingTurn) { + continue; + } + final liveTurn = turn?.isWorking == true && !supersedesWorkingTurn + ? turn + : null; signals[pubkey] = WorkingAgentSignal( pubkey: pubkey, source: AgentWorkingSource.typing, @@ -187,3 +198,20 @@ int _compareComposerTurnRecency(AgentTurnState a, AgentTurnState b) { if (a.isWorking != b.isWorking) return a.isWorking ? 1 : -1; return a.turnId.compareTo(b.turnId); } + +bool _typingSupersedesWorkingTurn(TypingEntry typing, AgentTurnState turn) { + final advertisedCadenceMs = + turn.livenessTimeout.inMilliseconds - + _observerLivenessSlack.inMilliseconds; + final graceMs = advertisedCadenceMs <= 0 + ? _maximumTypingSupersedeGrace.inMilliseconds + : advertisedCadenceMs + .clamp( + TypingEntry.ttl.inMilliseconds, + _maximumTypingSupersedeGrace.inMilliseconds, + ) + .toInt(); + return typing.receivedAt.isAfter( + turn.lastActivityAt.add(Duration(milliseconds: graceMs)), + ); +} diff --git a/mobile/lib/features/channels/channel_typing_provider.dart b/mobile/lib/features/channels/channel_typing_provider.dart index 73c7990ca2e..140a2fc4b36 100644 --- a/mobile/lib/features/channels/channel_typing_provider.dart +++ b/mobile/lib/features/channels/channel_typing_provider.dart @@ -8,6 +8,8 @@ import '../../shared/relay/relay.dart'; /// A single typing indicator entry. @immutable class TypingEntry { + static const ttl = Duration(seconds: 8); + final String pubkey; final String? threadHeadId; final int expiresAtMs; @@ -17,6 +19,12 @@ class TypingEntry { this.threadHeadId, required this.expiresAtMs, }); + + /// Local receipt time reconstructed from the fixed typing TTL. + DateTime get receivedAt => DateTime.fromMillisecondsSinceEpoch( + expiresAtMs - ttl.inMilliseconds, + isUtc: true, + ); } /// Tracks who is currently typing in a specific channel. @@ -24,7 +32,6 @@ class TypingEntry { /// Subscribes to kind:20002 (typing indicator) events via websocket. /// Entries expire after 8 seconds (matching the desktop TTL). class ChannelTypingNotifier extends Notifier> { - static const _ttlMs = 8000; static const _pruneIntervalMs = 1000; final String channelId; @@ -70,7 +77,7 @@ class ChannelTypingNotifier extends Notifier> { final entry = TypingEntry( pubkey: event.pubkey, threadHeadId: event.getTagValue('e'), - expiresAtMs: now + _ttlMs, + expiresAtMs: now + TypingEntry.ttl.inMilliseconds, ); // Upsert: replace existing entry for same pubkey+thread, or add. diff --git a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart index 23d85d0f7db..4d6edc335cd 100644 --- a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart +++ b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart @@ -14,7 +14,10 @@ const _channelId = 'channel-1'; void main() { test('prefers observer work and keeps human typing separate', () { - final observerTurn = _turn('agent-a'); + final observerTurn = _turn( + 'agent-a', + livenessTimeout: const Duration(seconds: 40), + ); final container = ProviderContainer( overrides: [ currentPubkeyProvider.overrideWith((ref) => 'owner'), @@ -22,10 +25,13 @@ void main() { _channelId, ).overrideWith((ref) async => const []), channelTypingProvider(_channelId).overrideWith( - () => _FakeTypingNotifier(const [ - TypingEntry(pubkey: 'agent-a', expiresAtMs: 9999999999999), - TypingEntry(pubkey: 'agent-b', expiresAtMs: 9999999999999), - TypingEntry(pubkey: 'human', expiresAtMs: 9999999999999), + () => _FakeTypingNotifier([ + _typingEntry( + 'agent-a', + receivedAt: DateTime.utc(2026, 8, 16, 12, 0, 9), + ), + _typingEntry('agent-b'), + _typingEntry('human'), ]), ), agentMentionPubkeysProvider( @@ -177,6 +183,62 @@ void main() { ); } + test('fresh typing supersedes a stale working turn in the same scope', () { + final staleTurn = _turn( + 'agent-a', + threadHeadId: 'thread-1', + lastActivityAt: DateTime.utc(2026, 8, 16, 12), + livenessTimeout: const Duration(days: 7, seconds: 30), + ); + final container = ProviderContainer( + overrides: [ + currentPubkeyProvider.overrideWith((ref) => 'owner'), + channelMembersProvider( + _channelId, + ).overrideWith((ref) async => const []), + channelTypingProvider(_channelId).overrideWith( + () => _FakeTypingNotifier([ + _typingEntry( + 'agent-a', + threadHeadId: 'thread-1', + receivedAt: DateTime.utc(2026, 8, 16, 12, 0, 31), + ), + ]), + ), + 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([staleTurn]), + ], + ); + addTearDown(container.dispose); + + final signal = container + .read( + composerActivityStateProvider(( + channelId: _channelId, + threadHeadId: 'thread-1', + )), + ) + .agents + .single; + + expect(signal.source, AgentWorkingSource.typing); + expect(signal.isWorking, isTrue); + expect(signal.turnId, isNull); + expect(signal.startedAt, isNull); + expect(container.read(workingBotPubkeysProvider(_channelId)), {'agent-a'}); + }); + test( 'keeps a scoped terminal outcome reachable after thread typing stops', () { @@ -435,6 +497,7 @@ AgentTurnState _turn( String? threadHeadId, DateTime? startedAt, DateTime? lastActivityAt, + Duration livenessTimeout = const Duration(seconds: 30), }) => AgentTurnState( agentPubkey: pubkey, channelId: _channelId, @@ -442,13 +505,26 @@ AgentTurnState _turn( turnId: turnId ?? 'turn-$pubkey', startedAt: startedAt ?? DateTime.utc(2026, 8, 16, 12), lastActivityAt: lastActivityAt ?? DateTime.utc(2026, 8, 16, 12), - livenessTimeout: const Duration(seconds: 30), + livenessTimeout: livenessTimeout, phase: phase, terminalAt: phase == AgentTurnPhase.working ? null : DateTime.utc(2026, 8, 16, 12, 0, 5), ); +TypingEntry _typingEntry( + String pubkey, { + String? threadHeadId, + DateTime? receivedAt, +}) { + final received = receivedAt ?? DateTime.utc(2026, 8, 16, 12); + return TypingEntry( + pubkey: pubkey, + threadHeadId: threadHeadId, + expiresAtMs: received.add(TypingEntry.ttl).millisecondsSinceEpoch, + ); +} + ObserverFrame _observerFrame(String pubkey) => ObserverFrame( seq: 1, timestamp: DateTime.utc(2026, 8, 16, 12).toIso8601String(), From aca4ee3415020f80e553d71da08e4d92663b5400 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Tue, 18 Aug 2026 08:07:48 +0100 Subject: [PATCH 13/17] Honor observer ordering and accessible activity sizing Signed-off-by: kenny lopez --- .../agent_activity/active_agent_turns.dart | 20 +- .../composer_agent_activity_indicator.dart | 201 +++++++++--------- .../agent_activity_controls.dart | 176 ++++++++++++++- .../agent_activity/observer_models.dart | 62 ++++++ .../agent_activity/observer_subscription.dart | 16 +- .../active_agent_turns_test.dart | 59 +++++ ...omposer_agent_activity_indicator_test.dart | 66 ++++++ 7 files changed, 469 insertions(+), 131 deletions(-) diff --git a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart index 7de03414ae6..994ff6f152c 100644 --- a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart +++ b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart @@ -89,13 +89,12 @@ List reduceAgentTurnStates( for (final entry in framesByAgent.entries) { final agentPubkey = entry.key.toLowerCase(); - final frames = [...entry.value]..sort(_compareFrames); + final frames = orderObserverFrames(entry.value); final turnsById = {}; - final terminalOrderById = {}; + final terminalSequenceById = {}; for (final frame in frames) { - final frameOrderAt = _frameTimestamp(frame); - final frameAt = frame.receivedAt ?? frameOrderAt; + final frameAt = frame.receivedAt ?? _frameTimestamp(frame); switch (frame.kind) { case 'turn_started': final channelId = frame.channelId; @@ -120,7 +119,7 @@ List reduceAgentTurnStates( : AgentTurnPhase.error; final turnId = frame.turnId; if (turnId != null) { - terminalOrderById[turnId] = frameOrderAt; + terminalSequenceById[turnId] = frame.seq; final existing = turnsById[turnId]; // The harness's generic completion guard can run after its result // handler emits the specific failure outcome. @@ -169,7 +168,7 @@ List reduceAgentTurnStates( : latest, ); if (matching == null) continue; - terminalOrderById[matching.turnId] = frameOrderAt; + terminalSequenceById[matching.turnId] = frame.seq; turnsById[matching.turnId] = matching.withTerminal( phase: terminalPhase, at: frameAt, @@ -191,8 +190,8 @@ List reduceAgentTurnStates( continue; } - final terminalOrder = terminalOrderById[turnId]; - if (terminalOrder != null && !frameOrderAt.isAfter(terminalOrder)) { + final terminalSequence = terminalSequenceById[turnId]; + if (terminalSequence != null && frame.seq <= terminalSequence) { continue; } final channelId = frame.channelId; @@ -374,8 +373,3 @@ Duration _livenessTimeout( : timeoutSeconds, ); } - -int _compareFrames(ObserverFrame a, ObserverFrame b) { - final timestamp = _frameTimestamp(a).compareTo(_frameTimestamp(b)); - return timestamp != 0 ? timestamp : a.seq.compareTo(b.seq); -} diff --git a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart index 44ac2c3fdb7..b5dae158f82 100644 --- a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart +++ b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator.dart @@ -27,7 +27,7 @@ part 'composer_agent_activity_indicator/compact_activity_item.dart'; /// stream without moving or covering the composer itself. class ComposerAgentActivityIndicator extends HookConsumerWidget { static const _compactSurfaceHeight = 52.0; - static const _expandedTargetHeight = 328.0; + static const _baseExpandedTargetHeight = 328.0; static const _surfaceMorphDuration = Duration(milliseconds: 220); static const _surfaceMorphCurve = Cubic(0.77, 0, 0.175, 1); @@ -76,7 +76,7 @@ class ComposerAgentActivityIndicator extends HookConsumerWidget { final activityAnchorKey = useMemoized(GlobalKey.new); final activityOverlayController = useMemoized(OverlayPortalController.new); final compactActivityWidth = useState(0.0); - final expandedHeight = useState(_expandedTargetHeight); + final expandedHeight = useState(_baseExpandedTargetHeight); final expansionController = useAnimationController( duration: _surfaceMorphDuration, ); @@ -88,6 +88,7 @@ class ComposerAgentActivityIndicator extends HookConsumerWidget { final reducedMotion = MediaQuery.disableAnimationsOf(context); final motionDisabled = !animated || reducedMotion; final mediaSize = MediaQuery.sizeOf(context); + final expandedTargetHeight = _expandedPanelTargetHeight(context); final viewInsets = MediaQuery.viewInsetsOf(context); final viewPadding = MediaQuery.viewPaddingOf(context); final platformView = View.of(context); @@ -113,7 +114,7 @@ class ComposerAgentActivityIndicator extends HookConsumerWidget { anchorBottom - topBoundary - Grid.xxs, ); final nextExpandedHeight = math.min( - _expandedTargetHeight, + expandedTargetHeight, availableHeight, ); final nextCompactWidth = renderObject.size.width; @@ -178,6 +179,7 @@ class ComposerAgentActivityIndicator extends HookConsumerWidget { keyboardInsetBottom, viewPadding.top, overlayTopBoundary, + expandedTargetHeight, expanded.value, ], ); @@ -485,6 +487,7 @@ class ComposerAgentActivityIndicator extends HookConsumerWidget { child: ClipRect( child: _InlineActivityPanel( height: panelViewportHeight, + panelWidth: panelWidth, expandedHeight: expandedHeight.value, morphProgress: progress, targetExpanded: expanded.value, @@ -610,9 +613,8 @@ List compactActivityItems(List transcript) { } class _InlineActivityPanel extends StatelessWidget { - static const _footerHeight = 44.0; - final double height; + final double panelWidth; final double expandedHeight; final double morphProgress; final bool targetExpanded; @@ -634,6 +636,7 @@ class _InlineActivityPanel extends StatelessWidget { const _InlineActivityPanel({ required this.height, + required this.panelWidth, required this.expandedHeight, required this.morphProgress, required this.targetExpanded, @@ -667,10 +670,29 @@ class _InlineActivityPanel extends StatelessWidget { expanded: false, ).visibleLabel; final topInset = Grid.xxs * morphProgress; - final panelHeight = math.max(_footerHeight, height - Grid.xxs - topInset); + final labelStyle = context.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + ); + final statusLabel = _activityStatusLabel(status); + final contentWidth = math.max( + 0.0, + panelWidth - horizontalInset * 2 - Grid.xxs * 2, + ); + final inlineFooterWidth = + _agentAvatarStackWidth(selectorAgents.length) + + Grid.xxs + + _singleLineTextWidth(context, 'Live activity', labelStyle) + + Grid.xxs + + _singleLineTextWidth(context, statusLabel, labelStyle) + + Grid.xxs * 2 + + Grid.half + + 18; + final stacksFooter = inlineFooterWidth > contentWidth; + final footerHeight = _activityFooterHeight(context, stacked: stacksFooter); + final panelHeight = math.max(footerHeight, height - Grid.xxs - topInset); final expandedDetailHeight = math.max( 0.0, - expandedHeight - Grid.xxs * 2 - _footerHeight, + expandedHeight - Grid.xxs * 2 - footerHeight, ); final detailsOpacity = Curves.easeOut.transform( ((morphProgress - 0.16) / 0.84).clamp(0.0, 1.0), @@ -700,7 +722,7 @@ class _InlineActivityPanel extends StatelessWidget { child: Stack( children: [ Positioned.fill( - bottom: _footerHeight, + bottom: footerHeight, child: ClipRect( child: IgnorePointer( ignoring: morphProgress < 0.95, @@ -730,6 +752,8 @@ class _InlineActivityPanel extends StatelessWidget { alignment: Alignment.centerLeft, child: Text( 'Live activity may be partial.', + maxLines: 2, + overflow: TextOverflow.ellipsis, style: context.textTheme.labelSmall ?.copyWith( color: @@ -776,7 +800,7 @@ class _InlineActivityPanel extends StatelessWidget { left: 0, right: 0, bottom: 0, - height: _footerHeight, + height: footerHeight, child: Semantics( button: true, label: @@ -788,69 +812,76 @@ class _InlineActivityPanel extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric( horizontal: Grid.xxs, + vertical: Grid.half, ), - child: Row( - children: [ - _AgentAvatarStack( - pubkeys: selectorAgents, - profiles: profiles, - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: Stack( - alignment: Alignment.centerLeft, + child: stacksFooter + ? Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Opacity( - opacity: 1 - morphProgress, - child: Text( - compactLabel, - style: context.textTheme.labelSmall - ?.copyWith( - color: context - .colors - .onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - overflow: TextOverflow.ellipsis, + Row( + children: [ + _AgentAvatarStack( + pubkeys: selectorAgents, + profiles: profiles, + ), + const SizedBox(width: Grid.xxs), + _ActivityFooterLabel( + compactLabel: compactLabel, + morphProgress: morphProgress, + ), + Transform.rotate( + angle: math.pi * morphProgress, + child: Icon( + LucideIcons.chevronUp, + size: 18, + color: + context.colors.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: Grid.half), + Align( + alignment: Alignment.centerLeft, + child: _ActivityStatusBadge( + status: status, + ), + ), + ], + ) + : Row( + children: [ + _AgentAvatarStack( + pubkeys: selectorAgents, + profiles: profiles, + ), + const SizedBox(width: Grid.xxs), + _ActivityFooterLabel( + compactLabel: compactLabel, + morphProgress: morphProgress, + ), + ClipRect( + child: Align( + widthFactor: morphProgress, + child: Opacity( + opacity: morphProgress, + child: _ActivityStatusBadge( + status: status, + ), + ), ), ), - Opacity( - opacity: morphProgress, - child: Text( - 'Live activity', - style: context.textTheme.labelSmall - ?.copyWith( - color: context - .colors - .onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - overflow: TextOverflow.ellipsis, + SizedBox(width: Grid.half * morphProgress), + Transform.rotate( + angle: math.pi * morphProgress, + child: Icon( + LucideIcons.chevronUp, + size: 18, + color: context.colors.onSurfaceVariant, ), ), ], ), - ), - ClipRect( - child: Align( - widthFactor: morphProgress, - child: Opacity( - opacity: morphProgress, - child: _ActivityStatusBadge(status: status), - ), - ), - ), - SizedBox(width: Grid.half * morphProgress), - Transform.rotate( - angle: math.pi * morphProgress, - child: Icon( - LucideIcons.chevronUp, - size: 18, - color: context.colors.onSurfaceVariant, - ), - ), - ], - ), ), ), ), @@ -865,48 +896,6 @@ class _InlineActivityPanel extends StatelessWidget { } } -class _ActivityStatusBadge extends StatelessWidget { - final _ActivityStatus status; - - const _ActivityStatusBadge({required this.status}); - - @override - Widget build(BuildContext context) { - final color = switch (status) { - _ActivityStatus.working => context.appColors.success, - _ActivityStatus.finished => context.colors.onSurfaceVariant, - _ActivityStatus.cancelled => context.colors.onSurfaceVariant, - _ActivityStatus.error => context.colors.error, - _ActivityStatus.waiting => context.appColors.warning, - }; - final label = switch (status) { - _ActivityStatus.working => 'Working', - _ActivityStatus.finished => 'Finished', - _ActivityStatus.cancelled => 'Cancelled', - _ActivityStatus.error => 'Error', - _ActivityStatus.waiting => 'Waiting', - }; - return Container( - key: const ValueKey('composer-agent-activity-status'), - padding: const EdgeInsets.symmetric( - horizontal: Grid.xxs, - vertical: Grid.quarter, - ), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - label, - style: context.textTheme.labelSmall?.copyWith( - color: color, - fontWeight: FontWeight.w600, - ), - ), - ); - } -} - class _ActivityEmptyState extends StatelessWidget { final _ActivityStatus status; final ObserverState? observerState; diff --git a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart index 691a109ba37..45f1046a32a 100644 --- a/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart +++ b/mobile/lib/features/channels/agent_activity/composer_agent_activity_indicator/agent_activity_controls.dart @@ -21,7 +21,7 @@ class _AgentSegmentedControl extends StatelessWidget { final isIos = defaultTargetPlatform == TargetPlatform.iOS; return SizedBox( - height: Grid.xl, + height: _agentSelectorHeight(context), child: Padding( padding: const EdgeInsets.symmetric( horizontal: _inset, @@ -361,7 +361,7 @@ class _AgentAvatarStack extends StatelessWidget { Widget build(BuildContext context) { final visible = pubkeys.take(3).toList(); return SizedBox( - width: 24.0 + math.max(0, visible.length - 1) * 14.0, + width: _agentAvatarStackWidth(visible.length), height: 24, child: Stack( children: [ @@ -380,6 +380,178 @@ class _AgentAvatarStack extends StatelessWidget { } } +class _ActivityFooterLabel extends StatelessWidget { + final String compactLabel; + final double morphProgress; + + const _ActivityFooterLabel({ + required this.compactLabel, + required this.morphProgress, + }); + + @override + Widget build(BuildContext context) { + final style = context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ); + return Expanded( + child: Stack( + alignment: Alignment.centerLeft, + children: [ + Opacity( + opacity: 1 - morphProgress, + child: Text( + compactLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: style, + ), + ), + Opacity( + opacity: morphProgress, + child: Text( + 'Live activity', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: style, + ), + ), + ], + ), + ); + } +} + +class _ActivityStatusBadge extends StatelessWidget { + final _ActivityStatus status; + + const _ActivityStatusBadge({required this.status}); + + @override + Widget build(BuildContext context) { + final color = switch (status) { + _ActivityStatus.working => context.appColors.success, + _ActivityStatus.finished => context.colors.onSurfaceVariant, + _ActivityStatus.cancelled => context.colors.onSurfaceVariant, + _ActivityStatus.error => context.colors.error, + _ActivityStatus.waiting => context.appColors.warning, + }; + return Container( + key: const ValueKey('composer-agent-activity-status'), + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs, + vertical: Grid.quarter, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _activityStatusLabel(status), + maxLines: 1, + style: context.textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +String _activityStatusLabel(_ActivityStatus status) => switch (status) { + _ActivityStatus.working => 'Working', + _ActivityStatus.finished => 'Finished', + _ActivityStatus.cancelled => 'Cancelled', + _ActivityStatus.error => 'Error', + _ActivityStatus.waiting => 'Waiting', +}; + +double _scaledTextHeight( + BuildContext context, + TextStyle? style, { + required double fallbackSize, + required double fallbackHeight, +}) { + final fontSize = style?.fontSize ?? fallbackSize; + return MediaQuery.textScalerOf(context).scale(fontSize) * + (style?.height ?? fallbackHeight); +} + +double _agentSelectorHeight(BuildContext context) { + final labelHeight = _scaledTextHeight( + context, + context.textTheme.labelMedium, + fallbackSize: 14, + fallbackHeight: 1.25, + ); + return math.max(Grid.xl, labelHeight + Grid.xs + Grid.half); +} + +double _activityFooterHeight(BuildContext context, {required bool stacked}) { + final labelHeight = _scaledTextHeight( + context, + context.textTheme.labelSmall, + fallbackSize: 11, + fallbackHeight: 1.2, + ); + if (stacked) { + return _stackedActivityFooterHeight(labelHeight); + } + final firstRowHeight = math.max(24.0, labelHeight); + final badgeHeight = labelHeight + Grid.half; + return math.max(44.0, Grid.xxs + math.max(firstRowHeight, badgeHeight)); +} + +double _stackedActivityFooterHeight(double labelHeight) => math.max( + 44.0, + Grid.xxs + + Grid.quarter + + math.max(24.0, labelHeight) + + Grid.half + + labelHeight + + Grid.half, +); + +double _expandedPanelTargetHeight(BuildContext context) { + final labelHeight = _scaledTextHeight( + context, + context.textTheme.labelSmall, + fallbackSize: 11, + fallbackHeight: 1.2, + ); + const baseLabelHeight = 11 * 1.2; + final extraDisclaimerHeight = + math.max(0.0, labelHeight - baseLabelHeight) * 2; + return ComposerAgentActivityIndicator._baseExpandedTargetHeight + + math.max(0.0, _agentSelectorHeight(context) - Grid.xl) + + math.max( + 0.0, + _activityFooterHeight(context, stacked: true) - + _stackedActivityFooterHeight(baseLabelHeight), + ) + + extraDisclaimerHeight; +} + +double _singleLineTextWidth( + BuildContext context, + String text, + TextStyle? style, +) { + final painter = TextPainter( + text: TextSpan(text: text, style: style), + maxLines: 1, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + return painter.width; +} + +double _agentAvatarStackWidth(int count) { + final visibleCount = math.min(3, count); + return visibleCount == 0 ? 0 : 24.0 + (visibleCount - 1) * 14.0; +} + enum _ActivityStatus { working, finished, cancelled, error, waiting } _ActivityStatus _activityStatus(AgentTurnState? turn, bool isFallbackWorking) { diff --git a/mobile/lib/features/channels/agent_activity/observer_models.dart b/mobile/lib/features/channels/agent_activity/observer_models.dart index 53c47882237..e6fd09d6a0e 100644 --- a/mobile/lib/features/channels/agent_activity/observer_models.dart +++ b/mobile/lib/features/channels/agent_activity/observer_models.dart @@ -53,6 +53,68 @@ class ObserverFrame { ); } +/// Orders observer frames by their protocol stream before presentation time. +/// +/// NIP-AO guarantees that [ObserverFrame.seq] increases within one session. +/// Host wall-clock timestamps are only presentation metadata and may jump in +/// either direction. Frames without a session inherit one from another frame +/// for the same turn; unrelated streams use local receipt time to establish +/// their relative epoch. +List orderObserverFrames(Iterable frames) { + final ordered = [...frames]; + final sessionByTurn = {}; + for (final frame in ordered) { + final sessionId = frame.sessionId; + final turnId = frame.turnId; + if (sessionId != null && + sessionId.isNotEmpty && + turnId != null && + turnId.isNotEmpty) { + sessionByTurn[turnId] = sessionId; + } + } + + String streamKey(ObserverFrame frame) { + final sessionId = frame.sessionId; + if (sessionId != null && sessionId.isNotEmpty) return 'session:$sessionId'; + final turnId = frame.turnId; + if (turnId != null && turnId.isNotEmpty) { + final inheritedSession = sessionByTurn[turnId]; + return inheritedSession == null + ? 'turn:$turnId' + : 'session:$inheritedSession'; + } + return 'legacy'; + } + + DateTime presentationTime(ObserverFrame frame) => + frame.receivedAt ?? + DateTime.tryParse(frame.timestamp)?.toUtc() ?? + DateTime.fromMillisecondsSinceEpoch(frame.seq, isUtc: true); + + final epochByStream = {}; + for (final frame in ordered) { + final key = streamKey(frame); + final time = presentationTime(frame); + final current = epochByStream[key]; + if (current == null || time.isBefore(current)) epochByStream[key] = time; + } + + ordered.sort((a, b) { + final aKey = streamKey(a); + final bKey = streamKey(b); + if (aKey == bKey) { + final sequence = a.seq.compareTo(b.seq); + if (sequence != 0) return sequence; + return presentationTime(a).compareTo(presentationTime(b)); + } + final epoch = epochByStream[aKey]!.compareTo(epochByStream[bKey]!); + if (epoch != 0) return epoch; + return aKey.compareTo(bKey); + }); + return ordered; +} + /// A section within prompt context metadata. @immutable class PromptSection { diff --git a/mobile/lib/features/channels/agent_activity/observer_subscription.dart b/mobile/lib/features/channels/agent_activity/observer_subscription.dart index 7cb582ecc2b..89a7268cdaf 100644 --- a/mobile/lib/features/channels/agent_activity/observer_subscription.dart +++ b/mobile/lib/features/channels/agent_activity/observer_subscription.dart @@ -228,7 +228,10 @@ class ObserverRelayNotifier extends Notifier { () => [], ); frames.add(frame); - frames.sort(_compareObserverFrames); + final orderedFrames = orderObserverFrames(frames); + frames + ..clear() + ..addAll(orderedFrames); if (frames.length > _maxObserverEvents) { final removeCount = frames.length - _maxObserverEvents; @@ -267,10 +270,10 @@ class ObserverRelayNotifier extends Notifier { return [frame]; } - final innerFrames = [ + final innerFrames = orderObserverFrames([ for (final inner in events) ObserverFrame.fromJson(inner as Map), - ]..sort(_compareObserverFrames); + ]); return [ for (var index = 0; index < innerFrames.length; index++) _withReceivedAt( @@ -356,13 +359,6 @@ class ObserverRelayNotifier extends Notifier { 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); - } - static ObserverFrame _withReceivedAt( ObserverFrame frame, DateTime receivedAt, diff --git a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart index 7700fcf7dfb..f158ab5625d 100644 --- a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart +++ b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart @@ -52,6 +52,63 @@ void main() { expect(turns[1].errorMessage, 'Tool permission denied'); }); + test('orders a turn by sequence when the host clock moves backward', () { + final turns = reduceAgentTurnStates({ + 'agent-a': [ + _frame( + seq: 1, + second: 10, + kind: 'turn_started', + sessionId: 'session-1', + receivedSecond: 1, + ), + _frame( + seq: 2, + second: 1, + kind: 'turn_completed', + sessionId: 'session-1', + receivedSecond: 2, + ), + ], + }, now: DateTime.utc(2026, 8, 16, 12, 0, 20)); + + expect(turns, hasLength(1)); + expect(turns.single.phase, AgentTurnPhase.finished); + expect(turns.single.lastActivityAt, DateTime.utc(2026, 8, 16, 12, 0, 2)); + }); + + test('orders a turn by sequence when the host clock jumps forward', () { + final turns = reduceAgentTurnStates({ + 'agent-a': [ + _frame( + seq: 1, + second: 1, + kind: 'turn_started', + sessionId: 'session-1', + receivedSecond: 1, + ), + _frame( + seq: 2, + second: 50, + kind: 'acp_read', + sessionId: 'session-1', + receivedSecond: 2, + ), + _frame( + seq: 3, + second: 3, + kind: 'turn_completed', + sessionId: 'session-1', + receivedSecond: 3, + ), + ], + }, now: DateTime.utc(2026, 8, 16, 12, 0, 20)); + + expect(turns, hasLength(1)); + expect(turns.single.phase, AgentTurnPhase.finished); + expect(turns.single.lastActivityAt, DateTime.utc(2026, 8, 16, 12, 0, 3)); + }); + test('reports cancelled completion separately from a finished turn', () { final turns = reduceAgentTurnStates({ 'agent-a': [ @@ -349,6 +406,7 @@ ObserverFrame _frame({ String? turnId = 'turn-1', String channelId = 'channel-1', String? threadHeadId, + String? sessionId, int? receivedSecond, String? startedAt, dynamic payload = const {}, @@ -359,6 +417,7 @@ ObserverFrame _frame({ kind: kind, channelId: channelId, threadHeadId: threadHeadId, + sessionId: sessionId, turnId: turnId, startedAt: startedAt, receivedAt: receivedSecond == null diff --git a/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart b/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart index b0d07181e71..81ea4fc0a63 100644 --- a/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart +++ b/mobile/test/features/channels/agent_activity/composer_agent_activity_indicator_test.dart @@ -599,6 +599,70 @@ void main() { expect(find.text('Running Read file'), findsNothing); }); + for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) { + for (final textScale in [2.0, 3.0]) { + testWidgets( + 'narrow $platform panel stays operable at ${textScale}x text', + (tester) async { + debugDefaultTargetPlatformOverride = platform; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(220, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = _multiAgentContainer(); + addTearDown(container.dispose); + + await tester.pumpWidget( + _app( + container, + disableAnimations: true, + textScaler: TextScaler.linear(textScale), + ), + ); + await tester.pump(); + expect(tester.takeException(), isNull); + + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-control')), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.text('Live activity'), findsOneWidget); + expect(find.text('Working'), findsOneWidget); + expect( + find.byKey(const ValueKey('composer-agent-activity-collapse')), + findsOneWidget, + ); + + final sprigSegment = find.byKey( + const ValueKey('composer-agent-segment-agent-b'), + ); + await tester.ensureVisible(sprigSegment); + await tester.tap(sprigSegment); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.text('Sprig'), findsOneWidget); + expect(find.text('Running Search messages'), findsOneWidget); + + await tester.tap( + find.byKey(const ValueKey('composer-agent-activity-collapse')), + ); + await tester.pump(); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect( + find.byKey(const ValueKey('composer-agent-activity-panel')), + findsNothing, + ); + debugDefaultTargetPlatformOverride = null; + }, + ); + } + } + testWidgets('iOS uses the native sliding segmented control', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); @@ -1087,6 +1151,7 @@ Widget _app( Animation? composerWidthAnimation, ValueNotifier? composerInteractionLock, ValueNotifier? composerActivationRequests, + TextScaler textScaler = TextScaler.noScaling, }) { Widget activityIndicator = ComposerAgentActivityIndicator( channelId: _channelId, @@ -1130,6 +1195,7 @@ Widget _app( data: MediaQuery.of(context).copyWith( disableAnimations: disableAnimations, viewInsets: viewInsets, + textScaler: textScaler, ), child: Scaffold( resizeToAvoidBottomInset: false, From e166a8ce75190aba7bddcba4928cae35f183f601 Mon Sep 17 00:00:00 2001 From: Princess Donut Date: Tue, 18 Aug 2026 10:52:28 +0100 Subject: [PATCH 14/17] fix(acp): preserve mobile activity across steering Co-authored-by: Kenny Lopez Signed-off-by: Kenny Lopez --- crates/buzz-acp/src/lib.rs | 111 ++++++++++++++++++ crates/buzz-acp/src/pool.rs | 20 +++- crates/buzz-acp/src/relay.rs | 37 ++++++ .../agent_activity/active_agent_turns.dart | 23 ++++ .../agent_activity/working_bots_provider.dart | 7 +- .../channels/channel_typing_provider.dart | 3 + .../active_agent_turns_test.dart | 30 +++++ .../working_bots_provider_test.dart | 56 +++++++++ 8 files changed, 285 insertions(+), 2 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 1cb99323a6d..2000b915b35 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1542,6 +1542,7 @@ struct RespawnResult { struct SteerAckEvent { channel_id: Uuid, event_id: String, + thread_tags: ThreadTags, /// `Ok` if the read loop sent any of the locked `SteerAck` variants. /// `Err` if the oneshot was dropped without a send — should not happen /// under the current read-loop drains, but if it ever does the main @@ -3134,10 +3135,16 @@ async fn tokio_main() -> Result<()> { // they're ephemeral and must not block the main loop during // relay reconnection (#35). for (&ch, thread_tags) in &typing_channels { + let turn_id = pool + .task_map() + .values() + .find(|meta| meta.channel_id == Some(ch)) + .map(|meta| meta.turn_id.as_str()); if let Ok(event) = relay.build_typing_event( ch, thread_tags.root_event_id.as_deref(), thread_tags.parent_event_id.as_deref(), + turn_id, ) { if let Err(e) = relay.try_publish_event(event) { tracing::debug!("typing indicator dropped for {ch}: {e}"); @@ -3224,6 +3231,7 @@ async fn tokio_main() -> Result<()> { Some(PoolEvent::SteerAck(SteerAckEvent { channel_id, event_id, + thread_tags, ack, })) => { // Mid-turn steer attempt resolved (either transport: @@ -3336,6 +3344,31 @@ async fn tokio_main() -> Result<()> { "non-cancelling steer ack received" ); if let Ok(pool::SteerAck::Success { session_id }) = &ack { + let steered_turn = rescope_successful_steer( + &mut pool, + &mut typing_channels, + channel_id, + &thread_tags, + ); + if let Some(turn_id) = steered_turn { + if let Some(observer) = observer.as_ref() { + let mut context = + observer::context_for(Some(channel_id), None, Some(turn_id)); + context.thread_head_id = thread_tags.root_event_id.clone(); + observer.emit( + "turn_rescoped", + None, + &context, + serde_json::json!({ "triggeringEventId": event_id }), + ); + } + } else { + tracing::warn!( + channel = %channel_id, + event_id = %event_id, + "successful steer could not rescope in-flight observer telemetry" + ); + } queue.extend_in_flight_deadline(channel_id, config.max_turn_duration_secs); if !pool.record_successful_steer( channel_id, @@ -3750,6 +3783,7 @@ fn try_native_steer( // steering (which is to inject only what's new). let (header, closing) = queue::native_steer_framing(); let event_id_hex = event.id.to_hex(); + let thread_tags = queue::parse_thread_tags(&event); let be = queue::BatchEvent { event, prompt_tag: prompt_tag.clone(), @@ -3794,6 +3828,7 @@ fn try_native_steer( let _ = ack_tx_clone.send(SteerAckEvent { channel_id, event_id: event_id_for_watcher, + thread_tags, ack, }); }); @@ -3810,6 +3845,22 @@ fn try_native_steer( } } +/// Rescope a successful non-cancelling steer while its original turn is live. +/// +/// The prompt result and steer acknowledgement race in the main `select!` loop. +/// If the result won, its handler already removed the typing entry and retired +/// the `TaskMeta`; a late acknowledgement must not resurrect typing forever. +fn rescope_successful_steer( + pool: &mut AgentPool, + typing_channels: &mut HashMap, + channel_id: Uuid, + thread_tags: &ThreadTags, +) -> Option { + let turn_id = pool.rescope_in_flight_turn(channel_id, thread_tags.root_event_id.clone())?; + typing_channels.insert(channel_id, thread_tags.clone()); + Some(turn_id) +} + // ── dispatch_pending ────────────────────────────────────────────────────────── /// Flush queued work to available agents. @@ -7432,6 +7483,66 @@ mod error_outcome_emission_tests { } } + #[tokio::test] + async fn stale_successful_steer_ack_does_not_resurrect_typing() { + let channel_id = Uuid::new_v4(); + let mut pool = AgentPool::from_slots(vec![]); + let mut typing_channels = HashMap::new(); + let thread_tags = ThreadTags { + root_event_id: Some("thread-b".into()), + parent_event_id: Some("message-b".into()), + mentioned_pubkeys: vec![], + }; + + assert_eq!( + rescope_successful_steer(&mut pool, &mut typing_channels, channel_id, &thread_tags,), + None, + ); + assert!(typing_channels.is_empty()); + } + + #[tokio::test] + async fn live_successful_steer_ack_rescopes_turn_and_typing() { + let channel_id = Uuid::new_v4(); + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: Some(channel_id), + thread_head_id: Some("thread-a".into()), + turn_id: "turn-1".into(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + successful_steer_deliveries: HashSet::new(), + }, + ); + let mut typing_channels = HashMap::new(); + let thread_tags = ThreadTags { + root_event_id: Some("thread-b".into()), + parent_event_id: Some("message-b".into()), + mentioned_pubkeys: vec![], + }; + + assert_eq!( + rescope_successful_steer(&mut pool, &mut typing_channels, channel_id, &thread_tags,) + .as_deref(), + Some("turn-1"), + ); + let updated_typing = typing_channels.get(&channel_id).expect("typing scope"); + assert_eq!(updated_typing.root_event_id.as_deref(), Some("thread-b")); + assert_eq!(updated_typing.parent_event_id.as_deref(), Some("message-b")); + assert_eq!( + pool.task_map() + .values() + .find(|meta| meta.channel_id == Some(channel_id)) + .and_then(|meta| meta.thread_head_id.as_deref()), + Some("thread-b"), + ); + } + #[tokio::test] async fn successful_native_steer_is_transferred_to_live_session_delivery_state() { let channel_id = Uuid::new_v4(); diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 2fe32b538ea..e11c44f1e4f 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -700,7 +700,7 @@ impl AgentPool { &mut self.task_map } - /// Try to send a goose-native steer request to the in-flight task for + /// Try to send a non-cancelling steer request to the in-flight task for /// `channel_id`. /// /// Returns `Ok(())` if the request was accepted by the read loop's @@ -740,6 +740,24 @@ impl AgentPool { .map_err(|e| SteerError::Transport(e.to_string())) } + /// Update the presentation scope for a successfully steered in-flight turn. + /// + /// Native steering keeps the same ACP turn alive, but the triggering Buzz + /// message can come from another thread. The returned turn ID lets the + /// caller publish an explicit rescope frame for observer consumers. + pub fn rescope_in_flight_turn( + &mut self, + channel_id: Uuid, + thread_head_id: Option, + ) -> Option { + let meta = self + .task_map + .values_mut() + .find(|meta| meta.channel_id == Some(channel_id))?; + meta.thread_head_id = thread_head_id; + Some(meta.turn_id.clone()) + } + /// Durably associate a successful steer with the exact ACP session that /// accepted it. Acks may arrive before or after the prompt result: while /// the task is in flight we stage the delivery in `TaskMeta`; after return diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index fc3a16ddb95..3f869d39f5f 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -907,6 +907,7 @@ impl HarnessRelay { channel_id: Uuid, root_event_id: Option<&str>, parent_event_id: Option<&str>, + turn_id: Option<&str>, ) -> Result { let h_tag = Tag::parse(["h", &channel_id.to_string()]) .map_err(|e| RelayError::AuthFailed(e.to_string()))?; @@ -925,6 +926,11 @@ impl HarnessRelay { .map_err(|e| RelayError::AuthFailed(e.to_string()))?, ); } + if let Some(turn_id) = turn_id.filter(|turn_id| !turn_id.is_empty()) { + tags.push( + Tag::parse(["turn", turn_id]).map_err(|e| RelayError::AuthFailed(e.to_string()))?, + ); + } let event = EventBuilder::new(Kind::Custom(KIND_TYPING_INDICATOR as u16), "") .tags(tags) .sign_with_keys(&self.keys)?; @@ -4057,6 +4063,37 @@ async fn wait_for_any_ok( mod tests { use super::*; + #[test] + fn typing_event_carries_active_turn_identity() { + let (_event_tx, event_rx) = mpsc::channel(1); + let (cmd_tx, _cmd_rx) = mpsc::channel(1); + let relay = HarnessRelay { + event_rx, + observer_control_rx: None, + cmd_tx, + http: reqwest::Client::new(), + relay_url: "ws://localhost:3000".into(), + keys: Keys::generate(), + auth_tag: None, + bg_handle: None, + }; + + let event = relay + .build_typing_event( + Uuid::new_v4(), + Some("thread-root"), + Some("message-parent"), + Some("turn-1"), + ) + .expect("build typing event"); + + assert!(event.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.first().map(String::as_str) == Some("turn") + && parts.get(1).map(String::as_str) == Some("turn-1") + })); + } + #[test] fn relay_ws_to_http_plain() { assert_eq!( diff --git a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart index 994ff6f152c..06b9d7f4eac 100644 --- a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart +++ b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart @@ -174,6 +174,25 @@ List reduceAgentTurnStates( at: frameAt, errorMessage: _turnError(frame.payload), ); + case 'turn_rescoped': + final turnId = frame.turnId; + if (turnId == null) continue; + final existing = turnsById[turnId]; + if (existing == null) continue; + turnsById[turnId] = AgentTurnState( + agentPubkey: existing.agentPubkey, + channelId: existing.channelId, + threadHeadId: frame.threadHeadId, + turnId: existing.turnId, + startedAt: existing.startedAt, + lastActivityAt: frameAt, + livenessTimeout: existing.livenessTimeout, + phase: existing.phase, + terminalAt: existing.terminalAt, + errorMessage: existing.errorMessage, + triggeringEventId: + _triggeringEventId(frame.payload) ?? existing.triggeringEventId, + ); case 'acp_read': case 'acp_write': case 'turn_liveness': @@ -330,6 +349,10 @@ DateTime _safeStartedAt(ObserverFrame frame, DateTime frameAt) { String? _triggeringEventId(dynamic payload) { if (payload is! Map) return null; + final directEventId = payload['triggeringEventId']; + if (directEventId is String && directEventId.isNotEmpty) { + return directEventId; + } final eventIds = payload['triggeringEventIds']; if (eventIds is! List) return null; for (final eventId in eventIds) { diff --git a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart index 1fa8b995888..b99769a1ca1 100644 --- a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart +++ b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart @@ -96,8 +96,12 @@ final composerActivityStateProvider = Provider.autoDispose } bool canView(String pubkey) { - if (currentPubkey == null) return false; final normalized = pubkey.toLowerCase(); + // A frame reaches this state only after the subscription validated its + // agent signature, owner p-tag, and NIP-44 decryption. That is stronger + // local authorization evidence than eventually-consistent profile data. + if (observerState.framesByAgent.containsKey(normalized)) return true; + if (currentPubkey == null) return false; return ownerByAgent[normalized]?.toLowerCase() == currentPubkey || profiles[normalized]?.ownerPubkey?.toLowerCase() == currentPubkey; } @@ -130,6 +134,7 @@ final composerActivityStateProvider = Provider.autoDispose final supersedesWorkingTurn = turn != null && turn.isWorking && + entry.turnId != turn.turnId && _typingSupersedesWorkingTurn(entry, turn); if (signals[pubkey]?.isWorking == true && !supersedesWorkingTurn) { continue; diff --git a/mobile/lib/features/channels/channel_typing_provider.dart b/mobile/lib/features/channels/channel_typing_provider.dart index 140a2fc4b36..5ceed5d913c 100644 --- a/mobile/lib/features/channels/channel_typing_provider.dart +++ b/mobile/lib/features/channels/channel_typing_provider.dart @@ -12,11 +12,13 @@ class TypingEntry { final String pubkey; final String? threadHeadId; + final String? turnId; final int expiresAtMs; const TypingEntry({ required this.pubkey, this.threadHeadId, + this.turnId, required this.expiresAtMs, }); @@ -77,6 +79,7 @@ class ChannelTypingNotifier extends Notifier> { final entry = TypingEntry( pubkey: event.pubkey, threadHeadId: event.getTagValue('e'), + turnId: event.getTagValue('turn'), expiresAtMs: now + TypingEntry.ttl.inMilliseconds, ); diff --git a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart index f158ab5625d..89db258d42e 100644 --- a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart +++ b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart @@ -28,6 +28,36 @@ void main() { expect(turns.single.lastActivityAt, DateTime.utc(2026, 8, 16, 12, 0, 20)); }); + test('rescopes a continuing turn after a successful native steer', () { + final turns = reduceAgentTurnStates({ + 'agent-a': [ + _frame( + seq: 1, + second: 1, + kind: 'turn_started', + threadHeadId: 'thread-a', + receivedSecond: 1, + payload: { + 'triggeringEventIds': ['message-a'], + }, + ), + _frame( + seq: 2, + second: 2, + kind: 'turn_rescoped', + threadHeadId: 'thread-b', + receivedSecond: 2, + payload: {'triggeringEventId': 'message-b'}, + ), + ], + }, now: DateTime.utc(2026, 8, 16, 12, 0, 3)); + + expect(turns.single.threadHeadId, 'thread-b'); + expect(turns.single.triggeringEventId, 'message-b'); + expect(turns.single.turnId, 'turn-1'); + expect(turns.single.isWorking, isTrue); + }); + test('preserves explicit completion and error outcomes', () { final turns = reduceAgentTurnStates({ 'agent-a': [ diff --git a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart index 4d6edc335cd..a31fdf64188 100644 --- a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart +++ b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart @@ -183,6 +183,60 @@ void main() { ); } + test('same-turn typing preserves observer identity past a long cadence', () { + final observerTurn = _turn( + 'agent-a', + threadHeadId: 'thread-1', + turnId: 'turn-1', + lastActivityAt: DateTime.utc(2026, 8, 16, 12), + livenessTimeout: const Duration(minutes: 2, seconds: 30), + ); + final container = ProviderContainer( + overrides: [ + currentPubkeyProvider.overrideWith((ref) => 'owner'), + channelMembersProvider( + _channelId, + ).overrideWith((ref) async => const []), + channelTypingProvider(_channelId).overrideWith( + () => _FakeTypingNotifier([ + _typingEntry( + 'agent-a', + threadHeadId: 'thread-1', + turnId: 'turn-1', + receivedAt: DateTime.utc(2026, 8, 16, 12, 3), + ), + ]), + ), + agentMentionPubkeysProvider( + _channelId, + ).overrideWith((ref) => const {'agent-a'}), + agentOwnersProvider.overrideWithValue(const AsyncData({})), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + observerRelayProvider.overrideWith( + () => _FakeObserverRelayNotifier({ + 'agent-a': [_observerFrame('agent-a')], + }), + ), + composerAgentTurnStatesProvider.overrideWithValue([observerTurn]), + ], + ); + addTearDown(container.dispose); + + final signal = container + .read( + composerActivityStateProvider(( + channelId: _channelId, + threadHeadId: 'thread-1', + )), + ) + .agents + .single; + + expect(signal.source, AgentWorkingSource.observer); + expect(signal.turnId, 'turn-1'); + expect(signal.canViewActivity, isTrue); + }); + test('fresh typing supersedes a stale working turn in the same scope', () { final staleTurn = _turn( 'agent-a', @@ -515,12 +569,14 @@ AgentTurnState _turn( TypingEntry _typingEntry( String pubkey, { String? threadHeadId, + String? turnId, DateTime? receivedAt, }) { final received = receivedAt ?? DateTime.utc(2026, 8, 16, 12); return TypingEntry( pubkey: pubkey, threadHeadId: threadHeadId, + turnId: turnId, expiresAtMs: received.add(TypingEntry.ttl).millisecondsSinceEpoch, ); } From 88f5ba2e6dd296293135e5a2f50293fc3f85a519 Mon Sep 17 00:00:00 2001 From: Princess Donut Date: Tue, 18 Aug 2026 14:15:32 +0100 Subject: [PATCH 15/17] fix(acp): rescope observer frames after steering Co-authored-by: Kenny Lopez Signed-off-by: Kenny Lopez --- Cargo.lock | 4 +- crates/buzz-acp/src/acp.rs | 71 +++++++++++++++++++++------- crates/buzz-acp/src/lib.rs | 45 ++++++++++++++++++ crates/buzz-acp/src/observer.rs | 35 ++++++++++++++ crates/buzz-acp/src/pool.rs | 84 +++++++++++++++++++++++++++------ 5 files changed, 206 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6c46beedf2f..27688daa9fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3338,9 +3338,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 10d5bdf7796..b79a100d7c4 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -174,6 +174,8 @@ pub struct AcpClient { observer_agent_index: Option, /// Best-effort context attached to raw ACP wire events. observer_context: ObserverContext, + /// Shared thread scope for the active turn, updated by successful steering. + observer_turn_scope: Option, /// Most recently observed `_meta.goose.activeRunId` from a /// `session/update` notification of kind `session_info_update`. /// @@ -557,6 +559,7 @@ impl AcpClient { observer: None, observer_agent_index: None, observer_context: ObserverContext::default(), + observer_turn_scope: None, active_run_id: None, steering_supported: false, steer_rx: None, @@ -577,9 +580,23 @@ impl AcpClient { self.observer_context = context; } + /// Share the active turn's mutable thread scope with subsequent wire events. + pub fn set_observer_turn_scope(&mut self, scope: Option) { + self.observer_turn_scope = scope; + } + /// Return the observer metadata for the current turn. pub(crate) fn observer_context(&self) -> ObserverContext { - self.observer_context.clone() + let mut context = self.observer_context.clone(); + if let Some(scope) = &self.observer_turn_scope { + context.thread_head_id = scope.thread_head_id(); + } + context + } + + #[cfg(test)] + pub(crate) fn observer_turn_scope(&self) -> Option { + self.observer_turn_scope.clone() } /// Return a clone of the observer handle, if attached. @@ -595,12 +612,8 @@ impl AcpClient { /// Emit a semantic event to the local observer feed, if enabled. pub fn observe(&self, kind: impl Into, payload: serde_json::Value) { if let Some(observer) = &self.observer { - observer.emit( - kind, - self.observer_agent_index, - &self.observer_context, - payload, - ); + let context = self.observer_context(); + observer.emit(kind, self.observer_agent_index, &context, payload); } } @@ -1343,6 +1356,7 @@ impl AcpClient { let mut pending_steer: Option<( u64, SteerTransport, + Option, tokio::sync::oneshot::Sender, )> = None; @@ -1369,7 +1383,7 @@ impl AcpClient { // exists). Check the classified deadline here so a steady- // stream agent is still bounded. if Instant::now() >= next_deadline { - if let Some((_, _, ack_tx)) = pending_steer.take() { + if let Some((_, _, _, ack_tx)) = pending_steer.take() { // Prompt is timing out — release the withheld event via // PromptCompletedNeutral (no fallback signal: there is // no in-flight turn to signal once we return, and @@ -1471,7 +1485,12 @@ impl AcpClient { ); match self.write_ndjson(&msg).await { Ok(()) => { - pending_steer = Some((id, transport, req.ack_tx)); + pending_steer = Some(( + id, + transport, + req.observer_thread_head_id, + req.ack_tx, + )); } Err(e) => { tracing::warn!( @@ -1494,7 +1513,7 @@ impl AcpClient { // would catch this anyway, but firing the deadline arm // here makes the wakeup immediate (no extra reader poll // round-trip when stdout is idle). - if let Some((_, _, ack_tx)) = pending_steer.take() { + if let Some((_, _, _, ack_tx)) = pending_steer.take() { let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); } if idle_fires_first { @@ -1518,13 +1537,13 @@ impl AcpClient { match read_result { None => { - if let Some((_, _, ack_tx)) = pending_steer.take() { + if let Some((_, _, _, ack_tx)) = pending_steer.take() { let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); } return Err(AcpError::AgentExited); } Some(Err(LinesCodecError::MaxLineLengthExceeded)) => { - if let Some((_, _, ack_tx)) = pending_steer.take() { + if let Some((_, _, _, ack_tx)) = pending_steer.take() { let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); } return Err(AcpError::Protocol( @@ -1532,7 +1551,7 @@ impl AcpClient { )); } Some(Err(e)) => { - if let Some((_, _, ack_tx)) = pending_steer.take() { + if let Some((_, _, _, ack_tx)) = pending_steer.take() { let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); } return Err(AcpError::Io(std::io::Error::other(e))); @@ -1575,13 +1594,13 @@ impl AcpClient { // share the `no method` guard. if let Some(id) = msg.get("id") { if msg.get("method").is_none() { - if let Some((steer_id, _, _)) = pending_steer.as_ref() { + if let Some((steer_id, _, _, _)) = pending_steer.as_ref() { if *id == serde_json::json!(*steer_id) { // Take the ack_tx out and route the // response. We do not return — keep // reading until the prompt response // arrives. - let (_, transport, ack_tx) = + let (_, transport, observer_thread_head_id, ack_tx) = pending_steer.take().expect("just checked"); let ack = if let Some(error) = msg.get("error") { let code = error @@ -1675,19 +1694,24 @@ impl AcpClient { } } }; + if matches!(ack, crate::pool::SteerAck::Success { .. }) { + if let Some(scope) = &self.observer_turn_scope { + scope.set_thread_head_id(observer_thread_head_id); + } + } let _ = ack_tx.send(ack); continue; } } if *id == serde_json::json!(expected_id) { if let Some(error) = msg.get("error") { - if let Some((_, _, ack_tx)) = pending_steer.take() { + if let Some((_, _, _, ack_tx)) = pending_steer.take() { let _ = ack_tx .send(crate::pool::SteerAck::PromptCompletedNeutral); } return Err(agent_error_from_json(error)); } - if let Some((_, _, ack_tx)) = pending_steer.take() { + if let Some((_, _, _, ack_tx)) = pending_steer.take() { let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); } @@ -3788,6 +3812,7 @@ mod tests { steer_tx .send(crate::pool::SteerRequest { prompt_blocks: vec!["test steer body".into()], + observer_thread_head_id: None, ack_tx, }) .await @@ -3849,6 +3874,8 @@ mod tests { let _ = client.handle_session_update(&update); assert_eq!(client.active_run_id(), Some("run-42")); + let turn_scope = crate::observer::ObserverTurnScope::new(Some("thread-a".into())); + client.set_observer_turn_scope(Some(turn_scope.clone())); let (steer_tx, steer_rx) = tokio::sync::mpsc::channel::(1); client.install_steer_rx(steer_rx); @@ -3857,6 +3884,7 @@ mod tests { steer_tx .send(crate::pool::SteerRequest { prompt_blocks: vec!["test steer body".into()], + observer_thread_head_id: Some("thread-b".into()), ack_tx, }) .await @@ -3895,6 +3923,11 @@ mod tests { crate::pool::SteerAck::Success { .. } => {} other => panic!("expected SteerAck::Success, got {other:?}"), } + assert_eq!( + turn_scope.thread_head_id().as_deref(), + Some("thread-b"), + "successful ACK must update live observer scope before reaching the main loop", + ); } /// Steer-success renewal keeps the turn alive past the original hard @@ -3929,6 +3962,7 @@ mod tests { steer_tx .send(crate::pool::SteerRequest { prompt_blocks: vec!["steer body".into()], + observer_thread_head_id: None, ack_tx, }) .await @@ -4003,6 +4037,7 @@ mod tests { steer_tx .send(crate::pool::SteerRequest { prompt_blocks: vec!["steer body".into()], + observer_thread_head_id: None, ack_tx, }) .await @@ -4253,6 +4288,7 @@ mod tests { steer_tx .send(crate::pool::SteerRequest { prompt_blocks: vec!["steer body".into()], + observer_thread_head_id: None, ack_tx, }) .await @@ -4306,6 +4342,7 @@ mod tests { steer_tx .send(crate::pool::SteerRequest { prompt_blocks: vec!["steer body".into()], + observer_thread_head_id: None, ack_tx, }) .await diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 2000b915b35..54f7e115dd1 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3795,6 +3795,7 @@ fn try_native_steer( let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::(); let request = pool::SteerRequest { prompt_blocks: vec![body], + observer_thread_head_id: thread_tags.root_event_id.clone(), ack_tx, }; @@ -3922,6 +3923,9 @@ fn dispatch_pending( let (control_tx, control_rx) = tokio::sync::oneshot::channel::(); let turn_id = Uuid::new_v4().to_string(); let task_turn_id = turn_id.clone(); + let observer_turn_scope = + observer::ObserverTurnScope::new(typing_scope.root_event_id.clone()); + let task_observer_turn_scope = observer_turn_scope.clone(); let abort_handle = pool.join_set.spawn(async move { pool::run_prompt_task( @@ -3932,6 +3936,7 @@ fn dispatch_pending( result_tx, Some(control_rx), task_turn_id, + task_observer_turn_scope, ) .await; }); @@ -3942,6 +3947,7 @@ fn dispatch_pending( agent_index, channel_id: Some(channel_id), thread_head_id: typing_scope.root_event_id.clone(), + observer_turn_scope: Some(observer_turn_scope), turn_id, recoverable_batch, control_tx: Some(control_tx), @@ -4565,6 +4571,8 @@ fn dispatch_heartbeat( let agent_index = agent.index; let turn_id = Uuid::new_v4().to_string(); let task_turn_id = turn_id.clone(); + let observer_turn_scope = observer::ObserverTurnScope::new(None); + let task_observer_turn_scope = observer_turn_scope.clone(); let abort_handle = pool.join_set.spawn(async move { pool::run_prompt_task( @@ -4575,6 +4583,7 @@ fn dispatch_heartbeat( result_tx, None, task_turn_id, + task_observer_turn_scope, ) .await; }); @@ -4585,6 +4594,7 @@ fn dispatch_heartbeat( agent_index, channel_id: None, thread_head_id: None, + observer_turn_scope: Some(observer_turn_scope), turn_id, recoverable_batch: None, control_tx: None, @@ -5380,6 +5390,7 @@ mod owner_control_command_tests { agent_index: 0, channel_id: Some(channel_id), thread_head_id: None, + observer_turn_scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: Some(control_tx), @@ -7506,12 +7517,22 @@ mod error_outcome_emission_tests { let channel_id = Uuid::new_v4(); let mut pool = AgentPool::from_slots(vec![None]); let task_id = pool.join_set.spawn(async {}).id(); + let observer_turn_scope = crate::observer::ObserverTurnScope::new(Some("thread-a".into())); + let mut live_agent = dummy_agent(0).await; + let mut observer_context = + crate::observer::context_for(Some(channel_id), None, Some("turn-1".into())); + observer_context.thread_head_id = Some("thread-a".into()); + live_agent.acp.set_observer_context(observer_context); + live_agent + .acp + .set_observer_turn_scope(Some(observer_turn_scope.clone())); pool.task_map_mut().insert( task_id, crate::pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), thread_head_id: Some("thread-a".into()), + observer_turn_scope: Some(observer_turn_scope.clone()), turn_id: "turn-1".into(), recoverable_batch: None, control_tx: None, @@ -7534,6 +7555,16 @@ mod error_outcome_emission_tests { let updated_typing = typing_channels.get(&channel_id).expect("typing scope"); assert_eq!(updated_typing.root_event_id.as_deref(), Some("thread-b")); assert_eq!(updated_typing.parent_event_id.as_deref(), Some("message-b")); + assert_eq!( + observer_turn_scope.thread_head_id().as_deref(), + Some("thread-b"), + "subsequent live observer frames must use the steered thread", + ); + assert_eq!( + live_agent.acp.observer_context().thread_head_id.as_deref(), + Some("thread-b"), + "raw ACP frames must read the shared steered scope", + ); assert_eq!( pool.task_map() .values() @@ -7565,6 +7596,7 @@ mod error_outcome_emission_tests { agent_index: 0, channel_id: Some(channel_id), thread_head_id: None, + observer_turn_scope: None, turn_id: "test-turn-id".into(), recoverable_batch: None, control_tx: None, @@ -7638,6 +7670,7 @@ mod error_outcome_emission_tests { agent_index: 0, channel_id: Some(channel_id), thread_head_id: None, + observer_turn_scope: None, turn_id: "test-turn-id".into(), recoverable_batch: None, control_tx: None, @@ -7754,6 +7787,7 @@ mod error_outcome_emission_tests { agent_index: 0, channel_id: Some(channel_id), thread_head_id: None, + observer_turn_scope: None, turn_id: "test-turn-id".into(), recoverable_batch: None, control_tx: None, @@ -7824,6 +7858,7 @@ mod error_outcome_emission_tests { agent_index: 0, channel_id: None, thread_head_id: None, + observer_turn_scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7908,6 +7943,7 @@ mod error_outcome_emission_tests { agent_index: 0, channel_id: Some(channel_id), thread_head_id: Some("thread-1".into()), + observer_turn_scope: None, turn_id: "panic-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8003,6 +8039,7 @@ mod error_outcome_emission_tests { agent_index: 0, channel_id: None, thread_head_id: None, + observer_turn_scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8096,6 +8133,7 @@ mod error_outcome_emission_tests { agent_index: 0, channel_id: None, thread_head_id: None, + observer_turn_scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8203,6 +8241,7 @@ mod error_outcome_emission_tests { agent_index: 0, channel_id: None, thread_head_id: None, + observer_turn_scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8281,6 +8320,7 @@ mod error_outcome_emission_tests { agent_index: 0, channel_id: None, thread_head_id: None, + observer_turn_scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8377,6 +8417,7 @@ mod error_outcome_emission_tests { agent_index: 0, channel_id: None, thread_head_id: None, + observer_turn_scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8495,6 +8536,7 @@ mod error_outcome_emission_tests { agent_index: 0, channel_id: None, thread_head_id: None, + observer_turn_scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8636,6 +8678,7 @@ mod error_outcome_emission_tests { agent_index: 0, channel_id: None, thread_head_id: None, + observer_turn_scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8826,6 +8869,7 @@ mod error_outcome_emission_tests { agent_index: 0, channel_id: None, thread_head_id: None, + observer_turn_scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8913,6 +8957,7 @@ mod error_outcome_emission_tests { agent_index: 0, channel_id: None, thread_head_id: None, + observer_turn_scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, diff --git a/crates/buzz-acp/src/observer.rs b/crates/buzz-acp/src/observer.rs index e4579524b96..7cf21dec697 100644 --- a/crates/buzz-acp/src/observer.rs +++ b/crates/buzz-acp/src/observer.rs @@ -32,6 +32,41 @@ pub struct ObserverContext { pub started_at: Option, } +/// Shared thread scope for a live observer turn. +/// +/// Native steering can move one in-flight ACP turn between Buzz threads. Raw +/// ACP frames, liveness pings, and the completion guard all hold clones of this +/// handle so a successful steer updates every later frame atomically. +#[derive(Clone, Debug)] +pub struct ObserverTurnScope { + thread_head_id: Arc>>, +} + +impl ObserverTurnScope { + /// Create a turn scope with its initial NIP-10 thread root. + pub fn new(thread_head_id: Option) -> Self { + Self { + thread_head_id: Arc::new(Mutex::new(thread_head_id)), + } + } + + /// Return the turn's current NIP-10 thread root. + pub fn thread_head_id(&self) -> Option { + match self.thread_head_id.lock() { + Ok(thread_head_id) => thread_head_id.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + } + } + + /// Move the live turn to another NIP-10 thread root. + pub fn set_thread_head_id(&self, thread_head_id: Option) { + match self.thread_head_id.lock() { + Ok(mut current) => *current = thread_head_id, + Err(poisoned) => *poisoned.into_inner() = thread_head_id, + } + } +} + /// Handle used by the harness to publish local observer events. #[derive(Clone)] pub struct ObserverHandle { diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index e11c44f1e4f..6df5b2c2782 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -61,6 +61,8 @@ pub struct TaskMeta { pub channel_id: Option, /// NIP-10 thread root for panic recovery telemetry. pub thread_head_id: Option, + /// Shared presentation scope used by every live observer frame for this turn. + pub observer_turn_scope: Option, /// Identifies terminal events when the task panics before returning a result. pub turn_id: String, /// Clone of batch for Queue mode panic recovery. @@ -382,6 +384,8 @@ pub struct SteerRequest { /// `queue::native_steer_framing()` + `queue::format_event_block` so /// the wording cannot drift from the cancel+merge fallback path. pub prompt_blocks: Vec, + /// NIP-10 thread root that subsequent observer frames use after success. + pub observer_thread_head_id: Option, /// Oneshot for the read loop to report the outcome. pub ack_tx: tokio::sync::oneshot::Sender, } @@ -754,7 +758,10 @@ impl AgentPool { .task_map .values_mut() .find(|meta| meta.channel_id == Some(channel_id))?; - meta.thread_head_id = thread_head_id; + meta.thread_head_id = thread_head_id.clone(); + if let Some(scope) = &meta.observer_turn_scope { + scope.set_thread_head_id(thread_head_id); + } Some(meta.turn_id.clone()) } @@ -1482,6 +1489,7 @@ fn send_prompt_result( }); } +#[cfg(test)] fn observer_thread_head_id(batch: Option<&FlushBatch>) -> Option { batch .and_then(|batch| batch.events.last()) @@ -1500,6 +1508,7 @@ fn observer_thread_head_id(batch: Option<&FlushBatch>) -> Option { /// /// The agent is ALWAYS returned — even on panic the `JoinSet` detects the /// abort and the caller uses `task_map` to recover the agent index. +#[allow(clippy::too_many_arguments)] pub async fn run_prompt_task( mut agent: OwnedAgent, batch: Option, @@ -1508,6 +1517,7 @@ pub async fn run_prompt_task( result_tx: mpsc::UnboundedSender, control_rx: Option>, turn_id: String, + observer_turn_scope: observer::ObserverTurnScope, ) { // Is this a channel prompt or a heartbeat? let source = match &batch { @@ -1518,8 +1528,11 @@ pub async fn run_prompt_task( PromptSource::Channel(channel_id) => Some(*channel_id), PromptSource::Heartbeat => None, }; - let observer_thread_head_id = observer_thread_head_id(batch.as_ref()); + let observer_thread_head_id = observer_turn_scope.thread_head_id(); let turn_started_at = chrono::Utc::now().to_rfc3339(); + agent + .acp + .set_observer_turn_scope(Some(observer_turn_scope.clone())); agent.acp.set_observer_context(observer::context_for_turn( observer_channel_id, observer_thread_head_id.clone(), @@ -1551,7 +1564,7 @@ pub async fn run_prompt_task( agent.acp.observer_handle(), agent.acp.observer_agent_index(), observer_channel_id, - observer_thread_head_id.clone(), + observer_turn_scope.clone(), turn_id.clone(), ); @@ -1578,6 +1591,7 @@ pub async fn run_prompt_task( turn_id.clone(), turn_started_at.clone(), ), + observer_turn_scope.clone(), ctx.turn_liveness_interval, Arc::clone(&liveness_state), ); @@ -3907,6 +3921,7 @@ async fn run_turn_liveness( observer: Option, agent_index: Option, mut context: observer::ObserverContext, + turn_scope: observer::ObserverTurnScope, interval: Duration, state: Arc>, ) { @@ -3934,6 +3949,7 @@ async fn run_turn_liveness( return; } context.session_id = guard.session_id.clone(); + context.thread_head_id = turn_scope.thread_head_id(); observer.emit( "turn_liveness", agent_index, @@ -4014,7 +4030,7 @@ struct TurnCompletionGuard { observer: Option, agent_index: Option, channel_id: Option, - thread_head_id: Option, + turn_scope: observer::ObserverTurnScope, turn_id: String, cancelled: bool, } @@ -4024,14 +4040,14 @@ impl TurnCompletionGuard { observer: Option, agent_index: Option, channel_id: Option, - thread_head_id: Option, + turn_scope: observer::ObserverTurnScope, turn_id: String, ) -> Self { Self { observer, agent_index, channel_id, - thread_head_id, + turn_scope, turn_id, cancelled: false, } @@ -4047,7 +4063,7 @@ impl Drop for TurnCompletionGuard { if let Some(observer) = self.observer.take() { let mut context = observer::context_for(self.channel_id, None, Some(self.turn_id.clone())); - context.thread_head_id = self.thread_head_id.clone(); + context.thread_head_id = self.turn_scope.thread_head_id(); let payload = if self.cancelled { serde_json::json!({ "outcome": "cancelled" }) } else { @@ -5720,6 +5736,7 @@ done"# result_tx.clone(), None, format!("turn-{turn}"), + observer::ObserverTurnScope::new(None), ) .await; let result = result_rx.recv().await.expect("prompt result"); @@ -5835,6 +5852,7 @@ done"# result_tx.clone(), None, format!("turn-{turn}"), + observer::ObserverTurnScope::new(None), ) .await; let result = result_rx.recv().await.expect("prompt result"); @@ -6010,6 +6028,7 @@ done"# result_tx.clone(), None, turn_id.into(), + observer::ObserverTurnScope::new(None), ) .await; let result = result_rx.recv().await.expect("prompt result"); @@ -6169,6 +6188,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" result_tx, None, "next-turn".into(), + observer::ObserverTurnScope::new(None), ) .await; let mut result = result_rx.recv().await.expect("next prompt result"); @@ -6832,7 +6852,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" Some(observer.clone()), Some(0), Some(Uuid::new_v4()), - Some("thread-1".into()), + observer::ObserverTurnScope::new(Some("thread-1".into())), "turn-1".into(), ); } @@ -6846,6 +6866,29 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" assert_eq!(event.payload, serde_json::json!({})); } + #[test] + fn test_completion_guard_uses_latest_turn_scope() { + let observer = observer::ObserverHandle::in_process(); + let scope = observer::ObserverTurnScope::new(Some("thread-1".into())); + { + let _guard = TurnCompletionGuard::new( + Some(observer.clone()), + Some(0), + Some(Uuid::new_v4()), + scope.clone(), + "turn-1".into(), + ); + scope.set_thread_head_id(Some("thread-2".into())); + } + + let event = observer + .snapshot() + .into_iter() + .find(|event| event.kind == "turn_completed") + .expect("completion frame"); + assert_eq!(event.thread_head_id.as_deref(), Some("thread-2")); + } + #[test] fn test_completion_guard_reports_cancelled_outcome() { let observer = observer::ObserverHandle::in_process(); @@ -6854,7 +6897,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" Some(observer.clone()), Some(0), Some(Uuid::new_v4()), - Some("thread-1".into()), + observer::ObserverTurnScope::new(Some("thread-1".into())), "turn-1".into(), ); guard.mark_cancelled(); @@ -6887,6 +6930,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" Some(observer.clone()), Some(0), context, + observer::ObserverTurnScope::new(None), Duration::from_secs(10), Arc::clone(&state), )), @@ -6938,11 +6982,13 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" started_at.clone(), ); let state = open_liveness_state(); + let turn_scope = observer::ObserverTurnScope::new(Some("thread-1".into())); let guard = LivenessGuard::new( tokio::spawn(run_turn_liveness( Some(observer.clone()), Some(0), context, + turn_scope.clone(), Duration::from_secs(10), Arc::clone(&state), )), @@ -6950,8 +6996,11 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" ); tokio::task::yield_now().await; - // First liveness tick at 10s and the second at 20s. - tokio::time::advance(Duration::from_secs(25)).await; + // First liveness tick at 10s, then rescope before the second at 20s. + tokio::time::advance(Duration::from_secs(10)).await; + tokio::task::yield_now().await; + turn_scope.set_thread_head_id(Some("thread-2".into())); + tokio::time::advance(Duration::from_secs(15)).await; tokio::task::yield_now().await; assert_eq!(liveness_count(&observer), 2); @@ -6966,9 +7015,8 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" assert!(pings .iter() .all(|event| event.started_at.as_deref() == Some(&started_at))); - assert!(pings - .iter() - .all(|event| event.thread_head_id.as_deref() == Some("thread-1"))); + assert_eq!(pings[0].thread_head_id.as_deref(), Some("thread-1")); + assert_eq!(pings[1].thread_head_id.as_deref(), Some("thread-2")); assert!(pings .iter() .all(|event| { event.payload == serde_json::json!({ "livenessIntervalSecs": 10 }) })); @@ -7003,6 +7051,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" Some(observer.clone()), Some(0), context, + observer::ObserverTurnScope::new(None), Duration::from_secs(10), Arc::clone(&state), )), @@ -7045,6 +7094,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" Some(observer.clone()), Some(0), context, + observer::ObserverTurnScope::new(None), Duration::ZERO, open_liveness_state(), ); @@ -7068,6 +7118,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" None, None, context, + observer::ObserverTurnScope::new(None), Duration::from_secs(10), open_liveness_state(), ); @@ -7112,6 +7163,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" Some(observer.clone()), Some(0), context, + observer::ObserverTurnScope::new(None), Duration::from_secs(10), state, ); @@ -7235,6 +7287,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" result.agent.acp.steer_rx_is_none(), "steer_rx must be None after send_prompt_result on error path" ); + assert!( + result.agent.acp.observer_turn_scope().is_none(), + "observer turn scope must remain unset when no turn installed one", + ); // The next dispatch can now install a fresh receiver without panicking. let (_steer_tx2, steer_rx2) = tokio::sync::mpsc::channel::(1); From 287e56228007bd5961b393ad2ff34d9855e477e0 Mon Sep 17 00:00:00 2001 From: Princess Donut Date: Tue, 18 Aug 2026 15:01:47 +0100 Subject: [PATCH 16/17] fix(mobile): honor observer frame scope updates Co-authored-by: Kenny Lopez Signed-off-by: Kenny Lopez --- crates/buzz-acp/src/observer.rs | 30 ++++- .../agent_activity/active_agent_turns.dart | 37 ++++-- .../agent_activity/observer_models.dart | 5 +- .../agent_activity/working_bots_provider.dart | 6 +- .../active_agent_turns_test.dart | 108 ++++++++++++++++++ .../observer_subscription_test.dart | 22 ++++ .../working_bots_provider_test.dart | 38 ++++++ 7 files changed, 230 insertions(+), 16 deletions(-) diff --git a/crates/buzz-acp/src/observer.rs b/crates/buzz-acp/src/observer.rs index 7cf21dec697..7b75468c6c6 100644 --- a/crates/buzz-acp/src/observer.rs +++ b/crates/buzz-acp/src/observer.rs @@ -104,8 +104,9 @@ pub struct ObserverEvent { pub agent_index: Option, /// Buzz channel UUID for channel-scoped events. pub channel_id: Option, - /// NIP-10 thread root for thread-scoped events. - #[serde(skip_serializing_if = "Option::is_none")] + /// NIP-10 thread root for this frame's turn. Serializes as null for the + /// channel root so consumers can distinguish an explicit root rescope from + /// legacy frames that omitted scope metadata. pub thread_head_id: Option, /// ACP session ID when known. pub session_id: Option, @@ -208,3 +209,28 @@ pub fn context_for_turn( started_at: Some(started_at), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn channel_root_scope_serializes_as_explicit_null() { + let event = ObserverEvent { + seq: 1, + timestamp: "2026-08-18T00:00:00Z".into(), + kind: "turn_liveness".into(), + agent_index: Some(0), + channel_id: Some("channel-1".into()), + thread_head_id: None, + session_id: Some("session-1".into()), + turn_id: Some("turn-1".into()), + started_at: None, + payload: serde_json::json!({}), + }; + + let json = serde_json::to_value(event).expect("serialize observer event"); + assert!(json.get("threadHeadId").is_some()); + assert!(json["threadHeadId"].is_null()); + } +} diff --git a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart index 06b9d7f4eac..d06c8186fe6 100644 --- a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart +++ b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart @@ -47,22 +47,26 @@ class AgentTurnState { bool get isWorking => phase == AgentTurnPhase.working; - AgentTurnState withActivity({required DateTime at, Duration? timeout}) => - AgentTurnState( - agentPubkey: agentPubkey, - channelId: channelId, - threadHeadId: threadHeadId, - turnId: turnId, - startedAt: startedAt, - lastActivityAt: at, - livenessTimeout: timeout ?? livenessTimeout, - phase: AgentTurnPhase.working, - triggeringEventId: triggeringEventId, - ); + AgentTurnState withActivity({ + required DateTime at, + required String? threadHeadId, + Duration? timeout, + }) => AgentTurnState( + agentPubkey: agentPubkey, + channelId: channelId, + threadHeadId: threadHeadId, + turnId: turnId, + startedAt: startedAt, + lastActivityAt: at, + livenessTimeout: timeout ?? livenessTimeout, + phase: AgentTurnPhase.working, + triggeringEventId: triggeringEventId, + ); AgentTurnState withTerminal({ required AgentTurnPhase phase, required DateTime at, + required String? threadHeadId, String? errorMessage, }) => AgentTurnState( agentPubkey: agentPubkey, @@ -133,6 +137,9 @@ List reduceAgentTurnStates( existing?.withTerminal( phase: terminalPhase, at: frameAt, + threadHeadId: frame.hasThreadScope + ? frame.threadHeadId + : existing.threadHeadId, errorMessage: _turnError(frame.payload), ) ?? AgentTurnState( @@ -172,6 +179,9 @@ List reduceAgentTurnStates( turnsById[matching.turnId] = matching.withTerminal( phase: terminalPhase, at: frameAt, + threadHeadId: frame.hasThreadScope + ? frame.threadHeadId + : matching.threadHeadId, errorMessage: _turnError(frame.payload), ); case 'turn_rescoped': @@ -202,6 +212,9 @@ List reduceAgentTurnStates( if (existing?.isWorking == true) { turnsById[turnId] = existing!.withActivity( at: frameAt, + threadHeadId: frame.hasThreadScope + ? frame.threadHeadId + : existing.threadHeadId, timeout: frame.kind == 'turn_liveness' ? _livenessTimeout(frame.payload) : null, diff --git a/mobile/lib/features/channels/agent_activity/observer_models.dart b/mobile/lib/features/channels/agent_activity/observer_models.dart index e6fd09d6a0e..2f91f0cfa74 100644 --- a/mobile/lib/features/channels/agent_activity/observer_models.dart +++ b/mobile/lib/features/channels/agent_activity/observer_models.dart @@ -15,6 +15,7 @@ class ObserverFrame { final int? agentIndex; final String? channelId; final String? threadHeadId; + final bool hasThreadScope; final String? sessionId; final String? turnId; final String? startedAt; @@ -28,12 +29,13 @@ class ObserverFrame { this.agentIndex, this.channelId, this.threadHeadId, + bool? hasThreadScope, this.sessionId, this.turnId, this.startedAt, this.receivedAt, this.payload, - }); + }) : hasThreadScope = hasThreadScope ?? threadHeadId != null; factory ObserverFrame.fromJson( Map json, { @@ -45,6 +47,7 @@ class ObserverFrame { agentIndex: json['agentIndex'] as int?, channelId: json['channelId'] as String?, threadHeadId: json['threadHeadId'] as String?, + hasThreadScope: json.containsKey('threadHeadId'), sessionId: json['sessionId'] as String?, turnId: json['turnId'] as String?, startedAt: json['startedAt'] as String?, diff --git a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart index b99769a1ca1..b19a61968ad 100644 --- a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart +++ b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart @@ -108,7 +108,11 @@ final composerActivityStateProvider = Provider.autoDispose final signals = {}; for (final entry in activeByAgent.entries) { - if (!channelAgents.contains(entry.key)) continue; + final hasValidatedObserverFrame = observerState.framesByAgent + .containsKey(entry.key); + if (!channelAgents.contains(entry.key) && !hasValidatedObserverFrame) { + continue; + } final turn = entry.value; if (!turn.isWorking && !canView(entry.key)) continue; signals[entry.key] = WorkingAgentSignal( diff --git a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart index 89db258d42e..5da622cad09 100644 --- a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart +++ b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart @@ -58,6 +58,112 @@ void main() { expect(turns.single.isWorking, isTrue); }); + test( + 'terminal and activity frames carry a rescope without a rescope event', + () { + final terminal = reduceAgentTurnStates({ + 'agent-a': [ + _frame( + seq: 1, + second: 1, + kind: 'turn_started', + threadHeadId: 'thread-a', + ), + _frame( + seq: 2, + second: 2, + kind: 'turn_completed', + threadHeadId: 'thread-b', + ), + ], + }, now: DateTime.utc(2026, 8, 16, 12, 0, 3)); + + expect(terminal.single.threadHeadId, 'thread-b'); + expect(terminal.single.phase, AgentTurnPhase.finished); + + final working = reduceAgentTurnStates({ + 'agent-a': [ + _frame( + seq: 1, + second: 1, + kind: 'turn_started', + threadHeadId: 'thread-a', + ), + _frame( + seq: 2, + second: 2, + kind: 'turn_liveness', + threadHeadId: 'thread-b', + ), + ], + }, now: DateTime.utc(2026, 8, 16, 12, 0, 3)); + + expect(working.single.threadHeadId, 'thread-b'); + expect(working.single.isWorking, isTrue); + }, + ); + + test( + 'explicit null scope moves later activity and terminal frames to root', + () { + List frames(String kind) => [ + _frame( + seq: 1, + second: 1, + kind: 'turn_started', + threadHeadId: 'thread-a', + ), + _frame(seq: 2, second: 2, kind: kind, hasThreadScope: true), + ]; + + final working = reduceAgentTurnStates({ + 'agent-a': frames('turn_liveness'), + }, now: DateTime.utc(2026, 8, 16, 12, 0, 3)); + expect(working.single.threadHeadId, isNull); + expect(working.single.isWorking, isTrue); + + final terminal = reduceAgentTurnStates({ + 'agent-a': frames('turn_completed'), + }, now: DateTime.utc(2026, 8, 16, 12, 0, 3)); + expect(terminal.single.threadHeadId, isNull); + expect(terminal.single.phase, AgentTurnPhase.finished); + }, + ); + + test('legacy scope omission preserves the latest known thread', () { + final turns = reduceAgentTurnStates({ + 'agent-a': [ + _frame( + seq: 1, + second: 1, + kind: 'turn_started', + threadHeadId: 'thread-a', + ), + _frame(seq: 2, second: 2, kind: 'turn_liveness'), + ], + }, now: DateTime.utc(2026, 8, 16, 12, 0, 3)); + + expect(turns.single.threadHeadId, 'thread-a'); + }); + + test('explicit rescope event moves a turn from a thread to root', () { + final turns = reduceAgentTurnStates({ + 'agent-a': [ + _frame( + seq: 1, + second: 1, + kind: 'turn_started', + threadHeadId: 'thread-a', + ), + _frame(seq: 2, second: 2, kind: 'turn_rescoped'), + _frame(seq: 3, second: 3, kind: 'turn_liveness'), + ], + }, now: DateTime.utc(2026, 8, 16, 12, 0, 4)); + + expect(turns.single.threadHeadId, isNull); + expect(turns.single.isWorking, isTrue); + }); + test('preserves explicit completion and error outcomes', () { final turns = reduceAgentTurnStates({ 'agent-a': [ @@ -436,6 +542,7 @@ ObserverFrame _frame({ String? turnId = 'turn-1', String channelId = 'channel-1', String? threadHeadId, + bool? hasThreadScope, String? sessionId, int? receivedSecond, String? startedAt, @@ -447,6 +554,7 @@ ObserverFrame _frame({ kind: kind, channelId: channelId, threadHeadId: threadHeadId, + hasThreadScope: hasThreadScope, sessionId: sessionId, turnId: turnId, startedAt: startedAt, diff --git a/mobile/test/features/channels/agent_activity/observer_subscription_test.dart b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart index e343aaf75fb..6f59538a7dd 100644 --- a/mobile/test/features/channels/agent_activity/observer_subscription_test.dart +++ b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart @@ -10,6 +10,28 @@ import 'package:buzz/shared/crypto/nip44.dart'; import 'package:buzz/shared/relay/relay.dart'; void main() { + test('decodes explicit root scope separately from legacy omission', () { + final explicitRoot = ObserverFrame.fromJson({ + 'seq': 1, + 'timestamp': '2026-04-30T12:00:01.000Z', + 'kind': 'turn_liveness', + 'channelId': 'channel-1', + 'threadHeadId': null, + 'turnId': 'turn-1', + }); + final legacy = ObserverFrame.fromJson({ + 'seq': 2, + 'timestamp': '2026-04-30T12:00:02.000Z', + 'kind': 'turn_liveness', + 'channelId': 'channel-1', + 'turnId': 'turn-1', + }); + + expect(explicitRoot.hasThreadScope, isTrue); + expect(explicitRoot.threadHeadId, isNull); + expect(legacy.hasThreadScope, isFalse); + }); + test('turn-scoped provider does not merge concurrent agent turns', () { final container = ProviderContainer( overrides: [ diff --git a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart index a31fdf64188..c1d5569fd3a 100644 --- a/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart +++ b/mobile/test/features/channels/agent_activity/working_bots_provider_test.dart @@ -67,6 +67,44 @@ void main() { expect(state.humanTyping.single.pubkey, 'human'); }); + test('validated observer work survives unavailable agent lookups', () { + final observerTurn = _turn('agent-a'); + final container = ProviderContainer( + overrides: [ + currentPubkeyProvider.overrideWith((ref) => 'owner'), + channelMembersProvider( + _channelId, + ).overrideWith((ref) => Future>.error('offline')), + channelTypingProvider( + _channelId, + ).overrideWith(() => _FakeTypingNotifier(const [])), + agentMentionPubkeysProvider( + _channelId, + ).overrideWith((ref) => const {}), + agentOwnersProvider.overrideWithValue(const AsyncLoading()), + userCacheProvider.overrideWith(_FakeUserCacheNotifier.new), + observerRelayProvider.overrideWith( + () => _FakeObserverRelayNotifier({ + 'agent-a': [_observerFrame('agent-a')], + }), + ), + composerAgentTurnStatesProvider.overrideWithValue([observerTurn]), + ], + ); + addTearDown(container.dispose); + + final state = container.read( + composerActivityStateProvider(( + channelId: _channelId, + threadHeadId: null, + )), + ); + + expect(state.agents.single.pubkey, 'agent-a'); + expect(state.agents.single.source, AgentWorkingSource.observer); + expect(state.agents.single.canViewActivity, isTrue); + }); + test( 'thread scope requires typing and does not surface observer-only work', () { From 6dbfb88d058cedc30e3dfda842c392ecdfda44cb Mon Sep 17 00:00:00 2001 From: Princess Donut Date: Tue, 18 Aug 2026 15:23:22 +0100 Subject: [PATCH 17/17] Preserve batch observer thread scope Co-authored-by: Princess Donut Signed-off-by: Princess Donut --- .../channels/agent_activity/observer_subscription.dart | 1 + .../channels/agent_activity/observer_subscription_test.dart | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/channels/agent_activity/observer_subscription.dart b/mobile/lib/features/channels/agent_activity/observer_subscription.dart index 89a7268cdaf..e29726ae96a 100644 --- a/mobile/lib/features/channels/agent_activity/observer_subscription.dart +++ b/mobile/lib/features/channels/agent_activity/observer_subscription.dart @@ -369,6 +369,7 @@ class ObserverRelayNotifier extends Notifier { agentIndex: frame.agentIndex, channelId: frame.channelId, threadHeadId: frame.threadHeadId, + hasThreadScope: frame.hasThreadScope, sessionId: frame.sessionId, turnId: frame.turnId, startedAt: frame.startedAt, diff --git a/mobile/test/features/channels/agent_activity/observer_subscription_test.dart b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart index 6f59538a7dd..9cc6d786c7e 100644 --- a/mobile/test/features/channels/agent_activity/observer_subscription_test.dart +++ b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart @@ -379,7 +379,7 @@ void main() { seq: 2, channelId: channelId, turnId: 'turn-2', - ); + )..['threadHeadId'] = null; final earlierFrame = _observerFrameJson( seq: 1, channelId: channelId, @@ -412,6 +412,9 @@ void main() { reason: 'batch receipt order must follow timestamp and sequence', ); expect(frames[0].threadHeadId, 'thread-1'); + expect(frames[0].hasThreadScope, isTrue); + expect(frames[1].threadHeadId, isNull); + expect(frames[1].hasThreadScope, isTrue); final state = container.read(observerSubscriptionProvider(key)); expect(state.connection, ObserverConnectionState.open);