diff --git a/crates/aionui-conversation/src/routes.rs b/crates/aionui-conversation/src/routes.rs index 4ae84dd38..ef0abc193 100644 --- a/crates/aionui-conversation/src/routes.rs +++ b/crates/aionui-conversation/src/routes.rs @@ -122,6 +122,9 @@ pub fn conversation_routes(state: ConversationRouterState) -> Router { .route("/api/conversations/{id}/fork", post(fork)) .route("/api/conversations/{id}/associated", get(associated)) .route("/api/conversations/{id}/messages", get(list_msg).post(send_msg)) + // MUST precede the `{messageId}` wildcard below: registered after it, + // "latest" would be captured as a message id and 404. + .route("/api/conversations/{id}/messages/latest", get(latest_msg)) .route("/api/conversations/{id}/messages/{messageId}", get(get_msg)) .route("/api/conversations/{id}/artifacts", get(list_artifacts)) .route("/api/conversations/{id}/artifacts/{artifactId}", patch(update_artifact)) @@ -266,6 +269,25 @@ struct MessagePathParams { message_id: String, } +#[derive(serde::Deserialize)] +struct LatestMessageQuery { + r#type: String, +} + +async fn latest_msg( + State(state): State, + Extension(user): Extension, + Path(id): Path, + Query(query): Query, +) -> Result>>, ApiError> { + let result = state + .service + .latest_message_of_type(&user.id, &id, &query.r#type) + .await + .map_err(ApiError::from)?; + Ok(Json(ApiResponse::ok(result))) +} + async fn get_msg( State(state): State, Extension(user): Extension, @@ -606,3 +628,24 @@ mod error_mapping_tests { assert_eq!(details["port"], 18789); } } + +#[cfg(test)] +mod route_shape_tests { + /// axum builds its route trie eagerly, so an overlapping registration panics + /// at construction, not at request time — `cargo check` would never catch it. + /// `/messages/latest` deliberately sits before the `{messageId}` wildcard; + /// this test is the guard that the pair stays registrable together. + #[test] + fn latest_message_route_coexists_with_the_message_id_wildcard() { + let router: axum::Router<()> = axum::Router::new() + .route( + "/api/conversations/{id}/messages/latest", + axum::routing::get(|| async { "latest" }), + ) + .route( + "/api/conversations/{id}/messages/{messageId}", + axum::routing::get(|| async { "by-id" }), + ); + let _ = router.into_make_service(); + } +} diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 5cd4aebb0..720cf91f8 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -3234,6 +3234,32 @@ impl ConversationService { } /// Return one full message for a conversation after verifying ownership. + /// Newest message of one type, or `None`. + /// + /// Serves the plan bar's rehydration: the paginated load alone cannot find a + /// plan row that its own turn buried under later messages (`upsert_message` + /// does not refresh `created_at`). + pub async fn latest_message_of_type( + &self, + user_id: &str, + conversation_id: &str, + message_type: &str, + ) -> Result, ConversationError> { + self.conversation_repo + .get(user_id, conversation_id) + .await? + .ok_or_else(|| ConversationError::NotFound { + id: conversation_id.to_owned(), + })?; + + let row = self + .conversation_repo + .latest_message_of_type(user_id, conversation_id, message_type) + .await?; + + row.map(row_to_message_response).transpose() + } + pub async fn get_message( &self, user_id: &str, diff --git a/crates/aionui-conversation/src/stream_persistence.rs b/crates/aionui-conversation/src/stream_persistence.rs index 1761a8567..1c68c8734 100644 --- a/crates/aionui-conversation/src/stream_persistence.rs +++ b/crates/aionui-conversation/src/stream_persistence.rs @@ -498,6 +498,58 @@ impl StreamPersistenceAdapter { } } + /// Persist a plan / to-do snapshot. + /// + /// One row per turn (`plan:{msg_id}`), upserted: a plan is a + /// FULL-REPLACEMENT snapshot (ACP spec — "the Agent MUST send a complete + /// list of all plan entries in each update"), so every frame overwrites the + /// previous entries instead of stacking rows. + /// + /// `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 here would make + /// a reloaded conversation show one live card plus one history card. + /// + /// Gated as `ToolCallPersist` — the same "mid-turn content write" lifecycle + /// class; a plan needs no gating rule of its own. + #[tracing::instrument(skip_all)] + pub async fn persist_plan( + &self, + data: &aionui_ai_agent::protocol::events::session_updates::PlanEventData, + turn_id: &str, + ) { + if !self.allows_write(RuntimeWriteKind::ToolCallPersist) { + return; + } + + // `turn_id` rides INSIDE the content JSON: the column set is fixed and + // this feature deliberately ships without a migration. The frontend + // gates the plan bar on it matching the running turn, so a finished + // turn's checklist cannot linger over the next one. + let mut value = serde_json::to_value(data).unwrap_or_default(); + if let Some(obj) = value.as_object_mut() { + obj.insert("turn_id".into(), serde_json::Value::String(turn_id.to_owned())); + } + let content = value.to_string(); + + let row = MessageRow { + id: format!("plan:{}", self.msg_id), + conversation_id: self.conversation_id.clone(), + msg_id: Some(self.msg_id.clone()), + r#type: "plan".into(), + content, + position: Some("left".into()), + status: Some("finish".into()), + hidden: false, + created_at: now_ms(), + backend_turn_id: self.current_backend_turn_id(), + }; + + if let Err(e) = self.repo.upsert_message(&self.user_id, &row).await { + log_persist_error(&e, "Failed to upsert plan message"); + } + } + /// Persist an ACP (Claude CLI) tool call event. #[tracing::instrument(skip_all)] pub async fn persist_acp_tool_call( diff --git a/crates/aionui-conversation/src/stream_relay.rs b/crates/aionui-conversation/src/stream_relay.rs index 92bbf05ba..8e9441a4a 100644 --- a/crates/aionui-conversation/src/stream_relay.rs +++ b/crates/aionui-conversation/src/stream_relay.rs @@ -704,6 +704,17 @@ impl StreamRelay { // The raw frame still reaches the frontend via message.stream. self.forward_to_websocket(&event); } + AgentStreamEvent::Plan(data) => { + // A plan is a side-channel SNAPSHOT, not turn work. It + // 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 otherwise shatter that + // reply into a fresh bubble, the same reasoning as the + // WorkflowProgress arm above. + self.forward_to_websocket(&event); + self.adapter.persist_plan(data, &self.turn_id).await; + } _ => { self.forward_to_websocket(&event); } @@ -2533,6 +2544,68 @@ mod tests { // ── Tool persistence tests ──────────────────────────────────── + /// A plan snapshot must reach the DB, not just the WebSocket: a turn that + /// keeps running in the background has to rehydrate its plan bar when the + /// user comes back to the conversation. + /// + /// One row per turn, upserted — a plan is a FULL-REPLACEMENT snapshot, so a + /// second frame overwrites the first rather than stacking a second card. + #[tokio::test] + async fn run_plan_persists_message() { + use aionui_ai_agent::protocol::events::session_updates::PlanEventData; + + let repo = Arc::new(RecordingRepo::new()); + let bus = Arc::new(aionui_realtime::BroadcastEventBus::new(64)); + let (tx, _) = broadcast::channel(64); + + let relay = StreamRelay::new( + "conv-1".into(), + "asst-1".into(), + "turn-1".into(), + "user-1".into(), + repo.clone(), + bus.clone(), + ); + + let rx = tx.subscribe(); + + tx.send(AgentStreamEvent::Plan(PlanEventData { + session_id: None, + entries: vec![json!({"content": "step one", "status": "pending"})], + })) + .unwrap(); + tx.send(AgentStreamEvent::Plan(PlanEventData { + session_id: None, + entries: vec![json!({"content": "step one", "status": "completed"})], + })) + .unwrap(); + tx.send(AgentStreamEvent::Finish(FinishEventData::default())).unwrap(); + + relay.consume(rx).await; + + let inserts = repo.take_inserts(); + let plans: Vec<_> = inserts.iter().filter(|m| m.r#type == "plan").collect(); + assert_eq!(plans.len(), 1, "one row per turn, not one per frame: {inserts:?}"); + + let row = plans[0]; + assert_eq!(row.id, "plan:asst-1"); + // BARE msg_id: the live WS frame carries the turn msg_id, and the + // renderer dedupes history against live frames on `${type}:${msg_id}`. + assert_eq!(row.msg_id.as_deref(), Some("asst-1")); + + let updates = repo.take_updates(); + let (_, upd) = updates + .iter() + .find(|(id, _)| id == "plan:asst-1") + .expect("the second frame must upsert the same row"); + + let content: serde_json::Value = serde_json::from_str(upd.content.as_deref().unwrap()).unwrap(); + assert_eq!(content["entries"][0]["status"], "completed"); + // turn_id rides inside content (the column set is fixed); the plan bar + // gates on it matching the running turn. + assert_eq!(content["turn_id"], "turn-1"); + } + #[tokio::test] async fn run_tool_call_persists_message() { use aionui_ai_agent::protocol::events::tool_call::{ToolCallEventData, ToolCallStatus}; diff --git a/crates/aionui-db/src/repository/conversation.rs b/crates/aionui-db/src/repository/conversation.rs index db6a6abe6..6f026bb30 100644 --- a/crates/aionui-db/src/repository/conversation.rs +++ b/crates/aionui-db/src/repository/conversation.rs @@ -194,6 +194,26 @@ pub trait IConversationRepository: Send + Sync { )) } + /// Newest message of one type in a conversation, or `None`. + /// + /// Exists for the plan bar: `upsert_message` does not refresh `created_at`, + /// so a plan row stays anchored at the start of its turn and a busy turn + /// buries it outside the default message page. Deliberately NOT a filter on + /// the shared paginator — that has four SQL variants and cursor semantics + /// (`has_more_before` / `has_more_after`) that a type filter would muddy. + /// + /// Default is unsupported so test doubles that never need it can skip it. + async fn latest_message_of_type( + &self, + _user_id: &str, + _conversation_id: &str, + _message_type: &str, + ) -> Result, DbError> { + Err(DbError::Init( + "latest_message_of_type is not supported by this repository".into(), + )) + } + /// Resolves the backend turn anchor for a fork point: the `backend_turn_id` /// of the nearest row at or before the `(created_at, id)` cursor that has /// one. `Ok(None)` when no row up to the fork point carries an anchor diff --git a/crates/aionui-db/src/repository/sqlite_conversation.rs b/crates/aionui-db/src/repository/sqlite_conversation.rs index 6339d4c75..8c9db04ab 100644 --- a/crates/aionui-db/src/repository/sqlite_conversation.rs +++ b/crates/aionui-db/src/repository/sqlite_conversation.rs @@ -760,6 +760,31 @@ impl IConversationRepository for SqliteConversationRepository { // ── Message operations ────────────────────────────────────────── + async fn latest_message_of_type( + &self, + user_id: &str, + conversation_id: &str, + message_type: &str, + ) -> Result, DbError> { + self.ensure_conversation_for_user(user_id, conversation_id).await?; + // Hits idx_messages_type_created (type, created_at DESC). + let row = sqlx::query_as::<_, MessageRow>( + "SELECT m.* FROM messages m \ + INNER JOIN conversations c ON c.id = m.conversation_id \ + WHERE c.user_id = ? \ + AND m.conversation_id = ? \ + AND m.type = ? \ + ORDER BY m.created_at DESC, m.id DESC \ + LIMIT 1", + ) + .bind(user_id) + .bind(conversation_id) + .bind(message_type) + .fetch_optional(&self.pool) + .await?; + Ok(row) + } + async fn list_messages_page( &self, user_id: &str, @@ -2310,6 +2335,49 @@ mod tests { assert!(!page1.has_more_after); } + /// The plan bar needs the newest plan row regardless of how many messages the + /// turn produced after it: `upsert_message` does not refresh `created_at`, so a + /// plan row stays anchored at the START of its turn and a busy turn buries it + /// far outside the default 50-message page. + #[tokio::test] + async fn latest_message_of_type_returns_the_newest_matching_row() { + let (repo, _db) = setup().await; + let conv = sample_conversation(SYSTEM_USER_ID); + repo.create(&conv).await.unwrap(); + + for created_at in [100, 300] { + let mut msg = sample_message(&conv.id); + msg.id = format!("plan-{created_at}"); + msg.r#type = "plan".to_string(); + msg.content = format!(r#"{{"entries":[],"turn_id":"turn-{created_at}"}}"#); + msg.created_at = created_at; + repo.insert_message(&conv.user_id, &msg).await.unwrap(); + } + // Bury the plan rows well past any realistic page size. + for i in 0..60 { + let mut msg = sample_message(&conv.id); + msg.id = aionui_common::generate_prefixed_id("msg"); + msg.created_at = 400 + i; + repo.insert_message(&conv.user_id, &msg).await.unwrap(); + } + + let found = repo + .latest_message_of_type(&conv.user_id, &conv.id, "plan") + .await + .unwrap() + .expect("the newest plan row must be reachable"); + assert_eq!(found.id, "plan-300"); + assert_eq!(found.created_at, 300); + + assert!( + repo.latest_message_of_type(&conv.user_id, &conv.id, "skill_suggest") + .await + .unwrap() + .is_none(), + "a type with no rows must return None, not an error" + ); + } + #[tokio::test] async fn before_pages_walk_history_without_duplicates() { let (repo, _db) = setup().await; diff --git a/crates/aionui-session/src/adapter/claude.rs b/crates/aionui-session/src/adapter/claude.rs index 5bd3fe4bf..bf94283e2 100644 --- a/crates/aionui-session/src/adapter/claude.rs +++ b/crates/aionui-session/src/adapter/claude.rs @@ -61,6 +61,12 @@ pub struct ClaudeAdapter { /// stale leftover here is harmless (set membership only biases WHICH resolve /// event a cancel emits, and ids are unique per control request). ask_requests: std::collections::HashSet, + /// tool_use_ids of TodoWrite calls this session translated into + /// `SessionEvent::Plan`. Their paired `tool_result` must be swallowed: + /// with no ToolCall to settle, the terminal frame it would produce makes + /// the renderer append a nameless junk card. Cleared at each turn + /// terminal, so it never grows across a session. + todo_tool_use_ids: std::collections::HashSet, } /// Per-message streaming state for `--include-partial-messages`. Reset on each @@ -446,8 +452,41 @@ impl ClaudeAdapter { tracing::warn!(item_id = %item_id, "claude tool_use has an empty name; dropping malformed call"); } else { let input = b.get("input").cloned().unwrap_or(Value::Null); + let tool_use_id = b.get("id").and_then(Value::as_str).unwrap_or("").to_string(); + + // claude's TodoWrite is a plan SNAPSHOT, not a tool step — + // the same treatment the ACP bridge gives it, so both lanes + // render one plan bar instead of a stray tool card. A + // malformed payload falls through to the tool path: losing + // the plan is acceptable, losing the card is not. + if raw_name == "TodoWrite" + && let Some(todos) = input.get("todos").and_then(Value::as_array) + { + let entries: Vec = todos + .iter() + .filter_map(|t| { + let content = t.get("content").and_then(Value::as_str)?.to_string(); + let status = crate::backend::map_plan_status( + t.get("status").and_then(Value::as_str).unwrap_or(""), + ); + Some(crate::event::PlanEntry { + content, + status, + // claude's todos carry no priority field. + priority: None, + }) + }) + .collect(); + self.todo_tool_use_ids.insert(tool_use_id); + out.push(SessionEvent::Plan { + entries, + explanation: None, + }); + continue; + } + out.push(SessionEvent::ToolCall { - tool_use_id: b.get("id").and_then(Value::as_str).unwrap_or("").to_string(), + tool_use_id, // Presentation only: a bare tool name ("Bash" × 73% of all // calls) says nothing about what the step is doing. The raw // `input` below is untouched. @@ -476,7 +515,7 @@ impl ClaudeAdapter { /// user frames carry synthesized `tool_result` blocks, referring back by /// tool_use_id. - fn parse_user(&self, v: &Value) -> Vec { + fn parse_user(&mut self, v: &Value) -> Vec { // 009 H5: same top-level attribution as parse_assistant — a subagent's // tool_result frame carries the parent's tool_use_id beside `message`. let parent_tool_use_id = v.get("parent_tool_use_id").and_then(Value::as_str).map(str::to_string); @@ -489,8 +528,14 @@ impl ClaudeAdapter { let mut out = Vec::new(); for b in &blocks { if b.get("type").and_then(Value::as_str) == Some("tool_result") { + let tool_use_id = b.get("tool_use_id").and_then(Value::as_str).unwrap_or("").to_string(); + // The TodoWrite call this answers became a Plan, so there is no + // card for this terminal frame to settle (see `todo_tool_use_ids`). + if self.todo_tool_use_ids.remove(&tool_use_id) { + continue; + } out.push(SessionEvent::ToolResult { - tool_use_id: b.get("tool_use_id").and_then(Value::as_str).unwrap_or("").to_string(), + tool_use_id, // 009 R7/H3: the wire block carries is_error on a failed/rejected // tool (default false = success). Carrying it keeps a red tool red. is_error: b.get("is_error").and_then(Value::as_bool).unwrap_or(false), @@ -535,6 +580,9 @@ impl ClaudeAdapter { /// terminal — codex already emits UsageDelta (map_usage); this closes the /// claude/codex asymmetry. The wrapping ClaudeConnection inherits both for free. fn parse_result(&mut self, v: &Value) -> Vec { + // Turn terminal: any TodoWrite whose tool_result never arrived is dead + // correlation state. Clearing here bounds the set to one turn. + self.todo_tool_use_ids.clear(); let is_error = v.get("is_error").and_then(Value::as_bool).unwrap_or(false); let result_text = match v.get("result").and_then(Value::as_str) { Some(s) if !s.is_empty() => s.to_string(), @@ -1640,7 +1688,7 @@ mod tests { #[test] fn parse_user_tool_result_string_content_to_text() { - let a = ClaudeAdapter::new(); + let mut a = ClaudeAdapter::new(); let frame = r#"{"type":"user","message":{"role":"user","content":[ {"type":"tool_result","tool_use_id":"tu1","content":"hello stdout"}]}}"#; let v: serde_json::Value = serde_json::from_str(frame).unwrap(); @@ -1683,6 +1731,111 @@ mod tests { ); } + /// claude's TodoWrite is a plan SNAPSHOT, not a tool step. The ACP bridge + /// (claude-code-acp) already translates it into a `plan` session update and + /// suppresses the tool card; the direct-CLI lane must match, or the same + /// conversation shows a to-do bar over ACP and a bare "TodoWrite" tool card + /// here. + /// + /// Wire-pinned against a REAL 2.1.141 frame (captured 2026-08-21), hence the + /// `activeForm` and `caller` keys we ignore: never hand-write a shape we have + /// not seen on the wire. + /// + /// VERSION NOTE: claude removed TodoWrite from the headless tool set in + /// **2.1.142** (bisected 2026-08-21: 2.1.141 advertises it, 2.1.142 does + /// not — the `system:init` frame's `tools` array is the ground truth). On + /// 2.1.142+ this arm is dormant: the model has no such tool to call. It is + /// kept because the translation is correct and cheap, and the CLI surface + /// moves — not because it fires today. + #[test] + fn todo_write_becomes_a_plan_event_and_no_tool_call() { + let mut a = ClaudeAdapter::new(); + // Verbatim 2.1.141 wire shape, including the `activeForm` / `caller` + // keys the translation ignores. + let frame = r#"{"type":"assistant","message":{"role":"assistant","content":[ + {"type":"tool_use","id":"tu_todo_1","name":"TodoWrite","caller":{"type":"direct"},"input":{"todos":[ + {"content":"read the readme","activeForm":"Reading the readme","status":"completed"}, + {"content":"count the files","activeForm":"Counting the files","status":"in_progress"}, + {"content":"summarize","activeForm":"Summarizing","status":"pending"}]}}]}}"#; + let v: serde_json::Value = serde_json::from_str(frame).unwrap(); + let events = a.parse_assistant(&v); + + assert!( + !events.iter().any(|e| matches!(e, SessionEvent::ToolCall { .. })), + "TodoWrite must not surface as a tool card, got {events:?}" + ); + let [SessionEvent::Plan { entries, explanation }] = events.as_slice() else { + panic!("expected exactly one Plan, got {events:?}"); + }; + assert_eq!(explanation, &None); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].content, "read the readme"); + assert_eq!(entries[0].status, crate::event::PlanStatus::Completed); + assert_eq!(entries[1].status, crate::event::PlanStatus::InProgress); + assert_eq!(entries[2].status, crate::event::PlanStatus::Pending); + } + + /// The paired `tool_result` must be swallowed too. It translates to a + /// TERMINAL `AgentStreamEvent::ToolCall` downstream, and a terminal frame + /// with no card to settle makes the renderer append a nameless junk card. + #[test] + fn todo_write_tool_result_is_suppressed() { + let mut a = ClaudeAdapter::new(); + let assistant = r#"{"type":"assistant","message":{"role":"assistant","content":[ + {"type":"tool_use","id":"tu_todo_1","name":"TodoWrite","input":{"todos":[ + {"content":"step","status":"pending"}]}}]}}"#; + a.parse_assistant(&serde_json::from_str(assistant).unwrap()); + + let user = r#"{"type":"user","message":{"role":"user","content":[ + {"type":"tool_result","tool_use_id":"tu_todo_1","content":"ok"}]}}"#; + let events = a.parse_user(&serde_json::from_str(user).unwrap()); + + assert!( + !events.iter().any(|e| matches!(e, SessionEvent::ToolResult { .. })), + "the TodoWrite tool_result must not reach the stream, got {events:?}" + ); + } + + /// An unrelated tool's `tool_result` must still flow — the suppression is + /// keyed on the TodoWrite tool_use_id, not on the frame shape. + #[test] + fn other_tool_results_still_flow_after_a_todo_write() { + let mut a = ClaudeAdapter::new(); + let assistant = r#"{"type":"assistant","message":{"role":"assistant","content":[ + {"type":"tool_use","id":"tu_todo_1","name":"TodoWrite","input":{"todos":[ + {"content":"step","status":"pending"}]}}, + {"type":"tool_use","id":"tu_bash_1","name":"Bash","input":{"command":"ls"}}]}}"#; + a.parse_assistant(&serde_json::from_str(assistant).unwrap()); + + let user = r#"{"type":"user","message":{"role":"user","content":[ + {"type":"tool_result","tool_use_id":"tu_bash_1","content":"a.txt"}]}}"#; + let events = a.parse_user(&serde_json::from_str(user).unwrap()); + + assert!( + events + .iter() + .any(|e| matches!(e, SessionEvent::ToolResult { tool_use_id, .. } if tool_use_id == "tu_bash_1")), + "a normal tool_result must still emit, got {events:?}" + ); + } + + /// A malformed TodoWrite must not ALSO lose the tool card — degrade to the + /// ordinary tool path rather than swallowing the call entirely. + #[test] + fn todo_write_without_todos_array_falls_back_to_tool_call() { + let mut a = ClaudeAdapter::new(); + let frame = r#"{"type":"assistant","message":{"role":"assistant","content":[ + {"type":"tool_use","id":"tu_todo_2","name":"TodoWrite","input":{"unexpected":1}}]}}"#; + let v: serde_json::Value = serde_json::from_str(frame).unwrap(); + let events = a.parse_assistant(&v); + + assert!( + events.iter().any(|e| matches!(e, SessionEvent::ToolCall { .. })), + "a malformed TodoWrite must still produce its tool card, got {events:?}" + ); + assert!(!events.iter().any(|e| matches!(e, SessionEvent::Plan { .. }))); + } + /// Regression guard for the other direction: a well-formed `tool_use` (real /// name) still emits its `ToolCall` — the #486 guard must not over-drop. #[test] @@ -1798,7 +1951,7 @@ mod tests { #[test] fn parse_user_tool_result_array_with_image_to_text_and_image() { use base64::Engine as _; - let a = ClaudeAdapter::new(); + let mut a = ClaudeAdapter::new(); let b64 = base64::engine::general_purpose::STANDARD.encode([1u8, 2, 3]); let frame = format!( r#"{{"type":"user","message":{{"role":"user","content":[ @@ -1837,7 +1990,7 @@ mod tests { /// (default-false) so the routing bit is pinned on both edges. #[test] fn parse_user_failed_tool_result_carries_is_error_true() { - let a = ClaudeAdapter::new(); + let mut a = ClaudeAdapter::new(); // A failed tool: is_error:true + the error text as content. let frame = r#"{"type":"user","message":{"role":"user","content":[ {"type":"tool_result","tool_use_id":"tf1","is_error":true, @@ -3184,7 +3337,7 @@ mod tests { "type": "user", "message": { "role": "user", "content": content } }); - let a = ClaudeAdapter::new(); + let mut a = ClaudeAdapter::new(); let events = a.parse_user(&frame); // (1) must not panic let results: Vec<&SessionEvent> = @@ -3213,7 +3366,7 @@ mod tests { {"type": "tool_result", "tool_use_id": "big1", "content": big} ]} }); - let a = ClaudeAdapter::new(); + let mut a = ClaudeAdapter::new(); match a.parse_user(&frame).as_slice() { [SessionEvent::ToolResult { content, .. }] => match content.as_slice() { [crate::event::ToolResultContent::Text(t)] => { diff --git a/crates/aionui-session/src/backend/acp_conn.rs b/crates/aionui-session/src/backend/acp_conn.rs index 51df6ef21..8303f6768 100644 --- a/crates/aionui-session/src/backend/acp_conn.rs +++ b/crates/aionui-session/src/backend/acp_conn.rs @@ -1926,7 +1926,7 @@ async fn map_update( /// LC-8a: normalize an ACP/codex plan-step status string → canonical `PlanStatus` /// (I8). camelCase `inProgress` (codex) AND snake_case `in_progress` (ACP) both map /// to `InProgress`; unknown → `Pending` (never panic). -fn map_plan_status(s: &str) -> crate::event::PlanStatus { +pub(crate) fn map_plan_status(s: &str) -> crate::event::PlanStatus { use crate::event::PlanStatus; match s { "inProgress" | "in_progress" => PlanStatus::InProgress, diff --git a/crates/aionui-session/src/backend/mod.rs b/crates/aionui-session/src/backend/mod.rs index 3e4cf5e67..fda6b7a18 100644 --- a/crates/aionui-session/src/backend/mod.rs +++ b/crates/aionui-session/src/backend/mod.rs @@ -22,6 +22,9 @@ mod suspend; mod types; pub use acp_conn::{AcpConnection, AcpSessionBackend, acp_capabilities}; +// Shared plan-status normalizer: the claude adapter's TodoWrite translation +// reuses it rather than adding a third copy of the same match. +pub(crate) use acp_conn::map_plan_status; pub use antigravity::{AntigravityConnection, AntigravitySessionBackend, antigravity_capabilities}; pub use claude_conn::{ClaudeConnection, ClaudeSessionBackend}; pub use cli_version::{