Skip to content

fix: parse array content in streamed chat responses - #458

Open
dtump wants to merge 3 commits into
nextcloud:mainfrom
dtump:fix/streamed-array-content
Open

dtump wants to merge 3 commits into
nextcloud:mainfrom
dtump:fix/streamed-array-content

Conversation

@dtump

@dtump dtump commented Nov 1, 2026 •

Copy link
Copy Markdown

Summary

The stream parser reads delta.content only when it is a string. Mistral sends content as a list of typed chunks. Each chunk has a type, for example text, thinking, or reference. The parser skips the list, so the answer text is lost. The task completes without an error and the Assistant shows no text.

Upstream PR #409 fixed the same problem for non-streamed responses. Version 5.0.0 added the streamed path, and that path still has the problem.

How to reproduce it

Use the Mistral API at https://api.eu.mistral.ai/v1 with the model mistral-large-4-0. Start a streamed core:text2text:chatwithtools task with Context Agent tools. Set reasoning_effort to low, medium, or high.

The server sends each chunk with "content":[{"type":"text","text":"..."}]. Some chunks also have thinking parts. The response has status 200 and finish_reason: stop. The assistant message is empty.

Change

The stream parser now accepts an array for delta.content. It appends only the text parts, and it skips thinking and reference parts. An empty array does not hide the message content. This is the same behavior as the non-streamed path. Streamed reasoning text stays a separate change.

The non-streamed path in createChatCompletion() had its own copy of the chunk-to-text loop. The helper now lives in lib/Service/ContentChunkParser.php as a static method, so the streamed and non-streamed paths call the same code.

Test results

I added these tests to tests/unit/Service/StreamingServiceTest.php:

  • testTypedContentChunksAreAssembledAndStreamed: thinking parts before the text parts, with one-chunk and multi-chunk deltas. It checks the streamed fragments and the final choice content.
  • testStringDeltasContinueAfterATypedContentChunk: a typed first chunk followed by string chunks.
  • testEmptyContentArrayFallsBackToMessageContent: an empty array must not hide the message content.

I also added tests/unit/Service/ContentChunkParserTest.php. It checks that text chunks are concatenated, that thinking, reference and malformed chunks are skipped, and that an empty list returns an empty string.

All 10 tests in the two files pass. php -l and php-cs-fixer pass on the changed files, and psalm reports no issues in them.

I tested the change on a live instance. The instance runs Nextcloud 35.0.2 with integration_openai 5.0.0 and the model mistral-large-4-0 through Context Agent. Before the change, the same Assistant request returned no text. After the change, the Assistant returns the answer and it creates the calendar event that the request asks for.

Related

#408 and #409 cover the same problem for non-streamed responses.

AI assistance

I used an AI assistant (Claude Code / DeepSeek V4.1 Flash) to analyze the response format and the parser, and to help review the patch. I ran the live test myself.

@edward-ly

edward-ly commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Thanks for the tip, although I am not yet able to reproduce the issue you are speaking of. Could you provide more details regarding your setup and reproducible steps? And does the issue still occur with the app on the main branch instead of v5.0.0?

@dtump

dtump commented Nov 4, 2026

Copy link
Copy Markdown
Author

Hi @edward-ly, I have not actively tested, but I expect it's still present on main. I compared v5.0.0, which I run, with main at d024974 (version 6.0.0). The main branch has many changes since 5.0.0, but parseSseEvent() still checks is_string($choice['delta']['content']) and skips array content, so the bug is there too.

My setup:

  • Nextcloud 35.0.2, integration_openai 5.0.0.
  • Provider: Mistral, base URL https://api.eu.mistral.ai/v1.
  • Model: mistral-large-4-0.
  • Extra parameters: {"reasoning_effort":"low"} in the Assistant provider settings. medium and high reproduce it too.
  • Streaming enabled.
  • Task type core:text2text:chatwithtools, with the Context Agent tools enabled (about ten tools). A plain core:text2text task sends a different request, so use a task with tools.

With my setup a simple Assistant request such as add X to my calendar shows no answer without the patch, and shows the answer with it. The task ends without an error. The same request against a provider that sends string content works either way.

To be clear, it's Mistral specific. I think they changed something when Mistral Large 4 went out of preview, because I had the same problem in a script I created once (which also uses Mistral).

@edward-ly

Copy link
Copy Markdown
Contributor

Ah right, I was using the core:text2text:multimodal-chatwithtools task the whole time. Although, with core:text2text:chatwithtools before this patch, I'm only seeing the first chunk of stream content missing, but it's still a bug worth fixing regardless.

Comment thread lib/Service/StreamingService.php Resolved
Comment thread lib/Service/StreamingService.php Resolved
@dtump

dtump commented Nov 8, 2026

Copy link
Copy Markdown
Author

@edward-ly thanks for your review! Team me + AI agent addressed both.

