Skip to main content

recursive/
runtime.rs

1//! High-level stateful agent runtime.
2//!
3//! Wraps the stateless [`AgentKernel`] and manages cross-turn state:
4//! transcript accumulation, usage tracking, and configuration that
5//! varies per turn (streaming, planning mode, permission hook, event sink).
6//!
7//! # Example
8//!
9//! ```ignore
10//! use recursive::{AgentRuntime, AgentRuntimeBuilder, NullSink};
11//!
12//! let mut rt = AgentRuntimeBuilder::new()
13//!     .llm(my_llm)
14//!     .tools(my_tools)
15//!     .system_prompt("You are a helpful assistant.")
16//!     .build()
17//!     .unwrap();
18//!
19//! let outcome = rt.run("What is the weather?").await.unwrap();
20//! println!("{}", outcome.final_text.unwrap_or_default());
21//! ```
22
23use std::sync::{Arc, Mutex, RwLock};
24
25use crate::agent::{FinishReason, PlanningMode};
26use crate::checkpoint::{CheckpointId, ShadowRepo};
27use crate::checkpoint_log::{CheckpointLogWriter, CheckpointRecord, TouchedVia};
28use crate::error::Result;
29use crate::event::{AgentEvent, EventSink, NullSink};
30use crate::hooks::{HookEvent, HookRegistry};
31use crate::kernel::{AgentKernel, AgentKernelBuilder, TurnContext, TurnOutcome};
32use crate::llm::{LlmProvider, TokenUsage};
33use crate::message::Message;
34use crate::tools::plan_mode::{
35    EnterPlanModeTool, ExitPlanModeTool, PlanApprovalGate, PlanModeRequestGate, RequestPlanModeTool,
36};
37use crate::tools::PermissionHook;
38use crate::tools::{TodoItem, TodoWriteTool, ToolRegistry, TouchedFiles};
39use crate::Compactor;
40
41// ──────────────────────────────────────────────────────────────────────────
42// Goal-168: GoalState / GoalStatus / GoalEvaluator
43// ──────────────────────────────────────────────────────────────────────────
44
45// Goal-loop data + judge live in `crate::runtime_goal`. Re-exported here so
46// historical paths like `crate::runtime::GoalState` keep working.
47pub use crate::runtime_goal::{GoalEvaluator, GoalState, GoalStatus, GoalVerdict};
48
49// ──────────────────────────────────────────────────────────────────────────
50// RuntimeOutcome
51// ──────────────────────────────────────────────────────────────────────────
52
53/// The result of a single [`AgentRuntime::run()`] turn.
54///
55/// Contains the model's final text (if any), how the turn ended,
56/// token usage for this turn, the number of LLM steps taken, and
57/// the LLM latency in milliseconds.
58#[derive(Debug, Clone)]
59pub struct RuntimeOutcome {
60    /// The final assistant text, if the model produced one.
61    pub final_text: Option<String>,
62    /// Why the turn stopped.
63    pub finish_reason: FinishReason,
64    /// Token usage for this turn only.
65    pub total_usage: TokenUsage,
66    /// Number of LLM calls made during this turn.
67    pub steps: usize,
68    /// Measured LLM latency for this turn (milliseconds).
69    pub llm_latency_ms: u64,
70    /// Checkpoint id captured at the end of this turn (if checkpointing
71    /// is enabled and the runtime is bound to a session).
72    pub checkpoint_id: Option<CheckpointId>,
73}
74
75impl From<TurnOutcome> for RuntimeOutcome {
76    fn from(t: TurnOutcome) -> Self {
77        Self {
78            final_text: t.final_text,
79            finish_reason: t.finish_reason,
80            total_usage: t.usage,
81            steps: t.steps,
82            llm_latency_ms: t.llm_latency_ms,
83            checkpoint_id: None,
84        }
85    }
86}
87
88// ──────────────────────────────────────────────────────────────────────────
89// CheckpointState
90// ──────────────────────────────────────────────────────────────────────────
91
92/// Checkpoint subsystem state, grouped to reduce field count on [`AgentRuntime`].
93///
94/// When `shadow` and `session_id` are both `Some`, snapshotting is active.
95/// When `None`, all snapshot/log operations are no-ops.
96struct CheckpointState {
97    shadow: Option<Arc<ShadowRepo>>,
98    session_id: Option<String>,
99    /// 0-indexed turn counter used for checkpoint labels.
100    turn_index: usize,
101    writer: Option<CheckpointLogWriter>,
102    touched_files: Option<Arc<Mutex<TouchedFiles>>>,
103}
104
105impl CheckpointState {
106    fn disabled() -> Self {
107        Self {
108            shadow: None,
109            session_id: None,
110            turn_index: 0,
111            writer: None,
112            touched_files: None,
113        }
114    }
115
116    fn enabled(&self) -> bool {
117        self.shadow.is_some() && self.session_id.is_some()
118    }
119
120    /// Take a snapshot just before a turn begins. Errors are logged
121    /// as warnings and swallowed so a checkpoint failure cannot brick a run.
122    fn snapshot_pre_turn(&self, user_text: &str) -> Option<CheckpointId> {
123        let (repo, sid) = (self.shadow.as_ref()?, self.session_id.as_ref()?);
124        let label = format!(
125            "turn {} pre: {}",
126            self.turn_index,
127            truncate_label(user_text)
128        );
129        match repo.snapshot_for_session(sid, &label) {
130            Ok(id) => Some(id),
131            Err(e) => {
132                tracing::warn!("checkpoint pre-snapshot failed: {e}");
133                None
134            }
135        }
136    }
137
138    /// Take a snapshot at the end of a turn, compute the touched-file set,
139    /// and append a [`CheckpointRecord`] to the log.
140    fn snapshot_post_turn(
141        &self,
142        user_text: &str,
143        pre: Option<&CheckpointId>,
144        started_at: i64,
145    ) -> Option<CheckpointId> {
146        let (repo, sid) = (self.shadow.as_ref()?, self.session_id.as_ref()?);
147        let label = format!(
148            "turn {} post: {}",
149            self.turn_index,
150            truncate_label(user_text)
151        );
152        let post = match repo.snapshot_for_session(sid, &label) {
153            Ok(id) => id,
154            Err(e) => {
155                tracing::warn!("checkpoint post-snapshot failed: {e}");
156                return None;
157            }
158        };
159
160        let (mut paths, saw_shell) = match &self.touched_files {
161            Some(slot) => match slot.lock() {
162                Ok(t) => (t.paths_sorted(), t.saw_shell),
163                Err(_) => (vec![], false),
164            },
165            None => (vec![], true),
166        };
167
168        let mut via = TouchedVia::Structured;
169        if saw_shell {
170            via = TouchedVia::ShellDiff;
171            if let Some(pre_id) = pre {
172                if let Ok(diff_paths) = repo.changed_paths(pre_id, &post) {
173                    let mut set: std::collections::HashSet<String> = paths.into_iter().collect();
174                    for p in diff_paths {
175                        set.insert(p);
176                    }
177                    let mut v: Vec<String> = set.into_iter().collect();
178                    v.sort();
179                    paths = v;
180                }
181            }
182        }
183
184        if let Some(writer) = &self.writer {
185            let rec = CheckpointRecord {
186                turn: self.turn_index,
187                pre: pre.cloned(),
188                post: post.clone(),
189                touched_files: paths,
190                touched_via: via,
191                started_at,
192                finished_at: unix_now(),
193            };
194            if let Err(e) = writer.append(&rec) {
195                tracing::warn!("checkpoint log append failed: {e}");
196            }
197        }
198
199        Some(post)
200    }
201}
202
203// ──────────────────────────────────────────────────────────────────────────
204// AgentRuntime
205// ──────────────────────────────────────────────────────────────────────────
206
207/// A stateful agent runtime that wraps [`AgentKernel`].
208///
209/// `AgentRuntime` owns the conversation transcript and all cross-turn
210/// configuration. Each call to [`run`](AgentRuntime::run) appends a user
211/// message to the transcript, delegates to the kernel for one turn, and
212/// appends the kernel's new messages back to the transcript.
213pub struct AgentRuntime {
214    /// The stateless kernel that executes each turn.
215    kernel: AgentKernel,
216    /// Accumulated conversation transcript.
217    transcript: Vec<Message>,
218    /// Event sink for streaming events (Arc for sharing with forwarder task).
219    event_sink: Arc<dyn EventSink>,
220    /// Pending plan tool calls buffered by the kernel (plan-first mode).
221    pending_plan_calls: Option<Vec<crate::llm::ToolCall>>,
222    /// Whether the user confirmed the pending plan.
223    plan_confirmed: bool,
224    /// Whether to request streaming responses from the LLM.
225    streaming: bool,
226    /// Optional permission hook for tool-call interception.
227    permission_hook: Option<Arc<dyn PermissionHook>>,
228    /// Planning mode (immediate vs plan-first).
229    planning_mode: PlanningMode,
230    /// Optional compactor for cross-turn transcript summarization.
231    compactor: Option<Compactor>,
232    /// Checkpoint subsystem (snapshot, session-id, writer, touched-files).
233    /// Grouped to reduce field count; inactive when checkpoints are disabled.
234    checkpoints: CheckpointState,
235    /// Goal-167: shared task-list state written by `todo_write` calls.
236    /// Read back via [`current_todos`](AgentRuntime::current_todos).
237    todo_list: Arc<RwLock<Vec<TodoItem>>>,
238    /// Goal-165: plan mode 2.0 gate — shared with `EnterPlanModeTool` and
239    /// `ExitPlanModeTool`. `confirm_plan` / `reject_plan` forward to it.
240    plan_approval_gate: Arc<PlanApprovalGate>,
241    /// Goal-202: pre-confirmation gate — shared with `RequestPlanModeTool`.
242    /// `approve_plan_mode_request` / `reject_plan_mode_request` forward here.
243    plan_mode_request_gate: Arc<PlanModeRequestGate>,
244    /// Goal-168: active goal state (set by `/goal`). `None` when no goal is active.
245    pub goal_state: Arc<RwLock<Option<GoalState>>>,
246    /// Goal-181: FIFO queue of user messages waiting to be processed.
247    /// Callers use [`enqueue`](AgentRuntime::enqueue) instead of
248    /// [`run`](AgentRuntime::run) directly; the queue is drained in FIFO
249    /// order so that messages sent while a turn is in flight are processed
250    /// automatically when the current turn completes.
251    message_queue: std::collections::VecDeque<String>,
252    /// Deferred `TurnFinished` event held by `execute_kernel_turn` until
253    /// `emit_turn_messages` can flush it after all assistant messages.
254    deferred_turn_finished: Option<AgentEvent>,
255}
256
257impl std::fmt::Debug for AgentRuntime {
258    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        f.debug_struct("AgentRuntime")
260            .field("kernel", &self.kernel)
261            .field("transcript", &self.transcript)
262            .field("event_sink", &"<EventSink>")
263            .field("streaming", &self.streaming)
264            .field(
265                "permission_hook",
266                &self.permission_hook.as_ref().map(|_| "<hook>"),
267            )
268            .field("planning_mode", &self.planning_mode)
269            .field(
270                "todo_list",
271                &self.todo_list.read().map(|l| l.len()).unwrap_or(0),
272            )
273            .field(
274                "goal_state",
275                &self
276                    .goal_state
277                    .read()
278                    .ok()
279                    .and_then(|g| g.as_ref().map(|s| s.condition.clone())),
280            )
281            .field(
282                "deferred_turn_finished",
283                &self.deferred_turn_finished.as_ref().map(|_| "<event>"),
284            )
285            .finish()
286    }
287}
288
289impl AgentRuntime {
290    /// Create a new [`AgentRuntimeBuilder`].
291    pub fn builder() -> AgentRuntimeBuilder {
292        AgentRuntimeBuilder::new()
293    }
294
295    /// Run one turn with the given user text.
296    ///
297    /// Appends `Message::user(text)` to the transcript, delegates to the kernel,
298    /// appends the new messages back, and returns a [`RuntimeOutcome`].
299    pub async fn run(&mut self, user_text: impl Into<String>) -> Result<RuntimeOutcome> {
300        let user_text = user_text.into();
301
302        tracing::Span::current().record(
303            "session_id",
304            self.checkpoints.session_id.as_deref().unwrap_or(""),
305        );
306        tracing::debug!(
307            session_id = self.checkpoints.session_id.as_deref().unwrap_or(""),
308            turn = self.checkpoints.turn_index,
309            "agent.turn: starting"
310        );
311
312        let started_at = unix_now();
313        let pre_id = self.checkpoints.snapshot_pre_turn(&user_text);
314        self.reset_touched_files();
315        self.append_user_message(&user_text).await;
316        self.maybe_compact_cross_turn().await?;
317
318        let turn_outcome = self.execute_kernel_turn().await?;
319        self.update_plan_state(&turn_outcome);
320        self.emit_turn_messages(&turn_outcome).await;
321
322        let mut outcome: RuntimeOutcome = turn_outcome.into();
323        outcome.checkpoint_id =
324            self.checkpoints
325                .snapshot_post_turn(&user_text, pre_id.as_ref(), started_at);
326
327        tracing::info!(
328            steps = outcome.steps,
329            finish_reason = ?outcome.finish_reason,
330            "agent.turn: finished"
331        );
332        self.checkpoints.turn_index += 1;
333
334        Ok(outcome)
335    }
336
337    /// Reset the touched-files collector at the start of a turn.
338    fn reset_touched_files(&self) {
339        if let Some(slot) = &self.checkpoints.touched_files {
340            if let Ok(mut t) = slot.lock() {
341                *t = TouchedFiles::new();
342            }
343        }
344    }
345
346    /// Append a user message to the transcript and emit `MessageAppended`.
347    async fn append_user_message(&mut self, user_text: &str) {
348        let user_msg = Message::user(user_text.to_string());
349        self.transcript.push(user_msg.clone());
350        self.event_sink
351            .emit(AgentEvent::MessageAppended {
352                message: user_msg,
353                parent_uuid: None,
354                usage: None,
355            })
356            .await;
357    }
358
359    /// Run cross-turn compaction if threshold is exceeded, emitting boundary events.
360    ///
361    /// This is the Wrapper's responsibility — the kernel only does intra-turn trim.
362    /// The compaction summary is emitted as `MessageAppended` so it lands in the
363    /// on-disk jsonl. A `CompactionBoundary` event (g157) lets the reader skip
364    /// pre-compaction messages on resume.
365    async fn maybe_compact_cross_turn(&mut self) -> Result<()> {
366        let Some(ref compactor) = self.compactor else {
367            return Ok(());
368        };
369        let chars = Compactor::estimate_chars(&self.transcript);
370        if chars < compactor.threshold_chars {
371            return Ok(());
372        }
373        self.kernel.hooks().dispatch(HookEvent::PreCompact {
374            transcript_len: chars,
375        });
376        let Some((removed, summary_chars)) = compactor
377            .apply_to_transcript(self.kernel.llm().as_ref(), &mut self.transcript)
378            .await?
379        else {
380            return Ok(());
381        };
382        self.kernel.hooks().dispatch(HookEvent::PostCompact {
383            removed,
384            summary_chars,
385        });
386        self.event_sink
387            .emit(AgentEvent::CompactionBoundary {
388                turn: self.checkpoints.turn_index as u32,
389                compacted_count: removed,
390                summary_uuid: None,
391            })
392            .await;
393        if let Some(summary) = self.transcript.first().cloned() {
394            self.event_sink
395                .emit(AgentEvent::MessageAppended {
396                    message: summary,
397                    parent_uuid: None,
398                    usage: None,
399                })
400                .await;
401        }
402        Ok(())
403    }
404
405    /// Build a `TurnContext`, run the kernel, and return the outcome.
406    ///
407    /// Spawns a forwarder task that withholds `TurnFinished` until after all
408    /// assistant/tool `MessageAppended` events have been emitted (prevents SDK
409    /// consumers from closing their stream before receiving the final text).
410    async fn execute_kernel_turn(&mut self) -> Result<crate::kernel::TurnOutcome> {
411        let (event_tx, mut event_rx) =
412            tokio::sync::mpsc::unbounded_channel::<crate::event::AgentEvent>();
413        let sink = self.event_sink.clone();
414        let forwarder = tokio::spawn(async move {
415            let mut deferred_finished: Option<crate::event::AgentEvent> = None;
416            while let Some(ev) = event_rx.recv().await {
417                if matches!(ev, AgentEvent::TurnFinished { .. }) {
418                    deferred_finished = Some(ev);
419                    continue;
420                }
421                sink.emit(ev).await;
422            }
423            deferred_finished
424        });
425
426        let ctx = TurnContext {
427            messages: self.transcript.clone(),
428            tool_specs: self.kernel.tools().specs(),
429            step_events_tx: Some(event_tx.clone()),
430            plan_confirmed: self.plan_confirmed,
431            plan_buffer: self.pending_plan_calls.clone(),
432            streaming: self.streaming,
433            permission_hook: self.permission_hook.clone(),
434            planning_mode: self.planning_mode.clone(),
435            exploring_plan_mode: self.plan_approval_gate.exploring_plan_mode.clone(),
436            permission_mode: self.kernel.tools().permission_mode(),
437            mailbox: None,
438        };
439
440        let turn_outcome = self.kernel.run(ctx).await?;
441        drop(event_tx);
442        // Wait for forwarder; stash the deferred TurnFinished for emit_turn_messages.
443        self.deferred_turn_finished = forwarder.await.ok().flatten();
444        Ok(turn_outcome)
445    }
446
447    /// Update plan-confirmation state from a completed turn outcome.
448    fn update_plan_state(&mut self, outcome: &crate::kernel::TurnOutcome) {
449        if outcome.finish_reason == crate::agent::FinishReason::PlanPending {
450            self.pending_plan_calls = outcome.plan_buffer.clone();
451        }
452        let was_confirmed = self.plan_confirmed;
453        self.plan_confirmed = false;
454        if was_confirmed {
455            self.pending_plan_calls = None;
456        }
457    }
458
459    /// Append new kernel messages to the transcript and emit `MessageAppended`
460    /// (or `MessageAppendedWithAudit`) for each, then flush the deferred
461    /// `TurnFinished` event.
462    async fn emit_turn_messages(&mut self, outcome: &crate::kernel::TurnOutcome) {
463        let new_messages = outcome.new_messages.clone();
464        let turn_usage = crate::session::UsageMeta::from_token_usage(&outcome.usage);
465        let mut tool_audits = outcome.tool_audits.clone();
466        self.transcript.extend(new_messages.iter().cloned());
467        for msg in &new_messages {
468            let event = if msg.role == crate::message::Role::Tool {
469                if let Some(tcid) = &msg.tool_call_id {
470                    if let Some(audit) = tool_audits.remove(tcid) {
471                        AgentEvent::MessageAppendedWithAudit {
472                            message: msg.clone(),
473                            audit,
474                        }
475                    } else {
476                        AgentEvent::MessageAppended {
477                            message: msg.clone(),
478                            parent_uuid: None,
479                            usage: None,
480                        }
481                    }
482                } else {
483                    AgentEvent::MessageAppended {
484                        message: msg.clone(),
485                        parent_uuid: None,
486                        usage: None,
487                    }
488                }
489            } else {
490                let usage = if matches!(msg.role, crate::message::Role::Assistant) {
491                    Some(turn_usage.clone())
492                } else {
493                    None
494                };
495                AgentEvent::MessageAppended {
496                    message: msg.clone(),
497                    parent_uuid: None,
498                    usage,
499                }
500            };
501            self.event_sink.emit(event).await;
502        }
503        // Emit TurnFinished after all messages are on the wire (SDK ordering guarantee).
504        if let Some(ev) = self.deferred_turn_finished.take() {
505            self.event_sink.emit(ev).await;
506        }
507    }
508
509    // ── Goal-181: message queue ────────────────────────────────────────────
510
511    /// Enqueue a user message and drain the queue in FIFO order.
512    ///
513    /// This is the preferred entry point for all interaction layers (TUI,
514    /// HTTP, CLI).  Unlike calling [`run`](Self::run) directly, `enqueue`
515    /// is safe to call while a turn is already in flight: the runtime is
516    /// single-threaded (`&mut self`), so multiple callers naturally
517    /// serialise.  The queue ensures messages submitted before the runtime
518    /// is ready are not lost and are processed in order.
519    ///
520    /// ```text
521    /// user sends A → enqueue(A) → run(A)
522    /// user sends B while A runs → enqueue(B) → queue=[B]  (A already running via prior call)
523    /// A finishes → loop pops B → run(B)
524    /// ```
525    ///
526    /// In practice the outer loop (`drain_queue`) is what creates this
527    /// ordering: a call to `enqueue` that arrives while another `enqueue`
528    /// is executing on the same runtime will block on `&mut self` borrow,
529    /// so the messages are processed strictly in order.
530    pub async fn enqueue(&mut self, text: impl Into<String>) -> Result<Option<RuntimeOutcome>> {
531        self.message_queue.push_back(text.into());
532        self.drain_queue().await
533    }
534
535    /// Process all queued messages in FIFO order.
536    ///
537    /// Returns `Ok(Some(outcome))` for the last turn processed, or
538    /// `Ok(None)` if the queue is empty when called.
539    async fn drain_queue(&mut self) -> Result<Option<RuntimeOutcome>> {
540        let mut last: Option<RuntimeOutcome> = None;
541        while let Some(msg) = self.message_queue.pop_front() {
542            last = Some(self.run(msg).await?);
543        }
544        Ok(last)
545    }
546
547    /// Number of messages currently waiting in the queue.
548    ///
549    /// Callers can expose this to the UI (e.g. status bar: "+N queued").
550    pub fn queue_len(&self) -> usize {
551        self.message_queue.len()
552    }
553
554    // ── Transcript access ──────────────────────────────────────────────────
555
556    /// Return a reference to the accumulated transcript.
557    pub fn transcript(&self) -> &[Message] {
558        &self.transcript
559    }
560
561    /// Replace the current transcript (useful for restoring from a saved session).
562    pub fn set_transcript(&mut self, transcript: Vec<Message>) {
563        self.transcript = transcript;
564    }
565
566    /// Discard all transcript messages after index `len`, restoring the
567    /// transcript to the state it had before a turn started. Used by the
568    /// TUI abort path to prevent orphan tool_call entries.
569    pub fn truncate_transcript(&mut self, len: usize) {
570        self.transcript.truncate(len);
571    }
572
573    /// Return a reference to the inner kernel.
574    pub fn kernel(&self) -> &AgentKernel {
575        &self.kernel
576    }
577
578    /// Return the event sink currently in use.
579    pub fn event_sink(&self) -> &dyn EventSink {
580        self.event_sink.as_ref()
581    }
582
583    /// Set a cancellation token that interrupts the current (or next) agent turn.
584    ///
585    /// When the token is cancelled the step loop exits with
586    /// [`FinishReason::Cancelled`](crate::agent::FinishReason::Cancelled) at
587    /// the next step boundary.  This method replaces any previously installed
588    /// token — call it before each `run()` so a fresh token is in place.
589    pub fn set_interrupt_token(&mut self, token: tokio_util::sync::CancellationToken) {
590        self.kernel.shutdown_token = Some(token);
591    }
592
593    /// Set the session id used for tracing-span labels and turn log lines.
594    ///
595    /// After this is called, every `run()` emits a tracing span record with
596    /// `session_id=<id>` and an info log line carrying the same field, so logs
597    /// and OTEL/Datadog traces can be filtered per session via
598    /// `RUST_LOG=recursive[{session_id}]=debug` or the `session_id` label.
599    pub fn set_session_id(&mut self, id: impl Into<String>) {
600        self.checkpoints.session_id = Some(id.into());
601    }
602
603    /// Set a new event sink (useful for REPL mode between turns).
604    pub fn set_event_sink(&mut self, sink: Arc<dyn EventSink>) {
605        self.event_sink = sink.clone();
606        // Goal-167: re-register TodoWriteTool with the new sink so that
607        // AgentEvent::TodoUpdated reaches the new consumer (e.g. TUI).
608        self.kernel
609            .tools_mut()
610            .register_mut(Arc::new(TodoWriteTool::new(
611                self.todo_list.clone(),
612                sink.clone(),
613            )));
614        // Goal-165: re-register ExitPlanModeTool with the new sink so that
615        // AgentEvent::PlanProposed reaches the new consumer (e.g. TUI).
616        self.kernel
617            .tools_mut()
618            .register_mut(Arc::new(ExitPlanModeTool::new(
619                self.plan_approval_gate.clone(),
620                sink,
621            )));
622    }
623
624    /// Goal-167: return a snapshot of the current agent task list.
625    ///
626    /// Returns a clone of the list as it stands at call time. Returns an
627    /// empty vec if the internal lock is poisoned.
628    pub fn current_todos(&self) -> Vec<TodoItem> {
629        self.todo_list.read().map(|l| l.clone()).unwrap_or_default()
630    }
631
632    /// Goal-161: attach a [`crate::tools::PermissionHook`] to the
633    /// underlying tool registry so every tool invocation passes through
634    /// the async permission gate before execution.
635    pub fn set_permission_hook(&mut self, hook: Arc<dyn crate::tools::PermissionHook>) {
636        self.kernel.tools_mut().set_permission_hook(hook);
637    }
638
639    /// Return a shared reference to the plan-approval gate.
640    ///
641    /// Callers (e.g. HTTP handlers) that need to inspect `pending_plan` or
642    /// call `approve`/`reject` without holding the runtime `Mutex` can clone
643    /// this `Arc` and operate on the gate directly.
644    pub fn plan_approval_gate(&self) -> Arc<PlanApprovalGate> {
645        self.plan_approval_gate.clone()
646    }
647
648    /// Confirm the pending plan, allowing execution to proceed on the next run.
649    ///
650    /// Covers both PlanFirst mode (sets `plan_confirmed` flag for kernel) and
651    /// Plan Mode 2.0 (wakes `exit_plan_mode`'s blocking wait via the gate).
652    pub fn confirm_plan(&mut self) {
653        self.plan_confirmed = true;
654        self.plan_approval_gate.approve();
655    }
656
657    /// Force a compaction pass right now, regardless of the
658    /// configured threshold. Useful for TUI / API surfaces that
659    /// expose a manual "/compact" command.
660    ///
661    /// No-op (returns `Ok(())`) when no compactor is configured or
662    /// when the transcript is too small to compact (fewer than
663    /// `keep_recent_n + 2` messages).
664    pub async fn compact_now(&mut self) -> Result<()> {
665        let Some(ref compactor) = self.compactor else {
666            return Ok(());
667        };
668        compactor
669            .apply_to_transcript(self.kernel.llm().as_ref(), &mut self.transcript)
670            .await?;
671        Ok(())
672    }
673
674    /// Update the planning mode in place. Allows the TUI's
675    /// `/plan on|off` command to flip plan-first vs immediate
676    /// without rebuilding the runtime.
677    pub fn set_planning_mode(&mut self, mode: PlanningMode) {
678        self.planning_mode = mode;
679    }
680
681    /// Goal-202: approve the plan-mode entry request.
682    ///
683    /// Wakes `RequestPlanModeTool`'s blocking wait, returning `{"approved": true}`
684    /// to the LLM so it can proceed with `enter_plan_mode`.
685    pub fn approve_plan_mode_request(&self) {
686        self.plan_mode_request_gate.approve();
687    }
688
689    /// Goal-202: reject the plan-mode entry request with a reason.
690    ///
691    /// Wakes `RequestPlanModeTool`'s blocking wait, returning
692    /// `{"approved": false, "reason": "..."}` so the LLM can execute directly.
693    pub fn reject_plan_mode_request(&self, reason: &str) {
694        self.plan_mode_request_gate.reject(reason);
695    }
696
697    /// Reject the pending plan with a reason.
698    ///
699    /// Covers both PlanFirst mode (injects a user message) and Plan Mode 2.0
700    /// (wakes `exit_plan_mode`'s blocking wait with the rejection reason).
701    pub fn reject_plan(&mut self, reason: &str) {
702        // PlanFirst mode: clear pending plan calls and inject rejection message.
703        self.pending_plan_calls = None;
704        self.plan_confirmed = false;
705        let rejection_msg = Message::user(format!("Plan rejected: {}", reason));
706        self.transcript.push(rejection_msg);
707
708        // Plan Mode 2.0: wake the blocking ExitPlanModeTool with the reason.
709        self.plan_approval_gate.reject(reason);
710    }
711
712    // ── Goal-168: goal state accessors ────────────────────────────────────
713
714    /// Return a clone of the current goal state (or `None`).
715    pub fn current_goal(&self) -> Option<GoalState> {
716        self.goal_state.read().ok().and_then(|g| g.clone())
717    }
718
719    /// Set a new active goal. Emits `AgentEvent::GoalSet` via the event sink.
720    pub async fn set_goal(&self, condition: String, max_turns: u32) {
721        let state = GoalState {
722            condition: condition.clone(),
723            status: GoalStatus::Pursuing,
724            turns: 0,
725            max_turns,
726            last_reason: None,
727        };
728        if let Ok(mut g) = self.goal_state.write() {
729            *g = Some(state);
730        }
731        self.event_sink
732            .emit(AgentEvent::GoalSet {
733                condition,
734                max_turns,
735            })
736            .await;
737    }
738
739    /// Clear the active goal. Emits `AgentEvent::GoalCleared`.
740    pub async fn clear_goal(&self) {
741        if let Ok(mut g) = self.goal_state.write() {
742            if let Some(ref mut s) = *g {
743                s.status = GoalStatus::Cleared;
744            }
745            *g = None;
746        }
747        self.event_sink.emit(AgentEvent::GoalCleared).await;
748    }
749
750    /// Run a goal loop: execute turns until the judge says the condition
751    /// is met, the turn budget is exhausted, or the goal is cleared externally.
752    ///
753    /// Steps per iteration:
754    /// 1. `run(prompt)` — execute one agent turn.
755    /// 2. Increment `GoalState.turns`.
756    /// 3. If `turns >= max_turns` → emit `GoalCleared` (budget exceeded), break.
757    /// 4. Call `GoalEvaluator::evaluate(condition, transcript_tail)`.
758    /// 5. If `achieved` → emit `GoalAchieved`, break.
759    /// 6. Else → emit `GoalContinuing { reason }`, continue with auto-prompt.
760    pub async fn run_goal_loop(
761        &mut self,
762        initial_prompt: impl Into<String>,
763        condition: impl Into<String>,
764        max_turns: u32,
765    ) -> Result<Vec<RuntimeOutcome>> {
766        let condition = condition.into();
767        self.set_goal(condition.clone(), max_turns).await;
768
769        let evaluator = GoalEvaluator::new(self.kernel.llm().clone());
770        let mut outcomes = Vec::new();
771        let mut next_prompt = initial_prompt.into();
772
773        loop {
774            // Check if goal was externally cleared while we were looping.
775            let active = self
776                .goal_state
777                .read()
778                .ok()
779                .and_then(|g| g.clone())
780                .map(|g| g.status == GoalStatus::Pursuing)
781                .unwrap_or(false);
782            if !active {
783                break;
784            }
785
786            let outcome = self.run(&next_prompt).await?;
787            outcomes.push(outcome);
788
789            // Increment turn counter.
790            let turns = {
791                let mut guard = self.goal_state.write().ok();
792                if let Some(ref mut guard) = guard {
793                    if let Some(ref mut gs) = **guard {
794                        gs.turns += 1;
795                        gs.turns
796                    } else {
797                        break; // goal was cleared externally
798                    }
799                } else {
800                    break;
801                }
802            };
803
804            // Budget exceeded?
805            if turns >= max_turns {
806                if let Ok(mut g) = self.goal_state.write() {
807                    if let Some(ref mut gs) = *g {
808                        gs.status = GoalStatus::Cleared;
809                    }
810                    *g = None;
811                }
812                self.event_sink.emit(AgentEvent::GoalCleared).await;
813                tracing::warn!(
814                    "goal loop: turn budget of {max_turns} exceeded without achieving condition"
815                );
816                break;
817            }
818
819            // Ask the judge.
820            let verdict = evaluator.evaluate(&condition, self.transcript()).await?;
821            if verdict.achieved {
822                if let Ok(mut g) = self.goal_state.write() {
823                    if let Some(ref mut gs) = *g {
824                        gs.status = GoalStatus::Achieved;
825                        gs.last_reason = Some(verdict.reason.clone());
826                    }
827                    *g = None;
828                }
829                self.event_sink
830                    .emit(AgentEvent::GoalAchieved {
831                        condition: condition.clone(),
832                        turns,
833                    })
834                    .await;
835                break;
836            } else {
837                // Store reason and continue.
838                if let Ok(mut g) = self.goal_state.write() {
839                    if let Some(ref mut gs) = *g {
840                        gs.last_reason = Some(verdict.reason.clone());
841                    }
842                }
843                self.event_sink
844                    .emit(AgentEvent::GoalContinuing {
845                        reason: verdict.reason.clone(),
846                        turns,
847                    })
848                    .await;
849
850                next_prompt = format!(
851                    "(Goal: {condition})\n\nPrevious attempt reason: {}\n\nContinue.",
852                    verdict.reason
853                );
854            }
855        }
856
857        Ok(outcomes)
858    }
859
860    /// Run a loop: execute turns until the agent stops scheduling wakeups.
861    ///
862    /// Between turns, sleeps for the requested `delay`. If the agent doesn't
863    /// call `schedule_wakeup` during a turn, the loop ends.
864    ///
865    /// The `wakeup_slot` should be the same slot registered with the
866    /// `ScheduleWakeup` tool in the agent's tool registry.
867    pub async fn run_loop(
868        &mut self,
869        initial_goal: impl Into<String>,
870        wakeup_slot: &crate::tools::WakeupSlot,
871    ) -> Result<Vec<RuntimeOutcome>> {
872        let mut outcomes = Vec::new();
873        let mut next_goal = initial_goal.into();
874
875        loop {
876            let outcome = self.run(&next_goal).await?;
877            outcomes.push(outcome);
878
879            // Check if the agent scheduled a wakeup
880            let wakeup = wakeup_slot.lock().ok().and_then(|mut slot| slot.take());
881
882            match wakeup {
883                Some(req) => {
884                    tokio::time::sleep(req.delay).await;
885                    next_goal = req.prompt;
886                }
887                None => break,
888            }
889        }
890        Ok(outcomes)
891    }
892
893    /// Run a loop with background job awareness.
894    ///
895    /// After each turn, checks both:
896    /// 1. The `WakeupSlot` for an explicit wakeup request
897    /// 2. The `BackgroundJobManager` for completed jobs
898    ///
899    /// If a background job completed, its output is injected as the next turn's
900    /// goal. If a wakeup was scheduled, the runtime sleeps for the requested
901    /// delay then continues. If neither is present, the loop ends.
902    pub async fn run_event_loop(
903        &mut self,
904        initial_goal: impl Into<String>,
905        wakeup_slot: &crate::tools::WakeupSlot,
906        bg_manager: Option<&tokio::sync::Mutex<crate::tools::BackgroundJobManager>>,
907    ) -> Result<Vec<RuntimeOutcome>> {
908        let mut outcomes = Vec::new();
909        let mut next_goal = initial_goal.into();
910
911        loop {
912            let outcome = self.run(&next_goal).await?;
913            outcomes.push(outcome);
914
915            // Priority 1: explicit wakeup
916            let wakeup = wakeup_slot.lock().ok().and_then(|mut slot| slot.take());
917            if let Some(req) = wakeup {
918                tokio::time::sleep(req.delay).await;
919                next_goal = req.prompt;
920                continue;
921            }
922
923            // Priority 2: background job completed
924            if let Some(mgr) = bg_manager {
925                if let Ok(mut mgr) = mgr.try_lock() {
926                    if let Some((id, output)) = mgr.take_completed() {
927                        next_goal = format!("Background job '{}' completed:\n{}", id, output);
928                        continue;
929                    }
930                }
931            }
932
933            // Nothing to do → loop ends
934            break;
935        }
936        Ok(outcomes)
937    }
938
939    // ──────────────────────────────────────────────────────────────────
940    // Checkpoint helpers
941    // ──────────────────────────────────────────────────────────────────
942
943    /// Bind this runtime to a checkpoint chain. Subsequent calls to
944    /// `run()` will snapshot before and after each turn under
945    /// `refs/sessions/<session_id>/HEAD` and append a record to
946    /// `checkpoint_log_path` (a `checkpoints.jsonl` file).
947    ///
948    /// `touched_slot` is the same collector previously installed on
949    /// the [`ToolRegistry`] via `with_touched_files`. If no collector
950    /// is provided, file-attribution falls back to "shell-diff" for
951    /// every turn.
952    ///
953    /// Side effect: registers the read-only `checkpoint_list` and
954    /// `checkpoint_diff` tools, scoped to this session, onto the
955    /// kernel's tool registry — so the agent can introspect its own
956    /// checkpoint chain (but cannot save or restore; those are
957    /// orchestration concerns).
958    pub fn enable_checkpoints(
959        &mut self,
960        shadow: Arc<ShadowRepo>,
961        session_id: impl Into<String>,
962        log_path: std::path::PathBuf,
963        touched_slot: Option<Arc<Mutex<TouchedFiles>>>,
964    ) -> Result<()> {
965        let writer = CheckpointLogWriter::open(&log_path)?;
966        let session_id = session_id.into();
967
968        // Register session-scoped read-only checkpoint tools onto the
969        // kernel's registry. The shadow repo is shared via
970        // Arc<Mutex<ShadowRepo>> so the tools and the runtime see the
971        // same checkpoint chain.
972        let tool_repo = Arc::new(Mutex::new(ShadowRepo::clone(&shadow)));
973        let ctx = crate::tools::CheckpointToolCtx {
974            repo: tool_repo,
975            session_id: session_id.clone(),
976        };
977        let tools = self.kernel.tools_mut();
978        tools.register_mut(Arc::new(crate::tools::CheckpointList::new(ctx.clone())));
979        tools.register_mut(Arc::new(crate::tools::CheckpointDiff::new(ctx)));
980
981        self.checkpoints.shadow = Some(shadow);
982        self.checkpoints.session_id = Some(session_id);
983        self.checkpoints.writer = Some(writer);
984        self.checkpoints.touched_files = touched_slot;
985        Ok(())
986    }
987
988    /// Whether checkpoint snapshots are active.
989    pub fn checkpoints_enabled(&self) -> bool {
990        self.checkpoints.enabled()
991    }
992
993    /// Returns the 0-indexed counter that will be assigned to the
994    /// *next* turn (i.e. the count of turns already executed).
995    pub fn turn_index(&self) -> usize {
996        self.checkpoints.turn_index
997    }
998}
999
1000/// Current Unix timestamp in seconds.
1001fn unix_now() -> i64 {
1002    std::time::SystemTime::now()
1003        .duration_since(std::time::UNIX_EPOCH)
1004        .map(|d| d.as_secs() as i64)
1005        .unwrap_or(0)
1006}
1007
1008/// Truncate a turn label to keep checkpoint commit messages readable.
1009fn truncate_label(s: &str) -> String {
1010    const MAX: usize = 120;
1011    let trimmed: String = s.lines().next().unwrap_or("").chars().take(MAX).collect();
1012    if trimmed.len() < s.len() {
1013        format!("{trimmed}…")
1014    } else {
1015        trimmed
1016    }
1017}
1018
1019// ──────────────────────────────────────────────────────────────────────────
1020// AgentRuntimeBuilder
1021// ──────────────────────────────────────────────────────────────────────────
1022
1023/// Builder for [`AgentRuntime`].
1024///
1025/// # Required
1026/// - `llm(...)` — The LLM provider.
1027///
1028/// All other methods are optional with sensible defaults.
1029pub struct AgentRuntimeBuilder {
1030    kernel_builder: AgentKernelBuilder,
1031    system_prompt: Option<String>,
1032    seed: Vec<Message>,
1033    streaming: bool,
1034    permission_hook: Option<Arc<dyn PermissionHook>>,
1035    planning_mode: PlanningMode,
1036    saved_event_sink: Option<Arc<dyn EventSink>>,
1037    compactor: Option<Compactor>,
1038    /// UUID of the parent agent's last message. When set, the first
1039    /// `MessageAppended` event will carry this as `parent_uuid`, branching
1040    /// the subagent's messages off that point in the conversation tree (g155).
1041    /// Stored here for future multi-agent orchestration; not yet wired to
1042    /// the actual event emission path.
1043    pub parent_agent_last_uuid: Option<String>,
1044}
1045
1046impl std::fmt::Debug for AgentRuntimeBuilder {
1047    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1048        f.debug_struct("AgentRuntimeBuilder")
1049            .field("kernel_builder", &self.kernel_builder)
1050            .field("system_prompt", &self.system_prompt)
1051            .field("seed", &self.seed)
1052            .field("streaming", &self.streaming)
1053            .field(
1054                "permission_hook",
1055                &self.permission_hook.as_ref().map(|_| "<hook>"),
1056            )
1057            .field("planning_mode", &self.planning_mode)
1058            .field(
1059                "event_sink",
1060                &self.saved_event_sink.as_ref().map(|_| "<EventSink>"),
1061            )
1062            .finish()
1063    }
1064}
1065
1066impl Default for AgentRuntimeBuilder {
1067    fn default() -> Self {
1068        Self::new()
1069    }
1070}
1071
1072impl AgentRuntimeBuilder {
1073    /// Create a new builder with default values.
1074    pub fn new() -> Self {
1075        Self {
1076            kernel_builder: AgentKernelBuilder::default(),
1077            system_prompt: None,
1078            seed: Vec::new(),
1079            streaming: false,
1080            permission_hook: None,
1081            planning_mode: PlanningMode::Immediate,
1082            saved_event_sink: None,
1083            compactor: None,
1084            parent_agent_last_uuid: None,
1085        }
1086    }
1087
1088    /// Set the UUID of the parent agent's last message.
1089    ///
1090    /// When set, this runtime's messages will be stamped with this UUID as
1091    /// `parent_uuid` on their first `MessageAppended` event, branching the
1092    /// subagent chain off the given point in the parent's conversation tree
1093    /// (g155). Currently stored for future multi-agent orchestration.
1094    pub fn parent_agent_last_uuid(mut self, uuid: impl Into<String>) -> Self {
1095        self.parent_agent_last_uuid = Some(uuid.into());
1096        self
1097    }
1098
1099    /// Set the LLM provider (required).
1100    pub fn llm(mut self, llm: Arc<dyn LlmProvider>) -> Self {
1101        self.kernel_builder = self.kernel_builder.llm(llm);
1102        self
1103    }
1104
1105    /// Set the tool registry (optional, defaults to a local empty registry).
1106    pub fn tools(mut self, tools: ToolRegistry) -> Self {
1107        self.kernel_builder = self.kernel_builder.tools(tools);
1108        self
1109    }
1110
1111    /// Set an initial system prompt (optional).
1112    ///
1113    /// This is prepended to the transcript as the first message.
1114    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
1115        self.system_prompt = Some(prompt.into());
1116        self
1117    }
1118
1119    /// Set the maximum number of LLM calls per turn (optional, default 32).
1120    pub fn max_steps(mut self, n: usize) -> Self {
1121        self.kernel_builder = self.kernel_builder.max_steps(n);
1122        self
1123    }
1124
1125    /// Set a transcript character limit (optional, default unlimited).
1126    pub fn max_transcript_chars(mut self, n: usize) -> Self {
1127        self.kernel_builder = self.kernel_builder.max_transcript_chars(n);
1128        self
1129    }
1130
1131    /// Set an optional compactor for summarising old messages.
1132    pub fn compactor(mut self, compactor: Compactor) -> Self {
1133        // Also pass the compactor to the kernel so `RunCore` can perform
1134        // intra-turn compaction (which dispatches `PreCompact` / `PostCompact`
1135        // hooks). Cross-turn compaction is performed by the runtime itself.
1136        self.kernel_builder = self.kernel_builder.compactor(compactor.clone());
1137        self.compactor = Some(compactor);
1138        self
1139    }
1140
1141    /// Enable or disable streaming of partial tokens (optional, default false).
1142    pub fn streaming(mut self, enabled: bool) -> Self {
1143        self.streaming = enabled;
1144        self
1145    }
1146
1147    /// Set the planning mode (optional, defaults to [`PlanningMode::Immediate`]).
1148    pub fn planning_mode(mut self, mode: PlanningMode) -> Self {
1149        self.planning_mode = mode;
1150        self
1151    }
1152
1153    /// Set the hook registry (optional).
1154    pub fn hooks(mut self, hooks: HookRegistry) -> Self {
1155        self.kernel_builder = self.kernel_builder.hooks(hooks);
1156        self
1157    }
1158
1159    /// Seed the transcript with messages from a previous session.
1160    ///
1161    /// These messages are placed after any system prompt, before the
1162    /// first user turn. Use this to resume an existing conversation.
1163    pub fn seed_transcript(mut self, messages: Vec<Message>) -> Self {
1164        self.seed = messages;
1165        self
1166    }
1167
1168    /// Set an optional permission hook for tool-call interception.
1169    pub fn permission_hook(mut self, hook: Arc<dyn PermissionHook>) -> Self {
1170        self.permission_hook = Some(hook);
1171        self
1172    }
1173
1174    /// Set the event sink for streaming events (optional, defaults to [`NullSink`]).
1175    pub fn event_sink(mut self, sink: Arc<dyn EventSink>) -> Self {
1176        self.saved_event_sink = Some(sink);
1177        self
1178    }
1179
1180    /// Set the cancellation token for graceful shutdown. When the token
1181    /// is cancelled, the runtime's underlying kernel terminates the
1182    /// step loop with
1183    /// [`FinishReason::Cancelled`](crate::agent::FinishReason::Cancelled)
1184    /// at the next step boundary.
1185    pub fn shutdown_token(mut self, token: tokio_util::sync::CancellationToken) -> Self {
1186        self.kernel_builder = self.kernel_builder.shutdown_token(token);
1187        self
1188    }
1189
1190    /// Build the [`AgentRuntime`].
1191    ///
1192    /// Returns an error if the LLM provider is missing.
1193    pub fn build(self) -> Result<AgentRuntime> {
1194        let mut kernel = self.kernel_builder.build()?;
1195
1196        let mut transcript = Vec::new();
1197        if let Some(sys) = self.system_prompt {
1198            transcript.push(Message::system(sys));
1199        }
1200        transcript.extend(self.seed);
1201
1202        let event_sink: Arc<dyn EventSink> =
1203            self.saved_event_sink.unwrap_or_else(|| Arc::new(NullSink));
1204
1205        // Goal-167: create the shared todo list and register a properly-sinked
1206        // TodoWriteTool, overriding the NullSink version from build_standard_tools.
1207        let todo_list = Arc::new(RwLock::new(Vec::<TodoItem>::new()));
1208        kernel.tools_mut().register_mut(Arc::new(TodoWriteTool::new(
1209            todo_list.clone(),
1210            event_sink.clone(),
1211        )));
1212
1213        // Goal-165: create the plan approval gate and register the plan mode tools.
1214        let plan_approval_gate = Arc::new(PlanApprovalGate::new());
1215        // Goal-190: pass permissions config to plan mode tools so they can
1216        // report which tools are in Plan mode.
1217        let permissions_arc = kernel.tools().permissions_config().map(Arc::new);
1218        kernel.tools_mut().register_mut({
1219            let mut tool = EnterPlanModeTool::new(plan_approval_gate.clone());
1220            if let Some(ref perms) = permissions_arc {
1221                tool = tool.with_permissions(perms.clone());
1222            }
1223            Arc::new(tool)
1224        });
1225        kernel.tools_mut().register_mut({
1226            let mut tool = ExitPlanModeTool::new(plan_approval_gate.clone(), event_sink.clone());
1227            if let Some(ref perms) = permissions_arc {
1228                tool = tool.with_permissions(perms.clone());
1229            }
1230            Arc::new(tool)
1231        });
1232
1233        // Goal-202: create the plan-mode pre-confirmation gate and register
1234        // the request_plan_mode tool (TUI / HTTP only, same as plan mode tools).
1235        let plan_mode_request_gate = Arc::new(PlanModeRequestGate::new());
1236        kernel
1237            .tools_mut()
1238            .register_mut(Arc::new(RequestPlanModeTool::new(
1239                plan_mode_request_gate.clone(),
1240                event_sink.clone(),
1241            )));
1242
1243        Ok(AgentRuntime {
1244            kernel,
1245            transcript,
1246            event_sink,
1247            pending_plan_calls: None,
1248            plan_confirmed: false,
1249            streaming: self.streaming,
1250            permission_hook: self.permission_hook,
1251            planning_mode: self.planning_mode,
1252            compactor: self.compactor,
1253            checkpoints: CheckpointState::disabled(),
1254            todo_list,
1255            plan_approval_gate,
1256            plan_mode_request_gate,
1257            goal_state: Arc::new(RwLock::new(None)),
1258            message_queue: std::collections::VecDeque::new(),
1259            deferred_turn_finished: None,
1260        })
1261    }
1262}
1263
1264// ──────────────────────────────────────────────────────────────────────────
1265// Tests
1266// ──────────────────────────────────────────────────────────────────────────
1267
1268#[cfg(test)]
1269mod tests {
1270    use super::*;
1271    use crate::llm::{Completion, MockProvider};
1272    use crate::tools::Tool;
1273    use async_trait::async_trait;
1274    use serde_json::{json, Value};
1275
1276    struct Adder;
1277
1278    #[async_trait]
1279    impl Tool for Adder {
1280        fn spec(&self) -> crate::llm::ToolSpec {
1281            crate::llm::ToolSpec {
1282                name: "add".into(),
1283                description: "add two numbers".into(),
1284                parameters: json!({"type":"object","properties":{"a":{"type":"integer"},"b":{"type":"integer"}}}),
1285            }
1286        }
1287        async fn execute(&self, args: Value) -> crate::error::Result<String> {
1288            let a = args["a"].as_i64().unwrap_or(0);
1289            let b = args["b"].as_i64().unwrap_or(0);
1290            Ok((a + b).to_string())
1291        }
1292    }
1293
1294    // ── basic turn execution ──────────────────────────────────────────
1295
1296    #[tokio::test]
1297    async fn single_turn_no_tools() {
1298        let llm = Arc::new(MockProvider::new(vec![Completion {
1299            content: "Hello!".into(),
1300            tool_calls: vec![],
1301            finish_reason: Some("stop".into()),
1302            usage: None,
1303            reasoning_content: None,
1304        }]));
1305        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1306        let out = rt.run("hi").await.unwrap();
1307        assert_eq!(out.final_text.as_deref(), Some("Hello!"));
1308        assert_eq!(out.steps, 1);
1309        assert_eq!(rt.transcript().len(), 2); // user + assistant
1310    }
1311
1312    #[tokio::test]
1313    async fn turn_with_tool() {
1314        let llm = Arc::new(MockProvider::new(vec![
1315            Completion {
1316                content: "Let me check...".into(),
1317                tool_calls: vec![crate::llm::ToolCall {
1318                    id: "c1".into(),
1319                    name: "add".into(),
1320                    arguments: json!({"a": 3, "b": 4}),
1321                }],
1322                finish_reason: Some("tool_calls".into()),
1323                usage: None,
1324                reasoning_content: None,
1325            },
1326            Completion {
1327                content: "7".into(),
1328                tool_calls: vec![],
1329                finish_reason: Some("stop".into()),
1330                usage: None,
1331                reasoning_content: None,
1332            },
1333        ]));
1334        let tools = ToolRegistry::local().register(Arc::new(Adder));
1335        let mut rt = AgentRuntime::builder()
1336            .llm(llm)
1337            .tools(tools)
1338            .build()
1339            .unwrap();
1340        let out = rt.run("3+4?").await.unwrap();
1341        assert_eq!(out.final_text.as_deref(), Some("7"));
1342        assert_eq!(out.steps, 2);
1343        assert_eq!(rt.transcript().len(), 4); // user, assistant, tool, assistant
1344    }
1345
1346    // ── transcript accumulation across turns ──────────────────────────
1347
1348    #[tokio::test]
1349    async fn multi_turn_transcript_grows() {
1350        let llm = Arc::new(MockProvider::new(vec![
1351            Completion {
1352                content: "First reply".into(),
1353                tool_calls: vec![],
1354                finish_reason: Some("stop".into()),
1355                usage: None,
1356                reasoning_content: None,
1357            },
1358            Completion {
1359                content: "Second reply".into(),
1360                tool_calls: vec![],
1361                finish_reason: Some("stop".into()),
1362                usage: None,
1363                reasoning_content: None,
1364            },
1365        ]));
1366        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1367
1368        let o1 = rt.run("turn 1").await.unwrap();
1369        assert_eq!(o1.final_text.as_deref(), Some("First reply"));
1370        assert_eq!(rt.transcript().len(), 2);
1371
1372        let o2 = rt.run("turn 2").await.unwrap();
1373        assert_eq!(o2.final_text.as_deref(), Some("Second reply"));
1374        assert_eq!(rt.transcript().len(), 4);
1375    }
1376
1377    // ── builder options ───────────────────────────────────────────────
1378
1379    #[tokio::test]
1380    async fn system_prompt_is_prepended() {
1381        let llm = Arc::new(MockProvider::new(vec![Completion {
1382            content: "ok".into(),
1383            tool_calls: vec![],
1384            finish_reason: Some("stop".into()),
1385            usage: None,
1386            reasoning_content: None,
1387        }]));
1388        let mut rt = AgentRuntime::builder()
1389            .llm(llm)
1390            .system_prompt("Be helpful.")
1391            .build()
1392            .unwrap();
1393        rt.run("hello").await.unwrap();
1394        assert_eq!(rt.transcript()[0].role, crate::message::Role::System);
1395        assert_eq!(rt.transcript()[0].content, "Be helpful.");
1396    }
1397
1398    #[tokio::test]
1399    async fn seed_transcript_is_included() {
1400        let seed = vec![
1401            Message::user("old Q".to_string()),
1402            Message::assistant("old A".to_string()),
1403        ];
1404        let llm = Arc::new(MockProvider::new(vec![Completion {
1405            content: "fresh".into(),
1406            tool_calls: vec![],
1407            finish_reason: Some("stop".into()),
1408            usage: None,
1409            reasoning_content: None,
1410        }]));
1411        let mut rt = AgentRuntime::builder()
1412            .llm(llm)
1413            .seed_transcript(seed)
1414            .build()
1415            .unwrap();
1416        rt.run("new Q").await.unwrap();
1417        // seed(2) + new user + new assistant = 4
1418        assert_eq!(rt.transcript().len(), 4);
1419        assert_eq!(rt.transcript()[0].content, "old Q");
1420        assert_eq!(rt.transcript()[1].content, "old A");
1421        assert_eq!(rt.transcript()[2].content, "new Q");
1422        assert_eq!(rt.transcript()[3].content, "fresh");
1423    }
1424
1425    #[tokio::test]
1426    async fn system_and_seed_ordering() {
1427        let seed = vec![Message::user("seeded user".to_string())];
1428        let llm = Arc::new(MockProvider::new(vec![Completion {
1429            content: "r".into(),
1430            tool_calls: vec![],
1431            finish_reason: Some("stop".into()),
1432            usage: None,
1433            reasoning_content: None,
1434        }]));
1435        let mut rt = AgentRuntime::builder()
1436            .llm(llm)
1437            .system_prompt("sys prompt")
1438            .seed_transcript(seed)
1439            .build()
1440            .unwrap();
1441        rt.run("real").await.unwrap();
1442        assert_eq!(rt.transcript()[0].role, crate::message::Role::System);
1443        assert_eq!(rt.transcript()[0].content, "sys prompt");
1444        assert_eq!(rt.transcript()[1].content, "seeded user");
1445        assert_eq!(rt.transcript()[2].content, "real");
1446    }
1447
1448    // ── state inspection / mutation ───────────────────────────────────
1449
1450    #[tokio::test]
1451    async fn set_transcript_replaces() {
1452        let llm = Arc::new(MockProvider::new(vec![Completion {
1453            content: "ok".into(),
1454            tool_calls: vec![],
1455            finish_reason: Some("stop".into()),
1456            usage: None,
1457            reasoning_content: None,
1458        }]));
1459        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1460        rt.set_transcript(vec![Message::user("custom".to_string())]);
1461        assert_eq!(rt.transcript().len(), 1);
1462        assert_eq!(rt.transcript()[0].content, "custom");
1463    }
1464
1465    #[tokio::test]
1466    async fn kernel_accessor_works() {
1467        let llm = Arc::new(MockProvider::new(vec![Completion {
1468            content: "ok".into(),
1469            tool_calls: vec![],
1470            finish_reason: Some("stop".into()),
1471            usage: None,
1472            reasoning_content: None,
1473        }]));
1474        let rt = AgentRuntime::builder().llm(llm).build().unwrap();
1475        let _kernel = rt.kernel(); // should compile and return a reference
1476    }
1477
1478    // ── default values ────────────────────────────────────────────────
1479
1480    #[tokio::test]
1481    async fn defaults_are_sensible() {
1482        let llm = Arc::new(MockProvider::new(vec![Completion {
1483            content: "done".into(),
1484            tool_calls: vec![],
1485            finish_reason: Some("stop".into()),
1486            usage: None,
1487            reasoning_content: None,
1488        }]));
1489        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1490        let out = rt.run("test").await.unwrap();
1491        assert_eq!(out.finish_reason, FinishReason::NoMoreToolCalls);
1492        assert_eq!(rt.transcript().len(), 2);
1493    }
1494
1495    // ── checkpoint integration ────────────────────────────────────────
1496
1497    fn has_git() -> bool {
1498        std::process::Command::new("git")
1499            .arg("--version")
1500            .output()
1501            .is_ok()
1502    }
1503
1504    /// Workspace tempdir + sibling shadow tempdir, both alive together.
1505    /// Tests open `ShadowRepo::open_at(...)` against `shadow_dir()` to
1506    /// avoid touching `paths::user_data_dir()` and the global env lock.
1507    struct ShadowWs {
1508        workspace: tempfile::TempDir,
1509        shadow: tempfile::TempDir,
1510    }
1511
1512    impl ShadowWs {
1513        fn path(&self) -> &std::path::Path {
1514            self.workspace.path()
1515        }
1516        fn shadow_dir(&self) -> std::path::PathBuf {
1517            self.shadow.path().join("shadow-git")
1518        }
1519    }
1520
1521    fn shadow_ws() -> ShadowWs {
1522        ShadowWs {
1523            workspace: tempfile::tempdir().expect("workspace tempdir"),
1524            shadow: tempfile::tempdir().expect("shadow tempdir"),
1525        }
1526    }
1527
1528    #[tokio::test]
1529    async fn runtime_snapshots_at_turn_boundaries() {
1530        if !has_git() {
1531            return;
1532        }
1533        let dir = shadow_ws();
1534        std::fs::write(dir.path().join("seed.txt"), "v0").unwrap();
1535
1536        let llm = Arc::new(MockProvider::new(vec![
1537            Completion {
1538                content: "ok".into(),
1539                tool_calls: vec![],
1540                finish_reason: Some("stop".into()),
1541                usage: None,
1542                reasoning_content: None,
1543            },
1544            Completion {
1545                content: "ok2".into(),
1546                tool_calls: vec![],
1547                finish_reason: Some("stop".into()),
1548                usage: None,
1549                reasoning_content: None,
1550            },
1551        ]));
1552        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1553
1554        let shadow = Arc::new(crate::ShadowRepo::open_at(dir.path(), dir.shadow_dir()).unwrap());
1555        let log_path = dir.path().join("checkpoints.jsonl");
1556        rt.enable_checkpoints(shadow.clone(), "sess", log_path.clone(), None)
1557            .unwrap();
1558
1559        let o1 = rt.run("turn 0").await.unwrap();
1560        assert!(o1.checkpoint_id.is_some());
1561        let o2 = rt.run("turn 1").await.unwrap();
1562        assert!(o2.checkpoint_id.is_some());
1563        assert_ne!(o1.checkpoint_id, o2.checkpoint_id);
1564
1565        let recs = crate::read_checkpoint_log(&log_path).unwrap();
1566        assert_eq!(recs.len(), 2);
1567        assert_eq!(recs[0].turn, 0);
1568        assert_eq!(recs[1].turn, 1);
1569        // pre exists for both turns (post-snapshot may or may not differ).
1570        assert!(recs[0].pre.is_some());
1571        assert!(recs[1].pre.is_some());
1572    }
1573
1574    #[tokio::test]
1575    async fn runtime_records_touched_files_for_write_file() {
1576        if !has_git() {
1577            return;
1578        }
1579        let dir = shadow_ws();
1580        // Provider plans one write_file then ends.
1581        let llm = Arc::new(MockProvider::new(vec![
1582            Completion {
1583                content: "writing".into(),
1584                tool_calls: vec![crate::llm::ToolCall {
1585                    id: "c1".into(),
1586                    name: "write_file".into(),
1587                    arguments: json!({"path": "out.txt", "contents": "hello"}),
1588                }],
1589                finish_reason: Some("tool_calls".into()),
1590                usage: None,
1591                reasoning_content: None,
1592            },
1593            Completion {
1594                content: "done".into(),
1595                tool_calls: vec![],
1596                finish_reason: Some("stop".into()),
1597                usage: None,
1598                reasoning_content: None,
1599            },
1600        ]));
1601
1602        let touched = Arc::new(Mutex::new(TouchedFiles::new()));
1603        let tools = ToolRegistry::local()
1604            .register(Arc::new(crate::tools::WriteFile::new(dir.path())))
1605            .with_touched_files(touched.clone());
1606
1607        let mut rt = AgentRuntime::builder()
1608            .llm(llm)
1609            .tools(tools)
1610            .build()
1611            .unwrap();
1612        let shadow = Arc::new(crate::ShadowRepo::open_at(dir.path(), dir.shadow_dir()).unwrap());
1613        let log_path = dir.path().join("checkpoints.jsonl");
1614        rt.enable_checkpoints(shadow, "sess", log_path.clone(), Some(touched))
1615            .unwrap();
1616
1617        let _ = rt.run("please write").await.unwrap();
1618
1619        let recs = crate::read_checkpoint_log(&log_path).unwrap();
1620        assert_eq!(recs.len(), 1);
1621        assert_eq!(recs[0].touched_files, vec!["out.txt".to_string()]);
1622        assert_eq!(recs[0].touched_via, crate::TouchedVia::Structured);
1623    }
1624
1625    #[tokio::test]
1626    #[cfg_attr(target_os = "windows", ignore)]
1627    async fn runtime_falls_back_to_diff_for_run_shell() {
1628        if !has_git() {
1629            return;
1630        }
1631        let dir = shadow_ws();
1632
1633        let llm = Arc::new(MockProvider::new(vec![
1634            Completion {
1635                content: "shelling".into(),
1636                tool_calls: vec![crate::llm::ToolCall {
1637                    id: "c1".into(),
1638                    name: "run_shell".into(),
1639                    arguments: json!({"command": "echo created > made.txt"}),
1640                }],
1641                finish_reason: Some("tool_calls".into()),
1642                usage: None,
1643                reasoning_content: None,
1644            },
1645            Completion {
1646                content: "done".into(),
1647                tool_calls: vec![],
1648                finish_reason: Some("stop".into()),
1649                usage: None,
1650                reasoning_content: None,
1651            },
1652        ]));
1653
1654        let touched = Arc::new(Mutex::new(TouchedFiles::new()));
1655        let tools = ToolRegistry::local()
1656            .register(Arc::new(crate::tools::RunShell::new(dir.path())))
1657            .with_touched_files(touched.clone());
1658
1659        let mut rt = AgentRuntime::builder()
1660            .llm(llm)
1661            .tools(tools)
1662            .build()
1663            .unwrap();
1664        let shadow = Arc::new(crate::ShadowRepo::open_at(dir.path(), dir.shadow_dir()).unwrap());
1665        let log_path = dir.path().join("checkpoints.jsonl");
1666        rt.enable_checkpoints(shadow, "sess", log_path.clone(), Some(touched))
1667            .unwrap();
1668
1669        let _ = rt.run("please make a file").await.unwrap();
1670
1671        let recs = crate::read_checkpoint_log(&log_path).unwrap();
1672        assert_eq!(recs.len(), 1);
1673        assert_eq!(recs[0].touched_via, crate::TouchedVia::ShellDiff);
1674        // The shell created made.txt which should be in the diff.
1675        assert!(
1676            recs[0].touched_files.iter().any(|p| p == "made.txt"),
1677            "expected made.txt in touched_files, got {:?}",
1678            recs[0].touched_files
1679        );
1680    }
1681
1682    #[tokio::test]
1683    async fn runtime_works_when_checkpoints_disabled() {
1684        // No call to enable_checkpoints → outcome.checkpoint_id is None,
1685        // no log file created.
1686        let llm = Arc::new(MockProvider::new(vec![Completion {
1687            content: "ok".into(),
1688            tool_calls: vec![],
1689            finish_reason: Some("stop".into()),
1690            usage: None,
1691            reasoning_content: None,
1692        }]));
1693        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1694        let out = rt.run("hi").await.unwrap();
1695        assert!(out.checkpoint_id.is_none());
1696        assert!(!rt.checkpoints_enabled());
1697    }
1698
1699    // ── compact_now / set_planning_mode (Goal 146) ────────────────────
1700
1701    #[tokio::test]
1702    async fn compact_now_invokes_compactor() {
1703        // Provider used (a) to answer two normal turns, (b) to answer
1704        // the compactor's "summarize" call.
1705        let llm = Arc::new(MockProvider::new(vec![
1706            Completion {
1707                content: "first reply".into(),
1708                tool_calls: vec![],
1709                finish_reason: Some("stop".into()),
1710                usage: None,
1711                reasoning_content: None,
1712            },
1713            Completion {
1714                content: "second reply".into(),
1715                tool_calls: vec![],
1716                finish_reason: Some("stop".into()),
1717                usage: None,
1718                reasoning_content: None,
1719            },
1720            Completion {
1721                content: "compacted summary".into(),
1722                tool_calls: vec![],
1723                finish_reason: Some("stop".into()),
1724                usage: None,
1725                reasoning_content: None,
1726            },
1727        ]));
1728        // Threshold = MAX so the auto-compaction in `run` never fires;
1729        // keep_recent_n=1 so we only need 3 messages before compact_now
1730        // has work to do.
1731        let compactor = crate::compact::Compactor::new(usize::MAX).keep_recent_n(1);
1732        let mut rt = AgentRuntime::builder()
1733            .llm(llm)
1734            .compactor(compactor)
1735            .build()
1736            .unwrap();
1737        rt.run("turn 1").await.unwrap();
1738        rt.run("turn 2").await.unwrap();
1739        let len_before = rt.transcript().len();
1740        assert!(len_before >= 3, "expected ≥3 messages, got {len_before}");
1741
1742        rt.compact_now().await.unwrap();
1743        // The compactor replaces older messages with one summary
1744        // system message plus keep_recent_n=1 verbatim message.
1745        assert_eq!(rt.transcript().len(), 2);
1746        assert_eq!(rt.transcript()[0].role, crate::message::Role::System);
1747        assert!(rt.transcript()[0].content.starts_with("[compacted:"));
1748    }
1749
1750    #[tokio::test]
1751    async fn compact_now_is_noop_without_compactor() {
1752        let llm = Arc::new(MockProvider::new(vec![Completion {
1753            content: "x".into(),
1754            tool_calls: vec![],
1755            finish_reason: Some("stop".into()),
1756            usage: None,
1757            reasoning_content: None,
1758        }]));
1759        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1760        rt.run("hi").await.unwrap();
1761        let before = rt.transcript().len();
1762        rt.compact_now().await.unwrap();
1763        assert_eq!(rt.transcript().len(), before);
1764    }
1765
1766    #[tokio::test]
1767    async fn set_planning_mode_updates_field() {
1768        let llm = Arc::new(MockProvider::new(vec![Completion {
1769            content: "ok".into(),
1770            tool_calls: vec![],
1771            finish_reason: Some("stop".into()),
1772            usage: None,
1773            reasoning_content: None,
1774        }]));
1775        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1776        // Default is Immediate.
1777        assert_eq!(rt.planning_mode, PlanningMode::Immediate);
1778        rt.set_planning_mode(PlanningMode::PlanFirst);
1779        assert_eq!(rt.planning_mode, PlanningMode::PlanFirst);
1780        rt.set_planning_mode(PlanningMode::Immediate);
1781        assert_eq!(rt.planning_mode, PlanningMode::Immediate);
1782    }
1783
1784    // ── Goal-168: GoalState / GoalEvaluator / run_goal_loop tests ──────────
1785
1786    #[tokio::test]
1787    async fn set_goal_stores_state() {
1788        let llm = Arc::new(MockProvider::new(vec![]));
1789        let rt = AgentRuntime::builder().llm(llm).build().unwrap();
1790        assert!(rt.current_goal().is_none());
1791        rt.set_goal("task is done".to_string(), 10).await;
1792        let g = rt.current_goal().expect("goal should be set");
1793        assert_eq!(g.condition, "task is done");
1794        assert_eq!(g.max_turns, 10);
1795        assert_eq!(g.turns, 0);
1796        assert_eq!(g.status, GoalStatus::Pursuing);
1797    }
1798
1799    #[tokio::test]
1800    async fn clear_goal_removes_state() {
1801        let llm = Arc::new(MockProvider::new(vec![]));
1802        let rt = AgentRuntime::builder().llm(llm).build().unwrap();
1803        rt.set_goal("anything".to_string(), 5).await;
1804        assert!(rt.current_goal().is_some());
1805        rt.clear_goal().await;
1806        assert!(rt.current_goal().is_none());
1807    }
1808
1809    #[tokio::test]
1810    async fn goal_status_default_is_pursuing() {
1811        let g = GoalState {
1812            condition: "done".to_string(),
1813            status: GoalStatus::Pursuing,
1814            turns: 0,
1815            max_turns: 20,
1816            last_reason: None,
1817        };
1818        assert_eq!(g.status, GoalStatus::Pursuing);
1819        assert_eq!(g.turns, 0);
1820        assert!(g.last_reason.is_none());
1821    }
1822
1823    #[tokio::test]
1824    async fn goal_evaluator_returns_achieved_on_yes_response() {
1825        // Mock a provider that returns "YES\nLooks complete."
1826        let llm = Arc::new(MockProvider::new(vec![Completion {
1827            content: "YES\nLooks complete.".into(),
1828            tool_calls: vec![],
1829            finish_reason: Some("stop".into()),
1830            usage: None,
1831            reasoning_content: None,
1832        }]));
1833        let evaluator = GoalEvaluator::new(llm);
1834        let msgs = vec![crate::message::Message::user("I completed the task.")];
1835        let verdict = evaluator
1836            .evaluate("task is done", &msgs)
1837            .await
1838            .expect("evaluate should succeed");
1839        assert!(verdict.achieved);
1840        assert!(!verdict.reason.is_empty());
1841    }
1842
1843    #[tokio::test]
1844    async fn goal_evaluator_returns_not_achieved_on_no_response() {
1845        let llm = Arc::new(MockProvider::new(vec![Completion {
1846            content: "NO\nStill in progress.".into(),
1847            tool_calls: vec![],
1848            finish_reason: Some("stop".into()),
1849            usage: None,
1850            reasoning_content: None,
1851        }]));
1852        let evaluator = GoalEvaluator::new(llm);
1853        let msgs = vec![crate::message::Message::user("I started the task.")];
1854        let verdict = evaluator
1855            .evaluate("task is done", &msgs)
1856            .await
1857            .expect("evaluate should succeed");
1858        assert!(!verdict.achieved);
1859    }
1860
1861    #[tokio::test]
1862    async fn goal_evaluator_tolerates_empty_transcript() {
1863        let llm = Arc::new(MockProvider::new(vec![Completion {
1864            content: "YES\nEmpty transcript but condition trivially met.".into(),
1865            tool_calls: vec![],
1866            finish_reason: Some("stop".into()),
1867            usage: None,
1868            reasoning_content: None,
1869        }]));
1870        let evaluator = GoalEvaluator::new(llm);
1871        let verdict = evaluator
1872            .evaluate("anything", &[])
1873            .await
1874            .expect("should not error on empty transcript");
1875        assert!(verdict.achieved);
1876    }
1877
1878    #[tokio::test]
1879    async fn run_goal_loop_stops_when_achieved() {
1880        // Provider: first call for the agent turn, second for the judge.
1881        let llm = Arc::new(MockProvider::new(vec![
1882            Completion {
1883                content: "I wrote the greeting.".into(),
1884                tool_calls: vec![],
1885                finish_reason: Some("stop".into()),
1886                usage: None,
1887                reasoning_content: None,
1888            },
1889            Completion {
1890                content: "YES\nGreeting was written.".into(),
1891                tool_calls: vec![],
1892                finish_reason: Some("stop".into()),
1893                usage: None,
1894                reasoning_content: None,
1895            },
1896        ]));
1897        let null_sink = Arc::new(crate::event::NullSink);
1898        let mut rt = AgentRuntime::builder()
1899            .llm(llm)
1900            .event_sink(null_sink)
1901            .build()
1902            .unwrap();
1903        let _ = rt
1904            .run_goal_loop("write a greeting", "write a greeting", 5)
1905            .await;
1906        // Goal should be cleared after achievement.
1907        assert!(rt.current_goal().is_none());
1908    }
1909
1910    #[tokio::test]
1911    async fn run_goal_loop_stops_at_max_turns() {
1912        // Provider: every judge call returns NO → loop hits max_turns.
1913        let completions: Vec<Completion> = (0..20)
1914            .flat_map(|_| {
1915                vec![
1916                    Completion {
1917                        content: "still working".into(),
1918                        tool_calls: vec![],
1919                        finish_reason: Some("stop".into()),
1920                        usage: None,
1921                        reasoning_content: None,
1922                    },
1923                    Completion {
1924                        content: "NO\nNot done yet.".into(),
1925                        tool_calls: vec![],
1926                        finish_reason: Some("stop".into()),
1927                        usage: None,
1928                        reasoning_content: None,
1929                    },
1930                ]
1931            })
1932            .collect();
1933        let llm = Arc::new(MockProvider::new(completions));
1934        let null_sink = Arc::new(crate::event::NullSink);
1935        let mut rt = AgentRuntime::builder()
1936            .llm(llm)
1937            .event_sink(null_sink)
1938            .build()
1939            .unwrap();
1940        // max_turns=2 so we stop after 2 regardless.
1941        let _ = rt
1942            .run_goal_loop("start on impossible task", "impossible task", 2)
1943            .await;
1944        // Goal should be cleared after budget exhaustion.
1945        assert!(rt.current_goal().is_none());
1946    }
1947
1948    #[tokio::test]
1949    async fn goal_serde_round_trip() {
1950        let g = GoalState {
1951            condition: "file written".to_string(),
1952            status: GoalStatus::Achieved,
1953            turns: 3,
1954            max_turns: 10,
1955            last_reason: Some("File was created.".to_string()),
1956        };
1957        let json = serde_json::to_string(&g).expect("serialize");
1958        let g2: GoalState = serde_json::from_str(&json).expect("deserialize");
1959        assert_eq!(g2.condition, g.condition);
1960        assert_eq!(g2.status, GoalStatus::Achieved);
1961        assert_eq!(g2.turns, 3);
1962        assert_eq!(g2.last_reason, Some("File was created.".to_string()));
1963    }
1964
1965    #[tokio::test]
1966    async fn multiple_set_goal_calls_overwrite_state() {
1967        let llm = Arc::new(MockProvider::new(vec![]));
1968        let rt = AgentRuntime::builder().llm(llm).build().unwrap();
1969        rt.set_goal("first goal".to_string(), 5).await;
1970        rt.set_goal("second goal".to_string(), 15).await;
1971        let g = rt.current_goal().unwrap();
1972        assert_eq!(g.condition, "second goal");
1973        assert_eq!(g.max_turns, 15);
1974    }
1975
1976    // ── Goal-181: message queue ───────────────────────────────────────────
1977
1978    #[tokio::test]
1979    async fn enqueue_processes_single_message() {
1980        let llm = Arc::new(MockProvider::new(vec![Completion {
1981            content: "queued reply".into(),
1982            tool_calls: vec![],
1983            finish_reason: Some("stop".into()),
1984            usage: None,
1985            reasoning_content: None,
1986        }]));
1987        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1988        let out = rt.enqueue("hello from queue").await.unwrap();
1989        assert!(out.is_some());
1990        assert_eq!(out.unwrap().final_text.as_deref(), Some("queued reply"));
1991        assert_eq!(rt.transcript().len(), 2);
1992    }
1993
1994    #[tokio::test]
1995    async fn enqueue_drains_multiple_messages_in_order() {
1996        let llm = Arc::new(MockProvider::new(vec![
1997            Completion {
1998                content: "reply A".into(),
1999                tool_calls: vec![],
2000                finish_reason: Some("stop".into()),
2001                usage: None,
2002                reasoning_content: None,
2003            },
2004            Completion {
2005                content: "reply B".into(),
2006                tool_calls: vec![],
2007                finish_reason: Some("stop".into()),
2008                usage: None,
2009                reasoning_content: None,
2010            },
2011        ]));
2012        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
2013        // Push two messages directly into the queue to simulate concurrent enqueue.
2014        rt.message_queue.push_back("msg A".into());
2015        rt.message_queue.push_back("msg B".into());
2016        let last = rt.drain_queue().await.unwrap();
2017        assert_eq!(last.unwrap().final_text.as_deref(), Some("reply B"));
2018        // Both user messages + both assistant replies are in transcript.
2019        assert_eq!(rt.transcript().len(), 4);
2020    }
2021
2022    #[test]
2023    fn queue_len_reflects_pending_messages() {
2024        let llm = Arc::new(MockProvider::new(vec![]));
2025        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
2026        assert_eq!(rt.queue_len(), 0);
2027        rt.message_queue.push_back("pending".into());
2028        assert_eq!(rt.queue_len(), 1);
2029        rt.message_queue.push_back("also pending".into());
2030        assert_eq!(rt.queue_len(), 2);
2031    }
2032
2033    // ── Goal-201: plan mode tools are registered by the runtime builder ──
2034
2035    #[test]
2036    fn runtime_builder_has_plan_mode_tools() {
2037        // AgentRuntimeBuilder::build() must register enter_plan_mode and
2038        // exit_plan_mode with the real PlanApprovalGate and EventSink.
2039        // These are channel capabilities used by the TUI and HTTP paths.
2040        let llm = Arc::new(MockProvider::new(vec![]));
2041        let rt = AgentRuntime::builder().llm(llm).build().unwrap();
2042        let tools = rt.kernel.tools();
2043        assert!(
2044            tools.get("enter_plan_mode").is_some(),
2045            "enter_plan_mode must be registered by AgentRuntimeBuilder"
2046        );
2047        assert!(
2048            tools.get("exit_plan_mode").is_some(),
2049            "exit_plan_mode must be registered by AgentRuntimeBuilder"
2050        );
2051    }
2052}