From b593c7d7feb6bf4207dbf3439906c25a714b703f Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 28 Aug 2026 15:38:03 -0600 Subject: [PATCH 01/22] perf(mobile): reduce cold startup and channel rendering delays (#6996) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinky is opening this PR on Wes’s behalf. ## Summary Reduce two separately measured mobile delays without changing the relay API or removing rich message rendering: - Publish the finite channel-list snapshot without waiting for live subscription setup. - Batch active channel-list subscriptions into sorted, deterministic chunks of at most 128 explicit channel IDs, retaining unchanged chunks. - Install replacement chunks before retiring old coverage. Retain old chunks across thrown replacement failures; filter callbacks to the current relay/identity and still-desired channels; clean up retired/in-flight work across disconnect and disposal. - Scope the custom-emoji Markdown matcher to known shortcodes actually referenced in the rendered content, rather than embedding the whole community palette in every message’s regex. Preserve unknown literals, shared-colon token boundaries, event-tag URL priority, content edits, and code literals. - Honor explicit zero retry hints without inventing a ten-second session-wide gate, while preserving the ordinary live-subscription retry backoff and any already-active gate. ## Matched performance results Medians of three before and three after process-cold launches, alternated on the same authenticated iPhone 17 Pro / iOS 26.5 simulator. Before is mobile source at `e76c81968b65b0755b83efdd59dc3375c59ddf40`; after is this production patch before two documentation-only comment fixes. First channel-list frame: 11.617s → 3.179s · 73% lower latency Live setup duration: 8.475s → 0.185s · 98% lower latency Channel-open first message-list frame: 2.754s → 1.230s · 55% lower latency Message data ready → first frame: 1.977s → 0.286s · 86% lower latency Channel-open reveal complete: 2.845s → 1.394s · 51% lower latency Channel-open data readiness: 0.770s → 0.944s · 23% higher latency The gain is client-side orchestration/rendering, not a claim that the relay became faster. First channel-list frame ranges were 10.835–11.788s before and 2.872–3.395s after; channel-open first-frame ranges were 1.560–2.906s before and 1.149–1.317s after. ### Measurement boundaries - Debug simulator builds, CPU sampling disabled, bounded timestamp probes enabled identically. These are not release/physical-device measurements. - Startup clock starts at Dart `main`; build/install/native pre-main time is excluded. Auth/preferences and OS/disk caches are retained between new processes. - Same account scale: 113 active channels. Latest-message events varied slightly with live activity (1543–1546). - Channel-open uses the same initial 50-row history window, 97 query events, and 67 provider events. The 2306-entry emoji palette is explicitly loaded before navigation on both sides; palette preparation is excluded from the channel-open clock and happens after the startup frame measurement. - Both diagnostic builds temporarily disabled unused avatar segmentation to work around the existing Google ML Kit arm64-simulator slice limitation. The workaround, dependency/native changes, auto-navigation, and all probes are excluded from this PR. ## Validation - Full mobile package suite: `flutter test` — 1890 passed. - `just mobile-check` — 506 files unchanged; analyzer clean. - `just file-size-check` — policy tests and all client ratchets passed. - `git diff --check` — passed. - New lifecycle regressions cover front-sorting insertion across a chunk boundary while replacement readiness is paused, failure retention/departed-channel filtering, retired generation + disconnect cleanup, disposal, chunk limits, unchanged-set reuse, and scope switches. - Emoji unit/widget coverage includes a 2500-unused-emoji palette, unknown tokens, case matching at the component level, shared-colon boundaries, rich text, event URL priority, and content edits. - Fresh-frame source review traced the subscription queue/fences, callback scopes, duplicate-event paths, matcher/wiring, and retry scheduling. - At committed/pushed head `13a83b628c8411c5885e6f76a250ba87accf6067`, all normal pre-push hooks passed: `mobile-checks` (formatter, analyzer, and the full 1890-test mobile suite), `file-size-check`, `branch-skew`, and `push-head-scope`. The commit hook formatted 506 files with no changes. Runtime measurements preceded only the two documentation-comment fixes; no runtime source changed afterward. ## Limits / follow-ups - `RelaySession.subscribe` still settles under its existing EOSE/fallback/retryable-CLOSED contract. “Setup completed” is not an unconditional EOSE or live-delivery guarantee. This PR does not add status-aware replacement ownership. - The channel-message provider still awaits subscribe before fetching history; that separate serialization is not removed here. - Oversized Huddle queries and the separate history batching path above 128 active channels remain follow-ups, as do pre-existing read-state initialization/size warnings. - Palette-only widget refresh and upstream Markdown uppercase-dispatch behavior are not changed. - A clean source build still has the existing Google ML Kit arm64-simulator issue; the profiling workaround is not a proposed product fix. Originating Buzz conversation: buzz://message?channel=793b0522-7995-4375-b1a6-fd94a96fa21d&id=6ba88afdec78ab2cfb6728afcd4a6d10f29e6aa33ff0f62f45d6750381e4d789 --------- Signed-off-by: Wes Co-authored-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz> --- .../features/channels/channels_provider.dart | 165 +------ .../channels/channels_provider_lifecycle.dart | 272 +++++++++++- .../features/channels/message_content.dart | 6 +- .../custom_emoji/custom_emoji_render.dart | 32 +- .../shared/relay/relay_rate_limit_gate.dart | 20 +- mobile/lib/shared/relay/relay_session.dart | 2 +- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + .../channels_provider_live_cases.dart | 415 ++++++++++++++++++ .../channels_provider_terminal_cases.dart | 254 +++++++++++ .../channels/channels_provider_test.dart | 260 +++-------- .../message_content_custom_emoji_test.dart | 121 +++++ .../custom_emoji_render_test.dart | 109 +++++ .../relay/relay_rate_limit_gate_test.dart | 65 ++- .../test/shared/relay/relay_session_test.dart | 36 ++ 15 files changed, 1383 insertions(+), 377 deletions(-) create mode 100644 mobile/test/features/channels/channels_provider_live_cases.dart create mode 100644 mobile/test/features/channels/channels_provider_terminal_cases.dart create mode 100644 mobile/test/features/channels/message_content_custom_emoji_test.dart create mode 100644 mobile/test/shared/custom_emoji/custom_emoji_render_test.dart diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 33bfa210d1..a696fbe336 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -37,16 +37,18 @@ const _authoredRootIdsPrefix = 'buzz-thread-authored.v1'; /// /// The paginated kind:39000 directory is fetched separately when Browse /// channels opens, so discovery never delays the main Conversations screen. -/// Live updates are layered on top via per-channel subscriptions on the -/// `#h` tag for any of the visible channel event kinds — incoming events -/// bump `lastMessageAt` for that channel. +/// Live updates are layered on top via chunked subscriptions on the `#h` tag +/// for any visible channel event kind. Chunks stay within the relay's explicit +/// channel cap and incoming events bump `lastMessageAt` for their channel. class ChannelsNotifier extends AsyncNotifier> { static const _backstopInterval = Duration(seconds: 60); - final Map _unsubscribersByChannel = {}; + final Map _liveSubscriptionsByChunk = {}; Future _liveSubscriptionQueue = Future.value(); Set _desiredLiveChannelIds = const {}; int _subscriptionVersion = 0; + int _nextLiveChunkGeneration = 0; + final Set _terminallyClosedLiveChunks = {}; String? _subscriptionRelayBaseUrl; Timer? _backstopTimer; final Map _latestObservedByChannel = {}; @@ -66,6 +68,10 @@ class ChannelsNotifier extends AsyncNotifier> { late final _ChannelRefreshCoordinator _refreshCoordinator = _ChannelRefreshCoordinator.forRef(ref); + // Expose the notifier's protected Ref only to same-library extensions used to + // keep lifecycle code below the file-size ratchet. + Ref get _lifecycleRef => ref; + /// The member snapshot already returned while loading the channel list. /// /// Mention autocomplete can use this synchronously while its independent @@ -113,6 +119,9 @@ class ChannelsNotifier extends AsyncNotifier> { !connected.isCompleted) { connected.complete(); } else if (previous?.status != SessionStatus.connected) { + // A new authenticated connection can change relay admission policy. + // Ordinary refreshes must not retry an unchanged terminal rejection. + _terminallyClosedLiveChunks.clear(); unawaited(_backstopRefresh()); } }); @@ -391,11 +400,11 @@ class ChannelsNotifier extends AsyncNotifier> { } } + // Layer live delivery onto the already-built snapshot without making EOSE + // part of the provider's readiness path. Every refresh queues a generation; + // a later refresh or scope switch retires older queued/in-flight work. if (subscribeLive) { - // Subscriptions are shared relay state, so a retired refresh must not - // install them even though its channel list is already built. - fence.ensureCurrent(); - await _fenced(fence, _subscribeLive(channels, fence)); + unawaited(_subscribeLive(channels, fence)); } // Guard the provider-state write in `retryDirectory` and `build`: the // caller assigns whatever this returns, so the last check belongs here. @@ -516,140 +525,6 @@ class ChannelsNotifier extends AsyncNotifier> { ); } - /// Subscribe per-channel to live events (requires `#h` tag for relay - /// channel-scoped fan-out). Also starts a 60s WS backstop poll to reconcile - /// membership changes without repeatedly downloading the global directory. - Future _subscribeLive( - List channels, - _ChannelRefreshFence fence, - ) { - final channelIds = { - for (final channel in channels) - if (channel.isMember && !channel.isArchived) channel.id, - }; - final relayBaseUrl = ref.read(relayConfigProvider).baseUrl; - _desiredLiveChannelIds = channelIds; - final subscriptionVersion = ++_subscriptionVersion; - - final sync = _liveSubscriptionQueue.then( - (_) => _syncLiveSubscriptions( - relayBaseUrl, - subscriptionVersion, - channels, - fence, - ), - ); - _liveSubscriptionQueue = sync.catchError((Object error, StackTrace stack) { - if (error is! _StaleChannelRefresh) { - debugPrint( - '[ChannelsNotifier] live subscription sync failed: $error\n$stack', - ); - } - }); - return sync; - } - - Future _syncLiveSubscriptions( - String relayBaseUrl, - int subscriptionVersion, - List channels, - _ChannelRefreshFence fence, - ) async { - fence.ensureCurrent(); - if (ref.read(relaySessionProvider).status != SessionStatus.connected) { - return; - } - - if (subscriptionVersion != _subscriptionVersion) { - return; - } - - if (_subscriptionRelayBaseUrl != relayBaseUrl) { - for (final unsubscribe in _unsubscribersByChannel.values) { - unsubscribe(); - } - _unsubscribersByChannel.clear(); - _subscriptionRelayBaseUrl = relayBaseUrl; - } - if (ref.read(relayConfigProvider).baseUrl != relayBaseUrl) { - return; - } - final session = ref.read(relaySessionProvider.notifier); - final channelIds = _desiredLiveChannelIds; - - for (final entry in _unsubscribersByChannel.entries.toList()) { - if (channelIds.contains(entry.key)) continue; - _unsubscribersByChannel.remove(entry.key); - entry.value(); - } - - for (final channelId in channelIds) { - if (ref.read(relaySessionProvider).status != SessionStatus.connected) { - return; - } - if (_unsubscribersByChannel.containsKey(channelId)) continue; - try { - final unsubscribe = await session.subscribe( - NostrFilter( - kinds: EventKind.channelEventKinds, - tags: { - '#h': [channelId], - }, - limit: 0, - ), - _handleLiveEvent, - ); - if (!fence.isCurrent) { - unsubscribe(); - throw const _StaleChannelRefresh(); - } - if (subscriptionVersion != _subscriptionVersion || - ref.read(relaySessionProvider).status != SessionStatus.connected || - !_desiredLiveChannelIds.contains(channelId) || - ref.read(relayConfigProvider).baseUrl != relayBaseUrl || - _subscriptionRelayBaseUrl != relayBaseUrl) { - unsubscribe(); - return; - } - final replaced = _unsubscribersByChannel[channelId]; - if (replaced != null) { - unsubscribe(); - continue; - } - _unsubscribersByChannel[channelId] = unsubscribe; - } on _StaleChannelRefresh { - rethrow; - } catch (error) { - debugPrint( - '[ChannelsNotifier] live subscription failed for $channelId: $error', - ); - } - } - - if (ref.read(relaySessionProvider).status != SessionStatus.connected) { - return; - } - - if (subscriptionVersion != _subscriptionVersion) { - final desiredChannelIds = _desiredLiveChannelIds; - for (final entry in _unsubscribersByChannel.entries.toList()) { - if (desiredChannelIds.contains(entry.key)) continue; - _unsubscribersByChannel.remove(entry.key); - entry.value(); - } - return; - } - - fence.ensureCurrent(); - unawaited(_catchUpUnreadEvents(channels, fence, subscriptionVersion)); - - _backstopTimer?.cancel(); - _backstopTimer = Timer.periodic( - _backstopInterval, - (_) => _backstopRefresh(), - ); - } - /// Backfills unread badges for the channels this refresh just installed. /// /// Runs detached from the refresh that starts it, so the lifecycle token @@ -668,6 +543,7 @@ class ChannelsNotifier extends AsyncNotifier> { _ChannelRefreshFence fence, int subscriptionGeneration, ) async { + if (!ref.mounted) return; final myPk = ref.read(myPubkeyProvider); if (myPk == null) return; @@ -712,7 +588,9 @@ class ChannelsNotifier extends AsyncNotifier> { // The relay round-trip above is the window Jed's probes park in: a newer // refresh, a community switch or an identity switch here means every // write below belongs to a channel list the user has left. - if (_isCatchUpRetired(fence, subscriptionGeneration)) return; + if (!ref.mounted || _isCatchUpRetired(fence, subscriptionGeneration)) { + return; + } for (final event in events) { if (event.pubkey.toLowerCase() == myPk.toLowerCase()) { @@ -752,6 +630,7 @@ class ChannelsNotifier extends AsyncNotifier> { state = state.whenData((channels) => List.of(channels)); } } catch (error) { + if (!ref.mounted) return; debugPrint('[ChannelsNotifier] unread catch-up failed: $error'); } } diff --git a/mobile/lib/features/channels/channels_provider_lifecycle.dart b/mobile/lib/features/channels/channels_provider_lifecycle.dart index 95cb0889ce..8c88377aa9 100644 --- a/mobile/lib/features/channels/channels_provider_lifecycle.dart +++ b/mobile/lib/features/channels/channels_provider_lifecycle.dart @@ -1,18 +1,274 @@ part of 'channels_provider.dart'; -// This extension exists only to keep `channels_provider.dart` under the -// desktop/mobile file-size ratchet; `_clearLiveSubscriptions` is not a -// standalone semantic boundary and belongs to `ChannelsNotifier`. -extension _ChannelsNotifierSubscriptionCleanup on ChannelsNotifier { +// Mirrors MAX_EXPLICIT_CHANNEL_VALUES in the relay REQ handler. A larger live +// set must use multiple subscriptions or the relay rejects the whole REQ. +const _maxLiveChannelsPerSubscription = 128; + +/// Owns live channel subscription reconciliation and teardown. +/// +/// Keeping this extension in a part preserves access to the notifier's private +/// lifecycle state while keeping `channels_provider.dart` below the mobile +/// file-size ratchet. +extension _ChannelsNotifierLiveSubscriptions on ChannelsNotifier { + /// Subscribe to live events in deterministic chunks that stay within the + /// relay's aggregate explicit-`#h` limit. Also starts a 60s WS backstop poll + /// to reconcile membership changes without downloading the global directory. + Future _subscribeLive( + List channels, + _ChannelRefreshFence fence, + ) { + final channelIds = { + for (final channel in channels) + if (channel.isMember && !channel.isArchived) channel.id, + }; + final relayBaseUrl = _lifecycleRef.read(relayConfigProvider).baseUrl; + _desiredLiveChannelIds = channelIds; + // A changed filter may be admitted, but an unchanged terminal rejection + // cannot recover just because a timer or manual refresh fetched it again. + // Drop obsolete keys so this quarantine stays bounded by desired chunks. + final desiredKeys = chunkChannelIdsForLiveSubscriptions( + channelIds, + ).map(_liveChunkKey).toSet(); + _terminallyClosedLiveChunks.retainAll(desiredKeys); + final subscriptionVersion = ++_subscriptionVersion; + + final sync = _liveSubscriptionQueue.then( + (_) => _syncLiveSubscriptions( + relayBaseUrl, + subscriptionVersion, + channels, + fence, + ), + ); + _liveSubscriptionQueue = sync + .whenComplete(() { + // Reconcile even if a retired generation exits before its successor + // can run (for example, a disconnect while subscribe is pending). + if (_lifecycleRef.mounted) _removeUndesiredLiveChunks(); + }) + .catchError((Object error, StackTrace stack) { + if (error is! _StaleChannelRefresh) { + debugPrint( + '[ChannelsNotifier] live subscription sync failed: $error\n$stack', + ); + } + }); + return _liveSubscriptionQueue; + } + + Future _syncLiveSubscriptions( + String relayBaseUrl, + int subscriptionVersion, + List channels, + _ChannelRefreshFence fence, + ) async { + if (!_lifecycleRef.mounted) return; + fence.ensureCurrent(); + if (_lifecycleRef.read(relaySessionProvider).status != + SessionStatus.connected || + subscriptionVersion != _subscriptionVersion) { + return; + } + + if (_subscriptionRelayBaseUrl != relayBaseUrl) { + _clearRetainedLiveChunks(); + _subscriptionRelayBaseUrl = relayBaseUrl; + } + if (_lifecycleRef.read(relayConfigProvider).baseUrl != relayBaseUrl) return; + + final desiredChunks = chunkChannelIdsForLiveSubscriptions( + _desiredLiveChannelIds, + ); + // Keep existing coverage while replacements are installed. Cleanup runs + // when this generation settles, and retains old coverage on failures. + + final session = _lifecycleRef.read(relaySessionProvider.notifier); + for (final chunk in desiredChunks) { + final chunkKey = _liveChunkKey(chunk); + if (_liveSubscriptionsByChunk.containsKey(chunkKey) || + _terminallyClosedLiveChunks.contains(chunkKey)) { + continue; + } + if (_lifecycleRef.read(relaySessionProvider).status != + SessionStatus.connected) { + return; + } + final generation = ++_nextLiveChunkGeneration; + final subscription = _LiveChunkSubscription(generation); + _liveSubscriptionsByChunk[chunkKey] = subscription; + try { + final unsubscribe = await session.subscribe( + NostrFilter( + kinds: EventKind.channelEventKinds, + tags: {'#h': chunk}, + limit: 0, + ), + (event) { + // Superseded chunks can overlap during replacement or recovery. + // Deliver only the current scope's still-desired channels; duplicate + // events are already idempotent in the unread/timestamp stores. + if (!_lifecycleRef.mounted || + _refreshCoordinator.currentScope() != fence.scope || + !_desiredLiveChannelIds.contains(event.channelId)) { + return; + } + _handleLiveEvent(event); + }, + onClosed: (message) => + _handleLiveChunkClosed(chunkKey, generation, message), + ); + subscription.unsubscribe = unsubscribe; + if (!_lifecycleRef.mounted || !fence.isCurrent) { + _liveSubscriptionsByChunk.remove(chunkKey); + unsubscribe(); + if (!fence.isCurrent) throw const _StaleChannelRefresh(); + return; + } + if (subscriptionVersion != _subscriptionVersion || + _lifecycleRef.read(relaySessionProvider).status != + SessionStatus.connected || + _lifecycleRef.read(relayConfigProvider).baseUrl != relayBaseUrl || + _subscriptionRelayBaseUrl != relayBaseUrl || + !chunk.every(_desiredLiveChannelIds.contains)) { + _liveSubscriptionsByChunk.remove(chunkKey); + unsubscribe(); + return; + } + if (_liveSubscriptionsByChunk[chunkKey] != subscription) { + unsubscribe(); + continue; + } + } on _StaleChannelRefresh { + rethrow; + } catch (error) { + if (_liveSubscriptionsByChunk[chunkKey] == subscription) { + _liveSubscriptionsByChunk.remove(chunkKey); + } + if (!_lifecycleRef.mounted) return; + debugPrint( + '[ChannelsNotifier] live subscription failed for ' + '${chunk.length} channels: $error', + ); + } + } + + if (!_lifecycleRef.mounted || + _lifecycleRef.read(relaySessionProvider).status != + SessionStatus.connected || + subscriptionVersion != _subscriptionVersion) { + return; + } + + fence.ensureCurrent(); + unawaited(_catchUpUnreadEvents(channels, fence, subscriptionVersion)); + + _backstopTimer?.cancel(); + _backstopTimer = Timer.periodic( + ChannelsNotifier._backstopInterval, + (_) => _backstopRefresh(), + ); + } + + void _handleLiveChunkClosed(String chunkKey, int generation, String message) { + if (!_lifecycleRef.mounted) return; + final subscription = _liveSubscriptionsByChunk[chunkKey]; + if (subscription == null || subscription.generation != generation) return; + _liveSubscriptionsByChunk.remove(chunkKey); + // RelaySession calls onClosed only for terminal admission failures. Its + // transient/rate-limit retries have their own backoff. Do not turn a + // terminal rejection into a fresh membership/history/REQ loop here. + if (chunkChannelIdsForLiveSubscriptions( + _desiredLiveChannelIds, + ).any((chunk) => _liveChunkKey(chunk) == chunkKey)) { + _terminallyClosedLiveChunks.add(chunkKey); + } + debugPrint( + '[ChannelsNotifier] live subscription closed by relay: $message', + ); + } + + void _removeUndesiredLiveChunks() { + final desiredChunks = chunkChannelIdsForLiveSubscriptions( + _desiredLiveChannelIds, + ); + final desiredKeys = desiredChunks.map(_liveChunkKey).toSet(); + final retainedKeys = {...desiredKeys}; + final uncoveredIds = {}; + for (final chunk in desiredChunks) { + final key = _liveChunkKey(chunk); + if (!_liveSubscriptionsByChunk.containsKey(key)) { + uncoveredIds.addAll(chunk); + } + } + final obsolete = _liveSubscriptionsByChunk.entries + .where((entry) => !desiredKeys.contains(entry.key)) + .toList(); + while (uncoveredIds.isNotEmpty) { + MapEntry? best; + var bestCoverage = 0; + for (final entry in obsolete) { + if (retainedKeys.contains(entry.key)) continue; + final coverage = entry.key + .split('\u0000') + .where(uncoveredIds.contains) + .length; + if (coverage > bestCoverage) { + best = entry; + bestCoverage = coverage; + } + } + if (best == null) break; + retainedKeys.add(best.key); + uncoveredIds.removeAll(best.key.split('\u0000')); + } + for (final entry in _liveSubscriptionsByChunk.entries.toList()) { + if (retainedKeys.contains(entry.key)) continue; + _liveSubscriptionsByChunk.remove(entry.key); + entry.value.unsubscribe?.call(); + } + } + + void _clearRetainedLiveChunks() { + for (final subscription in _liveSubscriptionsByChunk.values) { + subscription.unsubscribe?.call(); + } + _liveSubscriptionsByChunk.clear(); + } + void _clearLiveSubscriptions() { _subscriptionVersion++; _desiredLiveChannelIds = const {}; - for (final unsubscribe in _unsubscribersByChannel.values) { - unsubscribe(); - } - _unsubscribersByChannel.clear(); + _terminallyClosedLiveChunks.clear(); + _clearRetainedLiveChunks(); _subscriptionRelayBaseUrl = null; _backstopTimer?.cancel(); _backstopTimer = null; } } + +/// Returns deterministic channel-ID chunks within the relay's live REQ cap. +List> chunkChannelIdsForLiveSubscriptions( + Iterable channelIds, +) { + final sortedIds = channelIds.toList()..sort(); + return [ + for ( + var start = 0; + start < sortedIds.length; + start += _maxLiveChannelsPerSubscription + ) + sortedIds.sublist( + start, + min(start + _maxLiveChannelsPerSubscription, sortedIds.length), + ), + ]; +} + +String _liveChunkKey(List channelIds) => channelIds.join('\u0000'); + +class _LiveChunkSubscription { + _LiveChunkSubscription(this.generation); + + final int generation; + void Function()? unsubscribe; +} diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 64938b5c87..0fb3022409 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -287,7 +287,11 @@ class MessageContent extends HookConsumerWidget { agentMentionPubkeys: resolvedAgentMentionPubkeys, onMentionTap: onMentionTap, ), - CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize), + CustomEmojiMd( + customEmoji, + content: finalContent, + size: inlineCustomEmojiSize, + ), _ChannelLinkMd( channelNames: resolvedChannelNames, onChannelTap: resolvedChannelTap, diff --git a/mobile/lib/shared/custom_emoji/custom_emoji_render.dart b/mobile/lib/shared/custom_emoji/custom_emoji_render.dart index e4af0cc43f..feede9aec8 100644 --- a/mobile/lib/shared/custom_emoji/custom_emoji_render.dart +++ b/mobile/lib/shared/custom_emoji/custom_emoji_render.dart @@ -57,8 +57,36 @@ class CustomEmojiMd extends InlineMd { final double size; late final RegExp _exp = _buildPattern(_urlByShortcode.keys); - CustomEmojiMd(List palette, {this.size = kCustomEmojiInlineSize}) - : _urlByShortcode = {for (final e in palette) e.shortcode: e.url}; + /// Only include shortcodes present in the rendered [content]. gpt_markdown + /// embeds this pattern in a combined regex for every parsed text segment, so + /// unrelated community emoji must not make every message expensive to parse. + CustomEmojiMd( + List palette, { + required String content, + this.size = kCustomEmojiInlineSize, + }) : _urlByShortcode = _referencedUrls(palette, content); + + // Look ahead so adjacent tokens sharing a colon are both considered: + // :unknown:known: must still allow the known token to match. + static final _shortcodeScan = RegExp( + r'(?=:([a-z0-9_-]+):)', + caseSensitive: false, + ); + + static Map _referencedUrls( + List palette, + String content, + ) { + final referenced = { + for (final match in _shortcodeScan.allMatches(content)) + match.group(1)!.toLowerCase(), + }; + if (referenced.isEmpty) return const {}; + return { + for (final emoji in palette) + if (referenced.contains(emoji.shortcode)) emoji.shortcode: emoji.url, + }; + } @override RegExp get exp => _exp; diff --git a/mobile/lib/shared/relay/relay_rate_limit_gate.dart b/mobile/lib/shared/relay/relay_rate_limit_gate.dart index 6164013936..92ea7d85f0 100644 --- a/mobile/lib/shared/relay/relay_rate_limit_gate.dart +++ b/mobile/lib/shared/relay/relay_rate_limit_gate.dart @@ -7,7 +7,7 @@ typedef RelayTimerFactory = /// Session-owned gate that pauses relay requests after back-pressure. class RelayRateLimitGate { - /// Default gate duration when the relay omits a positive retry hint. + /// Default gate duration when the relay omits a parseable retry hint. static const defaultRetrySeconds = 10; /// Longest retry hint accepted from a relay response. @@ -32,10 +32,22 @@ class RelayRateLimitGate { } /// Activates or extends the gate without shrinking an existing window. + /// + /// A null [retryInSeconds] means the relay sent no parseable hint, so the + /// conservative [defaultRetrySeconds] applies. An explicit hint is honored as + /// given: the relay sends `retry in 0s` to mean "retry immediately", so a + /// non-positive hint opens no window at all. Treating `0` as if it were + /// absent previously gated every relay read for [defaultRetrySeconds] — on a + /// cold start with many channels that turned the relay's own "go now" into a + /// ten-second stall of the whole session. + /// + /// An already-active window is never shortened, so an immediate hint arriving + /// mid-window leaves that window intact. void activate(int? retryInSeconds) { - final seconds = retryInSeconds != null && retryInSeconds > 0 - ? min(retryInSeconds, maxRetrySeconds) - : defaultRetrySeconds; + if (retryInSeconds != null && retryInSeconds <= 0) return; + final seconds = retryInSeconds == null + ? defaultRetrySeconds + : min(retryInSeconds, maxRetrySeconds); final duration = Duration(seconds: seconds); final newExpiry = _now().add(duration); final currentExpiry = _expiresAt; diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index b8d13ee34a..a8209a9557 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -737,7 +737,7 @@ class RelaySessionNotifier extends Notifier { final retrySeconds = parseRateLimitRetrySeconds(message); _rateLimitGate.activate(retrySeconds); final fallbackMs = - (retrySeconds != null && retrySeconds > 0 + (retrySeconds != null ? min(retrySeconds, RelayRateLimitGate.maxRetrySeconds) : RelayRateLimitGate.defaultRetrySeconds) * 1000; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 46ea8cf564..4526e1f336 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -354,7 +354,7 @@ packages: source: hosted version: "0.3.12" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 4543888551..0abcc8a77e 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -51,6 +51,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + fake_async: ^1.3.3 camera_platform_interface: ^2.13.1 crypto: ^3.0.7 custom_lint: ^0.8.0 diff --git a/mobile/test/features/channels/channels_provider_live_cases.dart b/mobile/test/features/channels/channels_provider_live_cases.dart new file mode 100644 index 0000000000..2850114412 --- /dev/null +++ b/mobile/test/features/channels/channels_provider_live_cases.dart @@ -0,0 +1,415 @@ +part of 'channels_provider_test.dart'; + +void _liveSubscriptionTests() { + const myPk = 'me'; + + test( + 'subscribes once for joined non-archived channels without blocking snapshot', + () async { + final session = _FakeRelaySession( + memberships: [ + _membership(_channelA, myPk), + _membership(_channelB, myPk), + _membership(_channelD, myPk), + ], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'random'), + // channelD metadata missing -> won't appear in channel list + ], + ); + session.pauseNextSubscribe(); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + final channels = await container.read(channelsProvider.future); + + expect(channels.map((channel) => channel.id).toSet(), { + _channelA, + _channelB, + }); + await session.nextSubscribeStarted; + expect(session.subscribeFilters, hasLength(1)); + expect(session.subscribeFilters.single.tags['#h'], [ + _channelA, + _channelB, + ]); + expect( + session.subscribeFilters.single.kinds, + EventKind.channelEventKinds, + ); + expect(session.subscribeFilters.single.limit, 0); + + session.resumePausedSubscribe(); + await _waitUntil(() => session.activeChannels.length == 2); + }, + ); + + test('chunks live subscriptions at the relay explicit-channel cap', () async { + final channelIds = [for (var i = 0; i < 257; i++) _generatedChannelId(i)]; + final session = _FakeRelaySession( + memberships: [for (final id in channelIds) _membership(id, myPk)], + metadata: [for (final id in channelIds) _meta(id: id, name: id)], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + final channels = await container.read(channelsProvider.future); + expect(channels, hasLength(257)); + await _waitUntil(() => session.subscribeFilters.length == 3); + + expect( + session.subscribeFilters.map((filter) => filter.tags['#h']!.length), + [128, 128, 1], + ); + expect( + session.subscribeFilters.expand((filter) => filter.tags['#h']!).toSet(), + channelIds.toSet(), + ); + }); + + test( + 'refreshing an unchanged channel set issues zero new live REQs', + () async { + final session = _FakeRelaySession( + memberships: [ + _membership(_channelA, myPk), + _membership(_channelB, myPk), + ], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'random'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 1); + final initialSubscribeCount = session.totalSubscribeCount; + + await container.read(channelsProvider.notifier).refresh(); + await _settle(); + + expect(session.totalSubscribeCount, initialSubscribeCount); + expect(session.unsubscribeCount, 0); + expect(session.subscribeFilters, hasLength(1)); + }, + ); + + test('changing a live chunk replaces only that chunk', () async { + final channelIds = [for (var i = 0; i < 129; i++) _generatedChannelId(i)]; + final addedId = _generatedChannelId(999); + final session = _FakeRelaySession( + memberships: [for (final id in channelIds) _membership(id, myPk)], + metadata: [for (final id in channelIds) _meta(id: id, name: id)], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 2); + + session.memberships = [ + for (final id in [...channelIds.take(128), addedId]) + _membership(id, myPk), + ]; + session.metadata = [ + for (final id in [...channelIds.take(128), addedId]) + _meta(id: id, name: id), + ]; + await container.read(channelsProvider.notifier).refresh(); + await _waitUntil(() => session.totalSubscribeCount == 3); + + expect(session.activeSubscriptionCount, 2); + expect(session.unsubscribeCount, 1); + expect(session.activeChannels, {...channelIds.take(128), addedId}); + }); + + test( + 'front-sorting membership insertion retains coverage while replacement waits', + () async { + final channelIds = [ + for (var i = 1; i <= 129; i++) _generatedChannelId(i), + ]; + final addedId = _generatedChannelId(0); + final session = _FakeRelaySession( + memberships: [for (final id in channelIds) _membership(id, myPk)], + metadata: [for (final id in channelIds) _meta(id: id, name: id)], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 2); + + session.memberships.add(_membership(addedId, myPk)); + session.metadata.add(_meta(id: addedId, name: addedId)); + session.pauseNextSubscribe(); + await container.read(channelsProvider.notifier).refresh(); + await session.nextSubscribeStarted; + try { + expect(session.activeChannels, channelIds.toSet()); + expect(session.unsubscribeCount, 0); + session.emit(_liveMessage(channelIds.last)); + expect( + container + .read(channelsProvider) + .requireValue + .firstWhere((channel) => channel.id == channelIds.last) + .lastMessageAt + ?.millisecondsSinceEpoch, + 20000, + ); + } finally { + session.resumePausedSubscribe(); + } + await _waitUntil(() => session.unsubscribeCount == 2); + expect(session.totalSubscribeCount, 4); + expect(session.activeSubscriptionCount, 2); + expect(session.activeChannels, {...channelIds, addedId}); + }, + ); + + test( + 'failed replacement keeps desired coverage and ignores departed channels', + () async { + final session = _FakeRelaySession( + memberships: [ + _membership(_channelA, myPk), + _membership(_channelB, myPk), + ], + metadata: [ + _meta(id: _channelA, name: 'a'), + _meta(id: _channelB, name: 'b'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 1); + session.memberships = [ + _membership(_channelB, myPk), + _membership(_channelD, myPk), + ]; + session.metadata = [ + _meta(id: _channelB, name: 'b'), + _meta(id: _channelD, name: 'd'), + ]; + session.subscribeFailures = 1; + await container.read(channelsProvider.notifier).refresh(); + await _settle(); + expect(session.activeChannels, {_channelA, _channelB}); + expect(session.unsubscribeCount, 0); + final requests = session.membershipRequestCount; + session.emit(_liveMessage(_channelA)); + await _settle(); + expect(session.membershipRequestCount, requests); + session.emit(_liveMessage(_channelB)); + expect( + container + .read(channelsProvider) + .requireValue + .firstWhere((channel) => channel.id == _channelB) + .lastMessageAt + ?.millisecondsSinceEpoch, + 20000, + ); + + await container.read(channelsProvider.notifier).refresh(); + await _waitUntil(() => session.unsubscribeCount == 1); + expect(session.activeChannels, {_channelB, _channelD}); + expect(session.activeSubscriptionCount, 1); + }, + ); + + test( + 'terminal closure preserves fallback until reconnect retries coverage', + () async { + final channelIds = [ + for (var i = 1; i <= 129; i++) _generatedChannelId(i), + ]; + final addedId = _generatedChannelId(0); + final session = _FakeRelaySession( + memberships: [for (final id in channelIds) _membership(id, myPk)], + metadata: [for (final id in channelIds) _meta(id: id, name: id)], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 2); + + session.memberships.add(_membership(addedId, myPk)); + session.metadata.add(_meta(id: addedId, name: addedId)); + session.subscribeFailures = 1; + await container.read(channelsProvider.notifier).refresh(); + await _settle(); + expect(session.activeChannels, containsAll(channelIds)); + + session.closeSubscriptionContaining( + channelIds.last, + 'restricted: terminal closure', + ); + await _settle(); + // The rejected chunk itself is gone, but the previously retained + // fallback must still cover its original first 128 channels. + expect(session.activeChannels, channelIds.take(128).toSet()); + expect(session.activeChannels, isNot(contains(addedId))); + session.setStatus(SessionStatus.disconnected); + session.setStatus(SessionStatus.connected); + await _waitUntil(() => session.activeChannels.contains(addedId)); + + expect(session.activeChannels, containsAll({...channelIds, addedId})); + expect(session.activeSubscriptionCount, 2); + }, + ); + + test( + 'partial replacement churn keeps complete coverage with bounded fallbacks', + () async { + var channelIds = [for (var i = 100; i < 356; i++) _generatedChannelId(i)]; + final session = _FakeRelaySession( + memberships: [for (final id in channelIds) _membership(id, myPk)], + metadata: [for (final id in channelIds) _meta(id: id, name: id)], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 2); + + for (var cycle = 0; cycle < 7; cycle++) { + final addedId = _generatedChannelId(99 - cycle); + channelIds = [addedId, ...channelIds.take(255)]; + session.memberships = [ + for (final id in channelIds) _membership(id, myPk), + ]; + session.metadata = [ + for (final id in channelIds) _meta(id: id, name: id), + ]; + session.successfulSubscribesBeforeFailure = 1; + session.subscribeFailures = 1; + await container.read(channelsProvider.notifier).refresh(); + await _settle(); + expect(session.activeChannels, containsAll(channelIds)); + expect(session.activeSubscriptionCount, lessThanOrEqualTo(3)); + } + }, + ); + + test( + 'retired replacement cleans up even when the next generation disconnects', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'a')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 1); + session.memberships.add(_membership(_channelB, myPk)); + session.metadata.add(_meta(id: _channelB, name: 'b')); + session.pauseNextSubscribe(); + await container.read(channelsProvider.notifier).refresh(); + await session.nextSubscribeStarted; + try { + expect(session.activeChannels, {_channelA}); + session.memberships = []; + session.metadata = []; + await container.read(channelsProvider.notifier).refresh(); + session.setStatus(SessionStatus.disconnected); + } finally { + session.resumePausedSubscribe(); + } + await _waitUntil(() => session.unsubscribeCount == 2); + expect(session.activeChannels, isEmpty); + expect(session.activeSubscriptionCount, 0); + }, + ); + + test('disposal retires retained and in-flight replacement chunks', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'a')], + ); + final container = _buildContainer(session: session); + await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 1); + session.memberships.add(_membership(_channelB, myPk)); + session.metadata.add(_meta(id: _channelB, name: 'b')); + session.pauseNextSubscribe(); + await container.read(channelsProvider.notifier).refresh(); + await session.nextSubscribeStarted; + container.dispose(); + session.resumePausedSubscribe(); + await _waitUntil(() => session.unsubscribeCount == 2); + expect(session.activeSubscriptionCount, 0); + }); + + test('empty channel refresh removes every retained live chunk', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk), _membership(_channelB, myPk)], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'random'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 1); + session.memberships = []; + session.metadata = []; + + await container.read(channelsProvider.notifier).refresh(); + await _waitUntil(() => session.activeSubscriptionCount == 0); + + expect(session.activeChannels, isEmpty); + expect(session.unsubscribeCount, 1); + }); + + test( + 'community switch retires a detached old-scope live subscription', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + ); + session.pauseNextSubscribe(); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect( + (await container.read(channelsProvider.future)).single.id, + _channelA, + ); + await session.nextSubscribeStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'random')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://new-community.example'); + await container.read(channelsProvider.future); + + session.resumePausedSubscribe(); + await _waitUntil(() => session.activeChannels.contains(_channelB)); + + expect(session.activeChannels, {_channelB}); + expect(session.activeSubscriptionCount, 1); + expect(session.unsubscribeCount, 1); + }, + ); +} + +NostrEvent _liveMessage(String channelId) => NostrEvent( + id: 'live-$channelId', + pubkey: 'alice', + createdAt: 20, + kind: EventKind.streamMessageV2, + tags: [ + ['h', channelId], + ], + content: 'live update', + sig: 'sig', +); diff --git a/mobile/test/features/channels/channels_provider_terminal_cases.dart b/mobile/test/features/channels/channels_provider_terminal_cases.dart new file mode 100644 index 0000000000..dfbc41db60 --- /dev/null +++ b/mobile/test/features/channels/channels_provider_terminal_cases.dart @@ -0,0 +1,254 @@ +part of 'channels_provider_test.dart'; + +void _testWithClock(String name, void Function(FakeAsync) body) { + test(name, () => fakeAsync(body)); +} + +void _terminalSubscriptionTests() { + for (final beforeReady in [true, false]) { + _testWithClock( + 'persistent terminal rejection ${beforeReady ? 'before' : 'after'} ' + 'readiness cannot amplify refreshes or retries', + (clock) { + final session = _TerminalRelaySession(beforeReady: beforeReady); + final logs = []; + final originalDebugPrint = debugPrint; + debugPrint = (message, {wrapWidth}) { + if (message != null) logs.add(message); + }; + final container = _buildContainer(session: session); + container.read(channelsProvider); + try { + clock.flushMicrotasks(); + // The finite snapshot is usable even before live readiness settles. + expect( + container.read(channelsProvider).requireValue.single.id, + _channelA, + ); + expect(session.totalSubscribeCount, 1); + final initialMemberships = session.membershipRequestCount; + clock.elapse(const Duration(milliseconds: 2)); + final historyAfterClose = session.historyFilters.length; + final batchesAfterClose = session.queryBatches.length; + final logsAfterClose = logs.length; + + // Virtual time permits many immediate retries on the broken path. + for (var i = 0; i < 20; i++) { + clock.elapse(const Duration(milliseconds: 10)); + } + expect(session.rejectionCount, 1); + expect(session.membershipRequestCount, initialMemberships); + expect(session.historyFilters.length, historyAfterClose); + expect(session.queryBatches.length, batchesAfterClose); + expect(session.totalSubscribeCount, 1); + expect(logs.length, logsAfterClose); + expect(clock.periodicTimerCount, 1); + expect(clock.nonPeriodicTimerCount, 0); + + // Ordinary polling may refresh memberships, but never re-admits an + // unchanged terminal filter, creates retry timers, or repeats logs. + final pollStart = session.membershipRequestCount; + for (var i = 0; i < 3; i++) { + clock.elapse(const Duration(seconds: 60)); + expect(session.totalSubscribeCount, 1); + expect(session.activeSubscriptionCount, 0); + expect(logs.length, logsAfterClose); + expect(clock.periodicTimerCount, 1); + expect(clock.nonPeriodicTimerCount, 0); + expect( + container.read(channelsProvider).requireValue.single.id, + _channelA, + ); + } + expect(session.membershipRequestCount - pollStart, 6); + + // A pull-to-refresh still isn't evidence that admission changed. + unawaited(container.read(channelsProvider.notifier).refresh()); + clock.flushMicrotasks(); + clock.elapse(const Duration(milliseconds: 2)); + expect(session.totalSubscribeCount, 1); + expect(logs.length, logsAfterClose); + } finally { + container.dispose(); + debugPrint = originalDebugPrint; + } + clock.elapse(const Duration(seconds: 60)); + expect(clock.pendingTimers, isEmpty); + expect(session.totalSubscribeCount, 1); + }, + ); + } + + for (final beforeReady in [true, false]) { + _testWithClock( + 'disposal fences pending terminal closure ${beforeReady ? 'before' : 'after'} readiness', + (clock) { + final session = _TerminalRelaySession(beforeReady: beforeReady); + final container = _buildContainer(session: session); + container.read(channelsProvider); + clock.flushMicrotasks(); + final requests = session.membershipRequestCount; + container.dispose(); + clock.elapse(const Duration(minutes: 3)); + expect(session.totalSubscribeCount, 1); + expect(session.membershipRequestCount, requests); + expect(session.activeSubscriptionCount, 0); + expect(clock.pendingTimers, isEmpty); + }, + ); + } + + _testWithClock('terminal rejection retries only after a new connection', ( + clock, + ) { + final session = _TerminalRelaySession(); + final container = _buildContainer(session: session); + try { + container.read(channelsProvider); + clock.flushMicrotasks(); + clock.elapse(const Duration(milliseconds: 2)); + final oldClosed = session.closedCallbacks.single; + for (var attempt = 2; attempt <= 4; attempt++) { + session.setStatus(SessionStatus.disconnected); + clock.flushMicrotasks(); + session.setStatus(SessionStatus.connected); + clock.flushMicrotasks(); + clock.elapse(const Duration(milliseconds: 2)); + clock.elapse(const Duration(milliseconds: 20)); + expect(session.totalSubscribeCount, attempt); + expect(session.rejectionCount, attempt); + expect(session.activeSubscriptionCount, 0); + expect( + container.read(channelsProvider).requireValue.single.id, + _channelA, + ); + } + session.reject = false; + session.setStatus(SessionStatus.disconnected); + clock.flushMicrotasks(); + session.setStatus(SessionStatus.connected); + clock.flushMicrotasks(); + expect(session.totalSubscribeCount, 5); + expect(session.activeChannels, {_channelA}); + oldClosed(); + clock.flushMicrotasks(); + expect(session.activeChannels, {_channelA}); + expect(session.totalSubscribeCount, 5); + } finally { + container.dispose(); + } + clock.flushMicrotasks(); + }); + + _testWithClock( + 'changed membership admits a new filter, not a rejected old one', + (clock) { + final session = _TerminalRelaySession(); + final container = _buildContainer(session: session); + try { + container.read(channelsProvider); + clock.flushMicrotasks(); + clock.elapse(const Duration(milliseconds: 2)); + session.reject = false; + session.memberships.add(_membership(_channelB, 'me')); + session.metadata.add(_meta(id: _channelB, name: 'b')); + unawaited(container.read(channelsProvider.notifier).refresh()); + clock.flushMicrotasks(); + expect(session.totalSubscribeCount, 2); + expect(session.activeChannels, {_channelA, _channelB}); + // Removing then rejoining is a meaningful transition even if it returns + // to an earlier filter; obsolete quarantine keys must not leak forever. + session.memberships.removeLast(); + unawaited(container.read(channelsProvider.notifier).refresh()); + clock.flushMicrotasks(); + expect(session.totalSubscribeCount, 3); + expect(session.activeChannels, {_channelA}); + } finally { + container.dispose(); + } + clock.flushMicrotasks(); + }, + ); + + for (final switchIdentity in [true, false]) { + _testWithClock( + 'terminal quarantine does not cross ${switchIdentity ? 'identity' : 'community'} scope', + (clock) { + final session = _TerminalRelaySession(); + final container = _buildContainer(session: session); + try { + container.read(channelsProvider); + clock.flushMicrotasks(); + clock.elapse(const Duration(milliseconds: 2)); + session.reject = false; + if (switchIdentity) { + session.memberships = [_membership(_channelA, _otherPk)]; + container.read(_testPubkeyProvider.notifier).set(_otherPk); + } else { + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://new-community.example'); + } + container.read(channelsProvider); + clock.flushMicrotasks(); + expect(session.totalSubscribeCount, 2); + expect(session.activeChannels, {_channelA}); + } finally { + container.dispose(); + } + clock.flushMicrotasks(); + }, + ); + } +} + +/// Reject every attempted subscription, including every replacement, as a +/// terminal relay CLOSED. A timer models the wire turn and prevents a broken +/// immediate retry loop from hanging the test's microtask drain. +class _TerminalRelaySession extends _FakeRelaySession { + _TerminalRelaySession({this.beforeReady = false}) + : super( + memberships: [_membership(_channelA, 'me')], + metadata: [_meta(id: _channelA, name: 'a')], + ); + + final bool beforeReady; + bool reject = true; + int rejectionCount = 0; + final List closedCallbacks = []; + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + final unsubscribe = await super.subscribe( + filter, + onEvent, + onClosed: onClosed, + ); + if (!reject) return unsubscribe; + final key = _subscriptions.keys.last; + final closed = Completer(); + final timer = Timer(const Duration(milliseconds: 1), () { + final subscription = _subscriptions.remove(key); + if (subscription != null) { + subscribeFilters.remove(subscription.$1); + rejectionCount++; + void notify() => onClosed?.call('restricted: persistent rejection'); + closedCallbacks.add(notify); + notify(); + } + closed.complete(); + }); + if (beforeReady) { + await closed.future; + throw StateError('restricted: persistent rejection'); + } + return () { + timer.cancel(); + unsubscribe(); + }; + } +} diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 04f7cd917b..3bb1be5beb 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -7,12 +8,15 @@ import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; +part 'channels_provider_live_cases.dart'; +part 'channels_provider_terminal_cases.dart'; + /// Tests for [ChannelsNotifier] in the pure-Nostr world. /// /// The provider loads membership-backed channels first: /// 1. paginated kind:39002 memberships tagged `#p:` /// 2. kind:39000 metadata for those channel ids -/// then layers per-channel live subscriptions on the `#h` tag. Browse channels +/// then layers chunked live subscriptions on the `#h` tag. Browse channels /// separately triggers paginated kind:39000 open-channel discovery. /// /// Tests stub out the relay session by overriding [relaySessionProvider] with @@ -545,13 +549,15 @@ void main() { .retryDirectory(); await session.nextSubscribeStarted; - // Record every list emitted from the switch onward. The live-subscription - // queue is serialized, so community B's own subscribe waits behind the - // parked one. That makes the observable defect an emission of community - // A's channel into the current scope, not just a wrong final state. + await staleRefresh; + await _settle(); + + // The old scope's finite snapshot is allowed to publish before its live + // subscription becomes ready. From the actual switch onward, however, + // neither that snapshot nor its detached live work may republish. final emitted = >[]; container.listen(channelsProvider, (previous, next) { - final value = next.value; + final value = next.asData?.value; if (value != null) { emitted.add(value.map((channel) => channel.id).toList()); } @@ -562,9 +568,9 @@ void main() { container .read(relayConfigProvider.notifier) .update(baseUrl: 'https://community-b.example'); + await container.read(channelsProvider.future); session.resumePausedSubscribe(); - await staleRefresh; await _settle(); expect( @@ -1535,6 +1541,7 @@ void main() { expect(channels.map((channel) => channel.id), [_channelA, _channelB]); expect(channels.first.isMember, isTrue); expect(channels.last.isMember, isFalse); + await _waitUntil(() => session.subscribeFilters.length == 1); expect(session.subscribeFilters, hasLength(1)); }); @@ -1574,38 +1581,8 @@ void main() { }, ); - test( - 'subscribes per-channel with #h tags (only joined, non-archived)', - () async { - final session = _FakeRelaySession( - memberships: [ - _membership(_channelA, myPk), - _membership(_channelB, myPk), - _membership(_channelD, myPk), - ], - metadata: [ - _meta(id: _channelA, name: 'general'), - _meta(id: _channelB, name: 'random'), - // channelD metadata missing -> won't appear in channel list - ], - ); - final container = _buildContainer(session: session); - addTearDown(container.dispose); - - await container.read(channelsProvider.future); - - // One subscription per joined, non-archived channel. - expect(session.subscribeFilters, hasLength(2)); - expect( - session.subscribeFilters.map((f) => f.tags['#h']?.single).toSet(), - {_channelA, _channelB}, - ); - for (final filter in session.subscribeFilters) { - expect(filter.kinds, EventKind.channelEventKinds); - expect(filter.limit, 0); - } - }, - ); + _liveSubscriptionTests(); + _terminalSubscriptionTests(); test('retains channel-list member snapshots for immediate reuse', () async { final joinedAt = DateTime.fromMillisecondsSinceEpoch(1000, isUtc: true); @@ -1626,174 +1603,6 @@ void main() { expect(members.every((member) => member.joinedAt == joinedAt), isTrue); }); - test( - 'refreshing an unchanged channel set issues zero new live REQs', - () async { - final session = _FakeRelaySession( - memberships: [ - _membership(_channelA, myPk), - _membership(_channelB, myPk), - ], - metadata: [ - _meta(id: _channelA, name: 'general'), - _meta(id: _channelB, name: 'random'), - ], - ); - final container = _buildContainer(session: session); - addTearDown(container.dispose); - - await container.read(channelsProvider.future); - final initialSubscribeCount = session.totalSubscribeCount; - - await container.read(channelsProvider.notifier).refresh(); - - expect(session.totalSubscribeCount, initialSubscribeCount); - expect(session.unsubscribeCount, 0); - expect(session.subscribeFilters, hasLength(2)); - }, - ); - - test( - 'live subscription diff only removes and adds changed channels', - () async { - final session = _FakeRelaySession( - memberships: [ - _membership(_channelA, myPk), - _membership(_channelB, myPk), - ], - metadata: [ - _meta(id: _channelA, name: 'general'), - _meta(id: _channelB, name: 'random'), - ], - ); - final container = _buildContainer(session: session); - addTearDown(container.dispose); - - await container.read(channelsProvider.future); - session.memberships = [ - _membership(_channelB, myPk), - _membership(_channelD, myPk), - ]; - session.metadata = [ - _meta(id: _channelB, name: 'random'), - _meta(id: _channelD, name: 'support'), - ]; - - await container.read(channelsProvider.notifier).refresh(); - - expect(session.totalSubscribeCount, 3); - expect(session.unsubscribeCount, 1); - expect( - session.subscribeFilters - .map((filter) => filter.tags['#h']!.single) - .toSet(), - {_channelB, _channelD}, - ); - }, - ); - - test( - 'empty channel refresh removes every retained live subscription', - () async { - final session = _FakeRelaySession( - memberships: [ - _membership(_channelA, myPk), - _membership(_channelB, myPk), - ], - metadata: [ - _meta(id: _channelA, name: 'general'), - _meta(id: _channelB, name: 'random'), - ], - ); - final container = _buildContainer(session: session); - addTearDown(container.dispose); - - await container.read(channelsProvider.future); - session.memberships = []; - session.metadata = []; - - await container.read(channelsProvider.notifier).refresh(); - - expect(session.activeChannels, isEmpty); - expect(session.activeSubscriptionCount, 0); - expect(session.unsubscribeCount, 2); - }, - ); - - test( - 'overlapping refreshes retain one live subscription per desired channel', - () async { - final session = _FakeRelaySession( - memberships: [ - _membership(_channelA, myPk), - _membership(_channelB, myPk), - ], - metadata: [ - _meta(id: _channelA, name: 'general'), - _meta(id: _channelB, name: 'random'), - ], - ); - final container = _buildContainer(session: session); - addTearDown(container.dispose); - - await container.read(channelsProvider.future); - session.pauseNextSubscribe(); - session.memberships = [ - _membership(_channelA, myPk), - _membership(_channelB, myPk), - _membership(_channelD, myPk), - ]; - session.metadata = [ - _meta(id: _channelA, name: 'general'), - _meta(id: _channelB, name: 'random'), - _meta(id: _channelD, name: 'support'), - ]; - - final firstRefresh = container.read(channelsProvider.notifier).refresh(); - await session.nextSubscribeStarted; - final secondRefresh = container.read(channelsProvider.notifier).refresh(); - session.resumePausedSubscribe(); - await Future.wait([firstRefresh, secondRefresh]); - - expect(session.activeChannels, {_channelA, _channelB, _channelD}); - expect(session.activeSubscriptionCount, 3); - }, - ); - - test( - 'community switch replaces retained live subscriptions on the new relay', - () async { - final session = _FakeRelaySession( - memberships: [_membership(_channelA, myPk)], - metadata: [_meta(id: _channelA, name: 'general')], - ); - final container = _buildContainer(session: session); - addTearDown(container.dispose); - - await container.read(channelsProvider.future); - expect(session.activeChannels, {_channelA}); - - session.setStatus(SessionStatus.disconnected); - session.memberships = [_membership(_channelB, myPk)]; - session.metadata = [_meta(id: _channelB, name: 'random')]; - container - .read(relayConfigProvider.notifier) - .update(baseUrl: 'https://new-community.example'); - await Future.delayed(Duration.zero); - session.setStatus(SessionStatus.connected); - await container.read(channelsProvider.future); - await _waitUntil( - () => - session.activeChannels.length == 1 && - session.activeChannels.contains(_channelB), - ); - - expect(session.activeChannels, {_channelB}); - expect(session.activeSubscriptionCount, 1); - expect(session.unsubscribeCount, 1); - }, - ); - test('live channel events update channel lastMessageAt', () async { final session = _FakeRelaySession( memberships: [_membership(_channelA, myPk)], @@ -2196,6 +2005,7 @@ void main() { final initial = await container.read(channelsProvider.future); expect(initial.single.name, 'general'); + await _waitUntil(() => session.subscribeFilters.length == 1); expect(session.subscribeFilters, hasLength(1)); session.setStatus(SessionStatus.reconnecting); @@ -2296,6 +2106,7 @@ void main() { ); // And one live subscription on the resulting channel. + await _waitUntil(() => session.subscribeFilters.length == 1); expect(session.subscribeFilters, hasLength(1)); }); } @@ -2305,6 +2116,9 @@ const _channelB = '22222222-2222-4222-8222-222222222222'; const _channelD = '44444444-4444-4444-8444-444444444444'; const _otherPk = 'someone-else'; +String _generatedChannelId(int index) => + '${index.toString().padLeft(8, '0')}-0000-4000-8000-000000000000'; + /// Build a kind:39002 membership event tagged with the channel id and member. NostrEvent _membership( String channelId, @@ -2450,7 +2264,11 @@ class _FakeRelaySession extends RelaySessionNotifier { final List directoryQueryFilters = []; final List membershipQueryFilters = []; final List subscribeFilters = []; - final Map _subscriptions = {}; + final Map< + int, + (NostrFilter, void Function(NostrEvent), void Function(String message)?) + > + _subscriptions = {}; int _nextSubscriptionKey = 0; Completer? _pausedSubscribe; Completer? _subscribeStarted; @@ -2470,9 +2288,12 @@ class _FakeRelaySession extends RelaySessionNotifier { Completer? _claimedUnreadCatchUp; int unsubscribeCount = 0; int totalSubscribeCount = 0; + int subscribeFailures = 0; + int successfulSubscribesBeforeFailure = 0; Set get activeChannels => { - for (final (filter, _) in _subscriptions.values) ?filter.tags['#h']?.single, + for (final (filter, _, _) in _subscriptions.values) + ...filter.tags['#h'] ?? const [], }; int get activeSubscriptionCount => _subscriptions.length; @@ -2850,8 +2671,16 @@ class _FakeRelaySession extends RelaySessionNotifier { _pausedSubscribe = null; _subscribeStarted = null; } + if (subscribeFailures > 0 && successfulSubscribesBeforeFailure == 0) { + subscribeFailures--; + subscribeFilters.remove(filter); + throw StateError('live subscription failed'); + } + if (successfulSubscribesBeforeFailure > 0) { + successfulSubscribesBeforeFailure--; + } final subscriptionKey = ++_nextSubscriptionKey; - _subscriptions[subscriptionKey] = (filter, onEvent); + _subscriptions[subscriptionKey] = (filter, onEvent, onClosed); return () { final subscription = _subscriptions.remove(subscriptionKey); if (subscription == null) return; @@ -2860,13 +2689,22 @@ class _FakeRelaySession extends RelaySessionNotifier { }; } + void closeSubscriptionContaining(String channelId, String message) { + final entry = _subscriptions.entries.singleWhere( + (entry) => entry.value.$1.tags['#h']?.contains(channelId) ?? false, + ); + _subscriptions.remove(entry.key); + subscribeFilters.remove(entry.value.$1); + entry.value.$3?.call(message); + } + void setStatus(SessionStatus status) { state = SessionState(status: status); } /// Emit a live event to all subscribers. void emit(NostrEvent event) { - for (final (_, listener) in List.of(_subscriptions.values)) { + for (final (_, listener, _) in List.of(_subscriptions.values)) { listener(event); } } diff --git a/mobile/test/features/channels/message_content_custom_emoji_test.dart b/mobile/test/features/channels/message_content_custom_emoji_test.dart new file mode 100644 index 0000000000..3059af9a93 --- /dev/null +++ b/mobile/test/features/channels/message_content_custom_emoji_test.dart @@ -0,0 +1,121 @@ +import 'package:buzz/features/channels/message_content.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_render.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +Widget _testable(String content, {List> tags = const []}) { + return ProviderScope( + overrides: [ + customEmojiListProvider.overrideWithValue([ + const CustomEmoji( + shortcode: 'wave', + url: 'https://example.com/wave.png', + ), + for (var i = 0; i < 2500; i++) + CustomEmoji( + shortcode: 'unused_$i', + url: 'https://example.com/$i.png', + ), + ]), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: MessageContent( + content: content, + tags: tags, + channelNames: const {'test': 'test-channel'}, + ), + ), + ), + ); +} + +void main() { + testWidgets('message wiring excludes unrelated emoji from the regex', ( + tester, + ) async { + await tester.pumpWidget(_testable('**hello** :unknown:')); + final markdown = tester.widget(find.byType(GptMarkdown)); + final component = markdown.inlineComponents! + .whereType() + .single; + expect(component.exp.hasMatch(':unused_2499:'), isFalse); + expect(component.exp.hasMatch(':wave:'), isFalse); + expect(find.byType(CustomEmojiImage), findsNothing); + final text = tester + .widgetList(find.byType(RichText)) + .map((widget) => widget.text.toPlainText()) + .join(); + expect(text, contains('hello')); + expect(text, contains(':unknown:')); + expect(text, isNot(contains('**hello**'))); + }); + + testWidgets('referenced event emoji stays available with tag URL priority', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + ':wave:', + tags: const [ + ['emoji', 'wave', 'https://example.com/event-wave.png'], + ], + ), + ); + final image = tester.widget( + find.byType(CustomEmojiImage), + ); + expect(image.shortcode, 'wave'); + expect(image.url, 'https://example.com/event-wave.png'); + final markdown = tester.widget(find.byType(GptMarkdown)); + final component = markdown.inlineComponents! + .whereType() + .single; + expect(component.exp.hasMatch(':unused_2499:'), isFalse); + expect(component.exp.hasMatch(':wave:'), isTrue); + }); + + testWidgets('content edits rebuild the scoped matcher', (tester) async { + await tester.pumpWidget(_testable('plain message')); + expect(find.byType(CustomEmojiImage), findsNothing); + + await tester.pumpWidget(_testable('edited :wave:')); + expect( + tester.widget(find.byType(CustomEmojiImage)).shortcode, + 'wave', + ); + + await tester.pumpWidget(_testable('edited :unused_2499:')); + expect( + tester.widget(find.byType(CustomEmojiImage)).shortcode, + 'unused_2499', + ); + final markdown = tester.widget(find.byType(GptMarkdown)); + final component = markdown.inlineComponents! + .whereType() + .single; + expect(component.exp.hasMatch(':wave:'), isFalse); + + await tester.pumpWidget(_testable('edited :unknown:')); + expect(find.byType(CustomEmojiImage), findsNothing); + }); + + testWidgets('code keeps literal emoji while adjacent known tokens render', ( + tester, + ) async { + await tester.pumpWidget(_testable('`:wave:` :unknown:wave:')); + expect(find.byType(CustomEmojiImage), findsOneWidget); + final text = tester + .widgetList(find.byType(RichText)) + .map((widget) => widget.text.toPlainText()) + .join(); + expect(text, contains(':wave:')); + expect(text, contains(':unknown')); + }); +} diff --git a/mobile/test/shared/custom_emoji/custom_emoji_render_test.dart b/mobile/test/shared/custom_emoji/custom_emoji_render_test.dart new file mode 100644 index 0000000000..ae831d64c4 --- /dev/null +++ b/mobile/test/shared/custom_emoji/custom_emoji_render_test.dart @@ -0,0 +1,109 @@ +import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_render.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gpt_markdown/custom_widgets/markdown_config.dart'; + +const _palette = [ + CustomEmoji(shortcode: 'wave', url: 'https://example.com/wave.png'), + CustomEmoji(shortcode: 'wave_long', url: 'https://example.com/long.png'), + CustomEmoji(shortcode: 'party-parrot', url: 'https://example.com/parrot.png'), +]; + +void main() { + test('pattern is bounded by referenced emoji, not the community palette', () { + final largePalette = [ + ..._palette, + for (var i = 0; i < 2500; i++) + CustomEmoji(shortcode: 'unused_$i', url: 'https://example.com/$i.png'), + ]; + final small = CustomEmojiMd(_palette, content: 'hello :wave:'); + final large = CustomEmojiMd(largePalette, content: 'hello :wave:'); + expect(large.exp.pattern, small.exp.pattern); + expect(large.exp.hasMatch(':wave:'), isTrue); + expect(large.exp.hasMatch(':wave_long:'), isFalse); + expect(large.exp.hasMatch(':unused_2499:'), isFalse); + }); + + test( + 'no references and unknown references produce a nonmatching pattern', + () { + for (final content in [ + 'hello world', + ':unknown:', + 'https://example.com', + ]) { + final matcher = CustomEmojiMd(_palette, content: content); + expect(matcher.exp.allMatches(content), isEmpty); + expect(matcher.exp.hasMatch(':wave:'), isFalse); + } + expect( + CustomEmojiMd(const [], content: ':wave:').exp.hasMatch(':wave:'), + isFalse, + ); + }, + ); + + test('selection preserves the original matcher across token boundaries', () { + final original = RegExp( + ':(?:${_palette.map((e) => RegExp.escape(e.shortcode)).join('|')}):', + caseSensitive: false, + ); + for (final content in [ + ':wave:', + ':WAVE: :Wave_Long: :PARTY-PARROT:', + ':unknown:wave:', + ':wave:unknown:wave_long:', + ':wave::wave_long:', + ':::wave::: :wave_long:! (:party-parrot:)', + ':wave_longer: :unknown: no match', + '`code :wave:` **bold :wave_long:**', + ]) { + final selected = CustomEmojiMd(_palette, content: content); + expect( + selected.exp.allMatches(content).map((m) => m.group(0)).toList(), + original.allMatches(content).map((m) => m.group(0)).toList(), + reason: content, + ); + } + }); + + testWidgets('known tokens keep their URL and size; unknowns remain text', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + final context = tester.element(find.byType(SizedBox)); + final matcher = CustomEmojiMd( + _palette, + content: ':WAVE: :unknown:', + size: 32, + ); + final known = matcher.span(context, ':WAVE:', GptMarkdownConfig()); + expect(known, isA()); + final image = (known as WidgetSpan).child as CustomEmojiImage; + expect(image.shortcode, 'wave'); + expect(image.url, 'https://example.com/wave.png'); + expect(image.size, 32); + final unknown = matcher.span(context, ':unknown:', GptMarkdownConfig()); + expect(unknown, isA()); + expect((unknown as TextSpan).text, ':unknown:'); + }); + + testWidgets('selection uses the current content and current palette URL', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + final context = tester.element(find.byType(SizedBox)); + final initial = CustomEmojiMd(_palette, content: ':wave:'); + final updated = CustomEmojiMd(const [ + CustomEmoji(shortcode: 'wave_long', url: 'https://example.com/new.png'), + ], content: ':wave_long:'); + expect(initial.exp.hasMatch(':wave_long:'), isFalse); + expect(updated.exp.hasMatch(':wave:'), isFalse); + final span = updated.span(context, ':wave_long:', GptMarkdownConfig()); + expect( + ((span as WidgetSpan).child as CustomEmojiImage).url, + 'https://example.com/new.png', + ); + }); +} diff --git a/mobile/test/shared/relay/relay_rate_limit_gate_test.dart b/mobile/test/shared/relay/relay_rate_limit_gate_test.dart index 811d439572..9043982d14 100644 --- a/mobile/test/shared/relay/relay_rate_limit_gate_test.dart +++ b/mobile/test/shared/relay/relay_rate_limit_gate_test.dart @@ -4,7 +4,52 @@ import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('uses the default for absent and non-positive hints', () { + test('uses the default only for an absent hint', () { + final timers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + now: () => DateTime.utc(2026), + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + + gate.activate(null); + + expect(gate.isActive, isTrue); + expect(gate.remainingMs(), 10000); + expect(timers.single.duration, const Duration(seconds: 10)); + }); + + test('an explicit non-positive hint opens no window', () async { + final timers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + now: () => DateTime.utc(2026), + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + + // The relay sends `retry in 0s` to mean "retry immediately". + gate.activate(0); + + expect(gate.isActive, isFalse); + expect(gate.remainingMs(), 0); + expect(timers, isEmpty); + await gate.wait().timeout(const Duration(seconds: 1)); + + gate.activate(-5); + + expect(gate.isActive, isFalse); + expect(gate.remainingMs(), 0); + expect(timers, isEmpty); + await gate.wait().timeout(const Duration(seconds: 1)); + }); + + test('an immediate hint mid-window leaves the window intact', () async { var now = DateTime.utc(2026); final timers = <_ManualTimer>[]; final gate = RelayRateLimitGate( @@ -16,13 +61,21 @@ void main() { }, ); - gate.activate(null); - expect(gate.remainingMs(), 10000); - expect(timers.single.duration, const Duration(seconds: 10)); + gate.activate(30); + final wait = gate.wait(); + final armedTimer = timers.single; - now = now.add(const Duration(seconds: 11)); + now = now.add(const Duration(seconds: 5)); gate.activate(0); - expect(timers.last.duration, const Duration(seconds: 10)); + + expect(gate.isActive, isTrue); + expect(gate.remainingMs(), 25000); + expect(timers, hasLength(1)); + expect(armedTimer.isActive, isTrue); + + armedTimer.fire(); + await wait; + expect(gate.isActive, isFalse); }); test('clamps large hints to five minutes', () { diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index c6ae6e9bd8..d896250536 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -1036,6 +1036,42 @@ void main() { unsubscribe(); }); + test('rate-limited live CLOSED honors an immediate retry hint', () async { + final retryTimers = <_ManualTimer>[]; + final gateTimers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + gateTimers.add(timer); + return timer; + }, + ); + final session = RelaySessionNotifier( + rateLimitGate: gate, + retryTimerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + retryTimers.add(timer); + return timer; + }, + ); + final socket = _RecordingRelaySocket(); + session.debugAttachSocketForTest(socket); + final subscribe = session.subscribe(_channelFilter, (_) {}); + session.debugHandleMessage(['EOSE', 'l-1']); + final unsubscribe = await subscribe; + + session.debugHandleMessage([ + 'CLOSED', + 'l-1', + 'rate-limited: quota exceeded; retry in 0s', + ]); + + expect(retryTimers.single.duration, const Duration(seconds: 1)); + expect(gateTimers, isEmpty); + expect(gate.isActive, isFalse); + unsubscribe(); + }); + test( 'rate-limited CLOSED retry does not survive a superseded connection', () async { From 2c99ee7af5a20d239e95ec5368407887449795c9 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 28 Aug 2026 17:45:42 -0400 Subject: [PATCH 02/22] fix(desktop): resolve bundled sidecar on cheap path and bound login-shell spawns (#6904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why PR #6330 split agent harness/runtime detection into a cheap (cache-only) path and a forced (spawning) path. Two regressions followed, both surfacing as every harness showing "(not installed)" / "CLI missing" across the agent create/edit picker, Agents > Agent defaults, and Settings > Agents — blocking agent create/edit until the user clicked Install in Settings > Agents. ## Root cause One underlying bug, two victims: - **Boot false-negative.** The resolve cache is in-memory, so it starts cold on every launch. `resolve_command_cached` (the cheap path) consulted only the Buzz-managed shim dirs plus that cold cache, and `buzz_managed_command_path`'s allowlist structurally excludes `buzz-agent`. The bundled sidecar could therefore never resolve on the cheap path until a forced pass warmed the cache, so cheap-path surfaces rendered all-missing at boot. App setup never warms the cache. - **"Check again" hang.** `run_in_login_shell` used an untimeouted `Command::output()`; a wedged login shell froze the whole forced pipeline, leaving "Check again" spinning forever. ## What - `resolve_command_cached` now also calls `resolve_workspace_command`, resolving the bundled sidecar via a filesystem stat (no spawn) — the same class of work the managed-shim check already performs. `buzz-agent` can no longer report missing, even inside the boot warm window. - New `discovery/bounded_command.rs` runs any discovery child under a hard wall-clock deadline, polling with `try_wait` rather than blocking on `wait()`. Stdout and stderr are piped to two drain threads whose buffers share an aggregate `CAPTURE_LIMIT`; a breach fails closed (kill the tree, return `None`), so a noisy or hostile probe can force neither unbounded memory nor disk fill. Tree teardown runs on every exit path — timeout, error, cap breach, *and* success — because a login-shell rc file or auth CLI can legitimately background a descendant that would otherwise outlive discovery. Ownership is deliberately asymmetric: - **Unix:** the child leads its own process group (`process_group(0)`); teardown is `SIGTERM` → bounded grace → `SIGKILL` on the group. A descendant that leaves the group (`setsid`/`setpgid`) while holding a pipe is not owned and may survive one probe, but can never hang or unbound the helper: the Unix drains read nonblocking and end on `WouldBlock` once teardown sets the stop flag, so the join returns promptly without waiting on an escaped writer's EOF. - **Windows:** the child is spawned `CREATE_SUSPENDED`, assigned to a kill-on-close Job Object while frozen, then resumed. The job owns the root before any descendant can exist and is created without breakaway, so no writer can escape — a hard whole-tree guarantee, and closing the job reaps the tree even after the root has exited. Any failure to create, assign, or resume is fail-closed: the child is terminated and reaped and the spawn returns `None` (discovery treats it as command-not-found) rather than running unowned. - Each login-shell candidate is bounded by a 10s timeout via that helper, falling through to the next candidate on timeout instead of aborting the resolve. The login-shell path cache is generation-aware: a probe that loses to a concurrent refresh or lands mid-refresh returns the authoritative cached value (or re-probes under the new generation) rather than its own rejected local result, so a losing thread can never settle the UI with a PATH-missing catalog while the cache holds a fresh success. - Warm the ACP runtime catalog once at `AppShell` mount and gate the cheap-path surfaces on that pass. A module-level boot-warm state (`idle` → `pending` → `settled`/`failed`, deduped per launch) lets `useAcpRuntimesQuery` present a cold catalog as *loading* while the first forced pass runs and as a *retryable error* (carrying the probe's real reason) if it fails, instead of blessing "every harness not installed" as authoritative. A non-empty catalog always wins, so a revalidation or later failure never blanks a good list; the gate only overlays once the warm has started, so onboarding (which renders before the warm) is unaffected. Deduping per launch also fixes the previous per-remount re-fire. ## Verification Unix teardown and the drain contract are runtime-proven by `#[ignore]`-free tests that record a backgrounded descendant's real PID and assert the helper returns promptly on both the success and timeout paths without blocking on that writer. The generation-aware login-shell cache is covered by deterministic tests through a `cfg(test)` injectable probe seam that assert the function's return value under both concurrent-refresh interleavings — the losing caller returns the peer's committed success, and a mid-probe refresh forces a re-probe to the fresh value. The Windows ownership contract has no CI lane, so `bounded_command.rs` carries two `#[ignore]`-gated tests (spawn/assign race, looped; and the timeout path) for a sanctioned run on a Windows host. The boot-warm gate is covered by unit tests for the pure overlay and the `startBootWarm` failure → retry → settle lifecycle. Origin: [Buzz thread](buzz://message?channel=5ef5d5bb-643f-4b87-bbf4-e8b64585ffeb&id=a4b1c4485de4d35cff0f914d4f4211c796f44f670e76de2ef9431f7e882c906e) Fixes #6872 Related #6662 --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- desktop/src-tauri/Cargo.toml | 2 +- .../src-tauri/src/managed_agents/discovery.rs | 101 +- .../discovery/bounded_command.rs | 999 ++++++++++++++++++ .../managed_agents/discovery/login_shell.rs | 322 +++++- .../tests/managed_path_resolution.rs | 27 + .../src/managed_agents/process_lifecycle.rs | 75 +- .../src/app/useAppShellLifecycleEffects.ts | 18 + .../features/agents/acpRuntimesQuery.test.mjs | 196 +++- .../src/features/agents/acpRuntimesQuery.ts | 168 ++- desktop/src/features/agents/hooks.ts | 21 +- .../agents/ui/AgentDefaultsEditor.tsx | 23 +- .../agents/ui/AgentDefinitionDialog.tsx | 2 +- .../features/agents/ui/AgentHarnessField.tsx | 5 +- .../agents/ui/HarnessCatalogRetryNotice.tsx | 24 + 14 files changed, 1849 insertions(+), 134 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs create mode 100644 desktop/src/features/agents/ui/HarnessCatalogRetryNotice.tsx diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 050c3af89d..f41fa2d6e3 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -63,7 +63,7 @@ user-idle = { version = "0.6", default-features = false } plist = "1" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] } +windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] } keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true } user-idle = { version = "0.6", default-features = false } diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 78592357c9..1ee7e6e556 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -1,8 +1,7 @@ -use std::io::Read; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::OnceLock; -use std::time::{Duration, Instant}; +use std::time::Duration; use crate::managed_agents::{ buzz_managed_command_path, buzz_managed_node_bin_dir, buzz_managed_npm_bin_dir, @@ -10,6 +9,7 @@ use crate::managed_agents::{ HarnessSource, }; mod auth_status_cache; +mod bounded_command; mod login_shell; mod presets; mod runtime_metadata; @@ -593,6 +593,16 @@ pub fn resolve_command_cached(command: &str) -> Option { if let Some(managed) = resolve_buzz_managed_command(command) { return Some(managed); } + // Bundled sidecars (e.g. `buzz-agent`) ship next to the app executable, so + // `resolve_workspace_command` finds them with a filesystem stat and no + // login-shell spawn — the same class of work the managed-shim check above + // already performs. Without this the cheap path could never see the sidecar + // until a forced discovery warmed the resolve cache, so `buzz-agent` (which + // cannot legitimately be missing) reported "not installed" at every cold + // launch across the create/edit and agent-defaults surfaces. + if let Some(workspace) = resolve_workspace_command(command) { + return Some(workspace); + } resolve_cache() .lock() .ok() @@ -822,10 +832,9 @@ pub(crate) fn is_npm_global_install(cmd: &str) -> bool { /// Run a CLI auth probe with a 10-second process-level timeout. /// -/// Spawns the probe CLI as a child process. Stdout and stderr are drained on -/// background threads to prevent pipe-buffer deadlock. On timeout the child is -/// killed and `Unknown` is returned; no orphaned threads or processes are left -/// behind. Returns `Unknown` on timeout. +/// On timeout or spawn failure the child is killed and `Unknown` is returned; +/// no orphaned threads or processes are left behind (see +/// [`bounded_command::output_with_timeout`]). fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus { use crate::managed_agents::readiness::cli_probe; @@ -836,81 +845,17 @@ fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus { if let Some(ref path) = augmented_path { command.env("PATH", path); } - command - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - crate::util::configure_no_window(&mut command); + // Window suppression is owned by `output_with_timeout`'s spawn + // (`BOUNDED_CREATION_FLAGS` carries `CREATE_NO_WINDOW`); a + // `configure_no_window` call here would be clobbered by that later + // `creation_flags` set, so it is deliberately omitted. - let mut child = match command.spawn() { - Ok(c) => c, - Err(_) => return AuthStatus::Unknown, + let Some(output) = bounded_command::output_with_timeout(command, Duration::from_secs(10)) + else { + return AuthStatus::Unknown; }; - // Drain stdout/stderr on background threads to prevent pipe-buffer deadlock. - let stdout_pipe = child.stdout.take(); - let stderr_pipe = child.stderr.take(); - - let stdout_thread = std::thread::spawn(move || { - let mut buf = Vec::new(); - if let Some(mut pipe) = stdout_pipe { - let _ = pipe.read_to_end(&mut buf); - } - }); - let stderr_thread = std::thread::spawn(move || { - let mut buf = Vec::new(); - if let Some(mut pipe) = stderr_pipe { - let _ = pipe.read_to_end(&mut buf); - } - buf - }); - - // Save PID for kill-on-timeout before moving child into the wait thread. - let child_pid = child.id(); - let (tx, rx) = std::sync::mpsc::channel(); - let wait_thread = std::thread::spawn(move || { - let _ = tx.send(child.wait()); - }); - - // 10-second timeout for auth probes. - let deadline = Instant::now() + Duration::from_secs(10); - let exit_status = loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - #[cfg(unix)] - unsafe { - libc::kill(child_pid as i32, libc::SIGTERM); - } - #[cfg(not(unix))] - let _ = child_pid; - drop(rx); - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return AuthStatus::Unknown; - } - match rx.recv_timeout(Duration::from_millis(100).min(remaining)) { - Ok(Ok(status)) => break status, - Ok(Err(_)) => { - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return AuthStatus::Unknown; - } - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return AuthStatus::Unknown; - } - } - }; - - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let stderr_bytes = stderr_thread.join().unwrap_or_default(); - - match cli_probe::classify_probe_output(&stderr_bytes, exit_status.success()) { + match cli_probe::classify_probe_output(&output.stderr, output.status.success()) { cli_probe::ProbeOutcome::LoggedIn => AuthStatus::LoggedIn, cli_probe::ProbeOutcome::LoggedOut => AuthStatus::LoggedOut, cli_probe::ProbeOutcome::ConfigInvalid { stderr_excerpt } => AuthStatus::ConfigInvalid { diff --git a/desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs b/desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs new file mode 100644 index 0000000000..18286d62b1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs @@ -0,0 +1,999 @@ +//! Run a child process to completion under a hard wall-clock deadline. +//! +//! Every spawn on the discovery path — the CLI auth probes and the login-shell +//! PATH lookups — must return in bounded time no matter how the child behaves. +//! A login shell that blocks on an interactive prompt, a child that traps +//! `SIGTERM`, or a forked descendant that keeps a pipe open must not be able to +//! stall discovery; that stall is what left "Check again" spinning forever. + +use std::io::{ErrorKind, Read}; +use std::process::{ChildStderr, ChildStdout, Command, ExitStatus, Output, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +/// Poll interval while waiting for the child to exit. +const POLL_INTERVAL: Duration = Duration::from_millis(50); + +/// Idle backoff for a nonblocking Unix drain that has no bytes available and +/// has not yet been told to stop. Short so a running child's output is pulled +/// promptly and the post-teardown join returns quickly. +#[cfg(unix)] +const DRAIN_IDLE_POLL: Duration = Duration::from_millis(5); + +/// Maximum bytes retained across stdout + stderr for one bounded probe. +/// +/// Discovery output is tiny — a version string, an auth-status word, a PATH +/// lookup. A probe that emits more than this is noisy or hostile. The ceiling +/// is enforced *in the drain sink* (see [`spawn_drain`]): each stream is pulled +/// on its own thread into a capped buffer, the limit is checked the moment a +/// bounded read crosses it, and the probe is failed closed — so an over-cap +/// payload is never retained in memory (and, since output goes to pipes not +/// temp files, never written to disk). The ceiling is *aggregate*, not +/// per-stream, so a probe cannot double it by splitting output across stdout +/// and stderr. +const CAPTURE_LIMIT: u64 = 1 << 20; // 1 MiB + +/// Grace period between the initial `SIGTERM` and the escalating `SIGKILL` for a +/// timed-out process group. Long enough for a well-behaved child to flush and +/// exit cleanly, short enough that a signal-ignoring one is reaped promptly. +#[cfg(unix)] +const KILL_GRACE: Duration = Duration::from_millis(500); + +/// Freeze the child so the Job Object can take ownership before any child code +/// runs (see [`BoundedChild::spawn`]). +#[cfg(windows)] +const CREATE_SUSPENDED: u32 = 0x0000_0004; + +/// Suppress the console window a GUI-spawned console child would otherwise +/// flash — the same suppression [`crate::util::configure_no_window`] applies to +/// non-bounded spawns. +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// The exact creation flags every bounded child is spawned with. +/// +/// `Command::creation_flags` *replaces* rather than accumulates (std ORs only +/// `CREATE_UNICODE_ENVIRONMENT` afterward), and [`BoundedChild::spawn`] is the +/// last writer before spawn, so a caller's earlier `configure_no_window` is +/// wiped. This constant therefore has to carry every flag a bounded child +/// needs, and owning both here keeps the window-suppression contract in one +/// place instead of split between the caller and the helper. +#[cfg(windows)] +const BOUNDED_CREATION_FLAGS: u32 = CREATE_SUSPENDED | CREATE_NO_WINDOW; + +/// Compile-time guard: the bounded flags must always carry *both* bits. A +/// future edit that drops `CREATE_NO_WINDOW` (reintroducing the console-flash +/// regression) or `CREATE_SUSPENDED` (reopening the spawn-to-assign race) fails +/// the build on Windows rather than shipping silently. +#[cfg(windows)] +const _: () = { + assert!(BOUNDED_CREATION_FLAGS & CREATE_SUSPENDED == CREATE_SUSPENDED); + assert!(BOUNDED_CREATION_FLAGS & CREATE_NO_WINDOW == CREATE_NO_WINDOW); +}; + +/// A spawned child plus ownership of its descendant tree, torn down on *every* +/// exit path — timeout, error, or successful exit. The two platforms establish +/// ownership differently, and the guarantee is deliberately asymmetric — the +/// adjudicated design, not an oversight: +/// +/// - **Unix:** the child leads its own process group (`process_group(0)`), so +/// `killpg` reaches every descendant that has not left the group. A +/// `setsid`/`setpgid` escapee holding a pipe is *not* owned and may survive +/// one probe, yet never hangs the helper (see [`output_with_timeout`]). +/// - **Windows:** the child is spawned `CREATE_SUSPENDED`, assigned to a +/// kill-on-close Job Object while frozen, then resumed. The job owns the root +/// before any descendant can exist and is created without breakaway, so no +/// writer can escape it — a hard whole-tree guarantee. Closing that job reaps +/// the whole tree *even after the root has exited* — the distinction that +/// makes `taskkill /T ` (a live-root lookup) unfit for the success path. +/// This mirrors the Job Object discipline the harness uses to reap its 24 +/// agent workers (`process_lifecycle.rs`). +struct BoundedChild { + child: std::process::Child, + /// The kill-on-close job that owns the whole tree. Taken and dropped by + /// `kill_tree` so the reap happens exactly once. Spawn is fail-closed: if + /// the job cannot be created, assigned, or the child resumed, the child is + /// terminated and `spawn` returns `None` rather than running unowned. + #[cfg(windows)] + job: Option, +} + +impl BoundedChild { + /// Spawn `command`, establishing tree ownership before the child can run. + /// Returns `None` if the spawn fails or — on Windows — if the job cannot be + /// created, assigned, or the frozen child resumed; in every such case the + /// child is terminated and reaped before returning, so no unowned process + /// survives. + fn spawn(mut command: Command) -> Option { + // Run the child in its own process group so the whole tree can be torn + // down as a unit, not just a direct child that may have forked workers. + #[cfg(unix)] + { + use std::os::unix::process::CommandExt as _; + command.process_group(0); + } + + // Spawn frozen so the Job Object can take ownership before any child + // code runs and forks a descendant that would escape the job. The flags + // are set here as the last writer before spawn; `Command::creation_flags` + // replaces rather than ORs, so `BOUNDED_CREATION_FLAGS` must itself carry + // `CREATE_NO_WINDOW` — a caller's earlier `configure_no_window` would be + // clobbered otherwise, flashing a console window on GUI discovery. + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + command.creation_flags(BOUNDED_CREATION_FLAGS); + } + + // `mut` is used only on the Windows fail-closed path (kill/wait on the + // frozen child); Unix moves the child unmodified into `Self`. + #[cfg_attr(not(windows), allow(unused_mut))] + let mut child = command.spawn().ok()?; + + #[cfg(windows)] + let job = { + // Assign the frozen child to a kill-on-close job, then resume it. + // Any failure is fail-closed: terminate + reap the still-owned + // child and abort the spawn, never run it unowned to the deadline. + let Some(job) = crate::managed_agents::create_job_for_child(child.id()) else { + let _ = child.kill(); + let _ = child.wait(); + return None; + }; + if !crate::managed_agents::resume_process(child.id()) { + // Dropping the job kills the still-suspended child via + // kill-on-close; reap it so no zombie lingers. + drop(job); + let _ = child.wait(); + return None; + } + job + }; + + Some(Self { + child, + #[cfg(windows)] + job: Some(job), + }) + } + + fn try_wait(&mut self) -> std::io::Result> { + self.child.try_wait() + } + + /// Timeout teardown: a graceful `SIGTERM` to the group and a bounded grace + /// period for a clean flush on Unix, then the unconditional forced kill. + /// Windows has no group signal, so it goes straight to the forced kill. + fn terminate_timed_out(&mut self) { + #[cfg(unix)] + { + // SAFETY: `killpg` on the group led by the child; an ignored result + // is intentional — the group may already be gone (ESRCH). + unsafe { + libc::killpg(self.child.id() as i32, libc::SIGTERM); + } + std::thread::sleep(KILL_GRACE); + } + self.kill_tree(); + } + + /// Forcibly reap the whole tree. Idempotent and safe on an already-exited + /// tree. Runs on every exit path — including success, because a login shell + /// or auth CLI can background a descendant that outlives the leader while + /// still holding the captured-output descriptors. + fn kill_tree(&mut self) { + #[cfg(unix)] + // SAFETY: `killpg` on the group led by the child; ignored result is + // intentional — `ESRCH` on a dead group is the success case. + unsafe { + libc::killpg(self.child.id() as i32, libc::SIGKILL); + } + #[cfg(windows)] + // Closing the kill-on-close job reaps every descendant, even once the + // root has exited — which `taskkill /T ` cannot. `spawn` is + // fail-closed, so the job is always present until this first take; + // a later take is a no-op (the tree is already reaped). + if let Some(job) = self.job.take() { + drop(job); + } + } + + /// Reap the direct child so no zombie lingers after the tree is killed. + fn reap(&mut self) { + let _ = self.child.wait(); + } + + /// Take the captured stdout pipe. `Some` because [`output_with_timeout`] + /// configures `Stdio::piped()` before spawn. + fn take_stdout(&mut self) -> Option { + self.child.stdout.take() + } + + /// Take the captured stderr pipe. + fn take_stderr(&mut self) -> Option { + self.child.stderr.take() + } +} + +/// Set a file descriptor nonblocking so a read on it returns `WouldBlock` +/// instead of parking when no bytes are available. Returns `false` on any +/// `fcntl` failure, which the caller treats as fail-closed. +#[cfg(unix)] +fn set_nonblocking(f: &F) -> bool { + let fd = f.as_raw_fd(); + // SAFETY: `fd` is owned by `f` for the duration of this call; `F_GETFL` / + // `F_SETFL` read and set the descriptor's flags without transferring + // ownership or touching any other resource. + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + if flags < 0 { + return false; + } + libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) == 0 + } +} + +/// Drain one child stream on its own thread into a buffer capped by the shared +/// aggregate budget, so the sink itself — not a post-hoc size sample — enforces +/// [`CAPTURE_LIMIT`]. +/// +/// Continuous draining keeps the pipe buffer from filling, so the child can +/// never block on a full pipe while we poll it. Retention is bounded: `total` +/// reserves a disjoint byte range per chunk across both streams, so the sum of +/// both buffers never exceeds the aggregate cap. The moment a read crosses the +/// cap, `overflow` is set and the drain returns immediately — it does not keep +/// reading, so a writer that keeps the pipe continuously readable cannot spin +/// this loop forever (it must cross the finite cap). A read error other than +/// `Interrupted`/`WouldBlock` returns `Err`, which the caller treats as +/// fail-closed. +/// +/// **Bounded completion differs by platform, because tree ownership does.** +/// - **Unix:** the read end is nonblocking (see [`set_nonblocking`]). A killed +/// in-group writer's descriptors close, so the read reaches EOF (`Ok(0)`) and +/// the thread returns normally. But `kill_tree` is a `killpg` on the child's +/// group, which does *not* reach a descendant that left the group via +/// `setsid`/`setpgid` while retaining the pipe; that writer keeps the write +/// end open and EOF never comes. So once teardown has set `stop`, a +/// `WouldBlock` (nothing more buffered) ends the drain rather than waiting on +/// that escaped writer forever. This is what makes bounded return hold +/// *without* depending on every inherited writer exiting — the correction to +/// the round-8 blocking-EOF design. +/// - **Windows:** the read blocks to EOF. That is sound because the whole tree +/// is owned by a kill-on-close Job Object created without +/// `JOB_OBJECT_LIMIT_BREAKAWAY_OK`, so no descendant can escape the job; job +/// close reaps every writer and the read reaches EOF. `stop` is unused there. +fn spawn_drain( + mut reader: R, + total: Arc, + overflow: Arc, + stop: Arc, +) -> JoinHandle>> { + // `stop` gates only the nonblocking Unix drain; the Windows path blocks to + // the job-close EOF and never consults it. + #[cfg(windows)] + let _ = &stop; + std::thread::spawn(move || { + let mut buf = Vec::new(); + let mut chunk = [0u8; 8192]; + loop { + match reader.read(&mut chunk) { + Ok(0) => return Ok(buf), + Ok(n) => { + // Atomically reserve [prev, prev + n) of the shared budget; + // `prev` is unique per call, so the two streams keep + // disjoint ranges and their retained bytes sum to <= cap. + let prev = total.fetch_add(n as u64, Ordering::Relaxed); + if prev.saturating_add(n as u64) > CAPTURE_LIMIT { + overflow.store(true, Ordering::Relaxed); + let keep = CAPTURE_LIMIT.saturating_sub(prev).min(n as u64) as usize; + buf.extend_from_slice(&chunk[..keep]); + // Overflow: the result is already fail-closed, so nothing + // still in the pipe is worth preserving. Return NOW rather + // than draining to EOF — this is what bounds the `Ok(n)` + // path against a writer that keeps the pipe continuously + // readable, which would otherwise never reach the + // `WouldBlock`/`stop` check below and hang the join. It is + // safe to stop draining: the poll loop sees `overflow` and + // kills the tree, and a writer that then blocks on a full + // pipe dies to `killpg`/job-close. Do NOT "fix" that + // blocked-writer case by resuming an unbounded drain here. + return Ok(buf); + } + buf.extend_from_slice(&chunk[..n]); + } + Err(e) if e.kind() == ErrorKind::Interrupted => continue, + // Nonblocking read (Unix only): no bytes available right now. + // After teardown, an escaped out-of-group writer is the only + // thing that could still hold the pipe open, so stop draining it + // rather than block the join forever; otherwise back off and + // retry so a running child's later output is still captured. + #[cfg(unix)] + Err(e) if e.kind() == ErrorKind::WouldBlock => { + if stop.load(Ordering::Relaxed) { + return Ok(buf); + } + std::thread::sleep(DRAIN_IDLE_POLL); + } + Err(e) => return Err(e), + } + } + }) +} + +/// Run `command` to completion, bounded by `timeout`. +/// +/// Returns `Some(output)` when the child exits within the deadline, `None` when +/// it fails to spawn, exceeds the deadline, or breaches the capture ceiling. +/// Guarantees a bounded return regardless of child cooperation: +/// +/// - **Sink-enforced capture bound.** Stdout and stderr are piped to two drain +/// threads that read into buffers capped by a shared aggregate budget +/// ([`spawn_drain`]); nothing over [`CAPTURE_LIMIT`] is ever retained. On a +/// breach the poll loop fails closed — kill the tree, return `None` — so a +/// noisy or hostile probe cannot force unbounded memory (and, with pipes +/// rather than temp files, cannot fill the disk either). Continuous draining +/// also keeps the pipe buffer from filling, so the child can never block on a +/// full pipe while we poll. +/// - **Bounded drain completion without depending on writer death.** Tree +/// teardown runs on *every* exit path before the drains are joined — +/// [`BoundedChild::kill_tree`] on timeout, error, cap breach, *and* success. +/// But teardown alone does not guarantee EOF on Unix: `kill_tree` is a +/// `killpg` on the child's process group, and a descendant that left the +/// group (`setsid`/`setpgid`) while retaining the pipe survives it and keeps +/// the write end open. So the drains do not rely on EOF from every writer: +/// the Unix reads are nonblocking, and after teardown sets the shared `stop` +/// flag a `WouldBlock` (no more buffered bytes) ends each drain. An escaped +/// writer is allowed to survive; the join still returns promptly. On Windows +/// the reads block to EOF, which is sound because the kill-on-close Job Object +/// is created without breakaway, so no writer can escape the job. This is the +/// correction to the round-8 design, whose blocking Unix reads could hang the +/// join forever on a group-escaping writer. +/// - **No wait hang.** The child is polled with [`Child::try_wait`] against the +/// deadline rather than blocked on with `wait()`. +/// - **Tree termination on every exit path.** [`BoundedChild`] tears the tree +/// down whether the child times out, errors, breaches the cap, *or exits +/// successfully* — a login-shell rc file or auth CLI can legitimately +/// background a descendant (`worker &`) that would outlive discovery. +/// Ownership is a hard whole-tree guarantee on Windows but only the child's +/// process group on Unix (the group-escapee case bounded by the drain rule +/// above) — the adjudicated asymmetry. The timeout path additionally sends a +/// graceful `SIGTERM` and a grace period before the kill. +pub(crate) fn output_with_timeout(mut command: Command, timeout: Duration) -> Option { + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = BoundedChild::spawn(command)?; + + let stdout_pipe = child.take_stdout(); + let stderr_pipe = child.take_stderr(); + + // Unix: make the parent read ends nonblocking so a drain can be told to stop + // (post-teardown) instead of parking forever on a group-escaping writer that + // still holds the pipe. Fail closed if the fd cannot be reconfigured — the + // child is still fully owned here, so cleanup is just kill + reap. + #[cfg(unix)] + { + let stdout_ok = match stdout_pipe.as_ref() { + Some(p) => set_nonblocking(p), + None => true, + }; + let stderr_ok = match stderr_pipe.as_ref() { + Some(p) => set_nonblocking(p), + None => true, + }; + if !(stdout_ok && stderr_ok) { + child.kill_tree(); + child.reap(); + return None; + } + } + + // Shared drain state: one aggregate byte budget across both streams, an + // overflow flag the poll loop watches so a streaming producer that never + // exits is failed closed the moment it crosses the cap, and a stop flag that + // teardown raises to end the nonblocking Unix drains. + let total = Arc::new(AtomicU64::new(0)); + let overflow = Arc::new(AtomicBool::new(false)); + let stop = Arc::new(AtomicBool::new(false)); + let stdout_drain = + stdout_pipe.map(|s| spawn_drain(s, total.clone(), overflow.clone(), stop.clone())); + let stderr_drain = + stderr_pipe.map(|s| spawn_drain(s, total.clone(), overflow.clone(), stop.clone())); + + let deadline = Instant::now() + timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break Some(status), + Ok(None) => { + if Instant::now() >= deadline { + child.terminate_timed_out(); + break None; + } + // Fail closed on a capture breach *while the child runs*: the + // drain kept nothing over the cap; teardown below ends the + // drains so the join cannot hang. + if overflow.load(Ordering::Relaxed) { + child.kill_tree(); + break None; + } + std::thread::sleep(POLL_INTERVAL); + } + Err(_) => { + child.kill_tree(); + break None; + } + } + }; + + // Tree down on every path (timeout/error/overflow killed it above; a clean + // exit may still have backgrounded a descendant holding the pipe). Kill is + // idempotent, so calling it here on the success path is safe. Then raise + // `stop`: a killed in-group writer's pipe reaches EOF and ends its drain on + // its own, but a group-escaping writer never will — `stop` ends that drain + // on the next `WouldBlock` so the joins below return promptly. + child.kill_tree(); + child.reap(); + stop.store(true, Ordering::Relaxed); + + let stdout = join_drain(stdout_drain); + let stderr = join_drain(stderr_drain); + + // Fail closed if the child exited within the deadline but overran the cap in + // a final burst, or if either drain hit a read error (join_drain -> None). + let (status, stdout, stderr) = (status?, stdout?, stderr?); + if overflow.load(Ordering::Relaxed) { + return None; + } + + Some(Output { + status, + stdout, + stderr, + }) +} + +/// Join a drain thread, returning its captured bytes. `None` (fail closed) if +/// the stream was absent, the thread panicked, or the read errored. +fn join_drain(drain: Option>>>) -> Option> { + match drain { + Some(handle) => handle.join().ok()?.ok(), + None => Some(Vec::new()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + + /// Drive `output_with_timeout` on its own thread under an independent + /// wall-clock `bound` — the real outer bound, unreachable by an inline `elapsed()` assertion if the helper hangs. + /// The raw result lets the Windows sites fold transcripts into the expiry panic. + #[cfg(any(unix, windows))] + fn run_watchdogged_raw( + cmd: Command, + timeout: Duration, + bound: Duration, + ) -> Result, mpsc::RecvTimeoutError> { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(output_with_timeout(cmd, timeout)); + }); + rx.recv_timeout(bound) + } + #[cfg(unix)] + fn run_watchdogged(cmd: Command, timeout: Duration, bound: Duration) -> Option { + run_watchdogged_raw(cmd, timeout, bound) + .unwrap_or_else(|_| panic!("output_with_timeout did not return within {bound:?}")) + } + + /// True while a Unix process (or a reaped-but-not-waited zombie under this + /// test process) still exists. `kill(pid, 0)` probes existence without + /// signalling. Descendants reparent to init on exit, so a survivor stays + /// probeable; once `kill_tree` reaps it, the pid is gone (ESRCH). + #[cfg(unix)] + fn pid_alive(pid: i32) -> bool { + unsafe { libc::kill(pid, 0) == 0 } + } + + #[cfg(unix)] + #[test] + fn returns_output_for_fast_command() { + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", "printf hi; printf oops 1>&2"]); + let out = run_watchdogged(cmd, Duration::from_secs(5), Duration::from_secs(10)) + .expect("a fast command must complete within the timeout"); + assert!(out.status.success()); + assert_eq!(out.stdout, b"hi"); + assert_eq!(out.stderr, b"oops"); + } + + // Adversarial: a child that traps and ignores SIGTERM. The old + // wait-thread + lone-SIGTERM helper never returned for this input; the + // process-group SIGKILL escalation must reap it inside the grace period. + // The watchdog thread is the real bound — the helper hanging fails the + // test rather than hanging it. + #[cfg(unix)] + #[test] + fn kills_sigterm_ignoring_child_within_bound() { + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", "trap '' TERM; while :; do sleep 1; done"]); + let result = run_watchdogged(cmd, Duration::from_millis(200), Duration::from_secs(5)); + assert!(result.is_none(), "a timed-out child must yield None"); + } + + // Adversarial (success path): the direct child exits 0 but backgrounds a + // descendant that keeps writing to the inherited stdout/stderr forever. + // Two guarantees under test: (1) the drain returns rather than blocking on + // the descendant, and (2) `kill_tree` reaps that descendant before + // returning, so no survivor keeps consuming CPU after discovery reports + // success. This is the pass-2 leak Thufir proved with `(yes) & exit 0`. + #[cfg(unix)] + #[test] + fn reaps_backgrounded_descendant_on_success() { + let pid_file = tempfile::NamedTempFile::new().expect("temp file for descendant pid"); + let pid_path = pid_file + .path() + .to_str() + .expect("utf-8 temp path") + .to_string(); + // Background a real child process (`sleep`), record ITS pid via `$!` + // (not `$$`, which in a subshell is the invoking shell), then exit 0. + // The leader waits until the pid is recorded so the test can read it + // deterministically even though the success path kills the group at + // once. `$!` is the pass-2 `(yes) & exit 0` survivor, made observable. + let script = format!( + "sleep 30 & echo $! > '{pid_path}'; \ + until [ -s '{pid_path}' ]; do :; done; printf done; exit 0" + ); + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", &script]); + let out = run_watchdogged(cmd, Duration::from_secs(5), Duration::from_secs(10)) + .expect("the direct child exits, so this must return its output"); + assert!(out.status.success()); + + let descendant_pid: i32 = std::fs::read_to_string(&pid_path) + .expect("descendant must have recorded its PID") + .trim() + .parse() + .expect("descendant PID must be numeric"); + // Give the reaped group a moment to fully disappear, then assert dead. + std::thread::sleep(Duration::from_millis(200)); + assert!( + !pid_alive(descendant_pid), + "backgrounded descendant {descendant_pid} must be reaped on success, but it survived" + ); + } + + // Adversarial (timeout path): a SIGTERM-ignoring leader that backgrounds a + // descendant, both looping forever. The leader's process group is killed on + // timeout, so the descendant (same group) must die too. The descendant is a + // real child process whose PID is recorded via `$!`, so the test proves the + // actual descendant — not the already-reaped leader — reaches ESRCH. + #[cfg(unix)] + #[test] + fn reaps_descendant_on_timeout() { + let pid_file = tempfile::NamedTempFile::new().expect("temp file for descendant pid"); + let pid_path = pid_file + .path() + .to_str() + .expect("utf-8 temp path") + .to_string(); + let script = format!( + "trap '' TERM; sleep 300 & echo $! > '{pid_path}'; \ + while :; do sleep 1; done" + ); + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", &script]); + let result = run_watchdogged(cmd, Duration::from_millis(300), Duration::from_secs(5)); + assert!(result.is_none(), "a timed-out tree must yield None"); + + let descendant_pid: i32 = std::fs::read_to_string(&pid_path) + .expect("descendant must have written its PID") + .trim() + .parse() + .expect("descendant PID must be numeric"); + std::thread::sleep(Duration::from_millis(200)); + assert!( + !pid_alive(descendant_pid), + "backgrounded descendant {descendant_pid} must be group-killed on timeout, but it survived" + ); + } + + // Deterministic seam regression (Thufir's finding): a drain fed a reader + // that stays continuously readable — every `read` returns `Ok(8192)`, never + // `WouldBlock` — must still complete, because the `stop`/`WouldBlock` check + // alone never fires on such a reader. The bound comes from the `Ok(n)` path + // returning the instant the aggregate cap is crossed. No real process and no + // scheduler timing: the reader is a pure in-test `Read` impl, so this pins + // the control flow rather than relying on a descendant eventually blocking. + // With the round-9-initial code (which kept reading after overflow) the + // drain never returns and the join below hangs past the watchdog. + #[test] + fn overflow_bounds_a_continuously_readable_drain() { + /// A reader that is always ready with a full 8192-byte chunk. It never + /// returns 0 (EOF) or `WouldBlock`, so only the overflow return can end + /// a drain reading it. + struct AlwaysReady; + impl Read for AlwaysReady { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + for b in buf.iter_mut() { + *b = b'x'; + } + Ok(buf.len()) + } + } + + let total = Arc::new(AtomicU64::new(0)); + let overflow = Arc::new(AtomicBool::new(false)); + // `stop` set from the start: a correct drain must NOT depend on it here, + // since a continuously-ready reader never hits the `WouldBlock` arm that + // consults it. The overflow return is the only thing that can bound it. + let stop = Arc::new(AtomicBool::new(true)); + let drain = spawn_drain(AlwaysReady, total.clone(), overflow.clone(), stop); + + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(drain.join()); + }); + let joined = rx + .recv_timeout(Duration::from_secs(2)) + .expect("a continuously-readable drain must be bounded by the capture cap"); + let buf = joined + .expect("drain thread must not panic") + .expect("drain read must not error"); + assert!( + overflow.load(Ordering::Relaxed), + "the drain must have tripped overflow" + ); + assert!( + buf.len() as u64 <= CAPTURE_LIMIT, + "retained bytes {} must not exceed the cap {CAPTURE_LIMIT}", + buf.len() + ); + } + + // Adversarial (group escape): the leader backgrounds a descendant that + // calls `setsid()` — leaving the leader's process group while retaining the + // inherited stdout — then sleeps 300s; the leader itself loops forever, so + // the helper times out. `kill_tree` is a `killpg` on the leader's group and + // cannot reach the escaped descendant, so its pipe write end stays open and + // never reaches EOF. The helper must still return within the outer watchdog + // and fail closed: the nonblocking drains stop on `WouldBlock` after + // teardown rather than blocking on that surviving writer. This is the exact + // primitive Thufir reproduced against the round-8 blocking-read design; with + // blocking reads the drain join hangs forever and `run_watchdogged` panics. + // + // Non-vacuous: the descendant is asserted *alive* after the helper returns, + // proving it genuinely escaped the `killpg` (so it was still holding the + // pipe at join time) — the return therefore came from the stop path, not + // from an EOF the kill happened to produce. The test then reaps it. + #[cfg(unix)] + #[test] + fn returns_when_escaped_descendant_retains_pipe() { + let pid_file = tempfile::NamedTempFile::new().expect("temp file for descendant pid"); + let pid_path = pid_file + .path() + .to_str() + .expect("utf-8 temp path") + .to_string(); + // The perl descendant `setsid()`s out of the leader's group, records its + // PID, writes a few bytes to the retained stdout, then sleeps. The + // leader waits until the PID is recorded (so the test can read it) and + // then loops forever, forcing the timeout path. + let script = format!( + "perl -MPOSIX -e 'POSIX::setsid() or die; open(my $f,\">\",$ARGV[0]) or die; \ + print $f $$; close $f; print \"x\" x 4096; sleep 300;' '{pid_path}' & \ + until [ -s '{pid_path}' ]; do :; done; while :; do sleep 1; done" + ); + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", &script]); + let result = run_watchdogged(cmd, Duration::from_millis(300), Duration::from_secs(5)); + assert!( + result.is_none(), + "a timed-out probe must fail closed even when an escaped writer holds the pipe" + ); + + let descendant_pid: i32 = std::fs::read_to_string(&pid_path) + .expect("escaped descendant must have recorded its PID") + .trim() + .parse() + .expect("descendant PID must be numeric"); + assert!( + pid_alive(descendant_pid), + "descendant {descendant_pid} was expected to survive the group kill (proving it escaped)" + ); + // Reap the escaped writer so the test leaves nothing behind. + unsafe { + libc::kill(descendant_pid, libc::SIGKILL); + } + } + + // Adversarial (capture bound): a producer that streams zero bytes + // *indefinitely* — it never exits and never stops writing on its own, so + // the only thing that can end the probe is the in-flight ceiling check + // tripping `overflow`, killing the tree, and failing closed (None). + // + // The discriminator is `timeout >> bound`: the deadline is 60s but the + // watchdog fails the test at 10s, so a return within the bound proves the + // *cap* ended the probe, not the timeout. Neuter the overflow check and the + // helper runs until the 60s deadline, blowing the 10s watchdog. Pipe + // backpressure cannot end it either: the drains pull continuously, so `cat` + // would keep writing forever. Retention stays bounded by construction — + // `spawn_drain` reserves a disjoint byte range per chunk against the shared + // budget and discards everything past `CAPTURE_LIMIT` — so no over-cap + // payload is ever materialized even though the producer is infinite. + #[cfg(unix)] + #[test] + fn fails_closed_when_capture_exceeds_limit() { + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", "exec cat /dev/zero"]); + let result = run_watchdogged(cmd, Duration::from_secs(60), Duration::from_secs(10)); + assert!( + result.is_none(), + "an unbounded producer must fail closed on the capture cap, well before the deadline" + ); + } + + // The complement of the bound: output at or under the ceiling still returns + // in full, so the limit rejects only genuine overruns. + #[cfg(unix)] + #[test] + fn returns_full_output_at_capture_limit() { + let mut cmd = Command::new("/bin/sh"); + // Comfortably under 1 MiB, emitted in one burst then a clean exit. + cmd.args(["-c", "head -c 4096 /dev/zero"]); + let out = run_watchdogged(cmd, Duration::from_secs(5), Duration::from_secs(10)) + .expect("output under the limit must be returned"); + assert!(out.status.success()); + assert_eq!(out.stdout.len(), 4096); + } + + // ---- Windows tree-ownership verification (Will's box) ---------------- + // + // No CI lane executes Windows tests for this helper, so these are + // `#[ignore]`-gated for a sanctioned local run on a real Windows machine: + // + // cargo test -p buzz-desktop --lib bounded_command -- --ignored --nocapture + // + // Both assert on the actual PowerShell-recorded descendant PID (not the + // already-exited root), so neutering the Job Object ownership leaves that + // PID alive and fails the test — the mutation is observable. + + /// True while a Windows process still exists. Opens with the minimal + /// query right and reads its exit code: `STILL_ACTIVE` (259) means running, + /// any other code means exited. A failed open means the PID is gone. + #[cfg(windows)] + fn pid_alive(pid: u32) -> bool { + use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE}; + use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if handle.is_null() { + return false; + } + let mut code: u32 = 0; + let ok = GetExitCodeProcess(handle, &mut code); + CloseHandle(handle); + ok != 0 && code == STILL_ACTIVE as u32 + } + } + + /// Read a PID that a probe wrote to `path`, retrying briefly since the + /// descendant records it asynchronously. Dumps `logs` on failure so a remote + /// run diagnoses itself instead of panicking blind. + #[cfg(windows)] + fn read_recorded_pid(path: &str, logs: &[&str]) -> u32 { + for _ in 0..200 { + if let Ok(text) = std::fs::read_to_string(path) { + if let Ok(pid) = text.trim().parse::() { + return pid; + } + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!( + "descendant never recorded its PID at {path}\n{}", + dump_logs(logs) + ); + } + + /// Write a PowerShell payload to `path` as a `.ps1` file. Invoking these via + /// `powershell -File` avoids the Rust-std → cmd.exe → powershell quoting + /// gauntlet that silently mangled the inline `-Command` fixtures (the root + /// exited without its payload ever running), so the payload reaches + /// PowerShell verbatim. + #[cfg(windows)] + fn write_ps1(path: &std::path::Path, body: &str) { + std::fs::write(path, body).expect("write .ps1 payload"); + } + + /// Collect the named transcript files (each written by the fixture's + /// PowerShell) into one string for a self-diagnosing assert message. Missing + /// files are reported as such rather than skipped. + #[cfg(windows)] + fn dump_logs(paths: &[&str]) -> String { + let mut out = String::from("---- fixture transcripts ----\n"); + for p in paths { + out.push_str(&format!("[{p}]\n")); + match std::fs::read_to_string(p) { + Ok(text) if text.is_empty() => out.push_str("(empty)\n"), + Ok(text) => { + out.push_str(&text); + if !text.ends_with('\n') { + out.push('\n'); + } + } + Err(e) => out.push_str(&format!("(unreadable: {e})\n")), + } + } + out + } + + // Success path, run in a loop to hammer the spawn/assign race. A PowerShell + // root (no cmd.exe anywhere) launches a hidden, detached PowerShell + // descendant via `Start-Process -WindowStyle Hidden`; the descendant records + // its own PID and sleeps. The root then waits synchronously until the PID + // file is non-empty before exiting 0 — without that wait the root would exit + // in the same tick, the success path would close the kill-on-close job + // immediately, and the descendant would be reaped mid-cold-start before it + // could record its PID, starving the test of its evidence. The descendant is + // still born inside the job (suspend → assign → resume, no breakaway), so the + // reaping guarantee under test is unchanged; only the delivery mechanism (a + // `.ps1` via `-File`, not a mangled inline `-Command`) is fixed. Every assert + // dumps the PowerShell transcripts so a remote failure is self-diagnosing. + #[cfg(windows)] + #[test] + #[ignore = "requires a Windows host; run manually with --ignored"] + fn reaps_backgrounded_descendant_on_success_windows() { + for iteration in 0..25 { + let dir = tempfile::tempdir().expect("temp dir for fixture scripts"); + let pid_path = dir.path().join("descendant.pid"); + let child_ps1 = dir.path().join("child.ps1"); + let root_ps1 = dir.path().join("root.ps1"); + let root_log = dir.path().join("root.log"); + let child_log = dir.path().join("child.log"); + let pid_s = pid_path.to_str().expect("utf-8 pid path"); + let root_log_s = root_log.to_str().expect("utf-8 root log"); + let child_log_s = child_log.to_str().expect("utf-8 child log"); + + write_ps1( + &child_ps1, + &format!( + "$PID | Set-Content -Encoding ascii -Path '{pid_s}'\n\ + Add-Content -Path '{child_log_s}' -Value \"descendant $PID started\"\n\ + Start-Sleep -Seconds 30\n" + ), + ); + write_ps1( + &root_ps1, + &format!( + "Add-Content -Path '{root_log_s}' -Value \"root $PID launching descendant\"\n\ + Start-Process -FilePath 'powershell' -WindowStyle Hidden -ArgumentList \ + '-NoProfile','-ExecutionPolicy','Bypass','-File','{child}'\n\ + $deadline = (Get-Date).AddSeconds(15)\n\ + while (((-not (Test-Path '{pid_s}')) -or ((Get-Item '{pid_s}').Length -eq 0)) \ + -and (Get-Date) -lt $deadline) {{ Start-Sleep -Milliseconds 50 }}\n\ + Add-Content -Path '{root_log_s}' -Value \"root observed pid file, exiting\"\n\ + exit 0\n", + child = child_ps1.to_str().expect("utf-8 child path"), + ), + ); + + let mut cmd = Command::new("powershell"); + cmd.args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + root_ps1.to_str().expect("utf-8 root path"), + ]); + let out = run_watchdogged_raw(cmd, Duration::from_secs(20), Duration::from_secs(40)) + .ok() + .flatten() + .unwrap_or_else(|| { + panic!( + "iteration {iteration}: root exits, so this must return output\n{}", + dump_logs(&[root_log_s, child_log_s]) + ) + }); + assert!( + out.status.success(), + "iteration {iteration}: root must exit 0\nstdout={}\nstderr={}\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + dump_logs(&[root_log_s, child_log_s]) + ); + + let descendant_pid = read_recorded_pid(pid_s, &[root_log_s, child_log_s]); + std::thread::sleep(Duration::from_millis(300)); + assert!( + !pid_alive(descendant_pid), + "iteration {iteration}: descendant {descendant_pid} must be reaped on success, but it survived\n{}", + dump_logs(&[root_log_s, child_log_s]) + ); + } + } + + // Timeout path: a PowerShell root launches a hidden, detached PowerShell + // descendant (records its PID, sleeps 300s), waits synchronously until the + // PID file is non-empty, then enters its own 300s block so the helper's + // deadline fires inside it. The helper must time out and close the job, + // reaping both. The synchronous wait is the evidence — the descendant's PID + // is recorded before the root reaches the block the deadline fires in, so the + // reap cannot kill it mid-cold-start and starve the assert. Same `.ps1` + // delivery as the success fixture (no cmd tokenizer), and every assert dumps + // the transcripts. + #[cfg(windows)] + #[test] + #[ignore = "requires a Windows host; run manually with --ignored"] + fn reaps_descendant_on_timeout_windows() { + let dir = tempfile::tempdir().expect("temp dir for fixture scripts"); + let pid_path = dir.path().join("descendant.pid"); + let child_ps1 = dir.path().join("child.ps1"); + let root_ps1 = dir.path().join("root.ps1"); + let root_log = dir.path().join("root.log"); + let child_log = dir.path().join("child.log"); + let pid_s = pid_path.to_str().expect("utf-8 pid path"); + let root_log_s = root_log.to_str().expect("utf-8 root log"); + let child_log_s = child_log.to_str().expect("utf-8 child log"); + + write_ps1( + &child_ps1, + &format!( + "$PID | Set-Content -Encoding ascii -Path '{pid_s}'\n\ + Add-Content -Path '{child_log_s}' -Value \"descendant $PID started\"\n\ + Start-Sleep -Seconds 300\n" + ), + ); + write_ps1( + &root_ps1, + &format!( + "Add-Content -Path '{root_log_s}' -Value \"root $PID launching descendant\"\n\ + Start-Process -FilePath 'powershell' -WindowStyle Hidden -ArgumentList \ + '-NoProfile','-ExecutionPolicy','Bypass','-File','{child}'\n\ + $deadline = (Get-Date).AddSeconds(15)\n\ + while (((-not (Test-Path '{pid_s}')) -or ((Get-Item '{pid_s}').Length -eq 0)) \ + -and (Get-Date) -lt $deadline) {{ Start-Sleep -Milliseconds 50 }}\n\ + Add-Content -Path '{root_log_s}' -Value \"root observed pid file, blocking\"\n\ + Start-Sleep -Seconds 300\n", + child = child_ps1.to_str().expect("utf-8 child path"), + ), + ); + + let mut cmd = Command::new("powershell"); + cmd.args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + root_ps1.to_str().expect("utf-8 root path"), + ]); + let result = run_watchdogged_raw(cmd, Duration::from_secs(20), Duration::from_secs(40)) + .unwrap_or_else(|_| { + panic!( + "watchdog expired — output_with_timeout hung on the timeout path\n{}", + dump_logs(&[root_log_s, child_log_s]) + ) + }); + assert!( + result.is_none(), + "a timed-out tree must yield None\n{}", + dump_logs(&[root_log_s, child_log_s]) + ); + + let descendant_pid = read_recorded_pid(pid_s, &[root_log_s, child_log_s]); + std::thread::sleep(Duration::from_millis(300)); + assert!( + !pid_alive(descendant_pid), + "descendant {descendant_pid} must be job-killed on timeout, but it survived\n{}", + dump_logs(&[root_log_s, child_log_s]) + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/login_shell.rs b/desktop/src-tauri/src/managed_agents/discovery/login_shell.rs index d8f8e60354..c9109184d5 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/login_shell.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/login_shell.rs @@ -6,9 +6,15 @@ use std::path::{Path, PathBuf}; use std::process::Command; +use std::time::Duration; use super::is_executable_file; +/// Per-candidate wall-clock bound for a login-shell spawn. Matches the auth +/// probe's 10s discipline: long enough for a healthy interactive shell to +/// source its rc files, short enough that a wedged shell can't stall discovery. +const LOGIN_SHELL_TIMEOUT: Duration = Duration::from_secs(10); + /// Test-only spawn counter lives beside `discovery.rs`; import it here so the /// spawn-record call site stays byte-identical to the pre-extraction source. #[cfg(test)] @@ -34,14 +40,25 @@ pub(crate) fn login_shell_candidates() -> Vec { /// Run a command in a login shell (tries zsh then bash on Unix, Git Bash on Windows). /// Returns trimmed stdout if the command succeeds with non-empty output. +/// +/// Each candidate shell is bounded by [`LOGIN_SHELL_TIMEOUT`]: a shell whose +/// startup blocks (an interactive prompt in `.zshrc`, a stalled network mount, +/// a credential helper waiting on input) is killed and treated as a miss so the +/// loop falls through to the next candidate rather than hanging the whole +/// discovery. Without this bound a single slow login shell froze the forced +/// pipeline indefinitely, which is what left "Check again" spinning forever. fn run_in_login_shell(args: &[&str]) -> Option { #[cfg(test)] login_shell_spawn_probe::record(); for shell in login_shell_candidates() { let mut cmd = Command::new(&shell); cmd.args(args); - crate::util::configure_no_window(&mut cmd); - let Ok(output) = cmd.output() else { + // Window suppression is owned by `output_with_timeout`'s spawn + // (`BOUNDED_CREATION_FLAGS` carries `CREATE_NO_WINDOW`); a + // `configure_no_window` call here would be clobbered by that later + // `creation_flags` set, so it is deliberately omitted. + let Some(output) = super::bounded_command::output_with_timeout(cmd, LOGIN_SHELL_TIMEOUT) + else { continue; }; if !output.status.success() { @@ -72,10 +89,25 @@ enum LoginShellPath { Probed(Option), } -fn path_cache() -> &'static std::sync::Mutex { +/// Cache plus a monotonic generation counter. `refresh_login_shell_path` bumps +/// the generation and resets the state together; a probe records the generation +/// it started under and may only publish its result while that generation is +/// still current. This stops a slow, pre-refresh probe from committing a stale +/// (often false-negative) PATH over the fresh value a post-refresh probe wrote. +struct PathCache { + generation: u64, + state: LoginShellPath, +} + +fn path_cache() -> &'static std::sync::Mutex { use std::sync::{Mutex, OnceLock}; - static CACHE: OnceLock> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(LoginShellPath::Uninit)) + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| { + Mutex::new(PathCache { + generation: 0, + state: LoginShellPath::Uninit, + }) + }) } fn fetch_login_shell_path_inner() -> Option { @@ -103,45 +135,112 @@ fn fetch_login_shell_path_inner() -> Option { /// to invalidate the cache so the next call re-fetches — e.g. after the user /// installs Node.js mid-session and clicks Retry. /// -/// The lock is never held while the login shell spawns: we check for a cached -/// value, release the lock, run the shell, then re-lock to write. Two concurrent -/// callers may both run the shell (last-writer-wins is fine — both produce the -/// same result), but neither blocks a concurrent agent spawn on the Mutex. +/// The lock is never held while the login shell spawns: we read the cached +/// value and the current generation, release the lock, run the shell, then +/// re-lock to publish. Publication is generation-guarded so a probe that +/// started before a [`refresh_login_shell_path`] can never overwrite the fresh +/// value: if the generation moved while the probe ran, its result is discarded. +/// Within one generation two callers may both probe; a failure/timeout result +/// (`None`) never clobbers an already-committed success, so a slow timeout can't +/// undo a peer's fresh PATH. +/// +/// The caller never returns its own local probe result: after publishing it +/// returns the value now in the cache. This closes two divergences where a +/// caller's own result contradicted the authoritative cache: +/// - same-generation timeout-vs-success — a peer committed a success while +/// our probe timed out (`None`); we return the peer's success, not `None`; +/// - a pre-refresh probe whose writeback was generation-rejected — its local +/// value is stale, so we re-probe under the new generation instead. pub fn login_shell_path() -> Option { - // Fast path: return cached result without spawning a shell. - { - let guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); - if let LoginShellPath::Probed(ref result) = *guard { - return result.clone(); + loop { + // Fast path: return the cached result and capture the generation the + // probe will run under, all under a single lock. + let generation = { + let guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); + if let LoginShellPath::Probed(ref result) = guard.state { + return result.clone(); + } + guard.generation + }; + + // Slow path: spawn shell outside any lock. + let result = probe_login_shell_path(); + + // Publish under our generation, then return whatever value is now + // authoritative. `None` means a refresh invalidated our generation + // mid-probe and no fresh value is cached yet, so our `result` is stale + // by definition — discard it and re-probe under the new generation. + // + // Termination: another lap requires another [`refresh_login_shell_path`] + // to land during a probe. Refreshes come only from discrete human + // actions (install/retry/Doctor re-run) and one-shot boot warm, so the + // loop cannot spin unbounded. + if let Some(committed) = publish_probe_result(generation, result) { + return committed; } } +} - // Slow path: spawn shell outside any lock. - let result = fetch_login_shell_path_inner(); +/// Real login-shell probe. A `cfg(test)` seam lets the race tests inject +/// deterministic probe results (and side effects) without spawning shells. +#[cfg(not(test))] +fn probe_login_shell_path() -> Option { + fetch_login_shell_path_inner() +} - // Write back; last-writer-wins is safe here. - { - let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); - *guard = LoginShellPath::Probed(result.clone()); +#[cfg(test)] +fn probe_login_shell_path() -> Option { + match path_cache_race_tests::take_injected_probe() { + Some(injected) => injected(), + None => fetch_login_shell_path_inner(), } +} - result +/// Commit a probe's `result` under the generation it started with, then report +/// the value the caller should return. +/// +/// A probe whose generation is stale (a [`refresh_login_shell_path`] ran while +/// it was probing) does not commit. Within a live generation a failure/timeout +/// (`None`) never overwrites an already-committed success. This is the sole +/// writer of a probed value, so the two race outcomes are decided here. +/// +/// Returns `Some(v)` — the now-cached probed value the caller must return +/// (its own commit, or a peer's success that superseded it) — or `None` when +/// the cache is `Uninit` because a refresh landed mid-probe, signalling the +/// caller to re-probe under the new generation. Commit and re-read happen under +/// one lock so no refresh can slip between them. +fn publish_probe_result(generation: u64, result: Option) -> Option> { + let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); + if guard.generation == generation { + let keep_committed_success = + result.is_none() && matches!(guard.state, LoginShellPath::Probed(Some(_))); + if !keep_committed_success { + guard.state = LoginShellPath::Probed(result); + } + } + match guard.state { + LoginShellPath::Probed(ref v) => Some(v.clone()), + LoginShellPath::Uninit => None, + } } /// Invalidate the login-shell PATH cache so the next [`login_shell_path`] call /// re-fetches from a fresh login shell. /// /// Called before every install/retry operation and on Doctor Re-run so a -/// newly-installed tool becomes visible without restarting the app. +/// newly-installed tool becomes visible without restarting the app. Bumping the +/// generation revokes any in-flight probe's writeback, so a shell that started +/// before this refresh cannot recache its now-stale result. pub(crate) fn refresh_login_shell_path() { let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); - *guard = LoginShellPath::Uninit; + guard.generation = guard.generation.wrapping_add(1); + guard.state = LoginShellPath::Uninit; } #[cfg(test)] pub(crate) fn is_login_shell_path_uninit() -> bool { matches!( - *path_cache().lock().unwrap_or_else(|e| e.into_inner()), + path_cache().lock().unwrap_or_else(|e| e.into_inner()).state, LoginShellPath::Uninit ) } @@ -234,3 +333,178 @@ pub(crate) fn parse_semver_tag(s: &str) -> Option<(u64, u64, u64)> { let patch = patch_str.split('-').next()?.parse::().ok()?; Some((major, minor, patch)) } + +#[cfg(test)] +mod path_cache_race_tests { + use super::*; + use std::collections::VecDeque; + use std::sync::{Mutex, OnceLock}; + + /// A deterministic stand-in for one login-shell spawn. Returning it lets a + /// test drive `login_shell_path`'s slow path without a real shell, and run + /// side effects (a peer commit, a mid-probe refresh) at the exact moment a + /// probe would be executing. + pub(super) type InjectedProbe = Box Option + Send>; + + fn probe_queue() -> &'static Mutex> { + static Q: OnceLock>> = OnceLock::new(); + Q.get_or_init(|| Mutex::new(VecDeque::new())) + } + + /// Consumed by the `cfg(test)` `probe_login_shell_path` seam: each slow-path + /// probe pops the next injected result, falling back to the real shell when + /// the queue is empty (so unrelated cache tests still exercise real probing). + pub(super) fn take_injected_probe() -> Option { + probe_queue() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .pop_front() + } + + fn inject_probes(probes: Vec) { + let mut q = probe_queue().lock().unwrap_or_else(|e| e.into_inner()); + q.clear(); + q.extend(probes); + } + + fn cached_probe() -> Option> { + match path_cache().lock().unwrap_or_else(|e| e.into_inner()).state { + LoginShellPath::Uninit => None, + LoginShellPath::Probed(ref v) => Some(v.clone()), + } + } + + fn generation() -> u64 { + path_cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .generation + } + + /// A probe that started before a refresh must not recache its stale result. + /// Models the P1 interleaving: probe A captures generation G; a forced + /// refresh bumps to G+1 and (via probe B) commits a fresh PATH; then A + /// finishes late and tries to publish. A publishes a non-empty *success* + /// (`/stale/bin`), which the same-generation `None`-over-`Some` rule would + /// accept — so only the generation guard can reject it. This keeps the test + /// non-vacuous: delete the generation comparison and stale overwrites fresh. + #[test] + fn stale_probe_cannot_commit_after_refresh() { + let _guard = crate::managed_agents::lock_path_mutex(); + refresh_login_shell_path(); + + // Probe A starts here. + let gen_a = generation(); + + // A forced refresh invalidates the cache; probe B (new generation) then + // commits a fresh PATH. + refresh_login_shell_path(); + let gen_b = generation(); + assert_ne!(gen_a, gen_b, "refresh must bump the generation"); + publish_probe_result(gen_b, Some("/fresh/bin".to_string())); + + // Probe A finishes late and tries to publish a *stale success* under + // its old generation. Only the generation guard can reject this — the + // same-generation success-retention rule would let a `Some` through. + publish_probe_result(gen_a, Some("/stale/bin".to_string())); + + assert_eq!( + cached_probe(), + Some(Some("/fresh/bin".to_string())), + "a pre-refresh probe must not overwrite the post-refresh fresh PATH" + ); + + // Restore the shared cache so sibling tests re-probe a real PATH rather + // than reading this fixture value. + refresh_login_shell_path(); + } + + /// Within one generation a slow failure/timeout must not clobber a peer's + /// already-committed success. Two cold callers race under generation G: the + /// success lands first, the timeout (`None`) lands second and is dropped. + #[test] + fn timeout_does_not_clobber_committed_success() { + let _guard = crate::managed_agents::lock_path_mutex(); + refresh_login_shell_path(); + let gen = generation(); + + // Caller 1 succeeds. + publish_probe_result(gen, Some("/usr/local/bin".to_string())); + // Caller 2 times out later in the same generation. + publish_probe_result(gen, None); + + assert_eq!( + cached_probe(), + Some(Some("/usr/local/bin".to_string())), + "a same-generation timeout must not overwrite a committed success" + ); + + // Restore the shared cache so sibling tests re-probe a real PATH rather + // than reading this fixture value. + refresh_login_shell_path(); + } + + /// P1 #2, divergence (a): same-generation timeout-vs-success. A caller + /// whose own probe times out (`None`) must still return the success a peer + /// committed under the same generation — never its own `None`, which would + /// let a forced discovery on this thread settle a PATH-missing UI while the + /// authoritative cache holds the peer's success. + /// + /// Injected probe: commit the peer's `/peer/bin` success, then return `None` + /// (this caller's timeout). Non-vacuous for the "return authoritative value" + /// rule: return the local result instead and this yields `None`. + #[test] + fn caller_returns_peer_success_not_own_timeout() { + let _guard = crate::managed_agents::lock_path_mutex(); + refresh_login_shell_path(); + let gen = generation(); + + inject_probes(vec![Box::new(move || { + // A peer probe finishes first and commits a success under gen. + publish_probe_result(gen, Some("/peer/bin".to_string())); + // Our probe then times out. + None + })]); + + assert_eq!( + login_shell_path(), + Some("/peer/bin".to_string()), + "a timed-out caller must return the peer's committed success, not its own None" + ); + + refresh_login_shell_path(); + } + + /// P1 #2, divergence (b): a pre-refresh probe whose writeback is + /// generation-rejected must not return its stale local value; the caller + /// re-probes under the new generation and returns the fresh result. + /// + /// First injected probe refreshes mid-flight (bumping the generation) and + /// returns a stale `/stale/bin`; publication is rejected, so the caller + /// loops and the second probe returns the fresh `/fresh/bin`. Non-vacuous + /// for the re-probe rule: return the stale local value on a rejected commit + /// instead and this yields `/stale/bin`. + #[test] + fn caller_reprobes_after_midprobe_refresh() { + let _guard = crate::managed_agents::lock_path_mutex(); + refresh_login_shell_path(); + + inject_probes(vec![ + Box::new(|| { + // A forced refresh lands while this probe runs, invalidating the + // generation it started under; its result is stale by definition. + refresh_login_shell_path(); + Some("/stale/bin".to_string()) + }), + Box::new(|| Some("/fresh/bin".to_string())), + ]); + + assert_eq!( + login_shell_path(), + Some("/fresh/bin".to_string()), + "a generation-rejected probe must re-probe, never return its stale local value" + ); + + refresh_login_shell_path(); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs index 5369b6321b..aab5cd4529 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs @@ -187,3 +187,30 @@ fn cheap_discovery_never_spawns_login_shell_even_when_cold() { "the forced path must probe the absent command via login shell at least once, got {forced}" ); } + +/// Regression: `resolve_command_cached` (the cheap discovery path) must find a +/// bundled sidecar sitting next to the executable via a filesystem stat, even +/// with a cold resolve cache. Before the fix it consulted only the managed-shim +/// dirs + cache, so `buzz-agent` reported "not installed" at every cold launch. +/// Here the path form exercises the same `resolve_workspace_command` stat the +/// cheap path now shares. +#[cfg(unix)] +#[test] +fn cheap_path_resolves_workspace_sidecar_without_cache() { + use crate::managed_agents::discovery::resolve_command_cached; + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!("buzz-sidecar-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let bin = dir.join("buzz-agent"); + std::fs::write(&bin, "#!/bin/sh\n").expect("write sidecar"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + + assert_eq!( + resolve_command_cached(bin.to_str().expect("utf8 path")), + Some(bin.clone()), + "cheap path must resolve a bundled sidecar by path with a cold cache" + ); + + let _ = std::fs::remove_dir_all(dir); +} diff --git a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs index 479d6ec913..8e27ba1031 100644 --- a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs +++ b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs @@ -45,20 +45,19 @@ impl Drop for JobHandle { /// the caller can fall back to `Child::kill()` — a degraded teardown beats a /// failed spawn. /// -/// Assignment happens immediately after spawn, on the same parent thread. The -/// child (buzz-acp) does spawn its 24 workers before it connects to the relay, -/// so the window between our spawn and our assignment is NOT structurally empty. -/// What closes it is assign-latency: `OpenProcess` + `AssignProcessToJobObject` -/// are a few synchronous Win32 calls (microseconds), while buzz-acp must init -/// tokio, parse its config, and spawn 24 children (tens-to-hundreds of ms), so -/// the assign reliably wins before any worker exists. Once assigned, Windows -/// places every subsequently-spawned descendant in the job automatically. +/// For the harness spawn path ([`finish_spawn`]) assignment happens immediately +/// after a normal spawn. The child (buzz-acp) must init tokio, parse its config, +/// and spawn 24 children (tens-to-hundreds of ms) before any descendant exists, +/// so the microsecond `OpenProcess` + `AssignProcessToJobObject` reliably wins +/// that race. Once assigned, Windows places every subsequently-spawned +/// descendant in the job automatically. /// -/// `CREATE_SUSPENDED` -> assign -> `ResumeThread` would make the window airtight -/// regardless of child timing, but it requires raw `CreateProcessW`/`ResumeThread` -/// (materially more unsafe Win32) to close a microsecond race, so it is -/// deliberately not used here. -fn create_job_for_child(pid: u32) -> Option { +/// The discovery path (`bounded_command`) runs arbitrary probe commands that +/// can background a descendant and exit in the same tick, so it cannot rely on +/// assign-latency. It spawns with `CREATE_SUSPENDED`, assigns the frozen child +/// here, then calls [`resume_process`] — no descendant can exist until the job +/// owns the root, closing the race by construction. +pub(crate) fn create_job_for_child(pid: u32) -> Option { use std::ptr::null; use windows_sys::Win32::Foundation::{CloseHandle, FALSE}; use windows_sys::Win32::System::JobObjects::{ @@ -105,6 +104,56 @@ fn create_job_for_child(pid: u32) -> Option { } } +/// Resume a process spawned with `CREATE_SUSPENDED` by resuming every thread it +/// owns. A fresh `CREATE_SUSPENDED` process has exactly one thread suspended at +/// its entry point; resuming it lets the process run. We enumerate via a +/// ToolHelp thread snapshot filtered to `pid` rather than tracking the initial +/// thread id (`std::process::Command` does not expose it), and resume each so +/// the walk is correct even in the pathological multi-thread case. +/// +/// Returns `true` only if at least one owned thread was resumed. `false` means +/// no thread could be resumed — the caller must treat the child as unusable and +/// tear it down, since a still-suspended root would otherwise hang to the +/// deadline. +pub(crate) fn resume_process(pid: u32) -> bool { + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32, + }; + use windows_sys::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME}; + + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); + if snapshot == INVALID_HANDLE_VALUE { + return false; + } + + let mut entry: THREADENTRY32 = std::mem::zeroed(); + entry.dwSize = std::mem::size_of::() as u32; + + let mut resumed_any = false; + let mut has_entry = Thread32First(snapshot, &mut entry); + while has_entry != 0 { + if entry.th32OwnerProcessID == pid { + let thread = OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID); + if !thread.is_null() { + // ResumeThread returns u32::MAX on failure; any other value + // is the thread's previous suspend count. + if ResumeThread(thread) != u32::MAX { + resumed_any = true; + } + CloseHandle(thread); + } + } + entry.dwSize = std::mem::size_of::() as u32; + has_entry = Thread32Next(snapshot, &mut entry); + } + + CloseHandle(snapshot); + resumed_any + } +} + /// Kill the entire process tree rooted at `pid` via `taskkill /T`, the closest /// equivalent to the Unix process-group kill. Used on the after-restart path /// where no job handle survived. `CREATE_NO_WINDOW` keeps taskkill's own diff --git a/desktop/src/app/useAppShellLifecycleEffects.ts b/desktop/src/app/useAppShellLifecycleEffects.ts index 71db052369..fcdc29fc5f 100644 --- a/desktop/src/app/useAppShellLifecycleEffects.ts +++ b/desktop/src/app/useAppShellLifecycleEffects.ts @@ -1,5 +1,7 @@ import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { startBootWarm } from "@/features/agents/acpRuntimesQuery"; import { setDesktopAppBadge } from "@/features/notifications/lib/desktop"; import { useForegroundQueryRefresh } from "@/features/workflows/hooks"; import { relayClient } from "@/shared/api/relayClient"; @@ -23,6 +25,22 @@ export function useAppShellLifecycleEffects({ useRelayResumeTriggers(); useForegroundQueryRefresh(); + // Warm the ACP runtime catalog once at app launch. The shared runtime-catalog + // cache is in-memory only, so it starts cold every boot; the cheap discovery + // path reports every harness as "(not installed)" until a forced pass warms + // it. The create/edit picker and Agents > Agent defaults surfaces read that + // cheap path, so without this warm they render all-missing (and block agent + // save) until the user visits Settings > Agents — the accidental workaround. + // `startBootWarm` drives the module-level boot-warm gate (once per launch, so + // this remounting effect never re-fires the probe) which makes those cheap + // surfaces show loading/retryable-error instead of blessing the cold catalog, + // and swallows the probe's own errors so a failure leaves the last good + // catalog in place without an unhandled rejection. + const queryClient = useQueryClient(); + React.useEffect(() => { + void startBootWarm(queryClient); + }, [queryClient]); + // Prevent webview file:/// navigation on file drop outside the composer. // Scoped to file drags only (text drag-and-drop into inputs still works). // Composer's onDrop fires first (React synthetic before window bubble). diff --git a/desktop/src/features/agents/acpRuntimesQuery.test.mjs b/desktop/src/features/agents/acpRuntimesQuery.test.mjs index c51dea05b8..e9320bb6e9 100644 --- a/desktop/src/features/agents/acpRuntimesQuery.test.mjs +++ b/desktop/src/features/agents/acpRuntimesQuery.test.mjs @@ -179,12 +179,15 @@ globalThis.__TAURI_INTERNALS__ = { import React from "react"; import { createRoot } from "react-dom/client"; import { act } from "react"; -import { QueryClient } from "@tanstack/react-query"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query"; import { acpRuntimesQueryKey, + applyBootWarmGate, + getBootWarmSnapshot, refreshAcpRuntimes, + startBootWarm, useAcpRuntimesQueryForced, } from "./acpRuntimesQuery.ts"; import { discoverAcpRuntimes } from "@/shared/api/tauriAcpDiscovery.ts"; @@ -230,6 +233,144 @@ afterEach(() => { discoverHandler = () => Promise.resolve([]); }); +// Runs FIRST so the process-global boot-warm gate is observed from `idle`. +// Covers Carl's ask: the cheap/forced race (a cold cheap catalog must read as +// loading, not authoritative, while the first forced pass is in flight) and the +// failure state (a failed forced pass must surface a retryable error carrying +// the real reason, not a silent empty catalog), plus recovery on retry. +describe("boot-warm gate drives cheap consumers through the initial pass", () => { + it("applyBootWarmGate: a non-empty cold catalog is not authoritative while pending or failed", () => { + // The real cold cheap response is NEVER empty: discovery always emits the + // known runtimes as not_installed/cli_missing rows plus presets. Model that + // wire shape so the gate is exercised against the payload it exists to + // gate, not a `[]` that never occurs in production. + const coldCatalog = { + data: [ + rawEntry("codex", "unknown"), + rawEntry("goose", "unknown"), + rawEntry("claude-code", "unknown"), + ], + error: null, + isLoading: false, + isPending: false, + isFetching: false, + isError: false, + }; + // A consumer maps `isLoading -> "loading"`, `isError -> "error"`, else + // `"ready"`. "Ready" is what blesses the cold rows as authoritative — the + // exact P2 defect. Assert neither pending nor failed reads as ready. + const readsAsReady = (q) => !q.isLoading && !q.isError; + + const pending = applyBootWarmGate(coldCatalog, { + status: "pending", + error: null, + }); + assert.equal(pending.isLoading, true); + assert.equal(pending.isPending, true); + assert.equal( + readsAsReady(pending), + false, + "pending must not read as ready", + ); + // The catalog rows are preserved so a consumer reading `data ?? []` keeps + // them; only the lifecycle flags are overlaid. + assert.equal(pending.data.length, 3); + + const reason = new Error("PATH probe timed out"); + const failed = applyBootWarmGate(coldCatalog, { + status: "failed", + error: reason, + }); + assert.equal(failed.isError, true); + assert.equal(failed.error, reason); + assert.equal(readsAsReady(failed), false, "failed must not read as ready"); + assert.equal(failed.data.length, 3); + + // idle/settled pass through untouched: onboarding renders before the warm + // starts (idle) and the warmed hot path (settled) must both read as ready. + for (const status of ["idle", "settled"]) { + const passed = applyBootWarmGate(coldCatalog, { status, error: null }); + assert.equal(passed.isLoading, false); + assert.equal(passed.isError, false); + assert.equal(readsAsReady(passed), true, `${status} must read as ready`); + } + }); + + it("applyBootWarmGate: a warmed non-empty catalog reads as ready once settled", () => { + const warm = { + data: [rawEntry("codex", "logged_in")], + error: null, + isLoading: false, + isPending: false, + isFetching: false, + isError: false, + }; + const settled = applyBootWarmGate(warm, { status: "settled", error: null }); + assert.equal(settled.isLoading, false); + assert.equal(settled.isError, false); + assert.equal(settled.data.length, 1); + }); + + it("applyBootWarmGate: failed reads as a retryable error with the real reason", () => { + const cold = { + data: [], + error: null, + isLoading: true, + isPending: true, + isFetching: true, + isError: false, + }; + const reason = new Error("PATH probe timed out"); + const failed = applyBootWarmGate(cold, { status: "failed", error: reason }); + assert.equal(failed.isError, true); + assert.equal(failed.error, reason); + assert.equal(failed.isLoading, false, "a failed warm is not still loading"); + }); + + it("startBootWarm: failure marks the gate failed, a retry settles it", async () => { + assert.equal( + getBootWarmSnapshot().status, + "idle", + "gate must start idle before any warm", + ); + + const queryClient = makeQueryClient(); + queryClient.mount(); + + // 1. First forced pass fails: the gate goes `failed` and captures the + // reason, so cold cheap surfaces can show a retryable error. + let failForced = true; + discoverHandler = (args) => + args?.force === true && failForced + ? Promise.reject(new Error("discovery boom")) + : Promise.resolve([]); + await startBootWarm(queryClient); + assert.equal(getBootWarmSnapshot().status, "failed"); + assert.equal(getBootWarmSnapshot().error?.message, "discovery boom"); + + // 2. A retry that succeeds settles the gate and clears the error, so cheap + // consumers stop overlaying and render the warmed catalog. + failForced = false; + discoverHandler = () => Promise.resolve([rawEntry("codex", "logged_in")]); + await startBootWarm(queryClient); + assert.equal(getBootWarmSnapshot().status, "settled"); + assert.equal(getBootWarmSnapshot().error, null); + + // 3. Once settled, further boot warms are no-ops (fixes the per-remount + // re-fire): no additional forced probe fires. + const before = calls.filter( + (c) => c.command === "discover_acp_providers" && c.args?.force === true, + ).length; + await startBootWarm(queryClient); + const after = calls.filter( + (c) => c.command === "discover_acp_providers" && c.args?.force === true, + ).length; + assert.equal(after, before, "a settled gate must not re-fire the probe"); + + queryClient.unmount(); + }); +}); + describe("refreshAcpRuntimes cannot dedup onto an in-flight cheap request", () => { it("runs a distinct force:true probe and writes it into the shared cache", async () => { const queryClient = makeQueryClient(); @@ -275,6 +416,59 @@ describe("refreshAcpRuntimes cannot dedup onto an in-flight cheap request", () = queryClient.unmount(); }); + + it("an in-flight cheap query cannot clobber the forced result after refresh", async () => { + // Carl's settle-order finding: a cheap query in flight on the shared key + // must not land its (older) result after the forced catalog is written. + // `refreshAcpRuntimes` cancels the shared-key query before settling; this + // proves the cancel is load-bearing by holding a real cheap observer + // fetching, running the forced refresh, then resolving the cheap request + // late — its result must not overwrite the forced catalog, and the gate + // must settle on the forced state. (Removing the `cancelQueries` call makes + // the late cheap result win and fails this test.) + const queryClient = makeQueryClient(); + queryClient.mount(); + + // Seed a pre-existing cold catalog, then start a mounted cheap observer that + // refetches and is held pending — the real in-flight shape. + queryClient.setQueryData(acpRuntimesQueryKey, [ + rawEntry("codex", "unknown"), + ]); + const cheap = deferred(); + discoverHandler = (args) => { + if (args?.force === false) return cheap.promise; + return Promise.resolve([rawEntry("codex", "logged_in")]); + }; + const observer = new QueryObserver(queryClient, { + queryKey: acpRuntimesQueryKey, + queryFn: () => discoverAcpRuntimes(), + staleTime: 0, + }); + const unsubscribe = observer.subscribe(() => {}); + await new Promise((r) => setImmediate(r)); + + // Forced refresh completes and settles while the cheap observer is fetching. + await refreshAcpRuntimes(queryClient); + + // The cheap request resolves afterward; its result must be dropped. + cheap.resolve([rawEntry("codex", "unknown")]); + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); + + assert.equal( + queryClient.getQueryData(acpRuntimesQueryKey)?.[0]?.authStatus.status, + "logged_in", + "shared cache must remain the forced result after a late cheap resolution", + ); + assert.equal( + getBootWarmSnapshot().status, + "settled", + "the gate must settle on the forced catalog, not the stale cheap state", + ); + + unsubscribe(); + queryClient.unmount(); + }); }); describe("useAcpRuntimesQueryForced surfaces forced-probe failures", () => { diff --git a/desktop/src/features/agents/acpRuntimesQuery.ts b/desktop/src/features/agents/acpRuntimesQuery.ts index 0e76e25ee7..16f82a0aa9 100644 --- a/desktop/src/features/agents/acpRuntimesQuery.ts +++ b/desktop/src/features/agents/acpRuntimesQuery.ts @@ -19,6 +19,153 @@ export const acpRuntimesQueryKey = ["acp-runtimes"] as const; */ export const acpRuntimesForcedQueryKey = ["acp-runtimes", "forced"] as const; +/** + * Boot-warm gate for the *initial* forced discovery pass. + * + * The shared runtime catalog is in-memory only, so it starts cold every launch: + * the cheap discovery path reports every harness `(not installed)` until a + * forced pass warms it. Without a gate, the create/edit picker and Agents > + * Agent defaults surfaces read that cheap path and present the cold catalog as + * *authoritative* — blessing every harness as unavailable and blocking save — + * during the 20–65s boot probe, and forever if that probe fails. + * + * This module-level state lets cheap consumers (`useAcpRuntimesQuery`) treat the + * catalog as still-loading while the first forced pass is in flight and as a + * retryable error if it failed, instead of authoritative. It is process-global + * (one launch), so `startBootWarm` runs the warm exactly once no matter how many + * times `AppShell` mounts — that also fixes the per-remount re-fire. + * + * The seam that protects onboarding (which renders before `AppShell` fires the + * warm): the gate only overlays loading/error once the warm has *started* + * (`pending`/`failed`). While `idle` — no warm yet, e.g. the onboarding flow — + * cheap consumers behave exactly as before. A successful forced refresh from any + * surface settles the gate, so onboarding's own forced warm clears it too. + */ +export type AcpBootWarmStatus = "idle" | "pending" | "settled" | "failed"; + +/** + * A stable snapshot object for `useSyncExternalStore`: `getSnapshot` must return + * a referentially-stable value between changes, so the object is rebuilt only in + * `setBootWarm`, never per read. + */ +let bootWarmSnapshot: { status: AcpBootWarmStatus; error: Error | null } = { + status: "idle", + error: null, +}; +const bootWarmListeners = new Set<() => void>(); + +function setBootWarm(status: AcpBootWarmStatus, error: Error | null) { + if (bootWarmSnapshot.status === status && bootWarmSnapshot.error === error) { + return; + } + bootWarmSnapshot = { status, error }; + for (const listener of bootWarmListeners) listener(); +} + +export function subscribeBootWarm(listener: () => void) { + bootWarmListeners.add(listener); + return () => { + bootWarmListeners.delete(listener); + }; +} + +export function getBootWarmSnapshot() { + return bootWarmSnapshot; +} + +/** + * Overlay the launch boot-warm gate onto a cheap-path query result so cheap + * consumers never present a cold catalog as authoritative. Pure so it can be + * unit-tested without a mounted hook. + * + * The cheap backend response is *never* empty on a cold cache — discovery + * always emits the full set of known runtimes (as `not_installed`/`cli_missing` + * rows) plus presets. Gating on `data.length` would therefore be a no-op for the + * exact payload this exists to gate, so the gate keys on the boot-warm state + * instead and always preserves `query.data`: + * + * - `pending` (first forced pass in flight) reads as loading, so a cold catalog + * is presented as still-loading rather than a settled "everything + * unavailable" list — even though those cold rows are non-empty. + * - `failed` (forced pass rejected) reads as a retryable error carrying the + * probe's real reason. + * - `idle`/`settled` pass the query through unchanged, so onboarding (which + * renders before the warm starts) and the warmed hot path are untouched. + * + * `query.data` is preserved on every branch: overlaying only the lifecycle + * flags means a consumer that reads `data ?? []` keeps its rows while a + * status-driven consumer correctly treats them as not-yet-authoritative. + */ +export function applyBootWarmGate< + Q extends { + data?: unknown[]; + error: Error | null; + isLoading: boolean; + isPending: boolean; + isFetching: boolean; + isError: boolean; + }, +>(query: Q, bootWarm: { status: AcpBootWarmStatus; error: Error | null }): Q { + if (bootWarm.status === "pending") { + return { ...query, isLoading: true, isPending: true, isFetching: true }; + } + if (bootWarm.status === "failed") { + return { + ...query, + isError: true, + error: bootWarm.error ?? query.error, + isLoading: false, + }; + } + return query; +} + +/** + * Run the initial forced discovery pass once per launch and drive the boot-warm + * gate. `AppShell` calls this on mount; the `pending`/`settled` short-circuit + * makes remounts no-ops (fixing the re-fire) while still retrying after a prior + * failure. Success is recorded by `refreshAcpRuntimes` itself (any forced + * success settles the gate); this only has to mark its own failure. + */ +export async function startBootWarm( + queryClient: ReturnType, +) { + const status: AcpBootWarmStatus = bootWarmSnapshot.status; + if (status === "pending" || status === "settled") { + return; + } + setBootWarm("pending", null); + const result = await refreshAcpRuntimes(queryClient); + // A concurrent forced success may have already settled the gate; only mark + // failed if this pass is still the pending one and it returned no catalog. + if (result === undefined && bootWarmSnapshot.status === "pending") { + setBootWarm("failed", lastForcedError); + } +} + +/** + * A stable callback that re-runs the boot warm after it failed, for the retry + * affordance the cheap-path surfaces (create/edit picker, Agent defaults) show + * when the gate is in its `failed` state. `startBootWarm` is the retry + * primitive: from `failed` it transitions back through `pending` (so the + * surface shows loading again) to `settled` on success or `failed` with a fresh + * reason on another rejection. It no-ops while `pending`/`settled`, so a + * double-click cannot stack probes. + */ +export function useRetryBootWarm() { + const queryClient = useQueryClient(); + return React.useCallback(() => { + void startBootWarm(queryClient); + }, [queryClient]); +} + +/** + * The error from the most recent failed forced probe, surfaced through the + * boot-warm `failed` state so a cold catalog shows a real reason rather than a + * silent empty list. Cleared on the next forced success. + */ +let lastForcedError: Error | null = null; + /** * Run a forced (full re-discovery) refresh and write the result into the shared * runtime-catalog cache. @@ -48,13 +195,20 @@ export async function refreshAcpRuntimes( staleTime: 0, gcTime: 0, }); - queryClient.setQueryData(acpRuntimesQueryKey, result); - // A hot-surface cheap fetch may already be in flight on the shared key; cancel - // it so its (older, cached) result cannot land after and clobber the fresh - // forced catalog we just wrote. + // Cancel and *await* the in-flight cheap query on the shared key BEFORE + // writing the forced result. `cancelQueries` defaults to `revert: true`, so + // cancellation restores the cheap query's pre-fetch state; doing it after + // `setQueryData` would let that revert land last and clobber the fresh + // forced catalog, and the gate would then settle on the stale state. With + // the cancel awaited first, our `setQueryData` is the final write. await queryClient.cancelQueries({ queryKey: acpRuntimesQueryKey }); + queryClient.setQueryData(acpRuntimesQueryKey, result); + // Any forced success proves the catalog is warm: settle the boot-warm gate + // and clear the last error, so cheap consumers stop overlaying loading/error. + lastForcedError = null; + setBootWarm("settled", null); return result; - } catch { + } catch (error) { // The forced probe rejected. `fetchQuery` has already recorded the error in // the forced key's query state, where `useAcpRuntimesQueryForced` projects // it into the hook's returned `error`/`isError`. Swallow the rejection here @@ -63,7 +217,9 @@ export async function refreshAcpRuntimes( // paths) can keep `void refreshAcpRuntimes(...)` without ever leaking an // unhandled rejection, and a new call site can never reintroduce one. The // shared cache is left untouched so consumers keep the last good catalog - // alongside the surfaced error. + // alongside the surfaced error. Record the error so a failed boot warm can + // surface a real reason on the cheap-path surfaces (via the boot-warm gate). + lastForcedError = error instanceof Error ? error : new Error(String(error)); return undefined; } } diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index b7dd766733..3daf4fa78c 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -52,9 +52,15 @@ import { import { bootstrapManagedAgentRuntimePairs } from "@/features/agents/managedAgentRuntimeHooks"; import { acpRuntimesQueryKey, + applyBootWarmGate, + getBootWarmSnapshot, refreshAcpRuntimes, + subscribeBootWarm, +} from "@/features/agents/acpRuntimesQuery"; +export { + useAcpRuntimesQueryForced, + useRetryBootWarm, } from "@/features/agents/acpRuntimesQuery"; -export { useAcpRuntimesQueryForced } from "@/features/agents/acpRuntimesQuery"; import { createPersona, deletePersona, @@ -218,12 +224,23 @@ function invalidateManagedAgentQueriesInBackground( * probe pipeline. */ export function useAcpRuntimesQuery(options?: { enabled?: boolean }) { - return useQuery({ + const query = useQuery({ enabled: options?.enabled ?? true, queryKey: acpRuntimesQueryKey, queryFn: () => discoverAcpRuntimes(), staleTime: 30 * 60_000, }); + // Overlay the launch boot-warm gate so cheap consumers never present a cold + // catalog as authoritative: until the first forced pass settles, an un-warmed + // catalog reads as loading (`pending`) or a retryable error (`failed`) rather + // than "every harness not installed". `applyBootWarmGate` preserves an + // already-good list and passes through untouched while idle/settled. + const bootWarm = React.useSyncExternalStore( + subscribeBootWarm, + getBootWarmSnapshot, + getBootWarmSnapshot, + ); + return applyBootWarmGate(query, bootWarm); } export function useAvailableAcpRuntimes(options?: { enabled?: boolean }) { diff --git a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx index 69e8b3a5b4..3564f31cb5 100644 --- a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx +++ b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx @@ -33,6 +33,7 @@ import { sortPersonaRuntimes, } from "@/features/agents/ui/agentConfigOptions"; import { AgentDropdownSelect } from "@/features/agents/ui/agentConfigControls"; +import { HarnessCatalogRetryNotice } from "@/features/agents/ui/HarnessCatalogRetryNotice"; import { AgentConfigFields, EMPTY_GLOBAL_CONFIG, @@ -171,10 +172,12 @@ export function AgentDefaultsEditor({ [sortedRuntimes], ); const configSurfaceLoading = isLoading || runtimesQuery.isLoading; - const configSurfaceError = - loadError || + // The runtime catalog failing to warm is retryable in-place (re-run the boot + // probe); a global-config load failure is not, so it keeps the restart copy. + const runtimeCatalogError = runtimesQuery.isError || - (!configSurfaceLoading && sortedRuntimes.length === 0); + (!configSurfaceLoading && !loadError && sortedRuntimes.length === 0); + const configSurfaceError = loadError || runtimeCatalogError; function handleConfigChange(next: GlobalAgentConfig) { configRef.current = next; @@ -268,10 +271,16 @@ export function AgentDefaultsEditor({ Loading… ) : configSurfaceError ? ( -
- - Couldn't load agent defaults. Restart the app to try again. -
+ runtimeCatalogError && !loadError ? ( +
+ +
+ ) : ( +
+ + Couldn't load agent defaults. Restart the app to try again. +
+ ) ) : ( <>
diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 06f41667b0..81033f7d92 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -823,6 +823,7 @@ export function AgentDefinitionDialog({ > {aiConfigurationMode === "custom" ? ( ) : null} - {llmProviderFieldVisible && aiConfigurationMode === "custom" ? (
void; options: PersonaDropdownOption[]; @@ -34,7 +37,7 @@ export function AgentHarnessField({ placeholder={placeholder} value={value} /> - {warning} + {catalogStatus === "error" ? : warning}
); } diff --git a/desktop/src/features/agents/ui/HarnessCatalogRetryNotice.tsx b/desktop/src/features/agents/ui/HarnessCatalogRetryNotice.tsx new file mode 100644 index 0000000000..34efb54c94 --- /dev/null +++ b/desktop/src/features/agents/ui/HarnessCatalogRetryNotice.tsx @@ -0,0 +1,24 @@ +import { AlertCircle } from "lucide-react"; + +import { useRetryBootWarm } from "@/features/agents/hooks"; +import { Button } from "@/shared/ui/button"; + +/** + * Inline error affordance shown when the launch runtime-catalog warm failed + * (the boot-warm gate's `failed` state). Unlike a global-config load failure — + * which is not retryable and keeps the "restart the app" copy — a failed + * harness probe re-runs in place via `useRetryBootWarm`, so the create/edit + * picker and Agent defaults surfaces both render this instead of a dead end. + */ +export function HarnessCatalogRetryNotice() { + const retryBootWarm = useRetryBootWarm(); + return ( +
+ + Couldn't detect agent harnesses. + +
+ ); +} From 8dbc65d9e2c80d9d8516e17b751c46e0568100e6 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Fri, 28 Aug 2026 17:11:04 -0700 Subject: [PATCH 03/22] fix(composer): polish automatic mentions (#6956) **Category:** improvement **User Impact:** Automatic mentions are easier to turn off and now behave consistently across conversations, settings, drafts, and repeated agent mentions. **Problem:** People found the new automatic mention behavior hard to control: turning it off in Settings did not reliably affect the composer, removing a mention could require also disabling the feature, and root/thread composers could inherit or restore surprising state. Other reported rough edges included only one of several mentioned agents becoming automatic, synthetic mentions leaking into drafts, restored mentions corrupting adjacent text, controls remaining visible in archived channels, and unclear picker feedback. See the [original feedback thread](buzz://message?channel=e62570dd-33ad-42c5-b92b-75f2689f9694&id=c949ec399274fbb0d6633da3f95712e843a67dcada214f63d72f6975b406604b). **Solution:** Polish the existing feature around the problems people encountered, keeping automatic mentions controllable and scoped to the active conversation. | Reported issue | UX fix | | --- | --- | | Turning automatic mentions off in Settings did not reliably update the composer. | The global setting and composer control stay synchronized, and disabling the feature does not clear typed text. | | Removing an automatic mention could require both deleting the mention and turning off the feature. | Removing or unchecking an agent excludes that agent for the current conversation, while explicitly re-adding the agent can restore automatic mention behavior. | | Root and thread composers could share or restore surprising selections. | Each root or thread composer keeps its own automatic audience and restores it when the user returns. A request to enable automatic mentions only in agent threads was considered; this PR keeps them available at the channel root but prevents state from leaking between the two. | | Mentioning multiple agents could leave only one saved as automatic. | Multi-agent selections remain represented in the automatic audience and restored mention chips. | | Automatic mention prefixes could be saved as if the user typed them. | Synthetic prefixes stay out of persisted drafts while authored text is preserved. | | Restored mentions could lose their separator and corrupt continued typing. | Restored multi-word mentions retain their trailing space and place the caret after it. | | Archived channels showed automatic-mention state beside a disabled composer. | Disabled composers hide automatic-mention controls while preserving the draft and restoring state when re-enabled. | | Confirmation and picker behavior made the feature feel difficult to inspect or adjust. | Confirmations dismiss with removed agents, remain open while hovered, and expose the setting before it changes; pin icons, contrast, scope copy, animation, and keyboard toggling are also clarified. | | Agent suggestions and membership state could shift during directory refreshes. | Suggestions and membership labels stay stable during refreshes, while send-time authorization still revalidates access. | ## Changes
File changes **desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs** Adds coverage for the channel-roster eligibility rules used by agent mention autocomplete. **desktop/src/features/agents/lib/agentAutocompleteEligibility.ts** Aligns agent autocomplete eligibility with channel membership so available agents and their labels stay trustworthy. **desktop/src/features/channels/ui/MembersSidebar.tsx** Uses the shared member-pubkey logic when presenting and acting on channel members. **desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs** Covers preference changes that must remain stable while composer controls are toggled. **desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts** Keeps the automatic-mention preference as durable user intent rather than transient composer state. **desktop/src/features/messages/lib/mentionMemberPubkeys.ts** Centralizes which member identities count as mentionable in the current channel. **desktop/src/features/messages/lib/persistentAgentAudience.test.mjs** Expands lifecycle coverage for persistent agent audiences, explicit exclusions, and restored mentions. **desktop/src/features/messages/lib/persistentAgentAudience.ts** Models automatic, explicit, and excluded agent audiences separately so user choices survive updates without leaking across composers. **desktop/src/features/messages/lib/stripImplicitAgentMentions.test.mjs** Verifies implicit automatic mentions are removed without damaging surrounding separators or authored content. **desktop/src/features/messages/lib/stripImplicitAgentMentions.ts** Strips presentation-only automatic mentions before draft persistence while preserving whitespace and authored text. **desktop/src/features/messages/lib/useMentions.ts** Routes mention insertion and removal through the composer-local audience lifecycle. **desktop/src/features/messages/lib/useRichTextEditor.ts** Preserves mention-chip structure and caret placement when automatic mentions are restored. **desktop/src/features/messages/ui/ComposerAddressControls.test.mjs** Updates control-state expectations for disabled automatic mentions and restored pin affordances. **desktop/src/features/messages/ui/ComposerAddressControls.tsx** Makes automatic-mention state, disabled presentation, and pin controls visually explicit. **desktop/src/features/messages/ui/MentionAutocomplete.test.mjs** Adds coverage for roster labels, pin state, and picker behavior after mention selection. **desktop/src/features/messages/ui/MentionAutocomplete.tsx** Keeps the shortcut picker open for repeated selection and restores visible automatic-mention pin indicators. **desktop/src/features/messages/ui/MessageComposer.tsx** Scopes automatic mention state to each root or thread composer and coordinates restoration, draft persistence, and sending. **desktop/src/features/messages/ui/MessageComposerToolbar.tsx** Passes the effective automatic-mention state into the toolbar presentation. **desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs** Updates keyboard interaction coverage for toggling agents in place. **desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts** Restores automatic mention chips after lifecycle changes without moving or duplicating authored content. **desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs** Substantially expands coverage for toggles, exclusions, synchronization, and picker dismissal rules. **desktop/src/features/messages/ui/useAgentAddressLockPicker.ts** Keeps the picker usable across repeated choices and preserves explicit per-agent intent while settings change. **desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts** Makes the keyboard shortcut toggle the highlighted automatic audience choice without replacing unrelated selections. **desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts** Owns composer-local automatic-mention lifecycle behavior, including restoration, exclusions, deletion, and disabled-state handling. **desktop/src/features/messages/ui/useComposerMentionPicker.test.mjs** Adds focused picker lifecycle coverage for selection, hover, and dismissal behavior. **desktop/src/features/messages/ui/useComposerMentionPicker.ts** Prevents premature picker dismissal while the user is interacting with its controls. **desktop/src/features/messages/ui/useDraftPersistSnapshot.ts** Persists only user-authored draft content rather than implicit automatic mention decorations. **desktop/src/shared/lib/keyboard-shortcuts.ts** Updates the automatic-mention shortcut description to match its toggle behavior. **desktop/src/testing/e2eBridge.ts** Extends the desktop test bridge with the state needed to exercise roster and automatic-mention transitions. **desktop/tests/e2e/mentions.spec.ts** Covers roster-based labels, managed-agent invitation, revocation, and recovery behavior in the complete mention flow. **desktop/tests/e2e/persistent-agent-audience.spec.ts** Adds end-to-end coverage for root/thread isolation, preference synchronization, manual exclusions, draft hygiene, restored chips, separators, hover behavior, and disabled presentation.
## Reproduction Steps 1. Open a channel with at least two available agents and enable automatic mentions from the composer mention control. 2. Select multiple agents, remove or uncheck one, and confirm subsequent composer updates keep that agent excluded while the others remain automatic. 3. Open a thread, choose a different automatic audience there, and switch between the thread and root composer; confirm each composer retains only its own choices. 4. Disable automatic mentions and confirm the draft text remains unchanged while automatic chips and controls show the disabled state; re-enable the setting and confirm eligible automatic chips return. 5. Delete an automatic mention chip, then explicitly add the agent again; confirm it immediately returns as an automatic mention without disturbing spaces or the caret, including for a multi-word name. 6. Reload with a saved draft and confirm implicit automatic mentions were not persisted as authored draft text. 7. Use the automatic-mention keyboard shortcut and picker repeatedly; confirm the picker remains open for additional choices and the highlighted agent toggles in place. ## Validation Validated at `34d208b47d64a9816f88e10a46bcfd479e917d75` after rebasing onto `origin/main` (`69096c9a8`): - Desktop unit tests: 5,731 passed, 0 failed. - Desktop TypeScript typecheck: passed. - Desktop E2E build: passed; emitted only existing chunk and dynamic-import warnings. - `pnpm check`: exited successfully; 4 warnings and 5 informational findings are in unrelated files introduced by current main. ## Screenshots/Demos The behavioral changes are covered by the focused desktop E2E scenarios above. Screenshots can be attached from the screenshot-producing automatic-mention E2E after the PR is created. --------- Signed-off-by: Taylor Ho Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Carl Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com> --- .../lib/agentAutocompleteEligibility.test.mjs | 10 + .../lib/agentAutocompleteEligibility.ts | 13 + .../features/channels/ui/MembersSidebar.tsx | 9 +- .../autoPinMentionedAgentsPreference.test.mjs | 14 + .../lib/autoPinMentionedAgentsPreference.ts | 3 + .../lib/buildMentionCandidates.test.mjs | 1 + .../messages/lib/buildMentionCandidates.ts | 15 +- .../messages/lib/mentionHighlightExtension.ts | 7 +- .../messages/lib/mentionMemberPubkeys.ts | 19 + .../lib/persistentAgentAudience.test.mjs | 51 +- .../messages/lib/persistentAgentAudience.ts | 53 +- .../lib/stripImplicitAgentMentions.test.mjs | 45 ++ .../lib/stripImplicitAgentMentions.ts | 15 + .../src/features/messages/lib/useMentions.ts | 31 +- .../messages/lib/useRichTextEditor.ts | 28 +- .../ui/ComposerAddressControls.test.mjs | 8 +- .../messages/ui/ComposerAddressControls.tsx | 15 +- .../messages/ui/MentionAutocomplete.test.mjs | 61 +- .../messages/ui/MentionAutocomplete.tsx | 37 +- .../features/messages/ui/MessageComposer.tsx | 88 ++- .../messages/ui/MessageComposerToolbar.tsx | 3 + .../ui/composerAgentKeyboard.test.mjs | 17 +- .../ui/useAddressedAgentMentionRestore.ts | 66 ++ .../ui/useAgentAddressLockPicker.test.mjs | 255 ++++++- .../messages/ui/useAgentAddressLockPicker.ts | 149 +++- .../messages/ui/useAlwaysAddressShortcut.ts | 9 +- .../messages/ui/useAutoPinMentionedAgents.ts | 146 +++- .../ui/useComposerMentionPicker.test.mjs | 94 +++ .../messages/ui/useComposerMentionPicker.ts | 37 +- .../messages/ui/useDraftPersistSnapshot.ts | 16 +- .../ui/useImplicitAgentMentionProvenance.ts | 54 ++ desktop/src/shared/lib/keyboard-shortcuts.ts | 2 +- desktop/src/testing/e2eBridge.ts | 11 +- desktop/tests/e2e/mentions.spec.ts | 134 +++- .../e2e/persistent-agent-audience.spec.ts | 648 +++++++++++++++++- 35 files changed, 1922 insertions(+), 242 deletions(-) create mode 100644 desktop/src/features/messages/lib/mentionMemberPubkeys.ts create mode 100644 desktop/src/features/messages/lib/stripImplicitAgentMentions.test.mjs create mode 100644 desktop/src/features/messages/lib/stripImplicitAgentMentions.ts create mode 100644 desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts create mode 100644 desktop/src/features/messages/ui/useComposerMentionPicker.test.mjs create mode 100644 desktop/src/features/messages/ui/useImplicitAgentMentionProvenance.ts diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 21880eca2f..5398b28f05 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -8,6 +8,7 @@ import { getAgentMentionAdmission, getMentionableAgentPubkeys, getSharedChannelIds, + isAgentDirectoryReady, isAgentIdentityInAllowedList, isAgentMentionChannelType, relayAgentCanRespondInChannel, @@ -42,6 +43,15 @@ function makeAgent(overrides = {}) { }; } +test("isAgentDirectoryReady: requires successful cached directory evidence", () => { + assert.equal(isAgentDirectoryReady({ data: [], error: null }), true); + assert.equal(isAgentDirectoryReady({ data: undefined, error: null }), false); + assert.equal( + isAgentDirectoryReady({ data: [], error: new Error("offline") }), + false, + ); +}); + test("getSharedChannelIds: includes only active joined channels", () => { assert.deepEqual( getSharedChannelIds([ diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index 4e1c787f92..e3c82cfff4 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -1,6 +1,19 @@ import type { Channel, RelayAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; +export function isAgentDirectoryReady({ + data, + error, +}: { + data: unknown; + error: unknown; +}) { + // A successful cached directory remains suitable for autocomplete during a + // refetch. Sending still re-fetches and fails closed at its authorization + // boundary, so suggestions are hints rather than permission to send. + return data !== undefined && error === null; +} + export function getSharedChannelIds(channels: readonly Channel[] | undefined) { return new Set( (channels ?? []) diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 562f890c0f..9e15cdc635 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -12,6 +12,7 @@ import { coalesceAgentAutocompleteCandidates, getMentionableAgentPubkeys, getSharedChannelIds, + isAgentDirectoryReady, isAgentIdentityInAllowedList, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { isOtherSetupAgent } from "@/features/agents/lib/otherSetupAgent"; @@ -184,12 +185,8 @@ export function MembersSidebar({ relayAgentsQuery, } = useClassifiedMembers(rawMembers, currentPubkey); const agentDirectoriesReady = - managedAgentsQuery.data !== undefined && - managedAgentsQuery.error === null && - !managedAgentsQuery.isFetching && - relayAgentsQuery.data !== undefined && - relayAgentsQuery.error === null && - !relayAgentsQuery.isFetching; + isAgentDirectoryReady(managedAgentsQuery) && + isAgentDirectoryReady(relayAgentsQuery); const activeMembers = React.useMemo( () => [...people, ...bots].sort((left, right) => diff --git a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs index b30008109c..06a24b5b68 100644 --- a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs +++ b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs @@ -8,6 +8,7 @@ globalThis.localStorage = { }; const preference = await import("./autoPinMentionedAgentsPreference.ts"); +const persistentAudience = await import("./persistentAgentAudience.ts"); test("defaults missing and invalid values to one-time agent mentions", () => { assert.equal(preference.parseKeepMentionedAgentsPinned(null), false); @@ -31,3 +32,16 @@ test("persists changes to the post-mention pinning preference", () => { "false", ); }); + +test("turning off automatic mentions clears active conversation audiences", () => { + const scope = `${"1".repeat(64)}:channel-a:channel`; + persistentAudience.setPersistentAgentAudience(scope, ["a".repeat(64)]); + preference.setKeepMentionedAgentsPinned(true); + + preference.setKeepMentionedAgentsPinned(false); + + assert.deepEqual( + persistentAudience.getPersistentAgentAudienceSnapshot().audiences, + {}, + ); +}); diff --git a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts index 8f8e0b12d6..27a792f335 100644 --- a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts +++ b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts @@ -1,5 +1,7 @@ import * as React from "react"; +import { resetPersistentAgentAudienceStore } from "./persistentAgentAudience"; + export const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY = "buzz.messages.keepMentionedAgentsPinned"; export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = false; @@ -37,6 +39,7 @@ export function getKeepMentionedAgentsPinned(): boolean { } export function setKeepMentionedAgentsPinned(value: boolean): void { + if (!value) resetPersistentAgentAudienceStore(); if (value === keepMentionedAgentsPinned) return; keepMentionedAgentsPinned = value; try { diff --git a/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs b/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs index afb2a28921..250e40e0a3 100644 --- a/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs +++ b/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs @@ -23,6 +23,7 @@ function input(overrides = {}) { managedAgents: [], memberPubkeys: new Set(), members: [], + mentionChannelId: null, mentionableAgentPubkeys: new Set(), personaNameByPubkey: new Map(), profiles: undefined, diff --git a/desktop/src/features/messages/lib/buildMentionCandidates.ts b/desktop/src/features/messages/lib/buildMentionCandidates.ts index 1d9771317a..45bdf591e0 100644 --- a/desktop/src/features/messages/lib/buildMentionCandidates.ts +++ b/desktop/src/features/messages/lib/buildMentionCandidates.ts @@ -36,6 +36,7 @@ export type BuildMentionCandidatesInput = { managedAgents: readonly ManagedAgent[] | undefined; memberPubkeys: ReadonlySet; members: readonly ChannelMember[] | undefined; + mentionChannelId: string | null; mentionableAgentPubkeys: ReadonlySet; personaNameByPubkey: ReadonlyMap; profiles: UserProfileLookup | undefined; @@ -66,6 +67,7 @@ export function buildMentionCandidates({ managedAgents, memberPubkeys, members, + mentionChannelId, mentionableAgentPubkeys, personaNameByPubkey, profiles, @@ -167,7 +169,13 @@ export function buildMentionCandidates({ kind: "identity", pubkey, displayName: agent.name, - isMember: false, + // Prefer the active channel's signed roster. The relay-agent directory + // is filtered by access policy, so its channel ids can legitimately omit + // a room where this identity is already a member. + isMember: + memberPubkeys.has(pubkey) || + (mentionChannelId !== null && + agent.channelIds.includes(mentionChannelId)), personaId: managedAgentPersonaIdsByPubkey.get(pubkey) ?? (activePersonaById.has(pubkey) ? pubkey : undefined), @@ -177,11 +185,12 @@ export function buildMentionCandidates({ }); } for (const agent of managedAgents ?? []) { + const pubkey = normalizePubkey(agent.pubkey); addCandidate({ kind: "identity", - pubkey: agent.pubkey, + pubkey, displayName: agent.name, - isMember: false, + isMember: memberPubkeys.has(pubkey), isAgent: true, isActiveAgent: agent.status === "running" || agent.status === "deployed", isManagedAgent: true, diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index e55c79c9a3..2d75bcacc5 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -431,7 +431,12 @@ export const MentionHighlightExtension = Extension.create({ applying = false; } } - setDomCaretAtPos(view, view.state.selection.from); + // Highlight refreshes can land after the user has moved focus to + // a popover. Keep settlement armed for the next keystroke, but + // never drag DOM selection back into an unfocused composer. + if (view.hasFocus()) { + setDomCaretAtPos(view, view.state.selection.from); + } }, destroy() { settlement.cancel(); diff --git a/desktop/src/features/messages/lib/mentionMemberPubkeys.ts b/desktop/src/features/messages/lib/mentionMemberPubkeys.ts new file mode 100644 index 0000000000..ae1a3e7381 --- /dev/null +++ b/desktop/src/features/messages/lib/mentionMemberPubkeys.ts @@ -0,0 +1,19 @@ +import type { Channel, ChannelMember } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { channelMemberPubkeySet } from "@/shared/lib/rosterDerivations"; + +/** Merge the dedicated roster with the active channel's signed projection. */ +export function getMentionMemberPubkeys( + channelId: string | null, + channels: readonly Channel[] | undefined, + members: ChannelMember[] | undefined, +): Set { + const pubkeys = new Set( + members ? channelMemberPubkeySet(members) : undefined, + ); + const activeChannel = channels?.find((channel) => channel.id === channelId); + for (const pubkey of activeChannel?.memberPubkeys ?? []) { + pubkeys.add(normalizePubkey(pubkey)); + } + return pubkeys; +} diff --git a/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs b/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs index 64d11cd631..dc3ca94241 100644 --- a/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs +++ b/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs @@ -134,20 +134,29 @@ test("reset clears every audience for refresh and community boundaries", async ( assert.deepEqual(currentAudiences(store), {}); }); -test("channel and thread composers share the channel audience scope", async () => { +test("channel and thread composers have independent audience scopes", async () => { const store = await loadStore(8); const channelScope = store.getPersistentAgentAudienceScope({ ownerPubkey: ownerA, channelId: "channel-a", + composerKey: "channel-a", }); const threadScope = store.getPersistentAgentAudienceScope({ ownerPubkey: ownerA, channelId: "channel-a", - threadRootId: "root", + composerKey: "thread:root", + }); + const otherThreadScope = store.getPersistentAgentAudienceScope({ + ownerPubkey: ownerA, + channelId: "channel-a", + composerKey: "thread:other-root", }); assert.equal(channelScope, `${ownerA}:channel-a:channel`); - assert.equal(threadScope, channelScope); + assert.equal(threadScope, `${ownerA}:channel-a:thread:root`); + assert.equal(otherThreadScope, `${ownerA}:channel-a:thread:other-root`); + assert.notEqual(threadScope, channelScope); + assert.notEqual(otherThreadScope, threadScope); }); test("delayed promotion cannot overwrite a newer audience choice", async () => { @@ -191,6 +200,42 @@ test("stale auto-pin Undo cannot remove a newer explicit choice", async () => { assert.deepEqual(currentAudiences(store), { [scope]: [agentA] }); }); +test("explicitly excluded agents are not auto-promoted again", async () => { + const store = await loadStore(12); + const scope = `${ownerA}:channel-a:channel`; + store.setPersistentAgentAudience(scope, [agentA]); + store.excludePersistentAgentAudienceMember(scope, agentA); + + const promotion = store.promotePersistentAgentAudienceIfUnchanged({ + expectedRevision: store.getPersistentAgentAudienceRevision(scope), + pubkeys: [agentA], + scope, + }); + + assert.equal(promotion, null); + assert.deepEqual(currentAudiences(store), { [scope]: [] }); + + store.addPersistentAgentAudienceMember(scope, agentA); + assert.deepEqual(currentAudiences(store), { [scope]: [agentA] }); +}); + +test("explicit re-selection reinstates an excluded agent", async () => { + const store = await loadStore(13); + const scope = `${ownerA}:channel-a:channel`; + store.setPersistentAgentAudience(scope, [agentA]); + store.excludePersistentAgentAudienceMember(scope, agentA); + + const promotion = store.promotePersistentAgentAudienceIfUnchanged({ + expectedRevision: store.getPersistentAgentAudienceRevision(scope), + reinstateExcluded: true, + pubkeys: [agentA], + scope, + }); + + assert.deepEqual(promotion?.promotedPubkeys, [agentA]); + assert.deepEqual(currentAudiences(store), { [scope]: [agentA] }); +}); + test("promotion reports only newly added agents for transactional Undo", async () => { const store = await loadStore(11); const scope = `${ownerA}:channel-a:channel`; diff --git a/desktop/src/features/messages/lib/persistentAgentAudience.ts b/desktop/src/features/messages/lib/persistentAgentAudience.ts index 018a7da489..78819e0c73 100644 --- a/desktop/src/features/messages/lib/persistentAgentAudience.ts +++ b/desktop/src/features/messages/lib/persistentAgentAudience.ts @@ -4,6 +4,7 @@ export const MAX_IN_MEMORY_AGENT_AUDIENCES = 200; const listeners = new Set<() => void>(); const revisions = new Map(); +const excludedPubkeysByScope = new Map>(); let revisionClock = 0; let defaultRevision = 0; let audiences: Record = {}; @@ -16,7 +17,7 @@ export type PersistentAgentAudienceSnapshot = Readonly<{ type PersistentAgentAudienceScopeInput = { ownerPubkey: string; channelId: string; - threadRootId?: string | null; + composerKey?: string | null; }; function normalizePubkeys(pubkeys: Iterable): string[] { @@ -46,17 +47,22 @@ function emit(): void { export function getPersistentAgentAudienceScope({ ownerPubkey, channelId, + composerKey, }: PersistentAgentAudienceScopeInput): string | null { const owner = ownerPubkey.trim().toLowerCase(); if (!/^[0-9a-f]{64}$/.test(owner) || !channelId) return null; - // Thread composers intentionally share their parent channel's audience. - return `${owner}:${channelId}:channel`; + const composer = + composerKey?.trim() && composerKey.trim() !== channelId + ? composerKey.trim() + : "channel"; + return `${owner}:${channelId}:${composer}`; } export function resetPersistentAgentAudienceStore(): void { revisionClock += 1; defaultRevision = revisionClock; revisions.clear(); + excludedPubkeysByScope.clear(); audiences = {}; emit(); } @@ -83,6 +89,11 @@ export function setPersistentAgentAudience( const nextAudiences = { ...audiences }; delete nextAudiences[scope]; audiences = boundAudiences({ ...nextAudiences, [scope]: normalized }); + for (const excludedScope of excludedPubkeysByScope.keys()) { + if (!Object.hasOwn(audiences, excludedScope)) { + excludedPubkeysByScope.delete(excludedScope); + } + } for (const revisedScope of revisions.keys()) { if (!Object.hasOwn(audiences, revisedScope)) revisions.delete(revisedScope); } @@ -97,19 +108,29 @@ export function getPersistentAgentAudienceRevision(scope: string): number { export function promotePersistentAgentAudienceIfUnchanged({ expectedRevision, + reinstateExcluded = false, pubkeys, scope, }: { expectedRevision: number; + reinstateExcluded?: boolean; pubkeys: Iterable; scope: string; }): { promotedPubkeys: string[]; revision: number } | null { if (getPersistentAgentAudienceRevision(scope) !== expectedRevision) return null; - const promotedPubkeys = normalizePubkeys(pubkeys).filter( - (pubkey) => !(audiences[scope] ?? []).includes(pubkey), + const normalizedPubkeys = normalizePubkeys(pubkeys); + const promotedPubkeys = normalizedPubkeys.filter( + (pubkey) => + !(audiences[scope] ?? []).includes(pubkey) && + (reinstateExcluded || !excludedPubkeysByScope.get(scope)?.has(pubkey)), ); if (promotedPubkeys.length === 0) return null; + if (reinstateExcluded) { + const excluded = excludedPubkeysByScope.get(scope); + for (const pubkey of promotedPubkeys) excluded?.delete(pubkey); + if (excluded?.size === 0) excludedPubkeysByScope.delete(scope); + } setPersistentAgentAudience(scope, [ ...(audiences[scope] ?? []), ...promotedPubkeys, @@ -143,7 +164,22 @@ export function addPersistentAgentAudienceMember( scope: string, pubkey: string, ): void { - setPersistentAgentAudience(scope, [...(audiences[scope] ?? []), pubkey]); + const normalized = normalizePubkeys([pubkey])[0]; + if (!normalized) return; + excludedPubkeysByScope.get(scope)?.delete(normalized); + setPersistentAgentAudience(scope, [...(audiences[scope] ?? []), normalized]); +} + +export function excludePersistentAgentAudienceMember( + scope: string, + pubkey: string, +): void { + const normalized = normalizePubkeys([pubkey])[0]; + if (!scope || !normalized) return; + const excluded = excludedPubkeysByScope.get(scope) ?? new Set(); + excluded.add(normalized); + excludedPubkeysByScope.set(scope, excluded); + removePersistentAgentAudienceMember(scope, normalized); } export function removePersistentAgentAudienceMember( @@ -179,6 +215,7 @@ export function usePersistentAgentAudience(scope: string | null): { pubkeys: readonly string[]; addPubkey: (pubkey: string) => void; removePubkey: (pubkey: string) => void; + excludePubkey: (pubkey: string) => void; clear: () => void; } { const state = React.useSyncExternalStore( @@ -197,6 +234,10 @@ export function usePersistentAgentAudience(scope: string | null): { (pubkey) => removePersistentAgentAudienceMember(resolvedScope, pubkey), [resolvedScope], ), + excludePubkey: React.useCallback( + (pubkey) => excludePersistentAgentAudienceMember(resolvedScope, pubkey), + [resolvedScope], + ), clear: React.useCallback( () => setPersistentAgentAudience(resolvedScope, []), [resolvedScope], diff --git a/desktop/src/features/messages/lib/stripImplicitAgentMentions.test.mjs b/desktop/src/features/messages/lib/stripImplicitAgentMentions.test.mjs new file mode 100644 index 0000000000..d1c2a11cf0 --- /dev/null +++ b/desktop/src/features/messages/lib/stripImplicitAgentMentions.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { stripImplicitAgentMentionPrefix } from "./stripImplicitAgentMentions.ts"; + +test("removes the exact synthesized leading prefix", () => { + assert.equal( + stripImplicitAgentMentionPrefix("@Morgarita draft text", "@Morgarita "), + "draft text", + ); +}); + +test("removes the complete captured prefix for multiple agents", () => { + assert.equal( + stripImplicitAgentMentionPrefix( + "@Morgarita @Vogue draft text", + "@Morgarita @Vogue ", + ), + "draft text", + ); +}); + +test("removes an implicit-only mention when markdown drops its separator", () => { + assert.equal( + stripImplicitAgentMentionPrefix("@Morgarita", "@Morgarita "), + "", + ); +}); + +test("preserves an identical authored mention after the synthesized prefix", () => { + assert.equal( + stripImplicitAgentMentionPrefix( + "@Morgarita @Morgarita authored duplicate", + "@Morgarita ", + ), + "@Morgarita authored duplicate", + ); +}); + +test("preserves content when the captured prefix does not match exactly", () => { + assert.equal( + stripImplicitAgentMentionPrefix("@Alice ask @Morgarita", "@Morgarita "), + "@Alice ask @Morgarita", + ); +}); diff --git a/desktop/src/features/messages/lib/stripImplicitAgentMentions.ts b/desktop/src/features/messages/lib/stripImplicitAgentMentions.ts new file mode 100644 index 0000000000..0ac80ca751 --- /dev/null +++ b/desktop/src/features/messages/lib/stripImplicitAgentMentions.ts @@ -0,0 +1,15 @@ +/** + * Removes the exact leading prefix synthesized by automatic agent addressing. + * The captured prefix is provenance: an identical authored mention immediately + * after it must remain draft content. + */ +export function stripImplicitAgentMentionPrefix( + content: string, + implicitPrefix: string, +): string { + if (!implicitPrefix) return content; + if (content.startsWith(implicitPrefix)) { + return content.slice(implicitPrefix.length); + } + return content === implicitPrefix.trimEnd() ? "" : content; +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0d2e5ef6ac..de6e6da6c8 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -18,6 +18,7 @@ import { getAgentIdentityPubkeys, getMentionableAgentPubkeys, getSharedChannelIds, + isAgentDirectoryReady, isAgentMentionChannelType, rememberSelectedAgentPubkeys, uniqueAutocompleteLabels, @@ -32,7 +33,6 @@ import type { ChannelMember, ChannelType } from "@/shared/api/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import { channelMemberPubkeySet } from "@/shared/lib/rosterDerivations"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; import { useActiveAgentPubkeys } from "./useActiveAgentPubkeys"; import { useDefaultAgentSuggestion } from "./useDefaultAgentSuggestion"; @@ -50,6 +50,7 @@ import { } from "./useMentionSelection"; import { rankMentionCandidates } from "./mentionRanking"; import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping"; +import { getMentionMemberPubkeys } from "./mentionMemberPubkeys"; import { appendUniqueName, buildTeamMentionCandidates, @@ -99,14 +100,8 @@ export function useMentions( const channelsQuery = useChannelsQuery(); const personasQuery = usePersonasQuery(); const teamsQuery = useTeamsQuery(); - const managedAgentDirectoryReady = - managedAgentsQuery.data !== undefined && - managedAgentsQuery.error === null && - !managedAgentsQuery.isFetching; - const relayAgentDirectoryReady = - relayAgentsQuery.data !== undefined && - relayAgentsQuery.error === null && - !relayAgentsQuery.isFetching; + const managedAgentDirectoryReady = isAgentDirectoryReady(managedAgentsQuery); + const relayAgentDirectoryReady = isAgentDirectoryReady(relayAgentsQuery); const agentDirectoriesReady = managedAgentDirectoryReady && relayAgentDirectoryReady; const canSearchGlobalUsers = canSearchGlobalPeople && agentDirectoriesReady; @@ -228,11 +223,9 @@ export function useMentions( () => new Set(activePersonas.map((persona) => persona.id)), [activePersonas], ); - // Identity-cached (shared with the timeline's roster derivations) — the - // Set is built once per distinct roster instead of per consumer. const memberPubkeys = React.useMemo( - () => (members ? channelMemberPubkeySet(members) : new Set()), - [members], + () => getMentionMemberPubkeys(channelId, channelsQuery.data, members), + [channelId, channelsQuery.data, members], ); const agentIdentityPubkeys = React.useMemo( () => @@ -260,6 +253,7 @@ export function useMentions( managedAgents: managedAgentsQuery.data, memberPubkeys, members, + mentionChannelId, mentionableAgentPubkeys, personaNameByPubkey, profiles, @@ -283,6 +277,7 @@ export function useMentions( managedAgentsQuery.data, memberPubkeys, members, + mentionChannelId, mentionableAgentPubkeys, personaNameByPubkey, profiles, @@ -535,11 +530,11 @@ export function useMentions( appendUniqueName(current, trimmedName), ); if (options?.isAgent) { - setSelectedAgentMentionNames((current) => { - const next = appendUniqueName(current, trimmedName); - selectedAgentMentionNamesRef.current = next; - return next; - }); + selectedAgentMentionNamesRef.current = appendUniqueName( + selectedAgentMentionNamesRef.current, + trimmedName, + ); + setSelectedAgentMentionNames(selectedAgentMentionNamesRef.current); } }, [], diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index fa9644fa61..e3e17071fa 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -74,6 +74,8 @@ export type AutocompleteEdit = { insertText: string; /** Keep the current selection mapped through this edit instead of moving it to the insertion. */ preserveSelection?: boolean; + /** Skip asynchronous DOM caret reassertion when focus may move elsewhere. */ + reassertMentionCaret?: boolean; /** * When set, the replaced range becomes a CustomEmojiNode for this * shortcode (followed by `insertText`, which carries the trailing space) @@ -162,6 +164,7 @@ export function useRichTextEditor({ onLinkSelectionChange, onLinkShortcut, }: RichTextEditorOptions) { + const addressedAgentMentionNamesRef = React.useRef([]); const onUpdateRef = React.useRef(onUpdate); onUpdateRef.current = onUpdate; @@ -648,11 +651,30 @@ export function useRichTextEditor({ syncMentionHighlightFromProps( editor, mentionNames, - agentMentionNames, + [ + ...new Set([ + ...(agentMentionNames ?? []), + ...addressedAgentMentionNamesRef.current, + ]), + ], channelNames, ); }, [editor, mentionNames, agentMentionNames, channelNames]); + const syncAddressedAgentMentionNames = React.useCallback( + (names: readonly string[]) => { + addressedAgentMentionNamesRef.current = names; + if (!editor) return; + syncMentionHighlightFromProps( + editor, + mentionNames, + [...new Set([...(agentMentionNames ?? []), ...names])], + channelNames, + ); + }, + [agentMentionNames, channelNames, editor, mentionNames], + ); + // Custom-emoji set changes: re-resolve the `src` attr on any existing // node in the doc (e.g. an emoji's image was just published). React.useEffect(() => { @@ -777,6 +799,7 @@ export function useRichTextEditor({ text: string, customEmojiShortcode?: string, preserveSelection = false, + reassertMentionCaret = !preserveSelection, ) => { if (!editor) return; const projection = buildPlainTextProjection(editor.state.doc); @@ -825,7 +848,7 @@ export function useRichTextEditor({ settleAutocompleteMentionInsert(editor, tr, text, !preserveSelection); editor.view.dispatch(tr); editor.view.focus(); - if (!preserveSelection) reassertMentionCaretAfterFocus(editor.view); + if (reassertMentionCaret) reassertMentionCaretAfterFocus(editor.view); }, [editor, customEmojiWiring.resolveUrl], ); @@ -917,6 +940,7 @@ export function useRichTextEditor({ focusPreserve, getPlainTextAndCursor, replacePlainTextRange, + syncAddressedAgentMentionNames, getLinkSelectionInfo, applyLink, removeLink, diff --git a/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs b/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs index 4fe40da4b3..5dfc851bfd 100644 --- a/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs +++ b/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs @@ -111,10 +111,16 @@ test("mention control expands with automatically mentioned agents", async () => ); } const remove = view.getByTestId("composer-address-lock-remove-agent-pubkey"); + const removeChrome = remove.querySelector("span.absolute"); assert.match( - remove.querySelector("span.absolute")?.className ?? "", + removeChrome?.className ?? "", /group-hover\/address:opacity-100/, ); + assert.match(removeChrome?.className ?? "", /(?:^|\s)bg-foreground(?:\s|$)/); + assert.doesNotMatch( + removeChrome?.className ?? "", + /(?:^|\s)bg-foreground\/80(?:\s|$)/, + ); fireEvent.click(remove); assert.deepEqual(removed, ["agent-pubkey"]); view.rerender(renderButton([])); diff --git a/desktop/src/features/messages/ui/ComposerAddressControls.tsx b/desktop/src/features/messages/ui/ComposerAddressControls.tsx index f141a4d2fd..5ebcc43658 100644 --- a/desktop/src/features/messages/ui/ComposerAddressControls.tsx +++ b/desktop/src/features/messages/ui/ComposerAddressControls.tsx @@ -129,6 +129,7 @@ export function ComposerMentionButton({ confirmationTitle, disabled, onConfirmationDismiss, + onConfirmationHoverChange, onConfirmationTurnOff, onCaptureSelection, onOpen, @@ -140,6 +141,7 @@ export function ComposerMentionButton({ confirmationTitle?: string | null; disabled: boolean; onConfirmationDismiss?: () => void; + onConfirmationHoverChange?: (hovered: boolean) => void; onConfirmationTurnOff?: () => void; onCaptureSelection: () => void; onOpen: () => void; @@ -216,11 +218,11 @@ export function ComposerMentionButton({ className="flex items-center gap-1 overflow-hidden" data-testid="composer-address-locks" exit={{ opacity: 0, width: 0 }} - initial={false} + initial={shouldReduceMotion ? false : { opacity: 0, width: 0 }} transition={ shouldReduceMotion ? { duration: 0 } - : { duration: 0.18, ease: "easeOut" } + : { duration: 0.12, ease: "easeOut" } } > @@ -228,7 +230,7 @@ export function ComposerMentionButton({ - + - Stop automatically mentioning {agent.displayName} + Don't automatically mention {agent.displayName} in this + conversation ))} @@ -289,6 +292,8 @@ export function ComposerMentionButton({ data-testid="composer-auto-pin-confirmation" onCloseAutoFocus={(event) => event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()} + onPointerEnter={() => onConfirmationHoverChange?.(true)} + onPointerLeave={() => onConfirmationHoverChange?.(false)} side="right" sideOffset={8} style={{ width: "max-content" }} diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs b/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs index 012c962e0f..be3ec6938e 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs +++ b/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs @@ -77,6 +77,11 @@ test("agent rows offer automatic mention controls", async () => { }); assert.equal(action.getAttribute("aria-pressed"), "false"); assert.equal(action.getAttribute("data-state"), "off"); + const inactivePin = action.querySelector( + '[data-testid="mention-auto-pin-icon"]', + ); + assert.match(inactivePin?.getAttribute("class") ?? "", /\blucide-pin\b/); + assert.equal(inactivePin?.getAttribute("fill"), "none"); fireEvent.click(action); assert.deepEqual(toggled, [suggestion]); assert.deepEqual(selected, [suggestion]); @@ -88,10 +93,14 @@ test("agent rows offer automatic mention controls", async () => { }), ); const selectedAction = view.getByRole("button", { - name: "Stop automatically mentioning Agent Ada", + name: "Don't automatically mention Agent Ada in this conversation", }); assert.equal(selectedAction.getAttribute("aria-pressed"), "true"); assert.equal(selectedAction.getAttribute("data-state"), "on"); + const activePin = selectedAction.querySelector( + '[data-testid="mention-auto-pin-icon"]', + ); + assert.equal(activePin?.getAttribute("fill"), "currentColor"); fireEvent.click(selectedAction); assert.deepEqual(toggled, [suggestion, suggestion]); }); @@ -168,6 +177,56 @@ test("options expand in place without replacing the people list", async () => { assert.ok(view.getByRole("button", { name: "Mention Agent Ada" })); }); +test("automatic selection loads the setting once, then updates it in place", async () => { + const React = await import("react"); + const { render } = await import("@testing-library/react"); + const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx"); + const suggestion = { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }; + const props = { + suggestions: [suggestion], + selectedIndex: 0, + onSelect: () => {}, + keepMentionedAgentsPinned: false, + onKeepMentionedAgentsPinnedChange: () => {}, + }; + const view = render( + React.createElement(MentionAutocomplete, { + ...props, + openOptionsRequest: 0, + }), + ); + + view.rerender( + React.createElement(MentionAutocomplete, { + ...props, + openOptionsRequest: 1, + }), + ); + assert.equal( + view.getByRole("button", { name: "Options" }).getAttribute("aria-expanded"), + "true", + ); + const toggle = view.getByRole("switch", { + name: "Automatically mention agents", + }); + const settings = view.getByTestId("mention-options-settings"); + assert.equal(toggle.getAttribute("data-state"), "unchecked"); + + view.rerender( + React.createElement(MentionAutocomplete, { + ...props, + keepMentionedAgentsPinned: true, + openOptionsRequest: 2, + }), + ); + assert.equal(view.getByTestId("mention-options-settings"), settings); + assert.equal(toggle.getAttribute("data-state"), "checked"); +}); + test("clicking outside dismisses the tray without intercepting its trigger", async () => { const React = await import("react"); const { fireEvent, render } = await import("@testing-library/react"); diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index 9287c4f0e1..a242898c2e 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { AtSign, Bot, ChevronRight, Users } from "lucide-react"; +import { Bot, ChevronRight, Pin, Users } from "lucide-react"; import { OtherSetupAgentMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; import { motion } from "motion/react"; import type { TeamMentionMember } from "@/features/messages/lib/mentionCandidates"; @@ -44,6 +44,7 @@ type MentionAutocompleteProps = { keepMentionedAgentsPinned?: boolean; onKeepMentionedAgentsPinnedChange?: (value: boolean) => void; openOptionsRequest?: number; + onOptionsRevealComplete?: (request: number) => void; onDismiss?: () => void; position?: "above" | "below"; }; @@ -65,6 +66,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ keepMentionedAgentsPinned = true, onKeepMentionedAgentsPinnedChange, openOptionsRequest = 0, + onOptionsRevealComplete, onDismiss, position = "above", }: MentionAutocompleteProps) { @@ -74,6 +76,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ const optionsId = React.useId(); const keepPinnedSwitchId = React.useId(); const [optionsOpen, setOptionsOpen] = React.useState(false); + const handledOptionsRequestRef = React.useRef(0); const alwaysAddressShortcut = getPlatformKeysById("always-address-agent"); React.useEffect(() => { @@ -90,10 +93,17 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ }, [suggestions.length]); React.useEffect(() => { - if (openOptionsRequest > 0) { - setOptionsOpen(true); + if (openOptionsRequest <= handledOptionsRequestRef.current) return; + handledOptionsRequestRef.current = openOptionsRequest; + + // The first request waits for the entrance to finish. Once visible, apply + // later requests in place so toggling a pin cannot replay that entrance. + if (optionsOpen) { + onOptionsRevealComplete?.(openOptionsRequest); + return; } - }, [openOptionsRequest]); + setOptionsOpen(true); + }, [onOptionsRevealComplete, openOptionsRequest, optionsOpen]); React.useEffect(() => { if (!onDismiss) return; @@ -173,8 +183,14 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ { + if (openOptionsRequest > 0) { + onOptionsRevealComplete?.(openOptionsRequest); + } + }} transition={{ duration: 0.16, ease: "easeOut" }} >
@@ -390,7 +406,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ - @@ -416,11 +437,11 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ > {isAlwaysAddressed - ? "Stop automatically mentioning" + ? "Don't automatically mention in this conversation" : "Automatically mention"} {alwaysAddressShortcut ? ( - + {(alwaysAddressShortcut.includes("+") ? alwaysAddressShortcut.split("+") : Array.from(alwaysAddressShortcut) diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 5c0dced5ca..0cc8650795 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -64,9 +64,11 @@ import { useComposerAttachmentSpoilers } from "./useComposerAttachmentSpoilers"; import { useComposerContentState } from "./useComposerContentState"; import { useComposerPasteHandler } from "./useComposerPasteHandler"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; +import { useImplicitAgentMentionProvenance } from "./useImplicitAgentMentionProvenance"; import { submitMessageEdit } from "./submitMessageEdit"; import { prepareBackgroundLinkPreviews } from "@/features/messages/lib/linkPreviewPreparationStore"; import { useComposerLinkPreviews } from "./useComposerLinkPreviews"; +import { useAddressedAgentMentionRestore } from "./useAddressedAgentMentionRestore"; import { scheduleSettleGatedAutoSubmit } from "./messageComposerAutoSubmit"; import type { MessageComposerProps } from "./MessageComposer.types"; function MessageComposerImpl({ @@ -131,10 +133,13 @@ function MessageComposerImpl({ ? getPersistentAgentAudienceScope({ ownerPubkey, channelId, + composerKey: effectiveDraftKey, }) : null; const effectiveDraftKeyRef = React.useRef(effectiveDraftKey); effectiveDraftKeyRef.current = effectiveDraftKey; + const implicitAgentMentionProvenance = + useImplicitAgentMentionProvenance(effectiveDraftKey); const preEditSnapshotRef = React.useRef<{ content: string; pendingImeta: ImetaMedia[]; @@ -204,6 +209,7 @@ function MessageComposerImpl({ setSpoileredAttachmentUrls, spoileredAttachmentUrlsRef, syncComposerContentFromEditor, + getImplicitAgentMentionPrefix: implicitAgentMentionProvenance.getPrefix, }); // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger React.useEffect(() => { @@ -310,10 +316,13 @@ function MessageComposerImpl({ const keepMentionedAgentsPinned = useKeepMentionedAgentsPinned(); const addressPulse = useAddressMentionPulse(); const { + completeOptionsReveal: completeMentionOptionsReveal, confirmationTitle: autoPinConfirmationTitle, dismissConfirmation: dismissAutoPinConfirmation, + openOptionsRequest: openMentionOptionsRequest, promoteExplicitlyAddressedAgents, promoteMentionedAgents, + setConfirmationHovered: setAutoPinConfirmationHovered, turnOffConfirmation: turnOffAutoPinConfirmation, } = useAutoPinMentionedAgents({ audienceScope, @@ -321,26 +330,13 @@ function MessageComposerImpl({ getDisplayName: mentions.getMentionDisplayName, onPulse: addressPulse.pulseOne, onTurnOff: () => setKeepMentionedAgentsPinned(false), + onTurnOn: () => setKeepMentionedAgentsPinned(true), + }); + const addressedMentionRestore = useAddressedAgentMentionRestore({ + audiencePubkeys: persistentAudience.pubkeys, + channelId, + enabled: keepMentionedAgentsPinned, }); - const restoreAddressedAgentMentionsRef = React.useRef< - ( - pubkeys?: readonly string[], - allowedUnpinnedPubkeys?: readonly string[], - ) => string - >(() => ""); - const restoreAddressedAgentMentionsFrameRef = React.useRef( - null, - ); - const channelIdRef = React.useRef(channelId); - channelIdRef.current = channelId; - React.useEffect( - () => () => { - if (restoreAddressedAgentMentionsFrameRef.current !== null) { - cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current); - } - }, - [], - ); const mentionSendFlow = useMentionSendFlow({ channelId, channelLinks, @@ -350,23 +346,11 @@ function MessageComposerImpl({ drafts, emojiAutocomplete, mentions, - onAddressedAgentsComposerCleared: (pubkeys) => - restoreAddressedAgentMentionsRef.current(pubkeys), + onAddressedAgentsComposerCleared: + addressedMentionRestore.onAddressedAgentsComposerCleared, onAddressedAgentsSendFailed: addressPulse.shakeMany, - onAddressedAgentsSendSucceeded: (pubkeys, newlyPinnedPubkeys) => { - if (!keepMentionedAgentsPinned || newlyPinnedPubkeys.length === 0) return; - const sentChannelId = channelId; - if (restoreAddressedAgentMentionsFrameRef.current !== null) { - cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current); - } - restoreAddressedAgentMentionsFrameRef.current = requestAnimationFrame( - () => { - restoreAddressedAgentMentionsFrameRef.current = null; - if (channelIdRef.current !== sentChannelId) return; - restoreAddressedAgentMentionsRef.current(pubkeys, newlyPinnedPubkeys); - }, - ); - }, + onAddressedAgentsSendSucceeded: + addressedMentionRestore.onAddressedAgentsSendSucceeded, onPrepareSendChannel, onSendRef, richText, @@ -445,6 +429,7 @@ function MessageComposerImpl({ edit.insertText, edit.customEmojiShortcode, edit.preserveSelection, + edit.reassertMentionCaret, ); }, [richText.replacePlainTextRange], @@ -467,16 +452,24 @@ function MessageComposerImpl({ promoteExplicitlyAddressedAgents({ pubkeys: suggestion.pubkey ? [suggestion.pubkey] : [], }), - onAutoPinAgentMention: (suggestion) => { + onAutoPinAgentMention: (suggestion, options) => { promoteMentionedAgents({ + ...options, pubkeys: suggestion.pubkey ? [suggestion.pubkey] : [], }); }, + onImplicitPrefixInserted: implicitAgentMentionProvenance.add, + onImplicitPrefixRemoved: implicitAgentMentionProvenance.remove, onPulseAddressLock: addressPulse.pulseOne, profiles, richText, }); - restoreAddressedAgentMentionsRef.current = restoreAddressedAgentMentions; + addressedMentionRestore.restoreAddressedAgentMentionsRef.current = + restoreAddressedAgentMentions; + React.useLayoutEffect(() => { + if (!audienceScope || editTarget != null) return; + restoreAddressedAgentMentions(); + }, [audienceScope, editTarget, restoreAddressedAgentMentions]); syncAddressedAgentsFromTextRef.current = syncAddressedAgentsFromText; const applyChannelInsert = React.useCallback( (suggestion: ChannelSuggestion) => { @@ -536,21 +529,17 @@ function MessageComposerImpl({ }, [richText.editor, mentions.clearMentions, customEmoji], ); - const openMentionPicker = useComposerMentionPicker({ + const mentionPicker = useComposerMentionPicker({ mentions, + onTurnOffAutoPinConfirmation: turnOffAutoPinConfirmation, richText, setIsEmojiPickerOpen, }); - const openMentionSettings = React.useCallback( - () => openMentionPicker(false), - [openMentionPicker], - ); const handleAlwaysAddressShortcut = useAlwaysAddressShortcut({ enabled: Boolean(audienceScope && editTarget == null), lockedAgent: lockedAgents[0], mentions, - onOpenPicker: openMentionPicker, - onSelect: selectMentionSuggestion, + onOpenPicker: mentionPicker.openMentionPicker, onToggle: toggleAlwaysAddressAgent, }); const submitMessage = React.useCallback(async () => { @@ -872,11 +861,13 @@ function MessageComposerImpl({
void; onAutoPinConfirmationDismiss?: () => void; + onAutoPinConfirmationHoverChange?: (hovered: boolean) => void; onAutoPinConfirmationTurnOff?: () => void; onEmojiPickerOpenChange: (open: boolean) => void; onEmojiSelect: (emoji: string) => void; @@ -187,6 +189,7 @@ export const MessageComposerToolbar = React.memo( confirmationTitle={autoPinConfirmationTitle} disabled={composerDisabled} onConfirmationDismiss={onAutoPinConfirmationDismiss} + onConfirmationHoverChange={onAutoPinConfirmationHoverChange} onConfirmationTurnOff={onAutoPinConfirmationTurnOff} onCaptureSelection={onCaptureSelection} onOpen={onOpenMentionPicker} diff --git a/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs index b95528dd93..b027250057 100644 --- a/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs +++ b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs @@ -44,13 +44,12 @@ test("agent picker preference skips people", async () => { assert.equal(view.result.current.mentionSelectedIndex, 1); }); -test("primary+Shift+M addresses the default agent or toggles the tray selection", async () => { +test("primary+Shift+M addresses the default agent or toggles the tray selection in place", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAlwaysAddressShortcut } = await import( "./useAlwaysAddressShortcut.ts" ); const { isMacPlatform } = await import("@/shared/lib/platform"); - const selected = []; const toggled = []; const suggestion = { displayName: "Agent Ada", @@ -78,7 +77,6 @@ test("primary+Shift+M addresses the default agent or toggles the tray selection" suggestions: [suggestion], }, onOpenPicker: () => {}, - onSelect: (value) => selected.push(value), onToggle: (value) => toggled.push(value), }), { initialProps: { isMentionOpen: false } }, @@ -86,16 +84,13 @@ test("primary+Shift+M addresses the default agent or toggles the tray selection" act(() => assert.equal(view.result.current(createEvent()), true)); assert.deepEqual(toggled, [suggestion]); - assert.deepEqual(selected, []); view.rerender({ isMentionOpen: true }); act(() => assert.equal(view.result.current(createEvent()), true)); - assert.deepEqual(toggled, [suggestion]); - assert.deepEqual(selected, [suggestion]); + assert.deepEqual(toggled, [suggestion, suggestion]); act(() => assert.equal(view.result.current(createEvent()), true)); - assert.deepEqual(toggled, [suggestion]); - assert.deepEqual(selected, [suggestion, suggestion]); + assert.deepEqual(toggled, [suggestion, suggestion, suggestion]); }); test("primary+Shift+M removes the current locked agent before choosing a new default", async () => { @@ -126,7 +121,6 @@ test("primary+Shift+M removes the current locked agent before choosing a new def suggestions: [], }, onOpenPicker: () => {}, - onSelect: () => {}, onToggle: (value) => toggled.push(value), }), ); @@ -137,7 +131,7 @@ test("primary+Shift+M removes the current locked agent before choosing a new def altKey: false, code: "KeyM", ctrlKey: !isMacPlatform(), - key: "m", + key: "M", metaKey: isMacPlatform(), preventDefault() {}, repeat: false, @@ -169,7 +163,6 @@ test("primary+Shift+M opens the picker when no default agent is ready", async () onOpenPicker: () => { opened += 1; }, - onSelect: () => {}, onToggle: () => {}, }), ); @@ -180,7 +173,7 @@ test("primary+Shift+M opens the picker when no default agent is ready", async () altKey: false, code: "KeyM", ctrlKey: !isMacPlatform(), - key: "m", + key: "M", metaKey: isMacPlatform(), preventDefault() {}, repeat: false, diff --git a/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts b/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts new file mode 100644 index 0000000000..9bc88ea6b4 --- /dev/null +++ b/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts @@ -0,0 +1,66 @@ +import * as React from "react"; + +type RestoreAddressedAgentMentions = ( + pubkeys?: readonly string[], + allowedUnpinnedPubkeys?: readonly string[], +) => string; + +export function useAddressedAgentMentionRestore({ + audiencePubkeys, + channelId, + enabled, +}: { + audiencePubkeys: readonly string[]; + channelId: string | null; + enabled: boolean; +}) { + const restoreAddressedAgentMentionsRef = + React.useRef(() => ""); + const restoreFrameRef = React.useRef(null); + const channelIdRef = React.useRef(channelId); + channelIdRef.current = channelId; + + React.useEffect( + () => () => { + if (restoreFrameRef.current !== null) { + cancelAnimationFrame(restoreFrameRef.current); + } + }, + [], + ); + + const onAddressedAgentsComposerCleared = React.useCallback( + (pubkeys: readonly string[]) => + restoreAddressedAgentMentionsRef.current(pubkeys), + [], + ); + const onAddressedAgentsSendSucceeded = React.useCallback( + (pubkeys: readonly string[], newlyPinnedPubkeys: readonly string[]) => { + const currentAudience = new Set(audiencePubkeys); + const confirmedPinnedPubkeys = newlyPinnedPubkeys.filter((pubkey) => + currentAudience.has(pubkey), + ); + if (!enabled || confirmedPinnedPubkeys.length === 0) return; + + const sentChannelId = channelId; + if (restoreFrameRef.current !== null) { + cancelAnimationFrame(restoreFrameRef.current); + } + restoreFrameRef.current = requestAnimationFrame(() => { + restoreFrameRef.current = null; + if (channelIdRef.current !== sentChannelId) return; + restoreAddressedAgentMentionsRef.current( + pubkeys, + confirmedPinnedPubkeys, + ); + }); + }, + [audiencePubkeys, channelId, enabled], + ); + + return { + onAddressedAgentsComposerCleared, + onAddressedAgentsSendSucceeded, + restoreAddressedAgentMentionsRef, + }; +} diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs index 95e0af9e06..8ed155234f 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs @@ -30,6 +30,7 @@ test("always addressing an agent keeps autocomplete open, inserts the chip, adds ); const appliedEdits = []; const addedPubkeys = []; + const openPickerCalls = []; const pulsedPubkeys = []; let cancelCount = 0; const text = "@"; @@ -47,6 +48,7 @@ test("always addressing an agent keeps autocomplete open, inserts the chip, adds getMentionDisplayName: () => "Agent Ada", isInlineMentionSelection: () => false, isMentionOpen: true, + openMentionPicker: (...args) => openPickerCalls.push(args), registerMentionPubkey: () => {}, mentionStartIndex: text.lastIndexOf("@"), }; @@ -82,9 +84,11 @@ test("always addressing an agent keeps autocomplete open, inserts the chip, adds replaceToOffset: 0, insertText: "@Agent Ada ", preserveSelection: true, + reassertMentionCaret: false, }, ]); assert.equal(cancelCount, 0); + assert.deepEqual(openPickerCalls, [[text.length, "preserve"]]); assert.deepEqual(addedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); assert.equal( @@ -301,8 +305,8 @@ test("selecting an agent from a typed query immediately auto-addresses it", asyn audience, audienceScope: "channel-scope", mentions, - onAutoPinAgentMention: (suggestion) => - autoPinnedSuggestions.push(suggestion), + onAutoPinAgentMention: (suggestion, options) => + autoPinnedSuggestions.push([suggestion, options]), onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), richText, }), @@ -322,7 +326,9 @@ test("selecting an agent from a typed query immediately auto-addresses it", asyn insertText: "@Agent Ada ", }, ]); - assert.deepEqual(autoPinnedSuggestions, [suggestion]); + assert.deepEqual(autoPinnedSuggestions, [ + [suggestion, { reinstateExcluded: true }], + ]); assert.deepEqual(addedPubkeys, []); assert.deepEqual(pulsedPubkeys, []); assert.equal(result.current.announcement, ""); @@ -375,11 +381,149 @@ test("selecting a human mention never changes automatic addressing", async () => assert.deepEqual(autoPinnedSuggestions, []); }); -test("removing the last agent chip clears its automatic address", async () => { +test("restoring a multi-word automatic mention into an empty composer focuses after its trailing space", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" ); + const appliedEdits = []; + const registeredMentions = []; + let focusEndCount = 0; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience: { + pubkeys: ["agent-pubkey"], + addPubkey: () => {}, + }, + audienceScope: "thread-scope", + mentions: { + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "claude code", + registerMentionPubkey: (...args) => registeredMentions.push(args), + }, + onPulseAddressLock: () => {}, + richText: { + focusEnd: () => { + focusEndCount += 1; + }, + getPlainTextAndCursor: () => ({ text: "", cursor: 0 }), + }, + }), + ); + + act(() => result.current.restoreAddressedAgentMentions()); + + assert.deepEqual(registeredMentions, [ + ["claude code", "agent-pubkey", { isAgent: true }], + ]); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: "@claude code ", + preserveSelection: true, + }, + ]); + assert.equal(focusEndCount, 1); +}); + +test("restoring before authored text preserves its selection", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + let focusEndCount = 0; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience: { + pubkeys: ["agent-pubkey"], + addPubkey: () => {}, + }, + audienceScope: "thread-scope", + mentions: { + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "Morgarita", + registerMentionPubkey: () => {}, + }, + onPulseAddressLock: () => {}, + richText: { + focusEnd: () => { + focusEndCount += 1; + }, + getPlainTextAndCursor: () => ({ text: "draft text", cursor: 10 }), + }, + }), + ); + + act(() => result.current.restoreAddressedAgentMentions()); + + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: "@Morgarita ", + preserveSelection: true, + }, + ]); + assert.equal(focusEndCount, 0); +}); + +test("restoring an existing automatic mention re-registers its agent chip", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + const registeredMentions = []; + const syncedAddressedNames = []; + let focusEndCount = 0; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience: { + pubkeys: ["agent-pubkey"], + addPubkey: () => {}, + }, + audienceScope: "thread-scope", + mentions: { + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "claude code", + registerMentionPubkey: (...args) => registeredMentions.push(args), + }, + onPulseAddressLock: () => {}, + richText: { + focusEnd: () => { + focusEndCount += 1; + }, + getPlainTextAndCursor: () => ({ + text: "@claude code ", + cursor: 13, + }), + syncAddressedAgentMentionNames: (names) => + syncedAddressedNames.push(names), + }, + }), + ); + + act(() => result.current.restoreAddressedAgentMentions()); + + assert.deepEqual(registeredMentions, [ + ["claude code", "agent-pubkey", { isAgent: true }], + ]); + assert.deepEqual(appliedEdits, []); + assert.equal(focusEndCount, 0); + assert.deepEqual(syncedAddressedNames.at(-1), ["claude code"]); +}); + +test("deleting the last automatic agent mention explicitly excludes its address", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const excludedPubkeys = []; const removedPubkeys = []; const mentionRefsByText = { "@Agent Ada first @Agent Ada second": [ @@ -396,6 +540,7 @@ test("removing the last agent chip clears its automatic address", async () => { applyAutocompleteEdit: () => {}, audience: { pubkeys: ["agent-pubkey", "existing-lock"], + excludePubkey: (pubkey) => excludedPubkeys.push(pubkey), removePubkey: (pubkey) => removedPubkeys.push(pubkey), }, audienceScope: "channel-scope", @@ -418,20 +563,23 @@ test("removing the last agent chip clears its automatic address", async () => { assert.deepEqual(removedPubkeys, []); act(() => result.current.syncAddressedAgentsFromText("")); - assert.deepEqual(removedPubkeys, ["agent-pubkey"]); + assert.deepEqual(excludedPubkeys, ["agent-pubkey"]); + assert.deepEqual(removedPubkeys, []); }); -test("removing human mentions is ignored while removing a restored agent chip clears its lock", async () => { +test("deleting human mentions is ignored while deleting a restored automatic agent mention excludes its address", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" ); + const excludedPubkeys = []; const removedPubkeys = []; const { result } = renderHook(() => useAgentAddressLockPicker({ applyAutocompleteEdit: () => {}, audience: { pubkeys: ["existing-lock"], + excludePubkey: (pubkey) => excludedPubkeys.push(pubkey), removePubkey: (pubkey) => removedPubkeys.push(pubkey), }, audienceScope: "channel-scope", @@ -462,9 +610,11 @@ test("removing human mentions is ignored while removing a restored agent chip cl result.current.syncAddressedAgentsFromText("@Alice @Existing Agent"), ); act(() => result.current.syncAddressedAgentsFromText("@Alice")); - assert.deepEqual(removedPubkeys, ["existing-lock"]); + assert.deepEqual(excludedPubkeys, ["existing-lock"]); + assert.deepEqual(removedPubkeys, []); act(() => result.current.syncAddressedAgentsFromText("")); - assert.deepEqual(removedPubkeys, ["existing-lock"]); + assert.deepEqual(excludedPubkeys, ["existing-lock"]); + assert.deepEqual(removedPubkeys, []); }); test("selecting an agent from the explicit picker auto-addresses it", async () => { @@ -529,13 +679,14 @@ test("selecting an agent from the explicit picker auto-addresses it", async () = ); }); -test("selecting an explicitly unpinned agent inserts a mention until send", async () => { +test("repeatedly selecting an explicitly unpinned agent keeps its mentions manual", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" ); const appliedEdits = []; const addedPubkeys = []; + const autoPinnedSuggestions = []; const removedPubkeys = []; const pulsedPubkeys = []; const mentions = { @@ -545,7 +696,7 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn ], getMentionDisplayName: () => "Agent Ada", registerMentionPubkey: () => {}, - isInlineMentionSelection: () => false, + isInlineMentionSelection: () => true, insertMention: () => ({ replaceFromOffset: 0, replaceToOffset: 0, @@ -570,6 +721,8 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn }, audienceScope: "channel-scope", mentions, + onAutoPinAgentMention: (suggestion, options) => + autoPinnedSuggestions.push([suggestion, options]), onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), richText, }), @@ -577,7 +730,14 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn ); act(() => result.current.removeAddressedAgent("AGENT-PUBKEY")); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 11, + insertText: "", + }, + ]); + appliedEdits.length = 0; rerender({ pubkeys: [] }); act(() => { result.current.selectMentionSuggestion({ @@ -586,6 +746,13 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn isAgent: true, }); }); + act(() => { + result.current.selectMentionSuggestion({ + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }); + }); assert.deepEqual(removedPubkeys, ["agent-pubkey"]); assert.deepEqual(appliedEdits, [ @@ -594,11 +761,77 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn replaceToOffset: 0, insertText: "@Agent Ada ", }, + { + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: "@Agent Ada ", + }, ]); assert.deepEqual(addedPubkeys, []); + assert.deepEqual(autoPinnedSuggestions, [ + [ + { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }, + { reinstateExcluded: false }, + ], + [ + { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }, + { reinstateExcluded: false }, + ], + ]); assert.deepEqual(pulsedPubkeys, []); }); +test("restoring after an agent rename keeps the existing automatic mention", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + const registeredMentions = []; + const oldName = "OldName"; + const newName = "NewName"; + const { result, rerender } = renderHook( + ({ displayName }) => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience: { pubkeys: ["agent-pubkey"], addPubkey: () => {} }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: () => [ + { displayName: oldName, pubkey: "agent-pubkey", isAgent: true }, + ], + getMentionDisplayName: () => displayName, + registerMentionPubkey: (...args) => registeredMentions.push(args), + }, + onPulseAddressLock: () => {}, + profiles: {}, + richText: { + getPlainTextAndCursor: () => ({ + text: `@${oldName} authored draft`, + cursor: 23, + }), + }, + }), + { initialProps: { displayName: oldName } }, + ); + + rerender({ displayName: newName }); + act(() => result.current.restoreAddressedAgentMentions()); + + assert.deepEqual(appliedEdits, []); + assert.deepEqual(registeredMentions, [ + [newName, "agent-pubkey", { isAgent: true }], + ]); +}); + test("an addressed agent keeps its resolved name while mention state clears during send", async () => { const { renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts index 17883900ec..4a9ca5f15c 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { getMentionOffsets } from "@/features/messages/lib/hasMention"; +import { stripImplicitAgentMentionPrefix } from "@/features/messages/lib/stripImplicitAgentMentions"; import type { usePersistentAgentAudience } from "@/features/messages/lib/persistentAgentAudience"; import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; import type { @@ -59,6 +60,8 @@ export function useAgentAddressLockPicker({ mentions, onAddressAgentMention, onAutoPinAgentMention, + onImplicitPrefixInserted, + onImplicitPrefixRemoved, onPulseAddressLock, profiles, richText, @@ -68,7 +71,16 @@ export function useAgentAddressLockPicker({ audienceScope: string | null; mentions: UseMentionsResult; onAddressAgentMention?: (suggestion: MentionSuggestion) => void; - onAutoPinAgentMention?: (suggestion: MentionSuggestion) => void; + onAutoPinAgentMention?: ( + suggestion: MentionSuggestion, + options: { reinstateExcluded: boolean }, + ) => void; + /** Records generated mention provenance at the insertion boundary. */ + onImplicitPrefixInserted?: ( + mentions: readonly { pubkey: string; prefix: string }[], + ) => void; + /** Removes generated mention provenance by its stable identity. */ + onImplicitPrefixRemoved?: (pubkey: string) => void; onPulseAddressLock: (pubkey: string) => void; profiles?: UserProfileLookup; richText: UseRichTextEditorResult; @@ -83,6 +95,11 @@ export function useAgentAddressLockPicker({ unpinnedAudienceScopeRef.current = audienceScope; unpinnedAgentPubkeysRef.current.clear(); } + React.useEffect(() => { + for (const pubkey of lockedAgentPubkeys) { + unpinnedAgentPubkeysRef.current.delete(pubkey); + } + }, [lockedAgentPubkeys]); const lockedAgentNamesRef = React.useRef(new Map()); const visibleAgentMentionPubkeysRef = React.useRef(new Set()); const mentionSyncScopeRef = React.useRef(audienceScope); @@ -115,6 +132,11 @@ export function useAgentAddressLockPicker({ }), [audience.pubkeys, mentions.getMentionDisplayName, profiles], ); + React.useLayoutEffect(() => { + richText.syncAddressedAgentMentionNames?.( + lockedAgents.map((agent) => agent.displayName), + ); + }, [lockedAgents, richText.syncAddressedAgentMentionNames]); const trackMentionAddressedAgent = React.useCallback( (pubkey: string) => { const normalized = normalizePubkey(pubkey); @@ -138,16 +160,20 @@ export function useAgentAddressLockPicker({ !presentAgentPubkeys.has(pubkey) && lockedAgentPubkeys.has(pubkey) ) { - audience.removePubkey(pubkey); + const excludePubkey = audience.excludePubkey ?? audience.removePubkey; + onImplicitPrefixRemoved?.(pubkey); + excludePubkey(pubkey); } } visibleAgentMentionPubkeysRef.current = presentAgentPubkeys; }, [ + audience.excludePubkey, audience.removePubkey, audienceScope, lockedAgentPubkeys, mentions.getDraftMentionRefs, + onImplicitPrefixRemoved, ], ); @@ -156,9 +182,37 @@ export function useAgentAddressLockPicker({ const normalized = normalizePubkey(pubkey); if (!audienceScope || !normalized) return; unpinnedAgentPubkeysRef.current.add(normalized); - audience.removePubkey(normalized); + const excludePubkey = audience.excludePubkey ?? audience.removePubkey; + excludePubkey(normalized); + const displayName = lockedAgents.find( + (agent) => agent.pubkey === normalized, + )?.displayName; + if (displayName) { + const text = richText.getPlainTextAndCursor().text; + const implicitPrefix = `@${displayName}${text === `@${displayName}` ? "" : " "}`; + const strippedText = stripImplicitAgentMentionPrefix( + text, + implicitPrefix, + ); + if (strippedText !== text) { + onImplicitPrefixRemoved?.(normalized); + applyAutocompleteEdit({ + replaceFromOffset: 0, + replaceToOffset: text.length - strippedText.length, + insertText: "", + }); + } + } }, - [audience.removePubkey, audienceScope], + [ + applyAutocompleteEdit, + audience.excludePubkey, + audience.removePubkey, + audienceScope, + lockedAgents, + onImplicitPrefixRemoved, + richText.getPlainTextAndCursor, + ], ); const removeAddressedAgentMentions = React.useCallback( (pubkey: string) => { @@ -199,11 +253,14 @@ export function useAgentAddressLockPicker({ }); const { text } = richText.getPlainTextAndCursor(); if (getMentionOffsets(text, suggestion.displayName).length === 0) { + const insertedText = `@${suggestion.displayName} `; + onImplicitPrefixInserted?.([{ pubkey, prefix: insertedText }]); applyAutocompleteEdit({ replaceFromOffset: 0, replaceToOffset: 0, - insertText: `@${suggestion.displayName} `, - preserveSelection: true, + insertText: insertedText, + preserveSelection: text.length > 0, + reassertMentionCaret: false, }); } trackMentionAddressedAgent(pubkey); @@ -216,24 +273,31 @@ export function useAgentAddressLockPicker({ setAnnouncement(`Automatically mentioning ${suggestion.displayName}`); } - if (mentions.isMentionOpen && mentions.isInlineMentionSelection()) { + if (mentions.isMentionOpen) { const { text, cursor } = richText.getPlainTextAndCursor(); - const activeMention = detectPrefixQuery("@", text, cursor, [ - suggestion.displayName.toLowerCase(), - ]); - const queryStart = Math.max( - 0, - Math.min( - activeMention?.startIndex ?? mentions.mentionStartIndex, - text.length, - ), - ); - applyAutocompleteEdit({ - replaceFromOffset: queryStart, - replaceToOffset: Math.max(queryStart, Math.min(cursor, text.length)), - insertText: "", - }); - mentions.openMentionPicker(queryStart, "preserve"); + if (mentions.isInlineMentionSelection()) { + const activeMention = detectPrefixQuery("@", text, cursor, [ + suggestion.displayName.toLowerCase(), + ]); + const queryStart = Math.max( + 0, + Math.min( + activeMention?.startIndex ?? mentions.mentionStartIndex, + text.length, + ), + ); + applyAutocompleteEdit({ + replaceFromOffset: queryStart, + replaceToOffset: Math.max( + queryStart, + Math.min(cursor, text.length), + ), + insertText: "", + }); + mentions.openMentionPicker(queryStart, "preserve"); + } else { + mentions.openMentionPicker(cursor, "preserve"); + } } }, [ @@ -247,6 +311,7 @@ export function useAgentAddressLockPicker({ mentions.openMentionPicker, mentions.registerMentionPubkey, onAddressAgentMention, + onImplicitPrefixInserted, onPulseAddressLock, removeAddressedAgentMentions, richText.getPlainTextAndCursor, @@ -264,9 +329,10 @@ export function useAgentAddressLockPicker({ unpinnedAgentPubkeysRef.current.has(pubkey); if (mentions.isInlineMentionSelection() || wasUnpinned) { applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); - if (wasUnpinned) unpinnedAgentPubkeysRef.current.delete(pubkey); trackMentionAddressedAgent(pubkey); - onAutoPinAgentMention?.(suggestion); + onAutoPinAgentMention?.(suggestion, { + reinstateExcluded: !wasUnpinned, + }); return; } @@ -335,8 +401,24 @@ export function useAgentAddressLockPicker({ return { pubkey, displayName }; }); const { text } = richText.getPlainTextAndCursor(); + // A profile can rename an agent while this draft is off-screen. Mention + // refs retain the identity of its already-inserted automatic prefix, so + // use that identity as well as the current display name when deciding + // whether restoration is needed. + const presentAgentPubkeys = new Set( + mentions + .getDraftMentionRefs(text) + .filter((ref) => ref.isAgent) + .map((ref) => normalizePubkey(ref.pubkey)), + ); for (const agent of targetAgents) { - if (getMentionOffsets(text, agent.displayName).length > 0) { + if ( + presentAgentPubkeys.has(agent.pubkey) || + getMentionOffsets(text, agent.displayName).length > 0 + ) { + mentions.registerMentionPubkey(agent.displayName, agent.pubkey, { + isAgent: true, + }); visibleAgentMentionPubkeysRef.current.add(agent.pubkey); } } @@ -344,6 +426,7 @@ export function useAgentAddressLockPicker({ (agent) => (!unpinnedAgentPubkeysRef.current.has(agent.pubkey) || allowedUnpinned.has(agent.pubkey)) && + !presentAgentPubkeys.has(agent.pubkey) && getMentionOffsets(text, agent.displayName).length === 0, ); if (missingAgents.length === 0) return text; @@ -356,20 +439,34 @@ export function useAgentAddressLockPicker({ const insertedText = `${missingAgents .map((agent) => `@${agent.displayName}`) .join(" ")} `; + onImplicitPrefixInserted?.( + missingAgents.map((agent) => ({ + pubkey: agent.pubkey, + prefix: `@${agent.displayName} `, + })), + ); applyAutocompleteEdit({ replaceFromOffset: 0, replaceToOffset: 0, insertText: insertedText, preserveSelection: true, }); + // A restored empty composer has no authored caret to preserve. Move it + // to the real document end after insertion so WebKit places it after + // the trailing space, without routing the multi-word name back through + // autocomplete settlement (which would lose its mention decoration). + if (text.length === 0) richText.focusEnd(); return `${insertedText}${text}`; }, [ applyAutocompleteEdit, audience.pubkeys, + mentions.getDraftMentionRefs, mentions.getMentionDisplayName, mentions.registerMentionPubkey, + onImplicitPrefixInserted, profiles, + richText.focusEnd, richText.getPlainTextAndCursor, ], ); diff --git a/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts b/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts index ed92e7b3f0..cccb1b4086 100644 --- a/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts +++ b/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts @@ -9,14 +9,12 @@ export function useAlwaysAddressShortcut({ lockedAgent, mentions, onOpenPicker, - onSelect, onToggle, }: { enabled: boolean; lockedAgent?: Pick; mentions: UseMentionsResult; onOpenPicker: (insertTrigger?: boolean) => void; - onSelect: (suggestion: MentionSuggestion) => void; onToggle: (suggestion: MentionSuggestion) => void; }) { const { @@ -48,11 +46,7 @@ export function useAlwaysAddressShortcut({ if (!isMentionOpen) onOpenPicker(false); return true; } - if (isMentionOpen) { - onSelect(suggestion); - } else { - onToggle(suggestion); - } + onToggle(suggestion); return true; }, [ @@ -62,7 +56,6 @@ export function useAlwaysAddressShortcut({ lockedAgent, mentionSelectedIndex, onOpenPicker, - onSelect, onToggle, suggestions, ], diff --git a/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts b/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts index d0557567fd..550cdef983 100644 --- a/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts +++ b/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts @@ -4,6 +4,7 @@ import { getPersistentAgentAudienceRevision, promotePersistentAgentAudienceIfUnchanged, removePersistentAgentAudienceMembersIfUnchanged, + usePersistentAgentAudience, } from "@/features/messages/lib/persistentAgentAudience"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -16,12 +17,19 @@ type Confirmation = { title: string; }; +type PendingPreferenceChange = { + confirmation?: Confirmation; + enabled: boolean; + request: number; +}; + type Options = { audienceScope: string | null; enabled: boolean; getDisplayName: (pubkey: string) => string | null | undefined; onPulse: (pubkey: string) => void; onTurnOff: () => void; + onTurnOn: () => void; }; export function useAutoPinMentionedAgents({ @@ -30,19 +38,101 @@ export function useAutoPinMentionedAgents({ getDisplayName, onPulse, onTurnOff, + onTurnOn, }: Options) { + const { pubkeys: currentAudiencePubkeys } = + usePersistentAgentAudience(audienceScope); const [confirmation, setConfirmation] = React.useState( null, ); + const [confirmationHovered, setConfirmationHovered] = React.useState(false); + const [openOptionsRequest, setOpenOptionsRequest] = React.useState(0); + const nextOptionsRequestRef = React.useRef(0); + const pendingPreferenceChangeRef = + React.useRef(null); + const onTurnOffRef = React.useRef(onTurnOff); + const onTurnOnRef = React.useRef(onTurnOn); + onTurnOffRef.current = onTurnOff; + onTurnOnRef.current = onTurnOn; React.useEffect(() => { - if (!confirmation) return; + if (pendingPreferenceChangeRef.current?.enabled === enabled) { + pendingPreferenceChangeRef.current = null; + } + }, [enabled]); + + React.useEffect( + () => () => { + const pending = pendingPreferenceChangeRef.current; + pendingPreferenceChangeRef.current = null; + if (!pending) return; + if (pending.enabled) { + onTurnOnRef.current(); + } else { + onTurnOffRef.current(); + } + }, + [], + ); + + const requestPreferenceChange = React.useCallback( + (preferenceEnabled: boolean, pendingConfirmation?: Confirmation) => { + const request = nextOptionsRequestRef.current + 1; + nextOptionsRequestRef.current = request; + pendingPreferenceChangeRef.current = { + confirmation: pendingConfirmation, + enabled: preferenceEnabled, + request, + }; + setOpenOptionsRequest(request); + }, + [], + ); + + const completeOptionsReveal = React.useCallback((request: number) => { + const pending = pendingPreferenceChangeRef.current; + if (!pending || pending.request !== request) return; + pendingPreferenceChangeRef.current = null; + if (pending.enabled) { + onTurnOnRef.current(); + return; + } + if (pending.confirmation) { + removePersistentAgentAudienceMembersIfUnchanged({ + expectedRevision: pending.confirmation.expectedRevision, + pubkeys: pending.confirmation.pubkeys, + scope: pending.confirmation.scope, + }); + } + onTurnOffRef.current(); + }, []); + + const clearConfirmation = React.useCallback(() => { + setConfirmationHovered(false); + setConfirmation(null); + }, []); + + React.useEffect(() => { + if (!confirmation || confirmationHovered) return; const timeout = window.setTimeout( - () => setConfirmation(null), + clearConfirmation, CONFIRMATION_DURATION_MS, ); return () => window.clearTimeout(timeout); - }, [confirmation]); + }, [clearConfirmation, confirmation, confirmationHovered]); + + const currentAudiencePubkeySet = React.useMemo( + () => new Set(currentAudiencePubkeys.map(normalizePubkey).filter(Boolean)), + [currentAudiencePubkeys], + ); + const confirmationIsCurrent = + confirmation?.scope === audienceScope && + confirmation.pubkeys.every((pubkey) => + currentAudiencePubkeySet.has(pubkey), + ); + React.useEffect(() => { + if (confirmation && !confirmationIsCurrent) clearConfirmation(); + }, [clearConfirmation, confirmation, confirmationIsCurrent]); const promoteAgents = React.useCallback( ({ @@ -50,10 +140,12 @@ export function useAutoPinMentionedAgents({ ? getPersistentAgentAudienceRevision(audienceScope) : 0, pubkeys, + reinstateExcluded, requirePreference, }: { expectedRevision?: number; pubkeys: readonly string[]; + reinstateExcluded: boolean; requirePreference: boolean; }) => { if (!audienceScope || (requirePreference && !enabled)) return; @@ -62,6 +154,7 @@ export function useAutoPinMentionedAgents({ ].filter(Boolean); const promotion = promotePersistentAgentAudienceIfUnchanged({ expectedRevision, + reinstateExcluded, pubkeys: normalizedPubkeys, scope: audienceScope, }); @@ -78,6 +171,7 @@ export function useAutoPinMentionedAgents({ : promotedPubkeys.length === 1 ? "Agent will be mentioned automatically" : `${promotedPubkeys.length} agents will be mentioned automatically`; + setConfirmationHovered(false); setConfirmation({ expectedRevision: revision, pubkeys: promotedPubkeys, @@ -88,37 +182,45 @@ export function useAutoPinMentionedAgents({ [audienceScope, enabled, getDisplayName, onPulse], ); const promoteMentionedAgents = React.useCallback( - (promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => - promoteAgents({ ...promotion, requirePreference: true }), + (promotion: { + expectedRevision?: number; + pubkeys: readonly string[]; + reinstateExcluded?: boolean; + }) => + promoteAgents({ + ...promotion, + reinstateExcluded: promotion.reinstateExcluded ?? false, + requirePreference: true, + }), [promoteAgents], ); const promoteExplicitlyAddressedAgents = React.useCallback( - (promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => - promoteAgents({ ...promotion, requirePreference: false }), - [promoteAgents], + (promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => { + promoteAgents({ + ...promotion, + reinstateExcluded: true, + requirePreference: false, + }); + requestPreferenceChange(true); + }, + [promoteAgents, requestPreferenceChange], ); - const dismissConfirmation = React.useCallback( - () => setConfirmation(null), - [], - ); + const dismissConfirmation = clearConfirmation; const turnOffConfirmation = React.useCallback(() => { if (!confirmation) return; - setConfirmation(null); - removePersistentAgentAudienceMembersIfUnchanged({ - expectedRevision: confirmation.expectedRevision, - pubkeys: confirmation.pubkeys, - scope: confirmation.scope, - }); - onTurnOff(); - }, [confirmation, onTurnOff]); + clearConfirmation(); + requestPreferenceChange(false, confirmation); + }, [clearConfirmation, confirmation, requestPreferenceChange]); return { - confirmationTitle: - confirmation?.scope === audienceScope ? confirmation.title : null, + confirmationTitle: confirmationIsCurrent ? confirmation.title : null, + completeOptionsReveal, dismissConfirmation, + openOptionsRequest, promoteExplicitlyAddressedAgents, promoteMentionedAgents, + setConfirmationHovered, turnOffConfirmation, }; } diff --git a/desktop/src/features/messages/ui/useComposerMentionPicker.test.mjs b/desktop/src/features/messages/ui/useComposerMentionPicker.test.mjs new file mode 100644 index 0000000000..747df2ee7e --- /dev/null +++ b/desktop/src/features/messages/ui/useComposerMentionPicker.test.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +test("turning off automatic mentions opens a closed mention picker first", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useComposerMentionPicker } = await import( + "./useComposerMentionPicker.ts" + ); + const openCalls = []; + let turnOffCount = 0; + const { result } = renderHook(() => + useComposerMentionPicker({ + mentions: { + cancelMentionAutocomplete: () => {}, + isMentionOpen: false, + openMentionPicker: (...args) => openCalls.push(args), + updateMentionQuery: () => {}, + }, + onTurnOffAutoPinConfirmation: () => { + turnOffCount += 1; + }, + richText: { + editor: {}, + focus: () => {}, + getPlainTextAndCursor: () => ({ cursor: 4, text: "ping" }), + }, + setIsEmojiPickerOpen: () => {}, + }), + ); + + act(() => result.current.turnOff()); + + assert.deepEqual(openCalls, [[4, "first-agent"]]); + assert.equal(turnOffCount, 1); +}); + +test("turning off automatic mentions refreshes an open picker without closing it", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useComposerMentionPicker } = await import( + "./useComposerMentionPicker.ts" + ); + let pickerMutationCount = 0; + let turnOffCount = 0; + const { result } = renderHook(() => + useComposerMentionPicker({ + mentions: { + cancelMentionAutocomplete: () => { + pickerMutationCount += 1; + }, + isMentionOpen: true, + openMentionPicker: () => { + pickerMutationCount += 1; + }, + updateMentionQuery: () => {}, + }, + onTurnOffAutoPinConfirmation: () => { + turnOffCount += 1; + }, + richText: { + editor: {}, + focus: () => {}, + getPlainTextAndCursor: () => ({ cursor: 4, text: "ping" }), + }, + setIsEmojiPickerOpen: () => {}, + }), + ); + + act(() => result.current.turnOff()); + + assert.equal(pickerMutationCount, 1); + assert.equal(turnOffCount, 1); +}); diff --git a/desktop/src/features/messages/ui/useComposerMentionPicker.ts b/desktop/src/features/messages/ui/useComposerMentionPicker.ts index 1a13aa9858..331200e71f 100644 --- a/desktop/src/features/messages/ui/useComposerMentionPicker.ts +++ b/desktop/src/features/messages/ui/useComposerMentionPicker.ts @@ -5,21 +5,23 @@ import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTex export function useComposerMentionPicker({ mentions, + onTurnOffAutoPinConfirmation, richText, setIsEmojiPickerOpen, }: { mentions: UseMentionsResult; + onTurnOffAutoPinConfirmation: () => void; richText: UseRichTextEditorResult; setIsEmojiPickerOpen: (open: boolean) => void; }) { const { cancelMentionAutocomplete, isMentionOpen, - openMentionPicker, + openMentionPicker: setMentionPickerOpen, updateMentionQuery, } = mentions; const { editor, focus, getPlainTextAndCursor } = richText; - return React.useCallback( + const openMentionPicker = React.useCallback( (insertTrigger = true) => { if (!editor) return; const { text, cursor } = getPlainTextAndCursor(); @@ -30,7 +32,7 @@ export function useComposerMentionPicker({ focus(); return; } - openMentionPicker(cursor, "first-agent"); + setMentionPickerOpen(cursor, "first-agent"); setIsEmojiPickerOpen(false); focus(); return; @@ -55,9 +57,36 @@ export function useComposerMentionPicker({ focus, getPlainTextAndCursor, isMentionOpen, - openMentionPicker, + setMentionPickerOpen, setIsEmojiPickerOpen, updateMentionQuery, ], ); + const openMentionSettings = React.useCallback( + () => openMentionPicker(false), + [openMentionPicker], + ); + const revealMentionSettings = React.useCallback(() => { + if (!editor) return; + const { cursor } = getPlainTextAndCursor(); + setMentionPickerOpen(cursor, "first-agent"); + setIsEmojiPickerOpen(false); + focus(); + }, [ + editor, + focus, + getPlainTextAndCursor, + setIsEmojiPickerOpen, + setMentionPickerOpen, + ]); + const turnOffAutoPinFromConfirmation = React.useCallback(() => { + revealMentionSettings(); + onTurnOffAutoPinConfirmation(); + }, [onTurnOffAutoPinConfirmation, revealMentionSettings]); + + return { + openMentionPicker, + openMentionSettings, + turnOff: turnOffAutoPinFromConfirmation, + }; } diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index 694bf6a2a5..857f4bf0e0 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -1,5 +1,7 @@ import * as React from "react"; +import { stripImplicitAgentMentionPrefix } from "@/features/messages/lib/stripImplicitAgentMentions"; + import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; import { @@ -59,6 +61,8 @@ type UseDraftPersistLifecycleParams = { * closure to capture the latest text before the effect fires. */ syncComposerContentFromEditor: () => string; + /** Exact editor prefix inserted by automatic addressing, including separator. */ + getImplicitAgentMentionPrefix?: () => string; }; type UseDraftPersistLifecycleResult = { @@ -120,7 +124,17 @@ export function useDraftPersistLifecycle({ setSpoileredAttachmentUrls, spoileredAttachmentUrlsRef, syncComposerContentFromEditor, + getImplicitAgentMentionPrefix, }: UseDraftPersistLifecycleParams): UseDraftPersistLifecycleResult { + const persistedContent = React.useCallback( + (content: string) => + stripImplicitAgentMentionPrefix( + content, + getImplicitAgentMentionPrefix?.() ?? "", + ), + [getImplicitAgentMentionPrefix], + ); + const pendingImetaForPersistRef = React.useRef([]); const emptyContentIsAuthoritativeRef = React.useRef(false); const isRestoringContentRef = React.useRef(false); @@ -192,7 +206,7 @@ export function useDraftPersistLifecycle({ } const content = emptyContentIsAuthoritativeRef.current ? "" - : syncComposerContentFromEditor(); + : persistedContent(syncComposerContentFromEditor()); persistDraft( effectiveDraftKey, content, diff --git a/desktop/src/features/messages/ui/useImplicitAgentMentionProvenance.ts b/desktop/src/features/messages/ui/useImplicitAgentMentionProvenance.ts new file mode 100644 index 0000000000..c426ee1df7 --- /dev/null +++ b/desktop/src/features/messages/ui/useImplicitAgentMentionProvenance.ts @@ -0,0 +1,54 @@ +import * as React from "react"; + +import { trimMapToSize } from "@/shared/lib/trimMapToSize"; + +type GeneratedMention = { pubkey: string; prefix: string }; + +/** Tracks the leading mentions that automatic addressing inserted per draft. */ +export function useImplicitAgentMentionProvenance( + effectiveDraftKey: string | null | undefined, +) { + const byDraftRef = React.useRef(new Map()); + + const getPrefix = React.useCallback(() => { + if (!effectiveDraftKey) return ""; + return ( + byDraftRef.current + .get(effectiveDraftKey) + ?.map((fragment) => fragment.prefix) + .join("") ?? "" + ); + }, [effectiveDraftKey]); + + const add = React.useCallback( + (insertedFragments: readonly GeneratedMention[]) => { + if (!effectiveDraftKey) return; + const fragments = byDraftRef.current.get(effectiveDraftKey) ?? []; + const knownPubkeys = new Set( + fragments.map((fragment) => fragment.pubkey), + ); + byDraftRef.current.set(effectiveDraftKey, [ + ...insertedFragments.filter( + (fragment) => !knownPubkeys.has(fragment.pubkey), + ), + ...fragments, + ]); + trimMapToSize(byDraftRef.current, 200); + }, + [effectiveDraftKey], + ); + + const remove = React.useCallback( + (pubkey: string) => { + if (!effectiveDraftKey) return; + const fragments = byDraftRef.current.get(effectiveDraftKey) ?? []; + byDraftRef.current.set( + effectiveDraftKey, + fragments.filter((fragment) => fragment.pubkey !== pubkey), + ); + }, + [effectiveDraftKey], + ); + + return { add, getPrefix, remove }; +} diff --git a/desktop/src/shared/lib/keyboard-shortcuts.ts b/desktop/src/shared/lib/keyboard-shortcuts.ts index d8e1550549..30e7936246 100644 --- a/desktop/src/shared/lib/keyboard-shortcuts.ts +++ b/desktop/src/shared/lib/keyboard-shortcuts.ts @@ -166,7 +166,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ { id: "always-address-agent", label: "Always address agent", - description: "Address the default agent, or select the highlighted agent", + description: "Address the default agent, or toggle the highlighted agent", keys: "⇧⌘M", keysWindows: "Ctrl+Shift+M", category: "Messages", diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 814bd86223..9c12ebef4f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1411,7 +1411,16 @@ declare global { }) => unknown; __BUZZ_E2E_SEED_MOCK_REMINDERS__?: (reminders: RelayEvent[]) => void; __BUZZ_E2E_QUERY_CLIENT__?: { - invalidateQueries: (filters: { queryKey: readonly unknown[] }) => unknown; + invalidateQueries: (filters: { + queryKey: readonly unknown[]; + exact?: boolean; + }) => unknown; + getQueryState: (queryKey: readonly unknown[]) => + | { + fetchStatus: "fetching" | "paused" | "idle"; + status: "pending" | "error" | "success"; + } + | undefined; }; __BUZZ_E2E_MD_PARSE_COUNT__?: () => number; /** diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index c5a4522ef5..88985fedec 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -425,10 +425,10 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as await expect( page.getByTestId(`composer-address-lock-${relayPubkey}`), ).toHaveCount(0); - await input.fill("local"); + await input.pressSequentially("local"); await page.getByTestId("send-message").click(); await expect - .poll(() => readOutgoingMentionPubkeys(page, "local")) + .poll(() => readOutgoingMentionPubkeys(page, "@carl local")) .toEqual([managedPubkey]); await expect(input).toHaveText("@carl "); @@ -448,7 +448,7 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as await expect( page.getByTestId(`composer-address-lock-${managedPubkey}`), ).toHaveCount(0); - await input.fill("remote"); + await input.pressSequentially("remote"); await page.getByTestId("send-message").click(); const sendWithoutInviting = page.getByRole("button", { name: "Do nothing" }); try { @@ -458,7 +458,7 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as // In-channel selections send immediately without opening the prompt. } await expect - .poll(() => readOutgoingMentionPubkeys(page, "remote")) + .poll(() => readOutgoingMentionPubkeys(page, "@carl remote")) .toEqual([relayPubkey]); await page.getByTestId("channel-members-trigger").click(); @@ -1673,30 +1673,60 @@ test("forum sends revalidate relay-agent authorization before signing", async ({ .not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); }); -test("relay-only allowlisted agents are visible in channel mentions", async ({ +test("managed agents use the channel roster for membership labels", async ({ page, }) => { await installMockBridge(page, { - relayAgents: [ + managedAgents: [ { - pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, - name: "quinn", - respondTo: "allowlist", - respondToAllowlist: [MOCK_VIEWER_PUBKEY], - channelNames: ["general"], + pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY, + name: "carl", + status: "running", }, ], }); await page.goto("/"); await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect + .poll(() => + page.evaluate( + (channelId) => + window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState([ + "channels", + channelId, + "members", + ])?.status, + GENERAL_CHANNEL_ID, + ), + ) + .toBe("success"); + await page.evaluate( + async ({ channelId, pubkey }) => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("Mock bridge is not installed."); + await invoke("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + exact: true, + }); + }, + { + channelId: GENERAL_CHANNEL_ID, + pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY, + }, + ); const input = page.getByTestId("message-input"); - await input.fill("@quinn"); + await input.fill("@carl"); - const dropdown = autocomplete(page); - await expect(dropdown.getByText("quinn")).toBeVisible(); - await expect(dropdown.getByText("agent")).toBeVisible(); + const carlRow = autocomplete(page).locator("button", { hasText: "carl" }); + await expect(carlRow).toBeVisible(); + await expect(carlRow.getByText("agent")).toBeVisible(); + await expect(carlRow.getByText("not in channel")).toHaveCount(0); }); test("relay-agent directory errors fail closed and recover after a fresh fetch", async ({ @@ -1734,7 +1764,27 @@ test("relay-agent directory errors fail closed and recover after a fresh fetch", queryKey: ["relay-agents"], }); }); - await expect(autocomplete(page).getByText("quinn")).toHaveCount(0); + await expect + .poll(async () => + page.evaluate( + () => + window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["relay-agents"]) + ?.fetchStatus, + ), + ) + .toBe("fetching"); + await expect(autocomplete(page).getByText("quinn")).toBeVisible({ + timeout: 200, + }); + await expect + .poll(async () => + page.evaluate( + () => + window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["relay-agents"]) + ?.fetchStatus, + ), + ) + .toBe("idle"); await expect(autocomplete(page).getByText("quinn")).toBeVisible(); }); @@ -1917,6 +1967,56 @@ test("targeted revocation before send causes no agent side effects", async ({ } }); +test("selected relay agents are invited as bots before sending", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await expect(quinnRow.getByText("not in channel")).toHaveCount(0); + await quinnRow.click(); + await page.keyboard.type("hello"); + + const baselinePayloadCount = (await readCommandPayloadLog(page)).length; + await page.getByTestId("send-message").click(); + const inviteButton = page.getByRole("button", { + name: "Invite", + exact: true, + }); + await expect(inviteButton).toBeVisible(); + await inviteButton.click(); + + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) + .toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); + const sendCommands = (await readCommandPayloadLog(page)).slice( + baselinePayloadCount, + ); + const addCommand = sendCommands.find( + (entry) => entry.command === "add_channel_members", + ); + expect(addCommand?.payload).toMatchObject({ + channelId: GENERAL_CHANNEL_ID, + pubkeys: [ALLOWLIST_RELAY_AGENT_PUBKEY], + role: "bot", + }); +}); + test("selected relay agents revoked after the invite prompt cause no side effects", async ({ page, }) => { diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index 0b957992e2..643031454a 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -5,6 +5,7 @@ import { installMockBridge } from "../helpers/bridge"; const SHOTS = "test-results/persistent-agent-audience"; const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d"; const AGENT_A = "a".repeat(64); const AGENT_B = "b".repeat(64); const THREAD_ROOT_ID = "mock-general-welcome"; @@ -84,11 +85,11 @@ async function readComposerCaret(input: Locator) { }); } -async function pressPrimaryShift(page: Page, key: "M") { +async function pressPrimaryShiftM(page: Page) { const isMac = await page.evaluate(() => /mac|iphone|ipad|ipod/i.test(navigator.platform), ); - await page.keyboard.press(`${isMac ? "Meta" : "Control"}+Shift+${key}`); + await page.keyboard.press(`${isMac ? "Meta" : "Control"}+Shift+M`); } async function readOutgoingMentionPubkeys(page: Page, content: string) { @@ -156,6 +157,7 @@ async function emitMockMessage( async function installAudienceFixtures( page: Page, options: { + agentAName?: string; deferredComposerUploads?: boolean; sendMessageDelayMs?: number; sendMessageErrors?: string[]; @@ -171,12 +173,13 @@ async function installAudienceFixtures( usersBatchDelayMs?: number; } = {}, ) { + const { agentAName = "Morgarita", ...bridgeOptions } = options; await installMockBridge(page, { - ...options, + ...bridgeOptions, managedAgents: [ { pubkey: AGENT_A, - name: "Morgarita", + name: agentAName, status: "running", channelNames: ["general"], }, @@ -285,6 +288,104 @@ test("automatically mentions multiple agents from the mention picker", async ({ ).toBeVisible(); }); +test("keeps the composer and global automatic mention settings synchronized", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + await composer.getByTestId("message-insert-mention").click(); + const optionsTrigger = composer.getByTestId("mention-options-trigger"); + await expect(optionsTrigger).toHaveAttribute("aria-expanded", "false"); + await composer + .getByTestId("mention-autocomplete") + .getByRole("button", { name: "Automatically mention Morgarita" }) + .click(); + await expect(optionsTrigger).toHaveAttribute("aria-expanded", "true"); + const composerToggle = composer.getByTestId( + "mention-keep-agents-pinned-toggle", + ); + await expect(composerToggle).toHaveAttribute("data-state", "unchecked"); + await expect(composerToggle).toHaveAttribute("data-state", "checked", { + timeout: 1_500, + }); + + await page + .getByTestId("composer-auto-pin-confirmation") + .getByRole("button", { name: "Turn off" }) + .click(); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await expect(optionsTrigger).toHaveAttribute("aria-expanded", "true"); + await expect(composerToggle).toHaveAttribute("data-state", "checked"); + await expect(composerToggle).toHaveAttribute("data-state", "unchecked", { + timeout: 1_500, + }); + + await page.getByTestId("open-settings").click(); + await page.getByTestId("profile-popover-settings").click(); + await expect(page.getByTestId("settings-view")).toBeVisible(); + await page.getByTestId("settings-nav-agents").click(); + const settingsToggle = page + .getByTestId("settings-automatic-agent-mentions") + .getByRole("switch", { name: "Automatically mention agents" }); + await expect(settingsToggle).toHaveAttribute("data-state", "unchecked"); + + await page.getByTestId("settings-back-to-app").click(); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + await composer.getByTestId("message-insert-mention").click(); + await composer.getByTestId("mention-options-trigger").click(); + await expect( + composer.getByTestId("mention-keep-agents-pinned-toggle"), + ).toHaveAttribute("data-state", "unchecked"); + await expect( + composer.getByRole("button", { + name: "Automatically mention Morgarita", + }), + ).toHaveAttribute("aria-pressed", "false"); +}); + +test("hides automatic mention state while disabled without clearing the draft", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await automaticallyMention(composer, "Morgarita"); + await input.type("draft text"); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + + await page.getByTestId("channel-management-trigger").click(); + await expect(page.getByTestId("channel-management-sheet")).toBeVisible(); + await page.getByTestId("channel-management-archive").click(); + await expect(page.getByTestId("channel-management-unarchive")).toBeVisible(); + await page.getByTestId("auxiliary-panel-close").click(); + + await expect(input).toHaveAttribute("contenteditable", "false"); + await expect(input).toHaveText("@Morgarita draft text"); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + + await page.getByTestId("channel-management-trigger").click(); + await expect(page.getByTestId("channel-management-sheet")).toBeVisible(); + await page.getByTestId("channel-management-unarchive").click(); + await expect(page.getByTestId("channel-management-archive")).toBeVisible(); + await page.getByTestId("auxiliary-panel-close").click(); + + await expect(input).toHaveAttribute("contenteditable", "true"); + await expect(input).toHaveText("@Morgarita draft text"); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); +}); + test("Tab inserts a one-time agent mention by default", async ({ page }) => { await installAudienceFixtures(page); await openGeneral(page); @@ -346,7 +447,7 @@ test("disabling automatic mentions leaves the composer empty after send", async .toContain(AGENT_A); }); -test("primary+Shift+M addresses the default agent, then selects the highlighted agent", async ({ +test("primary+Shift+M addresses the default agent, then toggles the highlighted agent in place", async ({ page, }) => { await keepMentionedAgentsPinned(page); @@ -356,21 +457,21 @@ test("primary+Shift+M addresses the default agent, then selects the highlighted const composer = channelComposer(page); const input = composer.getByTestId("message-input"); await input.fill("draft text"); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("@alice draft text"); await expect(input.locator(".agent-mention-highlight")).toHaveText("alice"); await expect( page.getByTestId("composer-auto-pin-confirmation"), ).toContainText("alice will be mentioned automatically"); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("draft text"); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("@alice draft text"); await expect( composer .getByTestId("composer-address-locks") - .getByRole("button", { name: /^Stop automatically mentioning / }), + .getByRole("button", { name: /^Don't automatically mention / }), ).toHaveCount(1); await input.fill("@Vog"); @@ -378,9 +479,9 @@ test("primary+Shift+M addresses the default agent, then selects the highlighted await expect(menu.getByTestId(`mention-suggestion-${AGENT_B}`)).toHaveClass( /(?:^|\s)bg-accent(?:\s|$)/, ); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); - await expect(menu).toHaveCount(0); + await expect(menu).toBeVisible(); await expect(input).toHaveText("@Vogue "); await expect( composer.getByTestId(`composer-address-lock-${AGENT_B}`), @@ -391,7 +492,7 @@ test("primary+Shift+M addresses the default agent, then selects the highlighted await expect( composer .getByTestId("composer-address-locks") - .getByRole("button", { name: /^Stop automatically mentioning / }), + .getByRole("button", { name: /^Don't automatically mention / }), ).toHaveCount(1); }); @@ -407,13 +508,13 @@ test("primary+Shift+M favors the most recently mentioned eligible agent", async await input.press("ArrowLeft"); await input.press("ArrowLeft"); await expect.poll(() => readComposerCaret(input)).toBe(8); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("@Vogue draft text"); await expect.poll(() => readComposerCaret(input)).toBe(15); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("draft text"); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("@Vogue draft text"); }); @@ -468,12 +569,14 @@ test("the mention button opens settings and can undo an address", async ({ await expect(page.getByTestId("user-profile-panel")).toHaveCount(0); await expect( menu.getByRole("button", { - name: "Stop automatically mentioning Morgarita", + name: "Don't automatically mention Morgarita in this conversation", }), ).toHaveAttribute("aria-pressed", "true"); await menu - .getByRole("button", { name: "Stop automatically mentioning Morgarita" }) + .getByRole("button", { + name: "Don't automatically mention Morgarita in this conversation", + }) .click(); await expect(input).toHaveText("draft text"); await expect( @@ -724,44 +827,513 @@ test("the auto-pin popover can turn off automatic agent mentions", async ({ ); await autoPinConfirmation.getByRole("button", { name: "Turn off" }).click(); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await expect(composer.getByTestId("mention-options-trigger")).toHaveAttribute( + "aria-expanded", + "true", + ); + await expect( + composer.getByTestId("mention-keep-agents-pinned-toggle"), + ).toHaveAttribute("data-state", "unchecked"); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toHaveCount(0); await expect(autoPinConfirmation).toHaveCount(0); - await composer.getByTestId("message-insert-mention").click(); - await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); - await composer.getByTestId("mention-options-trigger").click(); - await expect( - composer.getByTestId("mention-keep-agents-pinned-toggle"), - ).toHaveAttribute("data-state", "unchecked"); - await input.press("Escape"); await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); }); -test("channel automatic mentions carry into threads and stay synchronized", async ({ +test("the auto-pin popover remains open while hovered", async ({ page }) => { + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await input.fill("@Mor"); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Tab"); + + const autoPinConfirmation = page.getByTestId( + "composer-auto-pin-confirmation", + ); + await autoPinConfirmation.hover(); + await page.waitForTimeout(4_250); + await expect(autoPinConfirmation).toBeVisible(); + + await input.press("Escape"); + await expect(autoPinConfirmation).toHaveCount(0); +}); + +test("removing the mention chip dismisses the auto-pin popover", async ({ + page, +}) => { + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await input.fill("@Mor"); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Tab"); + + const autoPinConfirmation = page.getByTestId( + "composer-auto-pin-confirmation", + ); + await expect(autoPinConfirmation).toBeVisible(); + + const selectAllShortcut = await page.evaluate(() => + /mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A", + ); + await input.press(selectAllShortcut); + await input.press("Backspace"); + + await expect(input).toHaveText(""); + await expect(autoPinConfirmation).toHaveCount(0); +}); + +test("automatic mentions are scoped to their channel or thread composer", async ({ page, }) => { await installAudienceFixtures(page); await openGeneral(page); await automaticallyMention(channelComposer(page), "Morgarita"); + await expect( + channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); await openThread(page); - const channelAutomaticMention = channelComposer(page).getByTestId( - `composer-address-lock-${AGENT_A}`, - ); - const threadAutomaticMention = threadComposer(page).getByTestId( - `composer-address-lock-${AGENT_A}`, - ); - await expect(channelAutomaticMention).toBeVisible(); - await expect(threadAutomaticMention).toBeVisible(); + await expect( + threadComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); - await threadComposer(page) + await automaticallyMention(threadComposer(page), "Vogue"); + await openGeneral(page); + await expect( + channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + await expect( + channelComposer(page).getByTestId(`composer-address-lock-${AGENT_B}`), + ).toHaveCount(0); + + await openThread(page); + await expect( + threadComposer(page).getByTestId(`composer-address-lock-${AGENT_B}`), + ).toBeVisible(); + + await openThread(page, "mock-general-alice"); + await expect( + threadComposer(page).getByTestId(`composer-address-lock-${AGENT_B}`), + ).toHaveCount(0); +}); + +test("a thread automatic mention preserves an explicitly unpinned root agent", async ({ + page, +}) => { + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page, { agentAName: "claude code" }); + await openGeneral(page); + + const rootComposer = channelComposer(page); + const rootInput = rootComposer.getByTestId("message-input"); + await automaticallyMention(rootComposer, "claude code"); + await expect( + rootComposer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + await rootComposer .getByTestId(`composer-address-lock-remove-${AGENT_A}`) .click(); - await expect(threadAutomaticMention).toHaveCount(0); - await expect(channelAutomaticMention).toHaveCount(0); + await expect(rootInput).toHaveText(""); + await expect( + rootComposer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + + await rootInput.fill("@cla"); + await expect(rootComposer.getByTestId("mention-autocomplete")).toBeVisible(); + await rootInput.press("Tab"); + await rootInput.type("one time"); + await rootInput.press("Enter"); + await expect(rootInput).toHaveText(""); + await expect( + rootComposer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + + await openThread(page); + const activeThreadComposer = threadComposer(page); + const threadInput = activeThreadComposer.getByTestId("message-input"); + await threadInput.fill("@cla"); + await expect( + activeThreadComposer.getByTestId("mention-autocomplete"), + ).toBeVisible(); + await threadInput.press("Tab"); + await expect( + activeThreadComposer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + await threadInput.type("thread message"); + await threadInput.press("Enter"); + + await openGeneral(page); + await expect( + channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + + const restoredRootInput = channelComposer(page).getByTestId("message-input"); + await restoredRootInput.fill("@cla"); + await expect( + channelComposer(page).getByTestId("mention-autocomplete"), + ).toBeVisible(); + await restoredRootInput.press("Tab"); + await expect( + channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + await restoredRootInput.type("one time"); + await restoredRootInput.press("Enter"); + + await expect(restoredRootInput).toHaveText(""); + await expect( + channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); +}); + +test("an unchecked agent remains excluded while automatic mentions stay enabled", async ({ + page, +}) => { + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page); + await openGeneral(page); + await automaticallyMention(channelComposer(page), "Morgarita"); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click(); + await expect(input).toHaveText(""); + await input.fill("@Mor"); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Tab"); + await input.type("one time"); + await input.press("Enter"); + + await expect(input).toHaveText(""); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); +}); + +test("re-adding a deleted automatic mention restores its automatic mention state immediately", async ({ + page, +}) => { + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await automaticallyMention(composer, "Morgarita"); + + const selectAllShortcut = await page.evaluate(() => + /mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A", + ); + await input.press(selectAllShortcut); + await input.press("Backspace"); + + await expect(input).toHaveText(""); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + + await input.fill("@Mor"); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Tab"); + await expect(input).toHaveText("@Morgarita "); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + + await input.type("re-added"); + await input.press("Enter"); + + await expect(input).toHaveText("@Morgarita "); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); +}); + +test("implicit automatic mentions stay out of persisted drafts", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + await automaticallyMention(channelComposer(page), "Morgarita"); + const input = channelComposer(page).getByTestId("message-input"); + await input.type("draft text"); + + await openThread(page); + await openGeneral(page); + + await expect(input).toHaveText("@Morgarita draft text"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); + await input.pressSequentially(" continues"); + await expect(input).toHaveText("@Morgarita draft text continues"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); + + await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { + waitUntil: "domcontentloaded", + }); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + await expect + .poll(() => + page.evaluate((channelId) => { + for (const storageKey of Object.keys(window.localStorage)) { + if (!storageKey.startsWith("buzz-drafts.v2:")) continue; + const drafts = JSON.parse( + window.localStorage.getItem(storageKey) ?? "{}", + ) as Record; + const draft = drafts[channelId]; + if (draft?.channelId === channelId) return draft.content ?? ""; + } + return ""; + }, CHANNEL_ID), + ) + .toBe("draft text continues"); +}); + +test("an authored duplicate leading mention survives draft restoration", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + await automaticallyMention(channelComposer(page), "Morgarita"); + const input = channelComposer(page).getByTestId("message-input"); + await input.pressSequentially("@Morgarita authored duplicate"); + + await openThread(page); + await openGeneral(page); + + await expect(input).toHaveText("@Morgarita @Morgarita authored duplicate"); + // Exact typed mentions now resolve on Space, so both the automatic prefix and + // the authored duplicate retain mention identity after restoration. + await expect(input.locator(".agent-mention-highlight")).toHaveCount(2); + + await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { + waitUntil: "domcontentloaded", + }); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await expect + .poll(() => + page.evaluate((channelId) => { + for (const storageKey of Object.keys(window.localStorage)) { + if (!storageKey.startsWith("buzz-drafts.v2:")) continue; + const drafts = JSON.parse( + window.localStorage.getItem(storageKey) ?? "{}", + ) as Record; + const draft = drafts[channelId]; + if (draft?.channelId === channelId) return draft.content ?? ""; + } + return ""; + }, CHANNEL_ID), + ) + .toBe("@Morgarita authored duplicate"); +}); + +test("typed deletion preserves an identical authored mention in drafts", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await automaticallyMention(composer, "Morgarita"); + + const selectAllShortcut = await page.evaluate(() => + /mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A", + ); + await input.press(selectAllShortcut); + await input.press("Backspace"); + await expect(input).toHaveText(""); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + + await input.pressSequentially("@Morgarita manual after typed deletion"); + await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { + waitUntil: "domcontentloaded", + }); + await expect + .poll(() => + page.evaluate((channelId) => { + for (const storageKey of Object.keys(window.localStorage)) { + if (!storageKey.startsWith("buzz-drafts.v2:")) continue; + const draft = ( + JSON.parse( + window.localStorage.getItem(storageKey) ?? "{}", + ) as Record + )[channelId]; + if (draft) return draft.content ?? ""; + } + return ""; + }, CHANNEL_ID), + ) + .toBe("@Morgarita manual after typed deletion"); +}); + +test("removing an automatic mention preserves an identical authored mention in drafts", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + + await automaticallyMention(composer, "Morgarita"); + await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click(); + await input.pressSequentially("@Morgarita manual after removal"); + await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { + waitUntil: "domcontentloaded", + }); + + await expect + .poll(() => + page.evaluate((channelId) => { + for (const storageKey of Object.keys(window.localStorage)) { + if (!storageKey.startsWith("buzz-drafts.v2:")) continue; + const draft = ( + JSON.parse( + window.localStorage.getItem(storageKey) ?? "{}", + ) as Record + )[channelId]; + if (draft) return draft.content ?? ""; + } + return ""; + }, CHANNEL_ID), + ) + .toBe("@Morgarita manual after removal"); +}); + +test("multiple automatic mentions stay out of persisted drafts", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await automaticallyMention(composer, "Morgarita"); + await automaticallyMention(composer, "Vogue"); + await input.pressSequentially("draft text"); + + await openThread(page); + await openGeneral(page); + await expect(input).toHaveText("@Vogue @Morgarita draft text"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(2); + await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { + waitUntil: "domcontentloaded", + }); + await expect + .poll(() => + page.evaluate((channelId) => { + for (const storageKey of Object.keys(window.localStorage)) { + if (!storageKey.startsWith("buzz-drafts.v2:")) continue; + const draft = ( + JSON.parse( + window.localStorage.getItem(storageKey) ?? "{}", + ) as Record + )[channelId]; + if (draft) return draft.content ?? ""; + } + return ""; + }, CHANNEL_ID), + ) + .toBe("draft text"); +}); + +test("re-enabling an automatic mention preserves an authored duplicate after draft restoration", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + + await automaticallyMention(composer, "Morgarita"); + await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click(); + await expect(input).toHaveText(""); + + await automaticallyMention(composer, "Morgarita"); + await input.pressSequentially("@Morgarita authored duplicate"); + await openThread(page); + await openGeneral(page); + + await expect(input).toHaveText("@Morgarita @Morgarita authored duplicate"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(2); + + await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { + waitUntil: "domcontentloaded", + }); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await expect + .poll(() => + page.evaluate((channelId) => { + for (const storageKey of Object.keys(window.localStorage)) { + if (!storageKey.startsWith("buzz-drafts.v2:")) continue; + const drafts = JSON.parse( + window.localStorage.getItem(storageKey) ?? "{}", + ) as Record; + const draft = drafts[channelId]; + if (draft?.channelId === channelId) return draft.content ?? ""; + } + return ""; + }, CHANNEL_ID), + ) + .toBe("@Morgarita authored duplicate"); +}); + +test("a restored multi-word automatic mention remains a chip with the caret after its space", async ({ + page, +}) => { + await installAudienceFixtures(page, { agentAName: "claude code" }); + await openGeneral(page); + const originalComposer = channelComposer(page); + await automaticallyMention(originalComposer, "claude code"); + const originalInput = originalComposer.getByTestId("message-input"); + await originalInput.pressSequentially("hello"); + await originalInput.press("Enter"); + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@claude code hello")) + .toContain(AGENT_A); + await expect(originalInput).toHaveText("@claude code "); + + await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { + waitUntil: "domcontentloaded", + }); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + const expectedContent = "@claude code "; + await expect(input).toHaveText(expectedContent); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + await expect( + composer.getByRole("button", { name: "Manage automatic agent mentions" }), + ).toBeVisible(); + await page.waitForTimeout(500); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); + await expect(input).toBeFocused(); + await expect + .poll(() => readComposerCaret(input)) + .toBe(expectedContent.length); + + await input.pressSequentially("follow-up"); + await expect(input).toHaveText("@claude code follow-up"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); }); test("reduced motion removes addressed agents without spatial animation", async ({ @@ -785,7 +1357,7 @@ test("reduced motion removes addressed agents without spatial animation", async await expect(removeButton).toHaveCSS("transform", "none"); await removeButton.click(); - await expect(input).toHaveText("@Morgarita "); + await expect(input).toHaveText(""); await expect(removeButton).toHaveCount(0); }); @@ -832,7 +1404,7 @@ test("captures the lightweight auto-pin popover", async ({ page }) => { const composer = channelComposer(page); const input = composer.getByTestId("message-input"); await input.fill("draft text"); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("@alice draft text"); const addressControl = composer From 00e61eafa917d296104006576b7a2ddbfd58bb5a Mon Sep 17 00:00:00 2001 From: thomaspblock Date: Fri, 28 Aug 2026 23:23:25 -0400 Subject: [PATCH 04/22] fix(desktop): surface channel history load failures (#7013) ## Summary A failed initial channel-history request no longer appears as an authoritative empty channel. The timeline now shows an announced error with a Retry action, while cached messages remain visible when a later refresh fails; successful empty channels continue to use their normal intro state. ### Related issue None found. ### Testing - Full desktop unit suite (`pnpm test`) - Desktop TypeScript check (`pnpm exec tsc --noEmit`) - Biome checks for changed files - Repository file-size ratchet - Full pre-push desktop checks and tests - Desktop app launched successfully against local Postgres and Redis for manual testing No screenshot is included because the new UI is only shown after a terminal relay-history failure; the regression test pins the error/empty/list precedence directly. --------- Signed-off-by: Thomas Petersen --- .../channels/ui/ChannelPane.helpers.ts | 17 +++ .../src/features/channels/ui/ChannelPane.tsx | 22 ++-- .../features/channels/ui/ChannelPane.types.ts | 3 + .../features/channels/ui/ChannelScreen.tsx | 33 ++---- .../lib/projectChannelWindow.test.mjs | 71 ++++++++++++ .../messages/lib/projectChannelWindow.ts | 2 +- .../lib/timelineLoadingState.test.mjs | 107 +++++++++++++++++- .../messages/lib/timelineLoadingState.ts | 38 ++++++- .../messages/lib/timelineSnapshot.test.mjs | 24 ++++ .../features/messages/lib/timelineSnapshot.ts | 10 +- .../features/messages/ui/MessageTimeline.tsx | 38 +++++-- .../messages/ui/MessageTimelineErrorCard.tsx | 36 ++++++ 12 files changed, 350 insertions(+), 51 deletions(-) create mode 100644 desktop/src/features/messages/ui/MessageTimelineErrorCard.tsx diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index 4dc0f0f247..a93eed6837 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -1,6 +1,7 @@ import { getChannelDetail } from "@/features/channels/lib/channelDescription"; import { isEphemeralChannel } from "@/features/channels/lib/ephemeralChannel"; import type { TimelineMessage } from "@/features/messages/types"; +import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; import type { Channel } from "@/shared/api/types"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; @@ -106,3 +107,19 @@ export function mentionsKnownAgent( knownAgentPubkeys.has(pubkey.toLowerCase()), ); } + +export function selectThreadComposerBotTypingPubkeys( + entries: TypingIndicatorEntry[], + threadHeadId: string | null, +) { + if (!threadHeadId) return []; + return entries + .filter((entry) => entry.threadHeadId === threadHeadId) + .map((entry) => entry.pubkey) + .filter( + (pubkey, index, all) => + all.findIndex( + (candidate) => candidate.toLowerCase() === pubkey.toLowerCase(), + ) === index, + ); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 38ebd9234a..d53e8dbbda 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -52,6 +52,7 @@ import { import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; import { mentionsKnownAgent, + selectThreadComposerBotTypingPubkeys, shouldPrioritizeIdleAuxiliary, shouldUseFocusIdleDrawer, } from "@/features/channels/ui/ChannelPane.helpers"; @@ -103,7 +104,9 @@ export const ChannelPane = React.memo(function ChannelPane({ isJoining = false, isSinglePanelView = false, isSending, + isTimelineError = false, isTimelineLoading, + onRetryTimeline, entranceMessageId = null, onEntranceMessageComplete, welcomeKickoffStage = null, @@ -334,18 +337,11 @@ export const ChannelPane = React.memo(function ChannelPane({ const hasCardMintActivity = useCardMintJobs().length > 0; const hasComposerBottomActivity = hasComposerBotActivity || hasTypingActivity || hasCardMintActivity; - const threadComposerBotTypingPubkeys = React.useMemo(() => { - if (!openThreadHeadId) return []; - return botTypingEntries - .filter((entry) => entry.threadHeadId === openThreadHeadId) - .map((entry) => entry.pubkey) - .filter( - (pubkey, index, all) => - all.findIndex( - (candidate) => candidate.toLowerCase() === pubkey.toLowerCase(), - ) === index, - ); - }, [botTypingEntries, openThreadHeadId]); + const threadComposerBotTypingPubkeys = React.useMemo( + () => + selectThreadComposerBotTypingPubkeys(botTypingEntries, openThreadHeadId), + [botTypingEntries, openThreadHeadId], + ); const hasThreadComposerBotActivity = threadComposerBotTypingPubkeys.length > 0; const directMessageIntro = React.useMemo( @@ -662,7 +658,9 @@ export const ChannelPane = React.memo(function ChannelPane({ : "No messages yet" : "No channel selected" } + isError={isTimelineError} isLoading={isHuddleTranscript ? false : isTimelineLoading} + onRetry={onRetryTimeline} entranceMessageId={entranceMessageId} onEntranceMessageComplete={onEntranceMessageComplete} mainEntries={mainTimelineEntries} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 5bce6a71ff..1fe5bf751b 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -59,7 +59,10 @@ export type ChannelPaneProps = { isJoining?: boolean; isSinglePanelView?: boolean; isSending: boolean; + /** Terminal channel-history failure. Cached messages remain visible when present. */ + isTimelineError?: boolean; isTimelineLoading: boolean; + onRetryTimeline?: () => void; /** Newly-created message that should receive the one-shot conversation arrival motion. */ entranceMessageId?: string | null; onEntranceMessageComplete?: (messageId: string) => void; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 9c2d25dd7c..f7a5b48012 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -50,10 +50,7 @@ import { isThreadReply, } from "@/features/messages/lib/threading"; import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache"; -import { - resolveTimelineLoadingLatch, - selectTimelineLoadingState, -} from "@/features/messages/lib/timelineLoadingState"; +import { resolveTimelineQueryLoadingState } from "@/features/messages/lib/timelineLoadingState"; import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; import { useIndependentThreadPanel } from "@/features/messages/useIndependentThreadPanel"; import { useThreadReplies } from "@/features/messages/useThreadReplies"; @@ -616,27 +613,21 @@ export function ChannelScreen({ setThreadScrollTargetId, }); const settledChannelIdRef = React.useRef(null); - const hasSettledThisChannel = - activeChannelId !== null && settledChannelIdRef.current === activeChannelId; - const timelineLoadingNow = - activeChannel !== null && - activeChannel.channelType !== "forum" && - selectTimelineLoadingState( + const { settledChannelId, isLoading: isTimelineLoading } = + resolveTimelineQueryLoadingState( + settledChannelIdRef.current, + activeChannelId, { + isEnabled: + activeChannel !== null && activeChannel.channelType !== "forum", isPending: messagesQuery.isPending, isFetching: messagesQuery.isFetching, isPlaceholderData: messagesQuery.isPlaceholderData, dataLength: messagesQuery.data?.length ?? null, + isError: messagesQuery.isError, }, - hasSettledThisChannel || - (activeChannelId !== null && - hasPersistedHydratedChannel(queryClient, activeChannelId)), - ); - const { settledChannelId, isLoading: isTimelineLoading } = - resolveTimelineLoadingLatch( - settledChannelIdRef.current, - activeChannelId, - timelineLoadingNow, + activeChannelId !== null && + hasPersistedHydratedChannel(queryClient, activeChannelId), ); settledChannelIdRef.current = settledChannelId; const { welcomeKickoffStage, welcomeKickoffSettingUp } = @@ -890,8 +881,8 @@ export function ChannelScreen({ isFollowingThread={isNotifiedForEffectiveThread} isSending={sendMessageMutation.isPending} isSinglePanelView={isSinglePanelView} - isTimelineLoading={isTimelineLoading} - messages={timelineMessages} + isTimelineError={messagesQuery.isError} isTimelineLoading={isTimelineLoading} + onRetryTimeline={() => void messagesQuery.refetch()} messages={timelineMessages} threadSummaries={threadSummaries} huddleThreadRepliesError={huddleThreadRepliesError} onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} diff --git a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs index 618f3fc991..9594b93787 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs +++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs @@ -416,3 +416,74 @@ test("test_concurrent_refreshes_after_seeded_snapshot_share_one_authoritative_fe unsubscribe(); } }); + +test("test_subscription_refresh_preserves_cold_history_error", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const channelId = "cold-failure"; + const queryKey = channelMessagesKey(channelId); + const observer = new QueryObserver(client, { + queryKey, + queryFn: async () => { + throw new Error("history unavailable"); + }, + }); + const unsubscribe = observer.subscribe(() => {}); + + try { + await observer.refetch(); + assert.equal(observer.getCurrentResult().status, "error"); + assert.equal(observer.getCurrentResult().data, undefined); + + await assert.rejects( + refreshChannelWindowMessages(client, channelId), + /history unavailable/, + ); + assert.equal(observer.getCurrentResult().status, "error"); + assert.equal(observer.getCurrentResult().data, undefined); + } finally { + unsubscribe(); + } +}); + +test("test_refresh_failure_retains_cached_rows_and_success_clears_error", async () => { + const harness = createHarness(); + let shouldFail = true; + const refreshed = event("refreshed", 110); + const observer = new QueryObserver(harness.client, { + queryKey: harness.messagesKey, + queryFn: async ({ signal }) => { + if (shouldFail) { + throw new Error("history unavailable"); + } + const previousMessages = + harness.client.getQueryData(harness.messagesKey) ?? []; + return reconcileFetchedChannelWindow( + harness.client, + harness.channelId, + wirePage([refreshed, event("initial", 100)]), + previousMessages, + signal, + ); + }, + staleTime: Number.POSITIVE_INFINITY, + }); + const unsubscribe = observer.subscribe(() => {}); + + try { + await assert.rejects( + refreshChannelWindowMessages(harness.client, harness.channelId), + /history unavailable/, + ); + assert.equal(observer.getCurrentResult().status, "error"); + assert.deepEqual(contents(harness), ["initial"]); + + shouldFail = false; + await refreshChannelWindowMessages(harness.client, harness.channelId); + assert.equal(observer.getCurrentResult().status, "success"); + assert.deepEqual(contents(harness), ["initial", "refreshed"]); + } finally { + unsubscribe(); + } +}); diff --git a/desktop/src/features/messages/lib/projectChannelWindow.ts b/desktop/src/features/messages/lib/projectChannelWindow.ts index b16187ce18..8531c867f2 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.ts +++ b/desktop/src/features/messages/lib/projectChannelWindow.ts @@ -47,7 +47,7 @@ export async function refreshChannelWindowMessages( } await queryClient.invalidateQueries( { queryKey, exact: true, refetchType: "active" }, - { cancelRefetch: !seeded }, + { cancelRefetch: !seeded, throwOnError: true }, ); projectChannelWindowMessages(queryClient, channelId); } diff --git a/desktop/src/features/messages/lib/timelineLoadingState.test.mjs b/desktop/src/features/messages/lib/timelineLoadingState.test.mjs index fe6960f74c..ed414365be 100644 --- a/desktop/src/features/messages/lib/timelineLoadingState.test.mjs +++ b/desktop/src/features/messages/lib/timelineLoadingState.test.mjs @@ -1,7 +1,12 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; -import { selectTimelineLoadingState } from "./timelineLoadingState.ts"; +import { + resolveTimelineLoadingLatch, + resolveTimelineQueryLoadingState, + selectTimelineLoadingState, +} from "./timelineLoadingState.ts"; const settled = { isPending: false, @@ -10,6 +15,27 @@ const settled = { dataLength: null, }; +function deferred() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function resolveQueryLoading(result, settledChannelId = null) { + return resolveTimelineQueryLoadingState(settledChannelId, "chan-a", { + isEnabled: true, + isPending: result.isPending, + isFetching: result.isFetching, + isPlaceholderData: result.isPlaceholderData, + dataLength: result.data?.length ?? null, + isError: result.isError, + }); +} + test("pending first fetch with no cache is loading", () => { assert.equal( selectTimelineLoadingState({ ...settled, isPending: true }), @@ -120,8 +146,6 @@ test("settled channel with rows mid-refetch is not loading", () => { ); }); -import { resolveTimelineLoadingLatch } from "./timelineLoadingState.ts"; - test("latch: loading on first entry to a channel", () => { const r = resolveTimelineLoadingLatch(null, "chan-a", true); assert.equal(r.isLoading, true); @@ -158,3 +182,80 @@ test("latch: no active channel passes loadingNow through untouched", () => { false, ); }); + +test("query wiring: cold error retry stays loading until successful empty result", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const first = deferred(); + const retry = deferred(); + let attempt = 0; + const observer = new QueryObserver(client, { + queryKey: ["messages", "chan-a"], + queryFn: () => [first.promise, retry.promise][attempt++], + }); + const unsubscribe = observer.subscribe(() => {}); + + try { + first.reject(new Error("history unavailable")); + await new Promise((resolve) => setImmediate(resolve)); + let loading = resolveQueryLoading(observer.getCurrentResult()); + assert.equal(observer.getCurrentResult().status, "error"); + assert.deepEqual(loading, { settledChannelId: null, isLoading: false }); + + const retryResult = observer.refetch(); + loading = resolveQueryLoading(observer.getCurrentResult()); + assert.equal(observer.getCurrentResult().status, "pending"); + assert.deepEqual(loading, { settledChannelId: null, isLoading: true }); + + retry.resolve([]); + await retryResult; + loading = resolveQueryLoading(observer.getCurrentResult()); + assert.equal(observer.getCurrentResult().status, "success"); + assert.deepEqual(loading, { + settledChannelId: "chan-a", + isLoading: false, + }); + } finally { + unsubscribe(); + } +}); + +test("query wiring: repeated cold retry failure never settles as empty", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const first = deferred(); + const retry = deferred(); + let attempt = 0; + const observer = new QueryObserver(client, { + queryKey: ["messages", "chan-a"], + queryFn: () => [first.promise, retry.promise][attempt++], + }); + const unsubscribe = observer.subscribe(() => {}); + + try { + first.reject(new Error("history unavailable")); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(resolveQueryLoading(observer.getCurrentResult()), { + settledChannelId: null, + isLoading: false, + }); + + const retryResult = observer.refetch(); + assert.deepEqual(resolveQueryLoading(observer.getCurrentResult()), { + settledChannelId: null, + isLoading: true, + }); + + retry.reject(new Error("still unavailable")); + await retryResult; + assert.equal(observer.getCurrentResult().status, "error"); + assert.deepEqual(resolveQueryLoading(observer.getCurrentResult()), { + settledChannelId: null, + isLoading: false, + }); + } finally { + unsubscribe(); + } +}); diff --git a/desktop/src/features/messages/lib/timelineLoadingState.ts b/desktop/src/features/messages/lib/timelineLoadingState.ts index ea46168d25..bb6f0bc96c 100644 --- a/desktop/src/features/messages/lib/timelineLoadingState.ts +++ b/desktop/src/features/messages/lib/timelineLoadingState.ts @@ -15,6 +15,11 @@ export type TimelineQueryStatus = { dataLength: number | null; }; +export type TimelineQueryLoadingStatus = TimelineQueryStatus & { + isEnabled: boolean; + isError: boolean; +}; + export function selectTimelineLoadingState( status: TimelineQueryStatus, hasSettled = true, @@ -50,6 +55,7 @@ export function resolveTimelineLoadingLatch( settledChannelId: string | null, activeChannelId: string | null, loadingNow: boolean, + canSettle = true, ): { settledChannelId: string | null; isLoading: boolean } { if (activeChannelId === null) { return { settledChannelId, isLoading: loadingNow }; @@ -58,9 +64,37 @@ export function resolveTimelineLoadingLatch( // Already settled for this channel — stay loaded through refetch blips. return { settledChannelId, isLoading: false }; } - if (!loadingNow) { + if (!loadingNow && canSettle) { // First settle for this channel; latch it. return { settledChannelId: activeChannelId, isLoading: false }; } - return { settledChannelId, isLoading: true }; + return { settledChannelId, isLoading: loadingNow }; +} + +/** + * Production coordinator from the messages query state to the channel loading + * latch. Keeping the error guard here prevents a cold terminal failure from + * being recorded as an authoritative empty result before Retry starts. + */ +export function resolveTimelineQueryLoadingState( + settledChannelId: string | null, + activeChannelId: string | null, + status: TimelineQueryLoadingStatus, + hasPersistedHydratedChannel = false, +): { settledChannelId: string | null; isLoading: boolean } { + const hasSettledThisChannel = + activeChannelId !== null && settledChannelId === activeChannelId; + const loadingNow = + status.isEnabled && + selectTimelineLoadingState( + status, + hasSettledThisChannel || hasPersistedHydratedChannel, + ); + + return resolveTimelineLoadingLatch( + settledChannelId, + activeChannelId, + loadingNow, + !status.isError, + ); } diff --git a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs index a8afb823ec..3787f8281a 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs +++ b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs @@ -590,6 +590,30 @@ test("timeline-body-surface: empty only when live and deferred rows are empty", ); }); +test("timeline-body-surface: terminal history failure never paints false-empty", () => { + assert.equal( + selectTimelineBodySurface({ + deferredCount: 0, + isError: true, + isLoading: false, + liveCount: 0, + }), + "error", + ); +}); + +test("timeline-body-surface: cached rows stay visible after a refetch failure", () => { + assert.equal( + selectTimelineBodySurface({ + deferredCount: 2, + isError: true, + isLoading: false, + liveCount: 2, + }), + "list", + ); +}); + test("deferred-snapshot: stale when channel ids diverge during channel switch", () => { assert.equal( isDeferredTimelineSnapshotStale({ diff --git a/desktop/src/features/messages/lib/timelineSnapshot.ts b/desktop/src/features/messages/lib/timelineSnapshot.ts index 4e23fb2245..656e25d1b6 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.ts +++ b/desktop/src/features/messages/lib/timelineSnapshot.ts @@ -181,16 +181,18 @@ export function selectDeferredListRenderState( return "pending"; } -export type TimelineBodySurface = "skeleton" | "empty" | "list"; +export type TimelineBodySurface = "skeleton" | "error" | "empty" | "list"; export function selectTimelineBodySurface({ deferredCount, preserveSettledEmptyIntro = false, + isError = false, isLoading, liveCount, }: { deferredCount: number; preserveSettledEmptyIntro?: boolean; + isError?: boolean; isLoading: boolean; liveCount: number; }): TimelineBodySurface { @@ -199,6 +201,12 @@ export function selectTimelineBodySurface({ } const renderState = selectDeferredListRenderState(deferredCount, liveCount); + if (renderState === "list") { + return "list"; + } + if (isError && liveCount === 0) { + return "error"; + } if (renderState === "pending") { // Preserve a channel/DM intro across a new append only when this channel // already committed an authoritative empty timeline. On first load, the diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index fa8bb4e9f6..88ec8080aa 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -19,6 +19,7 @@ import { TooltipProvider } from "@/shared/ui/tooltip"; import { useCommittedEmptyTimeline } from "./useCommittedEmptyTimeline"; import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill"; import { ChannelIntroBlock, type ChannelIntro } from "./ChannelIntroBlock"; +import { MessageTimelineErrorCard } from "./MessageTimelineErrorCard"; import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton"; import { TimelineMessageList } from "./TimelineMessageList"; import type { TimelineVirtualizerApi } from "./TimelineMessageList"; @@ -52,7 +53,9 @@ type MessageTimelineProps = { displayName: string; participants: DirectMessageIntroParticipant[]; } | null; + isError?: boolean; isLoading?: boolean; + onRetry?: () => void; entranceMessageId?: string | null; onEntranceMessageComplete?: (messageId: string) => void; emptyTitle?: string; @@ -163,7 +166,9 @@ const MessageTimelineBase = React.forwardRef< messages, mainEntries, threadSummaries, + isError = false, isLoading = false, + onRetry, entranceMessageId = null, onEntranceMessageComplete, emptyTitle = "No messages yet", @@ -299,10 +304,12 @@ const MessageTimelineBase = React.forwardRef< const timelineBodySurface = selectTimelineBodySurface({ deferredCount: deferredMessages.length, preserveSettledEmptyIntro, + isError, isLoading: timelineIsLoading, liveCount: messages.length, }); const showTimelineSkeleton = timelineBodySurface === "skeleton"; + const showTimelineError = timelineBodySurface === "error"; const [isSemanticallyAtBottom, setIsSemanticallyAtBottom] = React.useState(true); // biome-ignore lint/correctness/useExhaustiveDependencies: reset semantic tail state when the active channel changes @@ -425,15 +432,17 @@ const MessageTimelineBase = React.forwardRef< [onVirtualizerAtBottomStateChange, queueSemanticBottom], ); - const timelineIntroSurface = selectTimelineIntroSurface({ - hasChannelIntro: channelIntro !== null && directMessageIntro === null, - hasDirectMessageIntro: directMessageIntro !== null, - hasReachedChannelStart: - !isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) && - !isHoldingPrepend && - (messages.length === 0 || (!hasOlderMessages && !isFetchingOlder)), - isSkeletonVisible: showTimelineSkeleton, - }); + const timelineIntroSurface = showTimelineError + ? null + : selectTimelineIntroSurface({ + hasChannelIntro: channelIntro !== null && directMessageIntro === null, + hasDirectMessageIntro: directMessageIntro !== null, + hasReachedChannelStart: + !isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) && + !isHoldingPrepend && + (messages.length === 0 || (!hasOlderMessages && !isFetchingOlder)), + isSkeletonVisible: showTimelineSkeleton, + }); const showDirectMessageIntro = timelineIntroSurface === "direct-message-intro"; const showChannelIntro = timelineIntroSurface === "channel-intro"; @@ -765,7 +774,10 @@ const MessageTimelineBase = React.forwardRef< showChannelIntroOnly ? "pt-[var(--channel-top-chrome-height,4.5rem)]" : channelChrome.contentPadding, - (showIntro || showGenericEmpty || showMessageList) && + (showIntro || + showTimelineError || + showGenericEmpty || + showMessageList) && "min-h-full", )} ref={contentRef} @@ -784,7 +796,8 @@ const MessageTimelineBase = React.forwardRef< className={cn( "flex min-h-[18rem] min-w-0 flex-col gap-2", useTimelineVirtualizer && "min-h-0 flex-1", - (showIntro || showGenericEmpty) && "min-h-full", + (showIntro || showTimelineError || showGenericEmpty) && + "min-h-full", showMessageList && !showIntro && !useTimelineVirtualizer && @@ -794,6 +807,9 @@ const MessageTimelineBase = React.forwardRef< {showTimelineSkeleton ? ( ) : null} + {showTimelineError ? ( + + ) : null} {activeDirectMessageIntro ? (
void; +}) { + return ( +
+

+ Couldn't load messages +

+

+ The channel history didn't load. Check your connection and try + again. +

+ {onRetry ? ( + + ) : null} +
+ ); +} From eed74bde2f4797714335ac10c56c0b0244c1def4 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sat, 29 Aug 2026 19:14:28 -0400 Subject: [PATCH 05/22] feat(buzz-agent): surface stop reason and silent-turn WARN in telemetry (#7038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Two telemetry additions to make a known silent-death failure mode visible in harness logs. **1. `stop` field on `llm: call completed` INFO** (`crates/buzz-agent/src/llm.rs`) The `ProviderStop` value was already parsed and stored on `LlmResponse` but never emitted in the log line. Without it, "model chose `end_turn`" vs "gateway truncated/refused" is indistinguishable from telemetry alone. **2. WARN on silent-turn signature** (`crates/buzz-agent/src/agent.rs`) Emits a `WARN` when a turn produces no publish, no visible assistant text, and either near-zero or absent output tokens. The WARN logic is extracted into `warn_if_silent_turn` (pure synchronous function) so the seam is testable without the async run loop. Three independent gates before the WARN fires: 1. **`!buzz_reply_call_seen`** — no publish attempt in any round, tracked unconditionally via the existing `is_buzz_reply_call` matcher. Read-only tool calls do NOT suppress the WARN; a turn that ran tools but never published and died at 3 tokens is still a silent death. 2. **`text_is_empty`** — no visible assistant text in the final round. A terse reply like "OK" (≤12 tokens, non-empty) is not a silent death. 3. **Token check** — two distinct WARN messages: - `Some(t) where t <= 12`: near-zero token count, the observed failure signature (2–12 tokens) - `None` usage: provider omitted token counts entirely, separately diagnostic Tests use a scoped `tracing_subscriber` layer (same pattern as the existing stall-warn tests in `llm.rs`) to exercise the WARN seam directly: - Canonical signature (no publish, no text, 4 tokens) → 1 WARN - Non-empty assistant text → 0 WARNs - Publish seen → 0 WARNs - `None` usage (no publish, no text) → 1 WARN ## Why Recurring silent-death incident in a specific agent×channel combination: sessions die with 1 LLM call, 2–12 output tokens, no tool calls, no message, no error — recorded as a "successful" turn. The harness log shows the token count but not the `stop_reason`, leaving the root cause undiagnosable without request-level tracing. The observed shape also includes tool-step-then-3-token-death (one tool call, then silence) — the publish-aware gate catches both shapes. Context thread: buzz://message?channel=91fd9ca1-cf04-4ef7-b18f-aa2aee55692b&id=e3f1693f2e29f26a0c840f8054d592270c1504beacdc1d9c2063d8ab82960a06 ## Scope Logging and telemetry only. No behavior change, no retry-logic change, no stop-reason mapping change. --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- crates/buzz-agent/src/agent.rs | 238 ++++++++++++++++++++++++++++++++- crates/buzz-agent/src/llm.rs | 1 + 2 files changed, 238 insertions(+), 1 deletion(-) diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 0743a75667..5125b28074 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -68,6 +68,12 @@ fn replace_unsupported_images(history: &mut [HistoryItem]) -> usize { /// [`Config::require_reply`](crate::config::Config::require_reply). const MAX_REPLY_NAGS: u32 = 2; +/// Output-token upper bound (inclusive) for the silent-death signature: the +/// observed failure emits 2–12 tokens. Turns with `output_tokens <= 12` +/// and no prior tool call are flagged. Legitimate one-sentence replies land +/// well above this value even in the most terse case. +const SILENT_TURN_TOKEN_THRESHOLD: u64 = 12; + /// Server label on the synthetic reply-guard objection. /// /// Not a real MCP server. It rides the same tool-result path as `_Stop` hook @@ -348,6 +354,11 @@ impl RunCtx<'_> { // // Named for what it proves: a *recognized attempt* to publish, not a // successful publish. See `is_buzz_reply_call`. + // Tracks whether a publish-shaped tool call was seen this turn, updated + // unconditionally (not gated on `require_reply`) so the silent-turn + // diagnostic has a turn-level view regardless of config. A turn that + // ran read-only tools and then died at 3 tokens IS a silent death; + // only a genuine publish should suppress the WARN. let mut buzz_reply_call_seen = false; let mut reply_nags = 0u32; // Per-`run()` reactive context-recovery budget. Per-turn, not @@ -696,12 +707,33 @@ impl RunCtx<'_> { "provider: stop=tool_use but zero tool_calls".into(), )); } + // Capture before response.text is moved into history. + let text_is_empty = response.text.trim().is_empty(); self.history.push(HistoryItem::Assistant { text: response.text, tool_calls: Vec::new(), reasoning_details: response.reasoning_details.clone(), }); let stop = map_stop(response.stop); + // Diagnostic: warn when no publish was seen across the whole + // turn, the final response has no visible text, and the + // token count looks silent. Two independent gates: + // 1. `!buzz_reply_call_seen` — no publish attempt in any + // round (read-only tool calls do NOT suppress: a turn + // that ran tools but never published then died at 3 + // tokens is still a silent death). + // 2. `text_is_empty` — model emitted no visible text + // (a terse reply like "OK" is not silent). + // 3. token count or usage-absent check. + // Fires before the `_Stop` hook so the warning appears in + // the log even if the hook rejects the stop and the loop + // continues. Does not alter control flow. + warn_if_silent_turn( + buzz_reply_call_seen, + text_is_empty, + response.output_tokens, + response.stop, + ); // Only gate genuine end_turn — don't override max_tokens/refusal. if stop == StopReason::EndTurn { if stop_rejections >= self.cfg.stop_max_rejections { @@ -746,7 +778,10 @@ impl RunCtx<'_> { } // Deliberately after truncation: a publish-shaped call that was // discarded never runs, so it must not suppress the reminder. - if self.cfg.require_reply && !buzz_reply_call_seen { + // Updated unconditionally (not gated on `require_reply`) so the + // silent-turn diagnostic has a publish-aware turn-level signal + // regardless of config. + if !buzz_reply_call_seen { buzz_reply_call_seen = calls.iter().any(|c| is_buzz_reply_call(c, self.mcp)); } self.history.push(HistoryItem::Assistant { @@ -1286,10 +1321,69 @@ fn map_stop(p: ProviderStop) -> StopReason { } } +/// Returns `true` when a reported output-token count is at or below the +/// silent-death threshold. The observed failure signature is 2–12 tokens. +/// +/// Takes a bare `u64` — the caller handles `None` usage separately (a +/// provider that omits token counts is a distinct diagnostic case, not +/// automatically "near-zero"). +/// +/// Extracted as a pure function so it can be tested without standing up an +/// async agent loop. +fn is_silent_turn(output_tokens: u64) -> bool { + output_tokens <= SILENT_TURN_TOKEN_THRESHOLD +} + +/// Emits the silent-turn diagnostic WARN when the turn produced no publish, +/// no visible text, and either near-zero or absent output tokens. +/// +/// `buzz_reply_call_seen` is the publish-aware gate (from +/// `is_buzz_reply_call`), updated unconditionally regardless of +/// `require_reply`. Read-only tool calls do NOT suppress the WARN — a turn +/// that ran tools but never published and then died at 3 tokens is a silent +/// death. +/// +/// Two distinct WARN shapes: +/// - Near-zero token count (`output_tokens <= 12`): canonical silent-death. +/// - Unknown usage (`None`) with no publish and no text: separately +/// diagnostic; does not assert near-zero since the count is unknown. +/// +/// Extracted as a free function so the WARN seam can be exercised by a +/// scoped tracing subscriber without standing up the full async run loop. +fn warn_if_silent_turn( + buzz_reply_call_seen: bool, + text_is_empty: bool, + output_tokens: Option, + stop: ProviderStop, +) { + if buzz_reply_call_seen || !text_is_empty { + return; + } + match output_tokens { + Some(t) if is_silent_turn(t) => { + tracing::warn!( + stop = ?stop, + output_tokens = t, + "agent: turn ended with no publish attempt and near-zero output tokens — possible silent model/gateway early-stop" + ); + } + None => { + tracing::warn!( + stop = ?stop, + "agent: turn ended with no publish attempt and no usage reported — cannot confirm output size" + ); + } + _ => {} + } +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use tracing_subscriber::layer::SubscriberExt; /// `truncate_history` cannot serve as the context-window fallback: it is /// measured in BYTES (`max_history_bytes`, default 16 MiB, a request-body @@ -1635,4 +1729,146 @@ mod tests { "three identical rounds must remain consistently proven" ); } + + // ── is_silent_turn (pure predicate) ───────────────────────────────────── + + /// Counts WARN events emitted by `warn_if_silent_turn` calls inside `f`. + /// + /// Identifies silent-turn WARNs by target (`buzz_agent::agent`) + WARN + /// level + presence of the `stop` field, which is unique to these two + /// WARNs in this module. Using the target avoids parsing message strings, + /// which are routed through `record_debug` as `Display`-formatted values + /// and are not reliably interceptable via `record_str` across tracing + /// versions. + fn count_silent_turn_warnings(f: impl FnOnce()) -> usize { + struct Capture { + count: Arc, + } + struct Visitor { + saw_stop: bool, + } + impl tracing::field::Visit for Visitor { + fn record_debug(&mut self, field: &tracing::field::Field, _: &dyn std::fmt::Debug) { + if field.name() == "stop" { + self.saw_stop = true; + } + } + } + impl tracing_subscriber::Layer for Capture { + fn on_event( + &self, + event: &tracing::Event<'_>, + _: tracing_subscriber::layer::Context<'_, S>, + ) { + if *event.metadata().level() != tracing::Level::WARN { + return; + } + if event.metadata().target() != "buzz_agent::agent" { + return; + } + let mut v = Visitor { saw_stop: false }; + event.record(&mut v); + if v.saw_stop { + self.count.fetch_add(1, Ordering::SeqCst); + } + } + } + let count = Arc::new(AtomicUsize::new(0)); + let sub = tracing_subscriber::registry().with(Capture { + count: count.clone(), + }); + tracing::subscriber::with_default(sub, f); + count.load(Ordering::SeqCst) + } + + /// Predicate: values within the observed failure range (2–12) fire. + /// Pair (0, 12) catches an always-false mutation and an off-by-one at 12. + #[test] + fn is_silent_turn_fires_at_and_below_threshold() { + assert!( + is_silent_turn(0), + "zero output tokens must be a silent turn" + ); + assert!( + is_silent_turn(SILENT_TURN_TOKEN_THRESHOLD), + "exactly at threshold (12) must be a silent turn — 12 is in the observed range" + ); + } + + /// One above the threshold must NOT fire, catching `<` vs `<=` and + /// always-true mutations. + #[test] + fn is_silent_turn_silent_above_threshold() { + assert!( + !is_silent_turn(SILENT_TURN_TOKEN_THRESHOLD + 1), + "one above threshold (13) must not be a silent turn" + ); + } + + // ── warn_if_silent_turn (WARN seam) ─────────────────────────────────── + + /// The canonical silent-death signature — no publish, no text, ≤12 tokens + /// — must emit exactly one WARN. Deleting the WARN call, weakening the + /// token check, or hardcoding `buzz_reply_call_seen = true` are all caught. + #[test] + fn warn_if_silent_turn_fires_for_canonical_signature() { + let n = count_silent_turn_warnings(|| { + warn_if_silent_turn( + false, // no publish seen + true, // no text + Some(4), // 4 tokens — in the 2–12 range + ProviderStop::EndTurn, + ); + }); + assert_eq!(n, 1, "canonical silent-death must emit exactly one WARN"); + } + + /// A turn that ends with non-empty assistant text is NOT a silent death + /// even if token count is low — a terse reply like "OK" is legitimate. + /// Deleting the `text_is_empty` gate would cause this to fail. + #[test] + fn warn_if_silent_turn_silent_for_nonempty_text() { + let n = count_silent_turn_warnings(|| { + warn_if_silent_turn( + false, // no publish + false, // text IS present + Some(3), // low tokens — would fire without the text gate + ProviderStop::EndTurn, + ); + }); + assert_eq!(n, 0, "a turn with non-empty assistant text must not WARN"); + } + + /// A turn that published (buzz_reply_call_seen = true) then ended with a + /// short final completion must not trigger the WARN. This is the normal + /// publish-then-wrap pattern. Deleting the `buzz_reply_call_seen` gate + /// would cause this to fail. + #[test] + fn warn_if_silent_turn_silent_after_publish() { + let n = count_silent_turn_warnings(|| { + warn_if_silent_turn( + true, // publish seen + true, // no text in final round + Some(0), // zero tokens — would fire without the publish gate + ProviderStop::EndTurn, + ); + }); + assert_eq!(n, 0, "a turn where a publish ran must not WARN"); + } + + /// Unknown usage (None) with no publish and no text emits the distinct + /// "no usage reported" WARN. Mutating the None arm to fall through to + /// `_ => {}` would cause this. + #[test] + fn warn_if_silent_turn_fires_distinct_warn_for_none_usage() { + let n = count_silent_turn_warnings(|| { + warn_if_silent_turn( + false, // no publish + true, // no text + None, // provider omitted usage + ProviderStop::EndTurn, + ); + }); + assert_eq!(n, 1, "unknown-usage silent turn must emit exactly one WARN"); + } } diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 55c85bb0c5..1d46c16e16 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -229,6 +229,7 @@ impl Llm { input_tokens = ?response.input_tokens, cached_input_tokens = ?response.cached_input_tokens, output_tokens = ?response.output_tokens, + stop = ?response.stop, "llm: call completed" ); } From c3132c3ee982d194cd0198ad07b57ec8bd726e4e Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 31 Aug 2026 12:14:35 +1000 Subject: [PATCH 06/22] feat(desktop): use segmented controls for channel creation (#6845) ## Summary - replace the two-option Type and Visibility dropdowns in the Create channel dialog with single-click segmented controls - keep Expires after as a dropdown and preserve the existing dropdown controls in edit and management dialogs - update channel creation end-to-end coverage for the direct controls ## Before Default Ongoing/Public state: ![Create channel before - default Ongoing and Public](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6845/before-default.png) Type dropdown open, showing the extra selection click: ![Create channel before - Type dropdown open](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6845/before-type-dropdown.png) ## After Default Ongoing/Public state: ![Create channel after - default Ongoing and Public](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6845/after-default-reversed.png) Temporary/Private state with the Expires after row visible: ![Create channel after - Temporary and Private](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6845/after-temporary-private-reversed.png) ## Verification - Biome and TypeScript checks pass - 5,508 desktop unit tests pass - 88 channel smoke tests pass - source guards pass --------- Signed-off-by: Matt Toohey Co-authored-by: Claude Fable 5 --- .../ui/ChannelPermissionsSettings.tsx | 121 +++++++++++------- .../channels/ui/ChannelTypeSettings.tsx | 61 +++++++-- .../sidebar/lib/useCreateChannelForm.ts | 6 - .../sidebar/ui/CreateChannelFormFields.tsx | 16 ++- desktop/src/shared/ui/segmented-control.tsx | 4 + desktop/tests/e2e/channels.spec.ts | 47 ++++--- .../welcome-agent-modal-screenshots.spec.ts | 2 - 7 files changed, 160 insertions(+), 97 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx b/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx index eb7ee4739c..8914af2933 100644 --- a/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx +++ b/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx @@ -1,4 +1,4 @@ -import { ChevronDown } from "lucide-react"; +import { ChevronDown, Globe, Lock } from "lucide-react"; import type { ChannelVisibility } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; @@ -10,17 +10,25 @@ import { DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { cn } from "@/shared/lib/cn"; +import { SegmentedControl } from "@/shared/ui/segmented-control"; + +const VISIBILITY_OPTIONS = [ + { value: "private", label: "Private", Icon: Lock }, + { value: "open", label: "Public", Icon: Globe }, +] as const; export function ChannelPermissionsSettings({ disabled, onVisibilityChange, testIdPrefix, visibility, + variant = "dropdown", }: { disabled?: boolean; onVisibilityChange: (visibility: ChannelVisibility) => void; testIdPrefix: string; visibility: ChannelVisibility; + variant?: "dropdown" | "segmented"; }) { const visibilityLabel = visibility === "private" ? "Private" : "Public"; @@ -28,57 +36,76 @@ export function ChannelPermissionsSettings({
- Visibility - - - - - event.preventDefault()} - style={{ - minWidth: "var(--radix-dropdown-menu-trigger-width)", - }} - > - - onVisibilityChange( - nextVisibility === "private" ? "private" : "open", - ) - } - value={visibility} - > - + Visibility + + {variant === "segmented" ? ( + + ) : ( + + + + + event.preventDefault()} + style={{ + minWidth: "var(--radix-dropdown-menu-trigger-width)", + }} + > + + onVisibilityChange( + nextVisibility === "private" ? "private" : "open", + ) + } + value={visibility} > - Private - - - - + + Public + + + Private + + + + + )}
); } diff --git a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx index 82fd1c9f7a..3f7a2d907e 100644 --- a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx +++ b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx @@ -1,4 +1,4 @@ -import { ChevronDown } from "lucide-react"; +import { ChevronDown, ClockFading, Hash } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { @@ -11,6 +11,7 @@ import { } from "@/features/channels/lib/ephemeralChannel"; import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel"; import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -19,9 +20,15 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { SegmentedControl } from "@/shared/ui/segmented-control"; import { EditableInfoFieldRow } from "./ChannelManagementSheetRows"; import { ChannelTypePicker } from "./ChannelTypePicker"; +const CHANNEL_TYPE_OPTIONS = [ + { value: "temporary", label: "Temporary", Icon: ClockFading }, + { value: "ongoing", label: "Ongoing", Icon: Hash }, +] as const; + const EPHEMERAL_TIMEOUT_OPTIONS = [ { label: "30 minutes", seconds: 30 * 60 }, { label: "1 hour", seconds: 60 * 60 }, @@ -76,6 +83,7 @@ export function ChannelTypeSettings({ temporary, testIdPrefix, ttlSeconds, + variant = "dropdown", }: { channelId?: string | null; disabled?: boolean; @@ -87,6 +95,7 @@ export function ChannelTypeSettings({ temporary: boolean; testIdPrefix: string; ttlSeconds: number; + variant?: "dropdown" | "segmented"; }) { const projectHome = useIsProjectHomeChannel(channelId); const lifecycle = channelLifecycle({ projectHome, temporary }); @@ -116,18 +125,39 @@ export function ChannelTypeSettings({ className="flex items-center justify-between gap-3 px-3 py-3" data-testid={`${testIdPrefix}-channel-type-row`} > - {label} - onTemporaryChange(next === "temporary")} - onOpenChange={onOpenChange} - open={open} - testId={`${testIdPrefix}-channel-type`} - /> + + {label} + + {variant === "segmented" ? ( + onTemporaryChange(value === "temporary")} + optionTestIdPrefix={`${testIdPrefix}-channel-type-option`} + options={CHANNEL_TYPE_OPTIONS} + testId={`${testIdPrefix}-channel-type`} + value={temporary ? "temporary" : "ongoing"} + /> + ) : ( + + onTemporaryChange(next === "temporary") + } + onOpenChange={onOpenChange} + open={open} + testId={`${testIdPrefix}-channel-type`} + /> + )}
{temporary && !projectHome ? ( @@ -144,7 +174,10 @@ export function ChannelTypeSettings({ data-testid={`${testIdPrefix}-ephemeral-settings`} >