Your Java agent streams tokens. Your frontend speaks a protocol. Between them sits a translation layer that nobody wants to write — and that everybody writes badly.
It is the same story every time: text deltas, reasoning deltas, tool-call arguments, tool results, citations, errors, aborts. Six or seven channels, all multiplexed onto one SSE connection, all needing stable IDs so the UI can stitch deltas back into messages. Get the ID keying wrong and two parallel tool calls collide. Forget to close a block on error and the frontend hangs forever waiting for an end that never comes. Cancel the request and — if you forgot one line — the model keeps generating, and you keep paying.
Solon AI has a module whose only job is this translation layer: solon-ai-ui.
Repo: https://github.com/opensolon/solon-ai (module: solon-ai-ui)
It ships two adapters:
| Artifact | Protocol it speaks | Pairs with |
|---|---|---|
solon-ai-ui-aisdk |
Vercel AI SDK — UI Message Stream Protocol v1 |
@ai-sdk/react, @ai-sdk/vue useChat
|
solon-ai-ui-agui |
AG-UI | AG-UI compatible component libraries |
Both convert Solon AI's internal Flux<ChatEvent> into a frontend-facing event stream. Neither is a rewrite of your agent — they are thin adapters, and the way they stay thin is the most interesting part of the design.
The layering rule: adapters may not know what an agent is
The obvious implementation would be: ui depends on agent, and maps AgentEvent to UI events directly. Simple. And wrong — it would drag the entire agent stack into every app that just wants to stream a chat model, and every agent event added upstream would become a breaking change downstream.
So the adapters depend only on solon-ai-core. Agent events arrive as Object, and the adapter figures them out reflectively:
- If the event exposes
getChatEvent(), the adapter delegates to the core event state machine. The three delta events — the ones carrying actual text, reasoning, and tool arguments — all take this path. - Otherwise it matches on the simple class name:
ToolCallStartEvent,ToolCallEndEvent,RunEndEvent/SimpleEndEvent/TeamEndEvent. - Anything it still doesn't recognize falls through to a
custom/data-*bucket instead of being dropped.
Nothing is silently discarded. That is the rule the whole module is built around.
Adapter 1: Vercel AI SDK
The AI SDK adapter converts chatModel.prompt(prompt).stream() into a Flux<SseEvent> that is a drop-in for useChat.
@Controller
public class AiChatController {
@Inject
ChatModel chatModel;
private final AiSdkStreamWrapper wrapper = AiSdkStreamWrapper.of();
@Produces(MimeType.TEXT_EVENT_STREAM_UTF8_VALUE)
@Mapping("/ai/chat/stream")
public Flux<SseEvent> stream(String prompt, Context ctx) {
// required by the AI SDK protocol
ctx.headerSet("x-vercel-ai-ui-message-stream", "v1");
return wrapper.toAiSdkStream(chatModel.prompt(prompt).stream());
}
}
The protocol is a parts model — roughly twenty part types, each a small JSON frame — and the wrapper emits them in a fixed order:
start → (message-metadata) → start-step
→ (reasoning-start → reasoning-delta* → reasoning-end)
→ (tool-input-start → tool-input-delta* → tool-input-available → tool-output-available)
→ (source-url* / source-document*)
→ (text-start → text-delta* → text-end)
→ (file* / data-*)
→ finish-step → … → finish → [DONE]
A single-turn reply is one step. A tool call that re-prompts the model produces multiple steps, and the start-step / finish-step pair is what lets useChat reassemble a multi-step assistant turn correctly.
There is also a blocking counterpart: toAiSdkStream(ChatResponse) wraps a call() result into the same frame sequence, so a non-streaming endpoint can still feed a streaming client.
Adapter 2: AG-UI
The AG-UI adapter targets a different event vocabulary — RUN_STARTED, TEXT_MESSAGE_CONTENT, TOOL_CALL_ARGS, REASONING_MESSAGE_CONTENT, STEP_FINISHED, and so on.
AgUiStreamWrapper wrapper = AgUiStreamWrapper.of("thread-1", "run-1");
Flux<Event> stream = wrapper.toAgUiStream(chatModel.prompt(prompt).stream());
Two details are worth calling out.
The reasoning rename is handled for you. AG-UI's modern vocabulary is REASONING_*; the older THINKING_* events are marked @Deprecated in the enum, with each old constant pointing at its replacement. Solon AI's core still calls its events THINKING_*, so the adapter maps them onto the modern REASONING_START / REASONING_MESSAGE_CONTENT / REASONING_END family — including the message-level boundaries, not just the outer block.
Interruption is a first-class outcome, not an error. When the core emits ABORT, the AG-UI adapter closes any open content block, then emits a RUN_FINISHED whose outcome type is interrupt. A user pressing "stop" is a normal ending with a name — not a fake failure.
Events AG-UI has no standard representation for — server-side tools, media, safety, usage, custom payloads — are preserved as CUSTOM rather than mapped onto something semantically wrong. For backwards compatibility the payload is written to both the standard name/value fields and the legacy rawEvent field, so older clients keep working while standard clients move forward.
There is also typed support for state sync: StateDeltaEvent carries RFC 6902 JSON Patch operations.
StateDeltaEvent delta = new StateDeltaEvent()
.add(JsonPatchOperation.replace("/progress", 50))
.add(JsonPatchOperation.add("/message", "working..."));
The five details that decide whether this works in production
Protocol mapping is the easy 20%. These are the rest.
1. Stable IDs across deltas. Deltas arrive in fragments, so each block needs an ID minted once and reused for its start / delta / end frames. Both adapters key the ID map on responseId + step + itemId, falling back to index. That key is what keeps two concurrent tool calls, or two reasoning channels, from borrowing each other's IDs. The AI SDK adapter also lets you swap the ID source entirely — UUID by default, snowflake or anything else via AiSdkIdGenerator, with prefixed helpers (msg_, txt_, rsn_, call_, src_).
2. Cancellation has to propagate upstream. Both wrappers call sink.onDispose(upstream). When the browser disconnects, the subscription to the model is released — the request doesn't keep running in the background burning tokens after nobody is listening.
3. Failures must not be dressed up as success. If the stream errors mid-flight, the wrapper first closes any open text/reasoning blocks (otherwise the client waits forever for an end), then emits the error part with finishReason set to error — not the default stop. The error text is taken from the terminal ERROR event when one was emitted, because that carries more context than the bare Throwable. If the error path had to synthesize the end frames itself, it reuses the same closing logic as the success path rather than inventing new frames.
4. No orphan tool output. Some providers deliver a tool result without ever sending a tool-input-* frame. A strict client will drop an output that references an input it never saw. So the adapter idempotently emits the missing tool-input-start / tool-input-available pair first, then the output. Same guard applies to agent tool events arriving from a replay or resume path.
5. Content that isn't the answer must not look like the answer. In a multi-agent run, a supervisor's internal routing chatter can arrive on the same stream. Both adapters force those events into a custom / data-* bucket — never into the assistant text or reasoning channels, so internal deliberation can't leak into what the user sees as the reply. Similarly, agent turns are namespaced by run and reason ID in the AI SDK adapter, so two turns can't accidentally reuse a closed part ID.
One more, from the AI SDK adapter's javadoc, worth knowing before you file a bug: the core's default event filter blocks RAW and HEARTBEAT, so unmodeled raw frames never reach the wrapper by default. If you want them passed through, opt in explicitly when building the stream:
chatModel.prompt(prompt).eventFilter(ChatEventFilter.all()).stream()
Which adapter?
| If your frontend… | Use |
|---|---|
already uses useChat from @ai-sdk/react or @ai-sdk/vue, or any AI-Elements component library |
solon-ai-ui-aisdk |
| targets AG-UI / is protocol-first and wants run/step semantics, or you want typed JSON-Patch state sync | solon-ai-ui-agui |
| just wants plain SSE text and parses it by hand | neither — streamText() is enough |
The two are not exclusive. ChatEvent is Solon AI's internal, provider-agnostic model; each adapter is an outbound projection of it. If you ever genuinely need both, you're translating one core stream two ways, not maintaining two agents.
Getting started
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-ai-ui-aisdk</artifactId>
</dependency>
Versions are managed by the Solon AI BOM, so no <version> is needed. The adapter follows the Solon AI 4.1 line.
The interesting thing about a translation layer is that the best one is invisible. You wire two lines, the UI renders text, reasoning, tool calls and citations in order, and you never think about it again — until the day a tool call hangs the frontend, and you have to go find out whose job it was to close the block.
This module's answer to that question is: ours.
All behavior described above was read from the solon-ai-ui source in the opensolon/solon-ai repository.
Top comments (0)