Skip to main content

wcode_harness/
agent.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use futures::StreamExt as _;
5use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
6use tokio::sync::watch;
7use tokio_util::sync::CancellationToken;
8
9use crate::compaction::{self, CompactOutcome, CompactionPolicy};
10use crate::event::{AgentEvent, LlmStreamEvent};
11use crate::hooks::{HooksSet, PlanModeHandle};
12use crate::loop_::{LoopConfig, LoopError, run_loop};
13use crate::message::{AgentMessage, StopReason, Usage};
14use crate::session::{Session, SessionEntry};
15use crate::streamfn::{LlmOpts, StreamFn};
16use crate::tool::Tool;
17
18/// The system-prompt section appended while plan mode is on (D2). The kernel owns
19/// it so a socket client never has to compose the server's prompt.
20pub const PLAN_SECTION: &str = "# Plan mode\nYou are planning, not executing. Explore and propose only — do NOT modify the\nworkspace: the editing tools are disabled and mutating shell commands are refused.\nFine-tune the plan with the user first. When the plan is final, record it as a\ntodo list (status pending), then tell the user to run `/plan off` to execute.";
21
22pub struct AgentConfig {
23    pub system: String,
24    pub tools: Vec<Tool>,
25    pub llm: LlmOpts,
26    pub stream_fn: StreamFn,
27    pub hooks: HooksSet,
28    pub session: Option<Session>, // None = no persistence
29    /// Prior messages seeding the conversation (e.g. resumed session history).
30    pub context: Vec<AgentMessage>,
31    /// Working directory for tool execution; empty falls back to the process
32    /// cwd (bash runs here, file paths resolve) — plumb it so embeddings can
33    /// run against a directory other than `current_dir()`.
34    pub working_dir: PathBuf,
35    /// Maximum number of LLM turns in a single `run`; caps a runaway tool loop
36    /// (see [`crate::loop_::LoopConfig::max_turns`]). Use
37    /// [`crate::loop_::DEFAULT_MAX_TURNS`] for the kernel default.
38    pub max_turns: usize,
39    /// Run concurrency-safe tool calls in one batch in parallel. `false`
40    /// restores strictly-sequential execution.
41    pub parallel_tools: bool,
42    /// When to compact and how much recent context to keep
43    /// (see [`crate::compaction`]). Use [`CompactionPolicy::default`].
44    pub compaction: CompactionPolicy,
45    /// The shared plan-mode switch; the same handle is held by the
46    /// `PlanModeHooks` in `hooks`, so `set_plan_mode` flips both.
47    pub plan_mode: PlanModeHandle,
48}
49
50pub struct Agent {
51    system: String,
52    /// The composed prompt as passed (plan mode off); `system` derives from it.
53    base_system: String,
54    tools: Vec<Tool>,
55    llm: LlmOpts,
56    stream_fn: StreamFn,
57    hooks: HooksSet,
58    session: Option<Session>,
59    working_dir: PathBuf,
60    max_turns: usize,
61    parallel_tools: bool,
62    compaction: CompactionPolicy,
63    plan_mode: PlanModeHandle,
64    ctx: Vec<AgentMessage>,
65    steer_tx: UnboundedSender<AgentMessage>,
66    steer_rx: Option<UnboundedReceiver<AgentMessage>>,
67    follow_tx: UnboundedSender<AgentMessage>,
68    follow_rx: Option<UnboundedReceiver<AgentMessage>>,
69    cancel: CancellationToken,
70    /// The context snapshot the run republishes at each turn boundary (see
71    /// `LoopConfig::ctx_snapshot`); [`Agent::side_handle`] clones it so the actor
72    /// can answer a mid-run read without the `&mut` the run holds.
73    ctx_snapshot: watch::Sender<Vec<AgentMessage>>,
74}
75
76impl Agent {
77    pub fn new(mut cfg: AgentConfig) -> Agent {
78        // Stable routing id for providers (OpenCode Go `x-opencode-session`):
79        // session header wins (a resumed Agent inherits `llm` from the old
80        // one, so the opt may be stale), else explicit opt, else a per-agent
81        // id (covers --no-session / in-memory).
82        cfg.llm.session_id = cfg
83            .session
84            .as_ref()
85            .and_then(session_header_id)
86            .or_else(|| cfg.llm.session_id.clone())
87            .or_else(|| Some(uuid::Uuid::new_v4().to_string()));
88        let (steer_tx, steer_rx) = mpsc::unbounded_channel();
89        let (follow_tx, follow_rx) = mpsc::unbounded_channel();
90        // Seeded with the initial context so a read before the first turn sees
91        // it; `run_loop` republishes at each boundary. `send_replace` is
92        // infallible even with no receivers, so only the sender is held.
93        let (ctx_snapshot, _ctx_rx) = watch::channel(cfg.context.clone());
94        // An empty working_dir means "process cwd" (the historical default).
95        let working_dir = if cfg.working_dir.as_os_str().is_empty() {
96            default_working_dir()
97        } else {
98            cfg.working_dir
99        };
100        Agent {
101            system: cfg.system.clone(),
102            base_system: cfg.system,
103            tools: cfg.tools,
104            llm: cfg.llm,
105            stream_fn: cfg.stream_fn,
106            hooks: cfg.hooks,
107            session: cfg.session,
108            working_dir,
109            max_turns: cfg.max_turns,
110            parallel_tools: cfg.parallel_tools,
111            compaction: cfg.compaction,
112            plan_mode: cfg.plan_mode,
113            ctx: cfg.context,
114            steer_tx,
115            steer_rx: Some(steer_rx),
116            follow_tx,
117            follow_rx: Some(follow_rx),
118            cancel: CancellationToken::new(),
119            ctx_snapshot,
120        }
121    }
122
123    /// Queued for injection at the next turn start (this run or a later one).
124    pub fn steer(&self, m: AgentMessage) {
125        let _ = self.steer_tx.send(m);
126    }
127
128    /// Toggle plan mode: flips the shared handle and recomposes `system` from
129    /// `base_system` — appending [`PLAN_SECTION`] when on, restoring the base
130    /// prompt exactly when off. Takes effect on the NEXT run (the in-flight one
131    /// cloned `system` into its `LoopConfig` at start). Not persisted.
132    pub fn set_plan_mode(&mut self, on: bool) {
133        self.plan_mode.set(on);
134        self.system = if on {
135            format!("{}\n\n{}", self.base_system, PLAN_SECTION)
136        } else {
137            self.base_system.clone()
138        };
139    }
140    /// Swaps the model mid-conversation (takes effect on the next run) and
141    /// logs a `ModelChange` entry when a session is open. The new id's
142    /// `[models.<id>]` profile re-points `endpoint`/`base_url`/`api_key` by
143    /// settling on the launch provider first, so an unmapped id — and a switch
144    /// back to a previous model — falls back to it (the switch is reversible).
145    pub fn set_model(&mut self, model: String) -> std::io::Result<()> {
146        // Log first: a failed append must not leave the agent and the session
147        // disagreeing about which model runs next.
148        if let Some(session) = &mut self.session {
149            session.append(SessionEntry::ModelChange {
150                id: uuid::Uuid::new_v4().to_string(),
151                model: model.clone(),
152            })?;
153        }
154        self.llm.model = model;
155        // Settle the new id's provider: the launch provider, then this model's
156        // `[models.<id>]` overlay (a no-op when no profiles are configured).
157        self.llm.settle_provider();
158        Ok(())
159    }
160
161    /// Current reasoning effort (None = send nothing).
162    pub fn effort(&self) -> Option<&str> {
163        self.llm.effort.as_deref()
164    }
165
166    /// Swaps the reasoning effort mid-conversation (next run) and logs an
167    /// `EffortChange` entry when a session is open. None clears it.
168    pub fn set_effort(&mut self, effort: Option<String>) -> std::io::Result<()> {
169        if let Some(session) = &mut self.session {
170            session.append(SessionEntry::EffortChange {
171                id: uuid::Uuid::new_v4().to_string(),
172                effort: effort.clone(),
173            })?;
174        }
175        self.llm.effort = effort;
176        Ok(())
177    }
178
179    /// Sender clone for steering while `run` holds the `&mut` borrow (UI tasks).
180    ///
181    /// `run_loop` hands the lent channels back at the end of every run, so a
182    /// clone taken once stays valid across runs and a message sent while a run
183    /// is in flight — even one the loop never drained — reaches the next run.
184    /// (Only an *error* exit reboots the channels, invalidating old clones.)
185    pub fn steer_sender(&self) -> UnboundedSender<AgentMessage> {
186        self.steer_tx.clone()
187    }
188
189    /// Runs after the loop would stop.
190    pub fn follow_up(&self, m: AgentMessage) {
191        let _ = self.follow_tx.send(m);
192    }
193
194    /// Sender clone for injecting a follow-up while `run` holds the `&mut`
195    /// borrow (the mirror of [`Agent::steer_sender`], for a UI/actor task that
196    /// cannot reach `follow_up` directly).
197    pub fn follow_up_sender(&self) -> UnboundedSender<AgentMessage> {
198        self.follow_tx.clone()
199    }
200
201    pub fn cancel(&self) {
202        self.cancel.cancel();
203    }
204
205    /// Token for the run in flight or the next one; re-acquire after each
206    /// `run()` (a fresh token is minted at the end of every run).
207    pub fn cancel_token(&self) -> CancellationToken {
208        self.cancel.clone()
209    }
210
211    /// The hook set, exposed so the actor can run the inbound policy
212    /// ([`HooksSet::before_inbound`]) while `run` holds the `&mut` borrow.
213    pub fn hooks(&self) -> &HooksSet {
214        &self.hooks
215    }
216
217    pub async fn run(
218        &mut self,
219        user_text: &str,
220        sink: UnboundedSender<AgentEvent>,
221    ) -> Result<StopReason, LoopError> {
222        // The user message is persisted before the loop takes over; the loop
223        // records every subsequent message as it enters ctx, so a crash
224        // mid-run loses at most the in-flight message instead of the whole
225        // turn (see `record_session` in loop_).
226        let user = AgentMessage::user_text(user_text);
227        if let Some(session) = &mut self.session {
228            let appended = session
229                .append(SessionEntry::Message {
230                    id: uuid::Uuid::new_v4().to_string(),
231                    parent_id: None,
232                    message: user.clone(),
233                })
234                .map_err(LoopError::Session);
235            if let Err(e) = appended {
236                // A session-append I/O failure on this very first write exits the
237                // run *before* the loop, on a path the `Ok` arm far below never
238                // sees. Wake the owner here too, so a worker's report can never
239                // silently vanish; `StopReason::Error` makes `ReportBack`
240                // synthesize a status line rather than forward a stale prior reply.
241                self.hooks.after_run(&self.ctx, StopReason::Error).await;
242                return Err(e);
243            }
244        }
245        self.ctx.push(user);
246
247        // Cover the window between the submit and the first turn boundary: a
248        // read arriving before the loop's first publish still sees the question.
249        let _ = self.ctx_snapshot.send_replace(self.ctx.clone());
250
251        let steering = self.steer_rx.take().expect("steer_rx present between runs");
252        let follow_ups = self
253            .follow_rx
254            .take()
255            .expect("follow_rx present between runs");
256        let cfg = LoopConfig {
257            system: self.system.clone(),
258            tools: self.tools.clone(),
259            llm: self.llm.clone(),
260            stream_fn: Arc::clone(&self.stream_fn),
261            hooks: self.hooks.clone(),
262            steering,
263            follow_ups,
264            cancel: self.cancel.clone(),
265            working_dir: self.working_dir.clone(),
266            max_turns: self.max_turns,
267            parallel: self.parallel_tools,
268            compaction: self.compaction,
269            ctx_snapshot: self.ctx_snapshot.clone(),
270            session: self.session.as_mut(),
271        };
272        let res = run_loop(&mut self.ctx, cfg, sink).await;
273
274        // Fresh token for the next run: CancellationToken has no reset, so a
275        // cancel from this run must not poison the next one.
276        self.cancel = CancellationToken::new();
277
278        match res {
279            Ok(result) => {
280                // run_loop hands the lent channels back, so steer()/follow_up()
281                // and any sender clone taken before the run keep working, and a
282                // message sent while the run was in flight — even one the loop
283                // never drained — reaches the next run.
284                self.steer_rx = Some(result.steering);
285                self.follow_rx = Some(result.follow_ups);
286                // An `after_run` hook may act on the finished run (the A2A
287                // auto-report forwards a worker's result to its orchestrator).
288                self.hooks.after_run(&self.ctx, result.stop_reason).await;
289                Ok(result.stop_reason)
290            }
291            Err(e) => {
292                // Error exit: the channels were dropped with the error. Reboot
293                // both sides so a later steer()/follow_up() still work
294                // (pre-run sender clones are stale after an error).
295                let (steer_tx, steer_rx) = mpsc::unbounded_channel();
296                self.steer_tx = steer_tx;
297                self.steer_rx = Some(steer_rx);
298                let (follow_tx, follow_rx) = mpsc::unbounded_channel();
299                self.follow_tx = follow_tx;
300                self.follow_rx = Some(follow_rx);
301                // A `LoopError` never reaches the `Ok` arm above, so the owner of a
302                // worker would block forever on a report that never fires. Wake it
303                // here too; `StopReason::Error` makes `ReportBack` synthesize a
304                // status line rather than forward a stale prior reply.
305                self.hooks.after_run(&self.ctx, StopReason::Error).await;
306                Err(e)
307            }
308        }
309    }
310
311    pub fn messages(&self) -> &[AgentMessage] {
312        &self.ctx
313    }
314
315    /// Record an inbound peer message into the conversation *without* starting a
316    /// turn (the `Request::Notify` semantic): persist it as a user message and
317    /// push it onto the context, so the next run sees it. Returns the recorded
318    /// message so the actor can surface it. Idle-only — the actor routes a
319    /// `Notify` that arrives mid-run through steering instead.
320    pub fn notify(&mut self, content: String) -> std::io::Result<AgentMessage> {
321        let message = AgentMessage::user_text(content);
322        if let Some(session) = &mut self.session {
323            session.append(SessionEntry::Message {
324                id: uuid::Uuid::new_v4().to_string(),
325                parent_id: None,
326                message: message.clone(),
327            })?;
328        }
329        self.ctx.push(message.clone());
330        Ok(message)
331    }
332
333    /// Answer `text` tool-free, from the current context, as a one-off side call.
334    ///
335    /// CLONES `ctx`, appends the (framed) question, runs the raw `stream_fn` once
336    /// with an EMPTY tool list, and folds `TextDelta` → text until `Done`
337    /// (capturing `usage`). `self.ctx` and `self.session` are never mutated or
338    /// appended — hence `&self`, so it stays callable while the actor holds
339    /// `&mut Agent` for a run.
340    ///
341    /// Errors become [`LoopError::Stream`]: a stream `Error`, or a call that
342    /// ended with no text. No retry loop of its own — it inherits whatever
343    /// retry/idle-timeout the configured `stream_fn` adapter applies internally,
344    /// exactly as [`crate::compaction::summarize`] does. No cancel path in v1.
345    pub async fn side_ask(&self, text: &str) -> Result<SideAnswer, LoopError> {
346        side_ask_with(&self.stream_fn, &self.ctx, &self.system, &self.llm, text).await
347    }
348
349    /// A read-only handle the actor takes *before* a run borrows the agent, so a
350    /// mid-run `GetHistory`/`SideAsk` is answered from the context snapshot
351    /// `run_loop` republishes each turn, instead of waiting for the whole run.
352    /// See [`SideHandle`].
353    pub fn side_handle(&self) -> SideHandle {
354        SideHandle {
355            stream_fn: Arc::clone(&self.stream_fn),
356            system: self.system.clone(),
357            llm: self.llm.clone(),
358            ctx: self.ctx_snapshot.clone(),
359        }
360    }
361
362    /// Summarize the older part of the conversation in place: keep the newest
363    /// messages per [`CompactionPolicy`] and replace the rest with a single
364    /// summary message. `instructions` focuses the summary (the
365    /// `/compact <prompt>` argument).
366    ///
367    /// When a session is open, the boundary is persisted
368    /// ([`crate::session::SessionEntry::Compaction`]), so a resumed session
369    /// rebuilds to the same `summary + kept` view instead of re-expanding.
370    pub async fn compact(&mut self, instructions: Option<&str>) -> Result<CompactOutcome, String> {
371        compaction::compact_ctx(
372            &mut self.ctx,
373            &self.compaction,
374            &self.stream_fn,
375            &self.llm,
376            instructions,
377            self.session.as_mut(),
378        )
379        .await
380    }
381
382    pub fn session_path(&self) -> Option<&Path> {
383        self.session.as_ref().and_then(|s| s.path())
384    }
385
386    /// The session's stable id (from its `Header`), if a session is open. The
387    /// address a [`SessionHandle`](crate::actor::SessionHandle)'s frames carry.
388    pub fn session_id(&self) -> Option<String> {
389        self.session.as_ref().and_then(session_header_id)
390    }
391}
392
393/// The reply to a `/btw` side question: the answer text and the turn's token
394/// usage (`None` when the provider reported none). Harness data — no styling.
395#[derive(Clone, Debug, Default, PartialEq)]
396pub struct SideAnswer {
397    pub text: String,
398    pub usage: Option<Usage>,
399}
400
401/// A read-only view of an [`Agent`] the actor can mine *while a run holds the
402/// agent's `&mut`. It carries the same clones the run uses (`stream_fn`/
403/// `system`/`llm`) plus the context snapshot `run_loop` republishes each turn,
404/// so a mid-run `GetHistory`/`SideAsk` is answered from the latest completed
405/// turns instead of waiting for the whole run to finish.
406#[derive(Clone)]
407pub struct SideHandle {
408    stream_fn: StreamFn,
409    system: String,
410    llm: LlmOpts,
411    ctx: watch::Sender<Vec<AgentMessage>>,
412}
413
414impl SideHandle {
415    /// The latest turn-boundary context snapshot, owned (`send_replace` keeps
416    /// the value even with no receivers, so a read after the run still sees it).
417    pub fn history(&self) -> Vec<AgentMessage> {
418        self.ctx.subscribe().borrow().to_vec()
419    }
420
421    /// The latest snapshot's last-assistant text — a compact status read
422    /// (`crate::protocol::Request::Status`) that borrows the watch value instead
423    /// of cloning the whole transcript.
424    pub fn last_assistant_text(&self) -> Option<String> {
425        let rx = self.ctx.subscribe();
426        let messages = rx.borrow();
427        crate::message::last_assistant_text(&messages[..])
428    }
429
430    /// Answer `text` tool-free from the latest snapshot; semantics match
431    /// [`Agent::side_ask`].
432    pub async fn side_ask(&self, text: &str) -> Result<SideAnswer, LoopError> {
433        side_ask_with(&self.stream_fn, &self.history(), &self.system, &self.llm, text).await
434    }
435}
436
437/// The shared body of a side ask: frame `text` as a tool-free aside, run the raw
438/// `stream_fn` once with an EMPTY tool list, and fold `TextDelta` → text until
439/// `Done` (capturing `usage`). [`Agent::side_ask`] passes `&self.ctx`;
440/// [`SideHandle::side_ask`] passes the latest mid-run snapshot.
441async fn side_ask_with(
442    stream_fn: &StreamFn,
443    ctx: &[AgentMessage],
444    system: &str,
445    llm: &LlmOpts,
446    text: &str,
447) -> Result<SideAnswer, LoopError> {
448    let mut msgs = ctx.to_vec();
449    msgs.push(AgentMessage::user_text(frame_btw(text)));
450    let mut stream = (stream_fn)(&msgs, system, &[], llm);
451    let mut out = String::new();
452    let mut usage = None;
453    while let Some(ev) = stream.next().await {
454        match ev {
455            LlmStreamEvent::TextDelta(delta) => out.push_str(&delta),
456            LlmStreamEvent::Done { usage: u, .. } => {
457                usage = u;
458                break;
459            }
460            LlmStreamEvent::Error { message, .. } => return Err(LoopError::Stream(message)),
461            _ => {}
462        }
463    }
464    let text = out.trim().to_string();
465    if text.is_empty() {
466        return Err(LoopError::Stream("side answer produced no text".to_string()));
467    }
468    Ok(SideAnswer { text, usage })
469}
470
471/// Frame a raw `/btw` question so the model knows it is a tool-free aside: the
472/// tools are simply absent, so without a hint it may try to call one and fail.
473fn frame_btw(q: &str) -> String {
474    format!("[side question — no tools available; answer briefly and directly from the conversation above]\n{q}")
475}
476
477/// Process cwd, used only when `AgentConfig.working_dir` is left empty.
478fn default_working_dir() -> PathBuf {
479    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
480}
481
482/// Id from the session's `Header` entry, if present (old files may lack one).
483fn session_header_id(session: &Session) -> Option<String> {
484    session.entries().iter().find_map(|e| match e {
485        SessionEntry::Header { id, .. } => Some(id.clone()),
486        _ => None,
487    })
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use crate::compaction::CompactionPolicy;
494    use crate::hooks::HooksSet;
495    use crate::streamfn::{LlmStream, StreamFn};
496
497    /// A `StreamFn` that replays `script` on each call.
498    fn scripted(script: Vec<LlmStreamEvent>) -> StreamFn {
499        Arc::new(move |_ctx: &[AgentMessage], _system, _tools, _opts: &LlmOpts| {
500            Box::pin(futures::stream::iter(script.clone())) as LlmStream
501        })
502    }
503
504    fn make_agent(session: Option<Session>, stream_fn: StreamFn, context: Vec<AgentMessage>) -> Agent {
505        Agent::new(AgentConfig {
506            system: "sys".into(),
507            tools: vec![],
508            llm: LlmOpts {
509                model: "m1".into(),
510                ..LlmOpts::default()
511            },
512            stream_fn,
513            hooks: HooksSet::default(),
514            session,
515            context,
516            working_dir: PathBuf::new(),
517            max_turns: crate::loop_::DEFAULT_MAX_TURNS,
518            parallel_tools: true,
519            compaction: CompactionPolicy::default(),
520            plan_mode: PlanModeHandle::new(),
521        })
522    }
523
524    #[tokio::test]
525    async fn side_ask_leaves_ctx_and_session_untouched() {
526        let dir = tempfile::tempdir().unwrap();
527        let session = Session::create(dir.path()).unwrap();
528        let stream = scripted(vec![
529            LlmStreamEvent::TextDelta("an".into()),
530            LlmStreamEvent::TextDelta("swer".into()),
531            LlmStreamEvent::Done {
532                stop_reason: StopReason::Stop,
533                usage: None,
534            },
535        ]);
536        let agent = make_agent(Some(session), stream, vec![AgentMessage::user_text("earlier")]);
537
538        let ctx_before = agent.ctx.clone();
539        let path = agent.session.as_ref().unwrap().path().unwrap().to_path_buf();
540        let entries_before = agent.session.as_ref().unwrap().entries().to_vec();
541        let bytes_before = std::fs::read(&path).unwrap();
542
543        let answer = agent.side_ask("why?").await.unwrap();
544        assert_eq!(answer.text, "answer");
545
546        assert_eq!(agent.ctx, ctx_before, "ctx is unchanged");
547        assert_eq!(agent.messages(), &ctx_before[..]);
548        assert_eq!(
549            agent.session.as_ref().unwrap().entries(),
550            &entries_before[..],
551            "session entries unchanged"
552        );
553        assert_eq!(
554            std::fs::read(&path).unwrap(),
555            bytes_before,
556            "session file bytes unchanged"
557        );
558    }
559
560    #[tokio::test]
561    async fn side_ask_folds_deltas_and_captures_usage() {
562        let usage = Usage {
563            input_tokens: 7,
564            output_tokens: 3,
565            cache_read_tokens: Some(2),
566            cache_write_tokens: None,
567        };
568        let stream = scripted(vec![
569            LlmStreamEvent::TextDelta("he".into()),
570            LlmStreamEvent::TextDelta("llo".into()),
571            LlmStreamEvent::Done {
572                stop_reason: StopReason::Stop,
573                usage: Some(usage),
574            },
575        ]);
576        let agent = make_agent(None, stream, vec![]);
577        assert_eq!(
578            agent.side_ask("hi").await.unwrap(),
579            SideAnswer {
580                text: "hello".into(),
581                usage: Some(usage),
582            }
583        );
584    }
585
586    #[tokio::test]
587    async fn side_ask_surfaces_a_stream_error() {
588        let stream = scripted(vec![LlmStreamEvent::Error {
589            message: "boom".into(),
590            fatal: true,
591        }]);
592        let agent = make_agent(None, stream, vec![]);
593        let err = agent.side_ask("hi").await.unwrap_err();
594        assert!(err.to_string().contains("boom"), "{err}");
595    }
596
597    #[tokio::test]
598    async fn side_ask_errors_on_empty_output() {
599        let stream = scripted(vec![LlmStreamEvent::Done {
600            stop_reason: StopReason::Stop,
601            usage: None,
602        }]);
603        let agent = make_agent(None, stream, vec![]);
604        let err = agent.side_ask("hi").await.unwrap_err();
605        assert!(err.to_string().contains("no text"), "{err}");
606    }
607
608    #[test]
609    fn frame_btw_wraps_the_question() {
610        let framed = frame_btw("why?");
611        assert!(framed.starts_with("[side question"));
612        assert!(framed.ends_with("\nwhy?"));
613    }
614
615    #[test]
616    fn set_plan_mode_appends_and_restores_the_section() {
617        let mut agent = make_agent(None, scripted(vec![]), vec![]);
618        let base = agent.system.clone();
619
620        agent.set_plan_mode(true);
621        assert!(agent.plan_mode.get());
622        assert_eq!(agent.system, format!("{base}\n\n{PLAN_SECTION}"));
623        assert!(agent.system.contains("# Plan mode"));
624        assert!(
625            agent.system.contains("record it as a\ntodo list (status pending)"),
626            "the P2 plan\u{2192}todo instruction is present"
627        );
628
629        agent.set_plan_mode(false);
630        assert!(!agent.plan_mode.get());
631        assert_eq!(agent.system, base, "restored exactly");
632    }
633}