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.hooks.after_run(&self.ctx, StopReason::Error).await;
325 return Err(e);
326 }
327 }
328 self.ctx.push(user);
329
330 // Cover the window between the submit and the first turn boundary: a
331 // read arriving before the loop's first publish still sees the question.
332 let _ = self.ctx_snapshot.send_replace(self.ctx.clone());
333
334 let steering = self.steer_rx.take().expect("steer_rx present between runs");
335 let follow_ups = self
336 .follow_rx
337 .take()
338 .expect("follow_rx present between runs");
339 // Read before the literal: the accessor takes `&self` (the WHOLE struct),
340 // and `session: self.session.as_mut()` inside the literal holds a `&mut`
341 // borrow of it. A disjoint field READ (`self.title_after_turns`) would be
342 // fine there; the method call is not.
343 let title_after_turns = self.title_after_turns();
344 let cfg = LoopConfig {
345 system: self.system.clone(),
346 tools: self.tools.clone(),
347 llm: self.llm.clone(),
348 stream_fn: Arc::clone(&self.stream_fn),
349 hooks: self.hooks.clone(),
350 steering,
351 follow_ups,
352 cancel: self.cancel.clone(),
353 working_dir: self.working_dir.clone(),
354 max_turns: self.max_turns,
355 parallel: self.parallel_tools,
356 compaction: self.compaction,
357 ctx_snapshot: self.ctx_snapshot.clone(),
358 session: self.session.as_mut(),
359 title_after_turns,
360 };
361 let res = run_loop(&mut self.ctx, cfg, sink).await;
362
363 // Fresh token for the next run: CancellationToken has no reset, so a
364 // cancel from this run must not poison the next one.
365 self.cancel = CancellationToken::new();
366
367 match res {
368 Ok(result) => {
369 // run_loop hands the lent channels back, so steer()/follow_up()
370 // and any sender clone taken before the run keep working, and a
371 // message sent while the run was in flight — even one the loop
372 // never drained — reaches the next run.
373 self.steer_rx = Some(result.steering);
374 self.follow_rx = Some(result.follow_ups);
375 // An `after_run` hook may act on the finished run (the A2A
376 // auto-report forwards a worker's result to its orchestrator).
377 self.hooks.after_run(&self.ctx, result.stop_reason).await;
378 Ok(result.stop_reason)
379 }
380 Err(e) => {
381 // Error exit: the channels were dropped with the error. Reboot
382 // both sides so a later steer()/follow_up() still work
383 // (pre-run sender clones are stale after an error).
384 let (steer_tx, steer_rx) = mpsc::unbounded_channel();
385 self.steer_tx = steer_tx;
386 self.steer_rx = Some(steer_rx);
387 let (follow_tx, follow_rx) = mpsc::unbounded_channel();
388 self.follow_tx = follow_tx;
389 self.follow_rx = Some(follow_rx);
390 // A `LoopError` never reaches the `Ok` arm above, so the owner of a
391 // worker would block forever on a report that never fires. Wake it
392 // here too; `StopReason::Error` makes `ReportBack` synthesize a
393 // status line rather than forward a stale prior reply.
394 self.hooks.after_run(&self.ctx, StopReason::Error).await;
395 Err(e)
396 }
397 }
398 }
399
400 pub fn messages(&self) -> &[AgentMessage] {
401 &self.ctx
402 }
403
404 /// Record an inbound peer message into the conversation *without* starting a
405 /// turn (the `Request::Notify` semantic): persist it as a user message and
406 /// push it onto the context, so the next run sees it. Returns the recorded
407 /// message so the actor can surface it. Idle-only — the actor routes a
408 /// `Notify` that arrives mid-run through steering instead.
409 pub fn notify(&mut self, content: String) -> std::io::Result<AgentMessage> {
410 let message = AgentMessage::user_text(content);
411 if let Some(session) = &mut self.session {
412 session.append(SessionEntry::Message {
413 id: uuid::Uuid::new_v4().to_string(),
414 parent_id: None,
415 message: message.clone(),
416 })?;
417 }
418 self.ctx.push(message.clone());
419 Ok(message)
420 }
421
422 /// Answer `text` tool-free, from the current context, as a one-off side call.
423 ///
424 /// CLONES `ctx`, appends the (framed) question, runs the raw `stream_fn` once
425 /// with an EMPTY tool list, and folds `TextDelta` → text until `Done`
426 /// (capturing `usage`). `self.ctx` and `self.session` are never mutated or
427 /// appended — hence `&self`, so it stays callable while the actor holds
428 /// `&mut Agent` for a run.
429 ///
430 /// Errors become [`LoopError::Stream`]: a stream `Error`, or a call that
431 /// ended with no text. No retry loop of its own — it inherits whatever
432 /// retry/idle-timeout the configured `stream_fn` adapter applies internally,
433 /// exactly as [`crate::compaction::summarize`] does. No cancel path in v1.
434 pub async fn side_ask(&self, text: &str) -> Result<SideAnswer, LoopError> {
435 side_ask_with(&self.stream_fn, &self.ctx, &self.system, &self.llm, text).await
436 }
437
438 /// A read-only handle the actor takes *before* a run borrows the agent, so a
439 /// mid-run `GetHistory`/`SideAsk` is answered from the context snapshot
440 /// `run_loop` republishes each turn, instead of waiting for the whole run.
441 /// See [`SideHandle`].
442 pub fn side_handle(&self) -> SideHandle {
443 SideHandle {
444 stream_fn: Arc::clone(&self.stream_fn),
445 system: self.system.clone(),
446 llm: self.llm.clone(),
447 ctx: self.ctx_snapshot.clone(),
448 }
449 }
450
451 /// Summarize the older part of the conversation in place: keep the newest
452 /// messages per [`CompactionPolicy`] and replace the rest with a single
453 /// summary message. `instructions` focuses the summary (the
454 /// `/compact <prompt>` argument).
455 ///
456 /// When a session is open, the boundary is persisted
457 /// ([`crate::session::SessionEntry::Compaction`]), so a resumed session
458 /// rebuilds to the same `summary + kept` view instead of re-expanding.
459 pub async fn compact(&mut self, instructions: Option<&str>) -> Result<CompactOutcome, String> {
460 compaction::compact_ctx(
461 &mut self.ctx,
462 &self.compaction,
463 &self.stream_fn,
464 &self.llm,
465 instructions,
466 self.session.as_mut(),
467 )
468 .await
469 }
470
471 pub fn session_path(&self) -> Option<&Path> {
472 self.session.as_ref().and_then(|s| s.path())
473 }
474
475 /// The session's stable id (from its `Header`), if a session is open. The
476 /// address a [`SessionHandle`](crate::actor::SessionHandle)'s frames carry.
477 pub fn session_id(&self) -> Option<String> {
478 self.session.as_ref().and_then(session_header_id)
479 }
480
481 /// The provider options this agent bills against — the `base_url`/`model`
482 /// pair `pricing::model_price` keys on. Read-only, so the actor can price a
483 /// usage report without the `&mut` a run holds.
484 pub(crate) fn llm(&self) -> &LlmOpts {
485 &self.llm
486 }
487}
488
489/// The reply to a `/btw` side question: the answer text and the turn's token
490/// usage (`None` when the provider reported none). Harness data — no styling.
491#[derive(Clone, Debug, Default, PartialEq)]
492pub struct SideAnswer {
493 pub text: String,
494 pub usage: Option<Usage>,
495}
496
497/// A read-only view of an [`Agent`] the actor can mine *while a run holds the
498/// agent's `&mut`. It carries the same clones the run uses (`stream_fn`/
499/// `system`/`llm`) plus the context snapshot `run_loop` republishes each turn,
500/// so a mid-run `GetHistory`/`SideAsk` is answered from the latest completed
501/// turns instead of waiting for the whole run to finish.
502#[derive(Clone)]
503pub struct SideHandle {
504 stream_fn: StreamFn,
505 system: String,
506 llm: LlmOpts,
507 ctx: watch::Sender<Vec<AgentMessage>>,
508}
509
510impl SideHandle {
511 /// The latest turn-boundary context snapshot, owned (`send_replace` keeps
512 /// the value even with no receivers, so a read after the run still sees it).
513 pub fn history(&self) -> Vec<AgentMessage> {
514 self.ctx.subscribe().borrow().to_vec()
515 }
516
517 /// The latest snapshot's last-assistant text — a compact status read
518 /// (`crate::protocol::Request::Status`) that borrows the watch value instead
519 /// of cloning the whole transcript.
520 pub fn last_assistant_text(&self) -> Option<String> {
521 let rx = self.ctx.subscribe();
522 let messages = rx.borrow();
523 crate::message::last_assistant_text(&messages[..])
524 }
525
526 /// The provider options captured when the handle was taken — what a
527 /// MID-RUN usage report is priced with (`Request::Usage`).
528 pub(crate) fn llm(&self) -> &LlmOpts {
529 &self.llm
530 }
531
532 /// Answer `text` tool-free from the latest snapshot; semantics match
533 /// [`Agent::side_ask`].
534 pub async fn side_ask(&self, text: &str) -> Result<SideAnswer, LoopError> {
535 side_ask_with(&self.stream_fn, &self.history(), &self.system, &self.llm, text).await
536 }
537}
538
539/// The shared body of a side ask: frame `text` as a tool-free aside, run the raw
540/// `stream_fn` once with an EMPTY tool list, and fold `TextDelta` → text until
541/// `Done` (capturing `usage`). [`Agent::side_ask`] passes `&self.ctx`;
542/// [`SideHandle::side_ask`] passes the latest mid-run snapshot.
543async fn side_ask_with(
544 stream_fn: &StreamFn,
545 ctx: &[AgentMessage],
546 system: &str,
547 llm: &LlmOpts,
548 text: &str,
549) -> Result<SideAnswer, LoopError> {
550 let mut msgs = ctx.to_vec();
551 msgs.push(AgentMessage::user_text(frame_btw(text)));
552 let mut stream = (stream_fn)(&msgs, system, &[], llm);
553 let mut out = String::new();
554 let mut usage = None;
555 while let Some(ev) = stream.next().await {
556 match ev {
557 LlmStreamEvent::TextDelta(delta) => out.push_str(&delta),
558 LlmStreamEvent::Done { usage: u, .. } => {
559 usage = u;
560 break;
561 }
562 LlmStreamEvent::Error { message, .. } => return Err(LoopError::Stream(message)),
563 _ => {}
564 }
565 }
566 let text = out.trim().to_string();
567 if text.is_empty() {
568 return Err(LoopError::Stream("side answer produced no text".to_string()));
569 }
570 Ok(SideAnswer { text, usage })
571}
572
573/// Frame a raw `/btw` question so the model knows it is a tool-free aside: the
574/// tools are simply absent, so without a hint it may try to call one and fail.
575fn frame_btw(q: &str) -> String {
576 format!("[side question — no tools available; answer briefly and directly from the conversation above]\n{q}")
577}
578
579/// Process cwd, used only when `AgentConfig.working_dir` is left empty.
580fn default_working_dir() -> PathBuf {
581 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
582}
583
584/// Id from the session's `Header` entry, if present (old files may lack one).
585fn session_header_id(session: &Session) -> Option<String> {
586 session.entries().iter().find_map(|e| match e {
587 SessionEntry::Header { id, .. } => Some(id.clone()),
588 _ => None,
589 })
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595 use crate::compaction::CompactionPolicy;
596 use crate::hooks::HooksSet;
597 use crate::streamfn::{LlmStream, StreamFn};
598
599 /// A `StreamFn` that replays `script` on each call.
600 fn scripted(script: Vec<LlmStreamEvent>) -> StreamFn {
601 Arc::new(move |_ctx: &[AgentMessage], _system, _tools, _opts: &LlmOpts| {
602 Box::pin(futures::stream::iter(script.clone())) as LlmStream
603 })
604 }
605
606 fn make_agent(session: Option<Session>, stream_fn: StreamFn, context: Vec<AgentMessage>) -> Agent {
607 Agent::new(AgentConfig {
608 system: "sys".into(),
609 tools: vec![],
610 llm: LlmOpts {
611 model: "m1".into(),
612 ..LlmOpts::default()
613 },
614 stream_fn,
615 hooks: HooksSet::default(),
616 session,
617 context,
618 working_dir: PathBuf::new(),
619 max_turns: crate::loop_::DEFAULT_MAX_TURNS,
620 parallel_tools: true,
621 compaction: CompactionPolicy::default(),
622 plan_mode: PlanModeHandle::new(),
623 // W031 test double: the kernel default, like every non-CLI site.
624 title_after_turns: crate::title::DEFAULT_TITLE_AFTER_TURNS,
625 })
626 }
627
628 #[tokio::test]
629 async fn side_ask_leaves_ctx_and_session_untouched() {
630 let dir = tempfile::tempdir().unwrap();
631 let session = Session::create(dir.path()).unwrap();
632 let stream = scripted(vec![
633 LlmStreamEvent::TextDelta("an".into()),
634 LlmStreamEvent::TextDelta("swer".into()),
635 LlmStreamEvent::Done {
636 stop_reason: StopReason::Stop,
637 usage: None,
638 },
639 ]);
640 let agent = make_agent(Some(session), stream, vec![AgentMessage::user_text("earlier")]);
641
642 let ctx_before = agent.ctx.clone();
643 let path = agent.session.as_ref().unwrap().path().unwrap().to_path_buf();
644 let entries_before = agent.session.as_ref().unwrap().entries().to_vec();
645 let bytes_before = std::fs::read(&path).unwrap();
646
647 let answer = agent.side_ask("why?").await.unwrap();
648 assert_eq!(answer.text, "answer");
649
650 assert_eq!(agent.ctx, ctx_before, "ctx is unchanged");
651 assert_eq!(agent.messages(), &ctx_before[..]);
652 assert_eq!(
653 agent.session.as_ref().unwrap().entries(),
654 &entries_before[..],
655 "session entries unchanged"
656 );
657 assert_eq!(
658 std::fs::read(&path).unwrap(),
659 bytes_before,
660 "session file bytes unchanged"
661 );
662 }
663
664 #[tokio::test]
665 async fn side_ask_folds_deltas_and_captures_usage() {
666 let usage = Usage {
667 input_tokens: 7,
668 output_tokens: 3,
669 cache_read_tokens: Some(2),
670 cache_write_tokens: None,
671 };
672 let stream = scripted(vec![
673 LlmStreamEvent::TextDelta("he".into()),
674 LlmStreamEvent::TextDelta("llo".into()),
675 LlmStreamEvent::Done {
676 stop_reason: StopReason::Stop,
677 usage: Some(usage),
678 },
679 ]);
680 let agent = make_agent(None, stream, vec![]);
681 assert_eq!(
682 agent.side_ask("hi").await.unwrap(),
683 SideAnswer {
684 text: "hello".into(),
685 usage: Some(usage),
686 }
687 );
688 }
689
690 #[tokio::test]
691 async fn side_ask_surfaces_a_stream_error() {
692 let stream = scripted(vec![LlmStreamEvent::Error {
693 message: "boom".into(),
694 fatal: true,
695 }]);
696 let agent = make_agent(None, stream, vec![]);
697 let err = agent.side_ask("hi").await.unwrap_err();
698 assert!(err.to_string().contains("boom"), "{err}");
699 }
700
701 #[tokio::test]
702 async fn side_ask_errors_on_empty_output() {
703 let stream = scripted(vec![LlmStreamEvent::Done {
704 stop_reason: StopReason::Stop,
705 usage: None,
706 }]);
707 let agent = make_agent(None, stream, vec![]);
708 let err = agent.side_ask("hi").await.unwrap_err();
709 assert!(err.to_string().contains("no text"), "{err}");
710 }
711
712 #[test]
713 fn frame_btw_wraps_the_question() {
714 let framed = frame_btw("why?");
715 assert!(framed.starts_with("[side question"));
716 assert!(framed.ends_with("\nwhy?"));
717 }
718
719 #[test]
720 fn agent_config_title_after_turns_reaches_the_agent() {
721 // The `AgentConfig` → `Agent` half of the plumbing (the CLI half is pinned
722 // in `repl.rs`). A non-default value, `0`, and the default all survive, so
723 // a "hardcoded default" or an "off is silently ignored" regression fails.
724 fn build(title_after_turns: usize, session: Option<Session>) -> Agent {
725 Agent::new(AgentConfig {
726 system: "sys".into(),
727 tools: vec![],
728 llm: LlmOpts::default(),
729 stream_fn: scripted(vec![]),
730 hooks: HooksSet::default(),
731 session,
732 context: vec![],
733 working_dir: PathBuf::new(),
734 max_turns: crate::loop_::DEFAULT_MAX_TURNS,
735 parallel_tools: true,
736 compaction: CompactionPolicy::default(),
737 plan_mode: PlanModeHandle::new(),
738 title_after_turns,
739 })
740 }
741
742 let dir = tempfile::tempdir().unwrap();
743 let session = Session::create(dir.path()).unwrap();
744 assert_eq!(build(7, Some(session)).title_after_turns(), 7);
745 assert_eq!(
746 build(0, None).title_after_turns(),
747 0,
748 "0 = off is carried verbatim, not read as unset"
749 );
750 assert_eq!(
751 build(crate::title::DEFAULT_TITLE_AFTER_TURNS, None).title_after_turns(),
752 crate::title::DEFAULT_TITLE_AFTER_TURNS
753 );
754 }
755
756 #[test]
757 fn set_title_appends_a_title_entry() {
758 let dir = tempfile::tempdir().unwrap();
759 let session = Session::create(dir.path()).unwrap();
760 let path = session.path().unwrap().to_path_buf();
761 let mut agent = make_agent(Some(session), scripted(vec![]), vec![]);
762 assert_eq!(agent.title(), None, "a fresh session has no title");
763
764 agent.set_title("retry backoff fix").unwrap();
765
766 assert_eq!(agent.title().as_deref(), Some("retry backoff fix"));
767 // The SESSION, not just the mirror: an entry is in the file, exactly one,
768 // and the reader a `/resume` uses agrees.
769 let raw = std::fs::read_to_string(&path).unwrap();
770 assert!(raw.contains(r#""type":"title""#), "{raw}");
771 let reopened = Session::open(&path).unwrap();
772 assert_eq!(reopened.title().as_deref(), Some("retry backoff fix"));
773 assert_eq!(
774 reopened
775 .entries()
776 .iter()
777 .filter(|e| matches!(e, SessionEntry::Title { .. }))
778 .count(),
779 1,
780 "one appended entry, not a rewrite"
781 );
782
783 // A second rename appends again and last write wins.
784 agent.set_title("second name").unwrap();
785 assert_eq!(agent.title().as_deref(), Some("second name"));
786 assert_eq!(
787 Session::open(&path).unwrap().title().as_deref(),
788 Some("second name")
789 );
790 }
791
792 #[test]
793 fn set_title_on_a_failed_append_leaves_the_title_unchanged() {
794 let dir = tempfile::tempdir().unwrap();
795 let session = Session::create(dir.path()).unwrap();
796 let mut agent = make_agent(Some(session), scripted(vec![]), vec![]);
797 // The session's directory removed out from under the open handle, so the
798 // O_APPEND open fails.
799 std::fs::remove_dir_all(dir.path()).unwrap();
800
801 assert!(
802 agent.set_title("never persisted").is_err(),
803 "the append must fail"
804 );
805 assert_eq!(
806 agent.title(),
807 None,
808 "the append ran FIRST, so nothing was claimed"
809 );
810 }
811
812 #[test]
813 fn title_reads_the_session_first() {
814 // The auto-titler appends behind the agent's back — it holds `&mut
815 // Session` and no `&mut Agent` — so the mirror can go STALE while the
816 // session holds the truth. A mirror-first getter would return the older
817 // name here and a UI would disagree with the file.
818 let dir = tempfile::tempdir().unwrap();
819 let session = Session::create(dir.path()).unwrap();
820 let path = session.path().unwrap().to_path_buf();
821 let mut agent = make_agent(Some(session), scripted(vec![]), vec![]);
822
823 agent.set_title("from the agent").unwrap();
824 agent
825 .session
826 .as_mut()
827 .unwrap()
828 .append(SessionEntry::Title {
829 id: "t-loop".into(),
830 title: "titled by the loop".into(),
831 })
832 .unwrap();
833
834 assert_eq!(
835 agent.title().as_deref(),
836 Some("titled by the loop"),
837 "the persisted entry wins over the stale mirror"
838 );
839 assert_eq!(
840 Session::open(&path).unwrap().title().as_deref(),
841 Some("titled by the loop")
842 );
843 }
844
845 #[test]
846 fn title_without_a_session_uses_the_mirror() {
847 let mut agent = make_agent(None, scripted(vec![]), vec![]);
848 assert_eq!(agent.title(), None);
849 agent.set_title("no file to write").unwrap();
850 assert_eq!(agent.title().as_deref(), Some("no file to write"));
851 }
852
853 #[test]
854 fn set_plan_mode_appends_and_restores_the_section() {
855 let mut agent = make_agent(None, scripted(vec![]), vec![]);
856 let base = agent.system.clone();
857
858 agent.set_plan_mode(true);
859 assert!(agent.plan_mode.get());
860 assert_eq!(agent.system, format!("{base}\n\n{PLAN_SECTION}"));
861 assert!(agent.system.contains("# Plan mode"));
862 assert!(
863 agent.system.contains("record it as a\ntodo list (status pending)"),
864 "the P2 plan\u{2192}todo instruction is present"
865 );
866
867 agent.set_plan_mode(false);
868 assert!(!agent.plan_mode.get());
869 assert_eq!(agent.system, base, "restored exactly");
870 }
871}