fix(streaming): render Open WebUI structured output for live and reloaded messages#539
Conversation
…aded messages
Summary:
- Add serializeOpenWebUIOutput to openwebui_stream_parser.dart, mirroring
upstream serialize_output: message text, reasoning <details>, function_call
paired with function_call_output by call_id, and code_interpreter blocks.
- In streaming_helper.dart chat:completion socket handler, when the payload
has output but no content/choices, replaceVisibleAssistantContent with the
serialized output so live streaming responses render again.
- In conversation_parsing.dart _parseOpenWebUIMessageToJson, derive content
from the persisted output list when content is empty (Responses-API models
store only output server-side) and preserve output on the returned map so
reloaded conversations render agent messages after leaving and returning.
Rationale:
- Recent Open WebUI server update changed chat:completion socket deltas from
{content: serialize_output(...)} to {output: full_output()} with no content
string. Conduit stored output to metadata but never serialized it to
visible text, so streaming responses stopped appearing. The same output-
only shape is persisted server-side, so reloaded messages were also blank
until the structured output was serialized client-side on hydration.
Tests:
- flutter test test/core/services/openwebui_stream_parser_test.dart
- flutter test test/core/services/streaming_helper_transport_test.dart
- flutter test test/core/services/conversation_parsing_test.dart
- flutter analyze (changed files, no issues)
- Verified on Pixel 10 Pro XL against an updated Open WebUI server: live
streaming and leave-and-return reload both render.
Co-authored-by: Codex <codex@openai.com>
📝 WalkthroughWalkthroughAdds ChangesOpenWebUI Responses-API Output Serialization
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches✨ Simplify code
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
lib/core/services/streaming_helper.dart (1)
2640-2656: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winFall back to
outputwhencontentis present but empty.Line 2640 gates on
containsKey('content'); a delta withcontent: ''orcontent: nullplus non-emptyoutputskips the new serializer branch and leaves the assistant message blank.Proposed fix
- if (completionTargetId != null && payload.containsKey('content')) { - final raw = payload['content']?.toString() ?? ''; - if (raw.isNotEmpty) { - replaceVisibleAssistantContent(raw); - } + final rawContent = payload['content']?.toString() ?? ''; + if (completionTargetId != null && rawContent.isNotEmpty) { + replaceVisibleAssistantContent(rawContent); } else if (completionTargetId != null && normalizedOutputItems.isNotEmpty && !payload.containsKey('choices')) {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/core/services/streaming_helper.dart` around lines 2640 - 2656, The streaming delta handling in streaming_helper.dart should fall back to serializing normalizedOutputItems when payload['content'] is present but empty or null, not just when the content key is absent. Update the chat:completion branch around replaceVisibleAssistantContent and serializeOpenWebUIOutput so it treats empty content like missing content, while still preferring non-empty content when available.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/core/services/openwebui_stream_parser.dart`:
- Around line 276-289: Escape all dynamic values before interpolating them into
the HTML produced by the openwebui stream parser: in the reasoning/details
rendering logic that builds the <details> tags, sanitize duration, call_id, and
name before placing them in attributes or summary text. Update the relevant
helper path in openwebui_stream_parser.dart so the code that assembles parts for
the stream output uses escaped/attribute-safe values rather than raw item
fields, and apply the same fix anywhere else in the parser that emits these
dynamic attributes.
- Around line 246-255: Preserve multi-part message text in
openwebui_stream_parser.dart by updating the message handling in the parser
logic around the itemType == 'message' branch. The current per-part trim and
per-part append in the content list loop splits one message into separate output
blocks and drops intentional spacing, so change the aggregation in the relevant
parsing path (for example the loop that builds parts) to concatenate text parts
into a single message string while preserving their original order and internal
spacing before adding it to the final output.
In `@test/core/services/conversation_parsing_test.dart`:
- Around line 552-643: The new Responses API fallback tests in
parseFullConversation only cover chat.messages and miss the history-backed
reload path. Add an equivalent fixture using chat.history.currentId and
chat.history.messages to verify that output-derived content still populates
content when reloading a conversation. Reuse the existing fallback assertions
around messages.first['content'] and locate the changes in the Responses API
output fallback group and parseFullConversation test cases.
In `@test/core/services/openwebui_stream_parser_test.dart`:
- Around line 371-447: Add an escaping regression test to
serializeOpenWebUIOutput that verifies server-controlled content is HTML-escaped
before rendering. Use the existing serializeOpenWebUIOutput group to assert that
message/reasoning/tool payloads containing <, &, and quotes are emitted as
escaped text rather than raw HTML, so the escaping contract stays pinned.
In `@test/core/services/streaming_helper_transport_test.dart`:
- Around line 825-827: The test in streaming_helper_transport_test should verify
replacement semantics for the chat:completion.output path, not only that the
final assistant text equals a serialized output. Update the fixture around the
lastMessage/assertion to seed existing assistant content first, then assert that
the helper replaces it (or validate replacedContents) so the output handling in
the relevant streaming helper branch is covered. Use the existing test helper
and lastMessage/replacedContents symbols to locate the assertion.
---
Outside diff comments:
In `@lib/core/services/streaming_helper.dart`:
- Around line 2640-2656: The streaming delta handling in streaming_helper.dart
should fall back to serializing normalizedOutputItems when payload['content'] is
present but empty or null, not just when the content key is absent. Update the
chat:completion branch around replaceVisibleAssistantContent and
serializeOpenWebUIOutput so it treats empty content like missing content, while
still preferring non-empty content when available.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: d69352bf-8c4f-4d78-b1e0-8bbc80675331
📒 Files selected for processing (6)
lib/core/services/conversation_parsing.dartlib/core/services/openwebui_stream_parser.dartlib/core/services/streaming_helper.darttest/core/services/conversation_parsing_test.darttest/core/services/openwebui_stream_parser_test.darttest/core/services/streaming_helper_transport_test.dart
| if (itemType == 'message') { | ||
| final content = item['content']; | ||
| if (content is List) { | ||
| for (final part in content) { | ||
| if (part is Map) { | ||
| final partType = part['type']?.toString(); | ||
| if (partType == 'text' || partType == 'output_text') { | ||
| final text = part['text']?.toString().trim() ?? ''; | ||
| if (text.isNotEmpty) { | ||
| parts.add(text); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Preserve multi-part message text when serializing message output.
Line 253 trims each part and Line 255 appends each part as a separate output block, so ['Hello ', 'world'] becomes Hello\nworld instead of the documented concatenation.
Proposed fix
if (itemType == 'message') {
final content = item['content'];
if (content is List) {
+ final messageParts = <String>[];
for (final part in content) {
if (part is Map) {
final partType = part['type']?.toString();
if (partType == 'text' || partType == 'output_text') {
- final text = part['text']?.toString().trim() ?? '';
+ final text = part['text']?.toString() ?? '';
if (text.isNotEmpty) {
- parts.add(text);
+ messageParts.add(text);
}
}
}
}
+ final messageText = messageParts.join();
+ if (messageText.trim().isNotEmpty) {
+ parts.add(messageText);
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (itemType == 'message') { | |
| final content = item['content']; | |
| if (content is List) { | |
| for (final part in content) { | |
| if (part is Map) { | |
| final partType = part['type']?.toString(); | |
| if (partType == 'text' || partType == 'output_text') { | |
| final text = part['text']?.toString().trim() ?? ''; | |
| if (text.isNotEmpty) { | |
| parts.add(text); | |
| if (itemType == 'message') { | |
| final content = item['content']; | |
| if (content is List) { | |
| final messageParts = <String>[]; | |
| for (final part in content) { | |
| if (part is Map) { | |
| final partType = part['type']?.toString(); | |
| if (partType == 'text' || partType == 'output_text') { | |
| final text = part['text']?.toString() ?? ''; | |
| if (text.isNotEmpty) { | |
| messageParts.add(text); | |
| } | |
| } | |
| } | |
| } | |
| final messageText = messageParts.join(); | |
| if (messageText.trim().isNotEmpty) { | |
| parts.add(messageText); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/core/services/openwebui_stream_parser.dart` around lines 246 - 255,
Preserve multi-part message text in openwebui_stream_parser.dart by updating the
message handling in the parser logic around the itemType == 'message' branch.
The current per-part trim and per-part append in the content list loop splits
one message into separate output blocks and drops intentional spacing, so change
the aggregation in the relevant parsing path (for example the loop that builds
parts) to concatenate text parts into a single message string while preserving
their original order and internal spacing before adding it to the final output.
| final duration = item['duration']?.toString(); | ||
| final status = item['status']?.toString(); | ||
| final isLastItem = index == output.length - 1; | ||
| final done = status == 'completed' || duration != null || !isLastItem; | ||
| final escapedDisplay = _outputHtmlEscape.convert( | ||
| LineSplitter.split(reasoningContent) | ||
| .map((line) => line.startsWith('>') ? line : '> $line') | ||
| .join('\n'), | ||
| ); | ||
| if (done) { | ||
| final dur = duration ?? '0'; | ||
| parts.add( | ||
| '<details type="reasoning" done="true" duration="$dur">\n' | ||
| '<summary>Thought for $dur seconds</summary>\n' |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Escape dynamic values before inserting them into HTML attributes.
duration, call_id, and name are interpolated raw into generated <details> attributes. Values containing quotes or markup can break the attribute and inject HTML into rendered chat content.
Proposed fix
if (done) {
- final dur = duration ?? '0';
+ final dur = _outputHtmlEscape.convert(duration ?? '0');
parts.add(
'<details type="reasoning" done="true" duration="$dur">\n' } else if (itemType == 'function_call') {
final callId = item['call_id']?.toString() ?? '';
+ final escapedCallId = _outputHtmlEscape.convert(callId);
final name = item['name']?.toString() ?? '';
+ final escapedName = _outputHtmlEscape.convert(name);
final arguments = item['arguments'] ?? '';
final escapedArgs = _outputHtmlEscape.convert(jsonEncode(arguments));
@@
parts.add(
- '<details type="tool_calls" done="true" id="$callId" name="$name" arguments="$escapedArgs" files="$escapedFiles" embeds="$escapedEmbeds">\n'
+ '<details type="tool_calls" done="true" id="$escapedCallId" name="$escapedName" arguments="$escapedArgs" files="$escapedFiles" embeds="$escapedEmbeds">\n'
'<summary>Tool Executed</summary>\n'
'$escapedResult\n'
'</details>',
@@
parts.add(
- '<details type="tool_calls" done="false" id="$callId" name="$name" arguments="$escapedArgs">\n'
+ '<details type="tool_calls" done="false" id="$escapedCallId" name="$escapedName" arguments="$escapedArgs">\n'
'<summary>Executing...</summary>\n'
'</details>', if (done) {
- final dur = duration ?? '0';
+ final dur = _outputHtmlEscape.convert(duration ?? '0');
parts.add(
'<details type="code_interpreter" done="true" duration="$dur"$outputAttr>\n'Also applies to: 303-344, 351-381
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/core/services/openwebui_stream_parser.dart` around lines 276 - 289,
Escape all dynamic values before interpolating them into the HTML produced by
the openwebui stream parser: in the reasoning/details rendering logic that
builds the <details> tags, sanitize duration, call_id, and name before placing
them in attributes or summary text. Update the relevant helper path in
openwebui_stream_parser.dart so the code that assembles parts for the stream
output uses escaped/attribute-safe values rather than raw item fields, and apply
the same fix anywhere else in the parser that emits these dynamic attributes.
| group('Responses API output fallback', () { | ||
| test('derives content from output when content is empty', () { | ||
| final result = parseFullConversation({ | ||
| 'chat': { | ||
| 'messages': [ | ||
| { | ||
| 'id': 'msg-1', | ||
| 'role': 'assistant', | ||
| 'content': '', | ||
| 'output': [ | ||
| { | ||
| 'type': 'message', | ||
| 'role': 'assistant', | ||
| 'content': [ | ||
| {'type': 'output_text', 'text': 'Hello from output'}, | ||
| ], | ||
| }, | ||
| ], | ||
| 'timestamp': 1700000000, | ||
| }, | ||
| ], | ||
| }, | ||
| }); | ||
|
|
||
| final messages = result['messages'] as List<Map<String, dynamic>>; | ||
| check(messages.first['content']).equals('Hello from output'); | ||
| check(messages.first['output']).isNotNull(); | ||
| }); | ||
|
|
||
| test('keeps existing content when populated', () { | ||
| final result = parseFullConversation({ | ||
| 'chat': { | ||
| 'messages': [ | ||
| { | ||
| 'id': 'msg-1', | ||
| 'role': 'assistant', | ||
| 'content': 'Existing content', | ||
| 'output': [ | ||
| { | ||
| 'type': 'message', | ||
| 'content': [ | ||
| {'type': 'output_text', 'text': 'should not win'}, | ||
| ], | ||
| }, | ||
| ], | ||
| 'timestamp': 1700000000, | ||
| }, | ||
| ], | ||
| }, | ||
| }); | ||
|
|
||
| final messages = result['messages'] as List<Map<String, dynamic>>; | ||
| check(messages.first['content']).equals('Existing content'); | ||
| }); | ||
|
|
||
| test('derives reasoning and message text from output', () { | ||
| final result = parseFullConversation({ | ||
| 'chat': { | ||
| 'messages': [ | ||
| { | ||
| 'id': 'msg-1', | ||
| 'role': 'assistant', | ||
| 'content': '', | ||
| 'output': [ | ||
| { | ||
| 'type': 'reasoning', | ||
| 'summary': [ | ||
| {'type': 'summary_text', 'text': 'Thinking hard'}, | ||
| ], | ||
| 'duration': 2, | ||
| 'status': 'completed', | ||
| }, | ||
| { | ||
| 'type': 'message', | ||
| 'content': [ | ||
| {'type': 'output_text', 'text': 'Final answer'}, | ||
| ], | ||
| }, | ||
| ], | ||
| 'timestamp': 1700000000, | ||
| }, | ||
| ], | ||
| }, | ||
| }); | ||
|
|
||
| final messages = result['messages'] as List<Map<String, dynamic>>; | ||
| final content = messages.first['content'] as String; | ||
| check(content).contains('<details type="reasoning" done="true"'); | ||
| check(content).contains('Final answer'); | ||
| }); | ||
| }); | ||
|
|
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Cover the history-backed reload path as well.
parseFullConversation(...) can source messages from chat.messages and from chat.history.messages, but this new fallback group only exercises the list form. Since the bug description explicitly includes reloading conversations, add the same output -> content assertion for a history.currentId/messages fixture so the reload path is pinned too.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test/core/services/conversation_parsing_test.dart` around lines 552 - 643,
The new Responses API fallback tests in parseFullConversation only cover
chat.messages and miss the history-backed reload path. Add an equivalent fixture
using chat.history.currentId and chat.history.messages to verify that
output-derived content still populates content when reloading a conversation.
Reuse the existing fallback assertions around messages.first['content'] and
locate the changes in the Responses API output fallback group and
parseFullConversation test cases.
| group('serializeOpenWebUIOutput', () { | ||
| test('renders message output_text as visible content', () { | ||
| final result = serializeOpenWebUIOutput([ | ||
| { | ||
| 'type': 'message', | ||
| 'role': 'assistant', | ||
| 'content': [ | ||
| {'type': 'output_text', 'text': 'Hello world'}, | ||
| ], | ||
| }, | ||
| ]); | ||
| check(result).equals('Hello world'); | ||
| }); | ||
|
|
||
| test('renders reasoning as a done details block', () { | ||
| final result = serializeOpenWebUIOutput([ | ||
| { | ||
| 'type': 'reasoning', | ||
| 'summary': [ | ||
| {'type': 'summary_text', 'text': 'Because 2+2=4'}, | ||
| ], | ||
| 'duration': 3, | ||
| 'status': 'completed', | ||
| }, | ||
| { | ||
| 'type': 'message', | ||
| 'content': [ | ||
| {'type': 'output_text', 'text': '4'}, | ||
| ], | ||
| }, | ||
| ]); | ||
| check(result) | ||
| .contains('<details type="reasoning" done="true" duration="3">'); | ||
| check(result).contains('Thought for 3 seconds'); | ||
| check(result).contains('> Because 2+2=4'); | ||
| check(result).endsWith('4'); | ||
| }); | ||
|
|
||
| test('pairs function_call with function_call_output by call_id', () { | ||
| final result = serializeOpenWebUIOutput([ | ||
| { | ||
| 'type': 'function_call', | ||
| 'call_id': 'call_1', | ||
| 'name': 'get_weather', | ||
| 'arguments': '{"city":"Tampa"}', | ||
| }, | ||
| { | ||
| 'type': 'function_call_output', | ||
| 'call_id': 'call_1', | ||
| 'output': [ | ||
| {'text': 'Sunny, 90F'}, | ||
| ], | ||
| }, | ||
| ]); | ||
| check(result).contains('<details type="tool_calls" done="true"'); | ||
| check(result).contains('name="get_weather"'); | ||
| check(result).contains('Tool Executed'); | ||
| check(result).contains('Sunny, 90F'); | ||
| }); | ||
|
|
||
| test('renders pending function_call without output', () { | ||
| final result = serializeOpenWebUIOutput([ | ||
| { | ||
| 'type': 'function_call', | ||
| 'call_id': 'call_2', | ||
| 'name': 'search', | ||
| 'arguments': '{}', | ||
| }, | ||
| ]); | ||
| check(result).contains('<details type="tool_calls" done="false"'); | ||
| check(result).contains('Executing...'); | ||
| }); | ||
|
|
||
| test('returns empty string for empty output', () { | ||
| check(serializeOpenWebUIOutput(const [])).equals(''); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Add an escaping regression case for server-controlled HTML.
This new group pins the rendering shape, but it never asserts that <, &, or quotes are escaped. serializeOpenWebUIOutput(...) is turning untrusted server payloads into HTML, so the escaping contract is the XSS boundary and should be locked down here too.
Example test to pin the escaping contract
test('returns empty string for empty output', () {
check(serializeOpenWebUIOutput(const [])).equals('');
});
+
+ test('escapes HTML-sensitive characters in rendered output', () {
+ final result = serializeOpenWebUIOutput([
+ {
+ 'type': 'message',
+ 'content': [
+ {'type': 'output_text', 'text': '<b>Hello & "bye"</b>'},
+ ],
+ },
+ {
+ 'type': 'function_call',
+ 'call_id': 'call_1',
+ 'name': 'tool',
+ 'arguments': '{"x":"<tag>"}',
+ },
+ ]);
+
+ check(result).contains('<b>Hello & "bye"</b>');
+ check(result).contains('"<tag>"');
+ });📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| group('serializeOpenWebUIOutput', () { | |
| test('renders message output_text as visible content', () { | |
| final result = serializeOpenWebUIOutput([ | |
| { | |
| 'type': 'message', | |
| 'role': 'assistant', | |
| 'content': [ | |
| {'type': 'output_text', 'text': 'Hello world'}, | |
| ], | |
| }, | |
| ]); | |
| check(result).equals('Hello world'); | |
| }); | |
| test('renders reasoning as a done details block', () { | |
| final result = serializeOpenWebUIOutput([ | |
| { | |
| 'type': 'reasoning', | |
| 'summary': [ | |
| {'type': 'summary_text', 'text': 'Because 2+2=4'}, | |
| ], | |
| 'duration': 3, | |
| 'status': 'completed', | |
| }, | |
| { | |
| 'type': 'message', | |
| 'content': [ | |
| {'type': 'output_text', 'text': '4'}, | |
| ], | |
| }, | |
| ]); | |
| check(result) | |
| .contains('<details type="reasoning" done="true" duration="3">'); | |
| check(result).contains('Thought for 3 seconds'); | |
| check(result).contains('> Because 2+2=4'); | |
| check(result).endsWith('4'); | |
| }); | |
| test('pairs function_call with function_call_output by call_id', () { | |
| final result = serializeOpenWebUIOutput([ | |
| { | |
| 'type': 'function_call', | |
| 'call_id': 'call_1', | |
| 'name': 'get_weather', | |
| 'arguments': '{"city":"Tampa"}', | |
| }, | |
| { | |
| 'type': 'function_call_output', | |
| 'call_id': 'call_1', | |
| 'output': [ | |
| {'text': 'Sunny, 90F'}, | |
| ], | |
| }, | |
| ]); | |
| check(result).contains('<details type="tool_calls" done="true"'); | |
| check(result).contains('name="get_weather"'); | |
| check(result).contains('Tool Executed'); | |
| check(result).contains('Sunny, 90F'); | |
| }); | |
| test('renders pending function_call without output', () { | |
| final result = serializeOpenWebUIOutput([ | |
| { | |
| 'type': 'function_call', | |
| 'call_id': 'call_2', | |
| 'name': 'search', | |
| 'arguments': '{}', | |
| }, | |
| ]); | |
| check(result).contains('<details type="tool_calls" done="false"'); | |
| check(result).contains('Executing...'); | |
| }); | |
| test('returns empty string for empty output', () { | |
| check(serializeOpenWebUIOutput(const [])).equals(''); | |
| }); | |
| }); | |
| group('serializeOpenWebUIOutput', () { | |
| test('renders message output_text as visible content', () { | |
| final result = serializeOpenWebUIOutput([ | |
| { | |
| 'type': 'message', | |
| 'role': 'assistant', | |
| 'content': [ | |
| {'type': 'output_text', 'text': 'Hello world'}, | |
| ], | |
| }, | |
| ]); | |
| check(result).equals('Hello world'); | |
| }); | |
| test('renders reasoning as a done details block', () { | |
| final result = serializeOpenWebUIOutput([ | |
| { | |
| 'type': 'reasoning', | |
| 'summary': [ | |
| {'type': 'summary_text', 'text': 'Because 2+2=4'}, | |
| ], | |
| 'duration': 3, | |
| 'status': 'completed', | |
| }, | |
| { | |
| 'type': 'message', | |
| 'content': [ | |
| {'type': 'output_text', 'text': '4'}, | |
| ], | |
| }, | |
| ]); | |
| check(result) | |
| .contains('<details type="reasoning" done="true" duration="3">'); | |
| check(result).contains('Thought for 3 seconds'); | |
| check(result).contains('> Because 2+2=4'); | |
| check(result).endsWith('4'); | |
| }); | |
| test('pairs function_call with function_call_output by call_id', () { | |
| final result = serializeOpenWebUIOutput([ | |
| { | |
| 'type': 'function_call', | |
| 'call_id': 'call_1', | |
| 'name': 'get_weather', | |
| 'arguments': '{"city":"Tampa"}', | |
| }, | |
| { | |
| 'type': 'function_call_output', | |
| 'call_id': 'call_1', | |
| 'output': [ | |
| {'text': 'Sunny, 90F'}, | |
| ], | |
| }, | |
| ]); | |
| check(result).contains('<details type="tool_calls" done="true"'); | |
| check(result).contains('name="get_weather"'); | |
| check(result).contains('Tool Executed'); | |
| check(result).contains('Sunny, 90F'); | |
| }); | |
| test('renders pending function_call without output', () { | |
| final result = serializeOpenWebUIOutput([ | |
| { | |
| 'type': 'function_call', | |
| 'call_id': 'call_2', | |
| 'name': 'search', | |
| 'arguments': '{}', | |
| }, | |
| ]); | |
| check(result).contains('<details type="tool_calls" done="false"'); | |
| check(result).contains('Executing...'); | |
| }); | |
| test('returns empty string for empty output', () { | |
| check(serializeOpenWebUIOutput(const [])).equals(''); | |
| }); | |
| test('escapes HTML-sensitive characters in rendered output', () { | |
| final result = serializeOpenWebUIOutput([ | |
| { | |
| 'type': 'message', | |
| 'content': [ | |
| {'type': 'output_text', 'text': '<b>Hello & "bye"</b>'}, | |
| ], | |
| }, | |
| { | |
| 'type': 'function_call', | |
| 'call_id': 'call_1', | |
| 'name': 'tool', | |
| 'arguments': '{"x":"<tag>"}', | |
| }, | |
| ]); | |
| check(result).contains('<b>Hello & "bye"</b>'); | |
| check(result).contains('"<tag>"'); | |
| }); | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test/core/services/openwebui_stream_parser_test.dart` around lines 371 - 447,
Add an escaping regression test to serializeOpenWebUIOutput that verifies
server-controlled content is HTML-escaped before rendering. Use the existing
serializeOpenWebUIOutput group to assert that message/reasoning/tool payloads
containing <, &, and quotes are emitted as escaped text rather than raw HTML, so
the escaping contract stays pinned.
| // Newer Open WebUI servers emit `output` without a `content` string; | ||
| // the helper must serialize the output so the assistant text renders. | ||
| check(lastMessage.content).equals('Structured output'); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Assert replacement semantics, not just the final string.
This proves output was serialized, but not that the helper replaced visible assistant content. If the implementation accidentally appended to existing content, this fixture would still pass. Seed the assistant with prior text and assert the old content is gone (or assert against replacedContents) so the snapshot behavior from the new chat:completion.output branch is actually covered.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test/core/services/streaming_helper_transport_test.dart` around lines 825 -
827, The test in streaming_helper_transport_test should verify replacement
semantics for the chat:completion.output path, not only that the final assistant
text equals a serialized output. Update the fixture around the
lastMessage/assertion to seed existing assistant content first, then assert that
the helper replaces it (or validate replacedContents) so the output handling in
the relevant streaming helper branch is covered. Use the existing test helper
and lastMessage/replacedContents symbols to locate the assertion.
| if (partType == 'text' || partType == 'output_text') { | ||
| final text = part['text']?.toString().trim() ?? ''; | ||
| if (text.isNotEmpty) { | ||
| parts.add(text); |
There was a problem hiding this comment.
Message Text Becomes Control Markup
When a structured message item contains literal <details> or </details> text, this branch inserts it directly into the chat content. The live and reload paths then feed that text to the existing details parsers, so ordinary assistant text can be treated as reasoning/tool/code markup and disappear or corrupt nearby rendered content.
Context Used: AGENTS.md (source)
| final resultParts = <String>[]; | ||
| final resultOutput = resultItem['output']; |
There was a problem hiding this comment.
call_id and name are placed into HTML attributes without escaping, while the same tag already escapes arguments, files, and embeds. If Open WebUI or a tool name includes a quote, >, or &, the generated <details> tag becomes malformed and the tool-call parser can read the wrong attributes or miss the block entirely.
Context Used: AGENTS.md (source)
| !isLastItem; | ||
| final code = item['code']?.toString() ?? ''; | ||
| final lang = item['language']?.toString() ?? ''; | ||
| final display = code.isEmpty ? '' : '```$lang\n$code\n```'; |
There was a problem hiding this comment.
Interpreter Code Closes Details
Code interpreter source is written raw inside a <details type="code_interpreter"> body. A valid snippet that contains </details> closes the wrapper early, so the rest of the code is rendered outside the code-interpreter block or parsed as unrelated reasoning/tool markup.
Context Used: AGENTS.md (source)
| final isLastItem = index == output.length - 1; | ||
| final done = status == 'completed' || duration != null || !isLastItem; |
There was a problem hiding this comment.
In-Progress Reasoning Looks Complete
A reasoning item with status: "in_progress" is marked done whenever another output item follows it. During live output snapshots that include partial reasoning followed by partial message text, the UI switches from “Thinking…” to a completed “Thought for 0 seconds” block before the reasoning is complete.
Context Used: AGENTS.md (source)
The Responses-API output->content fallback only had coverage for the chat.messages list form. Reloaded conversations source messages from chat.history.messages (a Map) following the currentId chain, which is the actual leave-and-return scenario. Add a fixture pinning that output- derived content populates on that path too.
Summary
Recent Open WebUI server update changed
chat:completionsocket deltas from{content: serialize_output(...)}(a pre-serialized HTML string) to{output: full_output()}(a structured list of items with nocontentfield). Conduit storedoutputto message metadata but never serialized it to visible text, so:Changes
lib/core/services/openwebui_stream_parser.dart— addedserializeOpenWebUIOutput(List<Map<String, dynamic>> output), mirroring upstreamserialize_output: message text, reasoning<details>blocks (matching Conduit's existing_buildStreamingReasoningDetailsshape so reasoning parsers keep working),function_call/function_call_outputpairing bycall_id, andcode_interpreterblocks.lib/core/services/streaming_helper.dart— in thechat:completionsocket handler, when the payload hasoutputbut nocontent/choices,replaceVisibleAssistantContentwith the serialized output. Replace (not append) because new deltas are full snapshots, same semantics as the oldcontentpath.lib/core/services/conversation_parsing.dart— in_parseOpenWebUIMessageToJson(server→ChatMessage hydration on reload), derivecontentfrom the persistedoutputlist whencontentis empty (Responses-API models store onlyoutputserver-side) and preserveoutputon the returned map for parity with the live streaming path.Root cause reference
Diffing the vendored
openwebui-srcsubmodule (HEAD02dc3e68, 2026-06-01) against upstreamdevshowedbackend/open_webui/utils/middleware.pynow emitschat:completiondeltas as{output: full_output()}and only setsassistant_message['content']for legacy Chat Completions deltas (line 2937), not Responses-API models. The server'scontent = serialize_output(output)rederive only fires on explicitupdate_chat_by_id, not on the streaming-completion save path.Tests
flutter test test/core/services/openwebui_stream_parser_test.dart— 22 pass (5 new serializer tests)flutter test test/core/services/streaming_helper_transport_test.dart— 56 pass (updatedbatches outputtest assertscontent == 'Structured output')flutter test test/core/services/conversation_parsing_test.dart— 38 pass (3 new reload-from-output tests)flutter analyze(changed files) — no issuesChecklist
flutter pub get+dart run build_runner buildflutter test(targeted suites, 428+ tests green)flutter analyze(changed files, 0 issues)printcalls (uses existingDebugLoggerconventions)package:checks/flutter_testper AGENTS.mdNote
Render Open WebUI structured
outputin assistant messages during streaming and reloadserializeOpenWebUIOutputinopenwebui_stream_parser.dartto convert Responses-API structuredoutputitems (text, reasoning, tool calls, code interpreter) into an HTML string.streaming_helper.dartto callserializeOpenWebUIOutputduring live streaming when OpenWebUI sendsoutputsnapshots without acontentfield.conversation_parsing.dartto derive visible content fromoutputwhencontentStringis empty, so reloaded messages also render correctly.conversation_parsing,openwebui_stream_parser, andstreaming_helpertest suites for the new serialization and fallback paths.Macroscope summarized 62d292c.
Summary by CodeRabbit
New Features
Bug Fixes
Greptile Summary
This PR renders Open WebUI structured output in chat. The main changes are:
chat:completionupdates without plaincontent.outputwhen reloading empty assistant messages.Confidence Score: 5/5
The changes are focused on Open WebUI structured-output rendering and are covered by targeted parser, streaming, and reload tests.
No code issues were identified in the reviewed changes, and the implementation includes tests for the key live-streaming and persisted-message scenarios described by the patch.
What T-Rex did
Reviews (2): Last reviewed commit: "test(parsing): cover history-backed relo..." | Re-trigger Greptile