Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,13 @@ describe("createChatStore", () => {
beforeEach(() => {
fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
// processStreamedMessage debounces updates via requestAnimationFrame,
// which is absent in the node test environment. Run callbacks synchronously.
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
vi.stubGlobal("cancelAnimationFrame", () => {});
});

it("sends POST to apiUrl with threadId and messages", async () => {
Expand Down Expand Up @@ -458,6 +465,79 @@ describe("createChatStore", () => {
expect((store.getState().messages[1] as any).content).toBe("response text");
});

it("accumulates REASONING_MESSAGE_CONTENT into the assistant message's reasoning", async () => {
const sseBody =
`data: ${JSON.stringify({ type: "REASONING_MESSAGE_START", messageId: "r1", role: "reasoning" })}\n\n` +
`data: ${JSON.stringify({ type: "REASONING_MESSAGE_CONTENT", messageId: "r1", delta: "let me " })}\n\n` +
`data: ${JSON.stringify({ type: "REASONING_MESSAGE_CONTENT", messageId: "r1", delta: "think" })}\n\n` +
`data: ${JSON.stringify({ type: "REASONING_MESSAGE_END", messageId: "r1" })}\n\n` +
`data: [DONE]\n\n`;
const stream = new ReadableStream({
start(c) {
c.enqueue(new TextEncoder().encode(sseBody));
c.close();
},
});
fetchSpy.mockResolvedValue(new Response(stream));

const store = createChatStore({ apiUrl: "/api/chat" });
store.setState({ selectedThreadId: "t1" });

await store.getState().processMessage({ role: "user", content: "hello" });

const assistant = store.getState().messages[1] as any;
expect(assistant.role).toBe("assistant");
expect(assistant.reasoning).toBe("let me think");
});

it("leaves reasoning undefined when no reasoning events are streamed", async () => {
const sseBody = `data: ${JSON.stringify({ type: "TEXT_MESSAGE_CONTENT", delta: "just an answer" })}\n\ndata: [DONE]\n\n`;
const stream = new ReadableStream({
start(c) {
c.enqueue(new TextEncoder().encode(sseBody));
c.close();
},
});
fetchSpy.mockResolvedValue(new Response(stream));

const store = createChatStore({ apiUrl: "/api/chat" });
store.setState({ selectedThreadId: "t1" });

await store.getState().processMessage({ role: "user", content: "hello" });

const assistant = store.getState().messages[1] as any;
expect(assistant.role).toBe("assistant");
expect(assistant.content).toBe("just an answer");
expect(assistant.reasoning).toBeUndefined();
});

it("preserves reasoning across a TEXT_MESSAGE_START id swap and colocates it with content", async () => {
const sseBody =
`data: ${JSON.stringify({ type: "REASONING_MESSAGE_CONTENT", messageId: "r1", delta: "thinking..." })}\n\n` +
`data: ${JSON.stringify({ type: "TEXT_MESSAGE_START", messageId: "m1", role: "assistant" })}\n\n` +
`data: ${JSON.stringify({ type: "TEXT_MESSAGE_CONTENT", messageId: "m1", delta: "answer" })}\n\n` +
`data: [DONE]\n\n`;
const stream = new ReadableStream({
start(c) {
c.enqueue(new TextEncoder().encode(sseBody));
c.close();
},
});
fetchSpy.mockResolvedValue(new Response(stream));

const store = createChatStore({ apiUrl: "/api/chat" });
store.setState({ selectedThreadId: "t1" });

await store.getState().processMessage({ role: "user", content: "hello" });

const assistants = store.getState().messages.filter((m) => m.role === "assistant");
expect(assistants).toHaveLength(1);
const assistant = assistants[0] as any;
expect(assistant.id).toBe("m1");
expect(assistant.reasoning).toBe("thinking...");
expect(assistant.content).toBe("answer");
});

it("throws when neither apiUrl nor processMessage provided", async () => {
const store = createChatStore({});
store.setState({ selectedThreadId: "t1" });
Expand Down
13 changes: 13 additions & 0 deletions packages/react-headless/src/stream/processStreamedMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,19 @@ export const processStreamedMessage = async ({
}
break;

// Reasoning/thinking deltas, colocated on the assistant turn. CHUNK may
// carry a role change but, like TEXT_MESSAGE_CHUNK, we treat it as CONTENT.
// The reasoning's own messageId is ignored — a later TEXT_MESSAGE_START
// swaps in the real assistant id, and the spread preserves `reasoning`.
// (START/END carry no payload we need, so they fall through unhandled.)
case EventType.REASONING_MESSAGE_CHUNK:
case EventType.REASONING_MESSAGE_CONTENT:
currentMessage = {
...currentMessage,
reasoning: (currentMessage.reasoning || "") + (event.delta ?? ""),
};
break;

case EventType.TEXT_MESSAGE_START:
// Use the ID from the event if it differs from our optimistic ID
if (event.messageId !== currentMessage.id) {
Expand Down
36 changes: 36 additions & 0 deletions packages/react-headless/src/types/__tests__/messageFormat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import type { AssistantMessage, Message } from "../message";
import { identityMessageFormat } from "../messageFormat";

describe("identityMessageFormat", () => {
it("strips the client-only reasoning field from assistant messages in toApi", () => {
const messages: Message[] = [
{ id: "u1", role: "user", content: "hi" },
{ id: "a1", role: "assistant", content: "hello", reasoning: "let me think" },
];

const out = identityMessageFormat.toApi(messages) as Message[];

expect(out).toHaveLength(2);
const assistant = out[1] as AssistantMessage;
expect(assistant.content).toBe("hello");
expect("reasoning" in assistant).toBe(false);
});

it("leaves messages without reasoning untouched (same reference)", () => {
const messages: Message[] = [
{ id: "u1", role: "user", content: "hi" },
{ id: "a1", role: "assistant", content: "hello" },
];

const out = identityMessageFormat.toApi(messages) as Message[];

expect(out[0]).toBe(messages[0]);
expect(out[1]).toBe(messages[1]);
});

it("fromApi passes data through unchanged", () => {
const data: Message[] = [{ id: "a1", role: "assistant", content: "hello" }];
expect(identityMessageFormat.fromApi(data)).toBe(data);
});
});
21 changes: 19 additions & 2 deletions packages/react-headless/src/types/message.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
import type { AssistantMessage as AGUIAssistantMessage, Message as AGUIMessage } from "@ag-ui/core";

export type {
ActivityMessage,
AssistantMessage,
BinaryInputContent,
DeveloperMessage,
FunctionCall,
InputContent,
Message,
ReasoningMessage,
SystemMessage,
TextInputContent,
ToolCall,
ToolMessage,
UserMessage,
} from "@ag-ui/core";

/**
* AG-UI's assistant message, extended with a colocated `reasoning` field so
* thinking/reasoning tokens can live on the same turn as the answer (rendered
* in something like a BehindTheScenes section).
*/
export type AssistantMessage = AGUIAssistantMessage & {
/** Reasoning/thinking tokens colocated with this assistant turn. */
reasoning?: string;
};

/**
* AG-UI's message union with the assistant branch swapped for the extended
* {@link AssistantMessage}, so `role === "assistant"` narrowing exposes
* `reasoning`.
*/
export type Message = Exclude<AGUIMessage, { role: "assistant" }> | AssistantMessage;
19 changes: 16 additions & 3 deletions packages/react-headless/src/types/messageFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,23 @@ export interface MessageFormat {
}

/**
* Default identity message format — no conversion.
* Messages are sent and received as-is in AG-UI format.
* `reasoning` is a client-side display field colocated on the assistant turn,
* not part of the wire message shape. Strip it so the identity format doesn't
* echo accumulated thinking back to the backend on every subsequent turn.
*/
function stripReasoning(message: Message): Message {
if (message.role !== "assistant" || message.reasoning === undefined) return message;
const clone = { ...message };
delete clone.reasoning;
return clone;
}

/**
* Default identity message format — no conversion (aside from dropping the
* client-only `reasoning` field). Messages are sent and received as-is in
* AG-UI format.
*/
export const identityMessageFormat: MessageFormat = {
toApi: (messages) => messages,
toApi: (messages) => messages.map(stripReasoning),
fromApi: (data) => data as Message[],
};
14 changes: 13 additions & 1 deletion packages/react-ui/src/components/BottomTray/Thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ArtifactOverlay } from "../_shared/artifact";
import type { AssistantMessageComponent, UserMessageComponent } from "../_shared/types";
import { MarkDownRenderer } from "../MarkDownRenderer";
import { MessageLoading as MessageLoadingComponent } from "../MessageLoading";
import { ReasoningSection } from "../Reasoning";
import { ToolCallComponent } from "../ToolCall";
import { ToolResult } from "../ToolResult";

Expand Down Expand Up @@ -114,9 +115,11 @@ export const UserMessageContainer = ({
const AssistantMessageContent = ({
message,
allMessages,
isStreaming,
}: {
message: AssistantMessage;
allMessages: Message[];
isStreaming: boolean;
}) => {
const getToolName = (toolCallId: string) => {
const toolCall = message.toolCalls?.find((tc) => tc.id === toolCallId);
Expand All @@ -138,6 +141,11 @@ const AssistantMessageContent = ({

return (
<>
<ReasoningSection
reasoning={message.reasoning}
isStreaming={isStreaming}
hasContent={!!message.content}
/>
{message.content && (
<MarkDownRenderer
textMarkdown={message.content}
Expand Down Expand Up @@ -208,7 +216,11 @@ export const RenderMessage = memo(
}
return (
<AssistantMessageContainer className={className}>
<AssistantMessageContent message={message} allMessages={allMessages} />
<AssistantMessageContent
message={message}
allMessages={allMessages}
isStreaming={isStreaming}
/>
</AssistantMessageContainer>
);
}
Expand Down
14 changes: 13 additions & 1 deletion packages/react-ui/src/components/CopilotShell/Thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useShellStore } from "../_shared/store";
import type { AssistantMessageComponent, UserMessageComponent } from "../_shared/types";
import { MarkDownRenderer } from "../MarkDownRenderer";
import { MessageLoading as MessageLoadingComponent } from "../MessageLoading";
import { ReasoningSection } from "../Reasoning";
import { ToolCallComponent } from "../ToolCall";
import { ToolResult } from "../ToolResult";

Expand Down Expand Up @@ -127,9 +128,11 @@ export const UserMessageContainer = ({
const AssistantMessageContent = ({
message,
allMessages,
isStreaming,
}: {
message: AssistantMessage;
allMessages: Message[];
isStreaming: boolean;
}) => {
const getToolName = (toolCallId: string) => {
const toolCall = message.toolCalls?.find((tc) => tc.id === toolCallId);
Expand All @@ -151,6 +154,11 @@ const AssistantMessageContent = ({

return (
<>
<ReasoningSection
reasoning={message.reasoning}
isStreaming={isStreaming}
hasContent={!!message.content}
/>
{message.content && (
<MarkDownRenderer
textMarkdown={message.content}
Expand Down Expand Up @@ -221,7 +229,11 @@ export const RenderMessage = memo(
}
return (
<AssistantMessageContainer className={className}>
<AssistantMessageContent message={message} allMessages={allMessages} />
<AssistantMessageContent
message={message}
allMessages={allMessages}
isStreaming={isStreaming}
/>
</AssistantMessageContainer>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ActionEvent, Library } from "@openuidev/react-lang";
import { BuiltinActionType, Renderer } from "@openuidev/react-lang";
import { useCallback, useMemo } from "react";
import { separateContentAndContext, wrapContent, wrapContext } from "../../utils/contentParser";
import { ReasoningContent } from "../Reasoning";
import { AssistantMessageContainer } from "../Shell";
import { BehindTheScenes, ToolCallComponent } from "../ToolCall";
import { ToolResult } from "../ToolResult";
Expand Down Expand Up @@ -115,8 +116,9 @@ export const GenUIAssistantMessage = ({

return (
<AssistantMessageContainer>
{hasToolActivity && (
{(hasToolActivity || !!message.reasoning) && (
<BehindTheScenes isStreaming={isStreaming} toolCallsComplete={!!message.content}>
{message.reasoning && <ReasoningContent reasoning={message.reasoning} />}
{message.toolCalls?.map((toolCall, idx) => (
<ToolCallComponent
key={toolCall.id}
Expand Down
15 changes: 15 additions & 0 deletions packages/react-ui/src/components/Reasoning/ReasoningContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { MarkDownRenderer } from "../MarkDownRenderer";

export interface ReasoningContentProps {
/** The accumulated reasoning/thinking text for the assistant turn. */
reasoning: string;
}

/**
* Renders an assistant turn's reasoning/thinking content as markdown. Intended
* to be placed inside a `BehindTheScenes` section, colocated with the assistant
* message.
*/
export const ReasoningContent = ({ reasoning }: ReasoningContentProps) => (
<MarkDownRenderer textMarkdown={reasoning} className="openui-reasoning" />
);
26 changes: 26 additions & 0 deletions packages/react-ui/src/components/Reasoning/ReasoningSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { BehindTheScenes } from "../ToolCall";
import { ReasoningContent } from "./ReasoningContent";

export interface ReasoningSectionProps {
/** Accumulated reasoning/thinking text for the assistant turn, if any. */
reasoning: string | undefined;
/** True while the assistant turn is still streaming. */
isStreaming: boolean;
/** True once answer text has started — collapses the section. */
hasContent: boolean;
}

/**
* Standalone collapsible reasoning panel for assistant turns whose tool calls
* are rendered outside a BehindTheScenes (the Shell / CopilotShell / BottomTray
* surfaces). Renders nothing when there is no reasoning. The GenUI surface
* instead nests `ReasoningContent` inside its shared tool-call panel.
*/
export const ReasoningSection = ({ reasoning, isStreaming, hasContent }: ReasoningSectionProps) => {
if (!reasoning) return null;
return (
<BehindTheScenes isStreaming={isStreaming} toolCallsComplete={hasContent}>
<ReasoningContent reasoning={reasoning} />
</BehindTheScenes>
);
};
2 changes: 2 additions & 0 deletions packages/react-ui/src/components/Reasoning/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ReasoningContent, type ReasoningContentProps } from "./ReasoningContent";
export { ReasoningSection, type ReasoningSectionProps } from "./ReasoningSection";
9 changes: 9 additions & 0 deletions packages/react-ui/src/components/Reasoning/reasoning.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@use "../../cssUtils" as cssUtils;

// ── Reasoning / thinking content ──

.openui-reasoning {
width: 100%;
padding-left: cssUtils.$space-l;
color: cssUtils.$text-neutral-secondary;
}
Loading
Loading