Skip to content

feat(conversation): persist plan snapshots and expose them for rehydration - #916

Merged
kaizhou-lab merged 5 commits into
mainfrom
feat/plan-display-redesign
Aug 22, 2026
Merged

feat(conversation): persist plan snapshots and expose them for rehydration#916
kaizhou-lab merged 5 commits into
mainfrom
feat/plan-display-redesign

Conversation

@kaizhou-lab

Copy link
Copy Markdown
Contributor

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 armAgentStreamEvent::Plan was in the _ => catch-all of stream_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 existing WorkflowProgress arm.

msg_id stores the bare turn msg_id, not the plan: 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_id rides inside the content JSON 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_message does not refresh created_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 existing idx_messages_type_created, is the smaller blast radius.

adapter/claude.rs: TodoWriteSessionEvent::Plan — with the paired tool_result suppressed. That suppression is mandatory: session_agent.rs translates SessionEvent::ToolResult into a terminal AgentStreamEvent::ToolCall, and a terminal frame with no card to settle makes the renderer append a nameless junk card. A malformed payload (no todos array) falls back to the ordinary tool path — losing the plan is acceptable, losing the card is not.

map_plan_status is re-exported pub(crate) from backend/mod.rs rather than copied a third time. codex_conn.rs's existing private copy is left alone.

No migration

messages.type has no CHECK constraint, content is free-form JSON, MessageType::Plan already exists, and idx_messages_type_created already 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 TodoWrite from its headless tool set in 2.1.1422.1.141 still advertises it in the system:init frame, 2.1.142 does not. (The init frame's tools array is the ground truth; it is emitted before the model sees the prompt. The todoFeatureEnabled setting 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 emitting sessionUpdate:"plan" are unaffected — both verified live.

Verification

  • cargo test -p aionui-db --lib — 362 passed
  • cargo test -p aionui-conversation --lib — 425 passed
  • cargo test -p aionui-session --lib — 598 passed
  • cargo clippy -p aionui-db -p aionui-conversation -p aionui-session --all-targets -- -D warnings — clean
  • Live: plan rows confirmed written for a codex turn and for a grok (ACP) turn.

New tests cover: first-write then same-turn upsert collapsing to one row; turn_id present in persisted content; latest_message_of_type reaching a plan row buried under 60 later messages; the TodoWrite translation emitting exactly one Plan and zero ToolCall; the paired tool_result producing zero terminal frames; and a malformed TodoWrite falling back to ToolCall.

There is also a small guard test that the new /messages/latest route can be registered alongside the {messageId} wildcard — axum builds its route trie eagerly, so an overlap panics at construction and cargo check would never catch it.

@kaizhou-lab
kaizhou-lab merged commit b2f12e9 into main Aug 22, 2026
6 checks passed
@kaizhou-lab
kaizhou-lab deleted the feat/plan-display-redesign branch August 22, 2026 07:26
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.

feat(conversation): persist plan snapshots and expose them for rehydration

1 participant