Skip to content

fix: assemble streamed conversation message chunks into conversation.answer - #371

Closed
OsamaAnsar wants to merge 1 commit into
typesense:masterfrom
OsamaAnsar:fix/conversation-stream-answer-assembly
Closed

OsamaAnsar wants to merge 1 commit into
typesense:masterfrom
OsamaAnsar:fix/conversation-stream-answer-assembly

Conversation

@OsamaAnsar

Copy link
Copy Markdown

Summary

For conversational/RAG search streaming, the server sends the LLM answer token-by-token as {conversation_id, message} chunks, then a final metadata chunk (found/hits/search_time_ms/...) whose conversation.answer is left empty, since the answer was already streamed incrementally.

combineMessageChunks() in src/Typesense/ApiCall.ts logs how many message chunks it found (Found N message chunks to combine), but the messagesChunks parameter is never actually used to build a result — the function just returns the last chunk if it's already a complete search response, or otherwise the first complete-search-response chunk it finds. The streamed tokens are silently dropped, so callers get back conversation.answer === "" even though every token streamed correctly over the wire.

The function's own doc comment says combining chunks "is critical for ensuring we return the complete data rather than just the last chunk" — this fix makes that true for conversation streams too, not just regular search streams.

Fix

combineMessageChunks() now assembles messagesChunks.map(c => c.message).join("") and fills it into the metadata chunk's conversation.answer, but only when that field is still empty — so a non-empty answer the server might already provide is never overwritten.

Test plan

Added three cases to test/Typesense/ApiCall.spec.ts (exercising the private chunk-combination logic directly, since it's pure and has no I/O):

  • Streamed message chunks are assembled into conversation.answer correctly.
  • An already-populated conversation.answer is left untouched.
  • Plain non-conversation streams (no message chunks) are unaffected.

Ran the full ApiCall.spec.ts suite locally (31/31 passing) and eslint on the changed files (clean). Also confirmed the new tests actually catch the regression: reverted only the ApiCall.ts change and reran — the streamed-answer test fails with conversation.answer === "" as described above; restored the fix and it passes again.

…answer

combineMessageChunks() logged how many streamed message chunks it found
but never used them to build anything -- it just returned the last
complete-search-response chunk (or the first one found), whose
`conversation.answer` is deliberately left empty by the server since the
answer was already streamed token-by-token as separate message chunks.
Callers using conversational/RAG streaming search got back an empty
conversation.answer even though every token streamed correctly.

Assemble the message chunks into the answer and fill it in only when the
metadata chunk's conversation.answer is still empty, so a server-provided
answer (if one is ever sent) is never overwritten.

Adds regression tests covering: the streamed-answer assembly, that an
already-populated answer is left untouched, and that plain non-conversation
streams are unaffected.

@tharropoulos tharropoulos left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thank you for contributing! Couple of questions before this goes further. Could you attach a raw stream captured from a real Typesense server on sse? The fix depends on the final chunk having conversation.answer set to an empty string rather than no conversation field at all, and if it's the latter this change wouldn't do anything.

Comment thread src/Typesense/ApiCall.ts
}

if (metadataChunk.conversation && !metadataChunk.conversation.answer) {
metadataChunk.conversation.answer = assembledAnswer;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this mutates the chunk in place, and that same object was already handed to the user in onChunk. anyone holding onto chunks will see the metadata one change after the fact. could we return a copy instead? something like spreading metadataChunk and its conversation into a new object with the assembled answer

Comment thread src/Typesense/ApiCall.ts
// `conversation.answer` is left empty (the answer was already streamed
// incrementally). Assemble it here so callers get the complete answer
// instead of silently getting back an empty string.
const assembledAnswer = messagesChunks

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

processStreamingLine never really fails. anything that isn't a json object comes back as a message chunk with conversation_id unknown, including sse comments, event or id lines, and data: [DONE] if the line ends in \r. before this pr those were ignored but now they end up inside the answer. can we at least only join chunks whose conversation_id matches the one on the metadata chunk?

Comment thread src/Typesense/ApiCall.ts
throw new Error("No metadata chunk found");
}

if (metadataChunk.conversation && !metadataChunk.conversation.answer) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

answer === "" says what you mean more clearly than !answer. also if the final chunk has no conversation field at all this skips and the answer is lost. that's a case that the server shouldn't ever lie in, but always good to have guardrails around this stuff.

// combineStreamingChunks/combineMessageChunks are private: they hold the
// actual chunk-assembly logic, so they're exercised directly here rather
// than through the full Node/browser stream-parsing machinery.
const combineStreamingChunks = (chunks: unknown[]) =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I would avoid casting everything into unknown, this throws type checking off.

expect(result.conversation.answer).toBe("already set");
});

it("still returns the metadata chunk unchanged for non-conversation streams", () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this one has a single chunk, which hits the chunks.length === 1 early return and never reaches combineMessageChunks. it'd need a few non conversation chunks to actually test what the name says

@OsamaAnsar

Copy link
Copy Markdown
Author

You were right to ask for a real capture — I traced the actual server-side implementation (src/conversation_model.cpp, src/core_api.cpp) instead of assuming, and the premise behind this fix doesn't hold up.

Every provider (OpenAI, Cloudflare, vLLM, Gemini, Azure) accumulates the full answer text into async_conversation.response as it streams (async_conversation.response += parsed_response in each provider's async_res_write_callback). That accumulated value is what populates results_json["conversation"]["answer"] in the final SSE chunk, unconditionally. So in real usage the final chunk's conversation.answer already contains the complete, correct answer — it's not empty like this PR assumed.

My original reproduction was a hand-constructed mock stream with answer: "" baked in by hand, not an actual server capture, so it never caught this. Closing this out rather than defend a fix that's likely a no-op against the real server. Thanks for pushing back on it.

@OsamaAnsar OsamaAnsar closed this Sep 25, 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