Skip to repository content517 lines · 18.0 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:53:53.180Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
attention.rs
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