feat(api)!: say history is the server's, and share the state object - #86
Merged
Conversation
Two things a client could previously only learn from a README paragraph. Every run now emits MESSAGES_SNAPSHOT immediately after RUN_STARTED, carrying the thread as the server holds it. A client posts its whole messages array and only the trailing user message is read, so one that edited its own copy was wrong with nothing on the wire to say so. An empty snapshot on a thread's first turn is the same statement, and is sent; agui_events takes messages=None to send nothing at all, for a consumer driving it without a checkpointer. STATE_SNAPSHOT now carries its metadata under a toolState key rather than at the root. AG-UI's state object is the client's as much as ours — the protocol has it hold what a client and agent share, and STATE_DELTA patches it by JSON Pointer — so writing our map at the root left a client nowhere for its own keys and overwrote any it sent. BREAKING CHANGE: read STATE_SNAPSHOT metadata from snapshot.toolState rather than from snapshot. The read routes are unchanged: their bodies are ours alone, with nothing to share them with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The snapshot as first written broke the example. A client renders its question the moment it is typed, and defaultApplyEvents applies a snapshot by dropping every local message it does not name — so one sent after RUN_STARTED, before the turn was checkpointed, took the question off the screen and left the answer under nothing. Ids never matched either: the client's were minted locally and the thread's by the checkpointer, so the whole list was dropped and re-appended, stranding activities at the top. Two changes make it reconcile in place. The snapshot now closes the run rather than opening it, so everything the turn produced is in the thread. And every id lines up: the question keeps the id its client gave it, the answer carries the provider's own id off the chunk, and tool events carry the assistant and tool message ids from the thread. An id the thread already holds is ignored, because the message reducer matches on id and would replace that message rather than add one. Verified against a live turn: every id in the snapshot was already one the client held. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The client always started a fresh conversation, so the read routes were
exercised by tests and curl and never by the thing they exist for. The
thread id is now in the URL as `?thread=`, and a reload rebuilds the
conversation from it.
Two routes, joined on the question: /threads/{id} for the transcript,
/threads/{id}/turns for what state held at the end of each turn. Turn
boundaries have to come from somewhere — the stream brackets a run with
RUN_STARTED and RUN_FINISHED, which a client that reloaded never saw.
Activities do not come back, and the README now says so: receipts, views
and citations are activity messages and the server does not rebuild past
turns' activities, so `published` is empty and the cross-highlighting is
dark until the next turn.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ciaransweet
added a commit
that referenced
this pull request
Aug 17, 2026
…ges (#87) A restored thread showed what was said and nothing about what the agent did — no receipts, no publications, no views, no citations. Those are the things this runtime exists to make visible, so a reload lost the half that matters. `GET /threads/{id}` now returns them. ### It reads them back rather than rebuilding them The old docstring said rebuilding would mean "re-deriving every historical turn's receipts". It would not: receipts are **stored**. `_residue` writes `structured_content`, `captured_state` and `injected_state` onto each tool message's artifact, and the checkpointer keeps all three — confirmed through `JsonPlusSerializer` and against a deployed thread. So each activity comes from data already in the thread: | Activity | From | | --- | --- | | `state.consumed` | `receipts_of(artifact)`, rendered by `step_input` against that turn's state | | `state.published` | `_published(artifact)` | | `mcp.view` | `restore_structured(artifact, state)` plus the tool's `ui://` URI | | `answer.citations` | `answer_citations(ai_message)` | An activity **is** a message in AG-UI, so they travel in `messages` and sit beside the call they belong to with no correlation work — the same property the live stream relies on. ### The payload builders are shared now `consumed_content`, `published_content`, `view_content` and `citations_content` are used by both the stream and the readback, so a receipt cannot read one way live and another after a reload. That also removed the `_artifact` smuggling `_view`/`_filled` needed: the deferral stays in the loop, the content builder is pure. Verified against a live turn on a real model — every restored activity is byte-identical to the one the stream sent: ``` restored roles: [user, assistant, tool, activity, assistant, tool, activity, activity, activity, assistant] state.published identical to live state.consumed identical to live state.published identical to live mcp.view identical to live live-only activity types: ['tools.withheld'] ``` `tools.withheld` is correctly live-only: it announces what *this build* cannot do, which is not part of the conversation. ### What degrades, rather than guessing - A turn whose checkpoints have been **pruned** has no state to describe its receipts against, so its view is not rebuilt — better than drawing one from whatever state happens to be current. - A `ui://` bundle comes from the deployment as it stands, so a tool **removed since** has no view rather than a dangling URI. - State is per *turn*, not per call, so two tools writing the same key within one turn describe each other's value. The checkpoints could tell them apart; the turn index cannot. Documented on `thread_messages`. ### Cost A second read per thread — `turns_of` walks the checkpoint history, proportional to the conversation. That is the documented price of deriving turns from a structure that does not record them, and it is what makes the difference between restoring a conversation and restoring what the agent was seen to do. ### Example client Restored activities mean the cross-highlighting works on a reloaded thread with no special case — `origins` folds `state.published` out of the messages exactly as it does live. Each turn is bounded by the next one's start; unbounded, turn 1 would claim every later turn's publications. ### Also `stream_turn`'s `_tool_result` is now public as `tool_finished`, since the readback builds the same thing from the same message. And `RunRequest`'s docstring said the client's message id "is discarded rather than stored" — stale since #86, which propagates it. Fixed here. 350 tests, lint clean, example client builds. ### Activities are opt-in, and a run's closing snapshot opts out Caught by trying it in the browser: the restored activities were duplicating at the bottom of every turn. A client applying a `MESSAGES_SNAPSHOT` **keeps every local activity whether the snapshot names it or not** — `defaultApplyEvents` exempts the role. So a snapshot can never *correct* a client's activities, only append a second copy of ones the stream just sent. Readback ids are per-position (`act_4_1`) and the stream's are per-run (`act_{runId}_4`), so nothing matches and every one duplicates. Omitting `turns` and `tools` was not enough to stop it: `state.consumed` and `state.published` need neither, so they came out anyway — and rendered against no state at all. There is now an explicit `activities` flag, off by default, and `GET /threads/{id}` is the one caller that asks for them. Verified on a live turn: the closing snapshot carries six messages and no activities, while a reload of the same thread returns all four. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
ciaransweet
pushed a commit
that referenced
this pull request
Aug 17, 2026
🤖 I have created a release *beep* *boop* --- ## [0.6.0](mcp-toolsets-runtime-v0.5.7...mcp-toolsets-runtime-v0.6.0) (2026-08-17) ### ⚠ BREAKING CHANGES * **api:** say history is the server's, and share the state object ([#86](#86)) ### Features * **api:** read a thread back with its activities, not just its messages ([#87](#87)) ([d0549ea](d0549ea)) * **api:** say history is the server's, and share the state object ([#86](#86)) ([701d488](701d488)) ### Bug Fixes * **state:** teach the two ways to name state apart ([#84](#84)) ([9ea9ef1](9ea9ef1)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: ds-release-bot[bot] <116609932+ds-release-bot[bot]@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the two AG-UI follow-ups that get more expensive with every client integrating against the current shape. Both are things a consumer could previously only learn from a README paragraph.
MESSAGES_SNAPSHOTclosing every run. A client posts its wholemessagesarray and this server reads only the trailing user message — so a client that edited its own copy was wrong, with nothing on the wire saying so. Each run now ends with the thread as the server holds it.STATE_SNAPSHOTundertoolState. AG-UI'sstateobject is the client's as much as ours: the protocol has it hold what a client and agent share, andSTATE_DELTApatches it by JSON Pointer. Writing our metadata map at the root left a client nowhere for its own keys, and would overwrite any it sent. Under a namespace both fit, and a futureSTATE_DELTAhas a stable path to address.The read routes are unchanged — their bodies are ours alone, so
state_metadatais still served unwrapped there.The snapshot closes the run rather than opening it
Worth stating because the obvious placement is wrong, and the first version of this PR had it wrong.
defaultApplyEventsapplies a snapshot by dropping every local message the snapshot does not name. A client renders its question the moment it is typed, so a snapshot sent afterRUN_STARTED— before the turn is checkpointed — takes that question off the screen and leaves the answer under nothing. At the end of the run, everything the turn produced is in the thread.Ids line up, which is what makes it reconcile rather than rebuild
Placement alone was not enough: client ids were minted locally and thread ids by the checkpointer, so no id matched and the whole list was dropped and re-appended — stranding activities at the top of the turn. Three changes fix that:
idits client gave it (stream_turn(message_id=...))ToolStartedandToolFinishedcarry the assistant and tool message ids from the thread, soTOOL_CALL_STARTgets a realparentMessageIdandTOOL_CALL_RESULTa realmessageIdOne hazard this exposed and now guards: an id the thread already holds is ignored, because LangGraph's message reducer matches on id and reusing one replaces that message rather than adding this one. A client numbering messages per session, or retrying with the same id, would otherwise silently rewrite its own history. Caught by an existing test that posts
id: "u"on two turns.Verified against a live turn on a real model — every id in the closing snapshot was already one the client held:
A failed turn snapshots nothing: the thread is mid-write, and telling a client to adopt that would hand it a state the server may itself drop.
Breaking
Read
STATE_SNAPSHOTmetadata fromsnapshot.toolStaterather than fromsnapshot. The example client is updated here and builds.Under this repo's release-please settings this lands as 0.6.0 —
bump-minor-pre-majormakes a breaking change a minor while pre-1.0. Consumers pin<0.6.0, so nobody picks it up until they widen that deliberately, which is the point.Tests
348 total, lint clean, example client builds. Two existing tests read the snapshot at the root and were updated — they are the regression check that this is a wire change and not an accident.