Skip to main content

oxi_agent/advisor/
runtime.rs

1//! The advisor runtime — drives the advisor agent from primary transcript
2//! deltas. Ported from omp `AdvisorRuntime` (runtime.ts).
3//!
4//! Each primary turn, the host calls [`AdvisorRuntime::on_turn_end`] with the
5//! (new) transcript. The runtime renders a *delta* (messages added since the
6//! last drain) and feeds it to the advisor agent's `prompt()`. Accepted advice
7//! flows back through the host's `enqueue_advice` callback (the host owns the
8//! [`crate::advisor::emission_guard::AdvisorEmissionGuard`] and the delivery
9//! channel decision).
10//!
11//! # Concurrency
12//!
13//! omp's drain loop is safe only because JS's event loop serializes the
14//! synchronous segment between "queue empty? stop" and "release the busy flag".
15//! `tokio`'s multithreaded runtime breaks that: a concurrent `on_turn_end` on
16//! another worker can push + spawn a drain in the gap, and that spawned drain
17//! bails (busy still set) leaving the queue non-empty with no drain running —
18//! a lost-wakeup stall. The fix folds the "draining" role into the same lock
19//! that guards the pending queue, so "decide-to-stop" and "push-new-work +
20//! spawn" are each one atomic critical section (design doc §9.2). The
21//! catchup-waiter path has the same race and the same fix (register + check
22//! backlog under one lock).
23//!
24//! # Attribution
25//!
26//! Translated to Rust from omp (oh-my-pi), MIT licensed.
27
28use std::path::PathBuf;
29use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
30use std::sync::{Arc, Weak};
31use std::time::Duration;
32
33use async_trait::async_trait;
34use oxi_ai::Message;
35use parking_lot::Mutex;
36use tokio::sync::oneshot;
37
38use crate::advisor::types::AdvisorNote;
39
40/// Minimal slice of an agent the runtime drives. omp `AdvisorAgent`.
41/// Satisfied by `oxi_agent::Agent` via a host adapter; tests hand-roll a fake.
42#[async_trait]
43pub trait AdvisorAgent: Send + Sync + 'static {
44    /// Drive one advisor turn from the given batch text. `Err` marks the turn
45    /// failed (triggers the retry/drop-after-3 path).
46    async fn prompt(&self, input: String) -> Result<(), String>;
47    /// Abort any in-flight prompt (best-effort). omp `abort`.
48    fn abort(&self, reason: &str);
49    /// Reset the advisor's own conversation state. omp `reset`.
50    fn reset(&self);
51    /// Drop messages appended past `count`. Called after a failed `prompt` so a
52    /// retry doesn't replay the failed user batch. omp `rollbackTo`.
53    async fn rollback_to(&self, count: usize);
54    /// Current advisor message count (for the rollback snapshot).
55    fn message_count(&self) -> usize;
56}
57
58/// Host callbacks the runtime needs. omp `AdvisorRuntimeHost`.
59pub trait AdvisorRuntimeHost: Send + Sync + 'static {
60    /// Snapshot of the primary transcript (host should exclude the advisor's
61    /// own echoed advice so it isn't re-fed).
62    fn snapshot_messages(&self) -> Vec<Message>;
63    /// Route an accepted note to the primary (the host applies its emission
64    /// guard + delivery channel). omp `enqueueAdvice`.
65    fn enqueue_advice(&self, note: AdvisorNote);
66    /// Pre-prompt context maintenance for the advisor's own context. Return
67    /// `true` to force a re-prime (reset advisor context + replay the full
68    /// current transcript). omp `maintainContext`. Optional.
69    fn maintain_context(&self, _incoming_tokens: usize) -> bool {
70        false
71    }
72    /// Called immediately before each advisor `prompt` cycle, so the host can
73    /// clear per-update advisor state (its emission guard's one-advise budget).
74    /// omp `beginAdvisorUpdate`. Optional.
75    fn begin_advisor_update(&self) {}
76    /// Surface a non-recovering advisor failure (3 consecutive errors) without
77    /// adding model-visible context. omp `notifyFailure`. Optional.
78    fn notify_failure(&self, _error: &str) {}
79}
80
81/// One queued transcript delta awaiting the advisor's attention.
82struct PendingDelta {
83    text: String,
84    /// Number of primary turns this delta covers (for backlog accounting).
85    turns: u64,
86}
87
88/// Pending deltas + the "is a drain task currently running" role, behind one
89/// lock so the empty-check and role-release are atomic with a concurrent push
90/// (the lost-wakeup fix).
91#[derive(Default)]
92struct DrainState {
93    pending: Vec<PendingDelta>,
94    draining: bool,
95}
96
97/// A registered catchup waiter.
98struct CatchupWaiter {
99    threshold: u64,
100    tx: Option<oneshot::Sender<()>>,
101}
102
103/// Drives the advisor agent. Construct via [`AdvisorRuntime::new`], wrap in
104/// `Arc`, then call [`AdvisorRuntime::install_self`] so it can self-spawn its
105/// drain task.
106pub struct AdvisorRuntime {
107    agent: Arc<dyn AdvisorAgent>,
108    host: Arc<dyn AdvisorRuntimeHost>,
109    transcript_path: Mutex<Option<PathBuf>>,
110
111    state: Mutex<DrainState>,
112    /// Bumped by every external reset/dispose. A drain iteration captures it
113    /// before its awaits; a mismatch means a reset aborted the in-flight
114    /// advisor prompt, so the stale batch is dropped instead of being retried.
115    epoch: AtomicU64,
116    /// Count of primary turns the advisor has not yet digested.
117    backlog: AtomicU64,
118    /// Cursor into the primary transcript — render deltas from here.
119    last_count: AtomicU64,
120    /// Latest transcript snapshot (for re-prime rendering).
121    latest: Mutex<Option<Vec<Message>>>,
122
123    waiters: Mutex<Vec<CatchupWaiter>>,
124
125    consecutive_failures: AtomicU32,
126    failure_notified: AtomicBool,
127    disposed: AtomicBool,
128    retry_delay: Duration,
129
130    /// Weak self-reference so `on_turn_end` can spawn the drain task.
131    self_ref: Mutex<Option<Weak<AdvisorRuntime>>>,
132}
133
134impl AdvisorRuntime {
135    /// Construct. `retry_delay` is the backoff between failed advisor turns
136    /// (omp default 1000ms).
137    #[must_use]
138    pub fn new(
139        agent: Arc<dyn AdvisorAgent>,
140        host: Arc<dyn AdvisorRuntimeHost>,
141        retry_delay: Duration,
142    ) -> Self {
143        Self {
144            agent,
145            host,
146            transcript_path: Mutex::new(None),
147            state: Mutex::new(DrainState::default()),
148            epoch: AtomicU64::new(0),
149            backlog: AtomicU64::new(0),
150            last_count: AtomicU64::new(0),
151            latest: Mutex::new(None),
152            waiters: Mutex::new(Vec::new()),
153            consecutive_failures: AtomicU32::new(0),
154            failure_notified: AtomicBool::new(false),
155            disposed: AtomicBool::new(false),
156            retry_delay,
157            self_ref: Mutex::new(None),
158        }
159    }
160
161    /// Attach the host-owned advisor transcript path.
162    pub fn set_transcript_path(&self, path: Option<PathBuf>) {
163        *self.transcript_path.lock() = path;
164    }
165
166    /// Path to the advisor transcript, when persistence is available.
167    #[must_use]
168    pub fn transcript_path(&self) -> Option<PathBuf> {
169        self.transcript_path.lock().clone()
170    }
171
172    /// Install the weak self-reference required for self-spawning the drain
173    /// task. Call once after wrapping in `Arc`.
174    pub fn install_self(&self, weak: Weak<AdvisorRuntime>) {
175        *self.self_ref.lock() = Some(weak);
176    }
177
178    /// Current backlog (primary turns not yet digested by the advisor).
179    #[must_use]
180    pub fn backlog(&self) -> u64 {
181        self.backlog.load(Ordering::SeqCst)
182    }
183
184    /// Whether the runtime has been disposed.
185    #[must_use]
186    pub fn is_disposed(&self) -> bool {
187        self.disposed.load(Ordering::SeqCst)
188    }
189
190    /// Feed one primary turn's transcript to the advisor. Renders the delta
191    /// (new messages since the last drain), queues it, and spawns a drain task
192    /// if none is running. omp `onTurnEnd`.
193    pub fn on_turn_end(&self, messages: Vec<Message>) {
194        if self.disposed.load(Ordering::SeqCst) {
195            return;
196        }
197        *self.latest.lock() = Some(messages.clone());
198        let Some(render) = self.render_delta(&messages) else {
199            return;
200        };
201        let spawn = {
202            let mut s = self.state.lock();
203            s.pending.push(PendingDelta {
204                text: render,
205                turns: 1,
206            });
207            self.backlog.fetch_add(1, Ordering::SeqCst);
208            !s.draining
209        };
210        self.notify_waiters();
211        let drain_handle = self.self_ref.lock().as_ref().and_then(Weak::upgrade);
212        if spawn && let Some(this) = drain_handle {
213            tokio::spawn(async move {
214                this.drain().await;
215            });
216        }
217    }
218
219    /// Block until the backlog drops below `threshold`, or `max` elapses. omp
220    /// `waitForCatchup`. Registration + backlog-check happen under the waiters
221    /// lock so a concurrent `notify_waiters` cannot miss the waiter (the
222    /// catchup lost-wakeup fix).
223    pub async fn wait_for_catchup(&self, max: Duration, threshold: u64) {
224        if self.disposed.load(Ordering::SeqCst) || self.backlog.load(Ordering::SeqCst) < threshold {
225            return;
226        }
227        let (tx, rx) = oneshot::channel();
228        {
229            let mut waiters = self.waiters.lock();
230            // Re-check under the lock: a drain may have just decremented +
231            // notified before we registered.
232            if self.backlog.load(Ordering::SeqCst) < threshold {
233                return;
234            }
235            waiters.push(CatchupWaiter {
236                threshold,
237                tx: Some(tx),
238            });
239        }
240        let _ = tokio::time::timeout(max, rx).await;
241    }
242
243    /// Re-prime the advisor after a history rewrite (compaction, session
244    /// switch/resume, branch). Clears the advisor's context and rewinds the
245    /// cursor so the next turn replays the full current transcript. omp `reset`.
246    pub fn reset(&self) {
247        self.epoch.fetch_add(1, Ordering::SeqCst);
248        self.reset_advisor_context(true);
249        self.wake_all_waiters();
250    }
251
252    /// Seed the cursor to the current transcript length when the advisor is
253    /// enabled mid-session, so the next turn doesn't replay the entire history.
254    /// omp `seedTo`.
255    pub fn seed_to(&self, count: u64) {
256        self.epoch.fetch_add(1, Ordering::SeqCst);
257        self.last_count.store(count, Ordering::SeqCst);
258        let mut s = self.state.lock();
259        s.pending.clear();
260        // NOTE: do NOT clear `draining` here. Bumping the epoch above lets any
261        // in-flight drain exit on its own (epoch mismatch -> continue -> finds
262        // pending empty -> releases the draining role itself under this lock).
263        // Clearing `draining` externally would let a concurrent on_turn_end
264        // spawn a second drain while the first is still mid-prompt — two
265        // concurrent prompt() calls on one advisor agent.
266        self.backlog.store(0, Ordering::SeqCst);
267        self.consecutive_failures.store(0, Ordering::SeqCst);
268        self.failure_notified.store(false, Ordering::SeqCst);
269        drop(s);
270        self.wake_all_waiters();
271    }
272
273    /// Stop the runtime permanently. Aborts the advisor agent and drops all
274    /// pending state. omp `dispose`.
275    pub fn dispose(&self) {
276        self.disposed.store(true, Ordering::SeqCst);
277        self.epoch.fetch_add(1, Ordering::SeqCst);
278        let mut s = self.state.lock();
279        s.pending.clear();
280        s.draining = false;
281        self.backlog.store(0, Ordering::SeqCst);
282        drop(s);
283        self.wake_all_waiters();
284        self.agent.abort("advisor disposed");
285    }
286
287    fn reset_advisor_context(&self, clear_backlog: bool) {
288        self.last_count.store(0, Ordering::SeqCst);
289        let mut s = self.state.lock();
290        s.pending.clear();
291        if clear_backlog {
292            self.backlog.store(0, Ordering::SeqCst);
293        }
294        self.consecutive_failures.store(0, Ordering::SeqCst);
295        self.failure_notified.store(false, Ordering::SeqCst);
296        drop(s);
297        self.agent.reset();
298        self.agent.abort("advisor reset");
299    }
300
301    /// Render the transcript delta (messages added since `last_count`).
302    /// omp `#renderDelta`. Returns `None` when there is nothing new to feed.
303    fn render_delta(&self, messages: &[Message]) -> Option<String> {
304        let last = self.last_count.load(Ordering::SeqCst) as usize;
305        if messages.len() < last {
306            self.last_count
307                .store(messages.len() as u64, Ordering::SeqCst);
308            return None;
309        }
310        let delta = &messages[last..];
311        self.last_count
312            .store(messages.len() as u64, Ordering::SeqCst);
313        if delta.is_empty() {
314            return None;
315        }
316        let mut parts: Vec<String> = Vec::new();
317        for msg in delta {
318            if let Some(md) = format_message_md(msg) {
319                parts.push(md);
320            }
321        }
322        if parts.is_empty() {
323            return None;
324        }
325        Some(format!("### Session update\n\n{}", parts.join("\n\n")))
326    }
327
328    fn wake_all_waiters(&self) {
329        let mut waiters = self.waiters.lock();
330        for w in waiters.drain(..) {
331            if let Some(tx) = w.tx {
332                let _ = tx.send(());
333            }
334        }
335    }
336
337    fn notify_waiters(&self) {
338        let mut waiters = self.waiters.lock();
339        let backlog = self.backlog.load(Ordering::SeqCst);
340        for w in waiters.iter_mut() {
341            if backlog < w.threshold
342                && let Some(tx) = w.tx.take()
343            {
344                let _ = tx.send(());
345            }
346        }
347        waiters.retain(|w| w.tx.is_some());
348    }
349
350    fn decrement_backlog(&self, by: u64) {
351        let mut prev = self.backlog.load(Ordering::SeqCst);
352        loop {
353            let next = prev.saturating_sub(by);
354            match self
355                .backlog
356                .compare_exchange(prev, next, Ordering::SeqCst, Ordering::SeqCst)
357            {
358                Ok(_) => break,
359                Err(actual) => prev = actual,
360            }
361        }
362    }
363
364    /// The drain loop. Self-spawned by `on_turn_end`. Holds the "drainer" role
365    /// (under `state` lock) until the queue is empty, releasing it atomically
366    /// with the empty-check so a concurrent push cannot strand a delta.
367    async fn drain(self: Arc<Self>) {
368        {
369            let mut s = self.state.lock();
370            if s.draining || s.pending.is_empty() {
371                return;
372            }
373            s.draining = true;
374        }
375        loop {
376            // Take the whole pending queue as one batch (omp splices all).
377            let (batch_text, turns_covered) = {
378                let mut s = self.state.lock();
379                if s.pending.is_empty() {
380                    // Release the drainer role + empty-check in one critical
381                    // section: a concurrent on_turn_end push + spawn cannot
382                    // interleave here (it takes this same lock).
383                    s.draining = false;
384                    return;
385                }
386                let taken: Vec<PendingDelta> = s.pending.drain(..).collect();
387                let turns: u64 = taken.iter().map(|d| d.turns).sum();
388                let joined = taken
389                    .into_iter()
390                    .map(|d| d.text)
391                    .collect::<Vec<_>>()
392                    .join("\n\n");
393                (joined, turns)
394            };
395
396            let epoch_start = self.epoch.load(Ordering::SeqCst);
397
398            // Context maintenance (optional). A reset during maintenance
399            // invalidates this batch.
400            let should_reprime = self.host.maintain_context(batch_text.len());
401            if self.epoch.load(Ordering::SeqCst) != epoch_start {
402                continue;
403            }
404
405            let (batch, final_turns) = if should_reprime {
406                // Promotion could not fit — re-prime: reset advisor context,
407                // then re-render the full current transcript.
408                self.reset_advisor_context(false);
409                let new_turns = self.state.lock().pending.len() as u64;
410                let rendered = self
411                    .latest
412                    .lock()
413                    .as_ref()
414                    .and_then(|m| self.render_delta(m));
415                let final_turns = turns_covered.saturating_add(new_turns);
416                match rendered {
417                    Some(b) => (b, final_turns),
418                    None => {
419                        self.decrement_backlog(final_turns);
420                        self.notify_waiters();
421                        continue;
422                    }
423                }
424            } else {
425                (batch_text, turns_covered)
426            };
427
428            if self.disposed.load(Ordering::SeqCst) {
429                self.decrement_backlog(final_turns);
430                self.notify_waiters();
431                continue;
432            }
433
434            let message_snapshot = self.agent.message_count();
435            self.host.begin_advisor_update();
436            let prompt_result = self.agent.prompt(batch.clone()).await;
437
438            // A reset/dispose during the prompt invalidates this batch — drop it
439            // instead of requeuing into the post-reset conversation.
440            if self.epoch.load(Ordering::SeqCst) != epoch_start {
441                continue;
442            }
443
444            let success;
445            match prompt_result {
446                Ok(()) => {
447                    self.consecutive_failures.store(0, Ordering::SeqCst);
448                    self.failure_notified.store(false, Ordering::SeqCst);
449                    success = true;
450                }
451                Err(err) => {
452                    self.agent.rollback_to(message_snapshot).await;
453                    let failures = self.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
454                    if failures >= 3 {
455                        tracing::warn!(
456                            failures,
457                            "advisor failed consecutively; dropping backlog to prevent stall"
458                        );
459                        if !self.failure_notified.swap(true, Ordering::SeqCst) {
460                            self.host.notify_failure(&err);
461                        }
462                        self.consecutive_failures.store(0, Ordering::SeqCst);
463                        success = true;
464                    } else {
465                        // Requeue the failed batch at the head and back off.
466                        {
467                            let mut s = self.state.lock();
468                            s.pending.insert(
469                                0,
470                                PendingDelta {
471                                    text: batch,
472                                    turns: final_turns,
473                                },
474                            );
475                        }
476                        tokio::time::sleep(self.retry_delay).await;
477                        continue;
478                    }
479                }
480            }
481
482            if success {
483                self.decrement_backlog(final_turns);
484                self.notify_waiters();
485            }
486        }
487    }
488}
489
490/// Format one message as lean markdown for the advisor's transcript view.
491/// omp uses `formatSessionHistoryMarkdown` (thinking/tool-intent aware); this
492/// is a v1 — role tag + text content. Enrich later.
493fn format_message_md(msg: &Message) -> Option<String> {
494    let role = match msg {
495        Message::User(_) => "user",
496        Message::Assistant(_) => "assistant",
497        Message::ToolResult(_) => "tool",
498    };
499    let text = msg.text_content().unwrap_or_default();
500    if text.trim().is_empty() {
501        return None;
502    }
503    Some(format!("**[{role}]**\n{text}"))
504}
505
506#[cfg(test)]
507mod tests {
508    #![allow(clippy::unwrap_used)]
509    use super::*;
510    use std::sync::Mutex as StdMutex;
511    type PromptLog = Arc<StdMutex<Vec<String>>>;
512    type AdviceLog = Arc<StdMutex<Vec<AdvisorNote>>>;
513
514    /// Minimal advisor-agent fake that records prompts and can be made to fail.
515    struct FakeAgent {
516        prompts: PromptLog,
517        fail_first_n: AtomicU32,
518        messages_len: AtomicU64,
519    }
520
521    impl FakeAgent {
522        fn new() -> (Arc<Self>, PromptLog) {
523            let prompts = Arc::new(StdMutex::new(Vec::new()));
524            let a = Arc::new(Self {
525                prompts: Arc::clone(&prompts),
526                fail_first_n: AtomicU32::new(0),
527                messages_len: AtomicU64::new(0),
528            });
529            (a, prompts)
530        }
531    }
532
533    #[async_trait]
534    impl AdvisorAgent for FakeAgent {
535        async fn prompt(&self, input: String) -> Result<(), String> {
536            // simulate appending a user+assistant turn (4 messages)
537            self.messages_len.fetch_add(4, Ordering::SeqCst);
538            self.prompts.lock().unwrap().push(input);
539            // Fail the first `fail_first_n` calls, then succeed. (Atomic
540            // subtraction on 0 would wrap to u32::MAX, so load-then-decrement
541            // only while the counter is positive.)
542            let n = self.fail_first_n.load(Ordering::SeqCst);
543            if n > 0 {
544                self.fail_first_n.fetch_sub(1, Ordering::SeqCst);
545                Err("simulated advisor failure".into())
546            } else {
547                Ok(())
548            }
549        }
550        fn abort(&self, _reason: &str) {}
551        fn reset(&self) {
552            self.messages_len.store(0, Ordering::SeqCst);
553        }
554        async fn rollback_to(&self, count: usize) {
555            self.messages_len.store(count as u64, Ordering::SeqCst);
556        }
557        fn message_count(&self) -> usize {
558            self.messages_len.load(Ordering::SeqCst) as usize
559        }
560    }
561
562    /// Host fake that records enqueued advice.
563    struct FakeHost {
564        advice: AdviceLog,
565    }
566    impl AdvisorRuntimeHost for FakeHost {
567        fn snapshot_messages(&self) -> Vec<Message> {
568            Vec::new()
569        }
570        fn enqueue_advice(&self, note: AdvisorNote) {
571            self.advice.lock().unwrap().push(note);
572        }
573    }
574
575    fn build() -> (Arc<AdvisorRuntime>, PromptLog, AdviceLog) {
576        let (agent, prompts) = FakeAgent::new();
577        let advice = Arc::new(StdMutex::new(Vec::new()));
578        let host: Arc<dyn AdvisorRuntimeHost> = Arc::new(FakeHost {
579            advice: Arc::clone(&advice),
580        });
581        let rt = Arc::new(AdvisorRuntime::new(agent, host, Duration::from_millis(10)));
582        rt.install_self(Arc::downgrade(&rt));
583        (rt, prompts, advice)
584    }
585
586    fn user_msg(s: &str) -> Message {
587        Message::user(s)
588    }
589
590    #[tokio::test]
591    async fn drain_prompts_advisor_with_delta() {
592        let (rt, prompts, _advice) = build();
593        rt.on_turn_end(vec![user_msg("turn 1")]);
594        // allow the spawned drain to run
595        tokio::time::sleep(Duration::from_millis(50)).await;
596        let p = prompts.lock().unwrap();
597        assert_eq!(p.len(), 1);
598        assert!(p[0].contains("turn 1"));
599        assert!(p[0].starts_with("### Session update"));
600    }
601
602    #[tokio::test]
603    async fn reset_aborts_inflight_and_drops_batch() {
604        let (rt, prompts, _advice) = build();
605        rt.on_turn_end(vec![user_msg("turn 1")]);
606        rt.reset(); // bump epoch — in-flight batch should be dropped
607        tokio::time::sleep(Duration::from_millis(50)).await;
608        // The pre-reset prompt may or may not have landed, but the epoch guard
609        // means backlog accounting for the stale batch is skipped. Backlog is 0.
610        assert_eq!(rt.backlog(), 0);
611        let _ = prompts.lock().unwrap().len();
612    }
613
614    #[tokio::test]
615    async fn drain_exit_racing_turn_end_no_lost_wakeup() {
616        // Hammer on_turn_end from many tasks racing the drain's exit path.
617        // Every delta must eventually be consumed (no stranded pending).
618        let (rt, _prompts, _advice) = build();
619        let rt2 = Arc::clone(&rt);
620        let handles: Vec<_> = (0..20)
621            .map(move |i| {
622                let rt3 = Arc::clone(&rt2);
623                tokio::spawn(async move {
624                    rt3.on_turn_end(vec![user_msg(&format!("turn {i}"))]);
625                })
626            })
627            .collect();
628        for h in handles {
629            h.await.unwrap();
630        }
631        // Give drains time to quiesce.
632        tokio::time::sleep(Duration::from_millis(120)).await;
633        assert_eq!(rt.backlog(), 0);
634        // No pending stranded.
635        let pending = rt.state.lock().pending.len();
636        assert_eq!(pending, 0);
637    }
638
639    #[tokio::test]
640    async fn wait_for_catchup_resolves_below_threshold() {
641        let (rt, _prompts, _advice) = build();
642        rt.on_turn_end(vec![user_msg("turn 1")]);
643        // threshold 0 -> already below, returns immediately
644        rt.wait_for_catchup(Duration::from_millis(50), 0).await;
645        // wait for drain to clear backlog
646        let _ = tokio::time::timeout(Duration::from_millis(200), async {
647            while rt.backlog() > 0 {
648                tokio::time::sleep(Duration::from_millis(5)).await;
649            }
650        })
651        .await;
652        assert_eq!(rt.backlog(), 0);
653    }
654
655    #[tokio::test]
656    async fn seed_to_skips_history() {
657        let (rt, prompts, _advice) = build();
658        rt.seed_to(5); // cursor at 5
659        // a turn with only 3 messages (< cursor) renders nothing
660        rt.on_turn_end(vec![user_msg("a"), user_msg("b"), user_msg("c")]);
661        tokio::time::sleep(Duration::from_millis(30)).await;
662        assert!(prompts.lock().unwrap().is_empty());
663    }
664
665    #[tokio::test]
666    async fn reprime_via_maintain_context() {
667        // Host demands re-prime on every call -> advisor context reset, full
668        // transcript replayed.
669        struct ReprimeHost {
670            advice: Arc<StdMutex<Vec<AdvisorNote>>>,
671        }
672        impl AdvisorRuntimeHost for ReprimeHost {
673            fn snapshot_messages(&self) -> Vec<Message> {
674                Vec::new()
675            }
676            fn enqueue_advice(&self, n: AdvisorNote) {
677                self.advice.lock().unwrap().push(n);
678            }
679            fn maintain_context(&self, _t: usize) -> bool {
680                true
681            }
682        }
683        let (agent, prompts) = FakeAgent::new();
684        let advice = Arc::new(StdMutex::new(Vec::new()));
685        let host: Arc<dyn AdvisorRuntimeHost> = Arc::new(ReprimeHost {
686            advice: Arc::clone(&advice),
687        });
688        let rt = Arc::new(AdvisorRuntime::new(agent, host, Duration::from_millis(10)));
689        rt.install_self(Arc::downgrade(&rt));
690        rt.on_turn_end(vec![user_msg("turn 1"), user_msg("turn 2")]);
691        tokio::time::sleep(Duration::from_millis(60)).await;
692        let p = prompts.lock().unwrap();
693        assert!(!p.is_empty());
694        // re-prime replays the full latest transcript (both turns)
695        assert!(p[0].contains("turn 1") && p[0].contains("turn 2"));
696    }
697}