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::atomic::Ordering;
24use std::sync::{Arc, Mutex, RwLock};
25
26use crate::agent::FinishReason;
27use crate::checkpoint::{CheckpointId, ShadowRepo};
28use crate::checkpoint_log::CheckpointLogWriter;
29use crate::compact::Compactor;
30use crate::error::Result;
31use crate::event::{AgentEvent, EventSink};
32use crate::hooks::HookEvent;
33use crate::kernel::{AgentKernel, TurnContext, TurnOutcome};
34use crate::llm::{ChatProvider, TokenUsage};
35use crate::message::Message;
36use crate::tools::plan_mode::{ExitPlanModeTool, PlanApprovalGate, PlanModeRequestGate};
37use crate::tools::{TodoItem, TodoWriteTool, TouchedFiles};
38
39// Sub-modules extracted to keep `runtime.rs` under the invariant #1 line
40// budget (see `tests/invariants/loop_size_orthogonality.rs`).
41mod builder;
42pub use builder::AgentRuntimeBuilder;
43
44mod checkpoint;
45pub(crate) use checkpoint::CheckpointState;
46
47// ──────────────────────────────────────────────────────────────────────────
48// Goal-168: GoalState / GoalStatus / GoalEvaluator
49// ──────────────────────────────────────────────────────────────────────────
50
51// Goal-loop data + judge live in `crate::runtime_goal`. Re-exported here so
52// historical paths like `crate::runtime::GoalState` keep working.
53pub use crate::runtime_goal::{GoalEvaluator, GoalState, GoalStatus, GoalVerdict};
54
55// ──────────────────────────────────────────────────────────────────────────
56// RuntimeOutcome
57// ──────────────────────────────────────────────────────────────────────────
58
59/// The result of a single [`AgentRuntime::run()`] turn.
60///
61/// Contains the model's final text (if any), how the turn ended,
62/// token usage for this turn, the number of LLM steps taken, and
63/// the LLM latency in milliseconds.
64#[derive(Debug, Clone)]
65pub struct RuntimeOutcome {
66    /// The final assistant text, if the model produced one.
67    pub final_text: Option<String>,
68    /// Why the turn stopped.
69    pub finish_reason: FinishReason,
70    /// Token usage for this turn only.
71    pub total_usage: TokenUsage,
72    /// Number of LLM calls made during this turn.
73    pub steps: usize,
74    /// Measured LLM latency for this turn (milliseconds).
75    pub llm_latency_ms: u64,
76    /// Checkpoint id captured at the end of this turn (if checkpointing
77    /// is enabled and the runtime is bound to a session).
78    pub checkpoint_id: Option<CheckpointId>,
79}
80
81impl From<TurnOutcome> for RuntimeOutcome {
82    fn from(t: TurnOutcome) -> Self {
83        Self {
84            final_text: t.final_text,
85            finish_reason: t.finish_reason,
86            total_usage: t.usage,
87            steps: t.steps,
88            llm_latency_ms: t.llm_latency_ms,
89            checkpoint_id: None,
90        }
91    }
92}
93
94// ──────────────────────────────────────────────────────────────────────────
95// SessionLifecycle
96// ──────────────────────────────────────────────────────────────────────────
97
98/// Session-lifecycle state. Currently a single `closed` flag set by
99/// `AgentRuntime::close()` to prevent duplicate `SessionEnd` events on
100/// repeat calls. Kept as a sub-struct so future session-scoped signals
101/// (last-activity timestamps, abort signals, etc.) have an obvious
102/// home without bloating `AgentRuntime`'s top-level field list.
103///
104/// Named `SessionLifecycle` (not `SessionState`) to avoid confusion with
105/// `crate::http::SessionState` and `agui_tui::app::SessionState`, which
106/// describe session *metadata* (id, prompt count, last-active timestamp)
107/// rather than the runtime's own lifecycle phase.
108struct SessionLifecycle {
109    closed: bool,
110}
111
112impl SessionLifecycle {
113    fn open() -> Self {
114        Self { closed: false }
115    }
116}
117
118// ──────────────────────────────────────────────────────────────────────────
119// AgentRuntime
120// ──────────────────────────────────────────────────────────────────────────
121
122/// A stateful agent runtime that wraps [`AgentKernel`].
123///
124/// `AgentRuntime` owns the conversation transcript and all cross-turn
125/// configuration. Each call to [`run`](AgentRuntime::run) appends a user
126/// message to the transcript, delegates to the kernel for one turn, and
127/// appends the kernel's new messages back to the transcript.
128pub struct AgentRuntime {
129    /// The stateless kernel that executes each turn.
130    kernel: AgentKernel,
131    /// Accumulated conversation transcript (shared via Arc for O(1) clone
132    /// when building TurnContext).
133    transcript: Arc<Vec<Message>>,
134    /// Event sink for streaming events (Arc for sharing with forwarder task).
135    event_sink: Arc<dyn EventSink>,
136    /// Goal M2-1: permission hook that flows into TurnContext so client
137    /// tools (AG-UI bridge) get denied before dispatch and surface as
138    /// interrupts to the frontend.
139    permission_hook: Option<Arc<dyn crate::tools::PermissionHook>>,
140    /// Whether to request streaming responses from the LLM.
141    streaming: bool,
142    /// Optional compactor for cross-turn transcript summarization.
143    compactor: Option<Compactor>,
144    /// Optional microcompactor for no-LLM proactive pruning of old tool
145    /// results by count at cross-turn boundaries.
146    microcompactor: Option<crate::compact::Microcompactor>,
147    /// Goal-331: consecutive proactive compaction failures (cross-turn).
148    /// Same semantics as `RunCore::consecutive_compact_failures`.
149    consecutive_compact_failures: u32,
150    /// Checkpoint subsystem (snapshot, session-id, writer, touched-files).
151    checkpoints: CheckpointState,
152    /// Session-lifecycle signals (close flag, future per-session toggles).
153    /// See [`SessionLifecycle`] — kept small for now but is the natural home
154    /// for any new "set once at session start / flip once at close" state.
155    session: SessionLifecycle,
156    /// Goal-167: shared task-list state written by `todo_write` calls.
157    /// Read back via [`current_todos`](AgentRuntime::current_todos).
158    todo_list: Arc<RwLock<Vec<TodoItem>>>,
159    /// Goal-165: plan mode 2.0 gate — shared with `EnterPlanModeTool` and
160    /// `ExitPlanModeTool`. `confirm_plan` / `reject_plan` forward to it.
161    plan_approval_gate: Arc<PlanApprovalGate>,
162    /// Goal-202: pre-confirmation gate — shared with `RequestPlanModeTool`.
163    /// `approve_plan_mode_request` / `reject_plan_mode_request` forward here.
164    plan_mode_request_gate: Arc<PlanModeRequestGate>,
165    /// Goal-168: active goal state (set by `/goal`). `None` when no goal is active.
166    /// Use [`current_goal`], [`set_goal`], and [`clear_goal`] for all access.
167    goal_state: Arc<RwLock<Option<GoalState>>>,
168    /// Goal-181: FIFO queue of user messages waiting to be processed.
169    /// Callers use [`enqueue`](AgentRuntime::enqueue) instead of
170    /// [`run`](AgentRuntime::run) directly; the queue is drained in FIFO
171    /// order so that messages sent while a turn is in flight are processed
172    /// automatically when the current turn completes.
173    message_queue: std::collections::VecDeque<String>,
174    /// Deferred `TurnFinished` event held by `execute_kernel_turn` until
175    /// `emit_turn_messages` can flush it after all assistant messages.
176    deferred_turn_finished: Option<AgentEvent>,
177    /// Goal-291: number of most-recent transcript messages passed to the
178    /// goal-evaluator judge on each turn. Smaller values reduce judge cost;
179    /// larger values give the judge more context for long sessions.
180    /// Default 12. Set via [`AgentRuntimeBuilder::goal_eval_transcript_tail`].
181    goal_eval_transcript_tail: usize,
182    /// Goal-328: structured prompt segments for ContextBreakdown estimator.
183    prompt_segments: Option<crate::system_prompt::PromptSegments>,
184    /// Goal-334: file re-injector (recently-read files as System atts).
185    file_reinjector: Option<crate::compact::FileReinjector>,
186    /// Goal-335: skill re-injector (invoked skill bodies as System atts).
187    skill_reinjector: Option<crate::compact::SkillReinjector>,
188    /// Goal-340: plan/todo re-injector (pending plan + task list as System atts).
189    plan_todo_reinjector: Option<crate::compact::PlanTodoReinjector>,
190    /// Goal-338: turn index of the most recent compaction, used to detect
191    /// recompaction chains (compacting again within a few turns of the
192    /// previous compaction). `None` when no compaction has occurred yet.
193    last_compact_turn: Option<u32>,
194}
195
196impl std::fmt::Debug for AgentRuntime {
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        f.debug_struct("AgentRuntime")
199            .field("kernel", &self.kernel)
200            .field("transcript", &self.transcript)
201            .field("event_sink", &"<EventSink>")
202            .field("streaming", &self.streaming)
203            .field(
204                "todo_list",
205                &self.todo_list.read().map(|l| l.len()).unwrap_or(0),
206            )
207            .field(
208                "goal_state",
209                &self
210                    .goal_state
211                    .read()
212                    .ok()
213                    .and_then(|g| g.as_ref().map(|s| s.condition.clone())),
214            )
215            .field(
216                "deferred_turn_finished",
217                &self.deferred_turn_finished.as_ref().map(|_| "<event>"),
218            )
219            .field("goal_eval_transcript_tail", &self.goal_eval_transcript_tail)
220            .field("file_reinjector", &self.file_reinjector.is_some())
221            .field("skill_reinjector", &self.skill_reinjector.is_some())
222            .field("plan_todo_reinjector", &self.plan_todo_reinjector.is_some())
223            .finish()
224    }
225}
226
227impl AgentRuntime {
228    /// Create a new [`AgentRuntimeBuilder`].
229    pub fn builder() -> AgentRuntimeBuilder {
230        AgentRuntimeBuilder::new()
231    }
232
233    /// Run one turn with the given user text.
234    ///
235    /// Appends `Message::user(text)` to the transcript, delegates to the kernel,
236    /// appends the new messages back, and returns a [`RuntimeOutcome`].
237    ///
238    /// **Goal 284**: automatic pre/post checkpoints have been removed.
239    /// The agent must call `checkpoint_save` explicitly to record restore
240    /// points. `outcome.checkpoint_id` is always `None` here.
241    pub async fn run(&mut self, user_text: impl Into<String>) -> Result<RuntimeOutcome> {
242        let user_text = user_text.into();
243
244        let turn = self.checkpoints.turn_index.load(Ordering::Relaxed);
245        tracing::Span::current().record(
246            "session_id",
247            self.checkpoints.session_id.as_deref().unwrap_or(""),
248        );
249        tracing::debug!(
250            session_id = self.checkpoints.session_id.as_deref().unwrap_or(""),
251            turn,
252            "agent.turn: starting"
253        );
254
255        // SessionStart fires exactly once — at the beginning of the first turn.
256        if turn == 0 {
257            self.kernel
258                .hooks()
259                .dispatch(HookEvent::SessionStart { goal: &user_text });
260        }
261
262        self.reset_touched_files();
263        self.kernel.hooks().dispatch(HookEvent::UserPromptSubmit {
264            content: &user_text,
265        });
266        self.append_user_message(&user_text).await;
267
268        let turn_outcome = match self.execute_kernel_turn().await {
269            Ok(outcome) => outcome,
270            Err(e) if is_context_window_exceeded(&e) => {
271                // The LLM rejected the request because the transcript exceeded its
272                // context window.  Try to compact in-place (bypassing the normal
273                // threshold gate) and retry the turn once before propagating.
274                // The user message is already at the tail of `self.transcript`, so
275                // after compaction the shorter transcript still ends with it.
276                tracing::warn!(
277                    target: "recursive::agent",
278                    error = %e,
279                    "context window exceeded; attempting emergency compaction before retry"
280                );
281                if self.compact_on_overflow().await? {
282                    self.execute_kernel_turn().await?
283                } else {
284                    return Err(e);
285                }
286            }
287            Err(e) => return Err(e),
288        };
289        self.emit_turn_messages(&turn_outcome).await;
290        // Goal 289: cross-turn compaction runs AFTER the turn so the
291        // threshold check sees the full turn's growth (user + assistant +
292        // tool messages) rather than the pessimistic pre-turn size. One
293        // pass per turn covers the entire growth instead of firing
294        // reactively at the start of every turn.
295        //
296        // Use `last_prompt_tokens` — the single most-recent LLM call's
297        // prompt_tokens — rather than `usage.prompt_tokens` (the accumulated
298        // sum across all LLM calls in the turn). The accumulated sum grows
299        // proportionally to the number of tool-use steps in the turn, causing
300        // `should_compact` to fire prematurely on multi-step turns even when
301        // the actual context usage is well below the threshold.
302        //
303        // Pass the full TokenUsage so cache_hit_tokens / cache_miss_tokens
304        // land on the CompactionBoundary event (g336).
305        self.maybe_compact_cross_turn(&turn_outcome.usage).await?;
306
307        let outcome: RuntimeOutcome = turn_outcome.into();
308
309        tracing::info!(
310            steps = outcome.steps,
311            finish_reason = ?outcome.finish_reason,
312            "agent.turn: finished"
313        );
314        self.checkpoints.turn_index.fetch_add(1, Ordering::Relaxed);
315
316        Ok(outcome)
317    }
318
319    /// Signal that the session is permanently over and fire `SessionEnd`.
320    ///
321    /// Call this exactly once, after the last `run()` or `enqueue()` call, to
322    /// give hooks a chance to do post-session cleanup. Calling `run()` after
323    /// `close()` is safe but `SessionEnd` will not fire again.
324    pub async fn close(&mut self, last_outcome: Option<&RuntimeOutcome>) {
325        if self.session.closed {
326            return;
327        }
328        self.session.closed = true;
329        if let Some(outcome) = last_outcome {
330            if !matches!(outcome.finish_reason, FinishReason::Cancelled) {
331                self.kernel
332                    .hooks()
333                    .dispatch(HookEvent::SessionEnd { outcome });
334            }
335        }
336    }
337
338    /// Reset the touched-files collector at the start of a turn.
339    fn reset_touched_files(&self) {
340        if let Some(slot) = &self.checkpoints.touched_files {
341            if let Ok(mut t) = slot.lock() {
342                *t = TouchedFiles::new();
343            }
344        }
345    }
346
347    /// Append a user message to the transcript and emit `MessageAppended`.
348    async fn append_user_message(&mut self, user_text: &str) {
349        let user_msg = Message::user(user_text.to_string());
350        Arc::make_mut(&mut self.transcript).push(user_msg.clone());
351        self.event_sink
352            .emit(AgentEvent::MessageAppended {
353                message: user_msg,
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    ///
366    /// `last_usage` is the [`TokenUsage`] from the turn that just completed.
367    /// Its `prompt_tokens` field is the actual prompt token count reported by the
368    /// API — when non-zero and `compactor.threshold_prompt_tokens` is set, the
369    /// token-based check takes priority over the character estimate (more reliable
370    /// for CJK content where the 4-char/token assumption significantly
371    /// underestimates token density). The `cache_hit_tokens` /
372    /// `cache_miss_tokens` fields are forwarded to the emitted
373    /// `CompactionBoundary` event for cache-telemetry (g336).
374    pub async fn maybe_compact_cross_turn(&mut self, last_usage: &TokenUsage) -> Result<()> {
375        // Goal 333: run microcompact before the LLM-summary check so that
376        // count-based pruning of old tool results may drop the transcript
377        // below the compaction threshold, skipping the expensive summary.
378        if let Some(m) = &self.microcompactor {
379            let turn = self.checkpoints.turn_index.load(Ordering::Relaxed);
380            let pruned = m.prune(&mut *Arc::make_mut(&mut self.transcript));
381            if pruned > 0 {
382                self.event_sink
383                    .emit(AgentEvent::Microcompact { step: turn, pruned })
384                    .await;
385            }
386        }
387
388        let Some(ref compactor) = self.compactor else {
389            return Ok(());
390        };
391
392        // Circuit breaker: stop trying after too many consecutive failures.
393        if self.consecutive_compact_failures >= crate::compact::MAX_CONSECUTIVE_COMPACT_FAILURES {
394            self.event_sink
395                .emit(AgentEvent::CompactionSkipped {
396                    step: self.checkpoints.turn_index.load(Ordering::Relaxed),
397                    reason: crate::event::CompactionSkipReason::CircuitBreaker,
398                })
399                .await;
400            return Ok(());
401        }
402
403        let bytes = Compactor::estimate_bytes(&self.transcript);
404        if !compactor.should_compact(bytes, last_usage.prompt_tokens) {
405            return Ok(());
406        }
407        // Goal 345: only dispatch PreCompact when compaction will actually run,
408        // so PreCompact / PostCompact stay balanced (mirrors run_core's
409        // maybe_compact). Without this the degenerate-slice Ok(None) path
410        // fired PreCompact with no matching PostCompact.
411        if !compactor.would_compact(&self.transcript) {
412            return Ok(());
413        }
414        self.kernel.hooks().dispatch(HookEvent::PreCompact {
415            transcript_len: bytes,
416        });
417        // Snapshot pre-compact for file+skill reinjection (apply_to_transcript drains).
418        let pre_compact: Vec<Message> = self.transcript.iter().cloned().collect();
419        let result = compactor
420            .apply_to_transcript(
421                self.kernel.llm().as_ref(),
422                Arc::make_mut(&mut self.transcript),
423                self.checkpoints
424                    .turn_index
425                    .load(std::sync::atomic::Ordering::Relaxed),
426            )
427            .await;
428        match result {
429            Ok(Some((removed, summary_chars))) => {
430                // Success — reset the circuit breaker.
431                self.consecutive_compact_failures = 0;
432                self.kernel.hooks().dispatch(HookEvent::PostCompact {
433                    removed,
434                    summary_chars,
435                });
436                self.event_sink
437                    .emit(AgentEvent::CompactionBoundary {
438                        turn: self.checkpoints.turn_index.load(Ordering::Relaxed) as u32,
439                        compacted_count: removed,
440                        summary_uuid: None,
441                        cache_hit_tokens: last_usage.cache_hit_tokens,
442                        cache_miss_tokens: last_usage.cache_miss_tokens,
443                        is_recompaction_in_chain: self.last_compact_turn.is_some(),
444                        turns_since_previous_compact: match self.last_compact_turn {
445                            Some(prev) => {
446                                let current =
447                                    self.checkpoints.turn_index.load(Ordering::Relaxed) as u32;
448                                current.saturating_sub(prev)
449                            }
450                            None => 0,
451                        },
452                        previous_compact_turn: self.last_compact_turn,
453                    })
454                    .await;
455                self.last_compact_turn =
456                    Some(self.checkpoints.turn_index.load(Ordering::Relaxed) as u32);
457                if let Some(summary) = self.transcript.first().cloned() {
458                    self.event_sink
459                        .emit(AgentEvent::MessageAppended {
460                            message: summary,
461                            usage: None,
462                        })
463                        .await;
464                }
465                // Goal-334: re-inject recently-read files right after the summary.
466                // Transcript = [summary, <file-atts>, <skill-atts>, ...preserved].
467                if let Some(r) = &self.file_reinjector {
468                    // Capture the preserved tail BEFORE we start inserting, so the
469                    // slice indices stay valid. Summary is at index 0; preserved
470                    // messages follow.
471                    let preserved: Vec<_> = self.transcript.iter().skip(1).cloned().collect();
472                    let atts = r.reinject(&preserved);
473                    // Insert at index 1, shifting forward for final order.
474                    for (offset, att) in atts.into_iter().enumerate() {
475                        Arc::make_mut(&mut self.transcript).insert(1 + offset, att.clone());
476                        self.event_sink
477                            .emit(AgentEvent::MessageAppended {
478                                message: att,
479                                usage: None,
480                            })
481                            .await;
482                    }
483                }
484                // Goal-335: re-invoke skills (scans pre-compact for Skill tool calls).
485                if let Some(sr) = &self.skill_reinjector {
486                    let atts = sr.reinject(&pre_compact);
487                    // Insert after file attachments (which start at index 1).
488                    let insert_base: usize = 1 + self.file_reinjector.as_ref().map_or(0, |r| {
489                        // Approximate: the number of file attachments inserted.
490                        // We don't track the count directly, but we know the
491                        // file-reinjector reserves at most r.max_files slots.
492                        // Use a safer heuristic: count existing system messages
493                        // after index 1 that start with the file restore prefix.
494                        self.transcript
495                            .iter()
496                            .skip(1)
497                            .take(r.max_files)
498                            .filter(|m| m.content.starts_with("[post-compact file restore:"))
499                            .count()
500                    });
501                    for (offset, att) in atts.into_iter().enumerate() {
502                        Arc::make_mut(&mut self.transcript)
503                            .insert(insert_base + offset, att.clone());
504                        self.event_sink
505                            .emit(AgentEvent::MessageAppended {
506                                message: att,
507                                usage: None,
508                            })
509                            .await;
510                    }
511                }
512                // Goal-340: re-inject pending plan and task list (reads shared state).
513                if let Some(ptr) = &self.plan_todo_reinjector {
514                    let atts = ptr.reinject();
515                    if !atts.is_empty() {
516                        // Count all already-inserted post-compact attachments so we
517                        // insert right after them, before the preserved tail.
518                        let insert_base: usize = 1 + self
519                            .transcript
520                            .iter()
521                            .skip(1)
522                            .take_while(|m| {
523                                m.content.starts_with("[post-compact file restore:")
524                                    || m.content.starts_with("[post-compact skill restore:")
525                            })
526                            .count();
527                        for (offset, att) in atts.into_iter().enumerate() {
528                            Arc::make_mut(&mut self.transcript)
529                                .insert(insert_base + offset, att.clone());
530                            self.event_sink
531                                .emit(AgentEvent::MessageAppended {
532                                    message: att,
533                                    usage: None,
534                                })
535                                .await;
536                        }
537                    }
538                }
539            }
540            Ok(None) => {
541                // Transcript too short to compact — not a failure, leave counter unchanged.
542            }
543            Err(e) => {
544                // Compaction failed — increment the breaker, emit event, continue.
545                tracing::warn!(error = %e, "cross-turn proactive compaction failed");
546                self.consecutive_compact_failures += 1;
547                self.event_sink
548                    .emit(AgentEvent::CompactionSkipped {
549                        step: self.checkpoints.turn_index.load(Ordering::Relaxed),
550                        reason: crate::event::CompactionSkipReason::Error,
551                    })
552                    .await;
553            }
554        }
555        Ok(())
556    }
557
558    /// Force compact the transcript regardless of the configured threshold.
559    ///
560    /// Called when the LLM returns a context-window-exceeded error. Because
561    /// the turn already failed we have no `prompt_tokens` reading; we bypass
562    /// the threshold check entirely and compact immediately.
563    ///
564    /// Returns `true` when compaction succeeded (the transcript was long enough),
565    /// `false` when the transcript was too short to compact or no compactor is
566    /// configured. A `false` return means the caller should propagate the
567    /// original error rather than retrying.
568    async fn compact_on_overflow(&mut self) -> Result<bool> {
569        let Some(ref compactor) = self.compactor else {
570            return Ok(false);
571        };
572        // Keep the compaction lifecycle balanced: a rejected transcript must
573        // not emit PreCompact because it has no matching PostCompact event.
574        if !compactor.would_compact(&self.transcript) {
575            return Ok(false);
576        }
577        let bytes = Compactor::estimate_bytes(&self.transcript);
578        self.kernel.hooks().dispatch(HookEvent::PreCompact {
579            transcript_len: bytes,
580        });
581        let turn = self
582            .checkpoints
583            .turn_index
584            .load(std::sync::atomic::Ordering::Relaxed);
585        let Some((removed, summary_chars)) = compactor
586            .apply_to_transcript(
587                self.kernel.llm().as_ref(),
588                Arc::make_mut(&mut self.transcript),
589                turn,
590            )
591            .await?
592        else {
593            return Ok(false);
594        };
595        self.kernel.hooks().dispatch(HookEvent::PostCompact {
596            removed,
597            summary_chars,
598        });
599        // The turn failed before reporting usage, so cache metrics are 0.
600        self.event_sink
601            .emit(AgentEvent::CompactionBoundary {
602                turn: turn as u32,
603                compacted_count: removed,
604                summary_uuid: None,
605                cache_hit_tokens: 0,
606                cache_miss_tokens: 0,
607                is_recompaction_in_chain: self.last_compact_turn.is_some(),
608                turns_since_previous_compact: match self.last_compact_turn {
609                    Some(prev) => (turn as u32).saturating_sub(prev),
610                    None => 0,
611                },
612                previous_compact_turn: self.last_compact_turn,
613            })
614            .await;
615        self.last_compact_turn = Some(turn as u32);
616        if let Some(summary) = self.transcript.first().cloned() {
617            self.event_sink
618                .emit(AgentEvent::MessageAppended {
619                    message: summary,
620                    usage: None,
621                })
622                .await;
623        }
624        tracing::info!(
625            target: "recursive::agent",
626            removed,
627            summary_chars,
628            "emergency compaction complete; retrying turn"
629        );
630        Ok(true)
631    }
632
633    /// Build a `TurnContext`, run the kernel, and return the outcome.
634    ///
635    /// Spawns a forwarder task that withholds `TurnFinished` until after all
636    /// assistant/tool `MessageAppended` events have been emitted (prevents SDK
637    /// consumers from closing their stream before receiving the final text).
638    async fn execute_kernel_turn(&mut self) -> Result<crate::kernel::TurnOutcome> {
639        let (event_tx, mut event_rx) =
640            tokio::sync::mpsc::unbounded_channel::<crate::event::AgentEvent>();
641        let sink = self.event_sink.clone();
642        let forwarder = tokio::spawn(async move {
643            let mut deferred_finished: Option<crate::event::AgentEvent> = None;
644            while let Some(ev) = event_rx.recv().await {
645                if matches!(ev, AgentEvent::TurnFinished { .. }) {
646                    deferred_finished = Some(ev);
647                    continue;
648                }
649                sink.emit(ev).await;
650            }
651            deferred_finished
652        });
653
654        let ctx = TurnContext {
655            messages: Arc::clone(&self.transcript),
656            tool_specs: self.kernel.tools().specs(),
657            step_events_tx: Some(event_tx.clone()),
658            streaming: self.streaming,
659            permission_hook: self.permission_hook.clone(),
660            exploring_plan_mode: self.plan_approval_gate.exploring_plan_mode.clone(),
661            permission_mode: self.kernel.tools().permission_mode(),
662            mailbox: None,
663            turn: self.checkpoints.turn_index.load(Ordering::Relaxed) as u32,
664            prompt_segments: self.prompt_segments.clone(),
665            wall_timeout_secs: 0,
666        };
667
668        let turn_outcome = self.kernel.run(ctx).await?;
669        drop(event_tx);
670        // Wait for forwarder; stash the deferred TurnFinished for emit_turn_messages.
671        self.deferred_turn_finished = match forwarder.await {
672            Ok(v) => v,
673            Err(e) => {
674                tracing::error!("forwarder task panicked, TurnFinished will be synthesized: {e}");
675                None
676            }
677        };
678        Ok(turn_outcome)
679    }
680
681    /// Append new kernel messages to the transcript and emit `MessageAppended`
682    /// (or `MessageAppendedWithAudit`) for each, then flush the deferred
683    /// `TurnFinished` event.
684    async fn emit_turn_messages(&mut self, outcome: &crate::kernel::TurnOutcome) {
685        let new_messages = &outcome.new_messages;
686        let turn_usage = crate::session::UsageMeta::from_token_usage(&outcome.usage);
687        let mut tool_audits = outcome.tool_audits.clone();
688        // Token usage belongs only on the last assistant message of the turn —
689        // attaching it to every assistant message would cause consumers to
690        // multiply-count tokens.
691        let last_assistant_idx = new_messages
692            .iter()
693            .rposition(|m| matches!(m.role, crate::message::Role::Assistant));
694        Arc::make_mut(&mut self.transcript).extend(new_messages.iter().cloned());
695        for (idx, msg) in new_messages.iter().enumerate() {
696            let event = if msg.role == crate::message::Role::Tool {
697                if let Some(tcid) = &msg.tool_call_id {
698                    if let Some(audit) = tool_audits.remove(&(outcome.turn, tcid.clone())) {
699                        AgentEvent::MessageAppendedWithAudit {
700                            message: msg.clone(),
701                            audit,
702                        }
703                    } else {
704                        AgentEvent::MessageAppended {
705                            message: msg.clone(),
706                            usage: None,
707                        }
708                    }
709                } else {
710                    AgentEvent::MessageAppended {
711                        message: msg.clone(),
712                        usage: None,
713                    }
714                }
715            } else {
716                let usage = if matches!(msg.role, crate::message::Role::Assistant)
717                    && Some(idx) == last_assistant_idx
718                {
719                    Some(turn_usage.clone())
720                } else {
721                    None
722                };
723                AgentEvent::MessageAppended {
724                    message: msg.clone(),
725                    usage,
726                }
727            };
728            self.event_sink.emit(event).await;
729        }
730        // Emit TurnFinished after all messages are on the wire (SDK ordering guarantee).
731        if let Some(ev) = self.deferred_turn_finished.take() {
732            self.event_sink.emit(ev).await;
733        }
734    }
735
736    // ── Goal-181: message queue ────────────────────────────────────────────
737
738    /// Enqueue a user message and drain the queue in FIFO order.
739    ///
740    /// This is the preferred entry point for all interaction layers (TUI,
741    /// HTTP, CLI).  Unlike calling [`run`](Self::run) directly, `enqueue`
742    /// is safe to call while a turn is already in flight: the runtime is
743    /// single-threaded (`&mut self`), so multiple callers naturally
744    /// serialise.  The queue ensures messages submitted before the runtime
745    /// is ready are not lost and are processed in order.
746    ///
747    /// ```text
748    /// user sends A → enqueue(A) → run(A)
749    /// user sends B while A runs → enqueue(B) → queue=[B]  (A already running via prior call)
750    /// A finishes → loop pops B → run(B)
751    /// ```
752    ///
753    /// In practice the outer loop (`drain_queue`) is what creates this
754    /// ordering: a call to `enqueue` that arrives while another `enqueue`
755    /// is executing on the same runtime will block on `&mut self` borrow,
756    /// so the messages are processed strictly in order.
757    pub async fn enqueue(&mut self, text: impl Into<String>) -> Result<Option<RuntimeOutcome>> {
758        self.message_queue.push_back(text.into());
759        self.drain_queue().await
760    }
761
762    /// Process all queued messages in FIFO order.
763    ///
764    /// Returns `Ok(Some(outcome))` for the last turn processed, or
765    /// `Ok(None)` if the queue is empty when called.
766    ///
767    /// Stops on the first error and returns it to the caller. Messages
768    /// that were not yet popped from the queue remain in the queue for
769    /// later processing.
770    async fn drain_queue(&mut self) -> Result<Option<RuntimeOutcome>> {
771        let mut last: Option<RuntimeOutcome> = None;
772        // Peek then run: only pop the message from the queue after `run`
773        // returns Ok. Goal-259 — a transient error during `run` would
774        // otherwise permanently lose the in-flight message. The message
775        // stays at the front of the queue and can be retried by calling
776        // `drain_queue` again once the error is handled.
777        while let Some(msg) = self.message_queue.front().cloned() {
778            match self.run(msg).await {
779                Ok(outcome) => {
780                    self.message_queue.pop_front();
781                    last = Some(outcome);
782                }
783                Err(e) => {
784                    return Err(e);
785                }
786            }
787        }
788        Ok(last)
789    }
790
791    /// Number of messages currently waiting in the queue.
792    ///
793    /// Callers can expose this to the UI (e.g. status bar: "+N queued").
794    pub fn queue_len(&self) -> usize {
795        self.message_queue.len()
796    }
797
798    // ── Transcript access ──────────────────────────────────────────────────
799
800    /// Return a reference to the accumulated transcript.
801    pub fn transcript(&self) -> &[Message] {
802        &self.transcript
803    }
804
805    /// Return the most-recent `n` transcript messages, or the full
806    /// transcript if `n >= transcript.len()`. Returns an empty slice
807    /// when `n == 0`.
808    ///
809    /// Used by the goal-loop judge (`run_goal_loop`) to keep the
810    /// per-turn evaluator payload bounded as the transcript grows.
811    /// Goal-260.
812    pub fn transcript_tail(&self, n: usize) -> &[Message] {
813        let t: &Vec<Message> = &self.transcript;
814        let len = t.len();
815        if n >= len {
816            t
817        } else {
818            &t[len - n..]
819        }
820    }
821
822    /// Replace the current transcript (useful for restoring from a saved session).
823    pub fn set_transcript(&mut self, transcript: Vec<Message>) {
824        self.transcript = Arc::new(transcript);
825    }
826
827    /// Discard all transcript messages after index `len`, restoring the
828    /// transcript to the state it had before a turn started. Used by the
829    /// TUI abort path to prevent orphan tool_call entries.
830    pub fn truncate_transcript(&mut self, len: usize) {
831        Arc::make_mut(&mut self.transcript).truncate(len);
832    }
833
834    /// Return a reference to the inner kernel.
835    pub fn kernel(&self) -> &AgentKernel {
836        &self.kernel
837    }
838
839    /// Hot-swap the LLM provider backing this runtime.
840    ///
841    /// Delegates to [`AgentKernel::set_llm`]. Used by the TUI `/model` picker
842    /// to switch models mid-session. Must be called between turns (not while
843    /// `run` / `enqueue` / `run_goal_loop` is executing on this runtime).
844    pub fn set_llm(&mut self, llm: Arc<dyn ChatProvider>) {
845        self.kernel.set_llm(llm);
846    }
847
848    /// Return the event sink currently in use.
849    pub fn event_sink(&self) -> &dyn EventSink {
850        self.event_sink.as_ref()
851    }
852
853    /// Set a cancellation token that interrupts the current (or next) agent turn.
854    ///
855    /// When the token is cancelled the step loop exits with
856    /// [`FinishReason::Cancelled`](crate::agent::FinishReason::Cancelled) at
857    /// the next step boundary.  This method replaces any previously installed
858    /// token — call it before each `run()` so a fresh token is in place.
859    pub fn set_interrupt_token(&mut self, token: tokio_util::sync::CancellationToken) {
860        self.kernel.shutdown_token = Some(token);
861    }
862
863    /// Set the session id used for tracing-span labels and turn log lines.
864    ///
865    /// After this is called, every `run()` emits a tracing span record with
866    /// `session_id=<id>` and an info log line carrying the same field, so logs
867    /// and OTEL/Datadog traces can be filtered per session via
868    /// `RUST_LOG=recursive[{session_id}]=debug` or the `session_id` label.
869    pub fn set_session_id(&mut self, id: impl Into<String>) {
870        self.checkpoints.session_id = Some(id.into());
871    }
872
873    /// Set a new event sink (useful for REPL mode between turns).
874    ///
875    /// **Replaces the sink AND re-registers the tools that hold an `Arc<dyn EventSink>`**
876    /// — specifically [`TodoWriteTool`](crate::tools::todo::TodoWriteTool) (Goal-167)
877    /// and [`ExitPlanModeTool`](crate::tools::plan_mode::ExitPlanModeTool) (Goal-165) —
878    /// so that `AgentEvent::TodoUpdated` and `AgentEvent::PlanProposed` reach the new
879    /// consumer (e.g. when the TUI swaps in a `TuiEventSink` after construction).
880    ///
881    /// The side effect is intentional: every caller that swaps the sink (CLI per-turn,
882    /// HTTP per-session, TUI on backend init) expects those tools to forward events to
883    /// the new sink. The method name documents the side effect; callers that only want
884    /// to swap the sink without touching the tool registry must use
885    /// [`replace_event_sink`](Self::replace_event_sink) instead.
886    pub fn set_event_sink(&mut self, sink: Arc<dyn EventSink>) {
887        self.event_sink = sink.clone();
888        // Goal-167: re-register TodoWriteTool with the new sink so that
889        // AgentEvent::TodoUpdated reaches the new consumer (e.g. TUI).
890        self.kernel
891            .tools_mut()
892            .register_mut(Arc::new(TodoWriteTool::new(
893                self.todo_list.clone(),
894                sink.clone(),
895            )));
896        // Goal-165: re-register ExitPlanModeTool with the new sink so that
897        // AgentEvent::PlanProposed reaches the new consumer (e.g. TUI).
898        self.kernel
899            .tools_mut()
900            .register_mut(Arc::new(ExitPlanModeTool::new(
901                self.plan_approval_gate.clone(),
902                sink,
903            )));
904    }
905
906    /// Swap the event sink **without** re-registering any sink-dependent tools.
907    ///
908    /// Use this when you know the new sink should only receive events emitted by
909    /// `AgentRuntime` itself (e.g. `MessageAppended`, `TurnFinished`, compaction
910    /// boundaries) and do not need the `TodoUpdated` / `PlanProposed` fan-out to
911    /// the new consumer. Most callers want [`set_event_sink`](Self::set_event_sink)
912    /// — its tool-reregistration side effect is what makes the TUI's
913    /// `TodoUpdated` updates reach the live UI.
914    ///
915    /// Added in the P0-2 cleanup so the implicit side effect has a non-side-effect
916    /// sibling.
917    pub fn replace_event_sink(&mut self, sink: Arc<dyn EventSink>) {
918        self.event_sink = sink;
919    }
920
921    /// Goal-167: return a snapshot of the current agent task list.
922    ///
923    /// Returns a clone of the list as it stands at call time. Returns an
924    /// empty vec if the internal lock is poisoned.
925    pub fn current_todos(&self) -> Vec<TodoItem> {
926        self.todo_list.read().map(|l| l.clone()).unwrap_or_default()
927    }
928
929    /// Goal-161: attach a [`crate::tools::PermissionHook`] to the
930    /// underlying tool registry so every tool invocation passes through
931    /// the async permission gate before execution.
932    pub fn set_permission_hook(&mut self, hook: Arc<dyn crate::tools::PermissionHook>) {
933        self.permission_hook = Some(hook.clone());
934        self.kernel.tools_mut().set_permission_hook(hook);
935    }
936
937    /// Install a Claude SDK hook forwarder on the registry's
938    /// [`ExternalHookRunner`] (control-channel `hook_callback`).
939    pub fn set_sdk_hook_forwarder(
940        &mut self,
941        forwarder: Option<Arc<dyn crate::hooks::SdkHookForwarder>>,
942    ) {
943        self.kernel
944            .tools_mut()
945            .hook_runner
946            .set_sdk_forwarder(forwarder);
947    }
948
949    /// Return a shared reference to the plan-approval gate.
950    ///
951    /// Callers (e.g. HTTP handlers) that need to inspect `pending_plan` or
952    /// call `approve`/`reject` without holding the runtime `Mutex` can clone
953    /// this `Arc` and operate on the gate directly.
954    pub fn plan_approval_gate(&self) -> Arc<PlanApprovalGate> {
955        self.plan_approval_gate.clone()
956    }
957
958    /// Return a shared reference to the plan-mode-request gate (Goal-202).
959    ///
960    /// The TUI backend's `run_turn_select_loop` clones this arc so it can
961    /// forward `ApprovePlanMode` / `RejectPlanMode` user-actions to the gate
962    /// while the runtime is executing inside a spawned task.
963    pub fn plan_mode_request_gate(&self) -> Arc<PlanModeRequestGate> {
964        self.plan_mode_request_gate.clone()
965    }
966
967    /// Confirm the pending plan, allowing execution to proceed.
968    ///
969    /// Wakes `exit_plan_mode`'s blocking wait via the Plan Mode 2.0 gate.
970    pub fn confirm_plan(&mut self) {
971        self.plan_approval_gate.approve();
972    }
973
974    /// Force a compaction pass right now, regardless of the
975    /// configured threshold. Useful for TUI / API surfaces that
976    /// expose a manual "/compact" command.
977    ///
978    /// No-op (returns `Ok(())`) when no compactor is configured or
979    /// when the transcript is too small to compact (fewer than
980    /// `keep_recent_n + 2` messages).
981    pub async fn compact_now(&mut self) -> Result<()> {
982        let Some(ref compactor) = self.compactor else {
983            return Ok(());
984        };
985        if compactor
986            .apply_to_transcript(
987                self.kernel.llm().as_ref(),
988                Arc::make_mut(&mut self.transcript),
989                self.checkpoints
990                    .turn_index
991                    .load(std::sync::atomic::Ordering::Relaxed),
992            )
993            .await?
994            .is_some()
995        {
996            self.last_compact_turn =
997                Some(self.checkpoints.turn_index.load(Ordering::Relaxed) as u32);
998        }
999        Ok(())
1000    }
1001
1002    /// Goal-342: partial compaction — summarise messages *before* a given
1003    /// transcript index, keeping everything from `pivot_index` onward
1004    /// (plus the compactor's `keep_recent_n` safety margin) verbatim.
1005    ///
1006    /// `pivot_index` is a transcript message index (0-based, same indexing
1007    /// as `transcript()`). Uses `Compactor::safe_split_point` on the
1008    /// sub-transcript `[..=pivot_index]` to find a safe split that never
1009    /// breaks tool-call pairs (invariant #8).
1010    ///
1011    /// No-op when no compactor is configured, `pivot_index` is out of
1012    /// range, or the computed split is 0 (nothing to compact).
1013    pub async fn compact_partial_before(&mut self, pivot_index: usize) -> Result<()> {
1014        let Some(ref compactor) = self.compactor else {
1015            return Ok(());
1016        };
1017        let transcript = Arc::make_mut(&mut self.transcript);
1018        if pivot_index >= transcript.len() {
1019            return Ok(());
1020        }
1021        // Consider the sub-transcript up to and including the pivot.
1022        let scope = &transcript[..=pivot_index];
1023        let split = Compactor::safe_split_point(scope, compactor.keep_recent_n);
1024        if split == 0 {
1025            return Ok(());
1026        }
1027        // Create a temporary compactor with keep_recent_n=0 so that
1028        // compact() summarises *all* of transcript[..split] (not just
1029        // the oldest part of it as the full keep_recent_n would).
1030        let zero_keep = Compactor {
1031            threshold_chars: compactor.threshold_chars,
1032            threshold_prompt_tokens: compactor.threshold_prompt_tokens,
1033            keep_recent_n: 0,
1034        };
1035        let step = self.checkpoints.turn_index.load(Ordering::Relaxed);
1036        let summary_msg = zero_keep
1037            .compact(self.kernel.llm().as_ref(), &transcript[..split], step)
1038            .await?;
1039        transcript.drain(..split);
1040        transcript.insert(0, summary_msg);
1041        self.last_compact_turn = Some(self.checkpoints.turn_index.load(Ordering::Relaxed) as u32);
1042        Ok(())
1043    }
1044
1045    /// Goal-342: partial compaction — summarise messages *after* a given
1046    /// transcript index, keeping everything *before* `pivot_index` verbatim.
1047    ///
1048    /// `pivot_index` is a transcript message index (0-based). Backs up
1049    /// from the pivot as needed to avoid splitting tool-call pairs
1050    /// (invariant #8): any `Tool` or `Assistant` carrying `tool_calls`
1051    /// at the boundary is included with its pair.
1052    ///
1053    /// No-op when no compactor is configured or `pivot_index` is out of
1054    /// range.
1055    pub async fn compact_partial_after(&mut self, pivot_index: usize) -> Result<()> {
1056        let Some(ref compactor) = self.compactor else {
1057            return Ok(());
1058        };
1059        let transcript = Arc::make_mut(&mut self.transcript);
1060        if pivot_index >= transcript.len() {
1061            return Ok(());
1062        }
1063        // Back up from the pivot to avoid splitting tool-call pairs.
1064        // If the message at pivot_index is a Tool result or an
1065        // Assistant that issued tool_calls, include its pair by
1066        // starting earlier.
1067        let mut start = pivot_index;
1068        loop {
1069            if start == 0 {
1070                break;
1071            }
1072            let msg = &transcript[start];
1073            let should_back_up = msg.role == crate::message::Role::Tool
1074                || (msg.role == crate::message::Role::Assistant && !msg.tool_calls.is_empty());
1075            if should_back_up {
1076                start -= 1;
1077            } else {
1078                break;
1079            }
1080        }
1081        if start >= transcript.len() {
1082            return Ok(());
1083        }
1084        let suffix = transcript[start..].to_vec();
1085        // Nothing meaningful to summarise when the suffix is a single message
1086        // (or empty) — mirror compact_partial_before's `split == 0` no-op so a
1087        // degenerate slice does not surface a compact() error. (Goal 347
1088        // follow-up: surfaced by the too_short test once lib-test compilation
1089        // was re-enabled.)
1090        if suffix.len() <= 1 {
1091            return Ok(());
1092        }
1093        // Same zero-keep trick so compact() summarises everything in the suffix.
1094        let zero_keep = Compactor {
1095            threshold_chars: compactor.threshold_chars,
1096            threshold_prompt_tokens: compactor.threshold_prompt_tokens,
1097            keep_recent_n: 0,
1098        };
1099        let step = self.checkpoints.turn_index.load(Ordering::Relaxed);
1100        let summary_msg = zero_keep
1101            .compact(self.kernel.llm().as_ref(), &suffix, step)
1102            .await?;
1103        transcript.truncate(start);
1104        transcript.push(summary_msg);
1105        self.last_compact_turn = Some(self.checkpoints.turn_index.load(Ordering::Relaxed) as u32);
1106        Ok(())
1107    }
1108
1109    /// Goal-202: approve the plan-mode entry request.
1110    ///
1111    /// Wakes `RequestPlanModeTool`'s blocking wait, returning `{"approved": true}`
1112    /// to the LLM so it can proceed with `enter_plan_mode`.
1113    pub fn approve_plan_mode_request(&self) {
1114        self.plan_mode_request_gate.approve();
1115    }
1116
1117    /// Goal-202: reject the plan-mode entry request with a reason.
1118    ///
1119    /// Wakes `RequestPlanModeTool`'s blocking wait, returning
1120    /// `{"approved": false, "reason": "..."}` so the LLM can execute directly.
1121    pub fn reject_plan_mode_request(&self, reason: &str) {
1122        self.plan_mode_request_gate.reject(reason);
1123    }
1124
1125    /// Reject the pending plan with a reason.
1126    ///
1127    /// Injects a user message into the transcript and wakes `exit_plan_mode`'s
1128    /// blocking wait (Plan Mode 2.0 gate) with the rejection reason.
1129    pub fn reject_plan(&mut self, reason: &str) {
1130        let rejection_msg = Message::user(format!("Plan rejected: {}", reason));
1131        Arc::make_mut(&mut self.transcript).push(rejection_msg);
1132        self.plan_approval_gate.reject(reason);
1133    }
1134
1135    // ── Goal-168: goal state accessors ────────────────────────────────────
1136
1137    /// Return a clone of the current goal state (or `None`).
1138    pub fn current_goal(&self) -> Option<GoalState> {
1139        self.goal_state.read().ok().and_then(|g| g.clone())
1140    }
1141
1142    /// Set a new active goal. Emits `AgentEvent::GoalSet` via the event sink.
1143    pub async fn set_goal(&self, condition: String, max_turns: u32) {
1144        let state = GoalState {
1145            condition: condition.clone(),
1146            status: GoalStatus::Pursuing,
1147            turns: 0,
1148            max_turns,
1149            last_reason: None,
1150        };
1151        if let Ok(mut g) = self.goal_state.write() {
1152            *g = Some(state);
1153        }
1154        self.event_sink
1155            .emit(AgentEvent::GoalSet {
1156                condition,
1157                max_turns,
1158            })
1159            .await;
1160    }
1161
1162    /// Clear the active goal. Emits `AgentEvent::GoalCleared`.
1163    pub async fn clear_goal(&self) {
1164        if let Ok(mut g) = self.goal_state.write() {
1165            *g = None;
1166        }
1167        self.event_sink.emit(AgentEvent::GoalCleared).await;
1168    }
1169
1170    /// Run a goal loop: execute turns until the judge says the condition
1171    /// is met, the turn budget is exhausted, or the goal is cleared externally.
1172    ///
1173    /// Steps per iteration:
1174    /// 1. `run(prompt)` — execute one agent turn.
1175    /// 2. Increment `GoalState.turns`.
1176    /// 3. If `turns >= max_turns` → emit `GoalCleared` (budget exceeded), break.
1177    /// 4. Call `GoalEvaluator::evaluate(condition, transcript_tail)`.
1178    /// 5. If `achieved` → emit `GoalAchieved`, break.
1179    /// 6. Else → emit `GoalContinuing { reason }`, continue with auto-prompt.
1180    pub async fn run_goal_loop(
1181        &mut self,
1182        initial_prompt: impl Into<String>,
1183        condition: impl Into<String>,
1184        max_turns: u32,
1185    ) -> Result<Vec<RuntimeOutcome>> {
1186        let condition = condition.into();
1187        self.set_goal(condition.clone(), max_turns).await;
1188
1189        let evaluator = GoalEvaluator::new(self.kernel.llm().clone());
1190        let mut outcomes = Vec::new();
1191        let mut next_prompt = initial_prompt.into();
1192
1193        loop {
1194            // Check if goal was externally cleared while we were looping.
1195            let active = self
1196                .goal_state
1197                .read()
1198                .ok()
1199                .and_then(|g| g.clone())
1200                .map(|g| g.status == GoalStatus::Pursuing)
1201                .unwrap_or(false);
1202            if !active {
1203                break;
1204            }
1205
1206            let outcome = self.run(&next_prompt).await?;
1207            outcomes.push(outcome);
1208
1209            // Increment turn counter and check budget in a single write lock
1210            // (C-2: TOCTOU fix — previously two separate locks created a window
1211            // where an external clear_goal() call could set goal=None between the
1212            // increment and the budget check, causing a duplicate GoalCleared emit).
1213            enum TurnOutcomeKind {
1214                Continue(u32),
1215                BudgetExceeded(u32),
1216                ExternallyCleared,
1217            }
1218            let turn_outcome = {
1219                let mut guard = match self.goal_state.write().ok() {
1220                    Some(g) => g,
1221                    None => break,
1222                };
1223                match *guard {
1224                    None => TurnOutcomeKind::ExternallyCleared,
1225                    Some(ref mut gs) => {
1226                        gs.turns += 1;
1227                        let turns = gs.turns;
1228                        if turns >= max_turns {
1229                            *guard = None;
1230                            TurnOutcomeKind::BudgetExceeded(turns)
1231                        } else {
1232                            TurnOutcomeKind::Continue(turns)
1233                        }
1234                    }
1235                }
1236            };
1237
1238            let turns = match turn_outcome {
1239                TurnOutcomeKind::ExternallyCleared => break,
1240                TurnOutcomeKind::BudgetExceeded(t) => {
1241                    self.event_sink.emit(AgentEvent::GoalCleared).await;
1242                    tracing::warn!(
1243                        "goal loop: turn budget of {max_turns} exceeded without achieving condition"
1244                    );
1245                    let _ = t;
1246                    break;
1247                }
1248                TurnOutcomeKind::Continue(t) => t,
1249            };
1250
1251            // Ask the judge.
1252            // Goal-260: pass a tail slice, not the full transcript. The judge
1253            // only needs recent progress; the full transcript grows every turn
1254            // and would balloon the judge call's payload.
1255            // Goal-291: the slice length is now configurable via
1256            // `goal_eval_transcript_tail` (default 12, matching the previous
1257            // `GOAL_EVAL_TRANSCRIPT_TAIL` constant).
1258            let tail = self.transcript_tail(self.goal_eval_transcript_tail);
1259            let verdict = evaluator.evaluate(&condition, tail).await?;
1260            if verdict.achieved {
1261                if let Ok(mut g) = self.goal_state.write() {
1262                    if let Some(ref mut gs) = *g {
1263                        gs.status = GoalStatus::Achieved;
1264                        gs.last_reason = Some(verdict.reason.clone());
1265                    }
1266                    *g = None;
1267                }
1268                self.event_sink
1269                    .emit(AgentEvent::GoalAchieved {
1270                        condition: condition.clone(),
1271                        turns,
1272                    })
1273                    .await;
1274                break;
1275            } else {
1276                // Store reason and continue.
1277                if let Ok(mut g) = self.goal_state.write() {
1278                    if let Some(ref mut gs) = *g {
1279                        gs.last_reason = Some(verdict.reason.clone());
1280                    }
1281                }
1282                self.event_sink
1283                    .emit(AgentEvent::GoalContinuing {
1284                        reason: verdict.reason.clone(),
1285                        turns,
1286                    })
1287                    .await;
1288
1289                next_prompt = format!(
1290                    "(Goal: {condition})\n\nPrevious attempt reason: {}\n\nContinue.",
1291                    verdict.reason
1292                );
1293            }
1294        }
1295
1296        Ok(outcomes)
1297    }
1298
1299    /// Run a loop: execute turns until the agent stops scheduling wakeups.
1300    ///
1301    /// Between turns, sleeps for the requested `delay`. If the agent doesn't
1302    /// call `schedule_wakeup` during a turn, the loop ends.
1303    ///
1304    /// The `wakeup_slot` should be the same slot registered with the
1305    /// `ScheduleWakeup` tool in the agent's tool registry.
1306    pub async fn run_loop(
1307        &mut self,
1308        initial_goal: impl Into<String>,
1309        wakeup_slot: &crate::tools::WakeupSlot,
1310    ) -> Result<Vec<RuntimeOutcome>> {
1311        let mut outcomes = Vec::new();
1312        let mut next_goal = initial_goal.into();
1313
1314        loop {
1315            let outcome = self.run(&next_goal).await?;
1316            outcomes.push(outcome);
1317
1318            // Check if the agent scheduled a wakeup
1319            let wakeup = wakeup_slot.lock().ok().and_then(|mut slot| slot.take());
1320
1321            match wakeup {
1322                Some(req) => {
1323                    tokio::time::sleep(req.delay).await;
1324                    next_goal = req.prompt;
1325                }
1326                None => break,
1327            }
1328        }
1329        Ok(outcomes)
1330    }
1331
1332    /// Run a loop with background job awareness.
1333    ///
1334    /// After each turn, checks both:
1335    /// 1. The `WakeupSlot` for an explicit wakeup request
1336    /// 2. The `BackgroundJobManager` for completed jobs
1337    ///
1338    /// If a background job completed, its output is injected as the next turn's
1339    /// goal. If a wakeup was scheduled, the runtime sleeps for the requested
1340    /// delay then continues. If neither is present, the loop ends.
1341    pub async fn run_event_loop(
1342        &mut self,
1343        initial_goal: impl Into<String>,
1344        wakeup_slot: &crate::tools::WakeupSlot,
1345        bg_manager: Option<&tokio::sync::Mutex<crate::tools::BackgroundJobManager>>,
1346    ) -> Result<Vec<RuntimeOutcome>> {
1347        let mut outcomes = Vec::new();
1348        let mut next_goal = initial_goal.into();
1349
1350        // Completed background jobs drained in one wake but not yet serviced
1351        // as their own turn. Kept here (not just in the manager) so a
1352        // multi-job completion — where the manager's `notify_one()` permits
1353        // coalesce into a single wake — cannot orphan a job (goal-379).
1354        let mut pending_jobs: std::collections::VecDeque<(String, String)> =
1355            std::collections::VecDeque::new();
1356
1357        loop {
1358            let outcome = self.run(&next_goal).await?;
1359            outcomes.push(outcome);
1360
1361            // Priority 1: explicit wakeup
1362            let wakeup = wakeup_slot.lock().ok().and_then(|mut slot| slot.take());
1363            if let Some(req) = wakeup {
1364                tokio::time::sleep(req.delay).await;
1365                next_goal = req.prompt;
1366                continue;
1367            }
1368
1369            // Priority 2: background job completed
1370            // Use .lock().await instead of try_lock() so a completed job is
1371            // never silently skipped when the lock is momentarily contended.
1372            // Drain EVERY currently-completed job (via `take_completed`) in
1373            // one wake into `pending_jobs`, then service one per turn —
1374            // identical to the old single-job path (one
1375            // `Background job 'X' completed:` turn per job), but with no job
1376            // left behind when ≥2 jobs finish while a turn is in flight.
1377            if let Some(mgr) = bg_manager {
1378                let mut mgr = mgr.lock().await;
1379                while let Some((id, output)) = mgr.take_completed() {
1380                    pending_jobs.push_back((id, output));
1381                }
1382            }
1383            if let Some((id, output)) = pending_jobs.pop_front() {
1384                next_goal = format!("Background job '{}' completed:\n{}", id, output);
1385                continue;
1386            }
1387
1388            // Nothing to do → loop ends
1389            break;
1390        }
1391        Ok(outcomes)
1392    }
1393
1394    // ──────────────────────────────────────────────────────────────────
1395    // Checkpoint helpers
1396    // ──────────────────────────────────────────────────────────────────
1397
1398    /// Bind this runtime to a checkpoint chain. With **Goal 284**,
1399    /// automatic per-turn snapshots are removed. Checkpoints are
1400    /// created only when the agent explicitly calls `checkpoint_save`.
1401    ///
1402    /// Side effect: registers `checkpoint_list`, `checkpoint_diff`, and
1403    /// `checkpoint_save` tools, scoped to this session, onto the kernel's
1404    /// tool registry.
1405    pub fn enable_checkpoints(
1406        &mut self,
1407        shadow: Arc<ShadowRepo>,
1408        session_id: impl Into<String>,
1409        log_path: std::path::PathBuf,
1410        touched_slot: Option<Arc<Mutex<TouchedFiles>>>,
1411    ) -> Result<()> {
1412        let writer = Arc::new(Mutex::new(CheckpointLogWriter::open(&log_path)?));
1413        let session_id = session_id.into();
1414
1415        // Register session-scoped read-only checkpoint tools onto the
1416        // kernel's registry. The shadow repo is shared via
1417        // Arc<Mutex<ShadowRepo>> so the tools and the runtime see the
1418        // same checkpoint chain.
1419        let tool_repo = Arc::new(Mutex::new(ShadowRepo::clone(&shadow)));
1420        let ctx = crate::tools::CheckpointToolCtx {
1421            repo: tool_repo.clone(),
1422            session_id: session_id.clone(),
1423        };
1424        let tools = self.kernel.tools_mut();
1425        tools.register_mut(Arc::new(crate::tools::CheckpointList::new(ctx.clone())));
1426        tools.register_mut(Arc::new(crate::tools::CheckpointDiff::new(ctx)));
1427
1428        // Goal 284: register the on-demand checkpoint_save tool.
1429        let save_tool = crate::tools::checkpoint::build_checkpoint_save_tool(
1430            tool_repo,
1431            session_id.clone(),
1432            touched_slot.clone(),
1433            writer.clone(),
1434            self.checkpoints.turn_index.clone(),
1435            log_path.clone(),
1436        );
1437        tools.register_mut(Arc::new(save_tool));
1438
1439        self.checkpoints.shadow = Some(shadow);
1440        self.checkpoints.session_id = Some(session_id);
1441        self.checkpoints.writer = Some(writer);
1442        self.checkpoints.touched_files = touched_slot;
1443        self.checkpoints.log_path = Some(log_path);
1444        Ok(())
1445    }
1446
1447    /// Whether checkpoint snapshots are active.
1448    pub fn checkpoints_enabled(&self) -> bool {
1449        self.checkpoints.enabled()
1450    }
1451
1452    /// Returns the 0-indexed counter that will be assigned to the
1453    /// *next* turn (i.e. the count of turns already executed).
1454    pub fn turn_index(&self) -> usize {
1455        self.checkpoints.turn_index.load(Ordering::Relaxed)
1456    }
1457}
1458
1459// ──────────────────────────────────────────────────────────────────────────
1460// Context-window overflow detection
1461// ──────────────────────────────────────────────────────────────────────────
1462
1463use crate::error::is_context_window_exceeded;
1464
1465// ──────────────────────────────────────────────────────────────────────────
1466// Tests
1467// ──────────────────────────────────────────────────────────────────────────
1468
1469#[cfg(test)]
1470mod tests {
1471    use super::*;
1472    use crate::hooks::HookRegistry;
1473    use crate::llm::{Completion, MockProvider};
1474    use crate::tools::plan_mode::{ENTER_PLAN_MODE_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME};
1475    use crate::tools::todo::TodoStatus;
1476    use crate::tools::Tool;
1477    use crate::tools::ToolRegistry;
1478    use async_trait::async_trait;
1479    use serde_json::{json, Value};
1480
1481    struct Adder;
1482
1483    #[async_trait]
1484    impl Tool for Adder {
1485        fn spec(&self) -> crate::llm::ToolSpec {
1486            crate::llm::ToolSpec {
1487                name: "add".into(),
1488                description: "add two numbers".into(),
1489                parameters: json!({"type":"object","properties":{"a":{"type":"integer"},"b":{"type":"integer"}}}),
1490            }
1491        }
1492        async fn execute(&self, args: Value) -> crate::error::Result<String> {
1493            let a = args["a"].as_i64().unwrap_or(0);
1494            let b = args["b"].as_i64().unwrap_or(0);
1495            Ok((a + b).to_string())
1496        }
1497    }
1498
1499    // ── basic turn execution ──────────────────────────────────────────
1500
1501    #[tokio::test]
1502    async fn single_turn_no_tools() {
1503        let llm = Arc::new(MockProvider::new(vec![Completion {
1504            content: "Hello!".into(),
1505            tool_calls: vec![],
1506            finish_reason: Some("stop".into()),
1507            usage: None,
1508            reasoning_content: None,
1509        }]));
1510        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1511        let out = rt.run("hi").await.unwrap();
1512        assert_eq!(out.final_text.as_deref(), Some("Hello!"));
1513        assert_eq!(out.steps, 1);
1514        assert_eq!(rt.transcript().len(), 2); // user + assistant
1515    }
1516
1517    #[tokio::test]
1518    async fn turn_with_tool() {
1519        let llm = Arc::new(MockProvider::new(vec![
1520            Completion {
1521                content: "Let me check...".into(),
1522                tool_calls: vec![crate::llm::ToolCall {
1523                    id: "c1".into(),
1524                    name: "add".into(),
1525                    arguments: json!({"a": 3, "b": 4}),
1526                }],
1527                finish_reason: Some("tool_calls".into()),
1528                usage: None,
1529                reasoning_content: None,
1530            },
1531            Completion {
1532                content: "7".into(),
1533                tool_calls: vec![],
1534                finish_reason: Some("stop".into()),
1535                usage: None,
1536                reasoning_content: None,
1537            },
1538        ]));
1539        let tools = ToolRegistry::local().register(Arc::new(Adder));
1540        let mut rt = AgentRuntime::builder()
1541            .llm(llm)
1542            .tools(tools)
1543            .build()
1544            .unwrap();
1545        let out = rt.run("3+4?").await.unwrap();
1546        assert_eq!(out.final_text.as_deref(), Some("7"));
1547        assert_eq!(out.steps, 2);
1548        assert_eq!(rt.transcript().len(), 4); // user, assistant, tool, assistant
1549    }
1550
1551    // ── transcript accumulation across turns ──────────────────────────
1552
1553    #[tokio::test]
1554    async fn multi_turn_transcript_grows() {
1555        let llm = Arc::new(MockProvider::new(vec![
1556            Completion {
1557                content: "First reply".into(),
1558                tool_calls: vec![],
1559                finish_reason: Some("stop".into()),
1560                usage: None,
1561                reasoning_content: None,
1562            },
1563            Completion {
1564                content: "Second reply".into(),
1565                tool_calls: vec![],
1566                finish_reason: Some("stop".into()),
1567                usage: None,
1568                reasoning_content: None,
1569            },
1570        ]));
1571        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1572
1573        let o1 = rt.run("turn 1").await.unwrap();
1574        assert_eq!(o1.final_text.as_deref(), Some("First reply"));
1575        assert_eq!(rt.transcript().len(), 2);
1576
1577        let o2 = rt.run("turn 2").await.unwrap();
1578        assert_eq!(o2.final_text.as_deref(), Some("Second reply"));
1579        assert_eq!(rt.transcript().len(), 4);
1580    }
1581
1582    // ── builder options ───────────────────────────────────────────────
1583
1584    #[tokio::test]
1585    async fn system_prompt_is_prepended() {
1586        let llm = Arc::new(MockProvider::new(vec![Completion {
1587            content: "ok".into(),
1588            tool_calls: vec![],
1589            finish_reason: Some("stop".into()),
1590            usage: None,
1591            reasoning_content: None,
1592        }]));
1593        let mut rt = AgentRuntime::builder()
1594            .llm(llm)
1595            .system_prompt("Be helpful.")
1596            .build()
1597            .unwrap();
1598        rt.run("hello").await.unwrap();
1599        assert_eq!(rt.transcript()[0].role, crate::message::Role::System);
1600        assert_eq!(rt.transcript()[0].content, "Be helpful.");
1601    }
1602
1603    #[tokio::test]
1604    async fn seed_transcript_is_included() {
1605        let seed = vec![
1606            Message::user("old Q".to_string()),
1607            Message::assistant("old A".to_string()),
1608        ];
1609        let llm = Arc::new(MockProvider::new(vec![Completion {
1610            content: "fresh".into(),
1611            tool_calls: vec![],
1612            finish_reason: Some("stop".into()),
1613            usage: None,
1614            reasoning_content: None,
1615        }]));
1616        let mut rt = AgentRuntime::builder()
1617            .llm(llm)
1618            .seed_transcript(seed)
1619            .build()
1620            .unwrap();
1621        rt.run("new Q").await.unwrap();
1622        // seed(2) + new user + new assistant = 4
1623        assert_eq!(rt.transcript().len(), 4);
1624        assert_eq!(rt.transcript()[0].content, "old Q");
1625        assert_eq!(rt.transcript()[1].content, "old A");
1626        assert_eq!(rt.transcript()[2].content, "new Q");
1627        assert_eq!(rt.transcript()[3].content, "fresh");
1628    }
1629
1630    #[tokio::test]
1631    async fn system_and_seed_ordering() {
1632        let seed = vec![Message::user("seeded user".to_string())];
1633        let llm = Arc::new(MockProvider::new(vec![Completion {
1634            content: "r".into(),
1635            tool_calls: vec![],
1636            finish_reason: Some("stop".into()),
1637            usage: None,
1638            reasoning_content: None,
1639        }]));
1640        let mut rt = AgentRuntime::builder()
1641            .llm(llm)
1642            .system_prompt("sys prompt")
1643            .seed_transcript(seed)
1644            .build()
1645            .unwrap();
1646        rt.run("real").await.unwrap();
1647        assert_eq!(rt.transcript()[0].role, crate::message::Role::System);
1648        assert_eq!(rt.transcript()[0].content, "sys prompt");
1649        assert_eq!(rt.transcript()[1].content, "seeded user");
1650        assert_eq!(rt.transcript()[2].content, "real");
1651    }
1652
1653    // ── state inspection / mutation ───────────────────────────────────
1654
1655    #[tokio::test]
1656    async fn set_transcript_replaces() {
1657        let llm = Arc::new(MockProvider::new(vec![Completion {
1658            content: "ok".into(),
1659            tool_calls: vec![],
1660            finish_reason: Some("stop".into()),
1661            usage: None,
1662            reasoning_content: None,
1663        }]));
1664        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1665        rt.set_transcript(vec![Message::user("custom".to_string())]);
1666        assert_eq!(rt.transcript().len(), 1);
1667        assert_eq!(rt.transcript()[0].content, "custom");
1668    }
1669
1670    #[tokio::test]
1671    async fn kernel_accessor_works() {
1672        let llm = Arc::new(MockProvider::new(vec![Completion {
1673            content: "ok".into(),
1674            tool_calls: vec![],
1675            finish_reason: Some("stop".into()),
1676            usage: None,
1677            reasoning_content: None,
1678        }]));
1679        let rt = AgentRuntime::builder().llm(llm).build().unwrap();
1680        let _kernel = rt.kernel(); // should compile and return a reference
1681    }
1682
1683    // ── default values ────────────────────────────────────────────────
1684
1685    #[tokio::test]
1686    async fn defaults_are_sensible() {
1687        let llm = Arc::new(MockProvider::new(vec![Completion {
1688            content: "done".into(),
1689            tool_calls: vec![],
1690            finish_reason: Some("stop".into()),
1691            usage: None,
1692            reasoning_content: None,
1693        }]));
1694        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1695        let out = rt.run("test").await.unwrap();
1696        assert_eq!(out.finish_reason, FinishReason::NoMoreToolCalls);
1697        assert_eq!(rt.transcript().len(), 2);
1698    }
1699
1700    // ── checkpoint integration ────────────────────────────────────────
1701
1702    fn has_git() -> bool {
1703        std::process::Command::new("git")
1704            .arg("--version")
1705            .output()
1706            .is_ok()
1707    }
1708
1709    /// Workspace tempdir + sibling shadow tempdir, both alive together.
1710    /// Tests open `ShadowRepo::open_at(...)` against `shadow_dir()` to
1711    /// avoid touching `paths::user_data_dir()` and the global env lock.
1712    struct ShadowWs {
1713        workspace: tempfile::TempDir,
1714        shadow: tempfile::TempDir,
1715    }
1716
1717    impl ShadowWs {
1718        fn path(&self) -> &std::path::Path {
1719            self.workspace.path()
1720        }
1721        fn shadow_dir(&self) -> std::path::PathBuf {
1722            self.shadow.path().join("shadow-git")
1723        }
1724    }
1725
1726    fn shadow_ws() -> ShadowWs {
1727        ShadowWs {
1728            workspace: tempfile::tempdir().expect("workspace tempdir"),
1729            shadow: tempfile::tempdir().expect("shadow tempdir"),
1730        }
1731    }
1732
1733    /// Goal 284: with on-demand checkpoints, automatic per-turn snapshots
1734    /// are gone. Verify that `outcome.checkpoint_id` is `None` and no
1735    /// log entries are written automatically. The agent must call
1736    /// `checkpoint_save` to persist a checkpoint.
1737    #[tokio::test]
1738    async fn runtime_no_auto_snapshots_with_checkpoints_enabled() {
1739        if !has_git() {
1740            return;
1741        }
1742        let dir = shadow_ws();
1743        std::fs::write(dir.path().join("seed.txt"), "v0").unwrap();
1744
1745        let llm = Arc::new(MockProvider::new(vec![
1746            Completion {
1747                content: "ok".into(),
1748                tool_calls: vec![],
1749                finish_reason: Some("stop".into()),
1750                usage: None,
1751                reasoning_content: None,
1752            },
1753            Completion {
1754                content: "ok2".into(),
1755                tool_calls: vec![],
1756                finish_reason: Some("stop".into()),
1757                usage: None,
1758                reasoning_content: None,
1759            },
1760        ]));
1761        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1762
1763        let shadow = Arc::new(crate::ShadowRepo::open_at(dir.path(), dir.shadow_dir()).unwrap());
1764        let log_path = dir.path().join("checkpoints.jsonl");
1765        rt.enable_checkpoints(shadow.clone(), "sess", log_path.clone(), None)
1766            .unwrap();
1767        assert!(rt.checkpoints_enabled());
1768
1769        let o1 = rt.run("turn 0").await.unwrap();
1770        assert!(o1.checkpoint_id.is_none(), "no auto-snapshot in Goal 284");
1771        let o2 = rt.run("turn 1").await.unwrap();
1772        assert!(o2.checkpoint_id.is_none(), "no auto-snapshot in Goal 284");
1773
1774        // No log entries should exist (agent never called checkpoint_save).
1775        let recs = crate::read_checkpoint_log(&log_path).unwrap();
1776        assert_eq!(recs.len(), 0, "no auto log entries");
1777    }
1778
1779    /// Goal 284: verify that `checkpoint_save` tool is registered
1780    /// when checkpoints are enabled.
1781    #[tokio::test]
1782    async fn checkpoint_save_tool_is_registered() {
1783        if !has_git() {
1784            return;
1785        }
1786        let dir = shadow_ws();
1787        std::fs::write(dir.path().join("a.txt"), "hi").unwrap();
1788
1789        let llm = Arc::new(MockProvider::new(vec![Completion {
1790            content: "ok".into(),
1791            tool_calls: vec![],
1792            finish_reason: Some("stop".into()),
1793            usage: None,
1794            reasoning_content: None,
1795        }]));
1796        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1797
1798        let shadow = Arc::new(crate::ShadowRepo::open_at(dir.path(), dir.shadow_dir()).unwrap());
1799        let log_path = dir.path().join("checkpoints.jsonl");
1800        rt.enable_checkpoints(shadow, "sess", log_path, None)
1801            .unwrap();
1802
1803        let tools = rt.kernel.tools();
1804        assert!(
1805            tools.get("checkpoint_save").is_some(),
1806            "checkpoint_save must be registered"
1807        );
1808        assert!(
1809            tools.get("checkpoint_list").is_some(),
1810            "checkpoint_list must be registered"
1811        );
1812        assert!(
1813            tools.get("checkpoint_diff").is_some(),
1814            "checkpoint_diff must be registered"
1815        );
1816    }
1817
1818    #[tokio::test]
1819    async fn runtime_works_when_checkpoints_disabled() {
1820        // No call to enable_checkpoints → outcome.checkpoint_id is None,
1821        // no log file created.
1822        let llm = Arc::new(MockProvider::new(vec![Completion {
1823            content: "ok".into(),
1824            tool_calls: vec![],
1825            finish_reason: Some("stop".into()),
1826            usage: None,
1827            reasoning_content: None,
1828        }]));
1829        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1830        let out = rt.run("hi").await.unwrap();
1831        assert!(out.checkpoint_id.is_none());
1832        assert!(!rt.checkpoints_enabled());
1833    }
1834
1835    // ── compact_now (Goal 146) ────────────────────────────────────────────
1836
1837    #[tokio::test]
1838    async fn compact_now_invokes_compactor() {
1839        // Provider used (a) to answer two normal turns, (b) to answer
1840        // the compactor's "summarize" call.
1841        let llm = Arc::new(MockProvider::new(vec![
1842            Completion {
1843                content: "first reply".into(),
1844                tool_calls: vec![],
1845                finish_reason: Some("stop".into()),
1846                usage: None,
1847                reasoning_content: None,
1848            },
1849            Completion {
1850                content: "second reply".into(),
1851                tool_calls: vec![],
1852                finish_reason: Some("stop".into()),
1853                usage: None,
1854                reasoning_content: None,
1855            },
1856            Completion {
1857                content: "compacted summary".into(),
1858                tool_calls: vec![],
1859                finish_reason: Some("stop".into()),
1860                usage: None,
1861                reasoning_content: None,
1862            },
1863        ]));
1864        // Threshold = MAX so the auto-compaction in `run` never fires;
1865        // keep_recent_n=1 so we only need 3 messages before compact_now
1866        // has work to do.
1867        let compactor = crate::compact::Compactor::new(usize::MAX).keep_recent_n(1);
1868        let mut rt = AgentRuntime::builder()
1869            .llm(llm)
1870            .compactor(compactor)
1871            .build()
1872            .unwrap();
1873        rt.run("turn 1").await.unwrap();
1874        rt.run("turn 2").await.unwrap();
1875        let len_before = rt.transcript().len();
1876        assert!(len_before >= 3, "expected ≥3 messages, got {len_before}");
1877
1878        rt.compact_now().await.unwrap();
1879        // The compactor replaces older messages with one summary
1880        // system message plus keep_recent_n=1 verbatim message.
1881        assert_eq!(rt.transcript().len(), 2);
1882        assert_eq!(rt.transcript()[0].role, crate::message::Role::System);
1883        assert!(rt.transcript()[0].content.starts_with("[compacted:"));
1884    }
1885
1886    #[tokio::test]
1887    async fn compact_now_is_noop_without_compactor() {
1888        let llm = Arc::new(MockProvider::new(vec![Completion {
1889            content: "x".into(),
1890            tool_calls: vec![],
1891            finish_reason: Some("stop".into()),
1892            usage: None,
1893            reasoning_content: None,
1894        }]));
1895        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1896        rt.run("hi").await.unwrap();
1897        let before = rt.transcript().len();
1898        rt.compact_now().await.unwrap();
1899        assert_eq!(rt.transcript().len(), before);
1900    }
1901
1902    // ── Goal-305: turn index propagated to compaction summary header ──
1903
1904    #[tokio::test]
1905    async fn compact_now_uses_turn_index_in_header() {
1906        let llm = Arc::new(MockProvider::new(vec![
1907            Completion {
1908                content: "first reply".into(),
1909                tool_calls: vec![],
1910                finish_reason: Some("stop".into()),
1911                usage: None,
1912                reasoning_content: None,
1913            },
1914            Completion {
1915                content: "second reply".into(),
1916                tool_calls: vec![],
1917                finish_reason: Some("stop".into()),
1918                usage: None,
1919                reasoning_content: None,
1920            },
1921            Completion {
1922                content: "compacted summary text".into(),
1923                tool_calls: vec![],
1924                finish_reason: Some("stop".into()),
1925                usage: None,
1926                reasoning_content: None,
1927            },
1928        ]));
1929        // keep_recent_n = 1 → transcript after compaction is [summary, last message].
1930        // The summary is always at index 0, so we can inspect its header.
1931        let compactor = crate::compact::Compactor::new(usize::MAX).keep_recent_n(1);
1932        let mut rt = AgentRuntime::builder()
1933            .llm(llm)
1934            .compactor(compactor)
1935            .build()
1936            .unwrap();
1937
1938        // 2 turns → turn_index advances to 2.
1939        rt.run("turn 1").await.unwrap();
1940        rt.run("turn 2").await.unwrap();
1941        assert_eq!(rt.turn_index(), 2, "turn_index should be 2 after 2 turns");
1942
1943        rt.compact_now().await.unwrap();
1944
1945        // Transcript: [compaction summary, last verbatim message].
1946        assert_eq!(rt.transcript().len(), 2);
1947        let summary = &rt.transcript()[0].content;
1948        assert!(
1949            summary.contains("at step 2"),
1950            "compaction header should contain 'at step 2', got: {summary}"
1951        );
1952    }
1953
1954    // ── Goal-342: compact_partial_before / compact_partial_after ─────────
1955
1956    #[tokio::test]
1957    async fn compact_partial_before_summarizes_prefix_keeps_suffix() {
1958        let provider = Arc::new(MockProvider::new(vec![Completion {
1959            content: "partial summary before pivot".into(),
1960            tool_calls: vec![],
1961            finish_reason: Some("stop".into()),
1962            usage: None,
1963            reasoning_content: None,
1964        }]));
1965        let compactor = crate::compact::Compactor::new(usize::MAX).keep_recent_n(2);
1966        let mut rt = AgentRuntime::builder()
1967            .llm(provider)
1968            .compactor(compactor)
1969            .build()
1970            .unwrap();
1971        // Seed: [system, user0, asst0, user1, asst1, user2, asst2]
1972        let msgs = vec![
1973            Message::system("sys"),
1974            Message::user("u0"),
1975            Message::assistant("a0"),
1976            Message::user("u1"),
1977            Message::assistant("a1"),
1978            Message::user("u2"),
1979            Message::assistant("a2"),
1980        ];
1981        *Arc::make_mut(&mut rt.transcript) = msgs;
1982
1983        // Compact before index 5 (u2). The exact split depends on
1984        // safe_split_point; assert the invariants that matter: compaction
1985        // happened (transcript shrank, a summary now leads), and the tail
1986        // (pivot and after) is preserved verbatim.
1987        let len_before = rt.transcript().len();
1988        rt.compact_partial_before(5).await.unwrap();
1989
1990        let after = rt.transcript();
1991        assert!(
1992            after.len() < len_before,
1993            "compact_partial_before must shrink the transcript: {} -> {}",
1994            len_before,
1995            after.len()
1996        );
1997        assert!(
1998            after[0].content.starts_with("[compacted:"),
1999            "summary must lead the transcript after compact_partial_before"
2000        );
2001        // The pivot (index 5 = "u2") and the message after it (a2) must be
2002        // preserved verbatim in the tail.
2003        assert!(
2004            after.iter().any(|m| m.content == "u2"),
2005            "pivot message u2 must survive in the tail"
2006        );
2007        assert!(
2008            after.iter().any(|m| m.content == "a2"),
2009            "message after pivot (a2) must survive in the tail"
2010        );
2011    }
2012
2013    #[tokio::test]
2014    async fn compact_partial_after_summarizes_suffix_keeps_prefix() {
2015        let provider = Arc::new(MockProvider::new(vec![Completion {
2016            content: "partial summary after pivot".into(),
2017            tool_calls: vec![],
2018            finish_reason: Some("stop".into()),
2019            usage: None,
2020            reasoning_content: None,
2021        }]));
2022        let compactor = crate::compact::Compactor::new(usize::MAX).keep_recent_n(2);
2023        let mut rt = AgentRuntime::builder()
2024            .llm(provider)
2025            .compactor(compactor)
2026            .build()
2027            .unwrap();
2028        // Seed: [system, user0, asst0, user1, asst1, user2, asst2]
2029        let msgs = vec![
2030            Message::system("sys"),
2031            Message::user("u0"),
2032            Message::assistant("a0"),
2033            Message::user("u1"),
2034            Message::assistant("a1"),
2035            Message::user("u2"),
2036            Message::assistant("a2"),
2037        ];
2038        *Arc::make_mut(&mut rt.transcript) = msgs;
2039
2040        // Compact after index 3 (u1). The suffix from the pivot onward is
2041        // summarised; assert invariants: transcript shrank, the prefix before
2042        // the pivot is preserved verbatim, and a summary now closes it.
2043        let len_before = rt.transcript().len();
2044        rt.compact_partial_after(3).await.unwrap();
2045
2046        let after = rt.transcript();
2047        assert!(
2048            after.len() < len_before,
2049            "compact_partial_after must shrink the transcript: {} -> {}",
2050            len_before,
2051            after.len()
2052        );
2053        // Prefix before index 3 ([sys, u0, a0]) must be preserved verbatim.
2054        assert_eq!(after[0].content, "sys");
2055        assert_eq!(after[1].content, "u0");
2056        assert_eq!(after[2].content, "a0");
2057        // The last message must be the compaction summary.
2058        assert!(
2059            after.last().unwrap().content.starts_with("[compacted:"),
2060            "summary must be the last message after compact_partial_after"
2061        );
2062    }
2063
2064    #[tokio::test]
2065    async fn compact_partial_too_short_is_noop() {
2066        let provider = Arc::new(MockProvider::new(vec![Completion {
2067            content: "summary".into(),
2068            tool_calls: vec![],
2069            finish_reason: Some("stop".into()),
2070            usage: None,
2071            reasoning_content: None,
2072        }]));
2073        let compactor = crate::compact::Compactor::new(usize::MAX).keep_recent_n(2);
2074        let mut rt = AgentRuntime::builder()
2075            .llm(provider)
2076            .compactor(compactor)
2077            .build()
2078            .unwrap();
2079        // Very short transcript: just a system prompt.
2080        *Arc::make_mut(&mut rt.transcript) = vec![Message::system("sys")];
2081
2082        let before = rt.transcript().len();
2083        rt.compact_partial_before(0).await.unwrap();
2084        assert_eq!(rt.transcript().len(), before);
2085
2086        rt.compact_partial_after(0).await.unwrap();
2087        assert_eq!(rt.transcript().len(), before);
2088    }
2089
2090    #[tokio::test]
2091    async fn compact_partial_preserves_tool_call_pairing() {
2092        let provider = Arc::new(MockProvider::new(vec![Completion {
2093            content: "tool-pair-summary".into(),
2094            tool_calls: vec![],
2095            finish_reason: Some("stop".into()),
2096            usage: None,
2097            reasoning_content: None,
2098        }]));
2099        let compactor = crate::compact::Compactor::new(usize::MAX).keep_recent_n(1);
2100        let mut rt = AgentRuntime::builder()
2101            .llm(provider)
2102            .compactor(compactor)
2103            .build()
2104            .unwrap();
2105
2106        // Messages with a tool-call pair: [u0, a0(tool_calls), tool0, u1, a1]
2107        let tc = crate::llm::ToolCall {
2108            id: "c1".into(),
2109            name: "add".into(),
2110            arguments: serde_json::json!({}),
2111        };
2112        let msgs = vec![
2113            Message::user("u0"),
2114            Message::assistant_with_tool_calls("a0", vec![tc]),
2115            Message::tool_result("call_c1", "3"),
2116            Message::user("u1"),
2117            Message::assistant("a1"),
2118        ];
2119        *Arc::make_mut(&mut rt.transcript) = msgs;
2120
2121        // compact_partial_before at index 3 (u1). safe_split_point backs up
2122        // past Tool at index 2 in scope[..=3] → split=2. Older [0..2] compacted.
2123        rt.compact_partial_before(3).await.unwrap();
2124
2125        // After: [summary, u1, a1] — no orphan Tool messages.
2126        assert_eq!(rt.transcript().len(), 3, "summary + 2 kept");
2127        assert!(rt.transcript()[0].content.starts_with("[compacted:"));
2128        assert_eq!(rt.transcript()[1].content, "u1");
2129        assert_eq!(rt.transcript()[2].content, "a1");
2130        // No orphan Tool messages in the kept region
2131        for msg in &rt.transcript()[1..] {
2132            if msg.role == crate::message::Role::Tool {
2133                panic!("orphan Tool message in kept region: {:?}", msg);
2134            }
2135        }
2136    }
2137
2138    #[tokio::test]
2139    async fn compact_partial_noop_without_compactor() {
2140        let provider = Arc::new(MockProvider::new(vec![Completion {
2141            content: "x".into(),
2142            tool_calls: vec![],
2143            finish_reason: Some("stop".into()),
2144            usage: None,
2145            reasoning_content: None,
2146        }]));
2147        let mut rt = AgentRuntime::builder().llm(provider).build().unwrap();
2148        rt.set_transcript(vec![Message::user("hi"), Message::assistant("ok")]);
2149        let before = rt.transcript().len();
2150        rt.compact_partial_before(1).await.unwrap();
2151        assert_eq!(rt.transcript().len(), before);
2152        rt.compact_partial_after(0).await.unwrap();
2153        assert_eq!(rt.transcript().len(), before);
2154    }
2155
2156    // ── Goal-168: GoalState / GoalEvaluator / run_goal_loop tests ──────────
2157
2158    #[tokio::test]
2159    async fn set_goal_stores_state() {
2160        let llm = Arc::new(MockProvider::new(vec![]));
2161        let rt = AgentRuntime::builder().llm(llm).build().unwrap();
2162        assert!(rt.current_goal().is_none());
2163        rt.set_goal("task is done".to_string(), 10).await;
2164        let g = rt.current_goal().expect("goal should be set");
2165        assert_eq!(g.condition, "task is done");
2166        assert_eq!(g.max_turns, 10);
2167        assert_eq!(g.turns, 0);
2168        assert_eq!(g.status, GoalStatus::Pursuing);
2169    }
2170
2171    #[tokio::test]
2172    async fn clear_goal_removes_state() {
2173        let llm = Arc::new(MockProvider::new(vec![]));
2174        let rt = AgentRuntime::builder().llm(llm).build().unwrap();
2175        rt.set_goal("anything".to_string(), 5).await;
2176        assert!(rt.current_goal().is_some());
2177        rt.clear_goal().await;
2178        assert!(rt.current_goal().is_none());
2179    }
2180
2181    #[tokio::test]
2182    async fn goal_status_default_is_pursuing() {
2183        let g = GoalState {
2184            condition: "done".to_string(),
2185            status: GoalStatus::Pursuing,
2186            turns: 0,
2187            max_turns: 20,
2188            last_reason: None,
2189        };
2190        assert_eq!(g.status, GoalStatus::Pursuing);
2191        assert_eq!(g.turns, 0);
2192        assert!(g.last_reason.is_none());
2193    }
2194
2195    #[tokio::test]
2196    async fn goal_evaluator_returns_achieved_on_yes_response() {
2197        // Mock a provider that returns "YES\nLooks complete."
2198        let llm = Arc::new(MockProvider::new(vec![Completion {
2199            content: "YES\nLooks complete.".into(),
2200            tool_calls: vec![],
2201            finish_reason: Some("stop".into()),
2202            usage: None,
2203            reasoning_content: None,
2204        }]));
2205        let evaluator = GoalEvaluator::new(llm);
2206        let msgs = vec![crate::message::Message::user("I completed the task.")];
2207        let verdict = evaluator
2208            .evaluate("task is done", &msgs)
2209            .await
2210            .expect("evaluate should succeed");
2211        assert!(verdict.achieved);
2212        assert!(!verdict.reason.is_empty());
2213    }
2214
2215    #[tokio::test]
2216    async fn goal_evaluator_returns_not_achieved_on_no_response() {
2217        let llm = Arc::new(MockProvider::new(vec![Completion {
2218            content: "NO\nStill in progress.".into(),
2219            tool_calls: vec![],
2220            finish_reason: Some("stop".into()),
2221            usage: None,
2222            reasoning_content: None,
2223        }]));
2224        let evaluator = GoalEvaluator::new(llm);
2225        let msgs = vec![crate::message::Message::user("I started the task.")];
2226        let verdict = evaluator
2227            .evaluate("task is done", &msgs)
2228            .await
2229            .expect("evaluate should succeed");
2230        assert!(!verdict.achieved);
2231    }
2232
2233    #[tokio::test]
2234    async fn goal_evaluator_tolerates_empty_transcript() {
2235        let llm = Arc::new(MockProvider::new(vec![Completion {
2236            content: "YES\nEmpty transcript but condition trivially met.".into(),
2237            tool_calls: vec![],
2238            finish_reason: Some("stop".into()),
2239            usage: None,
2240            reasoning_content: None,
2241        }]));
2242        let evaluator = GoalEvaluator::new(llm);
2243        let verdict = evaluator
2244            .evaluate("anything", &[])
2245            .await
2246            .expect("should not error on empty transcript");
2247        assert!(verdict.achieved);
2248    }
2249
2250    #[tokio::test]
2251    async fn run_goal_loop_stops_when_achieved() {
2252        // Provider: first call for the agent turn, second for the judge.
2253        let llm = Arc::new(MockProvider::new(vec![
2254            Completion {
2255                content: "I wrote the greeting.".into(),
2256                tool_calls: vec![],
2257                finish_reason: Some("stop".into()),
2258                usage: None,
2259                reasoning_content: None,
2260            },
2261            Completion {
2262                content: "YES\nGreeting was written.".into(),
2263                tool_calls: vec![],
2264                finish_reason: Some("stop".into()),
2265                usage: None,
2266                reasoning_content: None,
2267            },
2268        ]));
2269        let null_sink = Arc::new(crate::event::NullSink);
2270        let mut rt = AgentRuntime::builder()
2271            .llm(llm)
2272            .event_sink(null_sink)
2273            .build()
2274            .unwrap();
2275        let _ = rt
2276            .run_goal_loop("write a greeting", "write a greeting", 5)
2277            .await;
2278        // Goal should be cleared after achievement.
2279        assert!(rt.current_goal().is_none());
2280    }
2281
2282    #[tokio::test]
2283    async fn run_goal_loop_stops_at_max_turns() {
2284        // Provider: every judge call returns NO → loop hits max_turns.
2285        let completions: Vec<Completion> = (0..20)
2286            .flat_map(|_| {
2287                vec![
2288                    Completion {
2289                        content: "still working".into(),
2290                        tool_calls: vec![],
2291                        finish_reason: Some("stop".into()),
2292                        usage: None,
2293                        reasoning_content: None,
2294                    },
2295                    Completion {
2296                        content: "NO\nNot done yet.".into(),
2297                        tool_calls: vec![],
2298                        finish_reason: Some("stop".into()),
2299                        usage: None,
2300                        reasoning_content: None,
2301                    },
2302                ]
2303            })
2304            .collect();
2305        let llm = Arc::new(MockProvider::new(completions));
2306        let null_sink = Arc::new(crate::event::NullSink);
2307        let mut rt = AgentRuntime::builder()
2308            .llm(llm)
2309            .event_sink(null_sink)
2310            .build()
2311            .unwrap();
2312        // max_turns=2 so we stop after 2 regardless.
2313        let _ = rt
2314            .run_goal_loop("start on impossible task", "impossible task", 2)
2315            .await;
2316        // Goal should be cleared after budget exhaustion.
2317        assert!(rt.current_goal().is_none());
2318    }
2319
2320    #[tokio::test]
2321    async fn goal_serde_round_trip() {
2322        let g = GoalState {
2323            condition: "file written".to_string(),
2324            status: GoalStatus::Achieved,
2325            turns: 3,
2326            max_turns: 10,
2327            last_reason: Some("File was created.".to_string()),
2328        };
2329        let json = serde_json::to_string(&g).expect("serialize");
2330        let g2: GoalState = serde_json::from_str(&json).expect("deserialize");
2331        assert_eq!(g2.condition, g.condition);
2332        assert_eq!(g2.status, GoalStatus::Achieved);
2333        assert_eq!(g2.turns, 3);
2334        assert_eq!(g2.last_reason, Some("File was created.".to_string()));
2335    }
2336
2337    #[tokio::test]
2338    async fn multiple_set_goal_calls_overwrite_state() {
2339        let llm = Arc::new(MockProvider::new(vec![]));
2340        let rt = AgentRuntime::builder().llm(llm).build().unwrap();
2341        rt.set_goal("first goal".to_string(), 5).await;
2342        rt.set_goal("second goal".to_string(), 15).await;
2343        let g = rt.current_goal().unwrap();
2344        assert_eq!(g.condition, "second goal");
2345        assert_eq!(g.max_turns, 15);
2346    }
2347
2348    // ── Goal-260: transcript_tail accessor ───────────────────────────────
2349
2350    #[test]
2351    fn transcript_tail_returns_full_when_n_exceeds_len() -> Result<(), Box<dyn std::error::Error>> {
2352        let llm = Arc::new(MockProvider::new(vec![]));
2353        let mut rt = AgentRuntime::builder().llm(llm).build()?;
2354        // Build a 3-message transcript directly (no LLM calls).
2355        rt.set_transcript(vec![
2356            crate::message::Message::user("one"),
2357            crate::message::Message::assistant("two"),
2358            crate::message::Message::user("three"),
2359        ]);
2360        let tail = rt.transcript_tail(10);
2361        assert_eq!(tail.len(), 3, "n > len should return the full transcript");
2362        assert_eq!(tail[0].content, "one");
2363        assert_eq!(tail[2].content, "three");
2364        Ok(())
2365    }
2366
2367    #[test]
2368    fn transcript_tail_returns_last_n() -> Result<(), Box<dyn std::error::Error>> {
2369        let llm = Arc::new(MockProvider::new(vec![]));
2370        let mut rt = AgentRuntime::builder().llm(llm).build()?;
2371        rt.set_transcript(vec![
2372            crate::message::Message::user("m0"),
2373            crate::message::Message::assistant("m1"),
2374            crate::message::Message::user("m2"),
2375            crate::message::Message::assistant("m3"),
2376            crate::message::Message::user("m4"),
2377        ]);
2378        let tail = rt.transcript_tail(2);
2379        assert_eq!(tail.len(), 2, "should return exactly the last 2 messages");
2380        assert_eq!(tail[0].content, "m3");
2381        assert_eq!(tail[1].content, "m4");
2382        Ok(())
2383    }
2384
2385    #[test]
2386    fn transcript_tail_handles_zero() -> Result<(), Box<dyn std::error::Error>> {
2387        let llm = Arc::new(MockProvider::new(vec![]));
2388        let mut rt = AgentRuntime::builder().llm(llm).build()?;
2389        rt.set_transcript(vec![
2390            crate::message::Message::user("only"),
2391            crate::message::Message::assistant("reply"),
2392        ]);
2393        let tail = rt.transcript_tail(0);
2394        assert_eq!(tail.len(), 0, "n == 0 should return an empty slice");
2395        assert!(tail.is_empty());
2396        Ok(())
2397    }
2398
2399    // ── Goal-291: configurable goal_eval_transcript_tail ──────────────────
2400    //
2401    // The `goal_eval_transcript_tail` field replaces the old
2402    // `GOAL_EVAL_TRANSCRIPT_TAIL` constant. We verify three things:
2403    //   1. The builder field is wired through to the runtime.
2404    //   2. The default stays at 12 (backward-compatible with sessions that
2405    //      don't set the field).
2406    //   3. The configured value is honored, not silently overwritten by
2407    //      the old constant.
2408    #[test]
2409    fn goal_eval_transcript_tail_builder_default_is_twelve() {
2410        let llm = Arc::new(MockProvider::new(vec![]));
2411        let rt = AgentRuntime::builder().llm(llm).build().unwrap();
2412        // The default is 12 — same value the old constant held.
2413        assert_eq!(rt.goal_eval_transcript_tail, 12);
2414    }
2415
2416    #[test]
2417    fn goal_eval_transcript_tail_builder_override_propagates() {
2418        let llm = Arc::new(MockProvider::new(vec![]));
2419        let rt = AgentRuntime::builder()
2420            .llm(llm)
2421            .goal_eval_transcript_tail(3)
2422            .build()
2423            .unwrap();
2424        assert_eq!(rt.goal_eval_transcript_tail, 3);
2425    }
2426
2427    /// Source-level check: with `goal_eval_transcript_tail = 3` and 6
2428    /// messages in the transcript, `transcript_tail(n)` returns 3 — the
2429    /// value the runtime would pass to the judge. Verifies the value
2430    /// is honored, not silently overwritten by the old constant.
2431    #[test]
2432    fn goal_eval_transcript_tail_honored_over_old_default() {
2433        let llm = Arc::new(MockProvider::new(vec![]));
2434        let mut rt = AgentRuntime::builder()
2435            .llm(llm)
2436            .goal_eval_transcript_tail(3)
2437            .build()
2438            .unwrap();
2439        // 6 messages: u0, a0, u1, a1, u2, a2.
2440        rt.set_transcript(vec![
2441            crate::message::Message::user("m0"),
2442            crate::message::Message::assistant("m1"),
2443            crate::message::Message::user("m2"),
2444            crate::message::Message::assistant("m3"),
2445            crate::message::Message::user("m4"),
2446            crate::message::Message::assistant("m5"),
2447        ]);
2448        // The judge slice should be exactly 3 (the configured value),
2449        // not 6 (full transcript) and not 12 (old default).
2450        let judge_slice = rt.transcript_tail(rt.goal_eval_transcript_tail);
2451        assert_eq!(
2452            judge_slice.len(),
2453            3,
2454            "judge should see only 3 messages, got {}",
2455            judge_slice.len()
2456        );
2457        assert_eq!(judge_slice[0].content, "m3");
2458        assert_eq!(judge_slice[1].content, "m4");
2459        assert_eq!(judge_slice[2].content, "m5");
2460    }
2461
2462    /// End-to-end check: with `goal_eval_transcript_tail = 1` the loop
2463    /// runs to completion (no panic, no wrong-tail-length error) using
2464    /// the configured tail size. This validates the wiring change in
2465    /// `run_goal_loop` — it now reads from the field, not the constant.
2466    #[tokio::test]
2467    async fn run_goal_loop_respects_tail_config() {
2468        use crate::event::ChannelSink;
2469
2470        let completions = vec![
2471            // First turn: agent reply
2472            Completion {
2473                content: "still working".into(),
2474                tool_calls: vec![],
2475                finish_reason: Some("stop".into()),
2476                usage: None,
2477                reasoning_content: None,
2478            },
2479            // First judge call: NO
2480            Completion {
2481                content: "NO\nNot done yet.".into(),
2482                tool_calls: vec![],
2483                finish_reason: Some("stop".into()),
2484                usage: None,
2485                reasoning_content: None,
2486            },
2487            // Second turn: agent reply
2488            Completion {
2489                content: "trying again".into(),
2490                tool_calls: vec![],
2491                finish_reason: Some("stop".into()),
2492                usage: None,
2493                reasoning_content: None,
2494            },
2495            // Second judge call: YES — loop should exit here
2496            Completion {
2497                content: "YES\nAll good.".into(),
2498                tool_calls: vec![],
2499                finish_reason: Some("stop".into()),
2500                usage: None,
2501                reasoning_content: None,
2502            },
2503        ];
2504        let llm = Arc::new(MockProvider::new(completions));
2505        let (sink, _rx) = ChannelSink::new();
2506        let mut rt = AgentRuntime::builder()
2507            .llm(llm)
2508            .event_sink(Arc::new(sink))
2509            .goal_eval_transcript_tail(1)
2510            .build()
2511            .unwrap();
2512
2513        // With tail=1, the judge sees only the most recent message per
2514        // call. This test just verifies the loop runs to completion
2515        // (no panic, no wrong-tail-length error) using the configured
2516        // tail size.
2517        let _ = rt
2518            .run_goal_loop("achieve it", "achieve it", 5)
2519            .await
2520            .expect("goal loop should run without error");
2521        // Goal is cleared on achievement.
2522        assert!(rt.current_goal().is_none());
2523        assert_eq!(rt.goal_eval_transcript_tail, 1);
2524    }
2525
2526    // ── Goal-181: message queue ───────────────────────────────────────────
2527
2528    #[tokio::test]
2529    async fn enqueue_processes_single_message() {
2530        let llm = Arc::new(MockProvider::new(vec![Completion {
2531            content: "queued reply".into(),
2532            tool_calls: vec![],
2533            finish_reason: Some("stop".into()),
2534            usage: None,
2535            reasoning_content: None,
2536        }]));
2537        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
2538        let out = rt.enqueue("hello from queue").await.unwrap();
2539        assert!(out.is_some());
2540        assert_eq!(out.unwrap().final_text.as_deref(), Some("queued reply"));
2541        assert_eq!(rt.transcript().len(), 2);
2542    }
2543
2544    #[tokio::test]
2545    async fn enqueue_drains_multiple_messages_in_order() {
2546        let llm = Arc::new(MockProvider::new(vec![
2547            Completion {
2548                content: "reply A".into(),
2549                tool_calls: vec![],
2550                finish_reason: Some("stop".into()),
2551                usage: None,
2552                reasoning_content: None,
2553            },
2554            Completion {
2555                content: "reply B".into(),
2556                tool_calls: vec![],
2557                finish_reason: Some("stop".into()),
2558                usage: None,
2559                reasoning_content: None,
2560            },
2561        ]));
2562        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
2563        // Push two messages directly into the queue to simulate concurrent enqueue.
2564        rt.message_queue.push_back("msg A".into());
2565        rt.message_queue.push_back("msg B".into());
2566        let last = rt.drain_queue().await.unwrap();
2567        assert_eq!(last.unwrap().final_text.as_deref(), Some("reply B"));
2568        // Both user messages + both assistant replies are in transcript.
2569        assert_eq!(rt.transcript().len(), 4);
2570    }
2571
2572    #[test]
2573    fn queue_len_reflects_pending_messages() {
2574        let llm = Arc::new(MockProvider::new(vec![]));
2575        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
2576        assert_eq!(rt.queue_len(), 0);
2577        rt.message_queue.push_back("pending".into());
2578        assert_eq!(rt.queue_len(), 1);
2579        rt.message_queue.push_back("also pending".into());
2580        assert_eq!(rt.queue_len(), 2);
2581    }
2582
2583    // ── Goal-244: drain_queue error propagation ──
2584
2585    #[tokio::test]
2586    async fn drain_queue_returns_ok_for_all_messages() {
2587        let llm = Arc::new(MockProvider::new(vec![
2588            Completion {
2589                content: "reply A".into(),
2590                tool_calls: vec![],
2591                finish_reason: Some("stop".into()),
2592                usage: None,
2593                reasoning_content: None,
2594            },
2595            Completion {
2596                content: "reply B".into(),
2597                tool_calls: vec![],
2598                finish_reason: Some("stop".into()),
2599                usage: None,
2600                reasoning_content: None,
2601            },
2602        ]));
2603        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
2604        rt.message_queue.push_back("msg A".into());
2605        rt.message_queue.push_back("msg B".into());
2606        let result = rt.drain_queue().await;
2607        assert!(result.is_ok());
2608        let last = result.unwrap();
2609        assert!(last.is_some());
2610        assert_eq!(last.unwrap().final_text.as_deref(), Some("reply B"));
2611        // Both user messages + both assistant replies are in transcript.
2612        assert_eq!(rt.transcript().len(), 4);
2613    }
2614
2615    #[tokio::test]
2616    async fn drain_queue_stops_on_first_error() {
2617        // Only one completion available — second message will fail.
2618        let llm = Arc::new(MockProvider::new(vec![Completion {
2619            content: "reply A".into(),
2620            tool_calls: vec![],
2621            finish_reason: Some("stop".into()),
2622            usage: None,
2623            reasoning_content: None,
2624        }]));
2625        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
2626        rt.message_queue.push_back("msg A".into());
2627        rt.message_queue.push_back("msg B".into());
2628        let result = rt.drain_queue().await;
2629        assert!(result.is_err(), "expected error, got {:?}", result);
2630        // Goal-259: the in-flight message must remain at the front of the
2631        // queue so it can be retried by calling drain_queue again.
2632        assert_eq!(
2633            rt.queue_len(),
2634            1,
2635            "second message should remain in queue for retry"
2636        );
2637        // Verify it is indeed the second message that was preserved.
2638        assert_eq!(
2639            rt.message_queue.front().map(String::as_str),
2640            Some("msg B"),
2641            "msg B should still be at the front of the queue"
2642        );
2643        // First message was successfully processed and is reflected in the
2644        // transcript (user message + assistant reply).
2645        assert_eq!(
2646            rt.transcript().len(),
2647            3,
2648            "transcript should hold msg A, reply A, and the in-flight msg B"
2649        );
2650    }
2651
2652    #[tokio::test]
2653    async fn drain_queue_preserves_remaining_messages_on_error() {
2654        // Goal-259: 3 messages queued, only 1 completion available. The
2655        // second message will fail. The first message must be popped
2656        // (success), and the remaining two (B and C) must stay in the
2657        // queue for later retry.
2658        let llm = Arc::new(MockProvider::new(vec![Completion {
2659            content: "reply A".into(),
2660            tool_calls: vec![],
2661            finish_reason: Some("stop".into()),
2662            usage: None,
2663            reasoning_content: None,
2664        }]));
2665        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
2666        rt.message_queue.push_back("msg A".into());
2667        rt.message_queue.push_back("msg B".into());
2668        rt.message_queue.push_back("msg C".into());
2669        let result = rt.drain_queue().await;
2670        assert!(result.is_err(), "expected error, got {:?}", result);
2671        // First message was processed and popped; B and C remain.
2672        assert_eq!(
2673            rt.queue_len(),
2674            2,
2675            "B and C should remain in queue for retry"
2676        );
2677        // FIFO order preserved: B at the front, C behind it.
2678        assert_eq!(rt.message_queue.front().map(String::as_str), Some("msg B"));
2679        // First turn reflected in transcript. The in-flight msg B is also
2680        // present (run() appends the user message to the transcript before
2681        // the LLM call), but it has no assistant reply yet because the
2682        // LLM call failed — the same pre-existing behaviour as in
2683        // drain_queue_stops_on_first_error.
2684        assert_eq!(rt.transcript().len(), 3);
2685    }
2686
2687    // ── Goal-201: plan mode tools are registered by the runtime builder ──
2688
2689    #[test]
2690    fn runtime_builder_skills_stores_skills_list() {
2691        // kills `replace AgentRuntimeBuilder::skills -> Self with Default::default()`:
2692        // if skills() discards the argument, the runtime's globs_skills would be empty.
2693        use crate::skills::{Skill, SkillMode};
2694        let llm = Arc::new(MockProvider::new(vec![]));
2695        let skill = Skill {
2696            name: "my-skill".to_string(),
2697            description: "A test skill".to_string(),
2698            path: std::path::PathBuf::from("/tmp/my-skill/SKILL.md"),
2699            mode: SkillMode::Always,
2700            triggers: vec![],
2701            hint: String::new(),
2702            depends_on: vec![],
2703            refs: vec![],
2704            params: vec![],
2705            scripts: vec![],
2706            sections: vec![],
2707            globs: None,
2708        };
2709        let rt = AgentRuntime::builder()
2710            .llm(llm)
2711            .skills(vec![skill])
2712            .build()
2713            .unwrap();
2714        // `globs_skills` is pub(crate); it must contain the skill we passed.
2715        assert_eq!(
2716            rt.kernel.globs_skills.len(),
2717            1,
2718            "skills() must store the provided skills list; len was {}",
2719            rt.kernel.globs_skills.len()
2720        );
2721        assert_eq!(rt.kernel.globs_skills[0].name, "my-skill");
2722    }
2723
2724    #[test]
2725    fn reject_plan_appends_rejection_message_to_transcript() {
2726        // kills `replace AgentRuntime::reject_plan with ()` mutation.
2727        // If reject_plan is a no-op, the transcript won't grow.
2728        let llm = Arc::new(MockProvider::new(vec![]));
2729        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
2730        let before = rt.transcript_tail(100).len();
2731        rt.reject_plan("too risky");
2732        let after = rt.transcript_tail(100).len();
2733        assert!(
2734            after > before,
2735            "reject_plan must append a rejection message to the transcript"
2736        );
2737        // Verify the message contains the reason
2738        let tail = rt.transcript_tail(100);
2739        let last = tail.last().expect("at least one message");
2740        assert!(
2741            last.content.contains("too risky"),
2742            "rejection message must contain the provided reason; got: {:?}",
2743            last.content
2744        );
2745    }
2746
2747    #[test]
2748    fn runtime_builder_has_plan_mode_tools() {
2749        // AgentRuntimeBuilder::build() must register enter_plan_mode and
2750        // exit_plan_mode when with_plan_mode_tools(true) is set.
2751        // These are channel capabilities used by the TUI and HTTP paths.
2752        let llm = Arc::new(MockProvider::new(vec![]));
2753        let rt = AgentRuntime::builder()
2754            .llm(llm)
2755            .with_plan_mode_tools(true)
2756            .build()
2757            .unwrap();
2758        let tools = rt.kernel.tools();
2759        assert!(
2760            tools.get(ENTER_PLAN_MODE_TOOL_NAME).is_some(),
2761            "enter_plan_mode must be registered by AgentRuntimeBuilder"
2762        );
2763        assert!(
2764            tools.get(EXIT_PLAN_MODE_TOOL_NAME).is_some(),
2765            "exit_plan_mode must be registered by AgentRuntimeBuilder"
2766        );
2767    }
2768
2769    // ── Goal-275: tool_audits keyed by (turn, tool_call_id) ──────────────
2770
2771    /// When two turns reuse the same `tool_call_id`, the new `(turn, id)`
2772    /// keying prevents the second turn's audit from overwriting the first
2773    /// turn's audit before it can be emitted.
2774    #[tokio::test]
2775    async fn audit_survives_collision_across_turns() {
2776        let llm = Arc::new(MockProvider::new(vec![
2777            // Turn 1: tool call "c1" (adder)
2778            Completion {
2779                content: "calculating...".into(),
2780                tool_calls: vec![crate::llm::ToolCall {
2781                    id: "c1".into(),
2782                    name: "add".into(),
2783                    arguments: json!({"a": 1, "b": 2}),
2784                }],
2785                finish_reason: Some("tool_calls".into()),
2786                usage: None,
2787                reasoning_content: None,
2788            },
2789            // Turn 1: finish
2790            Completion {
2791                content: "3".into(),
2792                tool_calls: vec![],
2793                finish_reason: Some("stop".into()),
2794                usage: None,
2795                reasoning_content: None,
2796            },
2797            // Turn 2: tool call "c1" (SAME id reused)
2798            Completion {
2799                content: "calculating again...".into(),
2800                tool_calls: vec![crate::llm::ToolCall {
2801                    id: "c1".into(),
2802                    name: "add".into(),
2803                    arguments: json!({"a": 5, "b": 7}),
2804                }],
2805                finish_reason: Some("tool_calls".into()),
2806                usage: None,
2807                reasoning_content: None,
2808            },
2809            // Turn 2: finish
2810            Completion {
2811                content: "12".into(),
2812                tool_calls: vec![],
2813                finish_reason: Some("stop".into()),
2814                usage: None,
2815                reasoning_content: None,
2816            },
2817        ]));
2818        let tools = ToolRegistry::local().register(Arc::new(Adder));
2819        let (sink, mut rx) = crate::event::ChannelSink::new();
2820        let sink_arc = Arc::new(sink);
2821        let mut rt = AgentRuntime::builder()
2822            .llm(llm)
2823            .tools(tools)
2824            .event_sink(sink_arc)
2825            .build()
2826            .unwrap();
2827
2828        // Drain events from builder registration.
2829        while let Ok(_ev) = rx.try_recv() {}
2830
2831        let _ = rt.run("turn 1").await.unwrap();
2832        let _ = rt.run("turn 2").await.unwrap();
2833
2834        let mut audit_count = 0usize;
2835        while let Ok(ev) = rx.try_recv() {
2836            if matches!(ev, AgentEvent::MessageAppendedWithAudit { .. }) {
2837                audit_count += 1;
2838            }
2839        }
2840
2841        // Both turns should produce a tool result with audit metadata.
2842        // Without (turn, id) keying, turn-2's audit overwrites turn-1's
2843        // entry before emit_turn_messages processes either, so
2844        // audit_count would be 1 instead of 2.
2845        assert_eq!(
2846            audit_count, 2,
2847            "expected both turns' tool results to have audit metadata"
2848        );
2849    }
2850
2851    /// A buggy model that emits the same `tool_call_id` twice in a single
2852    /// assistant message.  The `remove()` semantics mean only the first
2853    /// tool-result message gets the audit, but at least it gets *one*.
2854    /// Before the (turn, id) keying fix, cross-turn collisions could
2855    /// nuke even this one.
2856    #[tokio::test]
2857    async fn duplicate_tool_call_id_in_same_response_attaches_at_least_one() {
2858        let llm = Arc::new(MockProvider::new(vec![
2859            // Turn 1: two tool calls, both with id "c1"
2860            Completion {
2861                content: "doing two things...".into(),
2862                tool_calls: vec![
2863                    crate::llm::ToolCall {
2864                        id: "c1".into(),
2865                        name: "add".into(),
2866                        arguments: json!({"a": 1, "b": 2}),
2867                    },
2868                    crate::llm::ToolCall {
2869                        id: "c1".into(), // duplicate id
2870                        name: "add".into(),
2871                        arguments: json!({"a": 3, "b": 4}),
2872                    },
2873                ],
2874                finish_reason: Some("tool_calls".into()),
2875                usage: None,
2876                reasoning_content: None,
2877            },
2878            // Turn 1: finish
2879            Completion {
2880                content: "done".into(),
2881                tool_calls: vec![],
2882                finish_reason: Some("stop".into()),
2883                usage: None,
2884                reasoning_content: None,
2885            },
2886        ]));
2887        let tools = ToolRegistry::local().register(Arc::new(Adder));
2888        let (sink, mut rx) = crate::event::ChannelSink::new();
2889        let sink_arc = Arc::new(sink);
2890        let mut rt = AgentRuntime::builder()
2891            .llm(llm)
2892            .tools(tools)
2893            .event_sink(sink_arc)
2894            .build()
2895            .unwrap();
2896
2897        while let Ok(_ev) = rx.try_recv() {}
2898
2899        let _ = rt.run("do it").await.unwrap();
2900
2901        let mut audit_count = 0usize;
2902        while let Ok(ev) = rx.try_recv() {
2903            if matches!(ev, AgentEvent::MessageAppendedWithAudit { .. }) {
2904                audit_count += 1;
2905            }
2906        }
2907
2908        // At least one of the two tool results should carry audit metadata.
2909        assert!(
2910            audit_count >= 1,
2911            "expected at least one tool result to have audit metadata, got {audit_count}"
2912        );
2913    }
2914
2915    // ── Goal-285: DENIAL_LIMIT_SENTINEL double-push regression ──────────
2916
2917    /// When a batch of tool calls includes a `DENIAL_LIMIT_SENTINEL` as the
2918    /// second result, the transcript must have exactly N tool-result messages
2919    /// — not N+duplicates. The pre-Goal-285 code pushed earlier non-sentinel
2920    /// results twice (once in the outer loop, once in the sentinel inner loop),
2921    /// violating Invariant #8 (unique tool-call ↔ tool-result pairing).
2922    #[tokio::test]
2923    async fn denial_limit_sentinel_no_duplicate_pushes() {
2924        use crate::error::Error;
2925
2926        struct DenialTool;
2927
2928        #[async_trait]
2929        impl Tool for DenialTool {
2930            fn spec(&self) -> crate::llm::ToolSpec {
2931                crate::llm::ToolSpec {
2932                    name: "denial_tool".into(),
2933                    description: "always triggers permission denial limit".into(),
2934                    parameters: json!({"type": "object", "properties": {}}),
2935                }
2936            }
2937            async fn execute(&self, _args: Value) -> crate::error::Result<String> {
2938                Err(Error::PermissionDeniedLimit {
2939                    name: "denial_tool".into(),
2940                })
2941            }
2942        }
2943
2944        let llm = Arc::new(MockProvider::new(vec![Completion {
2945            content: "Let me try two things...".into(),
2946            tool_calls: vec![
2947                crate::llm::ToolCall {
2948                    id: "c1".into(),
2949                    name: "add".into(),
2950                    arguments: json!({"a": 1, "b": 2}),
2951                },
2952                crate::llm::ToolCall {
2953                    id: "c2".into(),
2954                    name: "denial_tool".into(),
2955                    arguments: json!({}),
2956                },
2957            ],
2958            finish_reason: Some("tool_calls".into()),
2959            usage: None,
2960            reasoning_content: None,
2961        }]));
2962
2963        let tools = ToolRegistry::local()
2964            .register(Arc::new(Adder))
2965            .register(Arc::new(DenialTool));
2966
2967        let mut rt = AgentRuntime::builder()
2968            .llm(llm)
2969            .tools(tools)
2970            .build()
2971            .unwrap();
2972
2973        let out = rt.run("test").await.unwrap();
2974
2975        // Verify finish reason
2976        assert_eq!(out.finish_reason, FinishReason::PermissionDenialLimit);
2977
2978        // Count tool-result messages in transcript.
2979        // Should have exactly 2 (add + denial_tool), NOT 3.
2980        let tool_msgs: Vec<_> = rt
2981            .transcript()
2982            .iter()
2983            .filter(|m| m.role == crate::message::Role::Tool)
2984            .collect();
2985
2986        assert_eq!(
2987            tool_msgs.len(),
2988            2,
2989            "expected exactly 2 tool-result messages, got {} (double-push bug?)",
2990            tool_msgs.len()
2991        );
2992
2993        // The first tool result ("add") must appear exactly once.
2994        let add_count = tool_msgs
2995            .iter()
2996            .filter(|m| m.tool_call_id.as_deref() == Some("c1"))
2997            .count();
2998        assert_eq!(
2999            add_count, 1,
3000            "add result (c1) should appear exactly once, got {add_count}"
3001        );
3002
3003        // The denial tool result must also appear exactly once.
3004        let denial_count = tool_msgs
3005            .iter()
3006            .filter(|m| m.tool_call_id.as_deref() == Some("c2"))
3007            .count();
3008        assert_eq!(
3009            denial_count, 1,
3010            "denial result (c2) should appear exactly once, got {denial_count}"
3011        );
3012
3013        // Total transcript messages: user(1) + assistant(1) + 2 tool results = 4
3014        assert_eq!(
3015            rt.transcript().len(),
3016            4,
3017            "transcript should have 4 messages (user, assistant, 2× tool), got {}",
3018            rt.transcript().len()
3019        );
3020    }
3021
3022    /// Invariant #8 regression: when stuck detection fires *mid-batch* (the
3023    /// error rate threshold is reached while iterating the results of a
3024    /// multi-call step), the turn must still push a tool_result for EVERY
3025    /// tool_call of the triggering assistant message. The old code returned
3026    /// from inside the result loop before pushing the remaining results,
3027    /// leaving orphaned `tool_use` blocks in the committed transcript — which
3028    /// the provider then rejects on every subsequent turn with HTTP 400
3029    /// ("tool_use ids ... were found without tool_result blocks").
3030    #[tokio::test]
3031    async fn stuck_detection_keeps_tool_calls_paired() {
3032        use crate::error::Error;
3033
3034        struct AlwaysFails;
3035
3036        #[async_trait]
3037        impl Tool for AlwaysFails {
3038            fn spec(&self) -> crate::llm::ToolSpec {
3039                crate::llm::ToolSpec {
3040                    name: "always_fails".into(),
3041                    description: "always returns an error".into(),
3042                    parameters: json!({"type": "object", "properties": {}}),
3043                }
3044            }
3045            async fn execute(&self, _args: Value) -> crate::error::Result<String> {
3046                Err(Error::Tool {
3047                    name: "always_fails".into(),
3048                    call_id: None,
3049                    message: "boom".into(),
3050                })
3051            }
3052        }
3053
3054        // One assistant message with three failing tool_calls. With
3055        // stuck_window=2 and stuck_error_rate=1.0, the second error trips
3056        // the stuck threshold — mid-batch, before the third result is
3057        // processed.
3058        let llm = Arc::new(MockProvider::new(vec![Completion {
3059            content: "Trying three things at once...".into(),
3060            tool_calls: vec![
3061                crate::llm::ToolCall {
3062                    id: "c1".into(),
3063                    name: "always_fails".into(),
3064                    arguments: json!({}),
3065                },
3066                crate::llm::ToolCall {
3067                    id: "c2".into(),
3068                    name: "always_fails".into(),
3069                    arguments: json!({}),
3070                },
3071                crate::llm::ToolCall {
3072                    id: "c3".into(),
3073                    name: "always_fails".into(),
3074                    arguments: json!({}),
3075                },
3076            ],
3077            finish_reason: Some("tool_calls".into()),
3078            usage: None,
3079            reasoning_content: None,
3080        }]));
3081
3082        let tools = ToolRegistry::local().register(Arc::new(AlwaysFails));
3083
3084        let mut rt = AgentRuntime::builder()
3085            .llm(llm)
3086            .tools(tools)
3087            .stuck_window(2)
3088            .stuck_error_rate(1.0)
3089            .build()
3090            .unwrap();
3091
3092        let out = rt.run("go").await.unwrap();
3093
3094        // The turn ends as Stuck (error rate hit the threshold).
3095        assert!(
3096            matches!(out.finish_reason, FinishReason::Stuck { .. }),
3097            "expected Stuck finish, got {:?}",
3098            out.finish_reason
3099        );
3100
3101        // Every one of the assistant's three tool_calls must have a matching
3102        // tool_result, even though stuck fired after the second.
3103        let assistant = rt
3104            .transcript()
3105            .iter()
3106            .find(|m| m.role == crate::message::Role::Assistant && !m.tool_calls.is_empty())
3107            .expect("assistant-with-tool_calls must be in transcript");
3108        let tool_results: Vec<&str> = rt
3109            .transcript()
3110            .iter()
3111            .filter(|m| m.role == crate::message::Role::Tool)
3112            .filter_map(|m| m.tool_call_id.as_deref())
3113            .collect();
3114        for tc in &assistant.tool_calls {
3115            assert!(
3116                tool_results.contains(&tc.id.as_str()),
3117                "tool_call {} has no matching tool_result (orphaned tool_use); results={tool_results:?}",
3118                tc.id
3119            );
3120        }
3121        assert_eq!(
3122            tool_results.len(),
3123            3,
3124            "expected exactly 3 tool_result messages, got {}",
3125            tool_results.len()
3126        );
3127    }
3128
3129    /// Goal 287 / Goal 288: verify that LLM errors propagate correctly.
3130    /// After Goal 288 removed the outer retry loop, the provider's internal
3131    /// `RetryPolicy` handles retries. MockProvider does not retry internally,
3132    /// so a `RateLimited` error surfaces immediately as a run error.
3133    #[tokio::test]
3134    async fn llm_retry_emits_event() {
3135        use crate::event::ChannelSink;
3136
3137        let (sink, _event_rx) = ChannelSink::new();
3138        let sink = Arc::new(sink);
3139
3140        // MockProvider returns a RateLimited error — without an outer retry
3141        // loop, this propagates to the caller.
3142        let provider = Arc::new(
3143            MockProvider::new(vec![Completion {
3144                content: "Hello!".into(),
3145                tool_calls: vec![],
3146                finish_reason: Some("stop".into()),
3147                usage: None,
3148                reasoning_content: None,
3149            }])
3150            .with_errors(vec![crate::error::Error::RateLimited {
3151                provider: "mock".into(),
3152                retry_after_ms: 1,
3153            }]),
3154        );
3155
3156        let mut rt = AgentRuntime::builder()
3157            .llm(provider)
3158            .event_sink(sink)
3159            .build()
3160            .unwrap();
3161
3162        // The error should propagate — retry is now handled at the provider
3163        // layer via `RetryPolicy`, not in `run_core`.
3164        let result = rt.run("hi").await;
3165        assert!(
3166            result.is_err(),
3167            "expected error from RateLimited MockProvider without outer retry loop"
3168        );
3169        let err = result.unwrap_err();
3170        let err_str = format!("{err}");
3171        assert!(
3172            err_str.contains("rate limited"),
3173            "expected rate-limited error, got: {err_str}"
3174        );
3175    }
3176
3177    // ── P0-2: set_event_sink / replace_event_sink side-effect contract ────
3178
3179    /// `replace_event_sink` swaps the runtime sink but must NOT touch the
3180    /// tool registry. This pins down the explicit non-side-effect path —
3181    /// callers that only want to redirect `MessageAppended` / `TurnFinished`
3182    /// events without triggering `TodoWriteTool` / `ExitPlanModeTool`
3183    /// re-registration can use this.
3184    #[tokio::test]
3185    async fn replace_event_sink_does_not_reregister_tools() {
3186        let llm = Arc::new(MockProvider::new(vec![]));
3187        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
3188
3189        // Capture the pre-swap TodoWriteTool Arc identity.
3190        let pre_todo = rt
3191            .kernel
3192            .tools()
3193            .get("TodoWrite")
3194            .expect("TodoWrite is in the default registry")
3195            .clone();
3196
3197        let (new_sink, _rx) = crate::event::ChannelSink::new();
3198        rt.replace_event_sink(Arc::new(new_sink));
3199
3200        // The registry's TodoWriteTool identity is unchanged — no re-register.
3201        let post_todo = rt
3202            .kernel
3203            .tools()
3204            .get("TodoWrite")
3205            .expect("TodoWrite still registered")
3206            .clone();
3207        assert!(
3208            Arc::ptr_eq(&pre_todo, &post_todo),
3209            "replace_event_sink must not re-register TodoWriteTool"
3210        );
3211    }
3212
3213    /// `set_event_sink` keeps its existing side effect: re-registering
3214    /// TodoWriteTool so it points to the new sink. This is the contract
3215    /// every caller (CLI per-turn, HTTP per-session, TUI on backend init)
3216    /// depends on; if you intentionally change it, also audit those callers.
3217    #[tokio::test]
3218    async fn set_event_sink_reregisters_todo_write_tool() {
3219        let llm = Arc::new(MockProvider::new(vec![]));
3220        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
3221
3222        let pre_todo = rt
3223            .kernel
3224            .tools()
3225            .get("TodoWrite")
3226            .expect("TodoWrite registered")
3227            .clone();
3228
3229        let (new_sink, _rx) = crate::event::ChannelSink::new();
3230        rt.set_event_sink(Arc::new(new_sink));
3231
3232        let post_todo = rt
3233            .kernel
3234            .tools()
3235            .get("TodoWrite")
3236            .expect("TodoWrite still registered after set_event_sink")
3237            .clone();
3238        assert!(
3239            !Arc::ptr_eq(&pre_todo, &post_todo),
3240            "set_event_sink MUST re-register TodoWriteTool — this side effect is \
3241             load-bearing for the TUI/CLI/HTTP sink-swap flows; removing it would \
3242             silently drop TodoUpdated events on the new sink."
3243        );
3244    }
3245
3246    // ── is_context_window_exceeded ──────────────────────────────────────────
3247
3248    #[test]
3249    fn context_overflow_detector_matches_known_patterns() {
3250        let cases = [
3251            // OpenAI / NVIDIA NIM error code
3252            "HTTP 400: {\"error\":{\"code\":\"context_length_exceeded\",\"message\":\"too long\"}}",
3253            // OpenAI human message
3254            "HTTP 400: This model's maximum context length is 200000 tokens, however you requested 201234",
3255            // Generic phrasing
3256            "HTTP 400: prompt is too long for the model",
3257            "HTTP 400: tokens exceeds model limit",
3258            "HTTP 400: exceeds the model context window",
3259        ];
3260        for msg in &cases {
3261            let err = crate::error::Error::Llm {
3262                provider: "test".into(),
3263                message: msg.to_string(),
3264            };
3265            assert!(
3266                is_context_window_exceeded(&err),
3267                "should detect context overflow in: {msg}"
3268            );
3269        }
3270    }
3271
3272    #[test]
3273    fn context_overflow_detector_ignores_unrelated_errors() {
3274        let cases = [
3275            "HTTP 400: invalid request body",
3276            "HTTP 401: unauthorized",
3277            "HTTP 429: rate limit exceeded",
3278            "network error: connection refused",
3279        ];
3280        for msg in &cases {
3281            let err = crate::error::Error::Llm {
3282                provider: "test".into(),
3283                message: msg.to_string(),
3284            };
3285            assert!(
3286                !is_context_window_exceeded(&err),
3287                "should NOT detect context overflow in: {msg}"
3288            );
3289        }
3290    }
3291
3292    #[test]
3293    fn context_overflow_detector_ignores_non_llm_errors() {
3294        let err = crate::error::Error::Timeout {
3295            duration_ms: 30_000,
3296        };
3297        assert!(!is_context_window_exceeded(&err));
3298        let err = crate::error::Error::Config {
3299            message: "context_length_exceeded".into(),
3300        };
3301        assert!(
3302            !is_context_window_exceeded(&err),
3303            "Config errors must not be detected even if they contain the keyword"
3304        );
3305    }
3306
3307    // ── cross-turn microcompact (Goal 333) ──────────────────────────────────
3308
3309    #[tokio::test]
3310    async fn cross_turn_microcompact_prunes_before_summary_check() {
3311        // Build a runtime with a Microcompactor (low trigger=2) and a Compactor
3312        // (char threshold high enough that the post-prune transcript would NOT
3313        // trigger the LLM summary). Seed a transcript with many tool results.
3314        // Verify: Microcompact event was emitted AND the compactor's LLM was
3315        // NOT called (summary skipped).
3316        let provider = Arc::new(MockProvider::new(vec![])); // should not be called
3317        let (sink, mut rx) = crate::event::ChannelSink::new();
3318        let sink_arc = Arc::new(sink);
3319
3320        let mc = crate::compact::Microcompactor::new(2, 1); // trigger at 2
3321        let compactor = crate::compact::Compactor::new(usize::MAX); // never fires by char
3322        let mut rt = AgentRuntime::builder()
3323            .llm(provider)
3324            .microcompactor(mc)
3325            .compactor(compactor)
3326            .event_sink(sink_arc)
3327            .build()
3328            .unwrap();
3329
3330        // Seed transcript with 5 tool-result messages (trigger=2, keep=1).
3331        let mut msgs: Vec<Message> = Vec::new();
3332        for i in 0..5 {
3333            msgs.push(Message::user(format!("user {i}")));
3334            msgs.push(Message::assistant(format!("asst {i}")));
3335            msgs.push(Message::tool_result(format!("call_{i}"), "x".repeat(300)));
3336        }
3337        *Arc::make_mut(&mut rt.transcript) = msgs;
3338
3339        // Drain channel events from builder setup.
3340        while rx.try_recv().is_ok() {}
3341
3342        // Call maybe_compact_cross_turn directly.
3343        rt.maybe_compact_cross_turn(&TokenUsage::default())
3344            .await
3345            .unwrap();
3346
3347        // Check for Microcompact event.
3348        let mut microcompact_fired = false;
3349        while let Ok(ev) = rx.try_recv() {
3350            if matches!(ev, AgentEvent::Microcompact { .. }) {
3351                microcompact_fired = true;
3352            }
3353        }
3354
3355        assert!(
3356            microcompact_fired,
3357            "Microcompact event must be emitted when microcompactor prunes"
3358        );
3359    }
3360
3361    #[tokio::test]
3362    async fn cross_turn_microcompact_disabled_when_none() {
3363        // No microcompactor configured → behavior identical to today (no Microcompact).
3364        let provider = Arc::new(MockProvider::new(vec![]));
3365        let (sink, mut rx) = crate::event::ChannelSink::new();
3366        let sink_arc = Arc::new(sink);
3367
3368        // Build runtime with ONLY a compactor (threshold=MAX so it never fires),
3369        // but NO microcompactor.
3370        let compactor = crate::compact::Compactor::new(usize::MAX);
3371        let mut rt = AgentRuntime::builder()
3372            .llm(provider)
3373            .compactor(compactor)
3374            .event_sink(sink_arc)
3375            .build()
3376            .unwrap();
3377
3378        // Seed transcript with many tool results.
3379        let mut msgs: Vec<Message> = Vec::new();
3380        for i in 0..5 {
3381            msgs.push(Message::user(format!("user {i}")));
3382            msgs.push(Message::assistant(format!("asst {i}")));
3383            msgs.push(Message::tool_result(format!("call_{i}"), "x".repeat(300)));
3384        }
3385        *Arc::make_mut(&mut rt.transcript) = msgs;
3386
3387        while rx.try_recv().is_ok() {}
3388
3389        // Call maybe_compact_cross_turn — it has no microcompactor, so the
3390        // microcompact block is skipped.
3391        rt.maybe_compact_cross_turn(&TokenUsage::default())
3392            .await
3393            .unwrap();
3394
3395        let mut microcompact_fired = false;
3396        while let Ok(ev) = rx.try_recv() {
3397            if matches!(ev, AgentEvent::Microcompact { .. }) {
3398                microcompact_fired = true;
3399            }
3400        }
3401
3402        assert!(
3403            !microcompact_fired,
3404            "Microcompact must NOT fire when no microcompactor is configured"
3405        );
3406    }
3407
3408    // ── compact_on_overflow ─────────────────────────────────────────────────
3409
3410    #[tokio::test]
3411    async fn compact_on_overflow_compacts_long_transcript() {
3412        let summary_resp = Completion {
3413            content: "Summary of prior conversation.".into(),
3414            tool_calls: vec![],
3415            finish_reason: Some("stop".into()),
3416            usage: None,
3417            reasoning_content: None,
3418        };
3419        let llm = Arc::new(MockProvider::new(vec![summary_resp]));
3420        let mut rt = AgentRuntime::builder()
3421            .llm(llm)
3422            .compactor(crate::Compactor::new(usize::MAX))
3423            .build()
3424            .unwrap();
3425
3426        // Populate a transcript long enough for compaction (> keep_recent_n + 2).
3427        let msgs: Vec<crate::message::Message> = (0..14)
3428            .map(|i| {
3429                if i % 2 == 0 {
3430                    crate::message::Message::user(format!("msg {i}"))
3431                } else {
3432                    crate::message::Message::assistant(format!("reply {i}"))
3433                }
3434            })
3435            .collect();
3436        *Arc::make_mut(&mut rt.transcript) = msgs;
3437        let before = rt.transcript.len();
3438
3439        let compacted = rt.compact_on_overflow().await.unwrap();
3440        assert!(compacted, "should return true when compaction ran");
3441        assert!(
3442            rt.transcript.len() < before,
3443            "transcript must shrink after compaction"
3444        );
3445        assert_eq!(
3446            rt.transcript[0].role,
3447            crate::message::Role::System,
3448            "first message after compaction is the summary"
3449        );
3450    }
3451
3452    #[tokio::test]
3453    async fn compact_on_overflow_returns_false_without_compactor() {
3454        let llm = Arc::new(MockProvider::new(vec![]));
3455        let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
3456        let ok = rt.compact_on_overflow().await.unwrap();
3457        assert!(!ok, "no compactor → must return false");
3458    }
3459
3460    #[tokio::test]
3461    async fn compact_on_overflow_rejects_degenerate_transcript_without_hook_events() {
3462        struct CompactionHookRecorder(Arc<std::sync::Mutex<Vec<&'static str>>>);
3463
3464        impl crate::hooks::Hook for CompactionHookRecorder {
3465            fn on_event(&self, event: HookEvent) -> crate::hooks::HookAction {
3466                match event {
3467                    HookEvent::PreCompact { .. } => {
3468                        self.0.lock().unwrap().push("PreCompact");
3469                    }
3470                    HookEvent::PostCompact { .. } => {
3471                        self.0.lock().unwrap().push("PostCompact");
3472                    }
3473                    _ => {}
3474                }
3475                crate::hooks::HookAction::Continue
3476            }
3477        }
3478
3479        let llm = Arc::new(MockProvider::new(vec![]));
3480        let hook_events = Arc::new(std::sync::Mutex::new(Vec::new()));
3481        let mut hooks = HookRegistry::new();
3482        hooks.register(Arc::new(CompactionHookRecorder(hook_events.clone())));
3483        let mut rt = AgentRuntime::builder()
3484            .llm(llm.clone())
3485            .hooks(hooks)
3486            .compactor(Compactor::new(usize::MAX).keep_recent_n(8))
3487            .build()
3488            .unwrap();
3489
3490        // The older slice contains only the system prompt, so it cannot be
3491        // summarized. This is long enough to reach the compaction guard.
3492        *Arc::make_mut(&mut rt.transcript) = vec![
3493            Message::system("System prompt".to_string()),
3494            Message::user("Add a feature".to_string()),
3495            Message::assistant("Working on it".to_string()),
3496            Message::user("Status?".to_string()),
3497            Message::assistant("Almost done".to_string()),
3498            Message::user("Run tests".to_string()),
3499            Message::assistant("Tests pass".to_string()),
3500            Message::user("Commit".to_string()),
3501            Message::assistant("Done".to_string()),
3502        ];
3503
3504        assert!(
3505            !rt.compact_on_overflow().await.unwrap(),
3506            "a degenerate transcript must reject emergency compaction"
3507        );
3508        assert!(
3509            hook_events.lock().unwrap().is_empty(),
3510            "a rejected compaction must emit neither PreCompact nor PostCompact"
3511        );
3512        assert!(
3513            llm.calls().is_empty(),
3514            "a rejected compaction must not call the provider"
3515        );
3516    }
3517
3518    /// Context-overflow error recovery integration test.
3519    ///
3520    /// Scenario:
3521    ///   1. Agent transcript is long (14 msgs, enough to compact).
3522    ///   2. First LLM call fails with a `context_length_exceeded` error.
3523    ///   3. `compact_on_overflow` fires: summarises older messages (uses
3524    ///      scripted completion [0] for the summary).
3525    ///   4. Retry uses scripted completion [1] and succeeds.
3526    #[tokio::test]
3527    async fn context_overflow_triggers_compact_and_retry() {
3528        let overflow_err = crate::error::Error::Llm {
3529            provider: "test-model".into(),
3530            message: "HTTP 400: {\"error\":{\"code\":\"context_length_exceeded\",\
3531                      \"message\":\"maximum context length is 200000 tokens\"}}"
3532                .into(),
3533        };
3534        let llm = Arc::new(
3535            MockProvider::new(vec![
3536                // [0] compaction summary
3537                Completion {
3538                    content: "Prior conversation summary for test.".into(),
3539                    tool_calls: vec![],
3540                    finish_reason: Some("stop".into()),
3541                    usage: None,
3542                    reasoning_content: None,
3543                },
3544                // [1] agent reply after successful retry
3545                Completion {
3546                    content: "Reply after emergency compaction.".into(),
3547                    tool_calls: vec![],
3548                    finish_reason: Some("stop".into()),
3549                    usage: None,
3550                    reasoning_content: None,
3551                },
3552            ])
3553            .with_errors(vec![overflow_err]),
3554        );
3555
3556        let mut rt = AgentRuntime::builder()
3557            .llm(llm)
3558            .compactor(crate::Compactor::new(usize::MAX))
3559            .build()
3560            .unwrap();
3561
3562        // Pre-populate transcript (> keep_recent_n + 2 = 10) so compaction can run.
3563        let msgs: Vec<Message> = (0..14)
3564            .map(|i| {
3565                if i % 2 == 0 {
3566                    Message::user(format!("prior msg {i}"))
3567                } else {
3568                    Message::assistant(format!("prior reply {i}"))
3569                }
3570            })
3571            .collect();
3572        *Arc::make_mut(&mut rt.transcript) = msgs;
3573
3574        let outcome = rt.run("test: overflow recovery").await.unwrap();
3575        assert_eq!(
3576            outcome.final_text.as_deref(),
3577            Some("Reply after emergency compaction."),
3578            "run() must return the retry's reply"
3579        );
3580
3581        // After recovery, the transcript's first message should be the compaction summary.
3582        assert_eq!(
3583            rt.transcript[0].role,
3584            crate::message::Role::System,
3585            "transcript must start with compaction summary after overflow recovery"
3586        );
3587        assert!(
3588            rt.transcript[0].is_compaction_summary,
3589            "summary message must be flagged as compaction_summary"
3590        );
3591    }
3592
3593    // ── Goal-340: cross-turn compaction re-injects plan and todos ─────────
3594
3595    #[tokio::test]
3596    async fn cross_turn_compaction_reinjects_plan_and_todos() {
3597        let provider = Arc::new(MockProvider::new(vec![Completion {
3598            content: "Summary of prior conversation.".into(),
3599            tool_calls: vec![],
3600            finish_reason: Some("stop".into()),
3601            usage: None,
3602            reasoning_content: None,
3603        }]));
3604
3605        let mut rt = AgentRuntime::builder()
3606            .llm(provider)
3607            .compactor(crate::Compactor::new(0)) // always compact
3608            .build()
3609            .unwrap();
3610
3611        // Seed pending plan via the runtime's shared plan gate.
3612        rt.plan_approval_gate
3613            .begin_approval("Step 1: explore\nStep 2: implement".to_string());
3614
3615        // Seed todos via the runtime's shared todo list.
3616        {
3617            let mut todos = rt.todo_list.write().unwrap();
3618            todos.push(TodoItem {
3619                content: "Read files".to_string(),
3620                status: TodoStatus::Completed,
3621                active_form: None,
3622            });
3623            todos.push(TodoItem {
3624                content: "Edit code".to_string(),
3625                status: TodoStatus::InProgress,
3626                active_form: Some("Editing code...".to_string()),
3627            });
3628            todos.push(TodoItem {
3629                content: "Run tests".to_string(),
3630                status: TodoStatus::Pending,
3631                active_form: None,
3632            });
3633        }
3634
3635        // Populate transcript long enough for compaction.
3636        let msgs: Vec<Message> = (0..14)
3637            .map(|i| {
3638                if i % 2 == 0 {
3639                    Message::user(format!("msg {i}"))
3640                } else {
3641                    Message::assistant(format!("reply {i}"))
3642                }
3643            })
3644            .collect();
3645        *Arc::make_mut(&mut rt.transcript) = msgs;
3646
3647        rt.maybe_compact_cross_turn(&TokenUsage::default())
3648            .await
3649            .unwrap();
3650
3651        let transcript = rt.transcript();
3652
3653        // Transcript order: [summary, plan-att, todo-att, ...preserved]
3654        assert!(
3655            transcript.len() >= 3,
3656            "at least summary + 2 atts + preserved"
3657        );
3658        assert_eq!(transcript[0].role, crate::message::Role::System);
3659        assert!(
3660            transcript[0].is_compaction_summary,
3661            "first message is the compaction summary"
3662        );
3663
3664        // Find the plan and todo attachments.
3665        let plan_msg = transcript
3666            .iter()
3667            .find(|m| m.content.starts_with("[post-compact plan restore]"))
3668            .expect("plan restore message must be present");
3669
3670        assert!(plan_msg.content.contains("Step 1: explore"));
3671        assert!(plan_msg.content.contains("You are in plan mode"));
3672
3673        let todo_msg = transcript
3674            .iter()
3675            .find(|m| m.content.starts_with("[post-compact todo restore]"))
3676            .expect("todo restore message must be present");
3677
3678        assert!(todo_msg.content.contains("- [x] Read files"));
3679        assert!(todo_msg.content.contains("- [/] Edit code"));
3680        assert!(todo_msg.content.contains("(active: Editing code...)"));
3681        assert!(todo_msg.content.contains("- [ ] Run tests"));
3682
3683        // Plan attachment should be before the todo attachment (plan first).
3684        let plan_idx = transcript
3685            .iter()
3686            .position(|m| m.content.starts_with("[post-compact plan restore]"))
3687            .unwrap();
3688        let todo_idx = transcript
3689            .iter()
3690            .position(|m| m.content.starts_with("[post-compact todo restore]"))
3691            .unwrap();
3692        assert!(
3693            plan_idx < todo_idx,
3694            "plan attachment must come before todo attachment"
3695        );
3696    }
3697}