diff --git a/mobile/lib/features/channels/agent_activity/transcript_item_widget.dart b/mobile/lib/features/channels/agent_activity/transcript_item_widget.dart
index 68339c9180..195a6912d1 100644
--- a/mobile/lib/features/channels/agent_activity/transcript_item_widget.dart
+++ b/mobile/lib/features/channels/agent_activity/transcript_item_widget.dart
@@ -1,4 +1,5 @@
import 'dart:convert';
+import '../../../shared/markdown/assistant_citations.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -34,6 +35,7 @@ class _MessageItemWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isAssistant = item.role == 'assistant';
+ final text = isAssistant ? assistantDisplayContent(item.text) : item.text;
final badgeColor = isAssistant
? context.colors.primary
: context.colors.onSurfaceVariant;
@@ -66,9 +68,9 @@ class _MessageItemWidget extends StatelessWidget {
],
),
const SizedBox(height: Grid.half),
- if (item.text.isNotEmpty)
+ if (text.isNotEmpty)
GptMarkdown(
- item.text,
+ text,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurface,
),
diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart
index 3f8ec5442a..c0ce9a3ba2 100644
--- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart
+++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart
@@ -346,6 +346,7 @@ class _MessageBubble extends HookConsumerWidget {
animate: typingHandoff,
child: MessageContent(
key: swipeContentKey,
+ isAssistant: isAgent,
content: message.content,
bubble: true,
hasReactions:
diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart
index 1c7d4df483..49e44883b9 100644
--- a/mobile/lib/features/channels/message_content.dart
+++ b/mobile/lib/features/channels/message_content.dart
@@ -1,4 +1,5 @@
import 'dart:async';
+import '../../shared/markdown/assistant_citations.dart';
import '../../shared/widgets/ios_media_carousel.dart';
import '../../shared/widgets/message_attachment_corners.dart';
import 'dart:io';
@@ -95,6 +96,9 @@ String _safeDownloadedFilename(String filename) {
class MessageContent extends HookConsumerWidget {
final String content;
+ /// Whether the sender is a known agent; enables display-only metadata hiding.
+ final bool isAssistant;
+
/// Paint conversation text bubbles while keeping attachments on the page.
final bool bubble;
@@ -172,6 +176,7 @@ class MessageContent extends HookConsumerWidget {
const MessageContent({
super.key,
required this.content,
+ this.isAssistant = false,
this.bubble = false,
this.ownMessage = false,
this.trimTrailingSpacing = false,
@@ -197,6 +202,10 @@ class MessageContent extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final content = useMemoized(
+ () => isAssistant ? assistantDisplayContent(this.content) : this.content,
+ [this.content, isAssistant],
+ );
final markdownTheme = GptMarkdownTheme.of(context);
final chatColors = ChatColors.of(context);
final ownTextColor = chatColors.foreground;
diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart
index 89bf0ef415..27be228564 100644
--- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart
+++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart
@@ -312,6 +312,7 @@ class _ThreadMessage extends HookConsumerWidget {
),
MessageContent(
key: swipeContentKey,
+ isAssistant: isAgent,
content: message.content,
bubble: true,
hasReactions:
diff --git a/mobile/lib/shared/markdown/assistant_citations.dart b/mobile/lib/shared/markdown/assistant_citations.dart
new file mode 100644
index 0000000000..676fa2053d
--- /dev/null
+++ b/mobile/lib/shared/markdown/assistant_citations.dart
@@ -0,0 +1,66 @@
+// Recognize the provider's envelope, not arbitrary XML or angle-bracket text.
+final _citationBlock = RegExp(
+ r' {0,3}\s*'
+ r'[^<>]*\s*'
+ r'(?:[^<>]*\s*)?'
+ r'[ \t]*(?:\r?\n|$)',
+);
+final _fence = RegExp(r'^ {0,3}(`{3,}|~{3,})(.*)$');
+final _ticks = RegExp(r'`+');
+
+/// Hides complete provider memory citations in assistant prose for display only.
+///
+/// The caller must establish assistant authorship. Signed content is untouched.
+/// Standalone envelopes are recognized regardless of payload or position, while
+/// literal code, quotes, indented examples, and incomplete/unknown markup stay.
+String assistantDisplayContent(String content) {
+ if (!content.contains('')) return content;
+ final output = StringBuffer();
+ var offset = 0;
+ String? fenceCharacter;
+ var fenceLength = 0;
+ var inlineLength = 0;
+ while (offset < content.length) {
+ final newline = content.indexOf('\n', offset);
+ final end = newline < 0 ? content.length : newline + 1;
+ final line = content.substring(offset, end);
+ final fence = _fence.firstMatch(line.trimRight());
+ if (fenceCharacter != null) {
+ if (fence != null &&
+ fence[1]![0] == fenceCharacter &&
+ fence[1]!.length >= fenceLength &&
+ fence[2]!.trim().isEmpty) {
+ fenceCharacter = null;
+ }
+ } else if (inlineLength == 0 && fence != null) {
+ fenceCharacter = fence[1]![0];
+ fenceLength = fence[1]!.length;
+ } else {
+ if (inlineLength == 0) {
+ final citation = _citationBlock.matchAsPrefix(content, offset);
+ if (citation != null) {
+ offset = citation.end;
+ continue;
+ }
+ }
+ // Keep multiline inline-code examples literal too. Conservatively retain
+ // the rest after an unmatched backtick rather than hide possible code.
+ for (final tick in _ticks.allMatches(line)) {
+ var slashes = 0;
+ for (var i = tick.start - 1; i >= 0 && line[i] == r'\'; i--) {
+ slashes++;
+ }
+ if (inlineLength == 0 && slashes.isOdd) continue;
+ final length = tick.end - tick.start;
+ if (inlineLength == 0) {
+ inlineLength = length;
+ } else if (inlineLength == length) {
+ inlineLength = 0;
+ }
+ }
+ }
+ output.write(line);
+ offset = end;
+ }
+ return output.toString();
+}
diff --git a/mobile/test/features/channels/assistant_citation_rendering_test.dart b/mobile/test/features/channels/assistant_citation_rendering_test.dart
new file mode 100644
index 0000000000..58563aa506
--- /dev/null
+++ b/mobile/test/features/channels/assistant_citation_rendering_test.dart
@@ -0,0 +1,83 @@
+import 'package:buzz/features/channels/message_content.dart';
+import 'package:buzz/features/channels/agent_activity/transcript_item_widget.dart';
+import 'package:buzz/features/channels/agent_activity/observer_models.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';
+import '../../shared/markdown/assistant_citations_test.dart' show citation;
+
+Widget host(Widget child) => ProviderScope(
+ child: MaterialApp(
+ theme: AppTheme.light(),
+ home: AppMarkdownTheme(
+ child: Scaffold(body: SingleChildScrollView(child: child)),
+ ),
+ ),
+);
+
+void main() {
+ testWidgets('activity filters assistant text only and leaves source intact', (
+ tester,
+ ) async {
+ for (final role in ['assistant', 'user']) {
+ final item = MessageItem(
+ id: 'message',
+ role: role,
+ title: 'Message',
+ text: 'Answer\n$citation',
+ timestamp: '2026-10-21T22:32:42Z',
+ );
+ await tester.pumpWidget(host(TranscriptItemWidget(item: item)));
+ final data = tester.widget(find.byType(GptMarkdown)).data;
+ expect(data.contains('oai-mem-citation'), role == 'user');
+ expect(item.text, 'Answer\n$citation');
+ }
+ });
+
+ testWidgets(
+ 'agent reply hides metadata and retains surrounding text and attachment',
+ (tester) async {
+ const image = 'https://example.test/status.png';
+ const source =
+ 'Online, Away, and Offline now use filled dots.\n\n$citation\n';
+ await tester.pumpWidget(
+ host(const MessageContent(content: source, isAssistant: true)),
+ );
+ final text = tester
+ .widgetList(find.byType(RichText))
+ .map((w) => w.text.toPlainText())
+ .join('\n');
+ expect(text, contains('Online, Away, and Offline now use filled dots.'));
+ expect(text, isNot(contains('oai-mem-citation')));
+ expect(text, isNot(contains('MEMORY.md')));
+ expect(
+ find.byKey(const ValueKey('message-media-image-preview:$image')),
+ findsOneWidget,
+ );
+ },
+ );
+ testWidgets('human and code example content reaches markdown unchanged', (
+ tester,
+ ) async {
+ for (final agent in [false, true]) {
+ final source = agent ? '```xml\n$citation\n```' : citation;
+ await tester.pumpWidget(
+ host(MessageContent(content: source, isAssistant: agent)),
+ );
+ expect(tester.widget(find.byType(GptMarkdown)).data, source);
+ }
+ });
+ testWidgets('new content and author classification recompute display', (
+ tester,
+ ) async {
+ for (final agent in [false, true, false]) {
+ await tester.pumpWidget(
+ host(MessageContent(content: 'Answer\n$citation', isAssistant: agent)),
+ );
+ final data = tester.widget(find.byType(GptMarkdown)).data;
+ expect(data.contains('oai-mem-citation'), !agent);
+ }
+ });
+}
diff --git a/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart
index de4bc69616..5deb786490 100644
--- a/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart
+++ b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart
@@ -215,6 +215,64 @@ void bubbleLayoutTests() {
expect(tester.takeException(), isNull);
});
+ for (final surface in ['channel', 'solo', 'thread']) {
+ for (final agent in [false, true]) {
+ testWidgets(
+ 'citation metadata on $surface uses agent=$agent classification',
+ (tester) async {
+ const metadata =
+ '\nMEMORY.md:1-2|note=[test]\n';
+ final events = [
+ _textMsg(
+ id: 'citation-message',
+ pubkey: 'alice',
+ content: 'Visible answer\n\n$metadata',
+ createdAt: 1000,
+ ),
+ ];
+ final messages = formatTimeline(events);
+ await tester.pumpWidget(
+ _buildTestable(
+ messages: events,
+ personalChat: surface == 'solo',
+ knownAgentPubkeys: agent ? {'alice'} : {},
+ users: const {
+ 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
+ },
+ home: surface == 'thread'
+ ? ThreadDetailPage(
+ threadHead: messages.single,
+ allMessages: messages,
+ channelId: _channelId,
+ currentPubkey: 'me',
+ isMember: true,
+ isArchived: false,
+ )
+ : null,
+ ),
+ );
+ await tester.pumpAndSettle();
+ final content = tester.widget(
+ find.byType(MessageContent).first,
+ );
+ expect(content.isAssistant, agent);
+ expect(content.content, contains(metadata));
+ final text = tester
+ .widgetList(
+ find.descendant(
+ of: find.byType(MessageContent).first,
+ matching: find.byType(RichText),
+ ),
+ )
+ .map((w) => w.text.toPlainText())
+ .join('\n');
+ expect(text, contains('Visible answer'));
+ expect(text.contains('oai-mem-citation'), !agent);
+ },
+ );
+ }
+ }
+
testWidgets('large activity members build and fetch only visible rows', (
tester,
) async {
diff --git a/mobile/test/shared/markdown/assistant_citations_test.dart b/mobile/test/shared/markdown/assistant_citations_test.dart
new file mode 100644
index 0000000000..0ebd7ed2ee
--- /dev/null
+++ b/mobile/test/shared/markdown/assistant_citations_test.dart
@@ -0,0 +1,65 @@
+import 'package:buzz/shared/markdown/assistant_citations.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+const citation = '''
+
+MEMORY.md:34-34|note=[Pinned mobile toolchain guidance verified locally]
+
+
+
+''';
+
+void main() {
+ test('removes metadata while preserving all surrounding bytes', () {
+ const image = '![STATUS\\_MENU.png]()';
+ const source = 'Reply.\n\n$citation\n$image';
+ expect(assistantDisplayContent(source), 'Reply.\n\n$image');
+ expect(assistantDisplayContent('$citation\nAnswer\n$citation'), 'Answer\n');
+ });
+ test('accepts varied payloads, whitespace and optional rollout ids', () {
+ for (final block in [
+ citation.replaceAll('\n', '\r\n'),
+ citation
+ .replaceAll('MEMORY.md:34-34', 'memory/other.md:1-99')
+ .replaceAll('\n', '\nfirst\nsecond\n'),
+ ' other ',
+ ' $citation',
+ ]) {
+ expect(assistantDisplayContent('$block\nAnswer'), 'Answer');
+ }
+ });
+ final literals = {
+ 'inline': '`$citation`',
+ 'double backticks': '``$citation``',
+ 'backtick fence': '```xml\n$citation\n```',
+ 'tilde fence': '~~~xml\n$citation\n~~~',
+ 'long fence': '````\n```\n$citation\n````',
+ 'unclosed fence': '```xml\n$citation',
+ 'unclosed inline': '`example\n$citation',
+ 'indented code': citation.split('\n').map((l) => ' $l').join('\n'),
+ 'quoted example': citation.split('\n').map((l) => '> $l').join('\n'),
+ 'escaped': '\\$citation',
+ 'literal mention': 'The tag is .',
+ 'unknown envelope': 'Useful prose',
+ 'incomplete': citation.replaceAll('', ''),
+ 'unknown inner tag': citation.replaceAll('citation_entries', 'example'),
+ 'ordinary HTML': 'Useful prose ',
+ 'same line prose': 'Explain $citation please',
+ };
+ for (final entry in literals.entries) {
+ test('preserves ${entry.key}', () {
+ expect(assistantDisplayContent(entry.value), entry.value);
+ });
+ }
+ test('resumes filtering after code closes', () {
+ final example = '~~~xml\n$citation\n~~~\n';
+ expect(
+ assistantDisplayContent('$example$citation\nAnswer'),
+ '${example}Answer',
+ );
+ });
+ test('does not repeatedly scan nested malformed openers', () {
+ final malformed = '\n' * 10000;
+ expect(assistantDisplayContent(malformed), malformed);
+ });
+}