Skip to content

feat(api): read a thread back with its activities, not just its messages - #87

Merged
ciaransweet merged 2 commits into
mainfrom
feat/restore-past-activities
Aug 17, 2026
Merged

feat(api): read a thread back with its activities, not just its messages#87
ciaransweet merged 2 commits into
mainfrom
feat/restore-past-activities

Conversation

@ciaransweet

@ciaransweet ciaransweet commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

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 notdefaultApplyEvents 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.

ciaransweet and others added 2 commits August 17, 2026 11:46
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
ciaransweet merged commit d0549ea into main Aug 17, 2026
4 checks passed
@ciaransweet
ciaransweet deleted the feat/restore-past-activities branch August 17, 2026 11:20
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant