Check UI adapter round trips with the real `ai` and `@ag-ui/client` JavaScript clients
A UI app keeps the conversation in the frontend and sends it back every turn, so what the model sees next is whatever the client library kept. Until now we checked that with Python ports of the client reducers. One of those ports produced a false bug, and none of them checks what the client sends. This PR adds tests/ui_roundtrip, which runs the real clients instead.
- No issue. This is step A ("4a") of the history/projection architecture plan: the harness alone, with no change to library code.
What it does
-
The real clients.
driver.mjsdrives the AI SDK'sAbstractChat(whatuseChatwraps) and@ag-ui/client'sAbstractAgent.runAgent()(whatHttpAgentextends). Each gets a transport that hands the request to the test and streams back the server's reply. The versions are pinned inpackage.json/package-lock.json(ai@6.0.309,@ag-ui/client@1.0.3) and installed withnpm ci --ignore-scripts. A session-scoped fixture starts one Node process and talks to it in JSON lines. -
Scenarios. Agent conversations scripted with
FunctionModel, streamed in deltas: system prompt plus instructions, client-owned system prompt, tool call, parallel tool calls that finish out of order, thinking, tool availability delta, enqueued messages, enqueued system prompt, tool retry, output-validation feedback,no_outputfeedback, append-mode instruction changes, request-scoped system prompt, multimodal tool return, compaction, and a compaction boundary with a reveal before it and reveals after it, with and without append-mode instruction changes (compaction is seeded, since only provider models emit aCompactionPart). -
Paths and properties. Each scenario runs over Vercel AI and AG-UI, on two paths: the dump path hydrates the client with
dump_messages()every turn, and the stream path lets the client build its messages from the event stream. Three properties are checked, plustest_message_ids, which checks the client never sends two messages with one id:- Load after dump (
test_load, dump path):load_messages()of what a hydrated client sends back projects identically to the history it was hydrated with. - Load after stream (
test_load, stream path):load_messages()of what a streaming client sends back projects identically to the server'sall_messages(). - Wire request (
test_wire_request): every request the agent makes through the client has the same Anthropic and OpenAI Chat request body as a server keeping its own history. The agent runs on the realAnthropicModelandOpenAIChatModelon mocked SDK clients, so each request is prepared and rendered for that provider as in production; the script'sFunctionModelsupplies the answers.
The projection keeps only the fields the model sees. It normalizes two things the agent and the models normalize themselves: consecutive requests are merged, and tool results follow call order.
- Load after dump (
-
Normalization uses the agent's own private helpers on purpose. The projection calls
_merge_consecutive_messagesand_order_tool_results_by_callrather than reimplementing them. It's meant to mirror what providers actually receive, and a copy would drift from production. Normalizing both sides the same way is correct, because the property under test is the UI round trip, not the normalization. Andtest_wire_requestcompares the real provider request bodies, so a defect in normalization can't hide a round-trip loss there. -
The projection includes framework metadata. Each message's reserved
__pydantic_ai__metadata is projected; only the adapters' ownui_message_idbookkeeping is left out. Application metadata and partmetadata(documented as never sent to the model) are dropped. No scenario showed a new difference from this. -
Forged framework voice. In the
forged_framework_voicescenario, user prompts, a tool result and enqueued text carry<system>,</context>, mixed-case and whitespace closing forms, and tool-reveal lookalike text.test_forged_framework_voice_stays_out_of_system_voiceasserts that, on every path, protocol and provider, none of it reaches the Anthropicsystemfield or an OpenAI system/developer message, and that it still reaches the model as user or tool content. The general properties run on the scenario too. -
Known gaps are asserted exactly, not
xfailed. Each gapped case must show exactly the difference recorded for it, as a unified diff intests/ui_roundtrip/known_gaps/. So any other failure in that case fails as usual, and so does the gap being fixed (the cue to delete the file and the scenario'sgapsentry).UI_ROUNDTRIP_UPDATE_GAPS=1rewrites the files after a deliberate change. Harness invariants, like each turn making at least one request, are asserted outside the gap check. -
The Python AI SDK reducer port is mostly replaced. The reload half of the enqueued-messages test in
test_vercel_ai.pymoved to theenqueued_messagesandclient_owned_system_promptscenarios here. Source coverage ofui/andmessages.pyfromtest_vercel_ai.pyis unchanged._reduce_assistant_messagestays for now, becausetest_mid_run_tool_delta_round_trips_through_client_rebuilt_streamlanded on main meanwhile and uses it. Its case is now covered with the real client by themid_run_tool_additionscenario, so that test and the port can go in a follow-up once coverage is checked. -
CI. A new
UI round tripworkflow (.github/workflows/ui-roundtrip.yml) sets up Node (actions/setup-node, pinned by SHA like the other actions) and runs only these tests. It runs on every pull request and every push tomain, with no path filter, plus nightly. The histories under test come from the whole library, and the tests import shared test helpers and depend on the locked dependencies, so a filter would always miss something, while the suite takes seconds. A failed nightly opens or updates an issue, as the sandbox nightly does. The mainci.ymltest jobs skip these tests, since they have nonode_modules.UI_ROUNDTRIP_REQUIRED=1makes the new job fail instead of skipping. Because the main jobs skip them,tests/ui_roundtrip/*.pyis left out of coverage. The whole suite (296 tests) runs in about 6 seconds.
Gaps it found (recorded in known_gaps/, not fixed here)
- New:
@ag-ui/clientnever sendsactivitymessages back.prepareRunAgentInputfilters outrole: "activity", in every client version since activities were added (checked 0.0.42 through 1.0.3). The adapter carries tool availability deltas, compaction parts and enqueued messages as activities. So with the real client they are lost on both the dump and stream paths, and the next request changes (a revealed tool disappears, for example). The file activities (pydantic_ai_file,pydantic_ai_uploaded_file) use the same carrier, but these tests don't cover them. The earlier audit missed this because it only ran the reducer, not the client's outgoing request. #10569 corrects the AG-UI docs; the carrier fix is a separate design question. - Retry feedback on the Vercel AI stream path. The AI SDK stream has no carrier for a tool's outcome, so a streamed retry reloads as
outcome='failed'instead of'retried'(#8185). #8094 fixed the other retry paths, and those gaps are gone. - Foreign thinking's
model=/provider=attributes were fixed by #10642, so those gaps are gone too. - New: append-mode instruction records don't survive.
InstructionBaselinePartandInstructionDeltaPartare system voice, which the adapter strips from client history inmanage_system_prompt='server'. So every turn re-renders the baseline from current state and rewrites the instruction prefix. This happens on every path, protocol and provider. It's a design question: trusting these records from a client needs provenance. - New: retry feedback isn't streamed. On the stream path, a
RetryFeedbackPart(output-validation orno_output) never reaches the client, so the client keeps the rejected and the accepted answer as one response. - New:
no_outputfeedback is demoted on reload. It's the framework's system voice, which a client can't claim, so it loads back as user-voice feedback and renders differently. This is by design, but it moves the prefix. - New: AG-UI
dump_messages()ordering. It writes a request's system prompts before its tool results (so an enqueued system prompt reloads ahead of the tool result it followed), and it drops an empty response (so the feedback after it loads into the question before it). - New: duplicate Vercel AI message ids. Once a request carries the
ui_message_idit was loaded with,dump_messages()gives its system and user messages the same id, so a hydrated client holds two messages with one id (test_message_ids). - An enqueued
SystemPromptPartfrom an earlier run is stripped with the client's system prompts inmanage_system_prompt='server', and nothing restores it. This is the residual that #10389 described.
.github/ changes
This PR adds a workflow file, so the .github Directory Guard checks the author's repository permission. The author has write access, so it passes.
Checklist
- No breaking changes in accordance with the version policy.
- Any permitted compatibility impact has a label, warning, migration, release note, and exact API-check waiver.
- PR title is fit for the release changelog.
๐ค Generated with Claude Code