Skip to main content

agentmux_srv/ambient/
title.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Where a session title is stored, and when a new one replaces the old.
5//!
6//! Both writers go through [`store_title`]: the pane's own request
7//! (`session:activity_summary`, on a human message) and the empty-title recovery
8//! sweep (`backend::reactive::activity_watcher`). The check and the write happen
9//! in one transaction, so neither can overwrite a title the other stored while
10//! its call ran. The pane used to write the title itself, after its own copy of
11//! these rules. docs/reports/REPORT_AMBIENT_FRAMEWORK_REASSESSMENT_2026_10_08.md
12//! section 6.7.
13
14use std::collections::HashSet;
15
16use crate::ambient::validate::is_usable_title;
17use crate::backend::obj::{self, Block};
18use crate::backend::storage::store::Store;
19
20/// The block meta key the title lives in. The pane header and the Swarm row read it.
21pub const META_TITLE: &str = "term:ambient_summary";
22
23/// When a candidate title is stored.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Replace {
26    /// Only where there is no usable title: the recovery sweep, whose source (the
27    /// activity alone) is weaker than the pane's.
28    IfEmpty,
29    /// Where there is none, or where the candidate names a different goal
30    /// ([`is_title_news`]). A rewording keeps the old title, so it stays stable.
31    IfNews,
32}
33
34/// Store `title` as the block's session title if `replace` allows it. A value
35/// that is not a usable title is never stored. `still_current` is asked inside
36/// the transaction: a request superseded by a newer one must not store its
37/// title after the newer one did. `Ok(true)` when written.
38pub fn store_title(
39    store: &Store,
40    block_id: &str,
41    title: &str,
42    replace: Replace,
43    still_current: impl Fn() -> bool,
44) -> Result<bool, String> {
45    if !is_usable_title(title) {
46        return Ok(false);
47    }
48    store
49        .with_tx(|tx| {
50            if !still_current() {
51                return Ok(false);
52            }
53            let mut block = tx.must_get::<Block>(block_id)?;
54            let current = obj::meta_get_string(&block.meta, META_TITLE, "");
55            if is_usable_title(&current) {
56                let keep = match replace {
57                    Replace::IfEmpty => true,
58                    Replace::IfNews => !is_title_news(&current, title),
59                };
60                if keep {
61                    return Ok(false);
62                }
63            }
64            block.meta.insert(META_TITLE.to_string(), serde_json::Value::String(title.to_string()));
65            tx.update(&mut block)?;
66            Ok(true)
67        })
68        .map_err(|e| e.to_string())
69}
70
71/// Words that carry no topic, ignored when comparing two titles.
72const STOP_WORDS: &[&str] = &[
73    "a", "an", "the", "and", "or", "of", "to", "for", "in", "on", "with", "by", "at", "from", "into", "its", "it",
74];
75
76/// Below this overlap (Jaccard over topic words) a new title counts as news.
77const NEWS_MAX_OVERLAP: f64 = 0.5;
78
79fn topic_words(title: &str) -> HashSet<String> {
80    title
81        .to_lowercase()
82        .split(|c: char| !c.is_ascii_alphanumeric())
83        .filter(|w| !w.is_empty() && !STOP_WORDS.contains(w))
84        .map(str::to_string)
85        .collect()
86}
87
88/// Is `candidate` a different goal from `current`, or a rewording of it? A
89/// rewording ("Fix the login race" → "Fix login race condition") keeps the old
90/// title; a real change of topic replaces it.
91pub fn is_title_news(current: &str, candidate: &str) -> bool {
92    let a = topic_words(current);
93    let b = topic_words(candidate);
94    if a.is_empty() || b.is_empty() {
95        return a.len() != b.len();
96    }
97    let shared = a.intersection(&b).count();
98    let overlap = shared as f64 / (a.len() + b.len() - shared) as f64;
99    overlap < NEWS_MAX_OVERLAP
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn a_rewording_is_not_news() {
108        for (current, candidate) in [
109            ("Fix the login race", "Fix login race condition"),
110            ("Fix the login race", "fix the LOGIN race"),
111            ("Harden the swarm ambient summary", "Harden swarm summary"),
112        ] {
113            assert!(!is_title_news(current, candidate), "{current:?} -> {candidate:?}");
114        }
115    }
116
117    #[test]
118    fn a_new_topic_is_news() {
119        for (current, candidate) in [
120            ("Fix the login race", "Set up CI for the docs site"),
121            ("Harden the swarm ambient summary", "Add UDP discovery for LAN peers"),
122            ("Review PR 4230", "Write the multi-host swarm spec"),
123        ] {
124            assert!(is_title_news(current, candidate), "{current:?} -> {candidate:?}");
125        }
126    }
127
128    fn store_with_block(meta: &[(&str, &str)]) -> Store {
129        let store = Store::open_in_memory().unwrap();
130        let mut block = Block {
131            oid: "b1".to_string(),
132            parentoref: String::new(),
133            version: 0,
134            runtimeopts: None,
135            stickers: None,
136            meta: obj::MetaMapType::new(),
137            subblockids: None,
138        };
139        for (k, v) in meta {
140            block.meta.insert(k.to_string(), serde_json::Value::String(v.to_string()));
141        }
142        store.insert(&mut block).unwrap();
143        store
144    }
145
146    fn title_of(store: &Store) -> String {
147        obj::meta_get_string(&store.must_get::<Block>("b1").unwrap().meta, META_TITLE, "")
148    }
149
150    #[test]
151    fn either_writer_fills_an_empty_or_placeholder_title() {
152        for replace in [Replace::IfEmpty, Replace::IfNews] {
153            for before in [&[][..], &[(META_TITLE, "(none yet)")][..]] {
154                let store = store_with_block(before);
155                assert_eq!(store_title(&store, "b1", "Fix the login race", replace, || true), Ok(true));
156                assert_eq!(title_of(&store), "Fix the login race");
157            }
158        }
159    }
160
161    #[test]
162    fn recovery_never_replaces_a_title() {
163        let store = store_with_block(&[(META_TITLE, "Set up CI for the docs site")]);
164        assert_eq!(store_title(&store, "b1", "Something else entirely", Replace::IfEmpty, || true), Ok(false));
165        assert_eq!(title_of(&store), "Set up CI for the docs site");
166    }
167
168    #[test]
169    fn the_pane_replaces_a_title_only_with_news() {
170        let store = store_with_block(&[(META_TITLE, "Fix the login race")]);
171        assert_eq!(store_title(&store, "b1", "Fix login race condition", Replace::IfNews, || true), Ok(false));
172        assert_eq!(title_of(&store), "Fix the login race");
173        assert_eq!(store_title(&store, "b1", "Set up CI for the docs site", Replace::IfNews, || true), Ok(true));
174        assert_eq!(title_of(&store), "Set up CI for the docs site");
175    }
176
177    #[test]
178    fn a_superseded_request_stores_nothing() {
179        let store = store_with_block(&[(META_TITLE, "Set up CI for the docs site")]);
180        assert_eq!(store_title(&store, "b1", "Fix the login race", Replace::IfNews, || false), Ok(false));
181        assert_eq!(title_of(&store), "Set up CI for the docs site");
182    }
183
184    #[test]
185    fn a_placeholder_is_never_stored() {
186        let store = store_with_block(&[]);
187        assert_eq!(store_title(&store, "b1", "(none yet)", Replace::IfNews, || true), Ok(false));
188        assert_eq!(title_of(&store), "");
189    }
190
191    #[test]
192    fn other_meta_survives_the_write() {
193        let store = store_with_block(&[("agentName", "AgentX")]);
194        store_title(&store, "b1", "Fix the login race", Replace::IfEmpty, || true).unwrap();
195        let meta = store.must_get::<Block>("b1").unwrap().meta;
196        assert_eq!(obj::meta_get_string(&meta, "agentName", ""), "AgentX");
197    }
198}