1use std::sync::{Mutex, MutexGuard};
9
10use chrono::{DateTime, Utc};
11use serde::Serialize;
12use tokio::sync::broadcast;
13use uuid::Uuid;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
17pub struct VisibleAssistantMessage {
18 pub id: String,
19 pub content: String,
20 pub created_at: DateTime<Utc>,
21 #[serde(skip)]
22 round_count: u32,
23 #[serde(skip)]
24 committed: bool,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
29pub struct VisibleMessageSnapshot {
30 pub version: u64,
31 pub messages: Vec<VisibleAssistantMessage>,
32 pub terminal: Option<String>,
33 pub history_committed: bool,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct SelectedRunOutputIdentity {
40 pub session_id: String,
41 pub run_id: String,
42 pub session_created_at: DateTime<Utc>,
43 pub receipt: bamboo_domain::SessionInboxReceipt,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct SelectedRunOutputSnapshot {
49 pub identity: SelectedRunOutputIdentity,
50 pub output: VisibleMessageSnapshot,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct VisibleMessageEvent {
56 pub version: u64,
57 pub kind: VisibleMessageEventKind,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum VisibleMessageEventKind {
62 Started {
63 message_id: String,
64 created_at: DateTime<Utc>,
65 },
66 Delta {
67 message_id: String,
68 offset: usize,
69 content: String,
70 created_at: DateTime<Utc>,
71 },
72 Discarded {
73 message_id: String,
74 },
75 Terminal {
76 reason: String,
77 },
78 HistoryCommitted,
79}
80
81#[derive(Debug, Default)]
82struct VisibleMessageReplay {
83 version: u64,
84 round_count: u32,
85 active_message_id: Option<String>,
86 messages: Vec<VisibleAssistantMessage>,
87 terminal: Option<String>,
88 history_committed: bool,
89 selected_output: Option<SelectedRunOutputIdentity>,
90}
91
92#[derive(Debug)]
99pub struct VisibleMessageStream {
100 state: Mutex<VisibleMessageReplay>,
101 sender: broadcast::Sender<VisibleMessageEvent>,
102}
103
104impl Default for VisibleMessageStream {
105 fn default() -> Self {
106 Self::new()
107 }
108}
109
110impl VisibleMessageStream {
111 const CHANNEL_CAPACITY: usize = 1000;
112
113 pub fn new() -> Self {
114 let (sender, _) = broadcast::channel(Self::CHANNEL_CAPACITY);
115 Self {
116 state: Mutex::new(VisibleMessageReplay::default()),
117 sender,
118 }
119 }
120
121 fn lock(&self) -> MutexGuard<'_, VisibleMessageReplay> {
122 self.state
123 .lock()
124 .unwrap_or_else(std::sync::PoisonError::into_inner)
125 }
126
127 fn send_locked(&self, event: VisibleMessageEvent) {
128 let _ = self.sender.send(event);
129 }
130
131 pub fn subscribe_with_snapshot(
134 &self,
135 ) -> (
136 broadcast::Receiver<VisibleMessageEvent>,
137 VisibleMessageSnapshot,
138 ) {
139 let receiver = self.sender.subscribe();
140 let state = self.lock();
141 let snapshot = VisibleMessageSnapshot {
142 version: state.version,
143 messages: state
144 .messages
145 .iter()
146 .filter(|message| !message.committed && !message.content.is_empty())
147 .cloned()
148 .collect(),
149 terminal: state.terminal.clone(),
150 history_committed: state.history_committed,
151 };
152 (receiver, snapshot)
153 }
154
155 pub(crate) fn retain_selected_output(
159 &self,
160 owner: &bamboo_domain::RootActorRuntimeWrite,
161 receipt: bamboo_domain::SessionInboxReceipt,
162 ) -> std::io::Result<()> {
163 let identity = SelectedRunOutputIdentity {
164 session_id: owner.fence.actor_id.clone(),
165 run_id: owner.fence.run_id.clone(),
166 session_created_at: owner.created_at,
167 receipt,
168 };
169 let mut state = self.lock();
170 if let Some(current) = &state.selected_output {
171 return if current == &identity {
172 Ok(())
173 } else {
174 Err(std::io::Error::other(
175 "Selected output identity cannot change",
176 ))
177 };
178 }
179 if state.version != 0 || identity.receipt.generation == 0 {
180 return Err(std::io::Error::other(
181 "Selected output must bind before publication",
182 ));
183 }
184 state.selected_output = Some(identity);
185 Ok(())
186 }
187
188 pub fn selected_output_snapshot(&self) -> Option<SelectedRunOutputSnapshot> {
192 let state = self.lock();
193 Some(SelectedRunOutputSnapshot {
194 identity: state.selected_output.clone()?,
195 output: VisibleMessageSnapshot {
196 version: state.version,
197 messages: state
198 .messages
199 .iter()
200 .filter(|m| !m.content.is_empty())
201 .cloned()
202 .collect(),
203 terminal: state.terminal.clone(),
204 history_committed: state.history_committed,
205 },
206 })
207 }
208
209 pub fn begin_round(&self, round_count: u32) {
210 self.lock().round_count = round_count;
211 }
212
213 pub fn start(&self, message_id: String, created_at: DateTime<Utc>) {
217 let mut state = self.lock();
218 if let Some(previous) = state
221 .messages
222 .iter()
223 .position(|m| m.id == message_id && m.committed)
224 {
225 state.messages.remove(previous);
226 }
227 if !state
228 .messages
229 .iter()
230 .any(|message| message.id == message_id)
231 {
232 let round_count = state.round_count;
233 state.messages.push(VisibleAssistantMessage {
234 id: message_id.clone(),
235 content: String::new(),
236 created_at,
237 round_count,
238 committed: false,
239 });
240 }
241 state.active_message_id = Some(message_id.clone());
242 state.history_committed = false;
243 state.version = state.version.saturating_add(1);
244 self.send_locked(VisibleMessageEvent {
245 version: state.version,
246 kind: VisibleMessageEventKind::Started {
247 message_id,
248 created_at,
249 },
250 });
251 }
252
253 pub fn append(&self, content: String) {
256 if content.is_empty() {
257 return;
258 }
259 let mut state = self.lock();
260 state.history_committed = false;
261 let message_id = match state.active_message_id.clone() {
262 Some(message_id) => message_id,
263 None => {
264 let message_id = Uuid::new_v4().to_string();
265 let created_at = Utc::now();
266 let round_count = state.round_count;
267 state.messages.push(VisibleAssistantMessage {
268 id: message_id.clone(),
269 content: String::new(),
270 created_at,
271 round_count,
272 committed: false,
273 });
274 state.active_message_id = Some(message_id.clone());
275 state.version = state.version.saturating_add(1);
276 self.send_locked(VisibleMessageEvent {
277 version: state.version,
278 kind: VisibleMessageEventKind::Started {
279 message_id: message_id.clone(),
280 created_at,
281 },
282 });
283 message_id
284 }
285 };
286
287 let Some(index) = state
288 .messages
289 .iter()
290 .position(|message| message.id == message_id)
291 else {
292 return;
293 };
294 let offset = state.messages[index].content.len();
295 let created_at = state.messages[index].created_at;
296 state.messages[index].content.push_str(&content);
297 state.version = state.version.saturating_add(1);
298 self.send_locked(VisibleMessageEvent {
299 version: state.version,
300 kind: VisibleMessageEventKind::Delta {
301 message_id,
302 offset,
303 content,
304 created_at,
305 },
306 });
307 }
308
309 pub fn discard(&self, message_id: &str) {
310 let mut state = self.lock();
311 let previous_len = state.messages.len();
312 state.messages.retain(|message| message.id != message_id);
313 if state.messages.len() == previous_len {
314 return;
315 }
316 if state.active_message_id.as_deref() == Some(message_id) {
317 state.active_message_id = None;
318 }
319 state.history_committed = false;
320 state.version = state.version.saturating_add(1);
321 self.send_locked(VisibleMessageEvent {
322 version: state.version,
323 kind: VisibleMessageEventKind::Discarded {
324 message_id: message_id.to_string(),
325 },
326 });
327 }
328
329 pub fn mark_terminal(&self, reason: impl Into<String>) {
330 let mut state = self.lock();
331 let reason = reason.into();
332 if state.terminal.as_deref() == Some(reason.as_str()) {
333 return;
334 }
335 state.terminal = Some(reason.clone());
336 state.version = state.version.saturating_add(1);
337 self.send_locked(VisibleMessageEvent {
338 version: state.version,
339 kind: VisibleMessageEventKind::Terminal { reason },
340 });
341 }
342
343 pub fn history_committed(&self) {
346 let mut state = self.lock();
347 if state.selected_output.is_some() {
348 for message in &mut state.messages {
349 message.committed = true;
350 }
351 } else {
352 state.messages.clear();
353 }
354 state.active_message_id = None;
355 state.history_committed = true;
356 state.version = state.version.saturating_add(1);
357 self.send_locked(VisibleMessageEvent {
358 version: state.version,
359 kind: VisibleMessageEventKind::HistoryCommitted,
360 });
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn subscribe_snapshot_and_live_tail_have_no_overlap_after_version_filter() {
370 let stream = VisibleMessageStream::new();
371 stream.begin_round(0);
372 let created_at = Utc::now();
373 stream.start("message-1".into(), created_at);
374 stream.append("before".into());
375
376 let (mut receiver, snapshot) = stream.subscribe_with_snapshot();
377 assert_eq!(snapshot.messages[0].content, "before");
378 stream.append(" after".into());
379
380 let event = receiver.try_recv().expect("live delta");
381 assert!(event.version > snapshot.version);
382 assert!(matches!(
383 event.kind,
384 VisibleMessageEventKind::Delta { offset: 6, ref content, .. } if content == " after"
385 ));
386 }
387
388 #[test]
389 fn explicit_retry_rollback_discards_previous_attempt() {
390 let stream = VisibleMessageStream::new();
391 stream.begin_round(2);
392 stream.start("attempt-1".into(), Utc::now());
393 stream.append("partial".into());
394 let (mut receiver, snapshot) = stream.subscribe_with_snapshot();
395 assert_eq!(snapshot.messages.len(), 1);
396
397 stream.discard("attempt-1");
398 stream.start("attempt-2".into(), Utc::now());
399 let discarded = receiver.try_recv().expect("discarded event");
400 let started = receiver.try_recv().expect("replacement start");
401 assert!(matches!(
402 discarded.kind,
403 VisibleMessageEventKind::Discarded { ref message_id } if message_id == "attempt-1"
404 ));
405 assert!(matches!(
406 started.kind,
407 VisibleMessageEventKind::Started { ref message_id, .. } if message_id == "attempt-2"
408 ));
409 }
410
411 #[test]
412 fn multi_round_messages_survive_until_history_commit() {
413 let stream = VisibleMessageStream::new();
414 stream.begin_round(1);
415 stream.start("round-1".into(), Utc::now());
416 stream.append("first".into());
417 stream.begin_round(2);
418 stream.start("round-2".into(), Utc::now());
419 stream.append("second".into());
420
421 let (_, snapshot) = stream.subscribe_with_snapshot();
422 assert_eq!(snapshot.messages.len(), 2);
423 assert_eq!(snapshot.messages[0].round_count, 1);
424 assert_eq!(snapshot.messages[0].content, "first");
425 assert_eq!(snapshot.messages[1].round_count, 2);
426 assert_eq!(snapshot.messages[1].content, "second");
427 }
428
429 #[test]
430 fn failed_and_cancelled_terminal_reasons_remain_in_late_snapshots() {
431 for reason in ["error", "cancelled"] {
432 let stream = VisibleMessageStream::new();
433 stream.start("partial".into(), Utc::now());
434 stream.append("visible prefix".into());
435 stream.mark_terminal(reason);
436
437 let (_, snapshot) = stream.subscribe_with_snapshot();
438 assert_eq!(snapshot.terminal.as_deref(), Some(reason));
439 assert_eq!(snapshot.messages[0].content, "visible prefix");
440 }
441 }
442
443 #[test]
444 fn history_commit_clears_text_only_after_control_is_emitted() {
445 let stream = VisibleMessageStream::new();
446 stream.start("message-1".into(), Utc::now());
447 stream.append("visible".into());
448 let (mut receiver, snapshot) = stream.subscribe_with_snapshot();
449 assert_eq!(snapshot.messages.len(), 1);
450
451 stream.history_committed();
452 let committed = receiver.try_recv().expect("history commit control");
453 assert!(matches!(
454 committed.kind,
455 VisibleMessageEventKind::HistoryCommitted
456 ));
457 let (_, settled) = stream.subscribe_with_snapshot();
458 assert!(settled.messages.is_empty());
459 assert!(settled.history_committed);
460 }
461
462 fn selected_owner() -> bamboo_domain::RootActorRuntimeWrite {
463 bamboo_domain::RootActorRuntimeWrite {
464 fence: bamboo_domain::ActorActivationFence {
465 schema_version: 1,
466 actor_id: "output-root".into(),
467 activation_id: "activation".into(),
468 attempt: 1,
469 run_id: "output-run".into(),
470 lease_owner: "host".into(),
471 lease_epoch: 1,
472 },
473 created_at: Utc::now(),
474 conversation_generation: bamboo_domain::Session::new("output-root", "model")
475 .conversation_generation_observation(),
476 }
477 }
478 fn selected_receipt() -> bamboo_domain::SessionInboxReceipt {
479 bamboo_domain::SessionInboxReceipt {
480 id: bamboo_domain::SessionMessageId::parse("selected-input").unwrap(),
481 generation: 7,
482 }
483 }
484
485 #[test]
486 fn run_output_retains_same_bytes_after_history_without_changing_ws_replay() {
487 let stream = VisibleMessageStream::new();
488 let owner = selected_owner();
489 stream
490 .retain_selected_output(&owner, selected_receipt())
491 .unwrap();
492 stream.start("visible".into(), Utc::now());
493 stream.append("界\r\nfinal".into());
494 stream.mark_terminal("complete");
495 let before = stream.selected_output_snapshot().unwrap();
496 stream.history_committed();
497 let replay = stream.selected_output_snapshot().unwrap();
498 assert_eq!(replay.identity.session_id, owner.fence.actor_id);
499 assert_eq!(replay.identity.run_id, owner.fence.run_id);
500 assert_eq!(replay.identity.session_created_at, owner.created_at);
501 assert_eq!(replay.identity.receipt, selected_receipt());
502 assert_eq!(
503 serde_json::to_value(&replay.output.messages).unwrap(),
504 serde_json::to_value(&before.output.messages).unwrap()
505 );
506 assert!(replay.output.history_committed && replay.output.version > before.output.version);
507 assert_eq!(replay.output.terminal.as_deref(), Some("complete"));
508 let (_, legacy) = stream.subscribe_with_snapshot();
509 assert!(legacy.messages.is_empty());
510 assert!(legacy.history_committed);
511 let wire = serde_json::to_value(&replay.output).unwrap();
512 assert_eq!(wire["messages"][0].as_object().unwrap().len(), 3);
513 assert!(wire["messages"][0].get("committed").is_none());
514 }
515
516 #[test]
517 fn run_output_rejects_rebinding_or_late_opt_in_and_defaults_to_no_receipt() {
518 let stream = VisibleMessageStream::new();
519 assert!(stream.selected_output_snapshot().is_none());
520 let owner = selected_owner();
521 stream
522 .retain_selected_output(&owner, selected_receipt())
523 .unwrap();
524 stream
525 .retain_selected_output(&owner, selected_receipt())
526 .unwrap();
527 let mut foreign = owner.clone();
528 foreign.fence.run_id = "successor".into();
529 assert!(stream
530 .retain_selected_output(&foreign, selected_receipt())
531 .is_err());
532 let mut foreign = owner.clone();
533 foreign.created_at += chrono::Duration::seconds(1);
534 assert!(stream
535 .retain_selected_output(&foreign, selected_receipt())
536 .is_err());
537 let mut receipt = selected_receipt();
538 receipt.generation += 1;
539 assert!(stream.retain_selected_output(&owner, receipt).is_err());
540 let late = VisibleMessageStream::new();
541 late.append("unattributed prefix".into());
542 assert!(late
543 .retain_selected_output(&owner, selected_receipt())
544 .is_err());
545 assert!(late.selected_output_snapshot().is_none());
546 }
547
548 #[test]
549 fn run_output_later_segments_do_not_restore_committed_text_to_ws() {
550 let stream = VisibleMessageStream::new();
551 stream
552 .retain_selected_output(&selected_owner(), selected_receipt())
553 .unwrap();
554 stream.start("old".into(), Utc::now());
555 stream.append("committed prefix".into());
556 stream.history_committed();
557 stream.start("next".into(), Utc::now());
558 stream.append("new segment".into());
559 let (_, legacy) = stream.subscribe_with_snapshot();
560 assert_eq!(legacy.messages.len(), 1);
561 assert_eq!(legacy.messages[0].id, "next");
562 let output = stream.selected_output_snapshot().unwrap().output;
563 assert_eq!(output.messages.len(), 2);
564 assert!(!output.history_committed);
565 stream.discard("next");
566 let (_, legacy) = stream.subscribe_with_snapshot();
567 assert!(legacy.messages.is_empty());
568 assert_eq!(
569 stream.selected_output_snapshot().unwrap().output.messages[0].content,
570 "committed prefix"
571 );
572 }
573
574 #[test]
575 fn run_output_reused_id_and_legacy_delta_never_append_committed_bytes() {
576 let stream = VisibleMessageStream::new();
577 stream
578 .retain_selected_output(&selected_owner(), selected_receipt())
579 .unwrap();
580 stream.start("same".into(), Utc::now());
581 stream.append("old".into());
582 stream.history_committed();
583 stream.start("same".into(), Utc::now());
584 stream.append("replacement".into());
585 let snapshot = stream.selected_output_snapshot().unwrap().output;
586 assert_eq!(snapshot.messages.len(), 1);
587 assert_eq!(snapshot.messages[0].content, "replacement");
588 stream.history_committed();
589 stream.append("legacy new segment".into());
590 let snapshot = stream.selected_output_snapshot().unwrap().output;
591 assert_eq!(snapshot.messages.len(), 2);
592 assert!(!snapshot.history_committed);
593 stream.history_committed();
594 stream.discard("same");
595 assert!(
596 !stream
597 .selected_output_snapshot()
598 .unwrap()
599 .output
600 .history_committed
601 );
602 }
603
604 #[test]
605 fn run_output_error_and_cancellation_keep_prefix_without_inventing_commit() {
606 for reason in ["error", "cancelled"] {
607 let stream = VisibleMessageStream::new();
608 stream
609 .retain_selected_output(&selected_owner(), selected_receipt())
610 .unwrap();
611 stream.append("partial visible output".into());
612 stream.mark_terminal(reason);
613 let snapshot = stream.selected_output_snapshot().unwrap().output;
614 assert_eq!(snapshot.messages[0].content, "partial visible output");
615 assert_eq!(snapshot.terminal.as_deref(), Some(reason));
616 assert!(!snapshot.history_committed);
617 stream.history_committed();
618 let snapshot = stream.selected_output_snapshot().unwrap().output;
619 assert_eq!(snapshot.messages[0].content, "partial visible output");
620 assert!(snapshot.history_committed);
621 }
622 }
623}