feat(conversation): persist plan snapshots and expose them for rehydration - #916
Merged
Conversation
added 4 commits
August 21, 2026 18:11
Merged
18 tasks
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 #915
What
Makes the agent's plan / to-do snapshot survive a conversation switch, and gives the frontend a way to rehydrate it mid-turn.
Changes
persist_plan+ a dedicated relay arm —AgentStreamEvent::Planwas in the_ =>catch-all ofstream_relay.rs: forwarded to the WebSocket, never written. It now persists one row per turn (id = plan:{msg_id}), upserted, because a plan is a full-replacement snapshot per the ACP spec.The arm deliberately does not set
saw_tool_or_side_effect(that would make an otherwise-replayable turn look unsafe to retry) and does not close the active text segment (a plan refresh lands mid-reply and would shatter it into a fresh bubble) — same reasoning as the existingWorkflowProgressarm.msg_idstores the bare turn msg_id, not theplan:form: the live WS frame carries the bare id and the renderer dedupes history against live frames on${type}:${msg_id}. Storing the prefixed form would show one live card plus one history card after reload.turn_idrides inside thecontentJSON so the frontend can gate the bar on the running turn without a schema change.latest_message_of_type+GET /api/conversations/{id}/messages/latest?type=plan— required, not a nicety:upsert_messagedoes not refreshcreated_at, so a plan row stays anchored at the START of its turn and a turn with many tool calls buries it outside the default 50-message page. A just-ended turn would otherwise rehydrate with an empty bar.Deliberately not a type filter on
list_messages_page: that paginator has four SQL variants and cursor semantics (has_more_before/has_more_after) that a type filter would muddy. One extra single-row query, hitting the existingidx_messages_type_created, is the smaller blast radius.adapter/claude.rs:TodoWrite→SessionEvent::Plan— with the pairedtool_resultsuppressed. That suppression is mandatory:session_agent.rstranslatesSessionEvent::ToolResultinto a terminalAgentStreamEvent::ToolCall, and a terminal frame with no card to settle makes the renderer append a nameless junk card. A malformed payload (notodosarray) falls back to the ordinary tool path — losing the plan is acceptable, losing the card is not.map_plan_statusis re-exportedpub(crate)frombackend/mod.rsrather than copied a third time.codex_conn.rs's existing private copy is left alone.No migration
messages.typehas no CHECK constraint,contentis free-form JSON,MessageType::Planalready exists, andidx_messages_type_createdalready indexes(type, created_at DESC). Plan rows have never been written, so there is no legacy data to migrate.Version boundary on the claude lane
Bisected 2026-08-21 over the npm line: claude removed
TodoWritefrom its headless tool set in 2.1.142 —2.1.141still advertises it in thesystem:initframe,2.1.142does not. (The init frame'stoolsarray is the ground truth; it is emitted before the model sees the prompt. ThetodoFeatureEnabledsetting is unrelated — it enables the interactive TUI panel and does nothing headless.)So on 2.1.142+ this translation is dormant: the model has no such tool to call. It is kept because the logic is correct and cheap, and the CLI surface moves. The test fixture is wire-pinned to a real 2.1.141 frame (
input.todos[{content, activeForm, status}],caller:{type:"direct"}) rather than a hand-written shape.VERIFIED_CLAUDE_VERSION(2.1.235) is already past the removal.codex (
update_plan) and ACP agents emittingsessionUpdate:"plan"are unaffected — both verified live.Verification
cargo test -p aionui-db --lib— 362 passedcargo test -p aionui-conversation --lib— 425 passedcargo test -p aionui-session --lib— 598 passedcargo clippy -p aionui-db -p aionui-conversation -p aionui-session --all-targets -- -D warnings— cleanNew tests cover: first-write then same-turn upsert collapsing to one row;
turn_idpresent in persisted content;latest_message_of_typereaching a plan row buried under 60 later messages; the TodoWrite translation emitting exactly onePlanand zeroToolCall; the pairedtool_resultproducing zero terminal frames; and a malformed TodoWrite falling back toToolCall.There is also a small guard test that the new
/messages/latestroute can be registered alongside the{messageId}wildcard — axum builds its route trie eagerly, so an overlap panics at construction andcargo checkwould never catch it.