Skip to content

Support Reasoning Data#12

Open
stickerdaniel wants to merge 3 commits intoget-convex:mainfrom
stickerdaniel:feature/reasoning-support
Open

Support Reasoning Data#12
stickerdaniel wants to merge 3 commits intoget-convex:mainfrom
stickerdaniel:feature/reasoning-support

Conversation

@stickerdaniel
Copy link

@stickerdaniel stickerdaniel commented Nov 18, 2025

Add optional reasoning field to streams, update example to use OpenAI o4-mini, and display reasoning summaries in the UI above the main response in example

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Summary by CodeRabbit

  • New Features
    • Chat responses now display AI reasoning when available, providing insight into the model's thinking process alongside the main response.
    • Enhanced streaming architecture for improved response delivery.

✏️ Tip: You can customize this high-level summary in your review settings.

… o4-mini, and display reasoning summaries in the UI above the main response in example
@stickerdaniel
Copy link
Author

Closes #11

Copy link
Member

@ianmacartney ianmacartney left a comment

Choose a reason for hiding this comment

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

overall I like the approach. There's a bit of a race folks will run into where old clients will be getting JSON from new servers. Ideally they could release the frontend first and have it handle either string or JSON (maybe assume string in the json parse failure branch?) but I think this pushes us forward

model: "gpt-4.1-mini",
messages: [
// o4-mini works best with the Responses API for reasoning
const response = await (openai as any).responses.create({
Copy link
Member

Choose a reason for hiding this comment

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

why is the cast here necessary?

Copy link
Author

Choose a reason for hiding this comment

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

Removed

Comment on lines +171 to +190
const lines = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(new TransformStream<string, string>({
transform(chunk, controller) {
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim()) {
controller.enqueue(line);
}
}
},
flush(controller) {
if (buffer.trim()) {
controller.enqueue(buffer);
}
}
}))
.getReader();
Copy link
Member

Choose a reason for hiding this comment

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

Am I understanding correctly we are changing the behavior here to break up streams by line for everyone? Can you say more about this?

Copy link
Author

Choose a reason for hiding this comment

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

The new protocol sends chunks as newline-delimited JSON {"text":"...","reasoning":"..."}\n to support the reasoning field. I added format detection from the first chunk; If it starts with { we enter json mode and split by newlines and otherwise chunks are passed through directly. This way old servers sending plain text still stream correctly and line splitting happens for new servers

Copilot AI review requested due to automatic review settings December 7, 2025 19:28
@coderabbitai
Copy link

coderabbitai bot commented Dec 7, 2025

Walkthrough

The changes introduce AI reasoning stream support alongside text output across the entire stack. The OpenAI API integration shifts from chat completions to a responses-based model (o4-mini) with reasoning configuration. A new reasoning field is added to the database schema and flows through client streaming, React hooks, and UI components, with separate accumulation and rendering of reasoning and text deltas.

Changes

Cohort / File(s) Summary
Database Schema & Queries
src/component/schema.ts, src/component/lib.ts
Added optional reasoning string field to chunks table. Extended addChunk mutation and getStreamText query to handle and return reasoning alongside text, with concatenation of per-chunk reasoning values.
Client Streaming Layer
src/client/index.ts
Introduced StreamChunk type and reasoning field to StreamBody. Updated ChunkAppender signature to accept structured chunks. Reworked streaming accumulation to normalize and persist both text and reasoning, with JSON-line encoding for chunks.
React Streaming Hook
src/react/index.ts
Updated startStreaming callback signature to receive chunk objects with text and optional reasoning. Implemented JSON-line parsing with plain-text fallback, using TextDecoderStream for chunk reading. Aggregates both text and reasoning separately in state.
UI Component Rendering
example/src/components/ServerMessage.tsx
Updated to destructure reasoning from useStream hook. Added conditional rendering of markdown-formatted reasoning block when present, positioned before text output.
OpenAI API Integration
example/convex/chat.ts
Switched from chat completions to responses.create API with o4-mini model. Updated payload structure to use input with system content and history. Added reasoning configuration. Implemented two-path streaming event handler for reasoning summary and output text deltas with separate accumulation.

Sequence Diagram

sequenceDiagram
    participant User
    participant Client as Client Component
    participant Stream as Streaming Layer
    participant API as OpenAI API<br/>(responses)
    participant Server as Server/DB
    participant UI as ServerMessage<br/>Component

    User->>Client: Request chat with reasoning
    Client->>API: Stream request (o4-mini + reasoning config)
    activate API
    API-->>Stream: Event: reasoning_summary delta
    API-->>Stream: Event: output_text delta
    deactivate API

    activate Stream
    Stream->>Stream: Accumulate text delta
    Stream->>Stream: Accumulate reasoning delta
    Stream-->>Server: Write chunk {text, reasoning?}
    Server->>Server: Insert to chunks table
    Server-->>Stream: Acknowledge
    Stream->>Stream: Normalize & encode JSON chunk
    Stream-->>Client: Emit {text, reasoning?}
    deactivate Stream

    activate Client
    Client->>Client: Parse JSON chunk or plain-text
    Client->>Client: Aggregate text & reasoning separately
    Client-->>UI: Update via useStream hook<br/>{text, reasoning, status}
    deactivate Client

    activate UI
    UI->>UI: Render reasoning block (if present)
    UI->>UI: Render text block
    UI-->>User: Display reasoning + output
    deactivate UI
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • src/client/index.ts: Requires careful review of chunk normalization logic, JSON encoding, and the flow of both text and reasoning through pending state and delta escaping.
  • src/react/index.ts: New JSON-line parsing with plain-text fallback detection—verify correctness of TextDecoderStream usage and chunk aggregation across both streaming modes.
  • example/convex/chat.ts: Validate the two-tiered event streaming logic for reasoning summary vs output text, and ensure proper delta accumulation.
  • Database layer (src/component/schema.ts & lib.ts): Verify schema migration compatibility and reasoning concatenation logic in getStreamText query.
  • Data flow integration: Trace the full path from API → client → server → database → React → UI to confirm reasoning propagates correctly at each step.

Poem

🐰 A rabbit's ode to streaming thought:
Reasoning flows where text once trod,
Two rivers now in harmony,
From AI's mind to thee and me,
Deltas dance, the schema's got
Wisdom whispered, prettily!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Support Reasoning Data' directly summarizes the main objective of the PR, which is to add support for reasoning field across the streaming infrastructure and display it in the UI.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
example/src/components/ServerMessage.tsx (1)

38-41: Consider triggering scroll on reasoning updates too.

The scrollToBottom effect only depends on text changes. If reasoning content arrives before text (which can happen with o4-mini's reasoning-first streaming), users may not see the reasoning appearing until text starts flowing.

   useEffect(() => {
-    if (!text) return;
+    if (!text && !reasoning) return;
     scrollToBottom();
-  }, [text, scrollToBottom]);
+  }, [text, reasoning, scrollToBottom]);
src/react/index.ts (1)

178-179: JSON mode detection may have false positives.

The detection firstChunk.trimStart().startsWith("{") assumes any content starting with { is JSON. If a plain-text stream legitimately starts with { (e.g., code output containing JSON), it would incorrectly enter JSON mode and potentially fail parsing.

Given the controlled server-side implementation in this PR always sends valid JSON lines when reasoning is involved, this is likely acceptable. However, consider documenting this assumption or using a more robust detection mechanism if the streaming format needs to be more flexible in the future.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 26db03d and d0e1a4d.

⛔ Files ignored due to path filters (1)
  • src/component/_generated/component.ts is excluded by !**/_generated/**
📒 Files selected for processing (6)
  • example/convex/chat.ts (1 hunks)
  • example/src/components/ServerMessage.tsx (2 hunks)
  • src/client/index.ts (6 hunks)
  • src/component/lib.ts (4 hunks)
  • src/component/schema.ts (1 hunks)
  • src/react/index.ts (5 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
src/component/lib.ts (1)
src/client/index.ts (1)
  • stream (114-186)
example/src/components/ServerMessage.tsx (1)
src/react/index.ts (1)
  • useStream (27-128)
src/client/index.ts (1)
src/component/schema.ts (1)
  • StreamStatus (11-11)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Agent
🔇 Additional comments (15)
src/component/schema.ts (1)

17-21: LGTM!

The optional reasoning field addition to the chunks table is well-designed. Using v.optional(v.string()) ensures backward compatibility with existing data while supporting the new reasoning feature.

example/src/components/ServerMessage.tsx (2)

20-25: LGTM!

Clean destructuring of the new reasoning field from useStream. The hook signature aligns with the updated StreamBody type.


45-53: LGTM!

The conditional rendering of the reasoning section is well-implemented with appropriate styling to visually separate it from the main response.

src/component/lib.ts (2)

19-42: LGTM!

The addChunk mutation correctly accepts optional reasoning and persists it to the database. The implementation aligns with the schema definition.


96-125: LGTM!

The getStreamText query properly aggregates reasoning from all chunks with appropriate fallback to empty string for chunks without reasoning data. The return type correctly reflects that the aggregated reasoning is always a string (never undefined).

src/react/index.ts (4)

66-79: LGTM!

State management correctly updated to track both text and reasoning, with proper accumulation in the chunk callback.


185-197: LGTM with a note on error handling.

The processBuffer function correctly handles newline-delimited JSON with proper buffer management for partial lines. The fallback to { text: line } on parse failure provides graceful degradation, though it silently swallows malformed JSON which could mask bugs during development.


201-221: LGTM!

The JSON mode streaming loop correctly handles buffer accumulation, final flush of remaining content, and error conditions. The while(true) pattern with explicit returns is a valid approach for stream consumption.


222-237: LGTM!

Plain text mode passthrough is straightforward and maintains backward compatibility with servers that don't send JSON-formatted chunks.

example/convex/chat.ts (1)

24-42: The o4-mini model and Responses API are confirmed as real, available features from OpenAI. The code implementation using openai.responses.create() with the reasoning parameter (effort and summary fields) is consistent with documented API capabilities. No changes needed.

src/client/index.ts (5)

189-202: LGTM!

The helper correctly forwards reasoning to the mutation. The reasoning || undefined conversion on line 199 ensures empty strings aren't unnecessarily stored in the database, which is appropriate since empty reasoning carries no semantic value.


15-22: Well-designed type hierarchy for reasoning support.

The type design properly handles the optionality: StreamChunk allows optional reasoning for flexibility when appending, while StreamBody guarantees a string (defaulting to "") for consumers. This provides a clean API contract.


82-91: LGTM!

The nullish coalescing (reasoning ?? "") correctly handles cases where the backend returns null or undefined, ensuring StreamBody always has a string value for reasoning.


134-161: Logic for accumulating and flushing reasoning looks correct.

The normalization, accumulation, and periodic flushing logic properly handles both string and object chunk formats. Using delimiter detection only on normalized.text is sensible since reasoning content doesn't need sentence-based database persistence triggers.


144-146: Wire format change: stream now emits JSON lines instead of plain text.

Lines 144-146 write JSON-encoded chunks ({"text":"...","reasoning":"..."}) instead of raw text. This is a breaking change for any clients consuming the readable stream directly. Verify that the React client correctly parses this new JSON line format and confirm it handles the format change appropriately.

Comment on lines +44 to +45
let currentReasoning = "";
let currentText = "";
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Remove unused variables.

currentReasoning and currentText are accumulated but never read. They appear to be leftover from an earlier implementation.

-      let currentReasoning = "";
-      let currentText = "";
-
       // Process the streaming response
       for await (const event of response) {
🤖 Prompt for AI Agents
In example/convex/chat.ts around lines 44-45, the variables currentReasoning and
currentText are declared and accumulated but never read; remove these unused
variables and any related accumulation code (appends/concatenations) so only
live variables remain—delete the declarations and search for any assignments or
+= operations tied to them and remove those lines as well.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds support for streaming reasoning data alongside text content, enabling AI models that provide reasoning chains (like OpenAI's o-series models) to display their thought process to users. The architecture maintains backward compatibility by accepting both plain string chunks and objects with text/reasoning fields.

Key changes:

  • Extended the streaming infrastructure to support optional reasoning field alongside text content
  • Updated example to demonstrate reasoning display in the UI, shown above the main response
  • Modified client/server streaming protocol to auto-detect JSON vs plain text format

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/component/schema.ts Added optional reasoning field to chunks table
src/component/lib.ts Extended addChunk mutation and getStreamText query to handle reasoning data
src/component/_generated/component.ts Updated generated TypeScript types to include reasoning field
src/client/index.ts Added StreamChunk union type, modified streaming logic to serialize chunks as JSON, and normalize inputs
src/react/index.ts Implemented format auto-detection (JSON vs text), updated state management to track both text and reasoning
example/src/components/ServerMessage.tsx Added UI rendering for reasoning summary above main response, removed "Thinking..." placeholder
example/convex/chat.ts Migrated from Chat Completions API to hypothetical Responses API with o4-mini model and reasoning support

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

return {
text,
reasoning: reasoning ?? "",
status: status as StreamStatus
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

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

[nitpick] Missing trailing comma. The other properties in this object have trailing commas (lines 87-88), so line 89 should also have one for consistency with the codebase style.

Suggested change
status: status as StreamStatus
status: status as StreamStatus,

Copilot uses AI. Check for mistakes.
reasoning: string;
status: StreamStatus;
};

Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

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

Consider adding a JSDoc comment for the new StreamChunk type to document that it supports both legacy string format and the new object format with optional reasoning field. This would help developers understand the API evolution and migration path.

Suggested change
/**
* Represents a chunk of streamed data.
*
* Supports both the legacy string format and the new object format.
* - Legacy format: `string` (e.g., "Hello world")
* - Object format: `{ text: string; reasoning?: string }`
*
* The object format allows for optional reasoning metadata to be attached to each chunk.
* This dual format supports API evolution: existing code using strings will continue to work,
* while new code can migrate to the richer object format as needed.
*/

Copilot uses AI. Check for mistakes.
Comment on lines +192 to +193
} catch {
onUpdate({ text: line });
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

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

The catch block silently swallows JSON parse errors. Consider logging the parse error and the problematic line to aid debugging when malformed JSON is received from the server.

Copilot uses AI. Check for mistakes.
Comment on lines +209 to +210
} catch {
onUpdate({ text: buffer });
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

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

The catch block silently swallows JSON parse errors. Consider logging the parse error and the problematic buffer content to aid debugging when malformed JSON is received from the server.

Copilot uses AI. Check for mistakes.
</div>
</div>
)}
<Markdown>{text}</Markdown>
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

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

The fallback text "Thinking..." has been removed, but no replacement is provided when text is empty. This means that during the initial "pending" state before any text has streamed, the UI will show an empty <Markdown> component. Consider adding back a loading indicator or placeholder text to provide feedback to users while the stream is starting.

Suggested change
<Markdown>{text}</Markdown>
{isCurrentlyStreaming && !text ? (
<div className="text-gray-400 italic">Thinking...</div>
) : (
<Markdown>{text}</Markdown>
)}

Copilot uses AI. Check for mistakes.
Comment on lines +27 to 36
input: [
{
role: "system",
content: `You are a helpful assistant that can answer questions and help with tasks.
Please provide your response in markdown format.

You are continuing a conversation. The conversation so far is found in the following JSON-formatted value:`,
},
...history,
],
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

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

The parameter input is used instead of messages. The OpenAI Chat Completions API uses messages as the parameter name for the conversation history. If this is intended to use a different/future API, please document which version of the OpenAI SDK supports this interface.

Copilot uses AI. Check for mistakes.
}