One remark from my AI agent which I'm just going to copy-paste, because it's no use to rewrite this in my own words:

About the duplicate code: OpenAiAPIService already uses StreamingService; it calls parseStreamChatResponse(). If the function moves to OpenAiAPIService and StreamingService calls it, the dependency becomes circular and the container cannot build it. I kept the function on StreamingService and made it public. createChatCompletion() now calls it. This also removes the unchecked array access in the old loop.

One behavior change after a review pass: the shared function skips a chunk that is not an array. The old loop read $content['type'] directly and threw a TypeError on a string chunk. I think the tolerant behavior is better, so I agreed with my review AI agent there. Concrete example: content: [{"type":"text","text":"Hello"}, "oops"] (old code returned "Hello" then crashed on "oops"; new code returns "Hello")

Also added tests.

@edward-ly

Copy link
Copy Markdown
Contributor

One remark from my AI agent which I'm just going to copy-paste, because it's no use to rewrite this in my own words:

Please rewrite the AI output in your own words anyway before I re-review, as it is our AI policy. This is just to make sure that you understand what it is saying before submitting its work to us.

@dtump

dtump commented Nov 9, 2026

Copy link
Copy Markdown
Author

One remark from my AI agent which I'm just going to copy-paste, because it's no use to rewrite this in my own words:

Please rewrite the AI output in your own words anyway before I re-review, as it is our AI policy. This is just to make sure that you understand what it is saying before submitting its work to us.

I'm aware of the policy, but rewriting it just felt as re-ordering the words from the AI agent. I understand your reasoning, so in my own words:

Monsieur Claude Code and I did not want to create a circular dependency by moving the helper to OpenAiAPIService, because then OpenAiAPIService calls StreamingService, which in turn calls the helper in OpenAiAPIService. Instead, I kept the helper on StreamingService and made it public, so createChatCompletion() now calls it.

@edward-ly

Copy link
Copy Markdown
Contributor

Monsieur Claude Code and I did not want to create a circular dependency by moving the helper to OpenAiAPIService, because then OpenAiAPIService calls StreamingService, which in turn calls the helper in OpenAiAPIService. Instead, I kept the helper on StreamingService and made it public, so createChatCompletion() now calls it.

I appreciate your understanding, and I see what you're trying to say. How about moving the helper into its own utility class and making it static? I'm not sure if it makes sense anymore for it to live in either class tbh.

Also, before pushing again, please rebase the branch rather than add any excessive merge commits.


Also also, FYI I'll be taking a long weekend off from today until Tuesday, but I'll ask someone else on the team to finish the review in the meantime so that you hopefully won't have to wait too much longer.

Mistral streams content as a list of typed chunks. The SSE parser only accepted string content, so answer text was dropped silently.

Assisted-by: ClaudeCode:deepseek-v4.1-flash
Signed-off-by: Dick Tump <dick@tump.me>
Check that a streamed content array is not empty before parsing it, and
reuse the same chunk-to-text extraction in createChatCompletion() instead
of a duplicate loop.

Assisted-by: ClaudeCode:deepseek-v4.1-flash
Signed-off-by: Dick Tump <dick@tump.me>
Move contentChunksToText() out of StreamingService so the streamed and
non-streamed paths do not depend on each other for it. Both now call
ContentChunkParser::contentChunksToText(). Add a direct unit test for
the parser.

Assisted-by: ClaudeCode:deepseek-v4.1-flash
Signed-off-by: Dick Tump <dick@tump.me>
@dtump
dtump force-pushed the fix/streamed-array-content branch from c9ada72 to 865b6ee Compare November 15, 2026 15:19
@dtump
dtump requested a review from kyteinsky as a code owner November 15, 2026 15:19
@dtump

dtump commented Nov 15, 2026

Copy link
Copy Markdown
Author

Monsieur Claude Code and I did not want to create a circular dependency by moving the helper to OpenAiAPIService, because then OpenAiAPIService calls StreamingService, which in turn calls the helper in OpenAiAPIService. Instead, I kept the helper on StreamingService and made it public, so createChatCompletion() now calls it.

I appreciate your understanding, and I see what you're trying to say. How about moving the helper into its own utility class and making it static? I'm not sure if it makes sense anymore for it to live in either class tbh.

Also, before pushing again, please rebase the branch rather than add any excessive merge commits.

Also also, FYI I'll be taking a long weekend off from today until Tuesday, but I'll ask someone else on the team to finish the review in the meantime so that you hopefully won't have to wait too much longer.

I think that's a good solution and more durable, so I moved the helper into its own utility class and made it static.

And I rebased the branch so there's no merge commit.

I was a bit late myself (busy at work), so no hurry. Enjoy your weekend off (please apply the 'enjoy' retroactively if you read it when you're back 😄)

This branch has not been deployed

No deployments
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