Skip to main content

harness/
agent_loop.rs

1use async_trait::async_trait;
2use futures::{stream::StreamExt, FutureExt};
3use serde_json::{json, Value};
4use std::sync::Arc;
5use std::time::Duration;
6use tokio::sync::mpsc;
7use tokio_util::sync::CancellationToken;
8
9use crate::compaction::{
10    estimate_messages_tokens, CompactionContext, CompactionStrategy, SummarizeCompactionStrategy,
11};
12use crate::event::{HarnessInternalEvent, HarnessUsage, NativeHarnessError, NativeTurnInput};
13use crate::model::{
14    AssistantThinking, CapabilitySupport, ChatMessage, HostedCapability, HostedTool, ModelChunk,
15    ModelClient, ModelClientError, ModelTurnInput,
16};
17use crate::runner::NativeHarness;
18use crate::tools::{
19    bounded::BoundedToolRuntime, ToolFailure, ToolFailureKind, ToolInvocation, ToolOutcome,
20    ToolRuntime, ToolRuntimeError, ToolSpec,
21};
22
23/// Optional compaction wiring: strategy + the model client used to run
24/// the summarize request + the resolved context-window cap. All three
25/// must travel together; without any of them the loop can't make a
26/// useful compaction decision. `AgentLoopHarness::with_compaction`
27/// installs it once and the per-turn loop checks it between steps.
28#[derive(Clone)]
29pub struct CompactionPolicy {
30    pub strategy: Arc<dyn CompactionStrategy>,
31    /// Model client used by `strategy.compact` to run the summarize
32    /// request. Usually the same provider as the main turn model so the
33    /// Anthropic cache prefix stays hot; tests may swap in a fake.
34    pub model_client: Arc<dyn ModelClient>,
35    pub context_window_tokens: u64,
36}
37
38impl CompactionPolicy {
39    /// Build a compaction policy from a custom strategy.
40    ///
41    /// The strategy receives the full message history plus a
42    /// [`CompactionContext`] containing `model_client`, `context_window_tokens`,
43    /// and the current tool specs. Custom strategies may ignore the model
44    /// client entirely, or use it to produce their own summaries.
45    pub fn new(
46        strategy: Arc<dyn CompactionStrategy>,
47        model_client: Arc<dyn ModelClient>,
48        context_window_tokens: u64,
49    ) -> Self {
50        Self {
51            strategy,
52            model_client,
53            context_window_tokens,
54        }
55    }
56
57    /// Build the default summarizing compaction policy used by the harness.
58    ///
59    /// This preserves the existing compaction behavior: old oversized tool
60    /// outputs are pruned first, and when pruning is insufficient the history is
61    /// folded with [`SummarizeCompactionStrategy::default`].
62    pub fn summarizing(model_client: Arc<dyn ModelClient>, context_window_tokens: u64) -> Self {
63        Self::new(
64            Arc::new(SummarizeCompactionStrategy::default()),
65            model_client,
66            context_window_tokens,
67        )
68    }
69}
70
71/// Default mid-stream idle timeout: how long `consume_step_stream` waits
72/// for the *next* model chunk before declaring the connection stalled.
73/// Generous enough to cover extended-thinking pauses (a model can legitimately
74/// go silent for tens of seconds while reasoning) yet bounded so a silently
75/// wedged upstream (TCP open, no FIN/RST, no bytes) can't park the turn forever.
76const DEFAULT_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
77
78/// Default stream-layer reconnect budget — how many times we re-establish the
79/// SSE stream after a stall / mid-stream transport drop *before any output has
80/// reached the user*. Separate from `MAX_RETRIES` (the request-establish
81/// budget); stream re-establishment tends to succeed on retry since the
82/// failure is usually a transient gateway / long-lived-connection hiccup, so
83/// this is set higher (6).
84const DEFAULT_STREAM_MAX_ATTEMPTS: u32 = 6;
85
86/// Selects how web search is exposed for an agent turn.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum WebSearchMode {
90    /// Do not expose either provider-hosted or harness-managed web search.
91    #[default]
92    Off,
93    /// Prefer proven provider-hosted search, otherwise use a managed
94    /// `web_search` function tool when the runtime supplies one.
95    Auto,
96    /// Require provider-hosted search. Custom clients reporting `Unknown` are
97    /// allowed to attempt it; explicit `Unsupported` support fails fast.
98    Native,
99    /// Require a harness-managed `web_search` function tool.
100    Managed,
101}
102
103#[derive(Clone)]
104pub struct AgentLoopHarness<M, R> {
105    model: M,
106    /// Every runtime is wrapped so repair + validation + tracing + the
107    /// safety-net output cap apply uniformly, regardless of which concrete
108    /// runtime the caller passed to [`AgentLoopHarness::new`].
109    tools: BoundedToolRuntime<R>,
110    max_steps: usize,
111    compaction: Option<CompactionPolicy>,
112    tool_choice: crate::model::ToolChoice,
113    web_search: WebSearchMode,
114    parallel_tool_calls: Option<bool>,
115    stream_idle_timeout: Duration,
116    stream_max_attempts: u32,
117}
118
119impl<M, R: ToolRuntime> AgentLoopHarness<M, R> {
120    pub fn new(model: M, tools: R) -> Self {
121        Self {
122            model,
123            tools: BoundedToolRuntime::new(tools),
124            max_steps: 8,
125            compaction: None,
126            tool_choice: crate::model::ToolChoice::Auto,
127            web_search: WebSearchMode::Off,
128            parallel_tool_calls: None,
129            stream_idle_timeout: DEFAULT_STREAM_IDLE_TIMEOUT,
130            stream_max_attempts: DEFAULT_STREAM_MAX_ATTEMPTS,
131        }
132    }
133
134    /// Cap the number of LLM steps per turn. `0` means unlimited — the
135    /// loop only ends when the model stops calling tools (or on
136    /// cancel/error), so callers passing `0` should keep their own
137    /// liveness backstop (idle/wall-clock) around the turn.
138    pub fn with_max_steps(mut self, max_steps: usize) -> Self {
139        self.max_steps = max_steps;
140        self
141    }
142
143    /// Attach a compaction policy. The loop will call
144    /// `policy.strategy.should_compact` before every step and run
145    /// `policy.strategy.compact` when it fires. Without a policy
146    /// installed the loop never compacts — fine for short / test
147    /// conversations, fatal for long production sessions.
148    pub fn with_compaction(mut self, policy: CompactionPolicy) -> Self {
149        self.compaction = Some(policy);
150        self
151    }
152
153    /// Constrain how the model selects tools this turn.
154    /// Defaults to `Auto`. See `ToolChoice` for variants.
155    pub fn with_tool_choice(mut self, choice: crate::model::ToolChoice) -> Self {
156        self.tool_choice = choice;
157        self
158    }
159
160    /// Configure web-search routing. Search is `Off` by default because it may
161    /// incur network egress and provider charges.
162    pub fn with_web_search(mut self, mode: WebSearchMode) -> Self {
163        self.web_search = mode;
164        self
165    }
166
167    /// OpenAI-only: whether the model may emit multiple `tool_use`
168    /// blocks in one response. `None` ⇒ provider default (true on
169    /// OpenAI). Ignored by Anthropic (multi tool_use is implicit).
170    pub fn with_parallel_tool_calls(mut self, parallel: Option<bool>) -> Self {
171        self.parallel_tool_calls = parallel;
172        self
173    }
174
175    /// Override mid-stream resilience knobs. `idle_timeout` is how long a step
176    /// waits for the next model chunk before declaring a stall;
177    /// `max_attempts` is the stream-layer reconnect budget (total stream
178    /// attempts, so `max_attempts = 1` disables reconnection). Primarily for
179    /// tests, which inject a sub-second timeout so a stall surfaces fast
180    /// instead of after the 90s production default.
181    pub fn with_stream_resilience(mut self, idle_timeout: Duration, max_attempts: u32) -> Self {
182        self.stream_idle_timeout = idle_timeout;
183        self.stream_max_attempts = max_attempts.max(1);
184        self
185    }
186}
187
188#[async_trait]
189impl<M, R> NativeHarness for AgentLoopHarness<M, R>
190where
191    M: ModelClient + Clone + Send + Sync + 'static,
192    R: ToolRuntime + Clone + Send + Sync + 'static,
193{
194    async fn run_turn(
195        &self,
196        input: NativeTurnInput,
197    ) -> Result<mpsc::Receiver<Result<HarnessInternalEvent, NativeHarnessError>>, NativeHarnessError>
198    {
199        let (tx, rx) = mpsc::channel(16);
200        let model = self.model.clone();
201        let tools = self.tools.clone();
202        let max_steps = self.max_steps;
203        let compaction = self.compaction.clone();
204        let tool_choice = self.tool_choice.clone();
205        let web_search = self.web_search;
206        let parallel_tool_calls = self.parallel_tool_calls;
207        let stream_idle_timeout = self.stream_idle_timeout;
208        let stream_max_attempts = self.stream_max_attempts;
209
210        tokio::spawn(async move {
211            let tx_for_panic = tx.clone();
212            let result = std::panic::AssertUnwindSafe(run_loop(
213                model,
214                tools,
215                RunLoopConfig {
216                    max_steps,
217                    compaction,
218                    tool_choice,
219                    web_search,
220                    parallel_tool_calls,
221                    stream_idle_timeout,
222                    stream_max_attempts,
223                },
224                input,
225                tx,
226            ))
227            .catch_unwind()
228            .await;
229            if let Err(payload) = result {
230                let detail = panic_payload_to_string(payload.as_ref());
231                tracing::error!(
232                    target: "harness::agent_loop",
233                    panic = %detail,
234                    "native agent loop panicked"
235                );
236                let _ = tx_for_panic
237                    .send(Err(NativeHarnessError::Failed(format!(
238                        "agent loop panicked: {detail}"
239                    ))))
240                    .await;
241            }
242        });
243
244        Ok(rx)
245    }
246}
247
248/// Test whether the cancel token (if any) has been signalled.
249fn cancel_fired(token: Option<&CancellationToken>) -> bool {
250    token.is_some_and(|t| t.is_cancelled())
251}
252
253fn is_silent_stop(text: &str, stop_reason: &str) -> bool {
254    text.trim().is_empty() && matches!(stop_reason, "end_turn" | "max_tokens")
255}
256
257struct RunLoopConfig {
258    max_steps: usize,
259    compaction: Option<CompactionPolicy>,
260    tool_choice: crate::model::ToolChoice,
261    web_search: WebSearchMode,
262    parallel_tool_calls: Option<bool>,
263    stream_idle_timeout: Duration,
264    stream_max_attempts: u32,
265}
266
267fn resolve_web_search_tools(
268    mode: WebSearchMode,
269    native_support: CapabilitySupport,
270    mut tools: Vec<ToolSpec>,
271) -> Result<(Vec<ToolSpec>, Vec<HostedTool>), String> {
272    let has_managed = tools.iter().any(|tool| tool.name == "web_search");
273    let remove_managed = |tools: &mut Vec<ToolSpec>| {
274        tools.retain(|tool| tool.name != "web_search");
275    };
276
277    match mode {
278        WebSearchMode::Off => {
279            remove_managed(&mut tools);
280            Ok((tools, vec![]))
281        }
282        WebSearchMode::Auto if native_support == CapabilitySupport::Supported => {
283            remove_managed(&mut tools);
284            Ok((tools, vec![HostedTool::WebSearch]))
285        }
286        WebSearchMode::Auto if has_managed => Ok((tools, vec![])),
287        WebSearchMode::Auto => Ok((tools, vec![])),
288        WebSearchMode::Native if native_support == CapabilitySupport::Unsupported => Err(
289            "web search mode is Native, but the model client does not support hosted web search"
290                .into(),
291        ),
292        WebSearchMode::Native => {
293            remove_managed(&mut tools);
294            Ok((tools, vec![HostedTool::WebSearch]))
295        }
296        WebSearchMode::Managed if !has_managed => Err(
297            "web search mode is Managed, but the tool runtime does not provide `web_search`".into(),
298        ),
299        WebSearchMode::Managed => Ok((tools, vec![])),
300    }
301}
302
303async fn run_loop<M, R>(
304    model: M,
305    tools: R,
306    config: RunLoopConfig,
307    input: NativeTurnInput,
308    tx: mpsc::Sender<Result<HarnessInternalEvent, NativeHarnessError>>,
309) where
310    M: ModelClient + Send + Sync,
311    R: ToolRuntime + Clone + Send + Sync + 'static,
312{
313    let system_prompt = input.system_prompt.clone();
314    let cancel_token = input.cancel_token.clone();
315    let context_path = input.context_path.clone();
316    // Snapshot and route web-search tools once per turn. This prevents native
317    // and managed `web_search` from being advertised together and keeps the
318    // provider prompt prefix stable across the inner tool loop.
319    let (tools_snapshot, hosted_tools) = match resolve_web_search_tools(
320        config.web_search,
321        model.hosted_capability(HostedCapability::WebSearch),
322        tools.specs(),
323    ) {
324        Ok(selection) => selection,
325        Err(message) => {
326            let _ = tx
327                .send(Err(NativeHarnessError::ModelBadRequest(message)))
328                .await;
329            return;
330        }
331    };
332    // Seed history: load from context JSONL when a path is provided
333    // (persistent mode), otherwise use the in-memory prior_messages.
334    let mut messages: Vec<ChatMessage> = if let Some(ref path) = context_path {
335        crate::context::jsonl::load_context(path).await
336    } else {
337        input.prior_messages
338    };
339    messages.push(ChatMessage::User {
340        content: input.prompt_text,
341        attachments: input.attachments,
342    });
343    // Cursor: how many messages have been flushed to the context JSONL.
344    // Set to messages.len() after the initial User flush (Some path),
345    // or 0 when running in-memory (None — ctx_written is never read).
346    let mut ctx_written: usize = match context_path.as_deref() {
347        None => 0,
348        Some(path) => {
349            let start = messages.len() - 1;
350            crate::context::jsonl::append_context(path, &messages[start..]).await;
351            messages.len()
352        }
353    };
354    // Per-turn accumulated token usage. Each model call may report a
355    // fresh `HarnessUsage` (provider reports per-call counts, not deltas);
356    // we sum them so `TurnEnd.usage` reflects what the whole turn cost.
357    let mut total_usage = HarnessUsage::default();
358    let mut saw_any_usage = false;
359
360    // Fired by `cancel_token.cancel()` from RD on InterruptDispatch.
361    // Emits a single TurnEnd{interrupt} and returns. We check at three
362    // load-bearing points: before each step, before tool dispatch, and
363    // (cheapest of all) inside `consume_step_stream`'s select! on every
364    // chunk await.
365    macro_rules! check_cancel {
366        () => {
367            if cancel_fired(cancel_token.as_ref()) {
368                let _ = tx
369                    .send(Ok(HarnessInternalEvent::TurnEnd {
370                        stop_reason: "interrupt".into(),
371                        usage: saw_any_usage.then(|| total_usage.clone()),
372                        final_messages: if context_path.is_none() {
373                            messages.clone()
374                        } else {
375                            vec![]
376                        },
377                    }))
378                    .await;
379                return;
380            }
381        };
382    }
383
384    for step in 0.. {
385        // max_steps == 0 ⇒ unlimited: only the model finishing (or
386        // cancel/error) ends the turn. Otherwise break to the trailing
387        // TurnEnd{max_turns} once the cap is hit.
388        if config.max_steps != 0 && step >= config.max_steps {
389            break;
390        }
391        check_cancel!();
392        // Compaction check — purely additive, never fails the turn. If
393        // the strategy errors out (e.g. provider returned empty
394        // summary), we leave `messages` untouched and let the next
395        // step / turn try again. This keeps "context overflow" as the
396        // worst case: HR sees a model error and decides how to react.
397        if let Some(policy) = &config.compaction {
398            if policy
399                .strategy
400                .should_compact(&messages, policy.context_window_tokens)
401            {
402                let original_count = messages.len();
403                let original_tokens = estimate_messages_tokens(&messages);
404                let cctx = CompactionContext {
405                    system_prompt: system_prompt.clone(),
406                    model_client: policy.model_client.clone(),
407                    context_window_tokens: policy.context_window_tokens,
408                    tools: tools_snapshot.clone(),
409                };
410                match policy.strategy.compact(messages.clone(), &cctx).await {
411                    Ok(outcome) => {
412                        let compacted_count = outcome.messages.len();
413                        let compacted_tokens = estimate_messages_tokens(&outcome.messages);
414                        messages = outcome.messages;
415                        // Compaction summarize-call usage attributed two
416                        // places: into the turn-level total (so HR sees
417                        // the full cost) AND into compaction_*_tokens
418                        // sub-buckets (so HR can isolate what compaction
419                        // alone cost).
420                        if let Some(u) = outcome.usage.as_ref() {
421                            saw_any_usage = true;
422                            total_usage.input_tokens += u.input_tokens;
423                            total_usage.output_tokens += u.output_tokens;
424                            total_usage.cache_read_input_tokens += u.cache_read_input_tokens;
425                            total_usage.cache_creation_input_tokens +=
426                                u.cache_creation_input_tokens;
427                            total_usage.compaction_input_tokens += u.input_tokens;
428                            total_usage.compaction_output_tokens += u.output_tokens;
429                        }
430                        // Structured tracing for operators / dashboards.
431                        // Token counts are estimator output (4 chars/token),
432                        // not provider-reported — labelled in field name.
433                        tracing::info!(
434                            target: "harness::compaction",
435                            step,
436                            original_message_count = original_count,
437                            compacted_message_count = compacted_count,
438                            original_estimated_tokens = original_tokens,
439                            compacted_estimated_tokens = compacted_tokens,
440                            context_window_tokens = policy.context_window_tokens,
441                            "compaction applied"
442                        );
443                        // Rewrite the context JSONL with the compacted history.
444                        if let Some(ref path) = context_path {
445                            crate::context::jsonl::rewrite_context(path, &messages).await;
446                            ctx_written = messages.len();
447                        }
448                        if tx
449                            .send(Ok(HarnessInternalEvent::CompactionApplied {
450                                original_message_count: original_count,
451                                compacted_message_count: compacted_count,
452                                original_tokens,
453                                compacted_tokens,
454                            }))
455                            .await
456                            .is_err()
457                        {
458                            return;
459                        }
460                    }
461                    Err(e) => {
462                        tracing::warn!(
463                            target: "harness::compaction",
464                            step,
465                            error = %e,
466                            "compaction skipped; history retained as-is, model call may now fail with context overflow"
467                        );
468                    }
469                }
470            }
471        }
472
473        // ── Model call with retry for transient errors ────────────────────────
474        // Non-retryable errors (bad config, auth, context overflow) surface
475        // immediately. Retryable errors (rate-limit, network, 5xx) back off
476        // exponentially up to MAX_RETRIES before giving up.
477        const MAX_RETRIES: u32 = 3;
478        const BASE_BACKOFF_MS: u64 = 1_000;
479        const MAX_BACKOFF_MS: u64 = 16_000;
480
481        let model_input = ModelTurnInput {
482            system_prompt: system_prompt.clone(),
483            messages: messages.clone(),
484            tools: tools_snapshot.clone(),
485            hosted_tools: hosted_tools.clone(),
486            tool_choice: config.tool_choice.clone(),
487            parallel_tool_calls: config.parallel_tool_calls,
488        };
489
490        // Per-step stream lifecycle with two independent retry budgets:
491        //   * establish — `model.stream()` erroring before any stream exists.
492        //     Retried up to MAX_RETRIES (request-layer transient faults).
493        //   * consume — a stall / drop *mid-stream*. Retried up to
494        //     `stream_max_attempts`, but ONLY while `had_progress == false`:
495        //     once output has reached the user, re-issuing the request would
496        //     duplicate it, so a mid-stream failure becomes terminal.
497        // The two are nested: each reconnect re-runs establishment (with its
498        // own request-layer retry) before consuming again.
499        let mut stream_attempt = 0u32;
500        let outcome = 'stream: loop {
501            let stream = {
502                let mut attempt = 0u32;
503                loop {
504                    match model.stream(model_input.clone()).await {
505                        Ok(s) => break s,
506                        Err(e) => {
507                            if e.retryable() && attempt < MAX_RETRIES {
508                                let delay_ms =
509                                    (BASE_BACKOFF_MS * (1 << attempt)).min(MAX_BACKOFF_MS);
510                                tracing::warn!(
511                                    attempt,
512                                    delay_ms,
513                                    error = %e,
514                                    "model call failed (retryable) — backing off"
515                                );
516                                if !backoff_sleep(delay_ms, cancel_token.as_ref()).await {
517                                    let _ = tx
518                                        .send(Err(NativeHarnessError::ModelOther(
519                                            "interrupted during retry backoff".into(),
520                                        )))
521                                        .await;
522                                    return;
523                                }
524                                attempt += 1;
525                            } else {
526                                // Non-retryable (config error, auth, etc.) or retries exhausted.
527                                // Surface the error immediately so the user can act on it.
528                                tracing::error!(
529                                    attempt,
530                                    error = %e,
531                                    retryable = e.retryable(),
532                                    "model call failed — terminating turn"
533                                );
534                                let _ = tx.send(Err(model_error_to_native(e))).await;
535                                return;
536                            }
537                        }
538                    }
539                }
540            };
541
542            // Consume the per-step stream: forward TextDelta chunks live
543            // (token-level emit) and accumulate the tool-call state so we
544            // can either dispatch a tool or finalise a message at the end.
545            // The idle watchdog inside fires if the stream goes silent.
546            match consume_step_stream(
547                stream,
548                &tx,
549                step,
550                cancel_token.as_ref(),
551                config.stream_idle_timeout,
552            )
553            .await
554            {
555                Ok(StepDrain::Complete(o)) => break 'stream o,
556                Ok(StepDrain::Cancelled) => {
557                    let _ = tx
558                        .send(Ok(HarnessInternalEvent::TurnEnd {
559                            stop_reason: "interrupt".into(),
560                            usage: saw_any_usage.then(|| total_usage.clone()),
561                            final_messages: if context_path.is_none() {
562                                messages.clone()
563                            } else {
564                                vec![]
565                            },
566                        }))
567                        .await;
568                    return;
569                }
570                Err(StepFailure::Model { err, had_progress }) => {
571                    // Reconnect only when nothing has reached the user yet, the
572                    // fault is transient, and the stream budget isn't spent.
573                    if !had_progress
574                        && err.retryable()
575                        && stream_attempt + 1 < config.stream_max_attempts
576                    {
577                        let delay_ms =
578                            (BASE_BACKOFF_MS * (1 << stream_attempt)).min(MAX_BACKOFF_MS);
579                        tracing::warn!(
580                            step,
581                            stream_attempt,
582                            delay_ms,
583                            error = %err,
584                            "model stream failed before any output — reconnecting"
585                        );
586                        if !backoff_sleep(delay_ms, cancel_token.as_ref()).await {
587                            let _ = tx
588                                .send(Err(NativeHarnessError::ModelOther(
589                                    "interrupted during stream reconnect backoff".into(),
590                                )))
591                                .await;
592                            return;
593                        }
594                        stream_attempt += 1;
595                        continue 'stream;
596                    }
597                    // Terminal: output already emitted, non-retryable, or budget
598                    // exhausted. Surface so the user / HR can act on it.
599                    tracing::error!(
600                        step,
601                        stream_attempt,
602                        error = %err,
603                        had_progress,
604                        retryable = err.retryable(),
605                        "model stream failed — terminating turn"
606                    );
607                    let _ = tx.send(Err(model_error_to_native(err))).await;
608                    return;
609                }
610                Err(StepFailure::ChannelClosed) => return,
611                Err(StepFailure::Fatal(e)) => {
612                    let _ = tx.send(Err(e)).await;
613                    return;
614                }
615            }
616        };
617
618        if let Some(u) = outcome.usage.as_ref() {
619            saw_any_usage = true;
620            total_usage.input_tokens += u.input_tokens;
621            total_usage.output_tokens += u.output_tokens;
622            total_usage.cache_read_input_tokens += u.cache_read_input_tokens;
623            total_usage.cache_creation_input_tokens += u.cache_creation_input_tokens;
624        }
625
626        match outcome.next {
627            StepNext::Message { text, stop_reason } => {
628                if is_silent_stop(&text, &stop_reason) {
629                    let _ = tx
630                        .send(Err(NativeHarnessError::ModelOther(format!(
631                            "silent_stop: model returned stop_reason={stop_reason} \
632                             with empty text and no tool calls"
633                        ))))
634                        .await;
635                    return;
636                }
637                let assistant_text = (!text.trim().is_empty()).then_some(text);
638                messages.push(ChatMessage::Assistant {
639                    text: assistant_text,
640                    tool_calls: vec![],
641                    thinking: outcome.thinking.clone(),
642                    usage: outcome.usage.clone(),
643                });
644                // Persist the final Assistant message to context JSONL.
645                if let Some(ref path) = context_path {
646                    crate::context::jsonl::append_context(path, &messages[ctx_written..]).await;
647                }
648                // Note: AssistantTextChunk events were already emitted
649                // mid-stream, so there's nothing more to send here.
650                let final_msgs = if context_path.is_none() {
651                    messages.clone()
652                } else {
653                    vec![]
654                };
655                let _ = tx
656                    .send(Ok(HarnessInternalEvent::TurnEnd {
657                        stop_reason,
658                        usage: saw_any_usage.then(|| total_usage.clone()),
659                        final_messages: final_msgs,
660                    }))
661                    .await;
662                return;
663            }
664            StepNext::ToolCalls {
665                preface,
666                mut invocations,
667            } => {
668                check_cancel!();
669                // Schema-guided input repair at dispatch time: fix common
670                // shape mistakes from weak models before the tool sees
671                // them. Runs BEFORE the history
672                // push and the ToolCall events so history, wire, and the
673                // actual execution all agree on the (repaired) arguments.
674                // No matching spec (e.g. model hallucinated a tool name) →
675                // leave the input alone; dispatch will fail it as unknown.
676                for inv in &mut invocations {
677                    // Repair lives in the runtime wrapper (single source of
678                    // truth). Running it here — before the history push and
679                    // ToolCall events below — keeps history, wire, and the
680                    // wrapper's (idempotent) dispatch-time repair in agreement.
681                    if let Some(repairs) = tools.repair_invocation(inv) {
682                        inv.raw_emitted_args = None;
683                        tracing::warn!(
684                            target: "harness::tool_repair",
685                            tool = %inv.name,
686                            id = %inv.id,
687                            repairs = ?repairs,
688                            "schema-guided tool input repair applied"
689                        );
690                    }
691                }
692                let preface_text = preface.filter(|s| !s.is_empty());
693                // Record the assistant turn in history BEFORE executing
694                // the tools. Two reasons:
695                //   * the tool_use blocks live in the assistant message
696                //     per the OpenAI / Anthropic protocols;
697                //   * if the tool errors and the loop bails, history
698                //     still reflects "model called X/Y/Z" — useful for
699                //     debugging and possible retry strategies.
700                messages.push(ChatMessage::Assistant {
701                    text: preface_text,
702                    tool_calls: invocations.clone(),
703                    thinking: outcome.thinking.clone(),
704                    usage: outcome.usage.clone(),
705                });
706
707                // Preface AssistantTextChunk was already emitted mid-stream.
708
709                // Emit ToolCall events in declared order so the wire
710                // sees them in a stable sequence (matters for HR's
711                // ordinal assignment in run_dispatch).
712                for inv in &invocations {
713                    if tx
714                        .send(Ok(HarnessInternalEvent::ToolCall {
715                            id: inv.id.clone(),
716                            name: inv.name.clone(),
717                            input: inv.input.clone(),
718                        }))
719                        .await
720                        .is_err()
721                    {
722                        return;
723                    }
724                }
725
726                // Dispatch all invocations concurrently in one aggregate future.
727                // Keeping them out of detached Tokio tasks is important: if the
728                // turn is cancelled, dropping `join` below drops every tool
729                // future, so a runtime that ignores the cancellation token cannot
730                // keep mutating state after TurnEnd.
731                let calls = invocations.iter().cloned().map(|inv| {
732                    let tools = tools.clone();
733                    let cancel_for_task = cancel_token.clone();
734                    let invocation_for_task = inv.clone();
735                    async move {
736                        let call =
737                            tools.invoke_cancellable(invocation_for_task, cancel_for_task.as_ref());
738                        let outcome = match std::panic::AssertUnwindSafe(call).catch_unwind().await
739                        {
740                            Ok(outcome) => outcome,
741                            Err(payload) => Err(ToolRuntimeError::Runtime(format!(
742                                "tool task panicked: {}",
743                                panic_payload_to_string(payload.as_ref())
744                            ))),
745                        };
746                        (inv, outcome)
747                    }
748                });
749                let join = futures::future::join_all(calls);
750                tokio::pin!(join);
751
752                let pairs_opt = if let Some(token) = cancel_token.as_ref() {
753                    tokio::select! {
754                        biased;
755                        _ = token.cancelled() => {
756                            // Give cancellation-aware runtimes a brief window to
757                            // perform remote cleanup (for example E2B SendSignal).
758                            // On expiry, dropping `join` below cancels all remaining
759                            // in-process futures; none are detached.
760                            let _ = tokio::time::timeout(
761                                Duration::from_secs(1),
762                                &mut join,
763                            ).await;
764                            None
765                        },
766                        results = &mut join => Some(results),
767                    }
768                } else {
769                    Some((&mut join).await)
770                };
771                let pairs = match pairs_opt {
772                    Some(o) => o,
773                    None => {
774                        // Cancel won the select. Cancellation-aware runtimes had
775                        // an opportunity to clean up, and all remaining futures
776                        // are dropped when this scope returns.
777                        let _ = tx
778                            .send(Ok(HarnessInternalEvent::TurnEnd {
779                                stop_reason: "interrupt".into(),
780                                usage: saw_any_usage.then(|| total_usage.clone()),
781                                final_messages: if context_path.is_none() {
782                                    messages.clone()
783                                } else {
784                                    vec![]
785                                },
786                            }))
787                            .await;
788                        return;
789                    }
790                };
791
792                // Walk invocations + outcomes pairwise to keep ordering
793                // stable. Tool failures are model-observable: the model can
794                // retry with better input or choose a different approach, and
795                // the harness must not disappear behind a missing terminal.
796                for (inv, outcome) in pairs {
797                    let id = inv.id.clone();
798                    let outcome = match outcome {
799                        Ok(o) => {
800                            tracing::info!(
801                                target: "harness::tool",
802                                step,
803                                tool = %inv.name,
804                                id = %id,
805                                success = o.output.is_ok(),
806                                attachments = o.attachments.len(),
807                                "tool invocation completed"
808                            );
809                            o
810                        }
811                        Err(e) => {
812                            let failure = tool_runtime_error_to_failure(
813                                &inv,
814                                e,
815                                tools_snapshot
816                                    .iter()
817                                    .find(|s| s.name == inv.name)
818                                    .map(|s| &s.input_schema),
819                            );
820                            tracing::warn!(
821                                target: "harness::tool",
822                                step,
823                                tool = %inv.name,
824                                id = %id,
825                                failure_kind = ?failure.kind,
826                                failure_message = %failure.message,
827                                "tool invocation failed; returning model-visible ToolResult"
828                            );
829                            ToolOutcome {
830                                output: Err(failure),
831                                attachments: vec![],
832                            }
833                        }
834                    };
835                    let tool_attachments = outcome.attachments;
836                    let output = outcome.output.map_err(|failure| failure.to_string());
837
838                    // Append the tool result to history so the next model
839                    // step sees it. OpenAI's `tool` role expects content as
840                    // a string; we serialize successes verbatim and wrap
841                    // failures into a small JSON object so the model can
842                    // tell the two apart structurally.
843                    let (tool_content, is_error) = match &output {
844                        Ok(value) => (value.to_string(), false),
845                        Err(err) => (json!({ "error": err }).to_string(), true),
846                    };
847                    messages.push(ChatMessage::Tool {
848                        tool_call_id: id.clone(),
849                        content: tool_content,
850                        is_error,
851                        attachments: tool_attachments,
852                    });
853
854                    if tx
855                        .send(Ok(HarnessInternalEvent::ToolResult { id, output }))
856                        .await
857                        .is_err()
858                    {
859                        return;
860                    }
861                }
862                // Flush the Assistant + all Tool messages for this step.
863                if let Some(ref path) = context_path {
864                    crate::context::jsonl::append_context(path, &messages[ctx_written..]).await;
865                    ctx_written = messages.len();
866                }
867                // Continue the loop — next step will see the tool
868                // results in `messages` and decide what to do.
869            }
870        }
871    }
872
873    // max_turns reached — also flush any unflushed messages.
874    if let Some(ref path) = context_path {
875        crate::context::jsonl::append_context(path, &messages[ctx_written..]).await;
876    }
877    let final_msgs = if context_path.is_none() {
878        messages
879    } else {
880        vec![]
881    };
882    let _ = tx
883        .send(Ok(HarnessInternalEvent::TurnEnd {
884            stop_reason: "max_turns".into(),
885            usage: saw_any_usage.then(|| total_usage.clone()),
886            final_messages: final_msgs,
887        }))
888        .await;
889}
890
891/// 1:1 lift from `ModelClientError` to `NativeHarnessError`. Two enums
892/// because `ModelClient` is provider-facing (the test fixture
893/// `ScriptedModelClient` exists in the same world) and shouldn't have to
894/// know about the harness-runtime variants (`Encode` / `ChannelClosed`
895/// don't apply to it).
896/// Sleep for `delay_ms`, waking early if the cancel token fires. Returns
897/// `true` if the full backoff elapsed, `false` if interrupted by cancel —
898/// callers treat `false` as "abort the turn". Shared by the request-establish
899/// retry and the stream-reconnect retry so both honour InterruptDispatch
900/// mid-backoff.
901async fn backoff_sleep(delay_ms: u64, cancel_token: Option<&CancellationToken>) -> bool {
902    let sleep = tokio::time::sleep(Duration::from_millis(delay_ms));
903    tokio::pin!(sleep);
904    let cancelled = async {
905        if let Some(t) = cancel_token {
906            t.cancelled().await
907        } else {
908            std::future::pending().await
909        }
910    };
911    tokio::select! {
912        _ = &mut sleep => true,
913        _ = cancelled => false,
914    }
915}
916
917fn model_error_to_native(err: ModelClientError) -> NativeHarnessError {
918    match err {
919        ModelClientError::RateLimit(s) => NativeHarnessError::ModelRateLimit(s),
920        ModelClientError::Auth(s) => NativeHarnessError::ModelAuth(s),
921        ModelClientError::ContextOverflow(s) => NativeHarnessError::ModelContextOverflow(s),
922        ModelClientError::BadRequest(s) => NativeHarnessError::ModelBadRequest(s),
923        ModelClientError::ServerError(s) => NativeHarnessError::ModelServerError(s),
924        ModelClientError::Network(s) => NativeHarnessError::ModelNetwork(s),
925        ModelClientError::Other(s) => NativeHarnessError::ModelOther(s),
926    }
927}
928
929fn tool_runtime_error_to_failure(
930    inv: &ToolInvocation,
931    err: ToolRuntimeError,
932    schema: Option<&serde_json::Value>,
933) -> ToolFailure {
934    match err {
935        ToolRuntimeError::Timeout(message) => ToolFailure::new(ToolFailureKind::Timeout, message),
936        ToolRuntimeError::InvalidInput { tool, message } => {
937            crate::tools::invalid_input_failure(&tool, message, &inv.input, schema)
938        }
939        ToolRuntimeError::UnknownTool(tool) => ToolFailure::new(
940            ToolFailureKind::InvalidInput,
941            format!("unknown tool {tool}; choose one of the advertised tools"),
942        ),
943        ToolRuntimeError::Runtime(message) => ToolFailure::new(ToolFailureKind::Runtime, message),
944    }
945}
946
947fn panic_payload_to_string(payload: &(dyn std::any::Any + Send)) -> String {
948    if let Some(s) = payload.downcast_ref::<&str>() {
949        (*s).to_string()
950    } else if let Some(s) = payload.downcast_ref::<String>() {
951        s.clone()
952    } else {
953        "non-string panic payload".into()
954    }
955}
956
957/// Per-step accumulated state extracted while draining a `ModelChunk`
958/// stream. `next` carries the "what to do next" decision (final
959/// message vs tool dispatch); `usage` rides separately because it must
960/// fold into the turn-level total regardless of the branch above; and
961/// `thinking` carries the (text + signature) of any extended-thinking
962/// block produced this step so the next turn's assistant message can
963/// echo it back verbatim (Anthropic rejects modified thinking blocks).
964struct StepOutcome {
965    next: StepNext,
966    usage: Option<HarnessUsage>,
967    thinking: Option<AssistantThinking>,
968}
969
970/// Outcome of draining a single step's chunk stream. `Cancelled` is
971/// distinct from `Complete` so the agent loop can emit a clean
972/// `TurnEnd { interrupt }` rather than papering over the half-finished
973/// state as "Message with empty text".
974enum StepDrain {
975    Complete(StepOutcome),
976    Cancelled,
977}
978
979/// Failure modes of draining a single step's chunk stream. Split out so
980/// `run_loop` can decide between reconnecting (re-establishing the stream)
981/// and terminating the turn.
982enum StepFailure {
983    /// Stream / transport failure (chunk error, premature close, or idle
984    /// stall). `err` keeps the original `ModelClientError` so the caller can
985    /// consult `retryable()`; `had_progress` records whether any model output
986    /// already reached the user this step. A reconnect is only safe when
987    /// `!had_progress` — re-issuing the request after partial output would
988    /// duplicate what the user has already seen.
989    Model {
990        err: ModelClientError,
991        had_progress: bool,
992    },
993    /// Downstream event channel closed — RD dropped the receiver. Nothing left
994    /// to send to; never retryable.
995    ChannelClosed,
996    /// Non-retryable processing error (e.g. tool-argument JSON decode failure).
997    /// Surfaced to the user as-is.
998    Fatal(NativeHarnessError),
999}
1000
1001enum StepNext {
1002    Message {
1003        text: String,
1004        stop_reason: String,
1005    },
1006    /// Model returned one or more `tool_use` blocks. Multi-element
1007    /// arrays come from providers that ship `parallel_tool_calls`
1008    /// (OpenAI default) or models that emit multiple tool_use
1009    /// blocks in a single Anthropic message. agent_loop dispatches
1010    /// them concurrently via `join_all`.
1011    ToolCalls {
1012        preface: Option<String>,
1013        invocations: Vec<ToolInvocation>,
1014    },
1015}
1016
1017/// Drain one model step's chunk stream. Forwards `TextDelta` chunks to
1018/// the harness output channel live (token-by-token), accumulates the
1019/// tool call (if any), and returns once `ModelChunk::Done` lands. All
1020/// emitted `AssistantTextChunk` events share `msg_id = "msg_native_<step>"`
1021/// so `native_adapter::TextAccumulator` collapses them into a single
1022/// `AdapterEvent::AgentMessage` on the wire.
1023/// Build the `StepFailure` for an idle-watchdog timeout. Classified as
1024/// `ModelClientError::Network` so `retryable()` is true (a stall is a
1025/// transport-level fault, like a dropped connection); whether it actually
1026/// gets retried is gated by `had_progress` in `run_loop`.
1027fn stall_failure(idle_timeout: Duration, had_progress: bool) -> StepFailure {
1028    StepFailure::Model {
1029        err: ModelClientError::Network(format!(
1030            "model stream stalled: no output for {}s (connection open but idle)",
1031            idle_timeout.as_secs()
1032        )),
1033        had_progress,
1034    }
1035}
1036
1037async fn consume_step_stream(
1038    mut stream: futures::stream::BoxStream<'static, Result<ModelChunk, ModelClientError>>,
1039    tx: &mpsc::Sender<Result<HarnessInternalEvent, NativeHarnessError>>,
1040    step: usize,
1041    cancel_token: Option<&CancellationToken>,
1042    idle_timeout: Duration,
1043) -> Result<StepDrain, StepFailure> {
1044    let emit_msg_id = format!("msg_native_{step}");
1045    let emit_thinking_id = format!("thinking_native_{step}");
1046    let mut text_buf = String::new();
1047    let mut text_stream_started = false;
1048    let mut thinking_buf = String::new();
1049    let mut thinking_signature: Option<String> = None;
1050    let mut saw_thinking = false;
1051    let mut tool_states: Vec<ToolBuf> = Vec::new();
1052    let mut stop_reason = "end_turn".to_string();
1053    let mut usage: Option<HarnessUsage> = None;
1054    // Whether any real model output has reached the user this step. Gates
1055    // whether a later stall / drop is safe to retry (see `StepFailure::Model`).
1056    let mut had_progress = false;
1057
1058    loop {
1059        // Mid-stream idle watchdog: a freshly-armed timer each iteration means
1060        // it measures the gap since the *last* chunk, i.e. it resets on every
1061        // chunk we receive. Keepalive / ping frames are dropped by the SSE
1062        // layer before they ever become a `ModelChunk`, so "received a chunk"
1063        // is exactly "the model made progress" — the timer only survives a
1064        // genuine silence, never a heartbeat-only lull.
1065        let idle = tokio::time::sleep(idle_timeout);
1066        tokio::pin!(idle);
1067
1068        // select! arms: cancellation (priority via `biased`), the idle
1069        // watchdog, and the next stream chunk. Without `biased`, tokio's
1070        // randomised polling can starve cancel checks under heavy
1071        // chunk throughput. With it, an InterruptDispatch fires
1072        // exactly one stream poll later — typically <100 µs.
1073        let item = if let Some(token) = cancel_token {
1074            tokio::select! {
1075                biased;
1076                _ = token.cancelled() => {
1077                    return Ok(StepDrain::Cancelled);
1078                }
1079                _ = &mut idle => return Err(stall_failure(idle_timeout, had_progress)),
1080                next = stream.next() => next,
1081            }
1082        } else {
1083            tokio::select! {
1084                _ = &mut idle => return Err(stall_failure(idle_timeout, had_progress)),
1085                next = stream.next() => next,
1086            }
1087        };
1088        let Some(item) = item else { break };
1089        let chunk = match item {
1090            Ok(c) => c,
1091            Err(e) => {
1092                return Err(StepFailure::Model {
1093                    err: e,
1094                    had_progress,
1095                })
1096            }
1097        };
1098        match chunk {
1099            ModelChunk::TextDelta { msg_id: _, delta } => {
1100                if delta.is_empty() {
1101                    continue;
1102                }
1103                text_buf.push_str(&delta);
1104                let mut flush_delta = delta;
1105                if !text_stream_started {
1106                    if text_buf.trim().is_empty() {
1107                        continue;
1108                    }
1109                    text_stream_started = true;
1110                    // First visible chunk: flush any leading whitespace we
1111                    // held back while deciding whether the step is silent.
1112                    flush_delta = text_buf.clone();
1113                }
1114                // A non-empty text delta is model output the user is about to
1115                // see — past this point a stall is no longer safe to retry.
1116                had_progress = true;
1117                // Forward live to harness output. We rewrite msg_id to
1118                // the per-step canonical form so native_adapter groups
1119                // every chunk of this step into one AdapterEvent.
1120                if tx
1121                    .send(Ok(HarnessInternalEvent::AssistantTextChunk {
1122                        msg_id: emit_msg_id.clone(),
1123                        delta: flush_delta,
1124                    }))
1125                    .await
1126                    .is_err()
1127                {
1128                    return Err(StepFailure::ChannelClosed);
1129                }
1130            }
1131            ModelChunk::ThinkingDelta {
1132                thinking_id: _,
1133                delta,
1134                signature,
1135            } => {
1136                // Signature chunks usually arrive without text and vice
1137                // versa; we accept both shapes and latch whichever the
1138                // provider sends. The text part feeds the live
1139                // AssistantThinkingChunk emit; the signature rides on
1140                // the final ChatMessage::Assistant.thinking so the next
1141                // turn can re-send the block verbatim.
1142                if let Some(sig) = signature {
1143                    if !sig.is_empty() {
1144                        thinking_signature = Some(sig);
1145                    }
1146                }
1147                if !delta.is_empty() {
1148                    saw_thinking = true;
1149                    had_progress = true;
1150                    thinking_buf.push_str(&delta);
1151                    if tx
1152                        .send(Ok(HarnessInternalEvent::AssistantThinkingChunk {
1153                            msg_id: emit_thinking_id.clone(),
1154                            delta,
1155                        }))
1156                        .await
1157                        .is_err()
1158                    {
1159                        return Err(StepFailure::ChannelClosed);
1160                    }
1161                }
1162            }
1163            ModelChunk::ToolCallStart { id, name } => {
1164                // A tool call is committed model output. Even though we buffer
1165                // tool args rather than forwarding them live, treat any
1166                // tool-call activity as progress: re-issuing the request after
1167                // the model has started emitting a tool_use risks a divergent
1168                // / duplicated call.
1169                had_progress = true;
1170                tool_states.push(ToolBuf {
1171                    id,
1172                    name,
1173                    args_buf: String::new(),
1174                    early_input: None,
1175                });
1176            }
1177            ModelChunk::ToolCallInputDelta { id, delta } => {
1178                if let Some(s) = tool_states.iter_mut().find(|s| s.id == id) {
1179                    s.args_buf.push_str(&delta);
1180                }
1181            }
1182            ModelChunk::ToolCallEnd { id, input } => {
1183                if let Some(s) = tool_states.iter_mut().find(|s| s.id == id) {
1184                    s.early_input = input;
1185                }
1186            }
1187            ModelChunk::Done {
1188                stop_reason: sr,
1189                usage: u,
1190            } => {
1191                stop_reason = sr;
1192                usage = u;
1193            }
1194        }
1195    }
1196
1197    // Finalised thinking block — `saw_thinking` covers the (rare) case
1198    // where the provider sent only signature + empty text. We only build
1199    // the AssistantThinking if at least one of the two parts landed.
1200    let thinking = if saw_thinking || thinking_signature.is_some() {
1201        Some(AssistantThinking {
1202            text: thinking_buf,
1203            signature: thinking_signature,
1204        })
1205    } else {
1206        None
1207    };
1208
1209    // Tool call takes precedence — see collect_model_response in model.rs
1210    // for the same rule; the model deferred the final answer until the
1211    // tool runs, so we dispatch the tool(s) instead of emitting TurnEnd.
1212    // Multiple tool_use blocks land here when the provider runs
1213    // parallel_tool_calls — we forward all of them to run_loop.
1214    if !tool_states.is_empty() {
1215        let mut invocations = Vec::with_capacity(tool_states.len());
1216        for state in tool_states {
1217            let parsed_input = match state.early_input {
1218                Some(v) => v,
1219                None => {
1220                    let trimmed = state.args_buf.trim();
1221                    if trimmed.is_empty() {
1222                        // Some providers ship the final tool_call with
1223                        // no args delta (e.g. zero-arg tools); treat
1224                        // empty buffer as an empty object.
1225                        Value::Object(serde_json::Map::new())
1226                    } else {
1227                        match serde_json::from_str(trimmed) {
1228                            Ok(v) => v,
1229                            Err(e) => {
1230                                // Weak models truncate / malform streamed
1231                                // arguments; run the repair chain before
1232                                // failing the turn. A rescued (possibly
1233                                // partial) input the tool can reject is
1234                                // strictly better than a dead turn.
1235                                let res = crate::tool_repair::repair_truncated_json(trimmed);
1236                                match serde_json::from_str(&res.repaired) {
1237                                    Ok(v) if res.changed => {
1238                                        tracing::warn!(
1239                                            target: "harness::tool_repair",
1240                                            tool = %state.name,
1241                                            id = %state.id,
1242                                            notes = ?res.notes,
1243                                            "repaired malformed tool arguments"
1244                                        );
1245                                        v
1246                                    }
1247                                    _ => {
1248                                        return Err(StepFailure::Fatal(
1249                                            NativeHarnessError::ModelOther(format!(
1250                                                "decode tool arguments for {id}: {e}",
1251                                                id = state.id
1252                                            )),
1253                                        ))
1254                                    }
1255                                }
1256                            }
1257                        }
1258                    }
1259                }
1260            };
1261            let raw_emitted_args = raw_args_for_input(&state.args_buf, &parsed_input);
1262            invocations.push(ToolInvocation {
1263                id: state.id,
1264                name: state.name,
1265                input: parsed_input,
1266                raw_emitted_args,
1267            });
1268        }
1269        return Ok(StepDrain::Complete(StepOutcome {
1270            next: StepNext::ToolCalls {
1271                preface: (!text_buf.is_empty()).then_some(text_buf),
1272                invocations,
1273            },
1274            usage,
1275            thinking,
1276        }));
1277    }
1278
1279    Ok(StepDrain::Complete(StepOutcome {
1280        next: StepNext::Message {
1281            text: text_buf,
1282            stop_reason,
1283        },
1284        usage,
1285        thinking,
1286    }))
1287}
1288
1289struct ToolBuf {
1290    id: String,
1291    name: String,
1292    args_buf: String,
1293    early_input: Option<Value>,
1294}
1295
1296fn raw_args_for_input(raw: &str, input: &Value) -> Option<String> {
1297    let trimmed = raw.trim();
1298    if trimmed.is_empty() {
1299        return None;
1300    }
1301    match serde_json::from_str::<Value>(trimmed) {
1302        Ok(parsed) if parsed == *input => Some(trimmed.to_string()),
1303        _ => None,
1304    }
1305}
1306
1307#[cfg(test)]
1308mod tests {
1309    use super::*;
1310    use crate::compaction::{CompactionContext, CompactionError, CompactionStrategy};
1311    use crate::model::{ModelChunk, ModelClient, ModelClientError, ModelResponse};
1312    use crate::tools::{ToolInvocation, ToolOutcome};
1313    use crate::{HarnessInternalEvent, MockToolRuntime, ScriptedModelClient};
1314    use async_trait::async_trait;
1315    use futures::stream::{BoxStream, StreamExt};
1316
1317    fn search_spec() -> crate::tools::ToolSpec {
1318        crate::tools::web_search::web_search_spec()
1319    }
1320
1321    fn ordinary_spec() -> crate::tools::ToolSpec {
1322        crate::tools::ToolSpec {
1323            name: "read".into(),
1324            description: "read".into(),
1325            input_schema: serde_json::json!({"type": "object"}),
1326        }
1327    }
1328
1329    #[test]
1330    fn web_search_mode_routes_exactly_one_search_surface() {
1331        let both = || vec![ordinary_spec(), search_spec()];
1332
1333        let (tools, hosted) =
1334            resolve_web_search_tools(WebSearchMode::Off, CapabilitySupport::Supported, both())
1335                .unwrap();
1336        assert!(tools.iter().all(|tool| tool.name != "web_search"));
1337        assert!(hosted.is_empty());
1338
1339        let (tools, hosted) =
1340            resolve_web_search_tools(WebSearchMode::Auto, CapabilitySupport::Supported, both())
1341                .unwrap();
1342        assert!(tools.iter().all(|tool| tool.name != "web_search"));
1343        assert_eq!(hosted, vec![HostedTool::WebSearch]);
1344
1345        for support in [CapabilitySupport::Unsupported, CapabilitySupport::Unknown] {
1346            let (tools, hosted) =
1347                resolve_web_search_tools(WebSearchMode::Auto, support, both()).unwrap();
1348            assert!(tools.iter().any(|tool| tool.name == "web_search"));
1349            assert!(hosted.is_empty());
1350        }
1351
1352        let (tools, hosted) =
1353            resolve_web_search_tools(WebSearchMode::Native, CapabilitySupport::Unknown, both())
1354                .unwrap();
1355        assert!(tools.iter().all(|tool| tool.name != "web_search"));
1356        assert_eq!(hosted, vec![HostedTool::WebSearch]);
1357
1358        assert!(resolve_web_search_tools(
1359            WebSearchMode::Native,
1360            CapabilitySupport::Unsupported,
1361            both(),
1362        )
1363        .is_err());
1364
1365        let (tools, hosted) =
1366            resolve_web_search_tools(WebSearchMode::Managed, CapabilitySupport::Supported, both())
1367                .unwrap();
1368        assert!(tools.iter().any(|tool| tool.name == "web_search"));
1369        assert!(hosted.is_empty());
1370
1371        assert!(resolve_web_search_tools(
1372            WebSearchMode::Managed,
1373            CapabilitySupport::Supported,
1374            vec![ordinary_spec()],
1375        )
1376        .is_err());
1377    }
1378
1379    #[test]
1380    fn auto_without_any_search_surface_degrades_to_no_search() {
1381        let (tools, hosted) = resolve_web_search_tools(
1382            WebSearchMode::Auto,
1383            CapabilitySupport::Unknown,
1384            vec![ordinary_spec()],
1385        )
1386        .unwrap();
1387        assert_eq!(tools.len(), 1);
1388        assert!(hosted.is_empty());
1389    }
1390    use std::sync::atomic::{AtomicUsize, Ordering};
1391    use std::sync::{Arc, Mutex};
1392
1393    /// Test-only model client that returns a scripted sequence of responses.
1394    /// Each `next()` pops the front of the queue. Used to assert how
1395    /// `AgentLoopHarness` folds per-call usage into the turn total.
1396    #[derive(Clone)]
1397    struct QueueModelClient {
1398        queue: Arc<Mutex<Vec<ModelResponse>>>,
1399    }
1400
1401    impl QueueModelClient {
1402        fn new(responses: Vec<ModelResponse>) -> Self {
1403            Self {
1404                queue: Arc::new(Mutex::new(responses)),
1405            }
1406        }
1407    }
1408
1409    #[async_trait]
1410    impl ModelClient for QueueModelClient {
1411        fn hosted_capability(&self, _capability: HostedCapability) -> CapabilitySupport {
1412            CapabilitySupport::Unsupported
1413        }
1414
1415        async fn stream(
1416            &self,
1417            _input: ModelTurnInput,
1418        ) -> Result<BoxStream<'static, Result<ModelChunk, ModelClientError>>, ModelClientError>
1419        {
1420            let mut q = self.queue.lock().unwrap();
1421            if q.is_empty() {
1422                return Err(ModelClientError::Other("queue exhausted".into()));
1423            }
1424            let response = q.remove(0);
1425            let chunks = response_to_chunks(response);
1426            Ok(futures::stream::iter(chunks.into_iter().map(Ok)).boxed())
1427        }
1428    }
1429
1430    /// Render a synthetic `ModelResponse` as the `ModelChunk` sequence the
1431    /// streaming impl would have emitted. Lets QueueModelClient assert
1432    /// agent-loop behaviour without doing real SSE in tests.
1433    fn response_to_chunks(response: ModelResponse) -> Vec<ModelChunk> {
1434        match response {
1435            ModelResponse::Message {
1436                text,
1437                stop_reason,
1438                usage,
1439            } => {
1440                let mut out = Vec::new();
1441                if !text.is_empty() {
1442                    out.push(ModelChunk::TextDelta {
1443                        msg_id: "queue_msg".into(),
1444                        delta: text,
1445                    });
1446                }
1447                out.push(ModelChunk::Done { stop_reason, usage });
1448                out
1449            }
1450            ModelResponse::ToolCall {
1451                preface,
1452                invocation,
1453                usage,
1454            } => {
1455                let mut out = Vec::new();
1456                if let Some(p) = preface {
1457                    if !p.is_empty() {
1458                        out.push(ModelChunk::TextDelta {
1459                            msg_id: "queue_msg".into(),
1460                            delta: p,
1461                        });
1462                    }
1463                }
1464                out.push(ModelChunk::ToolCallStart {
1465                    id: invocation.id.clone(),
1466                    name: invocation.name.clone(),
1467                });
1468                out.push(ModelChunk::ToolCallEnd {
1469                    id: invocation.id.clone(),
1470                    input: Some(invocation.input.clone()),
1471                });
1472                out.push(ModelChunk::Done {
1473                    stop_reason: "end_turn".into(),
1474                    usage,
1475                });
1476                out
1477            }
1478        }
1479    }
1480
1481    fn usage(input: u64, output: u64, cache_read: u64) -> HarnessUsage {
1482        HarnessUsage {
1483            input_tokens: input,
1484            output_tokens: output,
1485            cache_read_input_tokens: cache_read,
1486            cache_creation_input_tokens: 0,
1487            compaction_input_tokens: 0,
1488            compaction_output_tokens: 0,
1489        }
1490    }
1491
1492    #[tokio::test]
1493    async fn agent_loop_accumulates_usage_across_steps() {
1494        // 2 steps: tool call (10/5 tokens) then final message (20/15 tokens).
1495        let model = QueueModelClient::new(vec![
1496            ModelResponse::ToolCall {
1497                preface: None,
1498                invocation: ToolInvocation {
1499                    id: "tc_1".into(),
1500                    name: "bash".into(),
1501                    input: serde_json::json!({"command": "pwd"}),
1502                    raw_emitted_args: None,
1503                },
1504                usage: Some(usage(10, 5, 0)),
1505            },
1506            ModelResponse::Message {
1507                text: "done".into(),
1508                stop_reason: "end_turn".into(),
1509                usage: Some(usage(20, 15, 4)),
1510            },
1511        ]);
1512        let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
1513        let mut rx = harness
1514            .run_turn(NativeTurnInput {
1515                prompt_text: "pwd".into(),
1516                system_prompt: None,
1517                attachments: vec![],
1518                cancel_token: None,
1519                prior_messages: vec![],
1520                context_path: None,
1521            })
1522            .await
1523            .unwrap();
1524
1525        // Drain until TurnEnd and inspect usage.
1526        let mut final_usage = None;
1527        while let Some(item) = rx.recv().await {
1528            if let HarnessInternalEvent::TurnEnd { usage: u, .. } = item.unwrap() {
1529                final_usage = u;
1530                break;
1531            }
1532        }
1533        let u = final_usage.expect("TurnEnd carried usage");
1534        assert_eq!(u.input_tokens, 30);
1535        assert_eq!(u.output_tokens, 20);
1536        assert_eq!(u.cache_read_input_tokens, 4);
1537    }
1538
1539    #[tokio::test]
1540    async fn agent_loop_errors_on_silent_stop() {
1541        let model = QueueModelClient::new(vec![ModelResponse::Message {
1542            text: "   \n".into(),
1543            stop_reason: "end_turn".into(),
1544            usage: None,
1545        }]);
1546        let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
1547        let mut rx = harness
1548            .run_turn(NativeTurnInput {
1549                prompt_text: "say something".into(),
1550                system_prompt: None,
1551                attachments: vec![],
1552                cancel_token: None,
1553                prior_messages: vec![],
1554                context_path: None,
1555            })
1556            .await
1557            .unwrap();
1558
1559        match rx.recv().await.unwrap() {
1560            Err(NativeHarnessError::ModelOther(msg)) => {
1561                assert!(msg.contains("silent_stop"));
1562                assert!(msg.contains("stop_reason=end_turn"));
1563            }
1564            other => panic!("expected silent_stop model error, got {other:?}"),
1565        }
1566    }
1567
1568    #[tokio::test]
1569    async fn agent_loop_errors_on_empty_max_tokens_stop() {
1570        let model = QueueModelClient::new(vec![ModelResponse::Message {
1571            text: "".into(),
1572            stop_reason: "max_tokens".into(),
1573            usage: None,
1574        }]);
1575        let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
1576        let mut rx = harness
1577            .run_turn(NativeTurnInput {
1578                prompt_text: "think".into(),
1579                system_prompt: None,
1580                attachments: vec![],
1581                cancel_token: None,
1582                prior_messages: vec![],
1583                context_path: None,
1584            })
1585            .await
1586            .unwrap();
1587
1588        match rx.recv().await.unwrap() {
1589            Err(NativeHarnessError::ModelOther(msg)) => {
1590                assert!(msg.contains("silent_stop"));
1591                assert!(msg.contains("stop_reason=max_tokens"));
1592            }
1593            other => panic!("expected silent_stop model error, got {other:?}"),
1594        }
1595    }
1596
1597    #[tokio::test]
1598    async fn agent_loop_turn_end_usage_is_none_when_no_step_reported() {
1599        // Provider reports no usage on either step.
1600        let model = QueueModelClient::new(vec![ModelResponse::Message {
1601            text: "ok".into(),
1602            stop_reason: "end_turn".into(),
1603            usage: None,
1604        }]);
1605        let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
1606        let mut rx = harness
1607            .run_turn(NativeTurnInput {
1608                prompt_text: "noop".into(),
1609                system_prompt: None,
1610                attachments: vec![],
1611                cancel_token: None,
1612                prior_messages: vec![],
1613                context_path: None,
1614            })
1615            .await
1616            .unwrap();
1617        let mut saw_usage = None;
1618        while let Some(item) = rx.recv().await {
1619            if let HarnessInternalEvent::TurnEnd { usage, .. } = item.unwrap() {
1620                saw_usage = Some(usage);
1621                break;
1622            }
1623        }
1624        assert_eq!(saw_usage.unwrap(), None);
1625    }
1626
1627    /// Streaming-aware fake client. Emits a pre-computed `ModelChunk`
1628    /// sequence per call — distinct from `QueueModelClient` which uses
1629    /// the `ModelResponse → chunks` translation. Tests that need
1630    /// token-level chunking go through this one.
1631    #[derive(Clone)]
1632    struct StreamingFakeClient {
1633        chunks_per_call: Arc<Mutex<Vec<Vec<ModelChunk>>>>,
1634    }
1635
1636    impl StreamingFakeClient {
1637        fn new(per_call: Vec<Vec<ModelChunk>>) -> Self {
1638            Self {
1639                chunks_per_call: Arc::new(Mutex::new(per_call)),
1640            }
1641        }
1642    }
1643
1644    #[async_trait]
1645    impl ModelClient for StreamingFakeClient {
1646        fn hosted_capability(&self, _capability: HostedCapability) -> CapabilitySupport {
1647            CapabilitySupport::Unsupported
1648        }
1649
1650        async fn stream(
1651            &self,
1652            _input: ModelTurnInput,
1653        ) -> Result<BoxStream<'static, Result<ModelChunk, ModelClientError>>, ModelClientError>
1654        {
1655            let mut bucket = self.chunks_per_call.lock().unwrap();
1656            if bucket.is_empty() {
1657                return Err(ModelClientError::Other("queue exhausted".into()));
1658            }
1659            let chunks = bucket.remove(0);
1660            Ok(futures::stream::iter(chunks.into_iter().map(Ok)).boxed())
1661        }
1662    }
1663
1664    #[tokio::test]
1665    async fn agent_loop_forwards_token_chunks_to_harness_output() {
1666        let model = StreamingFakeClient::new(vec![vec![
1667            ModelChunk::TextDelta {
1668                msg_id: "remote_msg".into(),
1669                delta: "Hel".into(),
1670            },
1671            ModelChunk::TextDelta {
1672                msg_id: "remote_msg".into(),
1673                delta: "lo ".into(),
1674            },
1675            ModelChunk::TextDelta {
1676                msg_id: "remote_msg".into(),
1677                delta: "world".into(),
1678            },
1679            ModelChunk::Done {
1680                stop_reason: "end_turn".into(),
1681                usage: None,
1682            },
1683        ]]);
1684        let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
1685        let mut rx = harness
1686            .run_turn(NativeTurnInput {
1687                prompt_text: "hi".into(),
1688                system_prompt: None,
1689                attachments: vec![],
1690                cancel_token: None,
1691                prior_messages: vec![],
1692                context_path: None,
1693            })
1694            .await
1695            .unwrap();
1696
1697        let mut deltas: Vec<String> = Vec::new();
1698        let mut saw_end = false;
1699        while let Some(item) = rx.recv().await {
1700            match item.unwrap() {
1701                HarnessInternalEvent::AssistantTextChunk { msg_id, delta } => {
1702                    // The harness rewrites msg_id to the step-local form
1703                    // so native_adapter accumulates everything from one
1704                    // step into a single AgentMessage frame.
1705                    assert_eq!(msg_id, "msg_native_0");
1706                    deltas.push(delta);
1707                }
1708                HarnessInternalEvent::TurnEnd { stop_reason, .. } => {
1709                    assert_eq!(stop_reason, "end_turn");
1710                    saw_end = true;
1711                    break;
1712                }
1713                other => panic!("unexpected event: {other:?}"),
1714            }
1715        }
1716        assert_eq!(deltas, vec!["Hel", "lo ", "world"]);
1717        assert!(saw_end);
1718    }
1719
1720    #[tokio::test]
1721    async fn agent_loop_streaming_tool_call_then_summary() {
1722        // Two scripted streams: first dispatches a tool with streamed
1723        // arguments; second returns a final message after the tool
1724        // result. Tests that the agent loop:
1725        //  * accumulates streamed JSON arguments correctly
1726        //  * runs the tool with the parsed value
1727        //  * feeds the tool result back into the next stream's input
1728        let model = StreamingFakeClient::new(vec![
1729            vec![
1730                ModelChunk::TextDelta {
1731                    msg_id: "r1".into(),
1732                    delta: "running ".into(),
1733                },
1734                ModelChunk::ToolCallStart {
1735                    id: "tc_1".into(),
1736                    name: "bash".into(),
1737                },
1738                ModelChunk::ToolCallInputDelta {
1739                    id: "tc_1".into(),
1740                    delta: "{\"command\":".into(),
1741                },
1742                ModelChunk::ToolCallInputDelta {
1743                    id: "tc_1".into(),
1744                    delta: "\"pwd\"}".into(),
1745                },
1746                ModelChunk::ToolCallEnd {
1747                    id: "tc_1".into(),
1748                    input: None,
1749                },
1750                ModelChunk::Done {
1751                    stop_reason: "end_turn".into(),
1752                    usage: None,
1753                },
1754            ],
1755            vec![
1756                ModelChunk::TextDelta {
1757                    msg_id: "r2".into(),
1758                    delta: "done".into(),
1759                },
1760                ModelChunk::Done {
1761                    stop_reason: "end_turn".into(),
1762                    usage: None,
1763                },
1764            ],
1765        ]);
1766        let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
1767        let mut rx = harness
1768            .run_turn(NativeTurnInput {
1769                prompt_text: "pwd".into(),
1770                system_prompt: None,
1771                attachments: vec![],
1772                cancel_token: None,
1773                prior_messages: vec![],
1774                context_path: None,
1775            })
1776            .await
1777            .unwrap();
1778
1779        // Expected event sequence:
1780        //   AssistantTextChunk("running ")
1781        //   ToolCall{ name=bash, input={"command":"pwd"} }
1782        //   ToolResult{ ok }
1783        //   AssistantTextChunk("done")
1784        //   TurnEnd
1785        let ev = rx.recv().await.unwrap().unwrap();
1786        assert!(matches!(
1787            ev,
1788            HarnessInternalEvent::AssistantTextChunk { ref delta, .. } if delta == "running "
1789        ));
1790        let ev = rx.recv().await.unwrap().unwrap();
1791        let HarnessInternalEvent::ToolCall { name, input, .. } = ev else {
1792            panic!("expected ToolCall");
1793        };
1794        assert_eq!(name, "bash");
1795        assert_eq!(input["command"], "pwd");
1796        let ev = rx.recv().await.unwrap().unwrap();
1797        assert!(matches!(ev, HarnessInternalEvent::ToolResult { .. }));
1798        let ev = rx.recv().await.unwrap().unwrap();
1799        assert!(matches!(
1800            ev,
1801            HarnessInternalEvent::AssistantTextChunk { ref delta, .. } if delta == "done"
1802        ));
1803        let ev = rx.recv().await.unwrap().unwrap();
1804        assert!(matches!(ev, HarnessInternalEvent::TurnEnd { .. }));
1805    }
1806
1807    #[tokio::test]
1808    async fn agent_loop_repairs_truncated_tool_arguments() {
1809        // OpenAI-style streamed arguments cut off mid-object (missing the
1810        // closing brace). Without the repair chain this was a fatal
1811        // ModelOther; with it the args close cleanly and the tool runs.
1812        let model = StreamingFakeClient::new(vec![
1813            vec![
1814                ModelChunk::ToolCallStart {
1815                    id: "tc_trunc".into(),
1816                    name: "bash".into(),
1817                },
1818                ModelChunk::ToolCallInputDelta {
1819                    id: "tc_trunc".into(),
1820                    delta: r#"{"command":"pwd""#.into(), // truncated
1821                },
1822                ModelChunk::ToolCallEnd {
1823                    id: "tc_trunc".into(),
1824                    input: None,
1825                },
1826                ModelChunk::Done {
1827                    stop_reason: "tool_use".into(),
1828                    usage: None,
1829                },
1830            ],
1831            vec![
1832                ModelChunk::TextDelta {
1833                    msg_id: "r2".into(),
1834                    delta: "done".into(),
1835                },
1836                ModelChunk::Done {
1837                    stop_reason: "end_turn".into(),
1838                    usage: None,
1839                },
1840            ],
1841        ]);
1842        let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
1843        let mut rx = harness
1844            .run_turn(NativeTurnInput {
1845                prompt_text: "pwd".into(),
1846                system_prompt: None,
1847                attachments: vec![],
1848                cancel_token: None,
1849                prior_messages: vec![],
1850                context_path: None,
1851            })
1852            .await
1853            .unwrap();
1854
1855        let mut saw_tool_call = false;
1856        let mut saw_turn_end = false;
1857        while let Some(item) = rx.recv().await {
1858            match item.expect("turn must not fail on truncated args") {
1859                HarnessInternalEvent::ToolCall { name, input, .. } => {
1860                    assert_eq!(name, "bash");
1861                    assert_eq!(input["command"], "pwd", "repaired args reach the wire");
1862                    saw_tool_call = true;
1863                }
1864                HarnessInternalEvent::TurnEnd { .. } => {
1865                    saw_turn_end = true;
1866                    break;
1867                }
1868                _ => {}
1869            }
1870        }
1871        assert!(saw_tool_call, "expected ToolCall with repaired input");
1872        assert!(saw_turn_end);
1873    }
1874
1875    /// Records the invocation input the runtime actually received, so a
1876    /// test can assert dispatch saw the schema-repaired arguments.
1877    #[derive(Clone)]
1878    struct ProbeToolRuntime {
1879        seen_input: Arc<Mutex<Option<Value>>>,
1880    }
1881
1882    #[async_trait]
1883    impl ToolRuntime for ProbeToolRuntime {
1884        fn specs(&self) -> Vec<crate::tools::ToolSpec> {
1885            vec![crate::tools::ToolSpec {
1886                name: "probe".into(),
1887                description: "records its input".into(),
1888                input_schema: serde_json::json!({
1889                    "type": "object",
1890                    "properties": {
1891                        "pattern": {"type": "string"},
1892                        "literal": {"type": "boolean"},
1893                        "limit": {"type": "integer"}
1894                    },
1895                    "required": ["pattern"]
1896                }),
1897            }]
1898        }
1899
1900        async fn invoke(
1901            &self,
1902            invocation: ToolInvocation,
1903        ) -> Result<ToolOutcome, ToolRuntimeError> {
1904            *self.seen_input.lock().unwrap() = Some(invocation.input);
1905            Ok(ToolOutcome {
1906                output: Ok(r#"{"ok":true}"#.into()),
1907                attachments: vec![],
1908            })
1909        }
1910    }
1911
1912    #[tokio::test]
1913    async fn agent_loop_applies_schema_repair_before_dispatch() {
1914        // Weak-model shape mistakes — "true" for a boolean, "30" for an
1915        // integer — are coerced against the tool's input_schema before the
1916        // runtime sees them.
1917        let model = StreamingFakeClient::new(vec![
1918            vec![
1919                ModelChunk::ToolCallStart {
1920                    id: "tc_shape".into(),
1921                    name: "probe".into(),
1922                },
1923                ModelChunk::ToolCallEnd {
1924                    id: "tc_shape".into(),
1925                    input: Some(json!({"pattern": "x", "literal": "true", "limit": "30"})),
1926                },
1927                ModelChunk::Done {
1928                    stop_reason: "tool_use".into(),
1929                    usage: None,
1930                },
1931            ],
1932            vec![
1933                ModelChunk::TextDelta {
1934                    msg_id: "r2".into(),
1935                    delta: "done".into(),
1936                },
1937                ModelChunk::Done {
1938                    stop_reason: "end_turn".into(),
1939                    usage: None,
1940                },
1941            ],
1942        ]);
1943        let seen_input = Arc::new(Mutex::new(None));
1944        let tools = ProbeToolRuntime {
1945            seen_input: seen_input.clone(),
1946        };
1947        let harness = AgentLoopHarness::new(model, tools);
1948        let mut rx = harness
1949            .run_turn(NativeTurnInput {
1950                prompt_text: "go".into(),
1951                system_prompt: None,
1952                attachments: vec![],
1953                cancel_token: None,
1954                prior_messages: vec![],
1955                context_path: None,
1956            })
1957            .await
1958            .unwrap();
1959
1960        let mut wire_input: Option<Value> = None;
1961        let mut history: Option<Vec<ChatMessage>> = None;
1962        while let Some(item) = rx.recv().await {
1963            match item.unwrap() {
1964                HarnessInternalEvent::ToolCall { input, .. } => wire_input = Some(input),
1965                HarnessInternalEvent::TurnEnd { final_messages, .. } => {
1966                    history = Some(final_messages);
1967                    break;
1968                }
1969                _ => {}
1970            }
1971        }
1972        let repaired = json!({"pattern": "x", "literal": true, "limit": 30});
1973        // Runtime, wire event, and history all agree on the repaired input.
1974        assert_eq!(seen_input.lock().unwrap().clone().unwrap(), repaired);
1975        assert_eq!(wire_input.unwrap(), repaired);
1976        let history = history.unwrap();
1977        let assistant_tool_calls = history
1978            .iter()
1979            .find_map(|m| match m {
1980                ChatMessage::Assistant { tool_calls, .. } if !tool_calls.is_empty() => {
1981                    Some(tool_calls.clone())
1982                }
1983                _ => None,
1984            })
1985            .expect("assistant message with tool_calls in history");
1986        assert_eq!(assistant_tool_calls[0].input, repaired);
1987    }
1988
1989    #[derive(Clone)]
1990    struct TimeoutToolRuntime;
1991
1992    #[async_trait]
1993    impl ToolRuntime for TimeoutToolRuntime {
1994        fn specs(&self) -> Vec<crate::tools::ToolSpec> {
1995            vec![crate::tools::ToolSpec {
1996                name: "slow".into(),
1997                description: "always times out".into(),
1998                input_schema: serde_json::json!({"type": "object"}),
1999            }]
2000        }
2001
2002        async fn invoke(
2003            &self,
2004            _invocation: ToolInvocation,
2005        ) -> Result<ToolOutcome, ToolRuntimeError> {
2006            Err(ToolRuntimeError::Timeout("tool timed out after 1s".into()))
2007        }
2008    }
2009
2010    #[tokio::test]
2011    async fn agent_loop_tool_timeout_is_model_observable_result() {
2012        let model = StreamingFakeClient::new(vec![
2013            vec![
2014                ModelChunk::ToolCallStart {
2015                    id: "tc_timeout".into(),
2016                    name: "slow".into(),
2017                },
2018                ModelChunk::ToolCallEnd {
2019                    id: "tc_timeout".into(),
2020                    input: Some(json!({})),
2021                },
2022                ModelChunk::Done {
2023                    stop_reason: "tool_use".into(),
2024                    usage: None,
2025                },
2026            ],
2027            vec![
2028                ModelChunk::TextDelta {
2029                    msg_id: "r2".into(),
2030                    delta: "recovered".into(),
2031                },
2032                ModelChunk::Done {
2033                    stop_reason: "end_turn".into(),
2034                    usage: None,
2035                },
2036            ],
2037        ]);
2038        let harness = AgentLoopHarness::new(model, TimeoutToolRuntime);
2039        let mut rx = harness
2040            .run_turn(NativeTurnInput {
2041                prompt_text: "run slow".into(),
2042                system_prompt: None,
2043                attachments: vec![],
2044                cancel_token: None,
2045                prior_messages: vec![],
2046                context_path: None,
2047            })
2048            .await
2049            .unwrap();
2050
2051        assert!(matches!(
2052            rx.recv().await.unwrap().unwrap(),
2053            HarnessInternalEvent::ToolCall { .. }
2054        ));
2055        match rx.recv().await.unwrap().unwrap() {
2056            HarnessInternalEvent::ToolResult { output, .. } => {
2057                let err = output.unwrap_err();
2058                assert!(err.contains("Timeout"));
2059                assert!(err.contains("tool timed out"));
2060            }
2061            other => panic!("expected timeout ToolResult, got {other:?}"),
2062        }
2063        assert!(matches!(
2064            rx.recv().await.unwrap().unwrap(),
2065            HarnessInternalEvent::AssistantTextChunk { ref delta, .. } if delta == "recovered"
2066        ));
2067        assert!(matches!(
2068            rx.recv().await.unwrap().unwrap(),
2069            HarnessInternalEvent::TurnEnd { ref stop_reason, .. } if stop_reason == "end_turn"
2070        ));
2071    }
2072
2073    #[derive(Clone)]
2074    struct RuntimeErrorToolRuntime;
2075
2076    #[async_trait]
2077    impl ToolRuntime for RuntimeErrorToolRuntime {
2078        fn specs(&self) -> Vec<crate::tools::ToolSpec> {
2079            vec![crate::tools::ToolSpec {
2080                name: "flaky".into(),
2081                description: "always returns a runtime failure".into(),
2082                input_schema: serde_json::json!({"type": "object"}),
2083            }]
2084        }
2085
2086        async fn invoke(
2087            &self,
2088            _invocation: ToolInvocation,
2089        ) -> Result<ToolOutcome, ToolRuntimeError> {
2090            Err(ToolRuntimeError::Runtime(
2091                "sandbox exec stream closed".into(),
2092            ))
2093        }
2094    }
2095
2096    #[tokio::test]
2097    async fn agent_loop_tool_runtime_error_is_model_observable_result() {
2098        let model = StreamingFakeClient::new(vec![
2099            vec![
2100                ModelChunk::ToolCallStart {
2101                    id: "tc_runtime".into(),
2102                    name: "flaky".into(),
2103                },
2104                ModelChunk::ToolCallEnd {
2105                    id: "tc_runtime".into(),
2106                    input: Some(json!({})),
2107                },
2108                ModelChunk::Done {
2109                    stop_reason: "tool_use".into(),
2110                    usage: None,
2111                },
2112            ],
2113            vec![
2114                ModelChunk::TextDelta {
2115                    msg_id: "r2".into(),
2116                    delta: "recovered".into(),
2117                },
2118                ModelChunk::Done {
2119                    stop_reason: "end_turn".into(),
2120                    usage: None,
2121                },
2122            ],
2123        ]);
2124        let harness = AgentLoopHarness::new(model, RuntimeErrorToolRuntime);
2125        let mut rx = harness
2126            .run_turn(NativeTurnInput {
2127                prompt_text: "run flaky".into(),
2128                system_prompt: None,
2129                attachments: vec![],
2130                cancel_token: None,
2131                prior_messages: vec![],
2132                context_path: None,
2133            })
2134            .await
2135            .unwrap();
2136
2137        assert!(matches!(
2138            rx.recv().await.unwrap().unwrap(),
2139            HarnessInternalEvent::ToolCall { .. }
2140        ));
2141        match rx.recv().await.unwrap().unwrap() {
2142            HarnessInternalEvent::ToolResult { id, output } => {
2143                assert_eq!(id, "tc_runtime");
2144                let err = output.unwrap_err();
2145                assert!(err.contains("Runtime"));
2146                assert!(err.contains("sandbox exec stream closed"));
2147            }
2148            other => panic!("expected runtime-error ToolResult, got {other:?}"),
2149        }
2150        assert!(matches!(
2151            rx.recv().await.unwrap().unwrap(),
2152            HarnessInternalEvent::AssistantTextChunk { ref delta, .. } if delta == "recovered"
2153        ));
2154        assert!(matches!(
2155            rx.recv().await.unwrap().unwrap(),
2156            HarnessInternalEvent::TurnEnd { ref stop_reason, .. } if stop_reason == "end_turn"
2157        ));
2158    }
2159
2160    #[tokio::test]
2161    async fn agent_loop_invalid_tool_input_is_model_observable_and_bounded() {
2162        let huge_content = "x".repeat(20_000);
2163        let model = StreamingFakeClient::new(vec![
2164            vec![
2165                ModelChunk::ToolCallStart {
2166                    id: "tc_bad_write".into(),
2167                    name: "write".into(),
2168                },
2169                ModelChunk::ToolCallEnd {
2170                    id: "tc_bad_write".into(),
2171                    input: Some(json!({"content": huge_content})),
2172                },
2173                ModelChunk::Done {
2174                    stop_reason: "tool_use".into(),
2175                    usage: None,
2176                },
2177            ],
2178            vec![
2179                ModelChunk::TextDelta {
2180                    msg_id: "r2".into(),
2181                    delta: "recovered".into(),
2182                },
2183                ModelChunk::Done {
2184                    stop_reason: "end_turn".into(),
2185                    usage: None,
2186                },
2187            ],
2188        ]);
2189        let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
2190        let mut rx = harness
2191            .run_turn(NativeTurnInput {
2192                prompt_text: "write file".into(),
2193                system_prompt: None,
2194                attachments: vec![],
2195                cancel_token: None,
2196                prior_messages: vec![],
2197                context_path: None,
2198            })
2199            .await
2200            .unwrap();
2201
2202        assert!(matches!(
2203            rx.recv().await.unwrap().unwrap(),
2204            HarnessInternalEvent::ToolCall { .. }
2205        ));
2206        match rx.recv().await.unwrap().unwrap() {
2207            HarnessInternalEvent::ToolResult { output, .. } => {
2208                let err = output.unwrap_err();
2209                // Now caught by the wrapper's schema validation BEFORE the
2210                // inner runtime, with a teaching example appended.
2211                assert!(err.contains("The write tool was called with invalid arguments"));
2212                assert!(err.contains("missing required field `path`"), "{err}");
2213                assert!(err.contains("Received fields: content"));
2214                assert!(err.contains("string(20000 chars"));
2215                assert!(
2216                    err.contains("Expected shape"),
2217                    "teaching example missing: {err}"
2218                );
2219                assert!(
2220                    !err.contains(&"x".repeat(2000)),
2221                    "error should not echo full content"
2222                );
2223            }
2224            other => panic!("expected invalid-input ToolResult, got {other:?}"),
2225        }
2226        assert!(matches!(
2227            rx.recv().await.unwrap().unwrap(),
2228            HarnessInternalEvent::AssistantTextChunk { ref delta, .. } if delta == "recovered"
2229        ));
2230    }
2231
2232    /// Spy strategy that always fires and records the call count. Lets
2233    /// us verify agent_loop actually consults the compaction policy
2234    /// between steps without depending on a real summarize round trip.
2235    struct CountingCompactionStrategy {
2236        calls: Arc<AtomicUsize>,
2237    }
2238
2239    #[async_trait]
2240    impl CompactionStrategy for CountingCompactionStrategy {
2241        fn should_compact(&self, _messages: &[ChatMessage], _context_window_tokens: u64) -> bool {
2242            true
2243        }
2244
2245        async fn compact(
2246            &self,
2247            _messages: Vec<ChatMessage>,
2248            _ctx: &CompactionContext,
2249        ) -> Result<crate::compaction::CompactionOutcome, CompactionError> {
2250            self.calls.fetch_add(1, Ordering::SeqCst);
2251            // Replace history with a single synthetic user message — the
2252            // test asserts on the call count, not the content shape.
2253            Ok(crate::compaction::CompactionOutcome {
2254                messages: vec![ChatMessage::User {
2255                    content: "<conversation-summary>FOLDED</conversation-summary>".into(),
2256                    attachments: vec![],
2257                }],
2258                usage: None,
2259            })
2260        }
2261    }
2262
2263    /// Spy strategy that reports a fixed `HarnessUsage` from its compact
2264    /// call. Lets us assert that agent_loop forwards compaction usage
2265    /// into the turn-level total + the `compaction_*` sub-buckets.
2266    struct UsageReportingCompactionStrategy {
2267        invoked: Arc<AtomicUsize>,
2268        per_call_usage: HarnessUsage,
2269    }
2270
2271    #[async_trait]
2272    impl CompactionStrategy for UsageReportingCompactionStrategy {
2273        fn should_compact(&self, _: &[ChatMessage], _: u64) -> bool {
2274            // Fire once per step. Since the model fixture below ends the
2275            // turn after one step, this triggers exactly once per
2276            // run_turn.
2277            self.invoked.load(Ordering::SeqCst) == 0
2278        }
2279        async fn compact(
2280            &self,
2281            messages: Vec<ChatMessage>,
2282            _ctx: &CompactionContext,
2283        ) -> Result<crate::compaction::CompactionOutcome, CompactionError> {
2284            self.invoked.fetch_add(1, Ordering::SeqCst);
2285            Ok(crate::compaction::CompactionOutcome {
2286                messages,
2287                usage: Some(self.per_call_usage.clone()),
2288            })
2289        }
2290    }
2291
2292    #[tokio::test]
2293    async fn agent_loop_attributes_compaction_usage_to_subbucket_and_total() {
2294        // Compaction reports 50/20 tokens; main step reports 100/30.
2295        // TurnEnd.usage should sum into 150/50, with compaction_*
2296        // sub-buckets showing the 50/20 isolated.
2297        let model = StreamingFakeClient::new(vec![vec![
2298            ModelChunk::TextDelta {
2299                msg_id: "m".into(),
2300                delta: "done".into(),
2301            },
2302            ModelChunk::Done {
2303                stop_reason: "end_turn".into(),
2304                usage: Some(usage(100, 30, 0)),
2305            },
2306        ]]);
2307        let invoked = Arc::new(AtomicUsize::new(0));
2308        let strategy = UsageReportingCompactionStrategy {
2309            invoked: invoked.clone(),
2310            per_call_usage: usage(50, 20, 0),
2311        };
2312        let policy = CompactionPolicy::new(
2313            Arc::new(strategy),
2314            Arc::new(ScriptedModelClient),
2315            1, // forces should_compact's true branch
2316        );
2317        let harness = AgentLoopHarness::new(model, MockToolRuntime::new()).with_compaction(policy);
2318        let mut rx = harness
2319            .run_turn(NativeTurnInput {
2320                prompt_text: "hi".into(),
2321                system_prompt: None,
2322                attachments: vec![],
2323                cancel_token: None,
2324                prior_messages: vec![],
2325                context_path: None,
2326            })
2327            .await
2328            .unwrap();
2329        let mut final_usage = None;
2330        while let Some(item) = rx.recv().await {
2331            if let HarnessInternalEvent::TurnEnd { usage, .. } = item.unwrap() {
2332                final_usage = usage;
2333                break;
2334            }
2335        }
2336        assert_eq!(invoked.load(Ordering::SeqCst), 1);
2337        let u = final_usage.expect("TurnEnd carried usage");
2338        // Main step (100, 30) + compaction (50, 20) = total (150, 50).
2339        assert_eq!(u.input_tokens, 150);
2340        assert_eq!(u.output_tokens, 50);
2341        // Compaction sub-bucket isolates the (50, 20) portion.
2342        assert_eq!(u.compaction_input_tokens, 50);
2343        assert_eq!(u.compaction_output_tokens, 20);
2344    }
2345
2346    /// Stub client that records every `ModelTurnInput.messages` it was
2347    /// asked to stream. Lets the test assert that the compaction-replaced
2348    /// messages are what reaches the model on the next step.
2349    #[derive(Clone)]
2350    struct RecordingFakeClient {
2351        last_messages: Arc<Mutex<Option<Vec<ChatMessage>>>>,
2352        chunks: Vec<ModelChunk>,
2353    }
2354
2355    #[async_trait]
2356    impl ModelClient for RecordingFakeClient {
2357        fn hosted_capability(&self, _capability: HostedCapability) -> CapabilitySupport {
2358            CapabilitySupport::Unsupported
2359        }
2360
2361        async fn stream(
2362            &self,
2363            input: ModelTurnInput,
2364        ) -> Result<BoxStream<'static, Result<ModelChunk, ModelClientError>>, ModelClientError>
2365        {
2366            *self.last_messages.lock().unwrap() = Some(input.messages);
2367            Ok(futures::stream::iter(self.chunks.clone().into_iter().map(Ok)).boxed())
2368        }
2369    }
2370
2371    #[tokio::test]
2372    async fn agent_loop_invokes_compaction_between_steps() {
2373        let calls = Arc::new(AtomicUsize::new(0));
2374        let last_messages = Arc::new(Mutex::new(None::<Vec<ChatMessage>>));
2375        let model = RecordingFakeClient {
2376            last_messages: last_messages.clone(),
2377            chunks: vec![
2378                ModelChunk::TextDelta {
2379                    msg_id: "m".into(),
2380                    delta: "done".into(),
2381                },
2382                ModelChunk::Done {
2383                    stop_reason: "end_turn".into(),
2384                    usage: None,
2385                },
2386            ],
2387        };
2388        // Summary client used by the spy strategy's ctx — not actually
2389        // called because our strategy short-circuits, but we satisfy
2390        // the policy contract.
2391        let summary_client: Arc<dyn ModelClient> = Arc::new(ScriptedModelClient);
2392        let policy = CompactionPolicy::new(
2393            Arc::new(CountingCompactionStrategy {
2394                calls: calls.clone(),
2395            }),
2396            summary_client,
2397            1, // forces should_compact to fire
2398        );
2399
2400        let harness = AgentLoopHarness::new(model, MockToolRuntime::new()).with_compaction(policy);
2401        let mut rx = harness
2402            .run_turn(NativeTurnInput {
2403                prompt_text: "hello".into(),
2404                system_prompt: None,
2405                attachments: vec![],
2406                cancel_token: None,
2407                prior_messages: vec![],
2408                context_path: None,
2409            })
2410            .await
2411            .unwrap();
2412        let mut compaction_event: Option<(usize, usize)> = None;
2413        while let Some(item) = rx.recv().await {
2414            match item.unwrap() {
2415                HarnessInternalEvent::CompactionApplied {
2416                    original_message_count,
2417                    compacted_message_count,
2418                    ..
2419                } => {
2420                    compaction_event = Some((original_message_count, compacted_message_count));
2421                }
2422                HarnessInternalEvent::TurnEnd { .. } => break,
2423                _ => {}
2424            }
2425        }
2426        // Compaction ran exactly once before the single model step.
2427        assert_eq!(calls.load(Ordering::SeqCst), 1);
2428        // CompactionApplied event surfaced with sensible counts.
2429        let (orig, comp) = compaction_event.expect("CompactionApplied event emitted");
2430        assert_eq!(orig, 1, "started with 1 message ([User \"hello\"])");
2431        assert_eq!(comp, 1, "spy strategy folded to single User message");
2432        // The model saw the FOLDED messages, not the original
2433        // [User "hello"] prefix.
2434        let observed = last_messages.lock().unwrap().clone().expect("model called");
2435        assert_eq!(observed.len(), 1);
2436        match &observed[0] {
2437            ChatMessage::User { content, .. } => {
2438                assert!(content.contains("FOLDED"), "got {content:?}");
2439            }
2440            other => panic!("expected User, got {other:?}"),
2441        }
2442    }
2443
2444    #[test]
2445    fn compaction_policy_summarizing_uses_default_strategy() {
2446        let policy = CompactionPolicy::summarizing(Arc::new(ScriptedModelClient), 100_000);
2447
2448        assert_eq!(policy.context_window_tokens, 100_000);
2449        assert!(!policy.strategy.should_compact(&[], 100_000));
2450    }
2451
2452    /// Model client whose stream blocks indefinitely until cancelled.
2453    /// Lets the test prove that cancel_token.cancelled() races
2454    /// stream.next() and wins.
2455    #[derive(Clone)]
2456    struct HangingModelClient {
2457        started: Arc<tokio::sync::Notify>,
2458    }
2459
2460    #[async_trait]
2461    impl ModelClient for HangingModelClient {
2462        fn hosted_capability(&self, _capability: HostedCapability) -> CapabilitySupport {
2463            CapabilitySupport::Unsupported
2464        }
2465
2466        async fn stream(
2467            &self,
2468            _input: ModelTurnInput,
2469        ) -> Result<BoxStream<'static, Result<ModelChunk, ModelClientError>>, ModelClientError>
2470        {
2471            // Channel-backed stream whose sender never sends and never
2472            // drops — `rx.next().await` parks forever. Mirrors a real
2473            // LLM that opened the SSE response but hasn't shipped a
2474            // chunk yet (slow first-token time).
2475            let (tx, rx) = mpsc::channel::<Result<ModelChunk, ModelClientError>>(1);
2476            let started = self.started.clone();
2477            tokio::spawn(async move {
2478                // Hold the sender alive for the test's lifetime. Notify
2479                // the test that the stream is "started" so it knows
2480                // when to fire cancel — proves the cancel races a
2481                // pending stream.next(), not the pre-step check.
2482                started.notify_one();
2483                let _retain = tx; // suppress drop warning
2484                let () = std::future::pending().await;
2485            });
2486            Ok(tokio_stream::wrappers::ReceiverStream::new(rx).boxed())
2487        }
2488    }
2489
2490    #[tokio::test]
2491    async fn agent_loop_cancellation_interrupts_in_flight_stream() {
2492        // Without cancel: agent_loop would hang forever waiting for the
2493        // first chunk. With cancel fired *after* the stream began, the
2494        // select! arm in consume_step_stream wins and we get TurnEnd
2495        // with stop_reason "interrupt" within milliseconds.
2496        let started = Arc::new(tokio::sync::Notify::new());
2497        let model = HangingModelClient {
2498            started: started.clone(),
2499        };
2500        let cancel = CancellationToken::new();
2501        let cancel_for_outside = cancel.clone();
2502
2503        let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
2504        let mut rx = harness
2505            .run_turn(NativeTurnInput {
2506                prompt_text: "hi".into(),
2507                system_prompt: None,
2508                attachments: vec![],
2509                cancel_token: Some(cancel),
2510                prior_messages: vec![],
2511                context_path: None,
2512            })
2513            .await
2514            .unwrap();
2515
2516        // Wait for the model to actually start streaming, then cancel.
2517        // (Cancelling before the stream begins would short-circuit at
2518        // the pre-step check_cancel! macro — also correct, but a
2519        // different code path. We want to exercise the select!.)
2520        started.notified().await;
2521        cancel_for_outside.cancel();
2522
2523        // Within a small window we should observe a TurnEnd{interrupt}.
2524        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
2525        let mut saw_interrupt = false;
2526        while tokio::time::Instant::now() < deadline {
2527            tokio::select! {
2528                item = rx.recv() => {
2529                    match item {
2530                        Some(Ok(HarnessInternalEvent::TurnEnd { stop_reason, .. })) => {
2531                            assert_eq!(stop_reason, "interrupt");
2532                            saw_interrupt = true;
2533                            break;
2534                        }
2535                        Some(_) => continue,
2536                        None => break,
2537                    }
2538                }
2539                _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {}
2540            }
2541        }
2542        assert!(saw_interrupt, "expected TurnEnd{{interrupt}} after cancel");
2543    }
2544
2545    /// Per-`stream()`-call scripted client for the mid-stream idle-timeout
2546    /// tests. Each behavior either streams chunks to completion (stream
2547    /// closes), or emits an optional prefix then parks forever without
2548    /// closing — simulating a silently wedged upstream (TCP open, no FIN/RST,
2549    /// no further bytes). `calls` counts establishments so tests can assert
2550    /// whether a reconnect happened.
2551    enum StallBehavior {
2552        /// Stream these chunks, then close (clean end).
2553        Complete(Vec<ModelChunk>),
2554        /// Emit these chunks (possibly none), then hang forever.
2555        EmitThenHang(Vec<ModelChunk>),
2556    }
2557
2558    #[derive(Clone)]
2559    struct StallingModelClient {
2560        behaviors: Arc<Mutex<Vec<StallBehavior>>>,
2561        calls: Arc<AtomicUsize>,
2562    }
2563
2564    impl StallingModelClient {
2565        fn new(behaviors: Vec<StallBehavior>) -> Self {
2566            Self {
2567                behaviors: Arc::new(Mutex::new(behaviors)),
2568                calls: Arc::new(AtomicUsize::new(0)),
2569            }
2570        }
2571    }
2572
2573    #[async_trait]
2574    impl ModelClient for StallingModelClient {
2575        fn hosted_capability(&self, _capability: HostedCapability) -> CapabilitySupport {
2576            CapabilitySupport::Unsupported
2577        }
2578
2579        async fn stream(
2580            &self,
2581            _input: ModelTurnInput,
2582        ) -> Result<BoxStream<'static, Result<ModelChunk, ModelClientError>>, ModelClientError>
2583        {
2584            self.calls.fetch_add(1, Ordering::SeqCst);
2585            // Pop the next scripted behavior; once the script is exhausted,
2586            // default to hanging (covers "every attempt stalls" tests).
2587            let behavior = {
2588                let mut b = self.behaviors.lock().unwrap();
2589                if b.is_empty() {
2590                    StallBehavior::EmitThenHang(vec![])
2591                } else {
2592                    b.remove(0)
2593                }
2594            };
2595            let (tx, rx) = mpsc::channel::<Result<ModelChunk, ModelClientError>>(8);
2596            tokio::spawn(async move {
2597                match behavior {
2598                    StallBehavior::Complete(chunks) => {
2599                        for c in chunks {
2600                            if tx.send(Ok(c)).await.is_err() {
2601                                return;
2602                            }
2603                        }
2604                        // tx dropped here → stream ends cleanly.
2605                    }
2606                    StallBehavior::EmitThenHang(chunks) => {
2607                        for c in chunks {
2608                            if tx.send(Ok(c)).await.is_err() {
2609                                return;
2610                            }
2611                        }
2612                        let _retain = tx; // hold sender open so rx parks
2613                        let () = std::future::pending().await;
2614                    }
2615                }
2616            });
2617            Ok(tokio_stream::wrappers::ReceiverStream::new(rx).boxed())
2618        }
2619    }
2620
2621    #[tokio::test(start_paused = true)]
2622    async fn agent_loop_reconnects_after_stall_before_any_output() {
2623        // First establishment opens the stream then goes silent → idle
2624        // watchdog fires → no output yet, so it's safe to reconnect. Second
2625        // establishment streams a full response. We should see the text
2626        // exactly once and a clean end_turn, with two establishments total.
2627        let model = StallingModelClient::new(vec![
2628            StallBehavior::EmitThenHang(vec![]),
2629            StallBehavior::Complete(vec![
2630                ModelChunk::TextDelta {
2631                    msg_id: "m".into(),
2632                    delta: "ok".into(),
2633                },
2634                ModelChunk::Done {
2635                    stop_reason: "end_turn".into(),
2636                    usage: None,
2637                },
2638            ]),
2639        ]);
2640        let calls = model.calls.clone();
2641        let harness = AgentLoopHarness::new(model, MockToolRuntime::new())
2642            .with_stream_resilience(Duration::from_millis(50), 3);
2643        let mut rx = harness
2644            .run_turn(NativeTurnInput {
2645                prompt_text: "hi".into(),
2646                system_prompt: None,
2647                attachments: vec![],
2648                cancel_token: None,
2649                prior_messages: vec![],
2650                context_path: None,
2651            })
2652            .await
2653            .unwrap();
2654
2655        let mut text = String::new();
2656        let mut stop = None;
2657        while let Some(item) = rx.recv().await {
2658            match item.expect("no error expected") {
2659                HarnessInternalEvent::AssistantTextChunk { delta, .. } => text.push_str(&delta),
2660                HarnessInternalEvent::TurnEnd { stop_reason, .. } => {
2661                    stop = Some(stop_reason);
2662                    break;
2663                }
2664                _ => {}
2665            }
2666        }
2667        assert_eq!(stop.as_deref(), Some("end_turn"));
2668        assert_eq!(text, "ok", "text delivered exactly once, no duplication");
2669        assert_eq!(
2670            calls.load(Ordering::SeqCst),
2671            2,
2672            "stream established twice (one reconnect)"
2673        );
2674    }
2675
2676    #[tokio::test(start_paused = true)]
2677    async fn agent_loop_surfaces_error_when_reconnect_budget_exhausted() {
2678        // Every establishment stalls. With max_attempts = 2 we get one
2679        // reconnect, then the second stall is terminal → ModelNetwork error.
2680        let model = StallingModelClient::new(vec![]); // all default to hang
2681        let calls = model.calls.clone();
2682        let harness = AgentLoopHarness::new(model, MockToolRuntime::new())
2683            .with_stream_resilience(Duration::from_millis(50), 2);
2684        let mut rx = harness
2685            .run_turn(NativeTurnInput {
2686                prompt_text: "hi".into(),
2687                system_prompt: None,
2688                attachments: vec![],
2689                cancel_token: None,
2690                prior_messages: vec![],
2691                context_path: None,
2692            })
2693            .await
2694            .unwrap();
2695
2696        let mut saw_error = false;
2697        while let Some(item) = rx.recv().await {
2698            match item {
2699                Err(NativeHarnessError::ModelNetwork(msg)) => {
2700                    assert!(msg.contains("stalled"), "got {msg:?}");
2701                    saw_error = true;
2702                    break;
2703                }
2704                Err(other) => panic!("unexpected error variant: {other:?}"),
2705                Ok(_) => {}
2706            }
2707        }
2708        assert!(
2709            saw_error,
2710            "expected ModelNetwork stall error after budget exhausted"
2711        );
2712        assert_eq!(
2713            calls.load(Ordering::SeqCst),
2714            2,
2715            "two establishments (initial + one reconnect)"
2716        );
2717    }
2718
2719    #[tokio::test(start_paused = true)]
2720    async fn agent_loop_does_not_reconnect_after_stall_with_partial_output() {
2721        // Stream emits text (the user now sees it) then stalls. Even though
2722        // the reconnect budget is generous, a stall *after* output is
2723        // terminal — reconnecting would re-issue the request and duplicate
2724        // what was already shown. Expect: text once, then a ModelNetwork
2725        // error, and exactly one establishment (no reconnect).
2726        let model = StallingModelClient::new(vec![StallBehavior::EmitThenHang(vec![
2727            ModelChunk::TextDelta {
2728                msg_id: "m".into(),
2729                delta: "partial".into(),
2730            },
2731        ])]);
2732        let calls = model.calls.clone();
2733        let harness = AgentLoopHarness::new(model, MockToolRuntime::new())
2734            .with_stream_resilience(Duration::from_millis(50), 5);
2735        let mut rx = harness
2736            .run_turn(NativeTurnInput {
2737                prompt_text: "hi".into(),
2738                system_prompt: None,
2739                attachments: vec![],
2740                cancel_token: None,
2741                prior_messages: vec![],
2742                context_path: None,
2743            })
2744            .await
2745            .unwrap();
2746
2747        let mut text = String::new();
2748        let mut saw_error = false;
2749        while let Some(item) = rx.recv().await {
2750            match item {
2751                Ok(HarnessInternalEvent::AssistantTextChunk { delta, .. }) => text.push_str(&delta),
2752                Err(NativeHarnessError::ModelNetwork(_)) => {
2753                    saw_error = true;
2754                    break;
2755                }
2756                Err(other) => panic!("unexpected error variant: {other:?}"),
2757                Ok(_) => {}
2758            }
2759        }
2760        assert!(saw_error, "expected terminal ModelNetwork error");
2761        assert_eq!(
2762            text, "partial",
2763            "partial output delivered once, not replayed"
2764        );
2765        assert_eq!(
2766            calls.load(Ordering::SeqCst),
2767            1,
2768            "no reconnect once output has reached the user"
2769        );
2770    }
2771
2772    #[tokio::test]
2773    async fn agent_loop_accumulates_thinking_chunks_and_signature() {
2774        // Anthropic-style step: thinking deltas + signature, then text,
2775        // then Done. Asserts that:
2776        //   * each ThinkingDelta with non-empty text emits an
2777        //     AssistantThinkingChunk;
2778        //   * the signature latches and ends up on
2779        //     ChatMessage::Assistant.thinking;
2780        //   * an empty-text ThinkingDelta carrying a signature does NOT
2781        //     emit a chunk (signature-only chunks are silent).
2782        let model = StreamingFakeClient::new(vec![vec![
2783            ModelChunk::ThinkingDelta {
2784                thinking_id: "th_1".into(),
2785                delta: "let me think...".into(),
2786                signature: None,
2787            },
2788            ModelChunk::ThinkingDelta {
2789                thinking_id: "th_1".into(),
2790                delta: "".into(),
2791                signature: Some("sig_abc".into()),
2792            },
2793            ModelChunk::TextDelta {
2794                msg_id: "m1".into(),
2795                delta: "ok".into(),
2796            },
2797            ModelChunk::Done {
2798                stop_reason: "end_turn".into(),
2799                usage: None,
2800            },
2801        ]]);
2802        let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
2803        let mut rx = harness
2804            .run_turn(NativeTurnInput {
2805                prompt_text: "hi".into(),
2806                system_prompt: None,
2807                attachments: vec![],
2808                cancel_token: None,
2809                prior_messages: vec![],
2810                context_path: None,
2811            })
2812            .await
2813            .unwrap();
2814
2815        let mut thinking_chunks: Vec<String> = Vec::new();
2816        let mut text_chunks: Vec<String> = Vec::new();
2817        let mut saw_end = false;
2818        while let Some(item) = rx.recv().await {
2819            match item.unwrap() {
2820                HarnessInternalEvent::AssistantThinkingChunk { msg_id, delta } => {
2821                    assert_eq!(msg_id, "thinking_native_0");
2822                    thinking_chunks.push(delta);
2823                }
2824                HarnessInternalEvent::AssistantTextChunk { msg_id, delta } => {
2825                    assert_eq!(msg_id, "msg_native_0");
2826                    text_chunks.push(delta);
2827                }
2828                HarnessInternalEvent::TurnEnd { .. } => {
2829                    saw_end = true;
2830                    break;
2831                }
2832                other => panic!("unexpected event: {other:?}"),
2833            }
2834        }
2835        // Only the non-empty thinking delta emits a chunk; signature-only
2836        // chunk is silent.
2837        assert_eq!(thinking_chunks, vec!["let me think..."]);
2838        assert_eq!(text_chunks, vec!["ok"]);
2839        assert!(saw_end);
2840    }
2841
2842    #[tokio::test]
2843    async fn agent_loop_runs_tool_then_final_message() {
2844        let harness = AgentLoopHarness::new(
2845            ScriptedModelClient,
2846            MockToolRuntime::new().with_file("README.md", "hello"),
2847        );
2848        let mut rx = harness
2849            .run_turn(NativeTurnInput {
2850                prompt_text: "read README.md".into(),
2851                system_prompt: None,
2852                attachments: vec![],
2853                cancel_token: None,
2854                prior_messages: vec![],
2855                context_path: None,
2856            })
2857            .await
2858            .unwrap();
2859
2860        assert!(matches!(
2861            rx.recv().await.unwrap().unwrap(),
2862            HarnessInternalEvent::AssistantTextChunk { .. }
2863        ));
2864        assert!(matches!(
2865            rx.recv().await.unwrap().unwrap(),
2866            HarnessInternalEvent::ToolCall { ref name, .. } if name == "read"
2867        ));
2868        assert!(matches!(
2869            rx.recv().await.unwrap().unwrap(),
2870            HarnessInternalEvent::ToolResult { .. }
2871        ));
2872        assert!(matches!(
2873            rx.recv().await.unwrap().unwrap(),
2874            HarnessInternalEvent::AssistantTextChunk { .. }
2875        ));
2876        assert!(matches!(
2877            rx.recv().await.unwrap().unwrap(),
2878            HarnessInternalEvent::TurnEnd { .. }
2879        ));
2880        assert!(rx.recv().await.is_none());
2881    }
2882
2883    /// `TurnEnd.final_messages` must reflect the whole conversation:
2884    /// every `prior_messages` entry RD seeded the turn with, plus the
2885    /// new user prompt, plus the assistant's reply. This is the
2886    /// contract RD's `native_history` slot depends on for multi-turn
2887    /// replay — if it ever shrinks (e.g. we accidentally clone before
2888    /// the final push), same-process multi-turn loses history.
2889    #[tokio::test]
2890    async fn agent_loop_turn_end_carries_full_message_history() {
2891        let model = QueueModelClient::new(vec![ModelResponse::Message {
2892            text: "second reply".into(),
2893            stop_reason: "end_turn".into(),
2894            usage: None,
2895        }]);
2896        let harness = AgentLoopHarness::new(model, MockToolRuntime::new());
2897        // Simulate "RD captured this from a previous turn".
2898        let prior = vec![
2899            ChatMessage::User {
2900                content: "first prompt".into(),
2901                attachments: vec![],
2902            },
2903            ChatMessage::Assistant {
2904                text: Some("first reply".into()),
2905                tool_calls: vec![],
2906                thinking: None,
2907                usage: None,
2908            },
2909        ];
2910        let mut rx = harness
2911            .run_turn(NativeTurnInput {
2912                prompt_text: "second prompt".into(),
2913                system_prompt: None,
2914                attachments: vec![],
2915                cancel_token: None,
2916                prior_messages: prior,
2917                context_path: None,
2918            })
2919            .await
2920            .unwrap();
2921        let mut final_messages: Option<Vec<ChatMessage>> = None;
2922        while let Some(item) = rx.recv().await {
2923            if let HarnessInternalEvent::TurnEnd {
2924                final_messages: m, ..
2925            } = item.unwrap()
2926            {
2927                final_messages = Some(m);
2928                break;
2929            }
2930        }
2931        let msgs = final_messages.expect("TurnEnd carried final_messages");
2932        // [user-1, assistant-1, user-2, assistant-2] — 4 entries.
2933        assert_eq!(msgs.len(), 4, "got {msgs:?}");
2934        match &msgs[0] {
2935            ChatMessage::User { content, .. } => assert_eq!(content, "first prompt"),
2936            other => panic!("msgs[0] not user-1: {other:?}"),
2937        }
2938        match &msgs[1] {
2939            ChatMessage::Assistant { text, .. } => {
2940                assert_eq!(text.as_deref(), Some("first reply"));
2941            }
2942            other => panic!("msgs[1] not assistant-1: {other:?}"),
2943        }
2944        match &msgs[2] {
2945            ChatMessage::User { content, .. } => assert_eq!(content, "second prompt"),
2946            other => panic!("msgs[2] not user-2: {other:?}"),
2947        }
2948        match &msgs[3] {
2949            ChatMessage::Assistant { text, .. } => {
2950                assert_eq!(text.as_deref(), Some("second reply"));
2951            }
2952            other => panic!("msgs[3] not assistant-2: {other:?}"),
2953        }
2954    }
2955
2956    /// Tool runtime that sleeps for a configurable duration before
2957    /// returning. Records the actual concurrency observed (max number
2958    /// of in-flight invocations at any point) so we can assert the
2959    /// agent loop is truly running them in parallel, not interleaving.
2960    #[derive(Clone)]
2961    struct ConcurrencyProbeRuntime {
2962        sleep_for: std::time::Duration,
2963        in_flight: Arc<AtomicUsize>,
2964        max_concurrency: Arc<AtomicUsize>,
2965        call_order: Arc<Mutex<Vec<String>>>,
2966        cancelled: Arc<AtomicUsize>,
2967    }
2968
2969    impl ConcurrencyProbeRuntime {
2970        fn new(sleep_for: std::time::Duration) -> Self {
2971            Self {
2972                sleep_for,
2973                in_flight: Arc::new(AtomicUsize::new(0)),
2974                max_concurrency: Arc::new(AtomicUsize::new(0)),
2975                call_order: Arc::new(Mutex::new(Vec::new())),
2976                cancelled: Arc::new(AtomicUsize::new(0)),
2977            }
2978        }
2979    }
2980
2981    #[async_trait]
2982    impl ToolRuntime for ConcurrencyProbeRuntime {
2983        fn specs(&self) -> Vec<crate::tools::ToolSpec> {
2984            vec![crate::tools::ToolSpec {
2985                name: "slow".into(),
2986                description: "sleeps".into(),
2987                input_schema: serde_json::json!({"type": "object"}),
2988            }]
2989        }
2990
2991        async fn invoke(
2992            &self,
2993            invocation: ToolInvocation,
2994        ) -> Result<ToolOutcome, ToolRuntimeError> {
2995            self.call_order.lock().unwrap().push(invocation.id.clone());
2996            let now = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
2997            let mut prev = self.max_concurrency.load(Ordering::SeqCst);
2998            while now > prev {
2999                match self.max_concurrency.compare_exchange(
3000                    prev,
3001                    now,
3002                    Ordering::SeqCst,
3003                    Ordering::SeqCst,
3004                ) {
3005                    Ok(_) => break,
3006                    Err(actual) => prev = actual,
3007                }
3008            }
3009            tokio::time::sleep(self.sleep_for).await;
3010            self.in_flight.fetch_sub(1, Ordering::SeqCst);
3011            Ok(ToolOutcome {
3012                output: Ok(serde_json::json!({"slept": true, "id": invocation.id})),
3013                attachments: vec![],
3014            })
3015        }
3016
3017        async fn invoke_cancellable(
3018            &self,
3019            invocation: ToolInvocation,
3020            cancel: Option<&CancellationToken>,
3021        ) -> Result<ToolOutcome, ToolRuntimeError> {
3022            self.call_order.lock().unwrap().push(invocation.id.clone());
3023            let now = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
3024            let mut prev = self.max_concurrency.load(Ordering::SeqCst);
3025            while now > prev {
3026                match self.max_concurrency.compare_exchange(
3027                    prev,
3028                    now,
3029                    Ordering::SeqCst,
3030                    Ordering::SeqCst,
3031                ) {
3032                    Ok(_) => break,
3033                    Err(actual) => prev = actual,
3034                }
3035            }
3036            if let Some(token) = cancel {
3037                tokio::select! {
3038                    _ = token.cancelled() => {
3039                        self.cancelled.fetch_add(1, Ordering::SeqCst);
3040                        self.in_flight.fetch_sub(1, Ordering::SeqCst);
3041                        Err(ToolRuntimeError::Runtime("cancelled".into()))
3042                    }
3043                    _ = tokio::time::sleep(self.sleep_for) => {
3044                        self.in_flight.fetch_sub(1, Ordering::SeqCst);
3045                        Ok(ToolOutcome {
3046                            output: Ok(serde_json::json!({"slept": true, "id": invocation.id})),
3047                            attachments: vec![],
3048                        })
3049                    }
3050                }
3051            } else {
3052                tokio::time::sleep(self.sleep_for).await;
3053                self.in_flight.fetch_sub(1, Ordering::SeqCst);
3054                Ok(ToolOutcome {
3055                    output: Ok(serde_json::json!({"slept": true, "id": invocation.id})),
3056                    attachments: vec![],
3057                })
3058            }
3059        }
3060    }
3061
3062    /// When the model returns multiple `tool_use` blocks in a single
3063    /// step (parallel_tool_calls on OpenAI / multi tool_use on
3064    /// Anthropic), the agent loop MUST dispatch them concurrently —
3065    /// not sequentially. Before the F3 fix, only the first one was
3066    /// invoked and the rest were silently dropped.
3067    #[tokio::test]
3068    async fn agent_loop_runs_multi_tool_calls_concurrently() {
3069        // One step that emits 3 tool_use blocks back-to-back, then a
3070        // second step that returns a final message.
3071        let model = StreamingFakeClient::new(vec![
3072            vec![
3073                ModelChunk::ToolCallStart {
3074                    id: "tc_a".into(),
3075                    name: "slow".into(),
3076                },
3077                ModelChunk::ToolCallEnd {
3078                    id: "tc_a".into(),
3079                    input: Some(json!({})),
3080                },
3081                ModelChunk::ToolCallStart {
3082                    id: "tc_b".into(),
3083                    name: "slow".into(),
3084                },
3085                ModelChunk::ToolCallEnd {
3086                    id: "tc_b".into(),
3087                    input: Some(json!({})),
3088                },
3089                ModelChunk::ToolCallStart {
3090                    id: "tc_c".into(),
3091                    name: "slow".into(),
3092                },
3093                ModelChunk::ToolCallEnd {
3094                    id: "tc_c".into(),
3095                    input: Some(json!({})),
3096                },
3097                ModelChunk::Done {
3098                    stop_reason: "tool_use".into(),
3099                    usage: None,
3100                },
3101            ],
3102            vec![
3103                ModelChunk::TextDelta {
3104                    msg_id: "remote".into(),
3105                    delta: "done".into(),
3106                },
3107                ModelChunk::Done {
3108                    stop_reason: "end_turn".into(),
3109                    usage: None,
3110                },
3111            ],
3112        ]);
3113        let probe = ConcurrencyProbeRuntime::new(std::time::Duration::from_millis(80));
3114        let max_concurrency = probe.max_concurrency.clone();
3115        let harness = AgentLoopHarness::new(model, probe);
3116
3117        let start = std::time::Instant::now();
3118        let mut rx = harness
3119            .run_turn(NativeTurnInput {
3120                prompt_text: "go".into(),
3121                system_prompt: None,
3122                attachments: vec![],
3123                cancel_token: None,
3124                prior_messages: vec![],
3125                context_path: None,
3126            })
3127            .await
3128            .unwrap();
3129        let mut tool_results = 0;
3130        while let Some(item) = rx.recv().await {
3131            match item.unwrap() {
3132                HarnessInternalEvent::ToolResult { .. } => tool_results += 1,
3133                HarnessInternalEvent::TurnEnd { .. } => break,
3134                _ => {}
3135            }
3136        }
3137        let elapsed = start.elapsed();
3138        // All 3 tools surfaced results — none were silently dropped.
3139        assert_eq!(
3140            tool_results, 3,
3141            "expected 3 tool results, got {tool_results}"
3142        );
3143        // Concurrency probe saw all 3 in flight simultaneously.
3144        assert_eq!(
3145            max_concurrency.load(Ordering::SeqCst),
3146            3,
3147            "expected max concurrency 3 (parallel dispatch), got {}",
3148            max_concurrency.load(Ordering::SeqCst)
3149        );
3150        // Wall clock < 3× sleep duration confirms parallelism (3 × 80ms
3151        // = 240ms sequential; parallel should be ~80ms + scheduler
3152        // overhead, allow up to 200ms for slow CI).
3153        assert!(
3154            elapsed < std::time::Duration::from_millis(200),
3155            "elapsed {elapsed:?} suggests sequential execution"
3156        );
3157    }
3158
3159    /// When the cancel token fires while tool invocations are in
3160    /// flight, the agent loop must emit a clean `TurnEnd { interrupt }`
3161    /// and stop — not wait for the tools to drain naturally.
3162    #[tokio::test]
3163    async fn agent_loop_cancels_in_flight_tool_calls() {
3164        let model = StreamingFakeClient::new(vec![vec![
3165            ModelChunk::ToolCallStart {
3166                id: "tc_slow".into(),
3167                name: "slow".into(),
3168            },
3169            ModelChunk::ToolCallEnd {
3170                id: "tc_slow".into(),
3171                input: Some(json!({})),
3172            },
3173            ModelChunk::Done {
3174                stop_reason: "tool_use".into(),
3175                usage: None,
3176            },
3177        ]]);
3178        // 5-second sleep — if cancel didn't propagate, the test would
3179        // take 5s. We assert it returns in < 200ms.
3180        let probe = ConcurrencyProbeRuntime::new(std::time::Duration::from_secs(5));
3181        let cancelled_count = probe.cancelled.clone();
3182        let harness = AgentLoopHarness::new(model, probe);
3183
3184        let cancel = CancellationToken::new();
3185        let cancel_for_input = cancel.clone();
3186        let mut rx = harness
3187            .run_turn(NativeTurnInput {
3188                prompt_text: "go".into(),
3189                system_prompt: None,
3190                attachments: vec![],
3191                cancel_token: Some(cancel_for_input),
3192                prior_messages: vec![],
3193                context_path: None,
3194            })
3195            .await
3196            .unwrap();
3197
3198        // Let the tool spin up briefly, then cancel.
3199        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
3200        cancel.cancel();
3201
3202        let start = std::time::Instant::now();
3203        let mut saw_interrupt = false;
3204        while let Some(item) = rx.recv().await {
3205            if let HarnessInternalEvent::TurnEnd { stop_reason, .. } = item.unwrap() {
3206                assert_eq!(stop_reason, "interrupt");
3207                saw_interrupt = true;
3208                break;
3209            }
3210        }
3211        let elapsed = start.elapsed();
3212        assert!(saw_interrupt, "must see interrupt TurnEnd");
3213        assert!(
3214            elapsed < std::time::Duration::from_millis(200),
3215            "cancel propagation took too long: {elapsed:?}"
3216        );
3217        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
3218        while cancelled_count.load(Ordering::SeqCst) == 0 && tokio::time::Instant::now() < deadline
3219        {
3220            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3221        }
3222        assert_eq!(
3223            cancelled_count.load(Ordering::SeqCst),
3224            1,
3225            "tool runtime must observe the cancellation token"
3226        );
3227    }
3228
3229    #[derive(Clone)]
3230    struct DefaultCancellationProbe {
3231        started: Arc<tokio::sync::Notify>,
3232        completed: Arc<std::sync::atomic::AtomicBool>,
3233    }
3234
3235    #[async_trait]
3236    impl ToolRuntime for DefaultCancellationProbe {
3237        fn specs(&self) -> Vec<crate::tools::ToolSpec> {
3238            vec![crate::tools::ToolSpec {
3239                name: "slow".into(),
3240                description: "records a delayed side effect".into(),
3241                input_schema: serde_json::json!({"type": "object"}),
3242            }]
3243        }
3244
3245        async fn invoke(
3246            &self,
3247            _invocation: ToolInvocation,
3248        ) -> Result<ToolOutcome, ToolRuntimeError> {
3249            self.started.notify_one();
3250            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
3251            self.completed.store(true, Ordering::SeqCst);
3252            Ok(ToolOutcome {
3253                output: Ok(serde_json::json!({"completed": true})),
3254                attachments: vec![],
3255            })
3256        }
3257    }
3258
3259    /// The default `ToolRuntime::invoke_cancellable` must drop an invocation
3260    /// that does not implement custom cancellation. Before the fix, the agent
3261    /// loop detached its task and the delayed side effect happened after
3262    /// TurnEnd{interrupt}.
3263    #[tokio::test]
3264    async fn agent_loop_cancel_drops_default_tool_future() {
3265        let model = StreamingFakeClient::new(vec![vec![
3266            ModelChunk::ToolCallStart {
3267                id: "tc_slow".into(),
3268                name: "slow".into(),
3269            },
3270            ModelChunk::ToolCallEnd {
3271                id: "tc_slow".into(),
3272                input: Some(json!({})),
3273            },
3274            ModelChunk::Done {
3275                stop_reason: "tool_use".into(),
3276                usage: None,
3277            },
3278        ]]);
3279        let started = Arc::new(tokio::sync::Notify::new());
3280        let completed = Arc::new(std::sync::atomic::AtomicBool::new(false));
3281        let runtime = DefaultCancellationProbe {
3282            started: started.clone(),
3283            completed: completed.clone(),
3284        };
3285        let cancel = CancellationToken::new();
3286        let harness = AgentLoopHarness::new(model, runtime);
3287        let mut rx = harness
3288            .run_turn(NativeTurnInput {
3289                prompt_text: "go".into(),
3290                system_prompt: None,
3291                attachments: vec![],
3292                cancel_token: Some(cancel.clone()),
3293                prior_messages: vec![],
3294                context_path: None,
3295            })
3296            .await
3297            .unwrap();
3298
3299        started.notified().await;
3300        cancel.cancel();
3301
3302        let mut saw_interrupt = false;
3303        while let Some(item) = rx.recv().await {
3304            if let HarnessInternalEvent::TurnEnd { stop_reason, .. } = item.unwrap() {
3305                assert_eq!(stop_reason, "interrupt");
3306                saw_interrupt = true;
3307                break;
3308            }
3309        }
3310        assert!(saw_interrupt);
3311        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
3312        assert!(
3313            !completed.load(Ordering::SeqCst),
3314            "cancelled tool produced a side effect after TurnEnd"
3315        );
3316    }
3317
3318    /// Cancellation while the tool is in flight must put the harness
3319    /// into a clean state: TurnEnd.final_messages carries the
3320    /// assistant tool_use blocks but no synthetic tool_result rows
3321    /// (since the tool never finished).
3322    #[tokio::test]
3323    async fn agent_loop_cancel_during_tools_yields_clean_history() {
3324        let model = StreamingFakeClient::new(vec![vec![
3325            ModelChunk::ToolCallStart {
3326                id: "tc_a".into(),
3327                name: "slow".into(),
3328            },
3329            ModelChunk::ToolCallEnd {
3330                id: "tc_a".into(),
3331                input: Some(json!({})),
3332            },
3333            ModelChunk::ToolCallStart {
3334                id: "tc_b".into(),
3335                name: "slow".into(),
3336            },
3337            ModelChunk::ToolCallEnd {
3338                id: "tc_b".into(),
3339                input: Some(json!({})),
3340            },
3341            ModelChunk::Done {
3342                stop_reason: "tool_use".into(),
3343                usage: None,
3344            },
3345        ]]);
3346        let probe = ConcurrencyProbeRuntime::new(std::time::Duration::from_secs(3));
3347        let harness = AgentLoopHarness::new(model, probe);
3348
3349        let cancel = CancellationToken::new();
3350        let cancel_for_input = cancel.clone();
3351        let mut rx = harness
3352            .run_turn(NativeTurnInput {
3353                prompt_text: "go".into(),
3354                system_prompt: None,
3355                attachments: vec![],
3356                cancel_token: Some(cancel_for_input),
3357                prior_messages: vec![],
3358                context_path: None,
3359            })
3360            .await
3361            .unwrap();
3362
3363        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
3364        cancel.cancel();
3365
3366        let mut final_msgs = None;
3367        while let Some(item) = rx.recv().await {
3368            if let HarnessInternalEvent::TurnEnd { final_messages, .. } = item.unwrap() {
3369                final_msgs = Some(final_messages);
3370                break;
3371            }
3372        }
3373        let msgs = final_msgs.expect("interrupt TurnEnd");
3374        // History: [user, assistant(tool_use a + b)]
3375        // — no tool_result rows because the tools never finished.
3376        assert_eq!(msgs.len(), 2, "expected 2 messages, got {msgs:?}");
3377        match &msgs[1] {
3378            ChatMessage::Assistant { tool_calls, .. } => {
3379                assert_eq!(tool_calls.len(), 2);
3380                assert_eq!(tool_calls[0].id, "tc_a");
3381                assert_eq!(tool_calls[1].id, "tc_b");
3382            }
3383            other => panic!("msgs[1] not assistant: {other:?}"),
3384        }
3385    }
3386}