Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:53:53.180Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

attention.rs

517 lines · 18.0 KB · rust
1//! Proactive updates and room attention (`OMEGA-SW-06`).
2//!
3//! Proactive tick updates arrive as ordinary hosted-runtime turns on the same
4//! conversation. The pane needs no new source. Unread count and one attention
5//! marker are derived from the transcript projection plus a **local** read
6//! marker. Cross-device read state is `SARAH-NR-07` (NIP-RS kind 30078) — not
7//! invented here.
8//!
9//! `SARAH_AUTONOMOUS_TICK_ENABLED` stays default off. Omega never enables it.
10//! When the tick is off and the room has no turns, the empty room stays honest.
11
12use crate::projections::{MessageAck, TranscriptProjection, TranscriptRow};
13
14/// Server-side autonomous tick env flag name (OpenAgents API). Omega must not
15/// enable this flag; it is observed only as ambient product truth.
16pub const SARAH_AUTONOMOUS_TICK_FLAG: &str = "SARAH_AUTONOMOUS_TICK_ENABLED";
17
18/// Omega disposition: the autonomous tick stays **off** for this client.
19///
20/// The dogfood window may change server disposition later; Omega still never
21/// enables the flag from the pane.
22pub const OMEGA_AUTONOMOUS_TICK_ENABLED: bool = false;
23
24/// Local-only read marker for one Sarah room (MVP). Not a cross-device protocol.
25#[derive(Clone, Debug, PartialEq, Eq, Default)]
26pub struct LocalReadState {
27    /// Thread / conversation the marker applies to, when known.
28    pub thread_ref: Option<String>,
29    /// Last message the owner marked read (message_ref or event id).
30    pub last_read_message_ref: Option<String>,
31    /// True after the owner has explicitly or implicitly marked the room read.
32    pub has_marked_read: bool,
33}
34
35impl LocalReadState {
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    pub fn for_thread(thread_ref: impl Into<String>) -> Self {
41        Self {
42            thread_ref: Some(thread_ref.into()),
43            last_read_message_ref: None,
44            has_marked_read: false,
45        }
46    }
47
48    /// Bind the marker to a room thread. A different thread clears the cursor.
49    pub fn bind_thread(&mut self, thread_ref: Option<&str>) {
50        match (self.thread_ref.as_deref(), thread_ref) {
51            (Some(existing), Some(next)) if existing == next => {}
52            (_, Some(next)) => {
53                *self = Self::for_thread(next);
54            }
55            (_, None) => {}
56        }
57    }
58
59    /// Mark every currently known transcript message as read.
60    pub fn mark_read_through(&mut self, last_message_ref: Option<String>) {
61        self.last_read_message_ref = last_message_ref;
62        self.has_marked_read = true;
63    }
64
65    /// Mark read through the latest confirmed row in the transcript page.
66    pub fn mark_read_from_transcript(&mut self, transcript: &TranscriptProjection) {
67        let last = transcript
68            .rows
69            .iter()
70            .rev()
71            .find(|row| row.ack == MessageAck::Confirmed)
72            .map(|row| row.message_ref.clone())
73            .or_else(|| transcript.rows.last().map(|row| row.message_ref.clone()));
74        self.mark_read_through(last);
75    }
76
77    /// Serialize for local KVP / test fixtures only. Not a wire protocol.
78    pub fn to_local_value(&self) -> String {
79        match (
80            self.thread_ref.as_deref(),
81            self.last_read_message_ref.as_deref(),
82            self.has_marked_read,
83        ) {
84            (Some(thread), Some(msg), true) => format!("v1|{thread}|{msg}|1"),
85            (Some(thread), None, true) => format!("v1|{thread}||1"),
86            (None, Some(msg), true) => format!("v1||{msg}|1"),
87            (Some(thread), Some(msg), false) => format!("v1|{thread}|{msg}|0"),
88            _ => "v1|||0".into(),
89        }
90    }
91
92    /// Parse a value produced by [`Self::to_local_value`]. Unknown forms yield
93    /// an empty marker (honest unread until the owner marks read).
94    pub fn from_local_value(raw: &str) -> Self {
95        let parts: Vec<&str> = raw.splitn(4, '|').collect();
96        if parts.len() != 4 || parts[0] != "v1" {
97            return Self::default();
98        }
99        let thread_ref = if parts[1].is_empty() {
100            None
101        } else {
102            Some(parts[1].to_string())
103        };
104        let last_read_message_ref = if parts[2].is_empty() {
105            None
106        } else {
107            Some(parts[2].to_string())
108        };
109        let has_marked_read = parts[3] == "1";
110        Self {
111            thread_ref,
112            last_read_message_ref,
113            has_marked_read,
114        }
115    }
116}
117
118/// Whether the autonomous tick is considered enabled for honesty checks.
119///
120/// Omega hard-codes off. A future observation of server disposition may pass
121/// `server_enabled`; it must never flip the Omega client default.
122pub fn autonomous_tick_enabled(server_enabled: Option<bool>) -> bool {
123    let _ = server_enabled;
124    OMEGA_AUTONOMOUS_TICK_ENABLED
125}
126
127/// One attention marker for the Sarah room.
128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
129pub enum AttentionMarker {
130    /// No unread Sarah-originated turns.
131    None,
132    /// Owner has unread room turns that need attention.
133    NeedsAttention,
134}
135
136impl AttentionMarker {
137    pub fn label(self) -> &'static str {
138        match self {
139            Self::None => "none",
140            Self::NeedsAttention => "needs_attention",
141        }
142    }
143
144    pub fn is_set(self) -> bool {
145        matches!(self, Self::NeedsAttention)
146    }
147
148    pub fn from_unread(unread_count: usize) -> Self {
149        if unread_count > 0 {
150            Self::NeedsAttention
151        } else {
152            Self::None
153        }
154    }
155}
156
157/// Unread count + attention marker for the Sarah workroom room.
158#[derive(Clone, Debug, PartialEq, Eq)]
159pub struct RoomAttention {
160    pub unread_count: usize,
161    pub marker: AttentionMarker,
162    /// Local-only read marker (MVP). Not NIP-RS.
163    pub read_state: LocalReadState,
164    /// Ambient note when the autonomous tick is off (default).
165    pub tick_note: Option<&'static str>,
166}
167
168impl RoomAttention {
169    pub fn honest_empty() -> Self {
170        Self {
171            unread_count: 0,
172            marker: AttentionMarker::None,
173            read_state: LocalReadState::new(),
174            tick_note: Some(tick_off_honest_note()),
175        }
176    }
177
178    pub fn summary_line(&self) -> String {
179        format!(
180            "unread={} · attention={} · tick={}",
181            self.unread_count,
182            self.marker.label(),
183            if autonomous_tick_enabled(None) {
184                "on"
185            } else {
186                "off"
187            }
188        )
189    }
190
191    pub fn icon_label(&self) -> Option<String> {
192        if self.unread_count == 0 {
193            None
194        } else if self.unread_count > 99 {
195            Some("99+".into())
196        } else {
197            Some(self.unread_count.to_string())
198        }
199    }
200}
201
202/// Public-safe note when the autonomous tick is off.
203pub fn tick_off_honest_note() -> &'static str {
204    "Autonomous tick off (default). Empty room is not synthetic activity."
205}
206
207/// Roles whose confirmed messages count toward room attention when unread.
208pub fn is_attention_role(role: &str) -> bool {
209    let normalized = role.trim().to_ascii_lowercase();
210    matches!(
211        normalized.as_str(),
212        "sarah" | "assistant" | "agent" | "principal.sarah" | "owner_orchestrator"
213    )
214}
215
216/// True when a transcript row is a Sarah-originated (or proactive) turn that
217/// can raise attention when unread. Owner pending/confirmed rows do not.
218pub fn row_raises_attention(row: &TranscriptRow) -> bool {
219    row.ack == MessageAck::Confirmed && is_attention_role(&row.role)
220}
221
222/// Count unread attention-raising rows after the local read marker.
223///
224/// Ordering is page order (oldest → newest). Rows at or before the last-read
225/// message_ref are treated as read. If the marker is missing and the owner has
226/// never marked read, every attention-raising confirmed row is unread.
227pub fn count_unread(transcript: &TranscriptProjection, read: &LocalReadState) -> usize {
228    let rows: Vec<&TranscriptRow> = transcript
229        .rows
230        .iter()
231        .filter(|row| row_raises_attention(row))
232        .collect();
233    if rows.is_empty() {
234        return 0;
235    }
236    let Some(last_read) = read.last_read_message_ref.as_deref() else {
237        // Never marked: all attention rows are unread (including first open).
238        return rows.len();
239    };
240    if let Some(idx) = transcript
241        .rows
242        .iter()
243        .position(|row| row.message_ref == last_read)
244    {
245        transcript.rows[idx + 1..]
246            .iter()
247            .filter(|row| row_raises_attention(row))
248            .count()
249    } else {
250        // Marker not in page (truncated / different page): treat all page
251        // attention rows as potentially unread rather than inventing zero.
252        rows.len()
253    }
254}
255
256/// Recompute room attention from transcript + local read state.
257pub fn compute_room_attention(
258    transcript: &TranscriptProjection,
259    mut read_state: LocalReadState,
260    thread_ref: Option<&str>,
261) -> RoomAttention {
262    if let Some(thread) = thread_ref {
263        if read_state.thread_ref.as_deref() != Some(thread) {
264            // Thread change resets the local marker so we do not bleed cursors.
265            if read_state.thread_ref.is_some() {
266                read_state = LocalReadState::for_thread(thread);
267            } else {
268                read_state.thread_ref = Some(thread.to_string());
269            }
270        }
271    }
272
273    let unread_count = count_unread(transcript, &read_state);
274    let marker = AttentionMarker::from_unread(unread_count);
275    let tick_note = if autonomous_tick_enabled(None) {
276        None
277    } else {
278        Some(tick_off_honest_note())
279    };
280
281    RoomAttention {
282        unread_count,
283        marker,
284        read_state,
285        tick_note,
286    }
287}
288
289/// Proactive tick turns are ordinary transcript rows. No separate source.
290///
291/// Accepts the same shape as a Q&A answer: role + text + ack + message_ref.
292/// Optional `origin` / model tags on the wire are ignored for rendering class.
293pub fn proactive_turn_as_transcript_row(
294    message_ref: impl Into<String>,
295    text: impl Into<String>,
296    ack: MessageAck,
297) -> TranscriptRow {
298    TranscriptRow {
299        message_ref: message_ref.into(),
300        role: "sarah".into(),
301        text: text.into(),
302        ack,
303    }
304}
305
306/// Honest empty-room check when the autonomous tick is off: no synthetic rows.
307pub fn empty_room_is_honest(
308    transcript: &TranscriptProjection,
309    tick_enabled: bool,
310) -> bool {
311    if tick_enabled {
312        // When enabled, emptiness is still allowed; honesty is "no fake rows".
313        return !transcript_has_synthetic_tick_filler(transcript);
314    }
315    // Off: empty or real rows only — never invent proactive filler.
316    transcript.rows.is_empty() || !transcript_has_synthetic_tick_filler(transcript)
317}
318
319/// Detect pane-local synthetic filler that would fake tick activity.
320///
321/// Real service rows (including proactive ticks when the server posts them)
322/// use ordinary message refs. Only an explicit local placeholder is dishonest.
323fn transcript_has_synthetic_tick_filler(transcript: &TranscriptProjection) -> bool {
324    transcript.rows.iter().any(|row| {
325        row.message_ref.starts_with("local:synthetic_tick:")
326            || row.text == "__omega_fake_proactive_activity__"
327    })
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::projections::{ProjectionMeta, sources};
334
335    fn confirmed_sarah(id: &str, text: &str) -> TranscriptRow {
336        TranscriptRow {
337            message_ref: id.into(),
338            role: "sarah".into(),
339            text: text.into(),
340            ack: MessageAck::Confirmed,
341        }
342    }
343
344    fn confirmed_owner(id: &str, text: &str) -> TranscriptRow {
345        TranscriptRow {
346            message_ref: id.into(),
347            role: "owner".into(),
348            text: text.into(),
349            ack: MessageAck::Confirmed,
350        }
351    }
352
353    fn page(rows: Vec<TranscriptRow>) -> TranscriptProjection {
354        TranscriptProjection {
355            meta: ProjectionMeta::fresh(sources::TRANSCRIPT),
356            rows,
357            cursor: None,
358            truncated: false,
359        }
360    }
361
362    #[test]
363    fn omega_never_enables_autonomous_tick() {
364        assert!(!OMEGA_AUTONOMOUS_TICK_ENABLED);
365        assert!(!autonomous_tick_enabled(None));
366        assert!(!autonomous_tick_enabled(Some(true)));
367        assert_eq!(SARAH_AUTONOMOUS_TICK_FLAG, "SARAH_AUTONOMOUS_TICK_ENABLED");
368    }
369
370    #[test]
371    fn empty_room_stays_honest_when_tick_off() {
372        let empty = page(vec![]);
373        assert!(empty_room_is_honest(&empty, false));
374        let attention = compute_room_attention(&empty, LocalReadState::new(), None);
375        assert_eq!(attention.unread_count, 0);
376        assert_eq!(attention.marker, AttentionMarker::None);
377        assert!(attention.tick_note.is_some());
378    }
379
380    #[test]
381    fn does_not_invent_fake_proactive_activity() {
382        let empty = page(vec![]);
383        assert!(!transcript_has_synthetic_tick_filler(&empty));
384        let fake = page(vec![TranscriptRow {
385            message_ref: "local:synthetic_tick:1".into(),
386            role: "sarah".into(),
387            text: "__omega_fake_proactive_activity__".into(),
388            ack: MessageAck::Confirmed,
389        }]);
390        assert!(transcript_has_synthetic_tick_filler(&fake));
391        assert!(!empty_room_is_honest(&fake, false));
392    }
393
394    #[test]
395    fn proactive_tick_turn_is_ordinary_transcript_row() {
396        let row = proactive_turn_as_transcript_row(
397            "message.sarah_auto.tick.1",
398            "Release state is green.",
399            MessageAck::Confirmed,
400        );
401        assert_eq!(row.role, "sarah");
402        assert_eq!(row.ack, MessageAck::Confirmed);
403        // Same evidence class as a Q&A answer: ordinary row, same role path.
404        let answer = confirmed_sarah("message.answer.1", "Release state is green.");
405        assert_eq!(row.role, answer.role);
406        assert_eq!(row.ack, answer.ack);
407
408        let mut t = page(vec![confirmed_owner("m1", "status?"), row]);
409        t.push_bounded(answer);
410        assert_eq!(t.rows.len(), 3);
411        assert!(t.rows.iter().all(|r| r.role == "owner" || r.role == "sarah"));
412    }
413
414    #[test]
415    fn unread_count_and_attention_marker() {
416        let transcript = page(vec![
417            confirmed_owner("m1", "hello"),
418            confirmed_sarah("m2", "hi"),
419            confirmed_sarah("m3", "proactive update"),
420        ]);
421        let read = LocalReadState::new();
422        let attention = compute_room_attention(&transcript, read, Some("thread.sarah.abc"));
423        assert_eq!(attention.unread_count, 2);
424        assert_eq!(attention.marker, AttentionMarker::NeedsAttention);
425        assert_eq!(attention.icon_label().as_deref(), Some("2"));
426        assert!(attention.marker.is_set());
427    }
428
429    #[test]
430    fn mark_read_clears_attention() {
431        let transcript = page(vec![
432            confirmed_owner("m1", "hello"),
433            confirmed_sarah("m2", "hi"),
434            confirmed_sarah("m3", "proactive update"),
435        ]);
436        let mut read = LocalReadState::for_thread("thread.sarah.abc");
437        read.mark_read_from_transcript(&transcript);
438        assert_eq!(read.last_read_message_ref.as_deref(), Some("m3"));
439        assert!(read.has_marked_read);
440
441        let attention = compute_room_attention(&transcript, read, Some("thread.sarah.abc"));
442        assert_eq!(attention.unread_count, 0);
443        assert_eq!(attention.marker, AttentionMarker::None);
444        assert!(attention.icon_label().is_none());
445    }
446
447    #[test]
448    fn mark_read_mid_page_leaves_later_unread() {
449        let transcript = page(vec![
450            confirmed_sarah("m1", "a"),
451            confirmed_sarah("m2", "b"),
452            confirmed_sarah("m3", "c"),
453        ]);
454        let mut read = LocalReadState::new();
455        read.mark_read_through(Some("m1".into()));
456        assert_eq!(count_unread(&transcript, &read), 2);
457
458        read.mark_read_through(Some("m2".into()));
459        assert_eq!(count_unread(&transcript, &read), 1);
460    }
461
462    #[test]
463    fn owner_rows_do_not_raise_attention() {
464        let transcript = page(vec![
465            confirmed_owner("m1", "hello"),
466            confirmed_owner("m2", "still me"),
467        ]);
468        let attention = compute_room_attention(&transcript, LocalReadState::new(), None);
469        assert_eq!(attention.unread_count, 0);
470        assert_eq!(attention.marker, AttentionMarker::None);
471    }
472
473    #[test]
474    fn pending_sarah_row_does_not_raise_attention() {
475        let transcript = page(vec![TranscriptRow {
476            message_ref: "local:1".into(),
477            role: "sarah".into(),
478            text: "streaming".into(),
479            ack: MessageAck::Pending,
480        }]);
481        assert_eq!(count_unread(&transcript, &LocalReadState::new()), 0);
482    }
483
484    #[test]
485    fn local_read_state_round_trip() {
486        let mut read = LocalReadState::for_thread("thread.sarah.abc");
487        read.mark_read_through(Some("msg.9".into()));
488        let encoded = read.to_local_value();
489        let decoded = LocalReadState::from_local_value(&encoded);
490        assert_eq!(decoded, read);
491        assert_eq!(
492            LocalReadState::from_local_value("garbage"),
493            LocalReadState::default()
494        );
495    }
496
497    #[test]
498    fn thread_change_resets_read_marker() {
499        let mut read = LocalReadState::for_thread("thread.a");
500        read.mark_read_through(Some("m9".into()));
501        let transcript = page(vec![confirmed_sarah("m1", "hi")]);
502        let attention = compute_room_attention(&transcript, read, Some("thread.b"));
503        assert_eq!(attention.read_state.thread_ref.as_deref(), Some("thread.b"));
504        assert!(attention.read_state.last_read_message_ref.is_none());
505        assert_eq!(attention.unread_count, 1);
506    }
507
508    #[test]
509    fn attention_roles_cover_principal_aliases() {
510        assert!(is_attention_role("sarah"));
511        assert!(is_attention_role("principal.sarah"));
512        assert!(is_attention_role("Assistant"));
513        assert!(!is_attention_role("owner"));
514        assert!(!is_attention_role("user"));
515    }
516}
517
Served at tenant.openagents/omega Member data and write actions are omitted.