fix(ui): render reasoning in chat instead of raw think tags

The chat UI streams via the Responses API but only called onReasoningContent for response.reasoning.delta, an event only its own non-streaming helper synthesizes; the proxy streams response.reasoning_summary_text.delta, so streamed reasoning was dropped and the reasoning panel never appeared. It also never parsed inline <think> blocks, so deployments using merge_reasoning_content_in_choices rendered the literal tags in the answer.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-08-04 23:32:51 +00:00 committed by GitHub
parent ffcb54b06d
commit 2badff4776
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 167 additions and 11 deletions

View file

@ -11,6 +11,7 @@ import {
handleCodeInterpreterCall,
handleCodeInterpreterOutput,
} from "./code_interpreter_handler";
import { flushThinkTags, initialThinkTagState, splitThinkTags, ThinkTagState } from "./thinkTags";
export type { CodeInterpreterResult } from "./code_interpreter_handler";
@ -29,6 +30,12 @@ interface NonStreamedResponse {
output?: ResponseOutputItem[];
}
const REASONING_DELTA_EVENT_TYPES = new Set([
"response.reasoning.delta",
"response.reasoning_text.delta",
"response.reasoning_summary_text.delta",
]);
type SynthesizedResponseEvent =
| { type: "response.output_item.done"; item: ResponseOutputItem }
| { type: "response.reasoning.delta"; delta: string }
@ -206,6 +213,7 @@ export async function makeOpenAIResponsesRequest(
let mcpToolUsed = "";
let codeInterpreterState: CodeInterpreterState = { code: "", containerId: "" };
let thinkTagState: ThinkTagState = initialThinkTagState;
for await (const event of events) {
// Use a type-safe approach to handle events
@ -250,24 +258,30 @@ export async function makeOpenAIResponsesRequest(
// 2) only handle actual text deltas
if (event.type === "response.output_text.delta" && typeof event.delta === "string") {
const delta = event.delta;
if (delta.length > 0) {
updateTextUI("assistant", delta, selectedModel);
const split = splitThinkTags(thinkTagState, event.delta);
thinkTagState = split.state;
// Calculate time to first token
if (!firstTokenReceived) {
firstTokenReceived = true;
const timeToFirstToken = Date.now() - startTime;
if (split.reasoning.length > 0 && onReasoningContent) {
onReasoningContent(split.reasoning);
}
if (onTimingData && streamingEnabled) {
onTimingData(timeToFirstToken);
}
if (split.text.length > 0) {
updateTextUI("assistant", split.text, selectedModel);
}
// Calculate time to first token
if ((split.text.length > 0 || split.reasoning.length > 0) && !firstTokenReceived) {
firstTokenReceived = true;
const timeToFirstToken = Date.now() - startTime;
if (onTimingData && streamingEnabled) {
onTimingData(timeToFirstToken);
}
}
}
// Handle reasoning content
if (event.type === "response.reasoning.delta" && "delta" in event) {
if (REASONING_DELTA_EVENT_TYPES.has(event.type) && "delta" in event) {
const delta = event.delta;
if (typeof delta === "string" && onReasoningContent) {
onReasoningContent(delta);
@ -303,6 +317,14 @@ export async function makeOpenAIResponsesRequest(
}
}
const trailing = flushThinkTags(thinkTagState);
if (trailing.reasoning.length > 0 && onReasoningContent) {
onReasoningContent(trailing.reasoning);
}
if (trailing.text.length > 0) {
updateTextUI("assistant", trailing.text, selectedModel);
}
if (onTotalLatency) {
onTotalLatency(Date.now() - startTime);
}

View file

@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { flushThinkTags, initialThinkTagState, splitThinkTags, ThinkTagState } from "./thinkTags";
function run(deltas: string[]): { text: string; reasoning: string } {
const final = deltas.reduce<{ state: ThinkTagState; text: string; reasoning: string }>(
(acc, delta) => {
const split = splitThinkTags(acc.state, delta);
return {
state: split.state,
text: acc.text + split.text,
reasoning: acc.reasoning + split.reasoning,
};
},
{ state: initialThinkTagState, text: "", reasoning: "" },
);
const trailing = flushThinkTags(final.state);
return { text: final.text + trailing.text, reasoning: final.reasoning + trailing.reasoning };
}
describe("splitThinkTags", () => {
it("leaves plain text untouched", () => {
expect(run(["Hello! ", "How can I help?"])).toEqual({ text: "Hello! How can I help?", reasoning: "" });
});
it("routes a whole think block to reasoning and keeps the answer as text", () => {
expect(run(["<think>the user said hi</think>Hello!"])).toEqual({
text: "Hello!",
reasoning: "the user said hi",
});
});
it("handles a think block spread across many deltas", () => {
expect(run(["<think>", "the user ", "said hi", "</think>", "Hello!"])).toEqual({
text: "Hello!",
reasoning: "the user said hi",
});
});
it("handles tags split mid-token across delta boundaries", () => {
expect(run(["<thi", "nk>reasoning</thi", "nk>answer"])).toEqual({
text: "answer",
reasoning: "reasoning",
});
});
it("never emits a partial tag as text before it is resolved", () => {
const first = splitThinkTags(initialThinkTagState, "answer<thi");
expect(first.text).toBe("answer");
expect(first.reasoning).toBe("");
const second = splitThinkTags(first.state, "nk>hidden</think>done");
expect(second.text).toBe("done");
expect(second.reasoning).toBe("hidden");
});
it("emits held-back text that turns out not to be a tag", () => {
expect(run(["3 <", " 4"])).toEqual({ text: "3 < 4", reasoning: "" });
});
it("supports multiple think blocks in one response", () => {
expect(run(["<think>a</think>one<think>b</think>two"])).toEqual({ text: "onetwo", reasoning: "ab" });
});
it("flushes an unterminated think block as reasoning", () => {
expect(run(["<think>truncated reasoning"])).toEqual({ text: "", reasoning: "truncated reasoning" });
});
});

View file

@ -0,0 +1,67 @@
const OPEN_TAG = "<think>";
const CLOSE_TAG = "</think>";
export interface ThinkTagState {
readonly insideThink: boolean;
readonly pending: string;
}
export interface ThinkTagSplit {
readonly state: ThinkTagState;
readonly text: string;
readonly reasoning: string;
}
export const initialThinkTagState: ThinkTagState = { insideThink: false, pending: "" };
function heldBackLength(buffer: string, tag: string): number {
const max = Math.min(buffer.length, tag.length - 1);
for (let len = max; len > 0; len--) {
if (tag.startsWith(buffer.slice(buffer.length - len))) return len;
}
return 0;
}
export function splitThinkTags(state: ThinkTagState, delta: string): ThinkTagSplit {
const consume = (
buffer: string,
insideThink: boolean,
text: string,
reasoning: string,
): { pending: string; insideThink: boolean; text: string; reasoning: string } => {
const tag = insideThink ? CLOSE_TAG : OPEN_TAG;
const index = buffer.indexOf(tag);
if (index !== -1) {
const before = buffer.slice(0, index);
return consume(
buffer.slice(index + tag.length),
!insideThink,
insideThink ? text : text + before,
insideThink ? reasoning + before : reasoning,
);
}
const held = heldBackLength(buffer, tag);
const emitted = buffer.slice(0, buffer.length - held);
return {
pending: buffer.slice(buffer.length - held),
insideThink,
text: insideThink ? text : text + emitted,
reasoning: insideThink ? reasoning + emitted : reasoning,
};
};
const result = consume(state.pending + delta, state.insideThink, "", "");
return {
state: { insideThink: result.insideThink, pending: result.pending },
text: result.text,
reasoning: result.reasoning,
};
}
export function flushThinkTags(state: ThinkTagState): ThinkTagSplit {
return {
state: initialThinkTagState,
text: state.insideThink ? "" : state.pending,
reasoning: state.insideThink ? state.pending : "",
};
}