feat(api): read a thread back with its activities, not just its messages - #87
Merged
Conversation
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. They were never derived, only stored: capture writes the receipts and the captured-key map onto each tool message's artifact, and the checkpointer keeps them. So this reads them back rather than rebuilding anything. 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 property the live stream already relies on. The activity payload builders are now shared with the stream rather than reimplemented, so a receipt cannot read one way live and another after a reload. Verified against a live turn: every restored activity is byte-identical to the one the stream sent, `mcp.view` and its rendered data included. Two things degrade rather than guess. A turn whose checkpoints have been pruned has no state to describe its receipts against, so its view is not rebuilt. A `ui://` bundle comes from the deployment as it stands, so a tool removed since has no view rather than a dangling URI. Costs a second read per thread — turns_of walks the checkpoint history — which is the documented price of deriving turns from a structure that does not record them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They duplicated. 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 and the stream's are per-run, so nothing matches and every one duplicates at the end of the turn. Omitting turns and tools was not enough to prevent it: state.consumed and state.published need neither, so they came out anyway, rendered against no state at all. Now an explicit `activities` flag, off by default, and the read route is the one caller that asks for them. 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.
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.
_residuewritesstructured_content,captured_stateandinjected_stateonto each tool message's artifact, and the checkpointer keeps all three — confirmed throughJsonPlusSerializerand against a deployed thread.So each activity comes from data already in the thread:
state.consumedreceipts_of(artifact), rendered bystep_inputagainst that turn's statestate.published_published(artifact)mcp.viewrestore_structured(artifact, state)plus the tool'sui://URIanswer.citationsanswer_citations(ai_message)An activity is a message in AG-UI, so they travel in
messagesand 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_contentandcitations_contentare 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_artifactsmuggling_view/_filledneeded: 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:
tools.withheldis correctly live-only: it announces what this build cannot do, which is not part of the conversation.What degrades, rather than guessing
ui://bundle comes from the deployment as it stands, so a tool removed since has no view rather than a dangling URI.thread_messages.Cost
A second read per thread —
turns_ofwalks 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 —
originsfoldsstate.publishedout 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_resultis now public astool_finished, since the readback builds the same thing from the same message. AndRunRequest'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_SNAPSHOTkeeps every local activity whether the snapshot names it or not —defaultApplyEventsexempts 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
turnsandtoolswas not enough to stop it:state.consumedandstate.publishedneed neither, so they came out anyway — and rendered against no state at all. There is now an explicitactivitiesflag, off by default, andGET /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.