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    /// Auto-title after this many assistant turns; `0` disables it (W031
49    /// **D2**). Use [`crate::title::DEFAULT_TITLE_AFTER_TURNS`]. Plumbed into
50    /// `LoopConfig::title_after_turns` by [`Agent::run`] — the kernel never
51    /// reads it outside the loop, so there is no `Agent` mirror of it.
52    pub title_after_turns: usize,
53}
54
55pub struct Agent {
56    system: String,
57    /// The composed prompt as passed (plan mode off); `system` derives from it.
58    base_system: String,
59    tools: Vec<Tool>,
60    llm: LlmOpts,
61    stream_fn: StreamFn,
62    hooks: HooksSet,
63    session: Option<Session>,
64    working_dir: PathBuf,
65    max_turns: usize,
66    parallel_tools: bool,
67    compaction: CompactionPolicy,
68    plan_mode: PlanModeHandle,
69    /// W031 D2: assistant turns before the auto-titler fires; `0` = off.
70    title_after_turns: usize,
71    /// The in-memory mirror of the session's persisted `Title` entry. Only
72    /// authoritative when no session is open — see [`Agent::title`].
73    title: Option<String>,
74    ctx: Vec<AgentMessage>,
75    steer_tx: UnboundedSender<AgentMessage>,
76    steer_rx: Option<UnboundedReceiver<AgentMessage>>,
77    follow_tx: UnboundedSender<AgentMessage>,
78    follow_rx: Option<UnboundedReceiver<AgentMessage>>,
79    cancel: CancellationToken,
80    /// The context snapshot the run republishes at each turn boundary (see
81    /// `LoopConfig::ctx_snapshot`); [`Agent::side_handle`] clones it so the actor
82    /// can answer a mid-run read without the `&mut` the run holds.
83    ctx_snapshot: watch::Sender<Vec<AgentMessage>>,
84}
85
86impl Agent {
87    pub fn new(mut cfg: AgentConfig) -> Agent {
88        // Stable routing id for providers (OpenCode Go `x-opencode-session`):
89        // session header wins (a resumed Agent inherits `llm` from the old
90        // one, so the opt may be stale), else explicit opt, else a per-agent
91        // id (covers --no-session / in-memory).
92        cfg.llm.session_id = cfg
93            .session
94            .as_ref()
95            .and_then(session_header_id)
96            .or_else(|| cfg.llm.session_id.clone())
97            .or_else(|| Some(uuid::Uuid::new_v4().to_string()));
98        let (steer_tx, steer_rx) = mpsc::unbounded_channel();
99        let (follow_tx, follow_rx) = mpsc::unbounded_channel();
100        // Seeded with the initial context so a read before the first turn sees
101        // it; `run_loop` republishes at each boundary. `send_replace` is
102        // infallible even with no receivers, so only the sender is held.
103        let (ctx_snapshot, _ctx_rx) = watch::channel(cfg.context.clone());
104        // An empty working_dir means "process cwd" (the historical default).
105        let working_dir = if cfg.working_dir.as_os_str().is_empty() {
106            default_working_dir()
107        } else {
108            cfg.working_dir
109        };
110        // The title mirrors the session's persisted `Title` entry, seeded here
111        // because `cfg.session` is MOVED into the literal below — the same
112        // before-the-move shape as the routing id above.
113        let title = cfg.session.as_ref().and_then(|s| s.title());
114        Agent {
115            system: cfg.system.clone(),
116            base_system: cfg.system,
117            tools: cfg.tools,
118            llm: cfg.llm,
119            stream_fn: cfg.stream_fn,
120            hooks: cfg.hooks,
121            session: cfg.session,
122            working_dir,
123            max_turns: cfg.max_turns,
124            parallel_tools: cfg.parallel_tools,
125            compaction: cfg.compaction,
126            plan_mode: cfg.plan_mode,
127            title_after_turns: cfg.title_after_turns,
128            title,
129            ctx: cfg.context,
130            steer_tx,
131            steer_rx: Some(steer_rx),
132            follow_tx,
133            follow_rx: Some(follow_rx),
134            cancel: CancellationToken::new(),
135            ctx_snapshot,
136        }
137    }
138
139    /// Queued for injection at the next turn start (this run or a later one).
140    pub fn steer(&self, m: AgentMessage) {
141        let _ = self.steer_tx.send(m);
142    }
143
144    /// Toggle plan mode: flips the shared handle and recomposes `system` from
145    /// `base_system` — appending [`PLAN_SECTION`] when on, restoring the base
146    /// prompt exactly when off. Takes effect on the NEXT run (the in-flight one
147    /// cloned `system` into its `LoopConfig` at start). Not persisted.
148    pub fn set_plan_mode(&mut self, on: bool) {
149        self.plan_mode.set(on);
150        self.system = if on {
151            format!("{}\n\n{}", self.base_system, PLAN_SECTION)
152        } else {
153            self.base_system.clone()
154        };
155    }
156    /// Swaps the model mid-conversation (takes effect on the next run) and
157    /// logs a `ModelChange` entry when a session is open. The new id's
158    /// `[models.<id>]` profile re-points `endpoint`/`base_url`/`api_key` by
159    /// settling on the launch provider first, so an unmapped id — and a switch
160    /// back to a previous model — falls back to it (the switch is reversible).
161    pub fn set_model(&mut self, model: String) -> std::io::Result<()> {
162        // Log first: a failed append must not leave the agent and the session
163        // disagreeing about which model runs next.
164        if let Some(session) = &mut self.session {
165            session.append(SessionEntry::ModelChange {
166                id: uuid::Uuid::new_v4().to_string(),
167                model: model.clone(),
168            })?;
169        }
170        self.llm.model = model;
171        // Settle the new id's provider: the launch provider, then this model's
172        // `[models.<id>]` overlay (a no-op when no profiles are configured).
173        self.llm.settle_provider();
174        Ok(())
175    }
176
177    /// Current reasoning effort (None = send nothing).
178    pub fn effort(&self) -> Option<&str> {
179        self.llm.effort.as_deref()
180    }
181
182    /// The shared plan-mode switch — the read side of [`Agent::set_plan_mode`].
183    /// The actor clones it before `agent.run` borrows the agent (as it does
184    /// `hooks`), so a mid-run `Request::Settings` can report plan mode without
185    /// the `&mut`.
186    ///
187    /// Distinct from [`crate::agent::AgentConfig::plan_mode`], which is the
188    /// STARTUP flag (`[plan] on` in config); this is the live handle that flag
189    /// seeds. Two names, two facts — kept apart rather than aliased, so a reader
190    /// cannot mistake the config's intent for the session's current state.
191    pub(crate) fn plan_mode(&self) -> PlanModeHandle {
192        self.plan_mode.clone()
193    }
194
195    /// Assistant turns before the auto-titler fires (`0` = off) — the resolved
196    /// `[session] title_after_turns` this agent was built with (W031).
197    pub fn title_after_turns(&self) -> usize {
198        self.title_after_turns
199    }
200
201    /// Swaps the reasoning effort mid-conversation (next run) and logs an
202    /// `EffortChange` entry when a session is open. None clears it.
203    pub fn set_effort(&mut self, effort: Option<String>) -> std::io::Result<()> {
204        if let Some(session) = &mut self.session {
205            session.append(SessionEntry::EffortChange {
206                id: uuid::Uuid::new_v4().to_string(),
207                effort: effort.clone(),
208            })?;
209        }
210        self.llm.effort = effort;
211        Ok(())
212    }
213
214    /// Give this session a name: append a `Title` entry (last write wins) and
215    /// mirror it in memory. The EXPLICIT rename path — `/rename` on both human
216    /// surfaces, `--title`, and `crate::protocol::Request::SetTitle` — never
217    /// calls a model.
218    ///
219    /// Appends BEFORE the in-memory write, deliberately (the [`Agent::set_model`]
220    /// rationale, "a failed append must not leave the agent and the session
221    /// disagreeing"): if the append fails the caller gets the `io::Error` and
222    /// [`Agent::title`] is unchanged, so a UI never claims a name the file does
223    /// not have.
224    ///
225    /// # Errors
226    /// The session append's `io::Error`, unchanged. The in-memory mirror is NOT
227    /// updated on failure.
228    ///
229    /// # Does not
230    /// Sanitize (`/rename` takes the user's text verbatim; the AUTO path
231    /// sanitizes, in [`crate::title::sanitize`]), call a model, or refuse an
232    /// empty string — `set_title("")` stores an empty title and [`Agent::title`]
233    /// then returns `Some("")`.
234    pub fn set_title(&mut self, title: &str) -> std::io::Result<()> {
235        if let Some(session) = &mut self.session {
236            session.append(SessionEntry::Title {
237                id: uuid::Uuid::new_v4().to_string(),
238                title: title.to_string(),
239            })?;
240        }
241        self.title = Some(title.to_string());
242        Ok(())
243    }
244
245    /// The session's current title (`None` = never titled).
246    ///
247    /// Reads the SESSION first, the in-memory mirror second — and that order is
248    /// the whole contract. The auto-titler runs inside `run_loop`, which holds
249    /// `&mut Session` and no `&mut Agent`, so a title it appends lands in the
250    /// session and NOT in an `Agent` field; a mirror-only getter would report
251    /// `None` right after a successful auto-title, and a UI asking the actor
252    /// would disagree with the file. Session-first makes the persisted value
253    /// authoritative; the mirror covers `--no-session`, where there is no
254    /// session to ask.
255    pub fn title(&self) -> Option<String> {
256        self.session
257            .as_ref()
258            .and_then(|s| s.title())
259            .or_else(|| self.title.clone())
260    }
261
262    /// Sender clone for steering while `run` holds the `&mut` borrow (UI tasks).
263    ///
264    /// `run_loop` hands the lent channels back at the end of every run, so a
265    /// clone taken once stays valid across runs and a message sent while a run
266    /// is in flight — even one the loop never drained — reaches the next run.
267    /// (Only an *error* exit reboots the channels, invalidating old clones.)
268    pub fn steer_sender(&self) -> UnboundedSender<AgentMessage> {
269        self.steer_tx.clone()
270    }
271
272    /// Runs after the loop would stop.
273    pub fn follow_up(&self, m: AgentMessage) {
274        let _ = self.follow_tx.send(m);
275    }
276
277    /// Sender clone for injecting a follow-up while `run` holds the `&mut`
278    /// borrow (the mirror of [`Agent::steer_sender`], for a UI/actor task that
279    /// cannot reach `follow_up` directly).
280    pub fn follow_up_sender(&self) -> UnboundedSender<AgentMessage> {
281        self.follow_tx.clone()
282    }
283
284    pub fn cancel(&self) {
285        self.cancel.cancel();
286    }
287
288    /// Token for the run in flight or the next one; re-acquire after each
289    /// `run()` (a fresh token is minted at the end of every run).
290    pub fn cancel_token(&self) -> CancellationToken {
291        self.cancel.clone()
292    }
293
294    /// The hook set, exposed so the actor can run the inbound policy
295    /// ([`HooksSet::before_inbound`]) while `run` holds the `&mut` borrow.
296    pub fn hooks(&self) -> &HooksSet {
297        &self.hooks
298    }
299
300    pub async fn run(
301        &mut self,
302        user_text: &str,
303        sink: UnboundedSender<AgentEvent>,
304    ) -> Result<StopReason, LoopError> {
305        // The user message is persisted before the loop takes over; the loop
306        // records every subsequent message as it enters ctx, so a crash
307        // mid-run loses at most the in-flight message instead of the whole
308        // turn (see `record_session` in loop_).
309        let user = AgentMessage::user_text(user_text);
310        if let Some(session) = &mut self.session {
311            let appended = session
312                .append(SessionEntry::Message {
313                    id: uuid::Uuid::new_v4().to_string(),
314                    parent_id: None,
315                    message: user.clone(),
316                })
317                .map_err(LoopError::Session);
318            if let Err(e) = appended {
319                // A session-append I/O failure on this very first write exits the
320                // run *before* the loop, on a path the `Ok` arm far below never
321                // sees. Wake the owner here too, so a worker's report can never
322                // silently vanish; `StopReason::Error` makes `ReportBack`
323                // synthesize a status line rather than forward a stale prior reply.
324                self.cancel = CancellationToken::new();
325
326                self.hooks.after_run(&self.ctx, StopReason::Error).await;
327                return Err(e);
328            }
329        }
330        self.ctx.push(user);
331
332        // Cover the window between the submit and the first turn boundary: a
333        // read arriving before the loop's first publish still sees the question.
334        let _ = self.ctx_snapshot.send_replace(self.ctx.clone());
335
336        let steering = self.steer_rx.take().expect("steer_rx present between runs");
337        let follow_ups = self
338            .follow_rx
339            .take()
340            .expect("follow_rx present between runs");
341        // Read before the literal: the accessor takes `&self` (the WHOLE struct),
342        // and `session: self.session.as_mut()` inside the literal holds a `&mut`
343        // borrow of it. A disjoint field READ (`self.title_after_turns`) would be
344        // fine there; the method call is not.
345        let title_after_turns = self.title_after_turns();
346        let cfg = LoopConfig {
347            system: self.system.clone(),
348            tools: self.tools.clone(),
349            llm: self.llm.clone(),
350            stream_fn: Arc::clone(&self.stream_fn),
351            hooks: self.hooks.clone(),
352            steering,
353            follow_ups,
354            cancel: self.cancel.clone(),
355            working_dir: self.working_dir.clone(),
356            max_turns: self.max_turns,
357            parallel: self.parallel_tools,
358            compaction: self.compaction,
359            ctx_snapshot: self.ctx_snapshot.clone(),
360            session: self.session.as_mut(),
361            title_after_turns,
362        };
363        let res = run_loop(&mut self.ctx, cfg, sink).await;
364
365        // Fresh token for the next run: CancellationToken has no reset, so a
366        // cancel from this run must not poison the next one.
367        self.cancel = CancellationToken::new();
368
369        match res {
370            Ok(result) => {
371                // run_loop hands the lent channels back, so steer()/follow_up()
372                // and any sender clone taken before the run keep working, and a
373                // message sent while the run was in flight — even one the loop
374                // never drained — reaches the next run.
375                self.steer_rx = Some(result.steering);
376                self.follow_rx = Some(result.follow_ups);
377                // An `after_run` hook may act on the finished run (the A2A
378                // auto-report forwards a worker's result to its orchestrator).
379                self.hooks.after_run(&self.ctx, result.stop_reason).await;
380                Ok(result.stop_reason)
381            }
382            Err(e) => {
383                self.reset_channels();
384                // A `LoopError` never reaches the `Ok` arm above, so the owner of a
385                // worker would block forever on a report that never fires. Wake it
386                // here too; `StopReason::Error` makes `ReportBack` synthesize a
387                // status line rather than forward a stale prior reply.
388                self.hooks.after_run(&self.ctx, StopReason::Error).await;
389                Err(e)
390            }
391        }
392    }
393
394    fn reset_channels(&mut self) {
395        // Error exit: the channels were dropped with the error. Reboot
396        // both sides so a later steer()/follow_up() still work
397        // (pre-run sender clones are stale after an error).
398        let (steer_tx, steer_rx) = mpsc::unbounded_channel();
399        self.steer_tx = steer_tx;
400        self.steer_rx = Some(steer_rx);
401        let (follow_tx, follow_rx) = mpsc::unbounded_channel();
402        self.follow_tx = follow_tx;
403        self.follow_rx = Some(follow_rx);
404    }
405    
406    pub fn messages(&self) -> &[AgentMessage] {
407        &self.ctx
408    }
409
410    /// Record an inbound peer message into the conversation *without* starting a
411    /// turn (the `Request::Notify` semantic): persist it as a user message and
412    /// push it onto the context, so the next run sees it. Returns the recorded
413    /// message so the actor can surface it. Idle-only — the actor routes a
414    /// `Notify` that arrives mid-run through steering instead.
415    pub fn notify(&mut self, content: String) -> std::io::Result<AgentMessage> {
416        let message = AgentMessage::user_text(content);
417        if let Some(session) = &mut self.session {
418            session.append(SessionEntry::Message {
419                id: uuid::Uuid::new_v4().to_string(),
420                parent_id: None,
421                message: message.clone(),
422            })?;
423        }
424        self.ctx.push(message.clone());
425        Ok(message)
426    }
427
428    /// Answer `text` tool-free, from the current context, as a one-off side call.
429    ///
430    /// CLONES `ctx`, appends the (framed) question, runs the raw `stream_fn` once
431    /// with an EMPTY tool list, and folds `TextDelta` → text until `Done`
432    /// (capturing `usage`). `self.ctx` and `self.session` are never mutated or
433    /// appended — hence `&self`, so it stays callable while the actor holds
434    /// `&mut Agent` for a run.
435    ///
436    /// Errors become [`LoopError::Stream`]: a stream `Error`, or a call that
437    /// ended with no text. No retry loop of its own — it inherits whatever
438    /// retry/idle-timeout the configured `stream_fn` adapter applies internally,
439    /// exactly as [`crate::compaction::summarize`] does. No cancel path in v1.
440    pub async fn side_ask(&self, text: &str) -> Result<SideAnswer, LoopError> {
441        side_ask_with(&self.stream_fn, &self.ctx, &self.system, &self.llm, text).await
442    }
443
444    /// A read-only handle the actor takes *before* a run borrows the agent, so a
445    /// mid-run `GetHistory`/`SideAsk` is answered from the context snapshot
446    /// `run_loop` republishes each turn, instead of waiting for the whole run.
447    /// See [`SideHandle`].
448    pub fn side_handle(&self) -> SideHandle {
449        SideHandle {
450            stream_fn: Arc::clone(&self.stream_fn),
451            system: self.system.clone(),
452            llm: self.llm.clone(),
453            ctx: self.ctx_snapshot.clone(),
454        }
455    }
456
457    /// Summarize the older part of the conversation in place: keep the newest
458    /// messages per [`CompactionPolicy`] and replace the rest with a single
459    /// summary message. `instructions` focuses the summary (the
460    /// `/compact <prompt>` argument).
461    ///
462    /// When a session is open, the boundary is persisted
463    /// ([`crate::session::SessionEntry::Compaction`]), so a resumed session
464    /// rebuilds to the same `summary + kept` view instead of re-expanding.
465    pub async fn compact(&mut self, instructions: Option<&str>) -> Result<CompactOutcome, String> {
466        compaction::compact_ctx(
467            &mut self.ctx,
468            &self.compaction,
469            &self.stream_fn,
470            &self.llm,
471            instructions,
472            self.session.as_mut(),
473        )
474        .await
475    }
476
477    pub fn session_path(&self) -> Option<&Path> {
478        self.session.as_ref().and_then(|s| s.path())
479    }
480
481    /// The session's stable id (from its `Header`), if a session is open. The
482    /// address a [`SessionHandle`](crate::actor::SessionHandle)'s frames carry.
483    pub fn session_id(&self) -> Option<String> {
484        self.session.as_ref().and_then(session_header_id)
485    }
486
487    /// The provider options this agent bills against — the `base_url`/`model`
488    /// pair `pricing::model_price` keys on. Read-only, so the actor can price a
489    /// usage report without the `&mut` a run holds.
490    pub(crate) fn llm(&self) -> &LlmOpts {
491        &self.llm
492    }
493}
494
495/// The reply to a `/btw` side question: the answer text and the turn's token
496/// usage (`None` when the provider reported none). Harness data — no styling.
497#[derive(Clone, Debug, Default, PartialEq)]
498pub struct SideAnswer {
499    pub text: String,
500    pub usage: Option<Usage>,
501}
502
503/// A read-only view of an [`Agent`] the actor can mine *while a run holds the
504/// agent's `&mut`. It carries the same clones the run uses (`stream_fn`/
505/// `system`/`llm`) plus the context snapshot `run_loop` republishes each turn,
506/// so a mid-run `GetHistory`/`SideAsk` is answered from the latest completed
507/// turns instead of waiting for the whole run to finish.
508#[derive(Clone)]
509pub struct SideHandle {
510    stream_fn: StreamFn,
511    system: String,
512    llm: LlmOpts,
513    ctx: watch::Sender<Vec<AgentMessage>>,
514}
515
516impl SideHandle {
517    /// The latest turn-boundary context snapshot, owned (`send_replace` keeps
518    /// the value even with no receivers, so a read after the run still sees it).
519    pub fn history(&self) -> Vec<AgentMessage> {
520        self.ctx.subscribe().borrow().to_vec()
521    }
522
523    /// The latest snapshot's last-assistant text — a compact status read
524    /// (`crate::protocol::Request::Status`) that borrows the watch value instead
525    /// of cloning the whole transcript.
526    pub fn last_assistant_text(&self) -> Option<String> {
527        let rx = self.ctx.subscribe();
528        let messages = rx.borrow();
529        crate::message::last_assistant_text(&messages[..])
530    }
531
532    /// The provider options captured when the handle was taken — what a
533    /// MID-RUN usage report is priced with (`Request::Usage`).
534    pub(crate) fn llm(&self) -> &LlmOpts {
535        &self.llm
536    }
537
538    /// Answer `text` tool-free from the latest snapshot; semantics match
539    /// [`Agent::side_ask`].
540    pub async fn side_ask(&self, text: &str) -> Result<SideAnswer, LoopError> {
541        side_ask_with(&self.stream_fn, &self.history(), &self.system, &self.llm, text).await
542    }
543}
544
545/// The shared body of a side ask: frame `text` as a tool-free aside, run the raw
546/// `stream_fn` once with an EMPTY tool list, and fold `TextDelta` → text until
547/// `Done` (capturing `usage`). [`Agent::side_ask`] passes `&self.ctx`;
548/// [`SideHandle::side_ask`] passes the latest mid-run snapshot.
549async fn side_ask_with(
550    stream_fn: &StreamFn,
551    ctx: &[AgentMessage],
552    system: &str,
553    llm: &LlmOpts,
554    text: &str,
555) -> Result<SideAnswer, LoopError> {
556    let mut msgs = ctx.to_vec();
557    msgs.push(AgentMessage::user_text(frame_btw(text)));
558    let mut stream = (stream_fn)(&msgs, system, &[], llm);
559    let mut out = String::new();
560    let mut usage = None;
561    while let Some(ev) = stream.next().await {
562        match ev {
563            LlmStreamEvent::TextDelta(delta) => out.push_str(&delta),
564            LlmStreamEvent::Done { usage: u, .. } => {
565                usage = u;
566                break;
567            }
568            LlmStreamEvent::Error { message, .. } => return Err(LoopError::Stream(message)),
569            _ => {}
570        }
571    }
572    let text = out.trim().to_string();
573    if text.is_empty() {
574        return Err(LoopError::Stream("side answer produced no text".to_string()));
575    }
576    Ok(SideAnswer { text, usage })
577}
578
579/// Frame a raw `/btw` question so the model knows it is a tool-free aside: the
580/// tools are simply absent, so without a hint it may try to call one and fail.
581fn frame_btw(q: &str) -> String {
582    format!("[side question — no tools available; answer briefly and directly from the conversation above]\n{q}")
583}
584
585/// Process cwd, used only when `AgentConfig.working_dir` is left empty.
586fn default_working_dir() -> PathBuf {
587    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
588}
589
590/// Id from the session's `Header` entry, if present (old files may lack one).
591fn session_header_id(session: &Session) -> Option<String> {
592    session.entries().iter().find_map(|e| match e {
593        SessionEntry::Header { id, .. } => Some(id.clone()),
594        _ => None,
595    })
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use crate::compaction::CompactionPolicy;
602    use crate::hooks::HooksSet;
603    use crate::streamfn::{LlmStream, StreamFn};
604
605    /// A `StreamFn` that replays `script` on each call.
606    fn scripted(script: Vec<LlmStreamEvent>) -> StreamFn {
607        Arc::new(move |_ctx: &[AgentMessage], _system, _tools, _opts: &LlmOpts| {
608            Box::pin(futures::stream::iter(script.clone())) as LlmStream
609        })
610    }
611
612    fn make_agent(session: Option<Session>, stream_fn: StreamFn, context: Vec<AgentMessage>) -> Agent {
613        Agent::new(AgentConfig {
614            system: "sys".into(),
615            tools: vec![],
616            llm: LlmOpts {
617                model: "m1".into(),
618                ..LlmOpts::default()
619            },
620            stream_fn,
621            hooks: HooksSet::default(),
622            session,
623            context,
624            working_dir: PathBuf::new(),
625            max_turns: crate::loop_::DEFAULT_MAX_TURNS,
626            parallel_tools: true,
627            compaction: CompactionPolicy::default(),
628            plan_mode: PlanModeHandle::new(),
629            // W031 test double: the kernel default, like every non-CLI site.
630            title_after_turns: crate::title::DEFAULT_TITLE_AFTER_TURNS,
631        })
632    }
633
634    #[tokio::test]
635    async fn side_ask_leaves_ctx_and_session_untouched() {
636        let dir = tempfile::tempdir().unwrap();
637        let session = Session::create(dir.path()).unwrap();
638        let stream = scripted(vec![
639            LlmStreamEvent::TextDelta("an".into()),
640            LlmStreamEvent::TextDelta("swer".into()),
641            LlmStreamEvent::Done {
642                stop_reason: StopReason::Stop,
643                usage: None,
644            },
645        ]);
646        let agent = make_agent(Some(session), stream, vec![AgentMessage::user_text("earlier")]);
647
648        let ctx_before = agent.ctx.clone();
649        let path = agent.session.as_ref().unwrap().path().unwrap().to_path_buf();
650        let entries_before = agent.session.as_ref().unwrap().entries().to_vec();
651        let bytes_before = std::fs::read(&path).unwrap();
652
653        let answer = agent.side_ask("why?").await.unwrap();
654        assert_eq!(answer.text, "answer");
655
656        assert_eq!(agent.ctx, ctx_before, "ctx is unchanged");
657        assert_eq!(agent.messages(), &ctx_before[..]);
658        assert_eq!(
659            agent.session.as_ref().unwrap().entries(),
660            &entries_before[..],
661            "session entries unchanged"
662        );
663        assert_eq!(
664            std::fs::read(&path).unwrap(),
665            bytes_before,
666            "session file bytes unchanged"
667        );
668    }
669
670    #[tokio::test]
671    async fn side_ask_folds_deltas_and_captures_usage() {
672        let usage = Usage {
673            input_tokens: 7,
674            output_tokens: 3,
675            cache_read_tokens: Some(2),
676            cache_write_tokens: None,
677        };
678        let stream = scripted(vec![
679            LlmStreamEvent::TextDelta("he".into()),
680            LlmStreamEvent::TextDelta("llo".into()),
681            LlmStreamEvent::Done {
682                stop_reason: StopReason::Stop,
683                usage: Some(usage),
684            },
685        ]);
686        let agent = make_agent(None, stream, vec![]);
687        assert_eq!(
688            agent.side_ask("hi").await.unwrap(),
689            SideAnswer {
690                text: "hello".into(),
691                usage: Some(usage),
692            }
693        );
694    }
695
696    #[tokio::test]
697    async fn side_ask_surfaces_a_stream_error() {
698        let stream = scripted(vec![LlmStreamEvent::Error {
699            message: "boom".into(),
700            fatal: true,
701        }]);
702        let agent = make_agent(None, stream, vec![]);
703        let err = agent.side_ask("hi").await.unwrap_err();
704        assert!(err.to_string().contains("boom"), "{err}");
705    }
706
707    #[tokio::test]
708    async fn side_ask_errors_on_empty_output() {
709        let stream = scripted(vec![LlmStreamEvent::Done {
710            stop_reason: StopReason::Stop,
711            usage: None,
712        }]);
713        let agent = make_agent(None, stream, vec![]);
714        let err = agent.side_ask("hi").await.unwrap_err();
715        assert!(err.to_string().contains("no text"), "{err}");
716    }
717
718    #[test]
719    fn frame_btw_wraps_the_question() {
720        let framed = frame_btw("why?");
721        assert!(framed.starts_with("[side question"));
722        assert!(framed.ends_with("\nwhy?"));
723    }
724
725    #[test]
726    fn agent_config_title_after_turns_reaches_the_agent() {
727        // The `AgentConfig` → `Agent` half of the plumbing (the CLI half is pinned
728        // in `repl.rs`). A non-default value, `0`, and the default all survive, so
729        // a "hardcoded default" or an "off is silently ignored" regression fails.
730        fn build(title_after_turns: usize, session: Option<Session>) -> Agent {
731            Agent::new(AgentConfig {
732                system: "sys".into(),
733                tools: vec![],
734                llm: LlmOpts::default(),
735                stream_fn: scripted(vec![]),
736                hooks: HooksSet::default(),
737                session,
738                context: vec![],
739                working_dir: PathBuf::new(),
740                max_turns: crate::loop_::DEFAULT_MAX_TURNS,
741                parallel_tools: true,
742                compaction: CompactionPolicy::default(),
743                plan_mode: PlanModeHandle::new(),
744                title_after_turns,
745            })
746        }
747
748        let dir = tempfile::tempdir().unwrap();
749        let session = Session::create(dir.path()).unwrap();
750        assert_eq!(build(7, Some(session)).title_after_turns(), 7);
751        assert_eq!(
752            build(0, None).title_after_turns(),
753            0,
754            "0 = off is carried verbatim, not read as unset"
755        );
756        assert_eq!(
757            build(crate::title::DEFAULT_TITLE_AFTER_TURNS, None).title_after_turns(),
758            crate::title::DEFAULT_TITLE_AFTER_TURNS
759        );
760    }
761
762    #[test]
763    fn set_title_appends_a_title_entry() {
764        let dir = tempfile::tempdir().unwrap();
765        let session = Session::create(dir.path()).unwrap();
766        let path = session.path().unwrap().to_path_buf();
767        let mut agent = make_agent(Some(session), scripted(vec![]), vec![]);
768        assert_eq!(agent.title(), None, "a fresh session has no title");
769
770        agent.set_title("retry backoff fix").unwrap();
771
772        assert_eq!(agent.title().as_deref(), Some("retry backoff fix"));
773        // The SESSION, not just the mirror: an entry is in the file, exactly one,
774        // and the reader a `/resume` uses agrees.
775        let raw = std::fs::read_to_string(&path).unwrap();
776        assert!(raw.contains(r#""type":"title""#), "{raw}");
777        let reopened = Session::open(&path).unwrap();
778        assert_eq!(reopened.title().as_deref(), Some("retry backoff fix"));
779        assert_eq!(
780            reopened
781                .entries()
782                .iter()
783                .filter(|e| matches!(e, SessionEntry::Title { .. }))
784                .count(),
785            1,
786            "one appended entry, not a rewrite"
787        );
788
789        // A second rename appends again and last write wins.
790        agent.set_title("second name").unwrap();
791        assert_eq!(agent.title().as_deref(), Some("second name"));
792        assert_eq!(
793            Session::open(&path).unwrap().title().as_deref(),
794            Some("second name")
795        );
796    }
797
798    #[test]
799    fn set_title_on_a_failed_append_leaves_the_title_unchanged() {
800        let dir = tempfile::tempdir().unwrap();
801        let session = Session::create(dir.path()).unwrap();
802        let mut agent = make_agent(Some(session), scripted(vec![]), vec![]);
803        // The session's directory removed out from under the open handle, so the
804        // O_APPEND open fails.
805        std::fs::remove_dir_all(dir.path()).unwrap();
806
807        assert!(
808            agent.set_title("never persisted").is_err(),
809            "the append must fail"
810        );
811        assert_eq!(
812            agent.title(),
813            None,
814            "the append ran FIRST, so nothing was claimed"
815        );
816    }
817
818    #[test]
819    fn title_reads_the_session_first() {
820        // The auto-titler appends behind the agent's back — it holds `&mut
821        // Session` and no `&mut Agent` — so the mirror can go STALE while the
822        // session holds the truth. A mirror-first getter would return the older
823        // name here and a UI would disagree with the file.
824        let dir = tempfile::tempdir().unwrap();
825        let session = Session::create(dir.path()).unwrap();
826        let path = session.path().unwrap().to_path_buf();
827        let mut agent = make_agent(Some(session), scripted(vec![]), vec![]);
828
829        agent.set_title("from the agent").unwrap();
830        agent
831            .session
832            .as_mut()
833            .unwrap()
834            .append(SessionEntry::Title {
835                id: "t-loop".into(),
836                title: "titled by the loop".into(),
837            })
838            .unwrap();
839
840        assert_eq!(
841            agent.title().as_deref(),
842            Some("titled by the loop"),
843            "the persisted entry wins over the stale mirror"
844        );
845        assert_eq!(
846            Session::open(&path).unwrap().title().as_deref(),
847            Some("titled by the loop")
848        );
849    }
850
851    #[test]
852    fn title_without_a_session_uses_the_mirror() {
853        let mut agent = make_agent(None, scripted(vec![]), vec![]);
854        assert_eq!(agent.title(), None);
855        agent.set_title("no file to write").unwrap();
856        assert_eq!(agent.title().as_deref(), Some("no file to write"));
857    }
858
859    #[test]
860    fn set_plan_mode_appends_and_restores_the_section() {
861        let mut agent = make_agent(None, scripted(vec![]), vec![]);
862        let base = agent.system.clone();
863
864        agent.set_plan_mode(true);
865        assert!(agent.plan_mode.get());
866        assert_eq!(agent.system, format!("{base}\n\n{PLAN_SECTION}"));
867        assert!(agent.system.contains("# Plan mode"));
868        assert!(
869            agent.system.contains("record it as a\ntodo list (status pending)"),
870            "the P2 plan\u{2192}todo instruction is present"
871        );
872
873        agent.set_plan_mode(false);
874        assert!(!agent.plan_mode.get());
875        assert_eq!(agent.system, base, "restored exactly");
876    }
877}