fix(mobile): hide structured assistant citations in conversation display

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-21 20:08:27 -07:00
parent 347ce88d5d
commit e87745de5a
8 changed files with 287 additions and 2 deletions

View File

@@ -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,
),

View File

@@ -346,6 +346,7 @@ class _MessageBubble extends HookConsumerWidget {
animate: typingHandoff,
child: MessageContent(
key: swipeContentKey,
isAssistant: isAgent,
content: message.content,
bubble: true,
hasReactions:

View File

@@ -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;

View File

@@ -312,6 +312,7 @@ class _ThreadMessage extends HookConsumerWidget {
),
MessageContent(
key: swipeContentKey,
isAssistant: isAgent,
content: message.content,
bubble: true,
hasReactions:

View File

@@ -0,0 +1,66 @@
// Recognize the provider's envelope, not arbitrary XML or angle-bracket text.
final _citationBlock = RegExp(
r' {0,3}<oai-mem-citation>\s*'
r'<citation_entries>[^<>]*</citation_entries>\s*'
r'(?:<rollout_ids>[^<>]*</rollout_ids>\s*)?'
r'</oai-mem-citation>[ \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('<oai-mem-citation>')) 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();
}

View File

@@ -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<GptMarkdown>(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![Status]($image)';
await tester.pumpWidget(
host(const MessageContent(content: source, isAssistant: true)),
);
final text = tester
.widgetList<RichText>(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<GptMarkdown>(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<GptMarkdown>(find.byType(GptMarkdown)).data;
expect(data.contains('oai-mem-citation'), !agent);
}
});
}

View File

@@ -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 =
'<oai-mem-citation>\n<citation_entries>MEMORY.md:1-2|note=[test]</citation_entries>\n</oai-mem-citation>';
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<MessageContent>(
find.byType(MessageContent).first,
);
expect(content.isAssistant, agent);
expect(content.content, contains(metadata));
final text = tester
.widgetList<RichText>(
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 {

View File

@@ -0,0 +1,65 @@
import 'package:buzz/shared/markdown/assistant_citations.dart';
import 'package:flutter_test/flutter_test.dart';
const citation = '''<oai-mem-citation>
<citation_entries>
MEMORY.md:34-34|note=[Pinned mobile toolchain guidance verified locally]
</citation_entries>
<rollout_ids>
</rollout_ids>
</oai-mem-citation>''';
void main() {
test('removes metadata while preserving all surrounding bytes', () {
const image = '![STATUS\\_MENU.png](<https://example.test/image.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('<rollout_ids>\n', '<rollout_ids>\nfirst\nsecond\n'),
'<oai-mem-citation> <citation_entries>other</citation_entries> </oai-mem-citation>',
' $citation',
]) {
expect(assistantDisplayContent('$block\nAnswer'), 'Answer');
}
});
final literals = <String, String>{
'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 <oai-mem-citation>.',
'unknown envelope': '<oai-mem-citation>Useful prose</oai-mem-citation>',
'incomplete': citation.replaceAll('</oai-mem-citation>', ''),
'unknown inner tag': citation.replaceAll('citation_entries', 'example'),
'ordinary HTML': '<details>Useful prose</details>',
'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 = '<oai-mem-citation>\n' * 10000;
expect(assistantDisplayContent(malformed), malformed);
});
}