diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index b82b6f47d..28b05e470 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -11091,9 +11091,43 @@ async fn emit_conversation_update( // `normalize_goal_status` unchanged; its extra fields // (createdAt/updatedAt/iterations/lastReason/controlMethod) // survive inside the marker's raw goal object for the card. - // (`info.title` is Codex's native thread name; it is adopted via the - // parser auto-title path on the next conversation fetch, not here, to - // keep this DB-agnostic emit path unchanged — see parsers/codex.rs.) + // `info.title` is the agent's live session name (Codex thread name, + // Claude ACP 0.69+ generated titles, anyone else who publishes the + // field). Apply it immediately via a dedicated lifecycle event + // rather than waiting for the next conversation fetch. Goal-only + // updates leave title undefined and emit nothing. Identical repeats + // are skipped (CodeBuddy resends its fallback after every turn). + // A title that arrives before the row is bound is dropped, not + // remembered, so a later resend is still accepted. If it never + // comes back, the next detail load recovers it only for agents + // whose own transcript carries the name (Codex's session index, + // Claude's `ai-title`) — a custom ACP agent's is gone, because + // `parsers/acp_native.rs` records no `session_info_update` and can + // only ever title a session by its first prompt. + if let Some(title) = crate::acp::session_title::native_title_from_session_info( + info.title.value().map(|s| s.as_str()), + ) { + // Test and set under ONE write lock. Nothing can interleave + // here today — a session's notifications are handled serially, + // and the only other writer of `last_native_title` is the + // `ConversationLinked` arm, which is emitted ONLY while the row + // is still unbound and therefore can never race a title this + // admits. That safety currently rests on two guards in + // different files agreeing; keeping the halves in one critical + // section makes it hold by construction instead. + let admit = { + let mut s = state.write().await; + let admit = s.conversation_id.is_some() + && s.last_native_title.as_deref() != Some(title.as_str()); + if admit { + s.last_native_title = Some(title.clone()); + } + admit + }; + if admit { + emit_with_state(state, emitter, AcpEvent::NativeSessionTitle { title }).await; + } + } let neutral_goal_channel = state.read().await.neutral_goal_channel; if let Some(goal) = session_info_goal_value(neutral_goal_channel, info.meta.as_ref()) @@ -11759,6 +11793,145 @@ mod tests { assert!(session_info_goal_value(true, None).is_none()); } + // --- live ACP session title (`session_info_update.title`) -------------- + + /// Drive one `session_info_update` carrying `title` through + /// `emit_conversation_update`. + async fn drive_session_info_title(state: &Arc>, title: &str) { + let update: SessionUpdate = serde_json::from_value(serde_json::json!({ + "sessionUpdate": "session_info_update", + "title": title, + })) + .expect("valid session_info_update wire shape"); + let mut cache = ToolCallOutputCache::default(); + let mut cb = CodeBuddyLiveState::default(); + emit_conversation_update( + state, + &EventEmitter::Noop, + AgentType::CodeBuddy, + update, + None, + &mut cache, + &mut cb, + ) + .await; + } + + /// Titles emitted on this connection so far, oldest first. + async fn emitted_native_titles(state: &Arc>) -> Vec { + state + .read() + .await + .recent_events_after(0) + .unwrap_or_default() + .iter() + .filter_map(|e| match &e.payload { + AcpEvent::NativeSessionTitle { title } => Some(title.clone()), + _ => None, + }) + .collect() + } + + fn title_test_state(conversation_id: Option) -> Arc> { + let mut st = SessionState::new( + "conn-title".to_string(), + AgentType::CodeBuddy, + None, + "win".to_string(), + None, + ); + st.conversation_id = conversation_id; + Arc::new(RwLock::new(st)) + } + + /// A changed title emits; the SAME title arriving again does not. CodeBuddy + /// (`sendPendingTitleUpdate`) resends its 80-code-unit fallback after every + /// completed prompt with no last-sent guard of its own, and that string + /// differs from the 100-char title our own parser derives from the same + /// first message — so without this skip the sidebar name would flip on + /// every turn (live write, then session-file parse, repeat). + #[tokio::test] + async fn session_info_title_emits_once_and_skips_an_identical_repeat() { + let state = title_test_state(Some(7)); + + drive_session_info_title(&state, "Fix the login flow").await; + drive_session_info_title(&state, "Fix the login flow").await; + drive_session_info_title(&state, " Fix the login flow ").await; // same after trim + drive_session_info_title(&state, "Fix the signup flow").await; + + assert_eq!( + emitted_native_titles(&state).await, + vec![ + "Fix the login flow".to_string(), + "Fix the signup flow".to_string() + ], + "only a CHANGED title may reach the lifecycle worker" + ); + } + + /// A title published before the first prompt binds the row has nowhere to + /// land, so it is dropped — and deliberately NOT remembered, so the resend + /// that follows `ConversationLinked` is still accepted. Guards the + /// `conversation_id.is_some()` half of the skip: a stale cache here would + /// leave the row "Untitled" for the rest of the connection. + #[tokio::test] + async fn session_info_title_dropped_while_unbound_is_accepted_after_link() { + let state = title_test_state(None); + + drive_session_info_title(&state, "Fix the login flow").await; + assert!( + emitted_native_titles(&state).await.is_empty(), + "no row to write to yet" + ); + assert!( + state.read().await.last_native_title.is_none(), + "a dropped title must not poison the skip-cache" + ); + + state.write().await.apply_event(&AcpEvent::ConversationLinked { + conversation_id: 7, + folder_id: 1, + parent_conversation_id: None, + parent_tool_use_id: None, + }); + + drive_session_info_title(&state, "Fix the login flow").await; + assert_eq!( + emitted_native_titles(&state).await, + vec!["Fix the login flow".to_string()], + "the same title must be accepted once the row exists" + ); + } + + /// Goal-only / metadata-only `session_info_update`s (the common case for + /// codex `/goal` transitions) carry no title and must not queue the + /// lifecycle worker. + #[tokio::test] + async fn session_info_without_a_title_emits_no_native_title() { + let state = title_test_state(Some(7)); + for wire in [ + serde_json::json!({"sessionUpdate": "session_info_update"}), + serde_json::json!({"sessionUpdate": "session_info_update", "title": null}), + serde_json::json!({"sessionUpdate": "session_info_update", "title": " "}), + ] { + let update: SessionUpdate = + serde_json::from_value(wire).expect("valid session_info_update wire shape"); + let mut cache = ToolCallOutputCache::default(); + let mut cb = CodeBuddyLiveState::default(); + emit_conversation_update( + &state, + &EventEmitter::Noop, + AgentType::Codex, + update, + None, + &mut cache, + &mut cb, + ) + .await; + } + assert!(emitted_native_titles(&state).await.is_empty()); + } + #[test] fn goal_advertised_control_reads_method_and_actions_or_falls_back() { // claude 0.66+ / codex 1.2+ advertisements. diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index 0d3053336..ec3ce9065 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -35,7 +35,7 @@ use tokio::sync::RwLock; /// Per-connection worker queue depth. Sized for the **filtered** event set /// only (see `is_lifecycle_relevant`) — high-frequency events (ContentDelta, /// ToolCall*, PermissionRequest) are dropped at the dispatcher and never -/// enter the queue. The remaining 5 event types arrive at most a handful +/// enter the queue. The remaining 6 event types arrive at most a handful /// of times per turn, so 64 slots is comfortable headroom for a sustained /// SQLite stall without forcing the dispatcher to block on `send`. const WORKER_QUEUE_CAPACITY: usize = 64; @@ -65,6 +65,7 @@ fn is_lifecycle_relevant(event: &AcpEvent) -> bool { AcpEvent::SessionStarted { .. } | AcpEvent::TurnComplete { .. } | AcpEvent::ConversationLinked { .. } + | AcpEvent::NativeSessionTitle { .. } | AcpEvent::StatusChanged { status: ConnectionStatus::Disconnected } @@ -309,6 +310,43 @@ pub(crate) async fn handle_event( } Ok(()) } + AcpEvent::NativeSessionTitle { title } => { + // Live ACP session title: write it onto the bound row the moment + // the agent publishes it. `refresh_auto_title` is a no-op when the + // user renamed the chat, when the value is unchanged, or when the + // string is empty — so repeating the same title across goal + // snapshots does not churn the sidebar. + let Some((state_arc, emitter)) = + manager.get_state_and_emitter(&envelope.connection_id).await + else { + return Ok(()); + }; + let conversation_id = { state_arc.read().await.conversation_id }; + // Title published before the first prompt binds the row: drop it. + // `emit_conversation_update` never caches an unbound title, so a + // later resend is still accepted. Failing that, the next detail + // load recovers it only where the agent's own transcript carries + // the name — see the note at that emit site. + let Some(cid) = conversation_id else { + return Ok(()); + }; + // Unlocked on purpose: a later ACP title or a session-file parse + // can still replace this. Identical repeats are filtered at emit + // time so CodeBuddy's per-turn fallback cannot ping-pong the + // sidebar. Soft-deleted rows are skipped by the UPDATE predicate. + if conversation_service::refresh_auto_title(db_conn, cid, title.clone()).await? { + crate::commands::conversations::emit_conversation_upsert(&emitter, db_conn, cid) + .await; + if let Some(ccm) = manager.chat_channel() { + crate::commands::conversations::spawn_sync_conversation_title_until_current( + db_conn.clone(), + ccm, + cid, + ); + } + } + Ok(()) + } // Other events don't need cross-connection DB persistence today; extend // this dispatcher with new arms as the lifecycle scope grows. _ => Ok(()), @@ -1525,7 +1563,7 @@ async fn connection_worker_loop( /// connections, workers run independently so a slow SQLite write on one /// connection doesn't backpressure the others. /// -/// All forwarded events (the 5 types in `is_lifecycle_relevant`) use +/// All forwarded events (the 6 types in `is_lifecycle_relevant`) use /// blocking `send().await` to guarantee delivery even when the worker /// mailbox is full — `SessionStarted` (writes external_id) and /// `TurnComplete` (writes terminal status) are correctness-critical and @@ -1790,6 +1828,100 @@ mod tests { assert_eq!(p["summary"]["external_id"], "ext-99"); } + #[tokio::test] + async fn handle_event_native_session_title_writes_and_upserts() { + use crate::web::event_bridge::{WebEventBroadcaster, CONVERSATION_CHANGED_EVENT}; + let db = test_helpers::fresh_in_memory_db().await; + let folder_id = test_helpers::seed_folder(&db, "/tmp/test-native-title").await; + let conv = + conversation_service::create(&db.conn, folder_id, AgentType::ClaudeCode, None, None) + .await + .unwrap(); + let broadcaster = Arc::new(WebEventBroadcaster::new()); + let mut rx = broadcaster.subscribe(); + let mgr = ConnectionManager::new(); + { + let mut map = mgr.connections.lock().await; + let mut conn = fake_connection_with_state("c1", Some(conv.id)); + conn.emitter = EventEmitter::test_web_only(broadcaster.clone()); + map.insert("c1".to_string(), conn); + } + let env = EventEnvelope { + seq: 1, + connection_id: "c1".to_string(), + payload: AcpEvent::NativeSessionTitle { + title: " Fix login flow ".into(), + }, + }; + handle_event(&db.conn, &mgr, &env, None).await.unwrap(); + + let reloaded = conversation_service::get_by_id(&db.conn, conv.id) + .await + .unwrap(); + assert_eq!(reloaded.title.as_deref(), Some("Fix login flow")); + assert!(!reloaded.title_locked); + + let evt = rx + .try_recv() + .expect("a written native title should broadcast a conversation upsert"); + assert_eq!(evt.channel, CONVERSATION_CHANGED_EVENT); + let p = &*evt.payload; + assert_eq!(p["kind"], "upsert"); + assert_eq!(p["summary"]["title"], "Fix login flow"); + } + + #[tokio::test] + async fn handle_event_native_session_title_skips_locked_and_unbound() { + let db = test_helpers::fresh_in_memory_db().await; + let folder_id = test_helpers::seed_folder(&db, "/tmp/test-native-title-skip").await; + let conv = + conversation_service::create(&db.conn, folder_id, AgentType::ClaudeCode, None, None) + .await + .unwrap(); + conversation_service::update_title(&db.conn, conv.id, "User pick".into()) + .await + .unwrap(); + + let mgr = ConnectionManager::new(); + { + let mut map = mgr.connections.lock().await; + map.insert( + "locked".to_string(), + fake_connection_with_state("locked", Some(conv.id)), + ); + map.insert( + "unbound".to_string(), + fake_connection_with_state("unbound", None), + ); + } + let locked_env = EventEnvelope { + seq: 1, + connection_id: "locked".to_string(), + payload: AcpEvent::NativeSessionTitle { + title: "agent title".into(), + }, + }; + handle_event(&db.conn, &mgr, &locked_env, None) + .await + .unwrap(); + let unbound_env = EventEnvelope { + seq: 2, + connection_id: "unbound".to_string(), + payload: AcpEvent::NativeSessionTitle { + title: "should not land anywhere".into(), + }, + }; + handle_event(&db.conn, &mgr, &unbound_env, None) + .await + .unwrap(); + + let reloaded = conversation_service::get_by_id(&db.conn, conv.id) + .await + .unwrap(); + assert_eq!(reloaded.title.as_deref(), Some("User pick")); + assert!(reloaded.title_locked); + } + #[tokio::test] async fn handle_event_session_started_skips_soft_deleted_conversation() { // A fork emits `SessionStarted{S2}`. If the bound conversation was @@ -2353,6 +2485,9 @@ mod tests { parent_conversation_id: None, parent_tool_use_id: None, })); + assert!(is_lifecycle_relevant(&AcpEvent::NativeSessionTitle { + title: "Fix login".into(), + })); assert!(is_lifecycle_relevant(&AcpEvent::StatusChanged { status: ConnectionStatus::Disconnected, })); diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index 71be5576f..550cbb118 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -213,6 +213,10 @@ pub struct ConnectionManager { /// init. `Arc` so the inner `Self` cloned from `clone_ref` sees /// the install too — the lock is set once at startup and never mutated. delegation_injection: Arc>, + /// Chat-channel manager installed during bootstrap so live title writes + /// can sync Telegram topic names without threading the manager through + /// every `send_prompt_linked` caller. Optional in tests. + chat_channel: Arc>, /// Per-agent-type serialization for `probe_agent_options`. Without /// this, rapid agent-tab clicks in the settings UI would fan out one /// real CLI process per click — each one running up to 60s. The @@ -269,6 +273,7 @@ impl ConnectionManager { spawn_handshake_timeout: spawn_handshake_timeout_from_env(), terminal_shell_config: TerminalShellRuntimeConfig::new(), delegation_injection: Arc::new(std::sync::OnceLock::new()), + chat_channel: Arc::new(std::sync::OnceLock::new()), probe_locks: Arc::new(Mutex::new(HashMap::new())), pending_questions: Arc::new(Mutex::new(HashMap::new())), pending_plan_approvals: Arc::new(Mutex::new(HashMap::new())), @@ -283,6 +288,7 @@ impl ConnectionManager { spawn_handshake_timeout: self.spawn_handshake_timeout, terminal_shell_config: self.terminal_shell_config.clone(), delegation_injection: self.delegation_injection.clone(), + chat_channel: self.chat_channel.clone(), probe_locks: self.probe_locks.clone(), pending_questions: self.pending_questions.clone(), pending_plan_approvals: self.pending_plan_approvals.clone(), @@ -296,6 +302,22 @@ impl ConnectionManager { let _ = self.delegation_injection.set(injection); } + /// Install the chat-channel manager exactly once during bootstrap so + /// live title writes can sync bound forum topics. Calling twice is a + /// no-op. Tests leave this unset and skip the remote sync. + pub fn install_chat_channel( + &self, + manager: crate::chat_channel::manager::ChatChannelManager, + ) { + let _ = self.chat_channel.set(manager); + } + + pub(crate) fn chat_channel( + &self, + ) -> Option { + self.chat_channel.get().map(|c| c.clone_ref()) + } + fn delegation_snapshot(&self) -> Option { self.delegation_injection.get().cloned() } @@ -317,6 +339,7 @@ impl ConnectionManager { spawn_handshake_timeout: timeout, terminal_shell_config: TerminalShellRuntimeConfig::new(), delegation_injection: Arc::new(std::sync::OnceLock::new()), + chat_channel: Arc::new(std::sync::OnceLock::new()), probe_locks: Arc::new(Mutex::new(HashMap::new())), pending_questions: Arc::new(Mutex::new(HashMap::new())), pending_plan_approvals: Arc::new(Mutex::new(HashMap::new())), @@ -973,9 +996,11 @@ impl ConnectionManager { // Seed a delegation child's title from the task prompt so the // sidebar shows a meaningful label immediately. `list_children` // returns the raw DB title, so a child born with NULL reads - // "Untitled" until the first detail load backfills it. Roots (no - // delegation) keep `None` and follow the existing backfill. Computed - // out here so `blocks` stays with the caller for the actual prompt. + // "Untitled" until the first detail load backfills it. Roots keep + // `None` here and get the same first-prompt seed after the send + // succeeds (so a failed enqueue does not name a cancelled row). + // Computed out here so `blocks` stays with the caller for the + // actual prompt. let seed_title = if delegation.is_some() { delegation_child_title_seed(&blocks) } else { @@ -1227,6 +1252,15 @@ impl ConnectionManager { } else { None }; + // Seed an unlocked title from the first prompt so the sidebar is not + // "Untitled" while the agent is still working. Native ACP titles + // (session_info_update) replace this later via refresh_auto_title. + // Delegation children are already seeded at row create. + let first_prompt_title = if delegation.is_none() { + delegation_child_title_seed(&blocks) + } else { + None + }; // Project the user's prompt blocks for the cross-client viewer // broadcast BEFORE `send_prompt_inner` consumes `blocks`, and hand the @@ -1286,6 +1320,33 @@ impl ConnectionManager { ) .await; } + if let (Some(cid), Some(title)) = + (conversation_id_for_status, first_prompt_title) + { + match conversation_service::seed_auto_title_if_empty(&db.conn, cid, title) + .await + { + Ok(true) => { + crate::commands::conversations::emit_conversation_upsert( + &emitter, &db.conn, cid, + ) + .await; + if let Some(ccm) = self.chat_channel() { + crate::commands::conversations::spawn_sync_conversation_title_until_current( + db.conn.clone(), + ccm, + cid, + ); + } + } + Ok(false) => {} + Err(e) => tracing::warn!( + conversation_id = cid, + error = %e, + "[manager] first-prompt title seed failed" + ), + } + } Ok(conversation_id_for_status) } Err(send_err) => { @@ -4812,6 +4873,27 @@ mod tests { assert!(preview.ends_with("...")); } + /// The chat-channel handle is installed once at bootstrap on the ONE + /// manager Tauri/axum owns, but every consumer (the web AppState at + /// `web/mod.rs`, the lifecycle worker) holds a `clone_ref` of it. The + /// handle therefore has to live behind the shared `Arc` — if a + /// clone ever got a fresh lock, live title writes would stop renaming + /// bound Telegram topics on exactly the paths that do the writing, with + /// no error anywhere. + #[test] + fn installed_chat_channel_survives_clone_ref() { + let mgr = ConnectionManager::new(); + assert!(mgr.chat_channel().is_none(), "unset before bootstrap"); + + // Install on a clone: the handle must become visible on the original + // too (bootstrap order across `clone_ref` boundaries is not fixed). + let clone = mgr.clone_ref(); + clone.install_chat_channel(crate::chat_channel::manager::ChatChannelManager::new()); + + assert!(mgr.chat_channel().is_some()); + assert!(mgr.clone_ref().chat_channel().is_some()); + } + #[test] fn delegation_child_title_seed_uses_parser_title_from_first_prompt() { // The delegating prompt is a single text block (the task) — the seed must @@ -4895,6 +4977,52 @@ mod tests { assert_eq!(found.as_deref(), Some("hello world")); } + #[tokio::test] + async fn send_prompt_linked_seeds_first_prompt_title_when_untitled() { + use crate::db::test_helpers; + let db = test_helpers::fresh_in_memory_db().await; + let folder_id = test_helpers::seed_folder(&db, "/tmp/title-seed").await; + let mgr = ConnectionManager::new(); + let conn_id = "conn-title-seed"; + let _rx = insert_live_connection( + &mgr, + conn_id, + AgentType::ClaudeCode, + Some(PathBuf::from("/tmp/title-seed")), + ) + .await; + + mgr.send_prompt_linked( + &db, + conn_id, + vec![PromptInputBlock::Text { + text: "hello world".into(), + }], + Some(folder_id), + None, + None, + ) + .await + .expect("send"); + + let cid = mgr + .get_state(conn_id) + .await + .unwrap() + .read() + .await + .conversation_id + .expect("row linked"); + let row = conversation_service::get_by_id(&db.conn, cid) + .await + .expect("get"); + assert_eq!(row.title.as_deref(), Some("hello world")); + assert!( + !row.title_locked, + "first-prompt seed must stay unlocked so a later ACP title can replace it" + ); + } + /// A textless prompt (image-only) succeeds but emits NO `UserPromptSent` — /// the notification fires for text messages only. #[tokio::test] diff --git a/src-tauri/src/acp/mod.rs b/src-tauri/src/acp/mod.rs index bb6e5e64d..76ac0ce44 100644 --- a/src-tauri/src/acp/mod.rs +++ b/src-tauri/src/acp/mod.rs @@ -26,6 +26,7 @@ pub mod question; pub mod registry; pub mod remote_registry; pub mod session_info; +pub mod session_title; pub mod session_state; pub mod stderr_tail; pub mod terminal_runtime; diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index 3cb63facb..4a5e27e12 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -530,6 +530,14 @@ pub struct SessionState { /// Which settings surface drifted, for the banner's wording. `Some` iff /// `config_stale`; reset to `None` when staleness clears. pub config_stale_kind: Option, + + /// Last live ACP session title we actually emitted on this connection. + /// Used to skip identical `session_info_update.title` repeats (CodeBuddy + /// resends its fallback after every turn with no last-sent guard). + /// Backend-internal: not on the client snapshot. Cleared on + /// `ConversationLinked` so a title dropped while the row was still + /// unbound can be accepted on the next send. + pub last_native_title: Option, } impl SessionState { @@ -589,6 +597,7 @@ impl SessionState { last_turn_ended_abnormally: false, config_stale: false, config_stale_kind: None, + last_native_title: None, } } @@ -1048,6 +1057,10 @@ impl SessionState { } => { self.conversation_id = Some(*conversation_id); self.folder_id = Some(*folder_id); + // A title published before this bind was dropped (no row yet). + // Forget the skip-cache so a later resend of the same string + // is not suppressed. + self.last_native_title = None; } AcpEvent::PlanUpdate { entries } => { // Replace any existing Plan block, then append at end. @@ -1185,6 +1198,7 @@ impl SessionState { | AcpEvent::ConfigOptionRejected { .. } | AcpEvent::SessionLoadFailed { .. } | AcpEvent::TurnRetrying { .. } + | AcpEvent::NativeSessionTitle { .. } | AcpEvent::UserPromptSent { .. } => { // 这些事件不直接修改 SessionState 的可见字段。 // UserPromptSent 是纯通知事件,仅供 chat-channel 推送消费。 @@ -1822,6 +1836,32 @@ mod tests { ) } + /// `ConversationLinked` must forget the live-title skip-cache. + /// + /// Today this clear can only ever be a no-op: `emit_conversation_update` + /// refuses to cache a title while `conversation_id` is `None`, and both + /// producers of `ConversationLinked` fire only from that same unbound + /// state, so the cache is already empty every time this runs. It is kept — + /// and pinned here — because the day something rebinds a LIVE connection to + /// another row, a cache carried over from the old one would classify the + /// new row's first title as a repeat and leave it Untitled for the rest of + /// the connection, with no error anywhere to point at. + #[test] + fn conversation_linked_clears_the_native_title_skip_cache() { + let mut s = fresh_state(); + s.last_native_title = Some("Fix the login flow".into()); + + s.apply_event(&AcpEvent::ConversationLinked { + conversation_id: 7, + folder_id: 1, + parent_conversation_id: None, + parent_tool_use_id: None, + }); + + assert_eq!(s.conversation_id, Some(7)); + assert!(s.last_native_title.is_none()); + } + #[test] fn plan_approval_applies_clears_by_id_and_survives_snapshot() { let mut s = fresh_state(); diff --git a/src-tauri/src/acp/session_title.rs b/src-tauri/src/acp/session_title.rs new file mode 100644 index 000000000..c2f834467 --- /dev/null +++ b/src-tauri/src/acp/session_title.rs @@ -0,0 +1,50 @@ +//! Live ACP session titles. +//! +//! Agents publish a session name through `session_info_update.title`. Codeg +//! used to ignore that field and only adopt a title the next time the +//! conversation was loaded from disk. These helpers extract a usable title +//! from the live notification so the lifecycle worker can write it immediately. + +/// Pull a usable session title out of ACP `session_info_update.title`. +/// +/// `Undefined` (passed in as `None`) means the update did not touch the title +/// and is ignored. The schema also uses `Null` to mean "clear"; we treat that +/// the same as absent on purpose so an explicit clear cannot wipe the row +/// back to Untitled. Whitespace-only strings are ignored for the same reason. +pub(crate) fn native_title_from_session_info(title: Option<&str>) -> Option { + let t = title?.trim(); + if t.is_empty() { + None + } else { + Some(crate::parsers::truncate_str(t, 100)) + } +} + +#[cfg(test)] +mod tests { + use super::native_title_from_session_info; + + #[test] + fn rejects_missing_and_blank() { + assert_eq!(native_title_from_session_info(None), None); + assert_eq!(native_title_from_session_info(Some("")), None); + assert_eq!(native_title_from_session_info(Some(" ")), None); + assert_eq!(native_title_from_session_info(Some("\n\t")), None); + } + + #[test] + fn trims_and_keeps_a_real_title() { + assert_eq!( + native_title_from_session_info(Some(" Fix login flow ")).as_deref(), + Some("Fix login flow") + ); + } + + #[test] + fn caps_at_parser_title_length() { + let long = "a".repeat(150); + let got = native_title_from_session_info(Some(&long)).unwrap(); + assert_eq!(got, crate::parsers::truncate_str(&long, 100)); + assert!(got.ends_with("...")); + } +} diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index 93449874d..22ed37fd0 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -216,6 +216,12 @@ pub enum AcpEvent { #[serde(skip_serializing_if = "Option::is_none", default)] parent_tool_use_id: Option, }, + /// Agent published a live session title via ACP `session_info_update.title`. + /// Applied to the conversation row by the lifecycle worker (unlocked titles + /// only). The sidebar converges through `conversation://changed`; this event + /// itself is not rendered. Omitted when the update carries no title so + /// goal-only `session_info_update`s stay off the lifecycle path. + NativeSessionTitle { title: String }, /// Backend has transitioned the conversation row's `status` column. /// Emitted by `send_prompt_linked` (`InProgress`) and the lifecycle /// subscriber on `TurnComplete` (`PendingReview`). The frontend mirrors diff --git a/src-tauri/src/bin/codeg_server.rs b/src-tauri/src/bin/codeg_server.rs index 7bff2ede8..2e9562219 100644 --- a/src-tauri/src/bin/codeg_server.rs +++ b/src-tauri/src/bin/codeg_server.rs @@ -294,6 +294,9 @@ async fn async_main() -> ExitCode { system_op_lock: codeg_lib::app_state::default_system_op_lock(), update_state: codeg_lib::app_state::default_update_state(), }); + state + .connection_manager + .install_chat_channel(state.chat_channel_manager.clone_ref()); // Logging phase 3: wire the emitter so the Logs viewer's live tail // (`logs://appended`) reaches WS clients. diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index dbddadf5e..c20ad92ca 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -1707,6 +1707,20 @@ async fn sync_conversation_title_until_current( } } +/// Detach a chat-channel title sync so a live title write cannot sit on +/// Telegram's 60s `editForumTopic` timeout. Callers that already upserted +/// the sidebar should use this rather than awaiting `sync_conversation_title`. +pub(crate) fn spawn_sync_conversation_title_until_current( + conn: sea_orm::DatabaseConnection, + chat_channel_manager: crate::chat_channel::manager::ChatChannelManager, + conversation_id: i32, +) { + tokio::spawn(async move { + sync_conversation_title_until_current(&conn, &chat_channel_manager, conversation_id) + .await; + }); +} + /// Broadcast and propagate title changes discovered outside codeg (for /// example, Codex's session index or an import scan). Both operations are /// best-effort: the database update has already committed, so notification diff --git a/src-tauri/src/db/service/conversation_service.rs b/src-tauri/src/db/service/conversation_service.rs index 749511d3f..9f109071c 100644 --- a/src-tauri/src/db/service/conversation_service.rs +++ b/src-tauri/src/db/service/conversation_service.rs @@ -210,6 +210,7 @@ pub async fn refresh_auto_title( .col_expr(conversation::Column::Title, Expr::value(title)) .filter(conversation::Column::Id.eq(conversation_id)) .filter(conversation::Column::TitleLocked.eq(false)) + .filter(conversation::Column::DeletedAt.is_null()) .filter( sea_orm::Condition::any() .add(conversation::Column::Title.is_null()) @@ -220,6 +221,37 @@ pub async fn refresh_auto_title( Ok(res.rows_affected > 0) } +/// First-prompt seed: write `title` ONLY when the row is unlocked AND still +/// empty. Unlike [`refresh_auto_title`], this will not replace an existing +/// name — a later user prompt must not overwrite the first one, and an +/// agent-generated ACP title that already landed must not be clobbered by +/// the next send. Returns `true` when a row was written so the caller can +/// broadcast a sidebar upsert. Does not bump `updated_at` or set the lock. +pub async fn seed_auto_title_if_empty( + conn: &DatabaseConnection, + conversation_id: i32, + title: String, +) -> Result { + use sea_orm::sea_query::Expr; + let title = title.trim(); + if title.is_empty() { + return Ok(false); + } + let res = conversation::Entity::update_many() + .col_expr(conversation::Column::Title, Expr::value(title)) + .filter(conversation::Column::Id.eq(conversation_id)) + .filter(conversation::Column::TitleLocked.eq(false)) + .filter(conversation::Column::DeletedAt.is_null()) + .filter( + sea_orm::Condition::any() + .add(conversation::Column::Title.is_null()) + .add(conversation::Column::Title.eq("")), + ) + .exec(conn) + .await?; + Ok(res.rows_affected > 0) +} + /// Lock a row's title WITHOUT rewriting it. For a conversation whose name was /// typed by the user somewhere else — a work task's title, an automation's name /// — the seed passed to [`create`] already IS the name; all that's missing is @@ -2236,6 +2268,112 @@ mod tests { ); } + #[tokio::test] + async fn seed_auto_title_if_empty_writes_only_when_untitled() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-title-seed").await; + let row = create(&db.conn, folder, AgentType::ClaudeCode, None, None) + .await + .expect("create"); + let before = row.updated_at; + + assert!( + seed_auto_title_if_empty(&db.conn, row.id, " First prompt ".into()) + .await + .expect("seed"), + "an empty unlocked title must be seeded" + ); + let summary = get_by_id(&db.conn, row.id).await.expect("get"); + assert_eq!(summary.title.as_deref(), Some("First prompt")); + assert!(!summary.title_locked); + assert_eq!(summary.updated_at, before, "seed must not bump updated_at"); + + assert!( + !seed_auto_title_if_empty(&db.conn, row.id, "Second prompt".into()) + .await + .expect("seed-2"), + "a later prompt must not replace the first-prompt seed" + ); + let summary = get_by_id(&db.conn, row.id).await.expect("get-2"); + assert_eq!(summary.title.as_deref(), Some("First prompt")); + } + + #[tokio::test] + async fn seed_auto_title_if_empty_skips_locked_and_empty() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-title-seed-skip").await; + let row = create(&db.conn, folder, AgentType::ClaudeCode, None, None) + .await + .expect("create"); + update_title(&db.conn, row.id, "User pick".into()) + .await + .expect("rename"); + + assert!( + !seed_auto_title_if_empty(&db.conn, row.id, "First prompt".into()) + .await + .expect("seed-locked"), + "a locked title must not be seeded over" + ); + assert!( + !seed_auto_title_if_empty(&db.conn, row.id, String::new()) + .await + .expect("seed-empty") + ); + let summary = get_by_id(&db.conn, row.id).await.expect("get"); + assert_eq!(summary.title.as_deref(), Some("User pick")); + } + + /// Neither auto-title primitive may write a soft-deleted row. Both are now + /// driven from the live ACP path (a title can land while the user is + /// deleting the conversation), and a late write to a deleted row is a + /// resurrection the sidebar can never show — `emit_conversation_upsert` + /// filters it out, so the row would silently diverge from every client. + #[tokio::test] + async fn auto_title_writes_skip_soft_deleted_rows() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-title-deleted").await; + + // Untitled + deleted: the first-prompt seed must not name it. + let seeded = create(&db.conn, folder, AgentType::ClaudeCode, None, None) + .await + .expect("create"); + soft_delete(&db.conn, seeded.id).await.expect("soft delete"); + assert!( + !seed_auto_title_if_empty(&db.conn, seeded.id, "First prompt".into()) + .await + .expect("seed"), + "a soft-deleted row must not be seeded" + ); + + // Titled + deleted: a live ACP title must not replace it either. + let refreshed = create( + &db.conn, + folder, + AgentType::ClaudeCode, + Some("Old name".into()), + None, + ) + .await + .expect("create"); + soft_delete(&db.conn, refreshed.id).await.expect("soft delete"); + assert!( + !refresh_auto_title(&db.conn, refreshed.id, "Agent title".into()) + .await + .expect("refresh"), + "a soft-deleted row must not be auto-retitled" + ); + + for (id, expected) in [(seeded.id, None), (refreshed.id, Some("Old name"))] { + let row = conversation::Entity::find_by_id(id) + .one(&db.conn) + .await + .expect("query") + .expect("row still present"); + assert_eq!(row.title.as_deref(), expected); + } + } + /// The work-task / automation launch path: the seed IS the name, so locking /// must keep the title byte-identical, keep the row where it is in a /// recency-sorted sidebar, and make the next auto-title a no-op. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 24139204e..9e165ffde 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -502,6 +502,19 @@ mod tauri_app { }); } + // Hand the chat-channel manager to the connection manager + // BEFORE the chat background tasks below start accepting + // messages: a `/new` that lands first would write its live ACP + // title against an `install_chat_channel` that hasn't happened + // yet, and skip the topic rename for good (the later + // reconciliation passes only sync titles their own conditional + // UPDATE wrote, and this one already converged). + { + let cm = app.state::(); + let ccm = app.state::(); + cm.install_chat_channel(ccm.clone_ref()); + } + // Start chat channel background tasks { let ccm = app.state::(); diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index fa17437dc..8872d2954 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -3668,6 +3668,11 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { // what a backend GC / idle sweep removes. rememberResolvedIdentity(contextKey, { sessionId: e.session_id }) break + case "native_session_title": + // Title is applied on the conversation row by the lifecycle worker + // and reaches the sidebar via `conversation://changed`. Do not flush + // the streaming queue: this can arrive mid-turn. + break case "conversation_linked": // Backend just bound (or reaffirmed) the connection's DB conversation // row. Phase 3a frontend pre-creates rows for new-tab sends so this diff --git a/src/lib/types.ts b/src/lib/types.ts index a3f006b91..0a28683d1 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1820,6 +1820,13 @@ export type AcpEvent = conversation_id: number folder_id: number } + | { + // Agent published a live ACP session title. The backend writes the + // conversation row and broadcasts `conversation://changed`; the + // frontend does not apply this event itself. + type: "native_session_title" + title: string + } | { type: "conversation_status_changed" conversation_id: number