Skip to content

fix(streaming): render Open WebUI structured output for live and reloaded messages#539

Closed
AutomatorAlex wants to merge 2 commits into
cogwheel0:mainfrom
AutomatorAlex:fix/openwebui-output-streaming-and-reload
Closed

fix(streaming): render Open WebUI structured output for live and reloaded messages#539
AutomatorAlex wants to merge 2 commits into
cogwheel0:mainfrom
AutomatorAlex:fix/openwebui-output-streaming-and-reload

Conversation

@AutomatorAlex

@AutomatorAlex AutomatorAlex commented Jun 30, 2026

Copy link
Copy Markdown

Summary

Recent Open WebUI server update changed chat:completion socket deltas from {content: serialize_output(...)} (a pre-serialized HTML string) to {output: full_output()} (a structured list of items with no content field). Conduit stored output to message metadata but never serialized it to visible text, so:

  1. Live streaming responses stopped appearing in chat.
  2. Leave and return to a chat showed blank agent messages (server persists the same output-only shape).

Changes

  • lib/core/services/openwebui_stream_parser.dart — added serializeOpenWebUIOutput(List<Map<String, dynamic>> output), mirroring upstream serialize_output: message text, reasoning <details> blocks (matching Conduit's existing _buildStreamingReasoningDetails shape so reasoning parsers keep working), function_call/function_call_output pairing by call_id, and code_interpreter blocks.
  • lib/core/services/streaming_helper.dart — in the chat:completion socket handler, when the payload has output but no content/choices, replaceVisibleAssistantContent with the serialized output. Replace (not append) because new deltas are full snapshots, same semantics as the old content path.
  • lib/core/services/conversation_parsing.dart — in _parseOpenWebUIMessageToJson (server→ChatMessage hydration on reload), 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 for parity with the live streaming path.

Root cause reference

Diffing the vendored openwebui-src submodule (HEAD 02dc3e68, 2026-06-01) against upstream dev showed backend/open_webui/utils/middleware.py now emits chat:completion deltas as {output: full_output()} and only sets assistant_message['content'] for legacy Chat Completions deltas (line 2937), not Responses-API models. The server's content = serialize_output(output) rederive only fires on explicit update_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 (updated batches output test asserts content == 'Structured output')
  • flutter test test/core/services/conversation_parsing_test.dart — 38 pass (3 new reload-from-output tests)
  • 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.

Checklist

  • flutter pub get + dart run build_runner build
  • flutter test (targeted suites, 428+ tests green)
  • flutter analyze (changed files, 0 issues)
  • No raw print calls (uses existing DebugLogger conventions)
  • Tests use package:checks / flutter_test per AGENTS.md

Note

Render Open WebUI structured output in assistant messages during streaming and reload

  • Adds serializeOpenWebUIOutput in openwebui_stream_parser.dart to convert Responses-API structured output items (text, reasoning, tool calls, code interpreter) into an HTML string.
  • Updates streaming_helper.dart to call serializeOpenWebUIOutput during live streaming when OpenWebUI sends output snapshots without a content field.
  • Updates conversation_parsing.dart to derive visible content from output when contentString is empty, so reloaded messages also render correctly.
  • Adds test coverage in the conversation_parsing, openwebui_stream_parser, and streaming_helper test suites for the new serialization and fallback paths.

Macroscope summarized 62d292c.

Summary by CodeRabbit

  • New Features

    • Improved support for Open WebUI Responses/API messages by rendering structured assistant output as visible text, including reasoning, tool calls, and final answers.
    • Added richer handling for streamed assistant updates so structured output can appear correctly even when plain text content is missing.
  • Bug Fixes

    • Fixed cases where assistant messages could appear blank despite containing valid structured output.
    • Preserved existing visible message content when it is already present.

Greptile Summary

This PR renders Open WebUI structured output in chat. The main changes are:

  • Adds serialization for message, reasoning, tool call, and code-interpreter output items.
  • Uses serialized output for live chat:completion updates without plain content.
  • Rebuilds visible content from persisted output when reloading empty assistant messages.
  • Adds targeted tests for streaming, reload, and serializer behavior.

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.

T-Rex T-Rex Logs

What T-Rex did

  • Ran the OpenWebUI serializer test; the before-run log showed base could not resolve serializeOpenWebUIOutput and exit code 254, and the after-run log showed exit code 0 with serialized content for all four scenarios, including reasoning details, paired tool_calls, and code_interpreter blocks.
  • Attempted the Flutter/Dart-based tests for the base and head states, but the environment lacked a Flutter runtime, so commands exited with code 127 and no visible assistant content.
  • Attempted the reload-output tests for base and head paths; both ran into the same environment blocker and exited with code 127.

View all artifacts

T-Rex Ran code and verified through T-Rex

Reviews (2): Last reviewed commit: "test(parsing): cover history-backed relo..." | Re-trigger Greptile

…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>
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds serializeOpenWebUIOutput to openwebui_stream_parser.dart, which converts structured OpenWebUI Responses-API output items (message, reasoning, function_call, code_interpreter) into HTML. This serializer is wired into conversation_parsing.dart to populate content when it is empty, and into streaming_helper.dart for chat:completion deltas that carry output without choices. Tests cover all new paths.

Changes

OpenWebUI Responses-API Output Serialization

Layer / File(s) Summary
serializeOpenWebUIOutput implementation
lib/core/services/openwebui_stream_parser.dart
Adds _outputHtmlEscape constant and implements serializeOpenWebUIOutput, which indexes function_call_output by call_id then iterates output items to emit HTML blocks for each type, with escaping throughout. Formatting-only refactor to _eventUpdateFromMap data map construction.
Conversation parsing: output fallback
lib/core/services/conversation_parsing.dart
Adds import for openwebui_stream_parser.dart, normalizes msgData/historyMsg output via new _normalizeOutputItems, serializes to contentString when content is empty using serializeOpenWebUIOutput, and includes output field in returned JSON.
Streaming helper: chat:completion output branch
lib/core/services/streaming_helper.dart
Adds a compatibility branch in the chat:completion handler: when normalizedOutputItems is non-empty and the delta lacks choices, serializes output and replaces visible assistant content. Also refactors image content-type extraction and URL construction, rewrites applyAssistantServerPatch with output/files/embeds deep-copy and comprehensive equality check, refactors pollServerForMessage server message selection, stability signature construction, taskSocket byteStream controller close-on-done, and numerous formatting-only changes.
Tests
test/core/services/openwebui_stream_parser_test.dart, test/core/services/conversation_parsing_test.dart, test/core/services/streaming_helper_transport_test.dart
Adds serializeOpenWebUIOutput test group covering all output item types. Adds Responses-API fallback tests in conversation parsing. Adds transport test assertion that lastMessage.content is set from serialized output. Reformats existing stream parser test assertions.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 Hop hop, the output flows,
From structured JSON to HTML rows,
Reasoning wrapped in <details> tight,
Function calls paired just right,
Empty content? No fear, no frown—
The rabbit serializes it down! 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: rendering Open WebUI structured output for live updates and reloaded conversations.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified 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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Fall back to output when content is present but empty.

Line 2640 gates on containsKey('content'); a delta with content: '' or content: null plus non-empty output skips 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

📥 Commits

Reviewing files that changed from the base of the PR and between 6acb512 and 1736df3.

📒 Files selected for processing (6)
  • lib/core/services/conversation_parsing.dart
  • lib/core/services/openwebui_stream_parser.dart
  • lib/core/services/streaming_helper.dart
  • test/core/services/conversation_parsing_test.dart
  • test/core/services/openwebui_stream_parser_test.dart
  • test/core/services/streaming_helper_transport_test.dart

Comment on lines +246 to +255
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
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.

Comment on lines +276 to +289
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'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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.

Comment on lines +552 to +643
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');
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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.

Comment on lines +371 to +447
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('&gt; 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('');
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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('&lt;b&gt;Hello &amp; &quot;bye&quot;&lt;/b&gt;');
+    check(result).contains('&quot;&lt;tag&gt;&quot;');
+  });
📝 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.

Suggested change
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('&gt; 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('&gt; 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('&lt;b&gt;Hello &amp; &quot;bye&quot;&lt;/b&gt;');
check(result).contains('&quot;&lt;tag&gt;&quot;');
});
});
🤖 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.

Comment on lines +825 to +827
// 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');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security 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)

Comment on lines +309 to +310
final resultParts = <String>[];
final resultOutput = resultItem['output'];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Tool Attributes Break Markup

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```';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security 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)

Comment on lines +278 to +279
final isLastItem = index == output.length - 1;
final done = status == 'completed' || duration != null || !isLastItem;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.
@cogwheel0 cogwheel0 closed this Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants