Describe the bug
AguiAgentAdapter uses the same msg.getId() for both ThinkingBlock (reasoning) and TextBlock (text) events when a message contains both types of
blocks. This causes REASONING_MESSAGE_* and TEXT_MESSAGE_* events to share the same messageId, which breaks downstream AG-UI clients (CopilotKit) that use messageId as the primary key to distinguish and manage messages.
To Reproduce
- Configure AguiAgentAdapter with enableReasoning(true) and connect to an agent that produces both reasoning (thinking) and text output in a
single response.
- Backend code:
AguiAdapterConfig config = AguiAdapterConfig.builder()
.enableReasoning(true)
.build();
AguiAgentAdapter adapter = new AguiAgentAdapter(agent, config);
return adapter.run(input);
- Observe the SSE event stream — REASONING_MESSAGE_START, REASONING_MESSAGE_CONTENT, REASONING_MESSAGE_END and TEXT_MESSAGE_START,
TEXT_MESSAGE_CONTENT, TEXT_MESSAGE_END all share the same messageId.
- Connect a CopilotKit frontend via HttpAgent. The text response does not render in the chat UI.
Expected behavior
Reasoning and text messages should have distinct messageId values so that AG-UI clients can treat them as separate messages. The text message
should appear as a visible chat bubble, and the reasoning message should be handled independently (e.g., shown in a collapsible "thinking"
section).
Root cause
In the adapter, both ThinkingBlock and TextBlock events derive their messageId from the same msg.getId(). Since downstream clients (CopilotKit in
particular) identify messages by messageId, the shared ID causes the text content to be absorbed into the reasoning message. CopilotKit's
AG-UI→GraphQL converter then drops all role: "reasoning" messages (by design, since reasoning is surfaced via subscriber callbacks separately),
so the text content is lost entirely.
Workaround (user side)
Suffix text message IDs to differentiate them in the SSE stream before returning:
return adapter.run(input)
.map(event -> {
if (event instanceof TextMessageStart msg) {
return new TextMessageStart(msg.threadId(), msg.runId(), msg.messageId() + "-text", msg.role());
} else if (event instanceof TextMessageContent msg) {
return new TextMessageContent(msg.threadId(), msg.runId(), msg.messageId() + "-text", msg.delta());
} else if (event instanceof TextMessageEnd msg) {
return new TextMessageEnd(msg.threadId(), msg.runId(), msg.messageId() + "-text");
}
return event;
});
Environment
- AgentScope-Java Version: 2.0.0-RC1
- Java Version: 21
- OS: Windows 11
- AG-UI client: @ag-ui/client v0.0.55, CopilotKit v1.59.2
Describe the bug
AguiAgentAdapter uses the same msg.getId() for both ThinkingBlock (reasoning) and TextBlock (text) events when a message contains both types of
blocks. This causes REASONING_MESSAGE_* and TEXT_MESSAGE_* events to share the same messageId, which breaks downstream AG-UI clients (CopilotKit) that use messageId as the primary key to distinguish and manage messages.
To Reproduce
single response.
AguiAdapterConfig config = AguiAdapterConfig.builder()
.enableReasoning(true)
.build();
AguiAgentAdapter adapter = new AguiAgentAdapter(agent, config);
return adapter.run(input);
TEXT_MESSAGE_CONTENT, TEXT_MESSAGE_END all share the same messageId.
Expected behavior
Reasoning and text messages should have distinct messageId values so that AG-UI clients can treat them as separate messages. The text message
should appear as a visible chat bubble, and the reasoning message should be handled independently (e.g., shown in a collapsible "thinking"
section).
Root cause
In the adapter, both ThinkingBlock and TextBlock events derive their messageId from the same msg.getId(). Since downstream clients (CopilotKit in
particular) identify messages by messageId, the shared ID causes the text content to be absorbed into the reasoning message. CopilotKit's
AG-UI→GraphQL converter then drops all role: "reasoning" messages (by design, since reasoning is surfaced via subscriber callbacks separately),
so the text content is lost entirely.
Workaround (user side)
Suffix text message IDs to differentiate them in the SSE stream before returning:
return adapter.run(input)
.map(event -> {
if (event instanceof TextMessageStart msg) {
return new TextMessageStart(msg.threadId(), msg.runId(), msg.messageId() + "-text", msg.role());
} else if (event instanceof TextMessageContent msg) {
return new TextMessageContent(msg.threadId(), msg.runId(), msg.messageId() + "-text", msg.delta());
} else if (event instanceof TextMessageEnd msg) {
return new TextMessageEnd(msg.threadId(), msg.runId(), msg.messageId() + "-text");
}
return event;
});
Environment