const firstChunk = firstRead.value;
const isJsonMode = firstChunk.trimStart().startsWith("{");
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

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

The format detection logic assumes that JSON mode starts with {, but this check is performed on the trimmed first chunk which may not represent the complete first JSON object. If the first chunk is very small (e.g., just {), or if there's whitespace before the JSON, this detection could be unreliable. Consider a more robust detection method, such as checking for a complete JSON object or using a header/flag to indicate the format.

Copilot uses AI. Check for mistakes.
Comment on lines +60 to +66
if (event.type === "response.output_text.delta") {
currentText += event.delta || "";
await append({
text: event.delta || "",
reasoning: "",
});
}
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

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

The event processing loop doesn't handle other event types that might be emitted by the streaming API. If the API emits events other than response.reasoning_summary_text.delta and response.output_text.delta (such as error events, completion events, or other metadata), they will be silently ignored. Consider adding a default case to handle or log unexpected event types for debugging purposes.

Suggested change
if (event.type === "response.output_text.delta") {
currentText += event.delta || "";
await append({
text: event.delta || "",
reasoning: "",
});
}
else if (event.type === "response.output_text.delta") {
currentText += event.delta || "";
await append({
text: event.delta || "",
reasoning: "",
});
}
// Handle unexpected event types
else {
console.warn(
`Unhandled event type in streaming response: ${event.type}`,
event
);
}

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +45
let currentReasoning = "";
let currentText = "";
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

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

These variables currentReasoning and currentText are declared but never used. They accumulate values from the event stream but are not referenced anywhere else in the code. Consider removing them if they're not needed, or use them if they serve a purpose (e.g., for logging or validation).

Copilot uses AI. Check for mistakes.
@ianmacartney ianmacartney requested a review from jamwt January 8, 2026 00:51
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.

3 participants