Skip to main content

pi/
agent.rs

1//! Agent runtime - the core orchestration loop.
2//!
3//! The agent coordinates between:
4//! - Provider: Makes LLM API calls
5//! - Tools: Executes tool calls from the assistant
6//! - Session: Persists conversation history
7//!
8//! The main loop:
9//! 1. Receive user input
10//! 2. Build context (system prompt + history + tools)
11//! 3. Stream completion from provider
12//! 4. If tool calls: execute tools, append results, goto 3
13//! 5. If done: return final message
14
15use crate::auth::AuthStorage;
16use crate::compaction::{self, ResolvedCompactionSettings};
17use crate::compaction_worker::{
18    CompactionAdmissionSignals, CompactionQuota, CompactionWorkerState,
19};
20use crate::error::{Error, Result};
21use crate::extension_events::{
22    BeforeAgentStartOutcome, InputEventOutcome, SessionBeforeCompactOutcome,
23    apply_before_agent_start_response, apply_input_event_response,
24    apply_session_before_compact_response,
25};
26use crate::extension_tools::collect_extension_tool_wrappers;
27use crate::extensions::{
28    EXTENSION_EVENT_TIMEOUT_MS, ExtensionAiCompletionRequest, ExtensionDeliverAs,
29    ExtensionEventName, ExtensionHostActions, ExtensionLoadSpec, ExtensionManager, ExtensionPolicy,
30    ExtensionRegion, ExtensionRuntimeHandle, ExtensionSendMessage, ExtensionSendUserMessage,
31    JsExtensionLoadSpec, JsExtensionRuntimeHandle, NativeRustExtensionLoadSpec,
32    NativeRustExtensionRuntimeHandle, RepairPolicyMode, resolve_extension_load_spec,
33};
34#[cfg(feature = "wasm-host")]
35use crate::extensions::{WasmExtensionHost, WasmExtensionLoadSpec};
36use crate::extensions_js::{PiJsRuntimeConfig, RepairMode};
37use crate::model::{
38    AssistantMessage, AssistantMessageEvent, ContentBlock, CustomMessage, ImageContent, Message,
39    StopReason, StreamEvent, TextContent, ThinkingContent, ToolCall, ToolResultMessage, Usage,
40    UserContent, UserMessage,
41};
42use crate::models::{
43    ModelEntry, ModelRegistry, model_requires_configured_credential, normalize_api_key_opt,
44};
45use crate::provider::{Context, Provider, StreamOptions, ToolDef};
46use crate::semantic_workspace_graph::{ContextBundleItem, SemanticContextBundle};
47use crate::session::{AutosaveFlushTrigger, Session, SessionHandle};
48use crate::tools::{Tool, ToolEffects, ToolOutput, ToolRegistry, ToolUpdate};
49use asupersync::runtime::{Runtime, RuntimeBuilder, RuntimeHandle};
50use asupersync::sync::{Mutex, Notify};
51use async_trait::async_trait;
52use chrono::Utc;
53use futures::FutureExt;
54use futures::StreamExt;
55use futures::future::BoxFuture;
56use futures::stream;
57use serde::Serialize;
58use serde_json::{Value, json};
59use sha2::{Digest as _, Sha256};
60use std::borrow::Cow;
61use std::collections::VecDeque;
62use std::fmt;
63use std::sync::Arc;
64use std::sync::Mutex as StdMutex;
65use std::sync::OnceLock;
66use std::sync::atomic::{AtomicBool, Ordering};
67use std::time::{Duration, Instant};
68use tracing::warn;
69
70const MIN_COMPATIBLE_TOOL_PARALLELISM: usize = 8;
71const MAX_AUTO_COMPATIBLE_TOOL_PARALLELISM: usize = 64;
72const MAX_CONFIGURED_COMPATIBLE_TOOL_PARALLELISM: usize = 256;
73/// Maximum messages in steering queue to prevent unbounded growth
74const MAX_STEERING_QUEUE_SIZE: usize = 100;
75/// Maximum messages in follow-up queue to prevent unbounded growth
76const MAX_FOLLOW_UP_QUEUE_SIZE: usize = 100;
77/// Maximum messages in agent history to prevent unbounded growth
78const MAX_AGENT_MESSAGES: usize = 10_000;
79/// Schema identifier for per-turn latency budget breakdowns.
80pub const TURN_LATENCY_BREAKDOWN_SCHEMA_V1: &str = "pi.agent.turn_latency_breakdown.v1";
81/// Schema identifier for deterministic tool-effect batch plan evidence.
82pub const TOOL_EFFECT_BATCH_PLAN_SCHEMA_V1: &str = "pi.agent.tool_effect_batch_plan.v1";
83const TOOL_CANCELLATION_SCHEMA_V1: &str = "pi.tool.cancellation.v1";
84const TOOL_APPROVAL_DENIED_SCHEMA_V1: &str = "pi.tool.approval_denied.v1";
85const TOOL_APPROVAL_STATUS_SCHEMA_V1: &str = "pi.tool.approval_status.v1";
86const SEMANTIC_CONTEXT_PROMPT_SCHEMA_V1: &str = "pi.semantic_context_prompt.v1";
87const SEMANTIC_CONTEXT_PROVENANCE_SCHEMA_V1: &str = "pi.semantic_context_provenance.v1";
88const SEMANTIC_CONTEXT_CUSTOM_TYPE: &str = "semantic_context_bundle";
89const DEFAULT_SEMANTIC_CONTEXT_PROMPT_MAX_BYTES: u64 = 16 * 1024;
90const DEFAULT_SEMANTIC_CONTEXT_PROMPT_MAX_ITEMS: usize = 16;
91
92fn compatible_tool_parallelism_limit() -> usize {
93    static LIMIT: OnceLock<usize> = OnceLock::new();
94    *LIMIT.get_or_init(|| {
95        let host_parallelism = std::thread::available_parallelism()
96            .map_or(MIN_COMPATIBLE_TOOL_PARALLELISM, |parallelism| {
97                parallelism.get()
98            });
99        resolve_compatible_tool_parallelism(
100            std::env::var("PI_MAX_CONCURRENT_COMPATIBLE_TOOLS")
101                .ok()
102                .as_deref(),
103            host_parallelism,
104        )
105    })
106}
107
108fn resolve_compatible_tool_parallelism(
109    raw_override: Option<&str>,
110    host_parallelism: usize,
111) -> usize {
112    let host_default = host_parallelism.clamp(
113        MIN_COMPATIBLE_TOOL_PARALLELISM,
114        MAX_AUTO_COMPATIBLE_TOOL_PARALLELISM,
115    );
116
117    let Some(raw) = raw_override.map(str::trim).filter(|raw| !raw.is_empty()) else {
118        return host_default;
119    };
120
121    match raw.parse::<usize>() {
122        Ok(0) => {
123            warn!(
124                value = raw,
125                "Ignoring PI_MAX_CONCURRENT_COMPATIBLE_TOOLS=0; using host-scaled default"
126            );
127            host_default
128        }
129        Ok(limit) => limit.clamp(1, MAX_CONFIGURED_COMPATIBLE_TOOL_PARALLELISM),
130        Err(err) => {
131            warn!(
132                value = raw,
133                error = %err,
134                "Ignoring invalid PI_MAX_CONCURRENT_COMPATIBLE_TOOLS; using host-scaled default"
135            );
136            host_default
137        }
138    }
139}
140
141fn duration_millis_saturating(duration: Duration) -> u64 {
142    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
143}
144
145fn duration_micros_saturating(duration: Duration) -> u64 {
146    u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)
147}
148
149fn record_global_latency(counter: &crate::session_metrics::TimingCounter, duration: Duration) {
150    if crate::session_metrics::global().enabled() {
151        counter.record(duration_micros_saturating(duration));
152    }
153}
154
155/// Nearest-rank tail percentile summary for a latency sample set.
156#[derive(Debug, Clone, Serialize, Default)]
157#[serde(rename_all = "camelCase")]
158pub struct LatencyPercentiles {
159    /// Median latency, in milliseconds.
160    pub p50_ms: u64,
161    /// P95 latency, in milliseconds.
162    pub p95_ms: u64,
163    /// P99 latency, in milliseconds.
164    pub p99_ms: u64,
165    /// P99.9 latency, in milliseconds.
166    pub p999_ms: u64,
167}
168
169impl LatencyPercentiles {
170    fn from_samples(samples: &[u64]) -> Self {
171        Self {
172            p50_ms: percentile_nearest_rank(samples, 50),
173            p95_ms: percentile_nearest_rank(samples, 95),
174            p99_ms: percentile_nearest_rank(samples, 99),
175            p999_ms: percentile_nearest_rank_per_mille(samples, 999),
176        }
177    }
178}
179
180/// Latency budget contribution for one component in a turn.
181#[derive(Debug, Clone, Serialize, Default)]
182#[serde(rename_all = "camelCase")]
183pub struct LatencyComponentBreakdown {
184    /// Sum of all component samples in the turn, in milliseconds.
185    pub duration_ms: u64,
186    /// Number of samples recorded for the component in the turn.
187    pub samples: usize,
188    /// Tail percentiles for the component samples.
189    pub tail_percentiles: LatencyPercentiles,
190}
191
192impl LatencyComponentBreakdown {
193    /// Build a component breakdown from millisecond samples.
194    #[must_use]
195    pub fn from_millis_samples(samples: &[u64]) -> Self {
196        Self {
197            duration_ms: samples.iter().copied().fold(0u64, u64::saturating_add),
198            samples: samples.len(),
199            tail_percentiles: LatencyPercentiles::from_samples(samples),
200        }
201    }
202}
203
204/// Per-turn breakdown of provider, tool, extension hook, and persistence budgets.
205#[derive(Debug, Clone, Serialize)]
206#[serde(rename_all = "camelCase")]
207pub struct TurnLatencyBreakdown {
208    /// Versioned schema identifier for downstream evidence consumers.
209    pub schema: &'static str,
210    /// Total measured turn time in the core agent loop, in milliseconds.
211    pub total_ms: u64,
212    /// Provider streaming budget, including stream setup and drain time.
213    pub provider_streaming: LatencyComponentBreakdown,
214    /// Built-in/local tool execution budget.
215    pub local_tools: LatencyComponentBreakdown,
216    /// Extension hook dispatch budget around tool calls.
217    pub extension_hostcalls: LatencyComponentBreakdown,
218    /// Session persistence budget when measured by the current runtime path.
219    pub persistence: LatencyComponentBreakdown,
220    /// Component with the largest measured duration.
221    pub dominant_component: String,
222}
223
224impl TurnLatencyBreakdown {
225    /// Build a latency breakdown from component sample sets.
226    #[must_use]
227    pub fn from_component_samples(
228        total_ms: u64,
229        provider_streaming_ms: &[u64],
230        local_tool_ms: &[u64],
231        extension_hostcall_ms: &[u64],
232        persistence_ms: &[u64],
233    ) -> Self {
234        let provider_streaming =
235            LatencyComponentBreakdown::from_millis_samples(provider_streaming_ms);
236        let local_tools = LatencyComponentBreakdown::from_millis_samples(local_tool_ms);
237        let extension_hostcalls =
238            LatencyComponentBreakdown::from_millis_samples(extension_hostcall_ms);
239        let persistence = LatencyComponentBreakdown::from_millis_samples(persistence_ms);
240        let dominant_component = dominant_latency_component(
241            &provider_streaming,
242            &local_tools,
243            &extension_hostcalls,
244            &persistence,
245        );
246
247        Self {
248            schema: TURN_LATENCY_BREAKDOWN_SCHEMA_V1,
249            total_ms,
250            provider_streaming,
251            local_tools,
252            extension_hostcalls,
253            persistence,
254            dominant_component,
255        }
256    }
257}
258
259fn percentile_nearest_rank(samples: &[u64], percentile: usize) -> u64 {
260    if samples.is_empty() {
261        return 0;
262    }
263
264    let mut sorted = samples.to_vec();
265    sorted.sort_unstable();
266    let len = sorted.len();
267    let rank = percentile
268        .saturating_mul(len)
269        .div_ceil(100)
270        .saturating_sub(1)
271        .min(len.saturating_sub(1));
272    sorted[rank]
273}
274
275fn percentile_nearest_rank_per_mille(samples: &[u64], permille: usize) -> u64 {
276    if samples.is_empty() {
277        return 0;
278    }
279
280    let mut sorted = samples.to_vec();
281    sorted.sort_unstable();
282    let len = sorted.len();
283    let rank = permille
284        .saturating_mul(len)
285        .div_ceil(1000)
286        .saturating_sub(1)
287        .min(len.saturating_sub(1));
288    sorted[rank]
289}
290
291fn dominant_latency_component(
292    provider_streaming: &LatencyComponentBreakdown,
293    local_tools: &LatencyComponentBreakdown,
294    extension_hostcalls: &LatencyComponentBreakdown,
295    persistence: &LatencyComponentBreakdown,
296) -> String {
297    [
298        ("provider_streaming", provider_streaming.duration_ms),
299        ("local_tools", local_tools.duration_ms),
300        ("extension_hostcalls", extension_hostcalls.duration_ms),
301        ("persistence", persistence.duration_ms),
302    ]
303    .into_iter()
304    .max_by_key(|(_, duration_ms)| *duration_ms)
305    .filter(|(_, duration_ms)| *duration_ms > 0)
306    .map_or_else(|| "none".to_string(), |(name, _)| name.to_string())
307}
308
309#[derive(Debug)]
310struct TurnLatencyAccumulator {
311    started_at: Instant,
312    provider_streaming_ms: Vec<u64>,
313    local_tool_ms: Vec<u64>,
314    extension_hostcall_ms: Vec<u64>,
315    persistence_ms: Vec<u64>,
316}
317
318impl TurnLatencyAccumulator {
319    fn started() -> Self {
320        Self {
321            started_at: Instant::now(),
322            provider_streaming_ms: Vec::new(),
323            local_tool_ms: Vec::new(),
324            extension_hostcall_ms: Vec::new(),
325            persistence_ms: Vec::new(),
326        }
327    }
328
329    fn snapshot(&self) -> TurnLatencyBreakdown {
330        TurnLatencyBreakdown::from_component_samples(
331            duration_millis_saturating(self.started_at.elapsed()),
332            &self.provider_streaming_ms,
333            &self.local_tool_ms,
334            &self.extension_hostcall_ms,
335            &self.persistence_ms,
336        )
337    }
338}
339
340type SharedTurnLatencyAccumulator = Arc<StdMutex<TurnLatencyAccumulator>>;
341
342fn snapshot_turn_latency(
343    latency: &SharedTurnLatencyAccumulator,
344) -> Option<Box<TurnLatencyBreakdown>> {
345    latency.lock().ok().map(|guard| Box::new(guard.snapshot()))
346}
347
348fn record_provider_streaming_latency(latency: &SharedTurnLatencyAccumulator, duration: Duration) {
349    if let Ok(mut guard) = latency.lock() {
350        guard
351            .provider_streaming_ms
352            .push(duration_millis_saturating(duration));
353    }
354    let metrics = crate::session_metrics::global();
355    record_global_latency(&metrics.provider_streaming, duration);
356}
357
358fn record_local_tool_latency(latency: &SharedTurnLatencyAccumulator, duration: Duration) {
359    if let Ok(mut guard) = latency.lock() {
360        guard
361            .local_tool_ms
362            .push(duration_millis_saturating(duration));
363    }
364    let metrics = crate::session_metrics::global();
365    record_global_latency(&metrics.local_tools, duration);
366}
367
368fn record_extension_hostcall_latency(latency: &SharedTurnLatencyAccumulator, duration: Duration) {
369    if let Ok(mut guard) = latency.lock() {
370        guard
371            .extension_hostcall_ms
372            .push(duration_millis_saturating(duration));
373    }
374    let metrics = crate::session_metrics::global();
375    record_global_latency(&metrics.extension_hostcalls, duration);
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
379struct ToolEffectBatch {
380    start: usize,
381    end: usize,
382}
383
384/// Serializable evidence for one planned tool-effect batch.
385#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
386#[serde(rename_all = "camelCase")]
387pub struct ToolEffectBatchEvidence {
388    /// Inclusive start index in the original tool-call order.
389    pub start: usize,
390    /// Exclusive end index in the original tool-call order.
391    pub end: usize,
392    /// Number of tool calls covered by this batch.
393    pub len: usize,
394    /// Stable labels for the union of all effects in this batch.
395    pub combined_effects: Vec<&'static str>,
396    /// Whether this batch can be executed with compatible-tool parallelism.
397    pub parallel_safe: bool,
398    /// Fail-closed barrier reason when the batch is serialized.
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub barrier_reason: Option<&'static str>,
401}
402
403/// Serializable evidence for the full planned tool-effect batch layout.
404#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
405#[serde(rename_all = "camelCase")]
406pub struct ToolEffectBatchPlanEvidence {
407    /// Versioned schema identifier for downstream evidence consumers.
408    pub schema: &'static str,
409    /// Number of tool calls in the source plan.
410    pub tool_count: usize,
411    /// Parallelism cap that compatible batches will use at execution time.
412    pub parallelism_cap: usize,
413    /// Deterministic contiguous batch plan.
414    pub batches: Vec<ToolEffectBatchEvidence>,
415}
416
417fn plan_tool_effect_batches(effects: &[ToolEffects]) -> Vec<ToolEffectBatch> {
418    let Some((&first_effects, remaining_effects)) = effects.split_first() else {
419        return Vec::new();
420    };
421
422    let mut batches = Vec::new();
423    let mut start = 0;
424    let mut active_effects = first_effects;
425
426    for (offset, candidate_effects) in remaining_effects.iter().copied().enumerate() {
427        let index = offset + 1;
428        if active_effects.compatible_with(candidate_effects) {
429            active_effects = active_effects.union(candidate_effects);
430        } else {
431            batches.push(ToolEffectBatch { start, end: index });
432            start = index;
433            active_effects = candidate_effects;
434        }
435    }
436
437    batches.push(ToolEffectBatch {
438        start,
439        end: effects.len(),
440    });
441    batches
442}
443
444fn combined_tool_effects(effects: &[ToolEffects]) -> Option<ToolEffects> {
445    effects.iter().copied().reduce(ToolEffects::union)
446}
447
448const fn tool_effect_barrier_reason(effects: ToolEffects) -> Option<&'static str> {
449    if effects.parallel_safe() {
450        return None;
451    }
452    match (effects.writes(), effects.appends(), effects.processes()) {
453        (true, true, true) => Some("write_append_process_barrier"),
454        (true, true, false) => Some("write_append_barrier"),
455        (true, false, true) => Some("write_process_barrier"),
456        (false, true, true) => Some("append_process_barrier"),
457        (true, false, false) => Some("write_barrier"),
458        (false, true, false) => Some("append_barrier"),
459        (false, false, true) => Some("process_barrier"),
460        (false, false, false) => Some("undeclared_effects_barrier"),
461    }
462}
463
464/// Build deterministic machine-readable evidence for a tool-effect batch plan.
465#[must_use]
466pub fn tool_effect_batch_plan_evidence(
467    effects: &[ToolEffects],
468    parallelism_cap: usize,
469) -> ToolEffectBatchPlanEvidence {
470    let batches = plan_tool_effect_batches(effects)
471        .into_iter()
472        .map(|batch| {
473            let combined_effects = effects
474                .get(batch.start..batch.end)
475                .and_then(combined_tool_effects)
476                .unwrap_or_else(ToolEffects::read);
477            ToolEffectBatchEvidence {
478                start: batch.start,
479                end: batch.end,
480                len: batch.end.saturating_sub(batch.start),
481                combined_effects: combined_effects.labels(),
482                parallel_safe: combined_effects.parallel_safe(),
483                barrier_reason: tool_effect_barrier_reason(combined_effects),
484            }
485        })
486        .collect();
487
488    ToolEffectBatchPlanEvidence {
489        schema: TOOL_EFFECT_BATCH_PLAN_SCHEMA_V1,
490        tool_count: effects.len(),
491        parallelism_cap,
492        batches,
493    }
494}
495
496// ============================================================================
497// Agent Configuration
498// ============================================================================
499
500/// Default cap for tool-call iterations per agent turn.
501///
502/// Override per-invocation via `--max-tool-iterations` / the
503/// `PI_MAX_TOOL_ITERATIONS` env var, or programmatically by writing
504/// [`AgentConfig::max_tool_iterations`] directly. Resolved through
505/// [`resolve_max_tool_iterations`] which clamps invalid values back to this
506/// default rather than failing the run.
507pub const MAX_TOOL_ITERATIONS_DEFAULT: usize = 50;
508
509/// Sanity ceiling for `max_tool_iterations` overrides.
510///
511/// Guards against runaway loops from a typo while still leaving plenty of
512/// room for long, multi-step agentic tasks (large refactors, multi-phase
513/// spec implementations).
514pub const MAX_TOOL_ITERATIONS_CEILING: usize = 1_000;
515
516/// Threshold (as a fraction of `max_tool_iterations`) at which the runtime
517/// emits a one-shot soft-handoff steering message so the agent can begin a
518/// graceful incomplete-handoff rather than being silently killed at the cap.
519/// Encoded as numerator/denominator to avoid floating-point in a hot loop.
520const ITERATION_WARN_NUMERATOR: usize = 4;
521const ITERATION_WARN_DENOMINATOR: usize = 5;
522
523/// Below this absolute cap, the soft-handoff warning is suppressed — for
524/// caps like 3 or 4, the warning would fire on the first iteration and add
525/// noise rather than help.
526const ITERATION_WARN_MIN_CAP: usize = 5;
527
528/// Resolve the effective tool-iteration cap from `PI_MAX_TOOL_ITERATIONS`.
529///
530/// Falls back to [`MAX_TOOL_ITERATIONS_DEFAULT`] when unset/invalid. Used
531/// by callers that build an [`AgentConfig`] without going through the CLI
532/// parser (ACP server, SDK).
533pub fn resolved_max_tool_iterations_default() -> usize {
534    resolve_max_tool_iterations(std::env::var("PI_MAX_TOOL_ITERATIONS").ok().as_deref())
535}
536
537/// Pure resolver for `max_tool_iterations` string overrides.
538///
539/// Returns [`MAX_TOOL_ITERATIONS_DEFAULT`] when input is `None`, empty,
540/// unparseable, zero, or above the ceiling — emitting a warning so a
541/// misconfigured cap is observable in logs rather than silently lost.
542pub fn resolve_max_tool_iterations(raw_override: Option<&str>) -> usize {
543    let Some(raw) = raw_override.map(str::trim).filter(|raw| !raw.is_empty()) else {
544        return MAX_TOOL_ITERATIONS_DEFAULT;
545    };
546    match raw.parse::<usize>() {
547        Ok(0) => {
548            warn!(
549                "PI_MAX_TOOL_ITERATIONS=0 is invalid; falling back to {}",
550                MAX_TOOL_ITERATIONS_DEFAULT
551            );
552            MAX_TOOL_ITERATIONS_DEFAULT
553        }
554        Ok(n) if n > MAX_TOOL_ITERATIONS_CEILING => {
555            warn!(
556                "PI_MAX_TOOL_ITERATIONS={n} exceeds ceiling {MAX_TOOL_ITERATIONS_CEILING}; clamping to {MAX_TOOL_ITERATIONS_CEILING}"
557            );
558            MAX_TOOL_ITERATIONS_CEILING
559        }
560        Ok(n) => n,
561        Err(err) => {
562            warn!(
563                "PI_MAX_TOOL_ITERATIONS={raw:?} is not a valid usize ({err}); falling back to {}",
564                MAX_TOOL_ITERATIONS_DEFAULT
565            );
566            MAX_TOOL_ITERATIONS_DEFAULT
567        }
568    }
569}
570
571/// Clamp a CLI-parsed `Option<usize>` cap to the supported range.
572///
573/// Same semantics as [`resolve_max_tool_iterations`] but for values that
574/// have already been parsed by clap. Returns the effective cap, clamped
575/// to `[1, MAX_TOOL_ITERATIONS_CEILING]` with invalid values (None, 0)
576/// falling back to [`MAX_TOOL_ITERATIONS_DEFAULT`].
577pub fn clamp_max_tool_iterations(value: Option<usize>) -> usize {
578    match value {
579        None => MAX_TOOL_ITERATIONS_DEFAULT,
580        Some(0) => {
581            warn!(
582                "--max-tool-iterations=0 is invalid; falling back to {}",
583                MAX_TOOL_ITERATIONS_DEFAULT
584            );
585            MAX_TOOL_ITERATIONS_DEFAULT
586        }
587        Some(n) if n > MAX_TOOL_ITERATIONS_CEILING => {
588            warn!(
589                "--max-tool-iterations={n} exceeds ceiling {MAX_TOOL_ITERATIONS_CEILING}; clamping to {MAX_TOOL_ITERATIONS_CEILING}"
590            );
591            MAX_TOOL_ITERATIONS_CEILING
592        }
593        Some(n) => n,
594    }
595}
596
597/// Pure predicate: should we emit the one-shot iteration-budget warning at
598/// the current iteration, given the configured cap?
599///
600/// Fires when `current >= (max * 4) / 5` and `max >= ITERATION_WARN_MIN_CAP`.
601/// Caller is responsible for tracking fire-once state so the steering message
602/// only injects once per run-loop. Stateless and integer-only so it's safe to
603/// call inside the hot loop. Uses `saturating_mul` so an SDK caller that
604/// writes `AgentConfig::max_tool_iterations = usize::MAX` directly (bypassing
605/// the resolvers' clamp) gets a sane "never warn" rather than wrap-around to
606/// a tiny threshold.
607pub const fn should_warn_at_iteration_threshold(current: usize, max: usize) -> bool {
608    max >= ITERATION_WARN_MIN_CAP
609        && current >= max.saturating_mul(ITERATION_WARN_NUMERATOR) / ITERATION_WARN_DENOMINATOR
610}
611
612/// Body of the one-shot soft-handoff steering message, formatted with the
613/// current/max iteration counts. Kept as a free function so test fixtures
614/// can pin the wording without instantiating a full agent.
615pub fn iteration_handoff_steering_text(current: usize, max: usize) -> String {
616    format!(
617        "[runtime] Tool-iteration budget at >=80% (used {current} of {max}). \
618         Per the iteration-aware-handoff protocol in your spec, begin graceful \
619         handoff now: commit current work, post a one-line status note, and \
620         write an incomplete-handoff envelope with what's done / what remains \
621         / next-agent starting position. Do NOT compress remaining work into \
622         the last few iterations."
623    )
624}
625
626/// Configuration for the agent.
627#[derive(Clone)]
628pub struct AgentConfig {
629    /// System prompt to use for all requests.
630    pub system_prompt: Option<String>,
631
632    /// Maximum tool call iterations before stopping.
633    pub max_tool_iterations: usize,
634
635    /// Default stream options.
636    pub stream_options: StreamOptions,
637
638    /// Strip image blocks before sending context to providers.
639    pub block_images: bool,
640
641    /// Fail closed when extension tool hooks error or time out.
642    pub fail_closed_hooks: bool,
643
644    /// Optional approval gate invoked before a tool executes.
645    pub tool_approval: Option<ToolApprovalHandler>,
646}
647
648impl fmt::Debug for AgentConfig {
649    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
650        f.debug_struct("AgentConfig")
651            .field("system_prompt", &self.system_prompt)
652            .field("max_tool_iterations", &self.max_tool_iterations)
653            .field("stream_options", &self.stream_options)
654            .field("block_images", &self.block_images)
655            .field("fail_closed_hooks", &self.fail_closed_hooks)
656            .field("tool_approval", &self.tool_approval.is_some())
657            .finish()
658    }
659}
660
661/// Details for a pending tool approval request.
662#[derive(Debug, Clone)]
663pub struct ToolApprovalRequest {
664    pub tool_call_id: String,
665    pub tool_name: String,
666    pub arguments: Value,
667}
668
669/// Decision returned by a tool approval handler.
670#[derive(Debug, Clone, PartialEq, Eq)]
671pub enum ToolApprovalDecision {
672    Allow,
673    Deny { reason: String },
674}
675
676impl ToolApprovalDecision {
677    #[must_use]
678    pub fn deny(reason: impl Into<String>) -> Self {
679        Self::Deny {
680            reason: reason.into(),
681        }
682    }
683}
684
685pub type ToolApprovalHandler =
686    Arc<dyn Fn(ToolApprovalRequest) -> BoxFuture<'static, ToolApprovalDecision> + Send + Sync>;
687
688impl Default for AgentConfig {
689    fn default() -> Self {
690        Self {
691            system_prompt: None,
692            max_tool_iterations: resolved_max_tool_iterations_default(),
693            stream_options: StreamOptions::default(),
694            block_images: false,
695            fail_closed_hooks: false,
696            tool_approval: None,
697        }
698    }
699}
700
701/// Opt-in semantic context bundle controls for a single agent session.
702#[derive(Debug, Clone)]
703pub struct SemanticContextBundleInjection {
704    pub enabled: bool,
705    pub bundle: SemanticContextBundle,
706    pub max_prompt_items: usize,
707    pub max_prompt_bytes: u64,
708    pub include_exclusion_summary: bool,
709    pub include_validation_commands: bool,
710}
711
712impl SemanticContextBundleInjection {
713    pub fn enabled(bundle: SemanticContextBundle) -> Self {
714        let max_prompt_items = bundle
715            .budget
716            .max_items
717            .min(DEFAULT_SEMANTIC_CONTEXT_PROMPT_MAX_ITEMS);
718        let max_prompt_bytes = bundle
719            .budget
720            .max_bytes
721            .min(DEFAULT_SEMANTIC_CONTEXT_PROMPT_MAX_BYTES);
722        Self {
723            enabled: true,
724            bundle,
725            max_prompt_items,
726            max_prompt_bytes,
727            include_exclusion_summary: true,
728            include_validation_commands: true,
729        }
730    }
731
732    pub const fn disabled(bundle: SemanticContextBundle) -> Self {
733        Self {
734            enabled: false,
735            bundle,
736            max_prompt_items: DEFAULT_SEMANTIC_CONTEXT_PROMPT_MAX_ITEMS,
737            max_prompt_bytes: DEFAULT_SEMANTIC_CONTEXT_PROMPT_MAX_BYTES,
738            include_exclusion_summary: true,
739            include_validation_commands: true,
740        }
741    }
742
743    #[must_use]
744    pub const fn with_prompt_budget(mut self, max_items: usize, max_bytes: u64) -> Self {
745        self.max_prompt_items = max_items;
746        self.max_prompt_bytes = max_bytes;
747        self
748    }
749}
750
751#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
752#[serde(rename_all = "snake_case")]
753pub enum SemanticContextPromptShape {
754    CustomUserMessage,
755    SystemPromptAppend,
756}
757
758#[derive(Debug, Clone)]
759struct PreparedSemanticContextPrompt {
760    prompt: String,
761    revision: String,
762    shape: SemanticContextPromptShape,
763    details: Value,
764}
765
766#[derive(Debug, Clone, Copy)]
767struct SemanticContextPromptBudget {
768    max_items: usize,
769    max_bytes: u64,
770}
771
772#[derive(Debug, Default, Clone, Copy)]
773struct SemanticContextPromptStats {
774    selected_items_included: usize,
775    selected_items_omitted: usize,
776    validation_commands_included: usize,
777    validation_commands_omitted: usize,
778    exclusions_included: usize,
779    exclusions_omitted: usize,
780    truncated: bool,
781}
782
783/// Async fetcher for queued messages (steering or follow-up).
784pub type MessageFetcher = Arc<dyn Fn() -> BoxFuture<'static, Vec<Message>> + Send + Sync + 'static>;
785
786type AgentEventHandler = Arc<dyn Fn(AgentEvent) + Send + Sync + 'static>;
787
788#[derive(Debug, Clone, Copy, PartialEq, Eq)]
789pub enum QueueMode {
790    All,
791    OneAtATime,
792}
793
794impl QueueMode {
795    pub const fn as_str(self) -> &'static str {
796        match self {
797            Self::All => "all",
798            Self::OneAtATime => "one-at-a-time",
799        }
800    }
801}
802
803#[derive(Debug, Clone, Copy, PartialEq, Eq)]
804pub enum InputSource {
805    Interactive,
806    Rpc,
807    Extension,
808}
809
810impl InputSource {
811    pub const fn as_str(self) -> &'static str {
812        match self {
813            Self::Interactive => "interactive",
814            Self::Rpc => "rpc",
815            Self::Extension => "extension",
816        }
817    }
818}
819
820#[derive(Debug, Clone, Copy)]
821enum QueueKind {
822    Steering,
823    FollowUp,
824}
825
826#[derive(Debug, Clone)]
827struct QueuedMessage {
828    seq: u64,
829    enqueued_at: i64,
830    message: Message,
831}
832
833#[derive(Debug)]
834struct MessageQueue {
835    steering: VecDeque<QueuedMessage>,
836    follow_up: VecDeque<QueuedMessage>,
837    steering_mode: QueueMode,
838    follow_up_mode: QueueMode,
839    next_seq: u64,
840}
841
842impl MessageQueue {
843    const fn new(steering_mode: QueueMode, follow_up_mode: QueueMode) -> Self {
844        Self {
845            steering: VecDeque::new(),
846            follow_up: VecDeque::new(),
847            steering_mode,
848            follow_up_mode,
849            next_seq: 0,
850        }
851    }
852
853    const fn set_modes(&mut self, steering_mode: QueueMode, follow_up_mode: QueueMode) {
854        self.steering_mode = steering_mode;
855        self.follow_up_mode = follow_up_mode;
856    }
857
858    fn pending_count(&self) -> usize {
859        self.steering.len() + self.follow_up.len()
860    }
861
862    fn push(&mut self, kind: QueueKind, message: Message) -> u64 {
863        let seq = self.next_seq;
864        self.next_seq = self.next_seq.saturating_add(1);
865        let entry = QueuedMessage {
866            seq,
867            enqueued_at: Utc::now().timestamp_millis(),
868            message,
869        };
870        match kind {
871            QueueKind::Steering => {
872                if self.steering.len() >= MAX_STEERING_QUEUE_SIZE {
873                    tracing::warn!(
874                        "Steering queue full ({} messages), dropping oldest message",
875                        MAX_STEERING_QUEUE_SIZE
876                    );
877                    self.steering.pop_front();
878                }
879                self.steering.push_back(entry);
880            }
881            QueueKind::FollowUp => {
882                if self.follow_up.len() >= MAX_FOLLOW_UP_QUEUE_SIZE {
883                    tracing::warn!(
884                        "Follow-up queue full ({} messages), dropping oldest message",
885                        MAX_FOLLOW_UP_QUEUE_SIZE
886                    );
887                    self.follow_up.pop_front();
888                }
889                self.follow_up.push_back(entry);
890            }
891        }
892        seq
893    }
894
895    fn push_steering(&mut self, message: Message) -> u64 {
896        self.push(QueueKind::Steering, message)
897    }
898
899    fn push_follow_up(&mut self, message: Message) -> u64 {
900        self.push(QueueKind::FollowUp, message)
901    }
902
903    fn pop_steering(&mut self) -> Vec<Message> {
904        self.pop_kind(QueueKind::Steering)
905    }
906
907    fn pop_follow_up(&mut self) -> Vec<Message> {
908        self.pop_kind(QueueKind::FollowUp)
909    }
910
911    fn pop_kind(&mut self, kind: QueueKind) -> Vec<Message> {
912        let (queue, mode) = match kind {
913            QueueKind::Steering => (&mut self.steering, self.steering_mode),
914            QueueKind::FollowUp => (&mut self.follow_up, self.follow_up_mode),
915        };
916
917        match mode {
918            QueueMode::All => queue.drain(..).map(|entry| entry.message).collect(),
919            QueueMode::OneAtATime => queue
920                .pop_front()
921                .into_iter()
922                .map(|entry| entry.message)
923                .collect(),
924        }
925    }
926}
927
928// ============================================================================
929// Agent Event
930// ============================================================================
931
932/// Events emitted by the agent during execution.
933#[derive(Debug, Clone, Serialize)]
934#[serde(tag = "type", rename_all = "snake_case")]
935pub enum AgentEvent {
936    /// Agent lifecycle start.
937    AgentStart {
938        #[serde(rename = "sessionId")]
939        session_id: Arc<str>,
940    },
941    /// Agent lifecycle end with all new messages.
942    AgentEnd {
943        #[serde(rename = "sessionId")]
944        session_id: Arc<str>,
945        messages: Vec<Message>,
946        #[serde(skip_serializing_if = "Option::is_none")]
947        error: Option<String>,
948    },
949    /// Turn lifecycle start (assistant response + tool calls).
950    TurnStart {
951        #[serde(rename = "sessionId")]
952        session_id: Arc<str>,
953        #[serde(rename = "turnIndex")]
954        turn_index: usize,
955        timestamp: i64,
956    },
957    /// Turn lifecycle end with tool results.
958    TurnEnd {
959        #[serde(rename = "sessionId")]
960        session_id: Arc<str>,
961        #[serde(rename = "turnIndex")]
962        turn_index: usize,
963        message: Message,
964        #[serde(rename = "toolResults")]
965        tool_results: Vec<Message>,
966        #[serde(rename = "latencyBreakdown", skip_serializing_if = "Option::is_none")]
967        latency_breakdown: Option<Box<TurnLatencyBreakdown>>,
968    },
969    /// Message lifecycle start (user, assistant, or tool result).
970    MessageStart { message: Message },
971    /// Message update (assistant streaming).
972    MessageUpdate {
973        message: Message,
974        #[serde(rename = "assistantMessageEvent")]
975        assistant_message_event: AssistantMessageEvent,
976    },
977    /// Message lifecycle end.
978    MessageEnd { message: Message },
979    /// Tool execution start.
980    ToolExecutionStart {
981        #[serde(rename = "toolCallId")]
982        tool_call_id: String,
983        #[serde(rename = "toolName")]
984        tool_name: String,
985        args: serde_json::Value,
986    },
987    /// Tool execution update.
988    ToolExecutionUpdate {
989        #[serde(rename = "toolCallId")]
990        tool_call_id: String,
991        #[serde(rename = "toolName")]
992        tool_name: String,
993        args: serde_json::Value,
994        #[serde(rename = "partialResult")]
995        partial_result: ToolOutput,
996    },
997    /// Tool execution end.
998    ToolExecutionEnd {
999        #[serde(rename = "toolCallId")]
1000        tool_call_id: String,
1001        #[serde(rename = "toolName")]
1002        tool_name: String,
1003        result: ToolOutput,
1004        #[serde(rename = "isError")]
1005        is_error: bool,
1006    },
1007    /// Auto-compaction lifecycle start.
1008    AutoCompactionStart { reason: String },
1009    /// Auto-compaction lifecycle end.
1010    AutoCompactionEnd {
1011        #[serde(skip_serializing_if = "Option::is_none")]
1012        result: Option<serde_json::Value>,
1013        aborted: bool,
1014        #[serde(rename = "willRetry")]
1015        will_retry: bool,
1016        #[serde(rename = "errorMessage", skip_serializing_if = "Option::is_none")]
1017        error_message: Option<String>,
1018    },
1019    /// Auto-retry lifecycle start.
1020    AutoRetryStart {
1021        attempt: u32,
1022        #[serde(rename = "maxAttempts")]
1023        max_attempts: u32,
1024        #[serde(rename = "delayMs")]
1025        delay_ms: u64,
1026        #[serde(rename = "errorMessage")]
1027        error_message: String,
1028    },
1029    /// Auto-retry lifecycle end.
1030    AutoRetryEnd {
1031        success: bool,
1032        attempt: u32,
1033        #[serde(rename = "finalError", skip_serializing_if = "Option::is_none")]
1034        final_error: Option<String>,
1035    },
1036    /// Extension error during event dispatch or execution.
1037    ExtensionError {
1038        #[serde(rename = "extensionId", skip_serializing_if = "Option::is_none")]
1039        extension_id: Option<String>,
1040        event: String,
1041        error: String,
1042    },
1043}
1044
1045// ============================================================================
1046// Agent
1047// ============================================================================
1048
1049/// Handle to request an abort of an in-flight agent run.
1050#[derive(Debug, Clone)]
1051pub struct AbortHandle {
1052    inner: Arc<AbortSignalInner>,
1053}
1054
1055/// Signal for observing abort requests.
1056#[derive(Debug, Clone)]
1057pub struct AbortSignal {
1058    inner: Arc<AbortSignalInner>,
1059}
1060
1061#[derive(Debug)]
1062struct AbortSignalInner {
1063    aborted: AtomicBool,
1064    notify: Notify,
1065}
1066
1067impl AbortHandle {
1068    /// Create a new abort handle + signal pair.
1069    #[must_use]
1070    pub fn new() -> (Self, AbortSignal) {
1071        let inner = Arc::new(AbortSignalInner {
1072            aborted: AtomicBool::new(false),
1073            notify: Notify::new(),
1074        });
1075        (
1076            Self {
1077                inner: Arc::clone(&inner),
1078            },
1079            AbortSignal { inner },
1080        )
1081    }
1082
1083    /// Trigger an abort.
1084    pub fn abort(&self) {
1085        if !self.inner.aborted.swap(true, Ordering::SeqCst) {
1086            self.inner.notify.notify_waiters();
1087        }
1088    }
1089}
1090
1091impl AbortSignal {
1092    /// Check if an abort has already been requested.
1093    #[must_use]
1094    pub fn is_aborted(&self) -> bool {
1095        self.inner.aborted.load(Ordering::SeqCst)
1096    }
1097
1098    pub async fn wait(&self) {
1099        if self.is_aborted() {
1100            return;
1101        }
1102
1103        loop {
1104            self.inner.notify.notified().await;
1105            if self.is_aborted() {
1106                return;
1107            }
1108        }
1109    }
1110}
1111
1112/// The agent runtime that orchestrates LLM calls and tool execution.
1113pub struct Agent {
1114    /// The LLM provider.
1115    provider: Arc<dyn Provider>,
1116
1117    /// Tool registry.
1118    tools: ToolRegistry,
1119
1120    /// Agent configuration.
1121    config: AgentConfig,
1122
1123    /// Optional extension manager for tool/event hooks.
1124    extensions: Option<ExtensionManager>,
1125
1126    /// Message history.
1127    messages: Vec<Message>,
1128
1129    /// Fetchers for queued steering messages (interrupts).
1130    steering_fetchers: Vec<MessageFetcher>,
1131
1132    /// Fetchers for queued follow-up messages (idle).
1133    follow_up_fetchers: Vec<MessageFetcher>,
1134
1135    /// Internal queue for steering/follow-up messages.
1136    message_queue: MessageQueue,
1137
1138    /// Cached tool definitions. Invalidated when tools change via `extend_tools`.
1139    cached_tool_defs: Option<Vec<ToolDef>>,
1140}
1141
1142impl Agent {
1143    /// Create a new agent with the given provider and tools.
1144    pub fn new(provider: Arc<dyn Provider>, tools: ToolRegistry, config: AgentConfig) -> Self {
1145        Self {
1146            provider,
1147            tools,
1148            config,
1149            extensions: None,
1150            messages: Vec::new(),
1151            steering_fetchers: Vec::new(),
1152            follow_up_fetchers: Vec::new(),
1153            message_queue: MessageQueue::new(QueueMode::OneAtATime, QueueMode::OneAtATime),
1154            cached_tool_defs: None,
1155        }
1156    }
1157
1158    /// Get the current message history.
1159    #[must_use]
1160    pub fn messages(&self) -> &[Message] {
1161        &self.messages
1162    }
1163
1164    /// Clear the message history.
1165    pub fn clear_messages(&mut self) {
1166        self.messages.clear();
1167    }
1168
1169    /// Add a message to the history.
1170    pub fn add_message(&mut self, message: Message) {
1171        if self.messages.len() >= MAX_AGENT_MESSAGES {
1172            tracing::warn!(
1173                "Agent message history full ({} messages), dropping oldest message",
1174                MAX_AGENT_MESSAGES
1175            );
1176            self.messages.remove(0);
1177        }
1178        self.messages.push(message);
1179    }
1180
1181    /// Replace the message history.
1182    pub fn replace_messages(&mut self, messages: Vec<Message>) {
1183        self.messages = messages;
1184    }
1185
1186    /// Replace the provider implementation (used for model/provider switching).
1187    pub fn set_provider(&mut self, provider: Arc<dyn Provider>) {
1188        self.provider = provider;
1189    }
1190
1191    /// Register async fetchers for queued steering/follow-up messages.
1192    ///
1193    /// This is additive: multiple sources (e.g. RPC, extensions) can register
1194    /// fetchers, and the agent will poll all of them.
1195    pub fn register_message_fetchers(
1196        &mut self,
1197        steering: Option<MessageFetcher>,
1198        follow_up: Option<MessageFetcher>,
1199    ) {
1200        if let Some(fetcher) = steering {
1201            self.steering_fetchers.push(fetcher);
1202        }
1203        if let Some(fetcher) = follow_up {
1204            self.follow_up_fetchers.push(fetcher);
1205        }
1206    }
1207
1208    /// Extend the tool registry with additional tools (e.g. extension-registered tools).
1209    pub fn extend_tools<I>(&mut self, tools: I)
1210    where
1211        I: IntoIterator<Item = Box<dyn Tool>>,
1212    {
1213        self.tools.extend(tools);
1214        self.cached_tool_defs = None; // Invalidate cache when tools change
1215    }
1216
1217    /// Queue a steering message (delivered after tool completion).
1218    pub fn queue_steering(&mut self, message: Message) -> u64 {
1219        self.message_queue.push_steering(message)
1220    }
1221
1222    /// Queue a follow-up message (delivered when agent becomes idle).
1223    pub fn queue_follow_up(&mut self, message: Message) -> u64 {
1224        self.message_queue.push_follow_up(message)
1225    }
1226
1227    /// Configure queue delivery modes.
1228    pub const fn set_queue_modes(&mut self, steering: QueueMode, follow_up: QueueMode) {
1229        self.message_queue.set_modes(steering, follow_up);
1230    }
1231
1232    pub const fn queue_modes(&self) -> (QueueMode, QueueMode) {
1233        (
1234            self.message_queue.steering_mode,
1235            self.message_queue.follow_up_mode,
1236        )
1237    }
1238
1239    /// Count queued messages (steering + follow-up).
1240    #[must_use]
1241    pub fn queued_message_count(&self) -> usize {
1242        self.message_queue.pending_count()
1243    }
1244
1245    pub fn provider(&self) -> Arc<dyn Provider> {
1246        Arc::clone(&self.provider)
1247    }
1248
1249    pub const fn stream_options(&self) -> &StreamOptions {
1250        &self.config.stream_options
1251    }
1252
1253    pub const fn stream_options_mut(&mut self) -> &mut StreamOptions {
1254        &mut self.config.stream_options
1255    }
1256
1257    pub fn system_prompt(&self) -> Option<&str> {
1258        self.config.system_prompt.as_deref()
1259    }
1260
1261    pub fn set_system_prompt(&mut self, system_prompt: Option<String>) {
1262        self.config.system_prompt = system_prompt;
1263    }
1264
1265    /// Build context for a completion request.
1266    fn build_context(&mut self) -> Context<'_> {
1267        let messages: Cow<'_, [Message]> = if self.config.block_images {
1268            let mut msgs = self.messages.clone();
1269            // Filter out hidden custom messages.
1270            msgs.retain(|m| match m {
1271                Message::Custom(c) => c.display,
1272                _ => true,
1273            });
1274            let stats = filter_images_for_provider(&mut msgs);
1275            if stats.removed_images > 0 {
1276                tracing::debug!(
1277                    filtered_images = stats.removed_images,
1278                    affected_messages = stats.affected_messages,
1279                    "Filtered image content from outbound provider context (images.block_images=true)"
1280                );
1281            }
1282            Cow::Owned(msgs)
1283        } else {
1284            // Check if we need to filter hidden custom messages to avoid cloning if not needed.
1285            let has_hidden = self.messages.iter().any(|m| match m {
1286                Message::Custom(c) => !c.display,
1287                _ => false,
1288            });
1289
1290            if has_hidden {
1291                let mut msgs = self.messages.clone();
1292                msgs.retain(|m| match m {
1293                    Message::Custom(c) => c.display,
1294                    _ => true,
1295                });
1296                Cow::Owned(msgs)
1297            } else {
1298                Cow::Borrowed(self.messages.as_slice())
1299            }
1300        };
1301
1302        // Borrow cached tool defs if available; otherwise build + cache + borrow.
1303        if self.cached_tool_defs.is_none() {
1304            let defs: Vec<ToolDef> = self
1305                .tools
1306                .tools()
1307                .iter()
1308                .map(|t| ToolDef {
1309                    name: t.name().to_string(),
1310                    description: t.description().to_string(),
1311                    parameters: t.parameters(),
1312                })
1313                .collect();
1314            self.cached_tool_defs = Some(defs);
1315        }
1316        let tools = Cow::Borrowed(self.cached_tool_defs.as_deref().unwrap());
1317
1318        Context {
1319            system_prompt: self.config.system_prompt.as_deref().map(Cow::Borrowed),
1320            messages,
1321            tools,
1322        }
1323    }
1324
1325    /// Run the agent with a user message.
1326    ///
1327    /// Returns a stream of events and the final assistant message.
1328    pub async fn run(
1329        &mut self,
1330        user_input: impl Into<String>,
1331        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
1332    ) -> Result<AssistantMessage> {
1333        self.run_with_abort(user_input, None, on_event).await
1334    }
1335
1336    /// Run the agent with a user message and abort support.
1337    pub async fn run_with_abort(
1338        &mut self,
1339        user_input: impl Into<String>,
1340        abort: Option<AbortSignal>,
1341        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
1342    ) -> Result<AssistantMessage> {
1343        // Add user message
1344        let user_message = Message::User(UserMessage {
1345            content: UserContent::Text(user_input.into()),
1346            timestamp: Utc::now().timestamp_millis(),
1347        });
1348
1349        // Run the agent loop
1350        self.run_loop(vec![user_message], Arc::new(on_event), abort)
1351            .await
1352    }
1353
1354    /// Run the agent with structured content (text + images).
1355    pub async fn run_with_content(
1356        &mut self,
1357        content: Vec<ContentBlock>,
1358        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
1359    ) -> Result<AssistantMessage> {
1360        self.run_with_content_with_abort(content, None, on_event)
1361            .await
1362    }
1363
1364    /// Run the agent with structured content (text + images) and abort support.
1365    pub async fn run_with_content_with_abort(
1366        &mut self,
1367        content: Vec<ContentBlock>,
1368        abort: Option<AbortSignal>,
1369        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
1370    ) -> Result<AssistantMessage> {
1371        // Add user message
1372        let user_message = Message::User(UserMessage {
1373            content: UserContent::Blocks(content),
1374            timestamp: Utc::now().timestamp_millis(),
1375        });
1376
1377        // Run the agent loop
1378        self.run_loop(vec![user_message], Arc::new(on_event), abort)
1379            .await
1380    }
1381
1382    /// Run the agent with a pre-constructed user message and abort support.
1383    pub async fn run_with_message_with_abort(
1384        &mut self,
1385        message: Message,
1386        abort: Option<AbortSignal>,
1387        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
1388    ) -> Result<AssistantMessage> {
1389        self.run_loop(vec![message], Arc::new(on_event), abort)
1390            .await
1391    }
1392
1393    /// Run the agent with a pre-constructed prompt list and abort support.
1394    pub async fn run_with_messages_with_abort(
1395        &mut self,
1396        messages: Vec<Message>,
1397        abort: Option<AbortSignal>,
1398        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
1399    ) -> Result<AssistantMessage> {
1400        self.run_loop(messages, Arc::new(on_event), abort).await
1401    }
1402
1403    /// Continue the agent loop without adding a new prompt message (used for retries).
1404    pub async fn run_continue_with_abort(
1405        &mut self,
1406        abort: Option<AbortSignal>,
1407        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
1408    ) -> Result<AssistantMessage> {
1409        self.run_loop(Vec::new(), Arc::new(on_event), abort).await
1410    }
1411
1412    fn build_abort_message(&self, partial: Option<&AssistantMessage>) -> AssistantMessage {
1413        let mut message = partial.cloned().unwrap_or_else(|| AssistantMessage {
1414            content: Vec::new(),
1415            api: self.provider.api().to_string(),
1416            provider: self.provider.name().to_string(),
1417            model: self.provider.model_id().to_string(),
1418            usage: Usage::default(),
1419            stop_reason: StopReason::Aborted,
1420            error_message: Some("Aborted".to_string()),
1421            timestamp: Utc::now().timestamp_millis(),
1422        });
1423        message.stop_reason = StopReason::Aborted;
1424        message.error_message = Some("Aborted".to_string());
1425        message.timestamp = Utc::now().timestamp_millis();
1426        message
1427    }
1428
1429    fn build_error_message(
1430        &self,
1431        partial: Option<&AssistantMessage>,
1432        error_message: impl Into<String>,
1433    ) -> AssistantMessage {
1434        let error_message = error_message.into();
1435        let mut message = partial.cloned().unwrap_or_else(|| AssistantMessage {
1436            content: Vec::new(),
1437            api: self.provider.api().to_string(),
1438            provider: self.provider.name().to_string(),
1439            model: self.provider.model_id().to_string(),
1440            usage: Usage::default(),
1441            stop_reason: StopReason::Error,
1442            error_message: Some(error_message.clone()),
1443            timestamp: Utc::now().timestamp_millis(),
1444        });
1445        message.stop_reason = StopReason::Error;
1446        message.error_message = Some(error_message);
1447        message.timestamp = Utc::now().timestamp_millis();
1448        message
1449    }
1450
1451    /// The main agent loop.
1452    #[allow(clippy::too_many_lines)]
1453    async fn run_loop(
1454        &mut self,
1455        prompts: Vec<Message>,
1456        on_event: AgentEventHandler,
1457        abort: Option<AbortSignal>,
1458    ) -> Result<AssistantMessage> {
1459        let loop_cx = crate::agent_cx::AgentCx::for_current_or_request();
1460        let session_id: Arc<str> = self
1461            .config
1462            .stream_options
1463            .session_id
1464            .as_deref()
1465            .unwrap_or("")
1466            .into();
1467        let mut iterations = 0usize;
1468        let mut warned_at_handoff_threshold = false;
1469        let mut turn_index: usize = 0;
1470        let mut new_messages: Vec<Message> = Vec::with_capacity(prompts.len() + 8);
1471        let mut last_assistant: Option<Arc<AssistantMessage>> = None;
1472
1473        let agent_start_event = AgentEvent::AgentStart {
1474            session_id: session_id.clone(),
1475        };
1476        self.dispatch_extension_lifecycle_event(&agent_start_event)
1477            .await;
1478        on_event(agent_start_event);
1479
1480        for prompt in prompts {
1481            self.messages.push(prompt.clone());
1482            on_event(AgentEvent::MessageStart {
1483                message: prompt.clone(),
1484            });
1485            on_event(AgentEvent::MessageEnd {
1486                message: prompt.clone(),
1487            });
1488            new_messages.push(prompt);
1489        }
1490
1491        // Delivery boundary: start of turn (steering messages queued while idle).
1492        let mut pending_messages = self.drain_steering_messages().await;
1493
1494        loop {
1495            let mut has_more_tool_calls = true;
1496            let mut steering_after_tools: Option<Vec<Message>> = None;
1497
1498            while has_more_tool_calls || !pending_messages.is_empty() {
1499                let current_turn_index = turn_index;
1500                let turn_latency = Arc::new(StdMutex::new(TurnLatencyAccumulator::started()));
1501                let turn_start_event = AgentEvent::TurnStart {
1502                    session_id: session_id.clone(),
1503                    turn_index: current_turn_index,
1504                    timestamp: Utc::now().timestamp_millis(),
1505                };
1506                self.dispatch_extension_lifecycle_event(&turn_start_event)
1507                    .await;
1508                on_event(turn_start_event);
1509
1510                for message in std::mem::take(&mut pending_messages) {
1511                    self.messages.push(message.clone());
1512                    on_event(AgentEvent::MessageStart {
1513                        message: message.clone(),
1514                    });
1515                    on_event(AgentEvent::MessageEnd {
1516                        message: message.clone(),
1517                    });
1518                    new_messages.push(message);
1519                }
1520
1521                if abort.as_ref().is_some_and(AbortSignal::is_aborted) {
1522                    let abort_message = self.build_abort_message(None);
1523                    let message = Message::assistant(abort_message.clone());
1524
1525                    self.messages.push(message.clone());
1526                    new_messages.push(message.clone());
1527                    on_event(AgentEvent::MessageStart {
1528                        message: message.clone(),
1529                    });
1530                    on_event(AgentEvent::MessageEnd {
1531                        message: message.clone(),
1532                    });
1533
1534                    let turn_end_event = AgentEvent::TurnEnd {
1535                        session_id: session_id.clone(),
1536                        turn_index: current_turn_index,
1537                        message,
1538                        tool_results: Vec::new(),
1539                        latency_breakdown: snapshot_turn_latency(&turn_latency),
1540                    };
1541                    self.dispatch_extension_lifecycle_event(&turn_end_event)
1542                        .await;
1543                    on_event(turn_end_event);
1544                    let agent_end_event = AgentEvent::AgentEnd {
1545                        session_id: session_id.clone(),
1546                        messages: std::mem::take(&mut new_messages),
1547                        error: Some(
1548                            abort_message
1549                                .error_message
1550                                .clone()
1551                                .unwrap_or_else(|| "Aborted".to_string()),
1552                        ),
1553                    };
1554                    self.dispatch_extension_lifecycle_event(&agent_end_event)
1555                        .await;
1556                    on_event(agent_end_event);
1557                    return Ok(abort_message);
1558                }
1559
1560                let provider_streaming_started_at = Instant::now();
1561                let assistant_result = self
1562                    .stream_assistant_response(Arc::clone(&on_event), abort.clone(), &loop_cx)
1563                    .await;
1564                record_provider_streaming_latency(
1565                    &turn_latency,
1566                    provider_streaming_started_at.elapsed(),
1567                );
1568
1569                let assistant_message = match assistant_result {
1570                    Ok(msg) => msg,
1571                    Err(err) => {
1572                        let err_string = err.to_string();
1573                        let steering_to_add = self.drain_steering_messages().await;
1574                        for message in steering_to_add {
1575                            self.messages.push(message.clone());
1576                            on_event(AgentEvent::MessageStart {
1577                                message: message.clone(),
1578                            });
1579                            on_event(AgentEvent::MessageEnd {
1580                                message: message.clone(),
1581                            });
1582                            new_messages.push(message);
1583                        }
1584
1585                        let error_message = self.build_error_message(None, err_string.clone());
1586                        let assistant_event_message = Message::assistant(error_message.clone());
1587                        self.messages.push(assistant_event_message.clone());
1588                        new_messages.push(assistant_event_message.clone());
1589                        on_event(AgentEvent::MessageStart {
1590                            message: assistant_event_message.clone(),
1591                        });
1592                        on_event(AgentEvent::MessageEnd {
1593                            message: assistant_event_message.clone(),
1594                        });
1595
1596                        let turn_end_event = AgentEvent::TurnEnd {
1597                            session_id: session_id.clone(),
1598                            turn_index: current_turn_index,
1599                            message: assistant_event_message,
1600                            tool_results: Vec::new(),
1601                            latency_breakdown: snapshot_turn_latency(&turn_latency),
1602                        };
1603                        self.dispatch_extension_lifecycle_event(&turn_end_event)
1604                            .await;
1605                        on_event(turn_end_event);
1606
1607                        let agent_end_event = AgentEvent::AgentEnd {
1608                            session_id: session_id.clone(),
1609                            messages: std::mem::take(&mut new_messages),
1610                            error: Some(err_string),
1611                        };
1612                        self.dispatch_extension_lifecycle_event(&agent_end_event)
1613                            .await;
1614                        on_event(agent_end_event);
1615                        return Err(err);
1616                    }
1617                };
1618                // Wrap in Arc once; share via Arc::clone (O(1)) instead of deep
1619                // cloning the full AssistantMessage for every consumer.
1620                let assistant_arc = Arc::new(assistant_message);
1621                last_assistant = Some(Arc::clone(&assistant_arc));
1622
1623                let assistant_event_message = Message::Assistant(Arc::clone(&assistant_arc));
1624                new_messages.push(assistant_event_message.clone());
1625
1626                if matches!(
1627                    assistant_arc.stop_reason,
1628                    StopReason::Error | StopReason::Aborted
1629                ) {
1630                    let steering_to_add = self.drain_steering_messages().await;
1631                    for message in steering_to_add {
1632                        self.messages.push(message.clone());
1633                        on_event(AgentEvent::MessageStart {
1634                            message: message.clone(),
1635                        });
1636                        on_event(AgentEvent::MessageEnd {
1637                            message: message.clone(),
1638                        });
1639                        new_messages.push(message);
1640                    }
1641
1642                    let turn_end_event = AgentEvent::TurnEnd {
1643                        session_id: session_id.clone(),
1644                        turn_index: current_turn_index,
1645                        message: assistant_event_message.clone(),
1646                        tool_results: Vec::new(),
1647                        latency_breakdown: snapshot_turn_latency(&turn_latency),
1648                    };
1649                    self.dispatch_extension_lifecycle_event(&turn_end_event)
1650                        .await;
1651                    on_event(turn_end_event);
1652                    let agent_end_event = AgentEvent::AgentEnd {
1653                        session_id: session_id.clone(),
1654                        messages: std::mem::take(&mut new_messages),
1655                        error: assistant_arc.error_message.clone(),
1656                    };
1657                    self.dispatch_extension_lifecycle_event(&agent_end_event)
1658                        .await;
1659                    on_event(agent_end_event);
1660                    return Ok(Arc::unwrap_or_clone(assistant_arc));
1661                }
1662
1663                let tool_calls = extract_tool_calls(&assistant_arc.content);
1664                has_more_tool_calls = !tool_calls.is_empty();
1665
1666                let mut tool_results: Vec<Arc<ToolResultMessage>> = Vec::new();
1667                if has_more_tool_calls {
1668                    iterations += 1;
1669                    // Soft handoff: at >=80% of the cap, push a one-shot
1670                    // steering message so the agent has room to write an
1671                    // incomplete-handoff envelope before the hard stop. The
1672                    // queue drains at the next loop iteration via
1673                    // drain_steering_messages, so the agent observes the
1674                    // steering before its next assistant turn rather than
1675                    // after the cap fires.
1676                    if !warned_at_handoff_threshold
1677                        && should_warn_at_iteration_threshold(
1678                            iterations,
1679                            self.config.max_tool_iterations,
1680                        )
1681                    {
1682                        warned_at_handoff_threshold = true;
1683                        let warning = Message::User(UserMessage {
1684                            content: UserContent::Text(iteration_handoff_steering_text(
1685                                iterations,
1686                                self.config.max_tool_iterations,
1687                            )),
1688                            timestamp: Utc::now().timestamp_millis(),
1689                        });
1690                        self.message_queue.push_steering(warning);
1691                        tracing::warn!(
1692                            iterations,
1693                            max = self.config.max_tool_iterations,
1694                            "tool-iteration budget at >=80%; injected handoff steering message"
1695                        );
1696                    }
1697                    if iterations > self.config.max_tool_iterations {
1698                        let error_message = format!(
1699                            "Maximum tool iterations ({}) exceeded",
1700                            self.config.max_tool_iterations
1701                        );
1702                        let mut stop_message = (*assistant_arc).clone();
1703                        stop_message.stop_reason = StopReason::Error;
1704                        stop_message.error_message = Some(error_message.clone());
1705
1706                        // Strip dangling tool calls to prevent sequence mismatch on next user prompt.
1707                        stop_message
1708                            .content
1709                            .retain(|b| !matches!(b, crate::model::ContentBlock::ToolCall(_)));
1710
1711                        let stop_arc = Arc::new(stop_message.clone());
1712                        let stop_event_message = Message::Assistant(Arc::clone(&stop_arc));
1713
1714                        // Keep in-memory transcript and event payloads aligned with the
1715                        // error stop result returned to callers.
1716                        if let Some(last @ Message::Assistant(_)) = self
1717                            .messages
1718                            .iter_mut()
1719                            .rev()
1720                            .find(|m| matches!(m, Message::Assistant(_)))
1721                        {
1722                            *last = stop_event_message.clone();
1723                        }
1724                        if let Some(last @ Message::Assistant(_)) = new_messages.last_mut() {
1725                            *last = stop_event_message.clone();
1726                        }
1727
1728                        let steering_to_add = self.drain_steering_messages().await;
1729                        for message in steering_to_add {
1730                            self.messages.push(message.clone());
1731                            on_event(AgentEvent::MessageStart {
1732                                message: message.clone(),
1733                            });
1734                            on_event(AgentEvent::MessageEnd {
1735                                message: message.clone(),
1736                            });
1737                            new_messages.push(message);
1738                        }
1739
1740                        let turn_end_event = AgentEvent::TurnEnd {
1741                            session_id: session_id.clone(),
1742                            turn_index: current_turn_index,
1743                            message: stop_event_message,
1744                            tool_results: Vec::new(),
1745                            latency_breakdown: snapshot_turn_latency(&turn_latency),
1746                        };
1747                        self.dispatch_extension_lifecycle_event(&turn_end_event)
1748                            .await;
1749                        on_event(turn_end_event);
1750
1751                        let agent_end_event = AgentEvent::AgentEnd {
1752                            session_id: session_id.clone(),
1753                            messages: std::mem::take(&mut new_messages),
1754                            error: Some(error_message),
1755                        };
1756                        self.dispatch_extension_lifecycle_event(&agent_end_event)
1757                            .await;
1758                        on_event(agent_end_event);
1759
1760                        return Ok(stop_message);
1761                    }
1762
1763                    let outcome = match self
1764                        .execute_tool_calls(
1765                            &tool_calls,
1766                            Arc::clone(&on_event),
1767                            &mut new_messages,
1768                            abort.clone(),
1769                            Arc::clone(&turn_latency),
1770                        )
1771                        .await
1772                    {
1773                        Ok(outcome) => outcome,
1774                        Err(err) => {
1775                            let steering_to_add = self.drain_steering_messages().await;
1776                            for message in steering_to_add {
1777                                self.messages.push(message.clone());
1778                                on_event(AgentEvent::MessageStart {
1779                                    message: message.clone(),
1780                                });
1781                                on_event(AgentEvent::MessageEnd {
1782                                    message: message.clone(),
1783                                });
1784                                new_messages.push(message);
1785                            }
1786
1787                            let turn_end_event = AgentEvent::TurnEnd {
1788                                session_id: session_id.clone(),
1789                                turn_index: current_turn_index,
1790                                message: assistant_event_message.clone(),
1791                                tool_results: Vec::new(),
1792                                latency_breakdown: snapshot_turn_latency(&turn_latency),
1793                            };
1794                            self.dispatch_extension_lifecycle_event(&turn_end_event)
1795                                .await;
1796                            on_event(turn_end_event);
1797
1798                            let agent_end_event = AgentEvent::AgentEnd {
1799                                session_id: session_id.clone(),
1800                                messages: std::mem::take(&mut new_messages),
1801                                error: Some(err.to_string()),
1802                            };
1803                            self.dispatch_extension_lifecycle_event(&agent_end_event)
1804                                .await;
1805                            on_event(agent_end_event);
1806                            return Err(err);
1807                        }
1808                    };
1809                    tool_results = outcome.tool_results;
1810                    steering_after_tools = outcome.steering_messages;
1811                }
1812
1813                let tool_messages = tool_results
1814                    .iter()
1815                    .map(|r| Message::ToolResult(Arc::clone(r)))
1816                    .collect::<Vec<_>>();
1817
1818                let turn_end_event = AgentEvent::TurnEnd {
1819                    session_id: session_id.clone(),
1820                    turn_index: current_turn_index,
1821                    message: assistant_event_message.clone(),
1822                    tool_results: tool_messages,
1823                    latency_breakdown: snapshot_turn_latency(&turn_latency),
1824                };
1825                self.dispatch_extension_lifecycle_event(&turn_end_event)
1826                    .await;
1827                on_event(turn_end_event);
1828
1829                turn_index = turn_index.saturating_add(1);
1830
1831                if let Some(steering) = steering_after_tools.take() {
1832                    pending_messages = steering;
1833                } else {
1834                    // Delivery boundary: after assistant completion (no tool calls).
1835                    pending_messages = self.drain_steering_messages().await;
1836                }
1837            }
1838
1839            // Delivery boundary: agent idle (after all tool calls + steering).
1840            let follow_up = self.drain_follow_up_messages().await;
1841            if follow_up.is_empty() {
1842                break;
1843            }
1844            pending_messages = follow_up;
1845        }
1846
1847        let Some(final_arc) = last_assistant else {
1848            return Err(Error::api("Agent completed without assistant message"));
1849        };
1850
1851        let agent_end_event = AgentEvent::AgentEnd {
1852            session_id: session_id.clone(),
1853            messages: new_messages,
1854            error: None,
1855        };
1856        self.dispatch_extension_lifecycle_event(&agent_end_event)
1857            .await;
1858        on_event(agent_end_event);
1859        Ok(Arc::unwrap_or_clone(final_arc))
1860    }
1861
1862    async fn fetch_messages(&self, fetcher: Option<&MessageFetcher>) -> Vec<Message> {
1863        if let Some(fetcher) = fetcher {
1864            (fetcher)().await
1865        } else {
1866            Vec::new()
1867        }
1868    }
1869
1870    async fn dispatch_extension_lifecycle_event(&self, event: &AgentEvent) {
1871        let Some(extensions) = &self.extensions else {
1872            return;
1873        };
1874
1875        let name = match event {
1876            AgentEvent::AgentStart { .. } => ExtensionEventName::AgentStart,
1877            AgentEvent::AgentEnd { .. } => ExtensionEventName::AgentEnd,
1878            AgentEvent::TurnStart { .. } => ExtensionEventName::TurnStart,
1879            AgentEvent::TurnEnd { .. } => ExtensionEventName::TurnEnd,
1880            _ => return,
1881        };
1882
1883        let payload = match serde_json::to_value(event) {
1884            Ok(payload) => payload,
1885            Err(err) => {
1886                tracing::warn!("failed to serialize agent lifecycle event (fail-open): {err}");
1887                return;
1888            }
1889        };
1890
1891        if let Err(err) = extensions.dispatch_event(name, Some(payload)).await {
1892            tracing::warn!("agent lifecycle extension hook failed (fail-open): {err}");
1893        }
1894    }
1895
1896    async fn dispatch_context_event(&self, messages: &[Message]) -> Option<Vec<Message>> {
1897        let Some(extensions) = &self.extensions else {
1898            return None;
1899        };
1900
1901        let payload = json!({ "messages": messages });
1902        let response = extensions
1903            .dispatch_event_with_response(
1904                ExtensionEventName::Context,
1905                Some(payload),
1906                EXTENSION_EVENT_TIMEOUT_MS,
1907            )
1908            .await
1909            .ok()?;
1910
1911        let value = response?;
1912
1913        if value.is_null() {
1914            return None;
1915        }
1916
1917        let messages_value = if let Some(obj) = value.as_object() {
1918            obj.get("messages").cloned()?
1919        } else if value.is_array() {
1920            value
1921        } else {
1922            return None;
1923        };
1924
1925        if messages_value.is_null() {
1926            return Some(Vec::new());
1927        }
1928
1929        match serde_json::from_value(messages_value) {
1930            Ok(messages) => Some(messages),
1931            Err(err) => {
1932                tracing::warn!("context extension hook returned invalid messages: {err}");
1933                None
1934            }
1935        }
1936    }
1937
1938    async fn drain_steering_messages(&mut self) -> Vec<Message> {
1939        for fetcher in &self.steering_fetchers {
1940            let fetched = self.fetch_messages(Some(fetcher)).await;
1941            for message in fetched {
1942                self.message_queue.push_steering(message);
1943            }
1944        }
1945        self.message_queue.pop_steering()
1946    }
1947
1948    async fn drain_follow_up_messages(&mut self) -> Vec<Message> {
1949        for fetcher in &self.follow_up_fetchers {
1950            let fetched = self.fetch_messages(Some(fetcher)).await;
1951            for message in fetched {
1952                self.message_queue.push_follow_up(message);
1953            }
1954        }
1955        self.message_queue.pop_follow_up()
1956    }
1957
1958    /// Stream an assistant response and emit message events.
1959    #[allow(clippy::too_many_lines)]
1960    async fn stream_assistant_response(
1961        &mut self,
1962        on_event: AgentEventHandler,
1963        abort: Option<AbortSignal>,
1964        checkpoint_cx: &crate::agent_cx::AgentCx,
1965    ) -> Result<AssistantMessage> {
1966        // Build context and stream completion
1967        let provider = Arc::clone(&self.provider);
1968        let stream_options = self.config.stream_options.clone();
1969        let (system_prompt, tools, base_messages) = {
1970            let context = self.build_context();
1971            (
1972                context.system_prompt.as_deref().map(str::to_string),
1973                context.tools.to_vec(),
1974                context.messages.to_vec(),
1975            )
1976        };
1977        let messages = self
1978            .dispatch_context_event(&base_messages)
1979            .await
1980            .unwrap_or(base_messages);
1981        let context = Context::owned(system_prompt, messages, tools);
1982        let mut stream = provider.stream(&context, &stream_options).await?;
1983
1984        let mut added_partial = false;
1985        // Track whether we've already emitted `MessageStart` for this streaming response.
1986        // Avoids cloning the full message on every event just to re-emit a redundant start.
1987        let mut sent_start = false;
1988        // #126: raw accumulated tool-call argument fragments, keyed by content
1989        // index, for THIS streaming response. Providers keep their own partial's
1990        // `arguments` growing (#124), but the partial that RPC/ACP clients
1991        // actually receive is rebuilt here from `StreamEvent`s — so the
1992        // accumulation and best-effort JSON completion must happen here too,
1993        // uniformly for every provider.
1994        let mut tool_call_raw_args: std::collections::HashMap<usize, String> =
1995            std::collections::HashMap::new();
1996
1997        'stream: loop {
1998            if checkpoint_cx.checkpoint().is_err() {
1999                let last_partial = if added_partial {
2000                    match self
2001                        .messages
2002                        .iter()
2003                        .rev()
2004                        .find(|m| matches!(m, Message::Assistant(_)))
2005                    {
2006                        Some(Message::Assistant(a)) => Some(a.as_ref()),
2007                        _ => None,
2008                    }
2009                } else {
2010                    None
2011                };
2012                let abort_arc = Arc::new(self.build_abort_message(last_partial));
2013                if !sent_start {
2014                    on_event(AgentEvent::MessageStart {
2015                        message: Message::Assistant(Arc::clone(&abort_arc)),
2016                    });
2017                    self.messages
2018                        .push(Message::Assistant(Arc::clone(&abort_arc)));
2019                    added_partial = true;
2020                }
2021                on_event(AgentEvent::MessageUpdate {
2022                    message: Message::Assistant(Arc::clone(&abort_arc)),
2023                    assistant_message_event: AssistantMessageEvent::Error {
2024                        reason: StopReason::Aborted,
2025                        error: Arc::clone(&abort_arc),
2026                    },
2027                });
2028                return Ok(self.finalize_assistant_message(
2029                    Arc::try_unwrap(abort_arc).unwrap_or_else(|a| (*a).clone()),
2030                    &on_event,
2031                    added_partial,
2032                ));
2033            }
2034
2035            let event_result = if let Some(signal) = abort.as_ref() {
2036                let abort_fut = signal.wait().fuse();
2037                let event_fut = stream.next().fuse();
2038                futures::pin_mut!(abort_fut, event_fut);
2039
2040                match futures::future::select(abort_fut, event_fut).await {
2041                    futures::future::Either::Left(((), _event_fut)) => {
2042                        let last_partial = if added_partial {
2043                            match self
2044                                .messages
2045                                .iter()
2046                                .rev()
2047                                .find(|m| matches!(m, Message::Assistant(_)))
2048                            {
2049                                Some(Message::Assistant(a)) => Some(a.as_ref()),
2050                                _ => None,
2051                            }
2052                        } else {
2053                            None
2054                        };
2055                        let abort_arc = Arc::new(self.build_abort_message(last_partial));
2056                        if !sent_start {
2057                            on_event(AgentEvent::MessageStart {
2058                                message: Message::Assistant(Arc::clone(&abort_arc)),
2059                            });
2060                            self.messages
2061                                .push(Message::Assistant(Arc::clone(&abort_arc)));
2062                            added_partial = true;
2063                            // We do NOT set sent_start = true here because we are returning immediately,
2064                            // but setting added_partial = true prevents finalize_assistant_message from
2065                            // emitting a second MessageStart.
2066                        }
2067                        on_event(AgentEvent::MessageUpdate {
2068                            message: Message::Assistant(Arc::clone(&abort_arc)),
2069                            assistant_message_event: AssistantMessageEvent::Error {
2070                                reason: StopReason::Aborted,
2071                                error: Arc::clone(&abort_arc),
2072                            },
2073                        });
2074                        return Ok(self.finalize_assistant_message(
2075                            Arc::try_unwrap(abort_arc).unwrap_or_else(|a| (*a).clone()),
2076                            &on_event,
2077                            added_partial,
2078                        ));
2079                    }
2080                    futures::future::Either::Right((event, _abort_fut)) => event,
2081                }
2082            } else {
2083                let event_fut = stream.next().fuse();
2084                futures::pin_mut!(event_fut);
2085                loop {
2086                    let now = checkpoint_cx
2087                        .cx()
2088                        .timer_driver()
2089                        .map_or_else(asupersync::time::wall_now, |timer| timer.now());
2090                    let tick_fut =
2091                        asupersync::time::sleep(now, std::time::Duration::from_millis(25)).fuse();
2092                    futures::pin_mut!(tick_fut);
2093
2094                    match futures::future::select(tick_fut, &mut event_fut).await {
2095                        futures::future::Either::Left(((), _event_fut)) => {
2096                            if checkpoint_cx.checkpoint().is_err() {
2097                                continue 'stream;
2098                            }
2099                        }
2100                        futures::future::Either::Right((result, _tick_fut)) => break result,
2101                    }
2102                }
2103            };
2104
2105            let Some(event_result) = event_result else {
2106                break;
2107            };
2108            let event = match event_result {
2109                Ok(e) => e,
2110                Err(err) => {
2111                    let partial = if added_partial {
2112                        match self
2113                            .messages
2114                            .iter()
2115                            .rev()
2116                            .find(|m| matches!(m, Message::Assistant(_)))
2117                        {
2118                            Some(Message::Assistant(a)) => Some(a.as_ref()),
2119                            _ => None,
2120                        }
2121                    } else {
2122                        None
2123                    };
2124                    let msg = self.build_error_message(partial, err.to_string());
2125
2126                    // If we never sent a Start event, finalize_assistant_message handles it.
2127                    // But if sent_start is true and added_partial is somehow false,
2128                    // finalize_assistant_message will emit a second Start. That shouldn't happen.
2129                    return Ok(self.finalize_assistant_message(msg, &on_event, added_partial));
2130                }
2131            };
2132
2133            match event {
2134                StreamEvent::Start { partial } => {
2135                    if added_partial {
2136                        if let Some(Message::Assistant(msg_arc)) = self
2137                            .messages
2138                            .iter_mut()
2139                            .rev()
2140                            .find(|m| matches!(m, Message::Assistant(_)))
2141                        {
2142                            let msg = Arc::make_mut(msg_arc);
2143                            if msg.content.is_empty() {
2144                                *msg = partial;
2145                            } else {
2146                                msg.api = partial.api;
2147                                msg.provider = partial.provider;
2148                                msg.model = partial.model;
2149                                msg.usage = partial.usage;
2150                                msg.stop_reason = partial.stop_reason;
2151                                msg.error_message = partial.error_message;
2152                                msg.timestamp = partial.timestamp;
2153                            }
2154                            let shared = Arc::clone(msg_arc);
2155                            if !sent_start {
2156                                on_event(AgentEvent::MessageStart {
2157                                    message: Message::Assistant(Arc::clone(&shared)),
2158                                });
2159                                sent_start = true;
2160                            }
2161                            on_event(AgentEvent::MessageUpdate {
2162                                message: Message::Assistant(Arc::clone(&shared)),
2163                                assistant_message_event: AssistantMessageEvent::Start {
2164                                    partial: shared,
2165                                },
2166                            });
2167                        } else {
2168                            let shared = Arc::new(partial);
2169                            self.update_partial_message(Arc::clone(&shared), &mut added_partial);
2170                            on_event(AgentEvent::MessageStart {
2171                                message: Message::Assistant(Arc::clone(&shared)),
2172                            });
2173                            sent_start = true;
2174                            on_event(AgentEvent::MessageUpdate {
2175                                message: Message::Assistant(Arc::clone(&shared)),
2176                                assistant_message_event: AssistantMessageEvent::Start {
2177                                    partial: shared,
2178                                },
2179                            });
2180                        }
2181                    } else {
2182                        let shared = Arc::new(partial);
2183                        self.update_partial_message(Arc::clone(&shared), &mut added_partial);
2184                        on_event(AgentEvent::MessageStart {
2185                            message: Message::Assistant(Arc::clone(&shared)),
2186                        });
2187                        sent_start = true;
2188                        on_event(AgentEvent::MessageUpdate {
2189                            message: Message::Assistant(Arc::clone(&shared)),
2190                            assistant_message_event: AssistantMessageEvent::Start {
2191                                partial: shared,
2192                            },
2193                        });
2194                    }
2195                }
2196                StreamEvent::TextStart { content_index, .. } => {
2197                    self.seed_partial_message_if_missing(&mut added_partial);
2198                    if let Some(Message::Assistant(msg_arc)) = self
2199                        .messages
2200                        .iter_mut()
2201                        .rev()
2202                        .find(|m| matches!(m, Message::Assistant(_)))
2203                    {
2204                        let msg = Arc::make_mut(msg_arc);
2205                        if content_index == msg.content.len() {
2206                            msg.content.push(ContentBlock::Text(TextContent::new("")));
2207                        }
2208                        let shared = Arc::clone(msg_arc);
2209                        if !sent_start {
2210                            on_event(AgentEvent::MessageStart {
2211                                message: Message::Assistant(Arc::clone(&shared)),
2212                            });
2213                            sent_start = true;
2214                        }
2215                        on_event(AgentEvent::MessageUpdate {
2216                            message: Message::Assistant(Arc::clone(&shared)),
2217                            assistant_message_event: AssistantMessageEvent::TextStart {
2218                                content_index,
2219                                partial: shared,
2220                            },
2221                        });
2222                    }
2223                }
2224                StreamEvent::TextDelta {
2225                    content_index,
2226                    delta,
2227                    ..
2228                } => {
2229                    self.seed_partial_message_if_missing(&mut added_partial);
2230                    if let Some(Message::Assistant(msg_arc)) = self
2231                        .messages
2232                        .iter_mut()
2233                        .rev()
2234                        .find(|m| matches!(m, Message::Assistant(_)))
2235                    {
2236                        {
2237                            let msg = Arc::make_mut(msg_arc);
2238                            if msg.content.get(content_index).is_none()
2239                                && content_index == msg.content.len()
2240                            {
2241                                msg.content.push(ContentBlock::Text(TextContent::new("")));
2242                            }
2243                            if let Some(ContentBlock::Text(text)) =
2244                                msg.content.get_mut(content_index)
2245                            {
2246                                text.text.push_str(&delta);
2247                            }
2248                        }
2249                        let shared = Arc::clone(msg_arc);
2250                        if !sent_start {
2251                            on_event(AgentEvent::MessageStart {
2252                                message: Message::Assistant(Arc::clone(&shared)),
2253                            });
2254                            sent_start = true;
2255                        }
2256                        on_event(AgentEvent::MessageUpdate {
2257                            message: Message::Assistant(Arc::clone(&shared)),
2258                            assistant_message_event: AssistantMessageEvent::TextDelta {
2259                                content_index,
2260                                delta,
2261                                partial: shared,
2262                            },
2263                        });
2264                    }
2265                }
2266                StreamEvent::TextEnd {
2267                    content_index,
2268                    content,
2269                    ..
2270                } => {
2271                    self.seed_partial_message_if_missing(&mut added_partial);
2272                    if let Some(Message::Assistant(msg_arc)) = self
2273                        .messages
2274                        .iter_mut()
2275                        .rev()
2276                        .find(|m| matches!(m, Message::Assistant(_)))
2277                    {
2278                        {
2279                            let msg = Arc::make_mut(msg_arc);
2280                            if msg.content.get(content_index).is_none()
2281                                && content_index == msg.content.len()
2282                            {
2283                                msg.content.push(ContentBlock::Text(TextContent::new("")));
2284                            }
2285                            if let Some(ContentBlock::Text(text)) =
2286                                msg.content.get_mut(content_index)
2287                            {
2288                                text.text.clone_from(&content);
2289                            }
2290                        }
2291                        let shared = Arc::clone(msg_arc);
2292                        if !sent_start {
2293                            on_event(AgentEvent::MessageStart {
2294                                message: Message::Assistant(Arc::clone(&shared)),
2295                            });
2296                            sent_start = true;
2297                        }
2298                        on_event(AgentEvent::MessageUpdate {
2299                            message: Message::Assistant(Arc::clone(&shared)),
2300                            assistant_message_event: AssistantMessageEvent::TextEnd {
2301                                content_index,
2302                                content,
2303                                partial: shared,
2304                            },
2305                        });
2306                    }
2307                }
2308                StreamEvent::ThinkingStart { content_index, .. } => {
2309                    self.seed_partial_message_if_missing(&mut added_partial);
2310                    if let Some(Message::Assistant(msg_arc)) = self
2311                        .messages
2312                        .iter_mut()
2313                        .rev()
2314                        .find(|m| matches!(m, Message::Assistant(_)))
2315                    {
2316                        let msg = Arc::make_mut(msg_arc);
2317                        if content_index == msg.content.len() {
2318                            msg.content.push(ContentBlock::Thinking(ThinkingContent {
2319                                thinking: String::new(),
2320                                thinking_signature: None,
2321                            }));
2322                        }
2323                        let shared = Arc::clone(msg_arc);
2324                        if !sent_start {
2325                            on_event(AgentEvent::MessageStart {
2326                                message: Message::Assistant(Arc::clone(&shared)),
2327                            });
2328                            sent_start = true;
2329                        }
2330                        on_event(AgentEvent::MessageUpdate {
2331                            message: Message::Assistant(Arc::clone(&shared)),
2332                            assistant_message_event: AssistantMessageEvent::ThinkingStart {
2333                                content_index,
2334                                partial: shared,
2335                            },
2336                        });
2337                    }
2338                }
2339                StreamEvent::ThinkingDelta {
2340                    content_index,
2341                    delta,
2342                    ..
2343                } => {
2344                    self.seed_partial_message_if_missing(&mut added_partial);
2345                    if let Some(Message::Assistant(msg_arc)) = self
2346                        .messages
2347                        .iter_mut()
2348                        .rev()
2349                        .find(|m| matches!(m, Message::Assistant(_)))
2350                    {
2351                        {
2352                            let msg = Arc::make_mut(msg_arc);
2353                            if msg.content.get(content_index).is_none()
2354                                && content_index == msg.content.len()
2355                            {
2356                                msg.content.push(ContentBlock::Thinking(ThinkingContent {
2357                                    thinking: String::new(),
2358                                    thinking_signature: None,
2359                                }));
2360                            }
2361                            if let Some(ContentBlock::Thinking(thinking)) =
2362                                msg.content.get_mut(content_index)
2363                            {
2364                                thinking.thinking.push_str(&delta);
2365                            }
2366                        }
2367                        let shared = Arc::clone(msg_arc);
2368                        if !sent_start {
2369                            on_event(AgentEvent::MessageStart {
2370                                message: Message::Assistant(Arc::clone(&shared)),
2371                            });
2372                            sent_start = true;
2373                        }
2374                        on_event(AgentEvent::MessageUpdate {
2375                            message: Message::Assistant(Arc::clone(&shared)),
2376                            assistant_message_event: AssistantMessageEvent::ThinkingDelta {
2377                                content_index,
2378                                delta,
2379                                partial: shared,
2380                            },
2381                        });
2382                    }
2383                }
2384                StreamEvent::ThinkingEnd {
2385                    content_index,
2386                    content,
2387                    ..
2388                } => {
2389                    self.seed_partial_message_if_missing(&mut added_partial);
2390                    if let Some(Message::Assistant(msg_arc)) = self
2391                        .messages
2392                        .iter_mut()
2393                        .rev()
2394                        .find(|m| matches!(m, Message::Assistant(_)))
2395                    {
2396                        {
2397                            let msg = Arc::make_mut(msg_arc);
2398                            if msg.content.get(content_index).is_none()
2399                                && content_index == msg.content.len()
2400                            {
2401                                msg.content.push(ContentBlock::Thinking(ThinkingContent {
2402                                    thinking: String::new(),
2403                                    thinking_signature: None,
2404                                }));
2405                            }
2406                            if let Some(ContentBlock::Thinking(thinking)) =
2407                                msg.content.get_mut(content_index)
2408                            {
2409                                thinking.thinking.clone_from(&content);
2410                            }
2411                        }
2412                        let shared = Arc::clone(msg_arc);
2413                        if !sent_start {
2414                            on_event(AgentEvent::MessageStart {
2415                                message: Message::Assistant(Arc::clone(&shared)),
2416                            });
2417                            sent_start = true;
2418                        }
2419                        on_event(AgentEvent::MessageUpdate {
2420                            message: Message::Assistant(Arc::clone(&shared)),
2421                            assistant_message_event: AssistantMessageEvent::ThinkingEnd {
2422                                content_index,
2423                                content,
2424                                partial: shared,
2425                            },
2426                        });
2427                    }
2428                }
2429                StreamEvent::ToolCallStart { content_index, .. } => {
2430                    self.seed_partial_message_if_missing(&mut added_partial);
2431                    if let Some(Message::Assistant(msg_arc)) = self
2432                        .messages
2433                        .iter_mut()
2434                        .rev()
2435                        .find(|m| matches!(m, Message::Assistant(_)))
2436                    {
2437                        let msg = Arc::make_mut(msg_arc);
2438                        if content_index == msg.content.len() {
2439                            msg.content.push(ContentBlock::ToolCall(ToolCall {
2440                                id: String::new(),
2441                                name: String::new(),
2442                                arguments: serde_json::Value::Null,
2443                                thought_signature: None,
2444                            }));
2445                        }
2446                        let shared = Arc::clone(msg_arc);
2447                        if !sent_start {
2448                            on_event(AgentEvent::MessageStart {
2449                                message: Message::Assistant(Arc::clone(&shared)),
2450                            });
2451                            sent_start = true;
2452                        }
2453                        on_event(AgentEvent::MessageUpdate {
2454                            message: Message::Assistant(Arc::clone(&shared)),
2455                            assistant_message_event: AssistantMessageEvent::ToolCallStart {
2456                                content_index,
2457                                partial: shared,
2458                            },
2459                        });
2460                    }
2461                }
2462                StreamEvent::ToolCallDelta {
2463                    content_index,
2464                    delta,
2465                    ..
2466                } => {
2467                    self.seed_partial_message_if_missing(&mut added_partial);
2468                    if let Some(Message::Assistant(msg_arc)) = self
2469                        .messages
2470                        .iter_mut()
2471                        .rev()
2472                        .find(|m| matches!(m, Message::Assistant(_)))
2473                    {
2474                        {
2475                            let msg = Arc::make_mut(msg_arc);
2476                            if msg.content.get(content_index).is_none()
2477                                && content_index == msg.content.len()
2478                            {
2479                                msg.content.push(ContentBlock::ToolCall(ToolCall {
2480                                    id: String::new(),
2481                                    name: String::new(),
2482                                    arguments: serde_json::Value::Null,
2483                                    thought_signature: None,
2484                                }));
2485                            }
2486                            // #126: grow this partial's `arguments` as deltas
2487                            // arrive so snapshot-based clients (RPC/ACP IDE
2488                            // frontends) render a large tool call streaming in,
2489                            // like text, instead of pause-then-pop-in. The #124
2490                            // provider-side update mutates the provider's OWN
2491                            // partial, which is not the one emitted to clients —
2492                            // this one is, so the accumulated prefix must be
2493                            // completed here. On an un-completable fragment,
2494                            // `complete_partial_json` returns `None` and we keep
2495                            // the last good value (never wrong data). The
2496                            // terminal `ToolCallEnd` still sets the fully-parsed
2497                            // arguments.
2498                            let raw = tool_call_raw_args.entry(content_index).or_default();
2499                            raw.push_str(&delta);
2500                            if let Some(partial_args) =
2501                                crate::providers::openai::complete_partial_json(raw)
2502                            {
2503                                if let Some(ContentBlock::ToolCall(tc)) =
2504                                    msg.content.get_mut(content_index)
2505                                {
2506                                    tc.arguments = partial_args;
2507                                }
2508                            }
2509                        }
2510                        let shared = Arc::clone(msg_arc);
2511                        if !sent_start {
2512                            on_event(AgentEvent::MessageStart {
2513                                message: Message::Assistant(Arc::clone(&shared)),
2514                            });
2515                            sent_start = true;
2516                        }
2517                        on_event(AgentEvent::MessageUpdate {
2518                            message: Message::Assistant(Arc::clone(&shared)),
2519                            assistant_message_event: AssistantMessageEvent::ToolCallDelta {
2520                                content_index,
2521                                delta,
2522                                partial: shared,
2523                            },
2524                        });
2525                    }
2526                }
2527                StreamEvent::ToolCallEnd {
2528                    content_index,
2529                    tool_call,
2530                    ..
2531                } => {
2532                    self.seed_partial_message_if_missing(&mut added_partial);
2533                    if let Some(Message::Assistant(msg_arc)) = self
2534                        .messages
2535                        .iter_mut()
2536                        .rev()
2537                        .find(|m| matches!(m, Message::Assistant(_)))
2538                    {
2539                        {
2540                            let msg = Arc::make_mut(msg_arc);
2541                            if msg.content.get(content_index).is_none()
2542                                && content_index == msg.content.len()
2543                            {
2544                                msg.content.push(ContentBlock::ToolCall(ToolCall {
2545                                    id: String::new(),
2546                                    name: String::new(),
2547                                    arguments: serde_json::Value::Null,
2548                                    thought_signature: None,
2549                                }));
2550                            }
2551                            if let Some(ContentBlock::ToolCall(tc)) =
2552                                msg.content.get_mut(content_index)
2553                            {
2554                                *tc = tool_call.clone();
2555                            }
2556                        }
2557                        let shared = Arc::clone(msg_arc);
2558                        if !sent_start {
2559                            on_event(AgentEvent::MessageStart {
2560                                message: Message::Assistant(Arc::clone(&shared)),
2561                            });
2562                            sent_start = true;
2563                        }
2564                        on_event(AgentEvent::MessageUpdate {
2565                            message: Message::Assistant(Arc::clone(&shared)),
2566                            assistant_message_event: AssistantMessageEvent::ToolCallEnd {
2567                                content_index,
2568                                tool_call,
2569                                partial: shared,
2570                            },
2571                        });
2572                    }
2573                }
2574                StreamEvent::Done { message, .. } => {
2575                    return Ok(self.finalize_assistant_message(message, &on_event, added_partial));
2576                }
2577                StreamEvent::Error { error, .. } => {
2578                    return Ok(self.finalize_assistant_message(error, &on_event, added_partial));
2579                }
2580            }
2581        }
2582
2583        // If the stream ends without a Done/Error event, we may have a partial message.
2584        // Instead of discarding it, we finalize it with an error state so the user/session
2585        // retains the partial content.
2586        if added_partial {
2587            if let Some(Message::Assistant(last_msg)) = self
2588                .messages
2589                .iter()
2590                .rev()
2591                .find(|m| matches!(m, Message::Assistant(_)))
2592            {
2593                let mut final_msg = (**last_msg).clone();
2594                final_msg.stop_reason = StopReason::Error;
2595                final_msg.error_message = Some("Stream ended without Done event".to_string());
2596                return Ok(self.finalize_assistant_message(final_msg, &on_event, true));
2597            }
2598        }
2599        Err(Error::api("Stream ended without Done event"))
2600    }
2601
2602    /// Ensure we have a fresh assistant message for the current stream.
2603    ///
2604    /// Some providers/extensions can emit deltas without a Start event; without
2605    /// this guard we would mutate the previous assistant message instead.
2606    fn seed_partial_message_if_missing(&mut self, added_partial: &mut bool) {
2607        if *added_partial {
2608            return;
2609        }
2610
2611        let message = AssistantMessage {
2612            content: Vec::new(),
2613            api: self.provider.api().to_string(),
2614            provider: self.provider.name().to_string(),
2615            model: self.provider.model_id().to_string(),
2616            usage: Usage::default(),
2617            stop_reason: StopReason::Stop,
2618            error_message: None,
2619            timestamp: Utc::now().timestamp_millis(),
2620        };
2621        self.messages.push(Message::Assistant(Arc::new(message)));
2622        *added_partial = true;
2623    }
2624
2625    /// Update the partial assistant message in `self.messages`.
2626    ///
2627    /// Takes an `Arc<AssistantMessage>` and moves it into the message list
2628    /// (one Arc move, zero deep-copies).
2629    fn update_partial_message(
2630        &mut self,
2631        partial: Arc<AssistantMessage>,
2632        added_partial: &mut bool,
2633    ) -> bool {
2634        if *added_partial {
2635            if let Some(target) = self
2636                .messages
2637                .iter_mut()
2638                .rev()
2639                .find(|m| matches!(m, Message::Assistant(_)))
2640            {
2641                *target = Message::Assistant(partial);
2642            } else {
2643                // Defensive: added_partial is true but no Assistant message found.
2644                // Push as new message rather than silently dropping the update.
2645                tracing::warn!("update_partial_message: expected an Assistant message in history");
2646                self.messages.push(Message::Assistant(partial));
2647            }
2648            false
2649        } else {
2650            self.messages.push(Message::Assistant(partial));
2651            *added_partial = true;
2652            true
2653        }
2654    }
2655
2656    fn finalize_assistant_message(
2657        &mut self,
2658        message: AssistantMessage,
2659        on_event: &Arc<dyn Fn(AgentEvent) + Send + Sync>,
2660        added_partial: bool,
2661    ) -> AssistantMessage {
2662        let arc = Arc::new(message);
2663        if added_partial {
2664            if let Some(target) = self
2665                .messages
2666                .iter_mut()
2667                .rev()
2668                .find(|m| matches!(m, Message::Assistant(_)))
2669            {
2670                *target = Message::Assistant(Arc::clone(&arc));
2671            } else {
2672                // Defensive: added_partial is true but no Assistant message found.
2673                // Push as new message rather than overwriting an unrelated message.
2674                tracing::warn!(
2675                    "finalize_assistant_message: expected an Assistant message in history"
2676                );
2677                self.messages.push(Message::Assistant(Arc::clone(&arc)));
2678                on_event(AgentEvent::MessageStart {
2679                    message: Message::Assistant(Arc::clone(&arc)),
2680                });
2681            }
2682        } else {
2683            self.messages.push(Message::Assistant(Arc::clone(&arc)));
2684            on_event(AgentEvent::MessageStart {
2685                message: Message::Assistant(Arc::clone(&arc)),
2686            });
2687        }
2688
2689        on_event(AgentEvent::MessageEnd {
2690            message: Message::Assistant(Arc::clone(&arc)),
2691        });
2692        Arc::try_unwrap(arc).unwrap_or_else(|a| (*a).clone())
2693    }
2694
2695    async fn execute_tool_batch(
2696        &self,
2697        batch: Vec<(usize, ToolCall)>,
2698        on_event: AgentEventHandler,
2699        abort: Option<AbortSignal>,
2700        latency: SharedTurnLatencyAccumulator,
2701    ) -> Vec<(usize, (ToolOutput, bool))> {
2702        let parallelism = compatible_tool_parallelism_limit();
2703        let futures = batch.into_iter().map(|(idx, tc)| {
2704            let on_event = Arc::clone(&on_event);
2705            let latency = Arc::clone(&latency);
2706            async move { (idx, self.execute_tool_owned(tc, on_event, latency).await) }
2707        });
2708
2709        if let Some(signal) = abort.as_ref() {
2710            use futures::future::{Either, select};
2711            let all_fut = stream::iter(futures)
2712                .buffer_unordered(parallelism)
2713                .collect::<Vec<_>>()
2714                .fuse();
2715            let abort_fut = signal.wait().fuse();
2716            futures::pin_mut!(all_fut, abort_fut);
2717
2718            match select(all_fut, abort_fut).await {
2719                Either::Left((batch_results, _)) => batch_results,
2720                Either::Right(_) => Vec::new(), // Aborted
2721            }
2722        } else {
2723            stream::iter(futures)
2724                .buffer_unordered(parallelism)
2725                .collect::<Vec<_>>()
2726                .await
2727        }
2728    }
2729
2730    #[allow(clippy::too_many_lines)]
2731    async fn execute_tool_calls(
2732        &mut self,
2733        tool_calls: &[ToolCall],
2734        on_event: AgentEventHandler,
2735        new_messages: &mut Vec<Message>,
2736        abort: Option<AbortSignal>,
2737        latency: SharedTurnLatencyAccumulator,
2738    ) -> Result<ToolExecutionOutcome> {
2739        let mut results = Vec::new();
2740        let mut steering_messages: Option<Vec<Message>> = None;
2741
2742        // Phase 1: Emit start events for ALL tools up front.
2743        for tool_call in tool_calls {
2744            on_event(AgentEvent::ToolExecutionStart {
2745                tool_call_id: tool_call.id.clone(),
2746                tool_name: tool_call.name.clone(),
2747                args: tool_call.arguments.clone(),
2748            });
2749        }
2750
2751        // Phase 2: Execute tools in contiguous compatible-effect batches.
2752        let effect_plan = tool_calls
2753            .iter()
2754            .map(|tool_call| {
2755                self.tools
2756                    .get(&tool_call.name)
2757                    .map_or_else(ToolEffects::write, Tool::effects)
2758            })
2759            .collect::<Vec<_>>();
2760        let effect_batches = plan_tool_effect_batches(&effect_plan);
2761        let mut recorded_results: Vec<Option<Arc<ToolResultMessage>>> =
2762            vec![None; tool_calls.len()];
2763
2764        for effect_batch in effect_batches {
2765            if abort.as_ref().is_some_and(AbortSignal::is_aborted) {
2766                break;
2767            }
2768
2769            let steering = self.drain_steering_messages().await;
2770            if !steering.is_empty() {
2771                steering_messages = Some(steering);
2772                break;
2773            }
2774
2775            let batch_len = effect_batch.end.saturating_sub(effect_batch.start);
2776            let batch = tool_calls
2777                .iter()
2778                .cloned()
2779                .enumerate()
2780                .skip(effect_batch.start)
2781                .take(batch_len)
2782                .collect();
2783            let mut batch_results = self
2784                .execute_tool_batch(
2785                    batch,
2786                    Arc::clone(&on_event),
2787                    abort.clone(),
2788                    Arc::clone(&latency),
2789                )
2790                .await;
2791            batch_results.sort_by_key(|(idx, _)| *idx);
2792            for (idx, (output, is_error)) in batch_results {
2793                if let (Some(tool_call), Some(recorded_result)) =
2794                    (tool_calls.get(idx), recorded_results.get_mut(idx))
2795                {
2796                    *recorded_result = Some(self.record_tool_result(
2797                        tool_call,
2798                        output,
2799                        is_error,
2800                        &on_event,
2801                        new_messages,
2802                    ));
2803                }
2804            }
2805        }
2806
2807        // Phase 3: Process results sequentially and handle skips.
2808        for (index, tool_call) in tool_calls.iter().enumerate() {
2809            // Check for new steering if we haven't already found some.
2810            // This catches steering messages that arrived during the *last* tool's execution.
2811            if steering_messages.is_none() && !abort.as_ref().is_some_and(AbortSignal::is_aborted) {
2812                let steering = self.drain_steering_messages().await;
2813                if !steering.is_empty() {
2814                    steering_messages = Some(steering);
2815                }
2816            }
2817
2818            // If a result was recorded during execution, keep outcome ordering
2819            // without re-emitting lifecycle events or duplicating transcript entries.
2820            if let Some(tool_result) = recorded_results.get_mut(index).and_then(Option::take) {
2821                results.push(tool_result);
2822            } else if steering_messages.is_some() {
2823                // Skipped due to steering.
2824                results.push(self.skip_tool_call(tool_call, &on_event, new_messages));
2825            } else {
2826                // Aborted or otherwise failed to run (e.g. abort signal).
2827                let output = ToolOutput {
2828                    content: vec![ContentBlock::Text(TextContent::new(
2829                        "Tool execution aborted",
2830                    ))],
2831                    details: Some(Self::tool_cancellation_details(
2832                        &tool_call.name,
2833                        "abort_signal",
2834                    )),
2835                    is_error: true,
2836                };
2837
2838                on_event(AgentEvent::ToolExecutionUpdate {
2839                    tool_call_id: tool_call.id.clone(),
2840                    tool_name: tool_call.name.clone(),
2841                    args: tool_call.arguments.clone(),
2842                    partial_result: ToolOutput {
2843                        content: output.content.clone(),
2844                        details: output.details.clone(),
2845                        is_error: true,
2846                    },
2847                });
2848
2849                on_event(AgentEvent::ToolExecutionEnd {
2850                    tool_call_id: tool_call.id.clone(),
2851                    tool_name: tool_call.name.clone(),
2852                    result: ToolOutput {
2853                        content: output.content.clone(),
2854                        details: output.details.clone(),
2855                        is_error: true,
2856                    },
2857                    is_error: true,
2858                });
2859
2860                let tool_result = Arc::new(ToolResultMessage {
2861                    tool_call_id: tool_call.id.clone(),
2862                    tool_name: tool_call.name.clone(),
2863                    content: output.content,
2864                    details: output.details,
2865                    is_error: true,
2866                    timestamp: Utc::now().timestamp_millis(),
2867                });
2868
2869                let msg = Message::ToolResult(Arc::clone(&tool_result));
2870                self.messages.push(msg.clone());
2871                on_event(AgentEvent::MessageStart {
2872                    message: msg.clone(),
2873                });
2874                let end_msg = msg.clone();
2875                new_messages.push(msg);
2876                on_event(AgentEvent::MessageEnd { message: end_msg });
2877
2878                results.push(tool_result);
2879            }
2880        }
2881
2882        Ok(ToolExecutionOutcome {
2883            tool_results: results,
2884            steering_messages,
2885        })
2886    }
2887
2888    fn record_tool_result(
2889        &mut self,
2890        tool_call: &ToolCall,
2891        output: ToolOutput,
2892        is_error: bool,
2893        on_event: &AgentEventHandler,
2894        new_messages: &mut Vec<Message>,
2895    ) -> Arc<ToolResultMessage> {
2896        on_event(AgentEvent::ToolExecutionUpdate {
2897            tool_call_id: tool_call.id.clone(),
2898            tool_name: tool_call.name.clone(),
2899            args: tool_call.arguments.clone(),
2900            partial_result: ToolOutput {
2901                content: output.content.clone(),
2902                details: output.details.clone(),
2903                is_error,
2904            },
2905        });
2906
2907        let tool_result = Arc::new(ToolResultMessage {
2908            tool_call_id: tool_call.id.clone(),
2909            tool_name: tool_call.name.clone(),
2910            content: output.content,
2911            details: output.details,
2912            is_error,
2913            timestamp: Utc::now().timestamp_millis(),
2914        });
2915
2916        on_event(AgentEvent::ToolExecutionEnd {
2917            tool_call_id: tool_result.tool_call_id.clone(),
2918            tool_name: tool_result.tool_name.clone(),
2919            result: ToolOutput {
2920                content: tool_result.content.clone(),
2921                details: tool_result.details.clone(),
2922                is_error,
2923            },
2924            is_error,
2925        });
2926
2927        let msg = Message::ToolResult(Arc::clone(&tool_result));
2928        self.messages.push(msg.clone());
2929        on_event(AgentEvent::MessageStart {
2930            message: msg.clone(),
2931        });
2932        new_messages.push(msg.clone());
2933        on_event(AgentEvent::MessageEnd { message: msg });
2934
2935        tool_result
2936    }
2937
2938    async fn execute_tool(
2939        &self,
2940        tool_call: ToolCall,
2941        on_event: AgentEventHandler,
2942        latency: SharedTurnLatencyAccumulator,
2943    ) -> (ToolOutput, bool) {
2944        let extensions = self.extensions.clone();
2945
2946        let approval_denied_output = self
2947            .request_tool_approval(&tool_call, Arc::clone(&on_event))
2948            .await;
2949
2950        let (mut output, is_error) = if let Some(output) = approval_denied_output {
2951            (output, true)
2952        } else if let Some(extensions) = &extensions {
2953            let hook_started_at = Instant::now();
2954            let hook_outcome = Self::dispatch_tool_call_hook(
2955                extensions,
2956                &tool_call,
2957                self.config.fail_closed_hooks,
2958            )
2959            .await;
2960            record_extension_hostcall_latency(&latency, hook_started_at.elapsed());
2961
2962            if let Some(blocked_output) = hook_outcome {
2963                (blocked_output, true)
2964            } else {
2965                let tool_started_at = Instant::now();
2966                let outcome = self
2967                    .execute_tool_without_hooks(&tool_call, Arc::clone(&on_event))
2968                    .await;
2969                record_local_tool_latency(&latency, tool_started_at.elapsed());
2970                outcome
2971            }
2972        } else {
2973            let tool_started_at = Instant::now();
2974            let outcome = self
2975                .execute_tool_without_hooks(&tool_call, Arc::clone(&on_event))
2976                .await;
2977            record_local_tool_latency(&latency, tool_started_at.elapsed());
2978            outcome
2979        };
2980
2981        if let Some(extensions) = &extensions {
2982            let hook_started_at = Instant::now();
2983            Self::apply_tool_result_hook(extensions, &tool_call, &mut output, is_error).await;
2984            record_extension_hostcall_latency(&latency, hook_started_at.elapsed());
2985        }
2986
2987        (output, is_error)
2988    }
2989
2990    async fn request_tool_approval(
2991        &self,
2992        tool_call: &ToolCall,
2993        on_event: AgentEventHandler,
2994    ) -> Option<ToolOutput> {
2995        let Some(approval) = &self.config.tool_approval else {
2996            return None;
2997        };
2998
2999        let request = ToolApprovalRequest {
3000            tool_call_id: tool_call.id.clone(),
3001            tool_name: tool_call.name.clone(),
3002            arguments: tool_call.arguments.clone(),
3003        };
3004
3005        match approval(request).await {
3006            ToolApprovalDecision::Allow => {
3007                on_event(AgentEvent::ToolExecutionUpdate {
3008                    tool_call_id: tool_call.id.clone(),
3009                    tool_name: tool_call.name.clone(),
3010                    args: tool_call.arguments.clone(),
3011                    partial_result: ToolOutput {
3012                        content: Vec::new(),
3013                        details: Some(json!({
3014                            "schema": TOOL_APPROVAL_STATUS_SCHEMA_V1,
3015                            "status": "approved",
3016                        })),
3017                        is_error: false,
3018                    },
3019                });
3020                None
3021            }
3022            ToolApprovalDecision::Deny { reason } => {
3023                Some(Self::tool_approval_denied_output(&reason))
3024            }
3025        }
3026    }
3027
3028    async fn execute_tool_owned(
3029        &self,
3030        tool_call: ToolCall,
3031        on_event: AgentEventHandler,
3032        latency: SharedTurnLatencyAccumulator,
3033    ) -> (ToolOutput, bool) {
3034        self.execute_tool(tool_call, on_event, latency).await
3035    }
3036
3037    async fn execute_tool_without_hooks(
3038        &self,
3039        tool_call: &ToolCall,
3040        on_event: AgentEventHandler,
3041    ) -> (ToolOutput, bool) {
3042        // Find the tool
3043        let Some(tool) = self.tools.get(&tool_call.name) else {
3044            return (Self::tool_not_found_output(&tool_call.name), true);
3045        };
3046
3047        let tool_name = tool_call.name.clone();
3048        let tool_id = tool_call.id.clone();
3049        let tool_args = tool_call.arguments.clone();
3050        let on_event = Arc::clone(&on_event);
3051
3052        let update_callback = move |update: ToolUpdate| {
3053            on_event(AgentEvent::ToolExecutionUpdate {
3054                tool_call_id: tool_id.clone(),
3055                tool_name: tool_name.clone(),
3056                args: tool_args.clone(),
3057                partial_result: ToolOutput {
3058                    content: update.content,
3059                    details: update.details,
3060                    is_error: false,
3061                },
3062            });
3063        };
3064
3065        let _artifact_session_guard =
3066            self.config
3067                .stream_options
3068                .session_id
3069                .as_deref()
3070                .map(|session_id| {
3071                    crate::tools::register_tool_output_artifact_session(&tool_call.id, session_id)
3072                });
3073
3074        match tool
3075            .execute(
3076                &tool_call.id,
3077                tool_call.arguments.clone(),
3078                Some(Box::new(update_callback)),
3079            )
3080            .await
3081        {
3082            Ok(output) => {
3083                let is_error = output.is_error;
3084                (output, is_error)
3085            }
3086            Err(e) => (
3087                ToolOutput {
3088                    content: vec![ContentBlock::Text(TextContent::new(format!("Error: {e}")))],
3089                    details: None,
3090                    is_error: true,
3091                },
3092                true,
3093            ),
3094        }
3095    }
3096
3097    fn tool_not_found_output(tool_name: &str) -> ToolOutput {
3098        ToolOutput {
3099            content: vec![ContentBlock::Text(TextContent::new(format!(
3100                "Error: Tool '{tool_name}' not found"
3101            )))],
3102            details: None,
3103            is_error: true,
3104        }
3105    }
3106
3107    fn tool_cancellation_details(tool_name: &str, reason: &str) -> Value {
3108        json!({
3109            "schema": TOOL_CANCELLATION_SCHEMA_V1,
3110            "status": "cancelled",
3111            "reason": reason,
3112            "toolName": tool_name,
3113            "cleanup": "tool_result_recorded_no_success",
3114        })
3115    }
3116
3117    async fn dispatch_tool_call_hook(
3118        extensions: &ExtensionManager,
3119        tool_call: &ToolCall,
3120        fail_closed_hooks: bool,
3121    ) -> Option<ToolOutput> {
3122        match extensions
3123            .dispatch_tool_call(tool_call, EXTENSION_EVENT_TIMEOUT_MS)
3124            .await
3125        {
3126            Ok(Some(result)) if result.block => {
3127                Some(Self::tool_call_blocked_output(result.reason.as_deref()))
3128            }
3129            Ok(_) => None,
3130            Err(err) => {
3131                if fail_closed_hooks {
3132                    tracing::warn!(
3133                        error = ?err,
3134                        "tool_call extension hook failed (fail-closed)"
3135                    );
3136                    Some(Self::tool_call_blocked_output(Some(
3137                        "extension hook failed",
3138                    )))
3139                } else {
3140                    tracing::warn!("tool_call extension hook failed (fail-open): {err}");
3141                    None
3142                }
3143            }
3144        }
3145    }
3146
3147    fn tool_call_blocked_output(reason: Option<&str>) -> ToolOutput {
3148        let reason = reason.map(str::trim).filter(|reason| !reason.is_empty());
3149        let message = reason.map_or_else(
3150            || "Tool execution was blocked by an extension".to_string(),
3151            |reason| format!("Tool execution blocked: {reason}"),
3152        );
3153
3154        ToolOutput {
3155            content: vec![ContentBlock::Text(TextContent::new(message))],
3156            details: None,
3157            is_error: true,
3158        }
3159    }
3160
3161    fn tool_approval_denied_output(reason: &str) -> ToolOutput {
3162        let reason = reason.trim();
3163        let reason = if reason.is_empty() {
3164            "tool approval denied"
3165        } else {
3166            reason
3167        };
3168
3169        ToolOutput {
3170            content: vec![ContentBlock::Text(TextContent::new(format!(
3171                "Tool execution denied: {reason}"
3172            )))],
3173            details: Some(json!({
3174                "schema": TOOL_APPROVAL_DENIED_SCHEMA_V1,
3175                "status": "denied",
3176                "reason": reason,
3177            })),
3178            is_error: true,
3179        }
3180    }
3181
3182    async fn apply_tool_result_hook(
3183        extensions: &ExtensionManager,
3184        tool_call: &ToolCall,
3185        output: &mut ToolOutput,
3186        is_error: bool,
3187    ) {
3188        match extensions
3189            .dispatch_tool_result(tool_call, &*output, is_error, EXTENSION_EVENT_TIMEOUT_MS)
3190            .await
3191        {
3192            Ok(Some(result)) => {
3193                if let Some(content) = result.content {
3194                    output.content = content;
3195                }
3196                if let Some(details) = result.details {
3197                    output.details = Some(details);
3198                }
3199            }
3200            Ok(None) => {}
3201            Err(err) => tracing::warn!("tool_result extension hook failed (fail-open): {err}"),
3202        }
3203    }
3204
3205    fn skip_tool_call(
3206        &mut self,
3207        tool_call: &ToolCall,
3208        on_event: &Arc<dyn Fn(AgentEvent) + Send + Sync>,
3209        new_messages: &mut Vec<Message>,
3210    ) -> Arc<ToolResultMessage> {
3211        let output = ToolOutput {
3212            content: vec![ContentBlock::Text(TextContent::new(
3213                "Skipped due to queued user message.",
3214            ))],
3215            details: None,
3216            is_error: true,
3217        };
3218
3219        // Note: Phase 1 already emitted ToolExecutionStart for all tools,
3220        // so we only emit Update and End here.
3221        on_event(AgentEvent::ToolExecutionUpdate {
3222            tool_call_id: tool_call.id.clone(),
3223            tool_name: tool_call.name.clone(),
3224            args: tool_call.arguments.clone(),
3225            partial_result: output.clone(),
3226        });
3227        on_event(AgentEvent::ToolExecutionEnd {
3228            tool_call_id: tool_call.id.clone(),
3229            tool_name: tool_call.name.clone(),
3230            result: output.clone(),
3231            is_error: true,
3232        });
3233
3234        let tool_result = Arc::new(ToolResultMessage {
3235            tool_call_id: tool_call.id.clone(),
3236            tool_name: tool_call.name.clone(),
3237            content: output.content,
3238            details: output.details,
3239            is_error: true,
3240            timestamp: Utc::now().timestamp_millis(),
3241        });
3242
3243        let msg = Message::ToolResult(Arc::clone(&tool_result));
3244        self.messages.push(msg.clone());
3245        new_messages.push(msg.clone());
3246
3247        on_event(AgentEvent::MessageStart {
3248            message: msg.clone(),
3249        });
3250        on_event(AgentEvent::MessageEnd { message: msg });
3251
3252        tool_result
3253    }
3254}
3255
3256// ============================================================================
3257// Agent Session (Agent + Session persistence)
3258// ============================================================================
3259
3260struct ToolExecutionOutcome {
3261    tool_results: Vec<Arc<ToolResultMessage>>,
3262    steering_messages: Option<Vec<Message>>,
3263}
3264
3265/// Pre-created extension runtime state for overlapping startup I/O.
3266///
3267/// By spawning runtime boot as a background task *before* session creation and
3268/// model selection, expensive runtime startup can overlap with other work.
3269pub struct PreWarmedExtensionRuntime {
3270    /// The extension manager (already has `cwd` and risk config set).
3271    pub manager: ExtensionManager,
3272    /// The booted runtime handle.
3273    pub runtime: ExtensionRuntimeHandle,
3274    /// The tool registry passed to the runtime during boot.
3275    pub tools: Arc<ToolRegistry>,
3276}
3277
3278/// RAII guard that resets an `AtomicBool` to `false` on drop, ensuring the
3279/// flag is cleared even if the enclosing async task is cancelled.
3280struct AtomicBoolGuard(Arc<AtomicBool>);
3281
3282impl AtomicBoolGuard {
3283    fn activate(flag: &Arc<AtomicBool>) -> Self {
3284        flag.store(true, Ordering::SeqCst);
3285        Self(Arc::clone(flag))
3286    }
3287}
3288
3289impl Drop for AtomicBoolGuard {
3290    fn drop(&mut self) {
3291        self.0.store(false, Ordering::SeqCst);
3292    }
3293}
3294
3295pub struct AgentSession {
3296    pub agent: Agent,
3297    pub session: Arc<Mutex<Session>>,
3298    save_enabled: bool,
3299    input_source: InputSource,
3300    /// Extension lifecycle region — ensures the JS runtime thread is shut
3301    /// down when the session ends.
3302    pub extensions: Option<ExtensionRegion>,
3303    extensions_is_streaming: Arc<AtomicBool>,
3304    extensions_is_compacting: Arc<AtomicBool>,
3305    extensions_turn_active: Arc<AtomicBool>,
3306    extensions_pending_idle_actions: Arc<StdMutex<VecDeque<PendingIdleAction>>>,
3307    extension_queue_modes: Option<Arc<StdMutex<ExtensionQueueModeState>>>,
3308    extension_injected_queue: Option<Arc<StdMutex<ExtensionInjectedQueue>>>,
3309    extension_ai_completion: Arc<StdMutex<ExtensionAiCompletionHostState>>,
3310    compaction_settings: ResolvedCompactionSettings,
3311    compaction_runtime: Option<Runtime>,
3312    runtime_handle: Option<RuntimeHandle>,
3313    compaction_worker: CompactionWorkerState,
3314    model_registry: Option<ModelRegistry>,
3315    auth_storage: Option<AuthStorage>,
3316    api_key_override: Option<String>,
3317    semantic_context_bundle: Option<SemanticContextBundleInjection>,
3318}
3319
3320#[derive(Debug, Clone, Copy)]
3321struct ExtensionQueueModeState {
3322    steering_mode: QueueMode,
3323    follow_up_mode: QueueMode,
3324}
3325
3326impl ExtensionQueueModeState {
3327    const fn new(steering_mode: QueueMode, follow_up_mode: QueueMode) -> Self {
3328        Self {
3329            steering_mode,
3330            follow_up_mode,
3331        }
3332    }
3333
3334    const fn set_modes(&mut self, steering_mode: QueueMode, follow_up_mode: QueueMode) {
3335        self.steering_mode = steering_mode;
3336        self.follow_up_mode = follow_up_mode;
3337    }
3338}
3339
3340#[derive(Debug)]
3341struct ExtensionInjectedQueue {
3342    steering: VecDeque<Message>,
3343    follow_up: VecDeque<Message>,
3344    steering_mode: QueueMode,
3345    follow_up_mode: QueueMode,
3346}
3347
3348impl ExtensionInjectedQueue {
3349    const fn new(steering_mode: QueueMode, follow_up_mode: QueueMode) -> Self {
3350        Self {
3351            steering: VecDeque::new(),
3352            follow_up: VecDeque::new(),
3353            steering_mode,
3354            follow_up_mode,
3355        }
3356    }
3357
3358    const fn set_modes(&mut self, steering_mode: QueueMode, follow_up_mode: QueueMode) {
3359        self.steering_mode = steering_mode;
3360        self.follow_up_mode = follow_up_mode;
3361    }
3362
3363    fn push_steering(&mut self, message: Message) {
3364        if self.steering.len() >= MAX_STEERING_QUEUE_SIZE {
3365            tracing::warn!(
3366                "Extension steering queue full ({} messages), dropping oldest message",
3367                MAX_STEERING_QUEUE_SIZE
3368            );
3369            self.steering.pop_front();
3370        }
3371        self.steering.push_back(message);
3372    }
3373
3374    fn push_follow_up(&mut self, message: Message) {
3375        if self.follow_up.len() >= MAX_FOLLOW_UP_QUEUE_SIZE {
3376            tracing::warn!(
3377                "Extension follow-up queue full ({} messages), dropping oldest message",
3378                MAX_FOLLOW_UP_QUEUE_SIZE
3379            );
3380            self.follow_up.pop_front();
3381        }
3382        self.follow_up.push_back(message);
3383    }
3384
3385    fn pop_steering(&mut self) -> Vec<Message> {
3386        match self.steering_mode {
3387            QueueMode::All => self.steering.drain(..).collect(),
3388            QueueMode::OneAtATime => self.steering.pop_front().into_iter().collect(),
3389        }
3390    }
3391
3392    fn pop_follow_up(&mut self) -> Vec<Message> {
3393        match self.follow_up_mode {
3394            QueueMode::All => self.follow_up.drain(..).collect(),
3395            QueueMode::OneAtATime => self.follow_up.pop_front().into_iter().collect(),
3396        }
3397    }
3398}
3399
3400impl Default for ExtensionInjectedQueue {
3401    fn default() -> Self {
3402        Self::new(QueueMode::OneAtATime, QueueMode::OneAtATime)
3403    }
3404}
3405
3406#[derive(Debug)]
3407enum PendingIdleAction {
3408    CustomMessage(Message),
3409    UserText(String),
3410}
3411
3412#[derive(Clone)]
3413struct AgentSessionHostActions {
3414    session: Arc<Mutex<Session>>,
3415    injected: Arc<StdMutex<ExtensionInjectedQueue>>,
3416    is_streaming: Arc<AtomicBool>,
3417    is_turn_active: Arc<AtomicBool>,
3418    pending_idle_actions: Arc<StdMutex<VecDeque<PendingIdleAction>>>,
3419    ai_completion: Arc<StdMutex<ExtensionAiCompletionHostState>>,
3420}
3421
3422#[derive(Clone)]
3423struct ExtensionAiCompletionHostState {
3424    provider: Arc<dyn Provider>,
3425    stream_options: StreamOptions,
3426    models: Vec<Value>,
3427}
3428
3429impl AgentSessionHostActions {
3430    fn enqueue(&self, deliver_as: Option<ExtensionDeliverAs>, message: Message) {
3431        let deliver_as = deliver_as.unwrap_or(ExtensionDeliverAs::Steer);
3432        let Ok(mut queue) = self.injected.lock() else {
3433            tracing::error!("injected queue mutex poisoned; dropping extension message");
3434            return;
3435        };
3436        match deliver_as {
3437            ExtensionDeliverAs::FollowUp => {
3438                queue.push_follow_up(message);
3439            }
3440            ExtensionDeliverAs::Steer | ExtensionDeliverAs::NextTurn => {
3441                queue.push_steering(message);
3442            }
3443        }
3444    }
3445
3446    async fn append_to_session(&self, message: Message) -> Result<()> {
3447        let cx = crate::agent_cx::AgentCx::for_current_or_request();
3448        let mut session = self
3449            .session
3450            .lock(cx.cx())
3451            .await
3452            .map_err(|e| Error::session(e.to_string()))?;
3453        session.append_model_message(message);
3454        Ok(())
3455    }
3456
3457    fn queue_pending_idle_action(&self, action: PendingIdleAction) {
3458        let Ok(mut actions) = self.pending_idle_actions.lock() else {
3459            tracing::error!("pending idle actions mutex poisoned; dropping idle action");
3460            return;
3461        };
3462        actions.push_back(action);
3463    }
3464}
3465
3466#[async_trait]
3467impl ExtensionHostActions for AgentSessionHostActions {
3468    async fn send_message(&self, message: ExtensionSendMessage) -> Result<()> {
3469        let custom_message = Message::Custom(CustomMessage {
3470            content: message.content,
3471            custom_type: message.custom_type,
3472            display: message.display,
3473            details: message.details,
3474            timestamp: Utc::now().timestamp_millis(),
3475        });
3476
3477        if matches!(message.deliver_as, Some(ExtensionDeliverAs::NextTurn)) {
3478            return self.append_to_session(custom_message).await;
3479        }
3480
3481        if self.is_streaming.load(Ordering::SeqCst) {
3482            self.enqueue(message.deliver_as, custom_message);
3483            return Ok(());
3484        }
3485
3486        if self.is_turn_active.load(Ordering::SeqCst) {
3487            return self.append_to_session(custom_message).await;
3488        }
3489
3490        if message.trigger_turn {
3491            self.queue_pending_idle_action(PendingIdleAction::CustomMessage(custom_message));
3492            return Ok(());
3493        }
3494
3495        self.append_to_session(custom_message).await
3496    }
3497
3498    async fn send_user_message(&self, message: ExtensionSendUserMessage) -> Result<()> {
3499        let text = message.text;
3500        let user_message = Message::User(UserMessage {
3501            content: UserContent::Text(text.clone()),
3502            timestamp: Utc::now().timestamp_millis(),
3503        });
3504
3505        if self.is_streaming.load(Ordering::SeqCst) {
3506            self.enqueue(message.deliver_as, user_message);
3507            return Ok(());
3508        }
3509
3510        if self.is_turn_active.load(Ordering::SeqCst) {
3511            return self.append_to_session(user_message).await;
3512        }
3513
3514        self.queue_pending_idle_action(PendingIdleAction::UserText(text));
3515        Ok(())
3516    }
3517
3518    async fn complete_ai(&self, request: ExtensionAiCompletionRequest) -> Result<Value> {
3519        let (provider, mut stream_options) = {
3520            let state = self.ai_completion.lock().map_err(|_| {
3521                Error::extension("extension completion host state mutex poisoned".to_string())
3522            })?;
3523            (Arc::clone(&state.provider), state.stream_options.clone())
3524        };
3525
3526        apply_pi_ai_completion_options(&request.options, &mut stream_options)?;
3527        let context = build_pi_ai_completion_context(&request)?;
3528        let provider_name = provider.name().to_string();
3529        let mut events = provider.stream(&context, &stream_options).await?;
3530        let mut streamed_text = String::new();
3531
3532        while let Some(event) = events.next().await {
3533            match event.map_err(|err| Error::provider(provider_name.clone(), err.to_string()))? {
3534                StreamEvent::TextDelta { delta, .. } => streamed_text.push_str(&delta),
3535                StreamEvent::TextEnd { content, .. } => {
3536                    streamed_text.push_str(&content);
3537                }
3538                StreamEvent::Done { message, .. } => {
3539                    if message.stop_reason == StopReason::Error {
3540                        return Err(Error::provider(
3541                            provider_name,
3542                            pi_ai_assistant_error_message(&message),
3543                        ));
3544                    }
3545                    return pi_ai_completion_response(&message, request.simple);
3546                }
3547                StreamEvent::Error { error, .. } => {
3548                    return Err(Error::provider(
3549                        provider_name,
3550                        pi_ai_assistant_error_message(&error),
3551                    ));
3552                }
3553                StreamEvent::Start { .. }
3554                | StreamEvent::TextStart { .. }
3555                | StreamEvent::ThinkingStart { .. }
3556                | StreamEvent::ThinkingDelta { .. }
3557                | StreamEvent::ThinkingEnd { .. }
3558                | StreamEvent::ToolCallStart { .. }
3559                | StreamEvent::ToolCallDelta { .. }
3560                | StreamEvent::ToolCallEnd { .. } => {}
3561            }
3562        }
3563
3564        let suffix = if streamed_text.is_empty() {
3565            String::new()
3566        } else {
3567            format!(" after streaming {} text bytes", streamed_text.len())
3568        };
3569        Err(Error::provider(
3570            provider_name,
3571            format!("pi-ai completion stream ended without Done event{suffix}"),
3572        ))
3573    }
3574
3575    async fn list_ai_models(&self) -> Result<Value> {
3576        let state = self.ai_completion.lock().map_err(|_| {
3577            Error::extension("extension completion host state mutex poisoned".to_string())
3578        })?;
3579        if state.models.is_empty() {
3580            return Ok(json!([{
3581                "id": state.provider.model_id(),
3582                "name": state.provider.model_id(),
3583                "api": state.provider.api(),
3584                "provider": state.provider.name(),
3585            }]));
3586        }
3587        Ok(Value::Array(state.models.clone()))
3588    }
3589}
3590
3591fn pi_ai_model_entry_value(entry: &ModelEntry) -> Value {
3592    json!({
3593        "id": entry.model.id,
3594        "name": entry.model.name,
3595        "api": entry.model.api,
3596        "provider": entry.model.provider,
3597        "baseUrl": entry.model.base_url,
3598        "reasoning": entry.model.reasoning,
3599        "input": entry.model.input,
3600        "cost": entry.model.cost,
3601        "contextWindow": entry.model.context_window,
3602        "maxTokens": entry.model.max_tokens,
3603        "authHeader": entry.auth_header,
3604        "hasCredentials": entry.api_key.is_some(),
3605    })
3606}
3607
3608fn pi_ai_model_registry_values(registry: &ModelRegistry) -> Vec<Value> {
3609    registry
3610        .models()
3611        .iter()
3612        .map(pi_ai_model_entry_value)
3613        .collect()
3614}
3615
3616fn apply_pi_ai_completion_options(
3617    options: &Value,
3618    stream_options: &mut StreamOptions,
3619) -> Result<()> {
3620    if let Some(value) = options
3621        .get("temperature")
3622        .or_else(|| options.get("temp"))
3623        .filter(|value| !value.is_null())
3624    {
3625        let temperature = serde_json::from_value::<f32>(value.clone()).map_err(|err| {
3626            Error::validation(format!(
3627                "pi-ai completion temperature must be numeric: {err}"
3628            ))
3629        })?;
3630        if !(0.0..=2.0).contains(&temperature) {
3631            return Err(Error::validation(
3632                "pi-ai completion temperature must be between 0 and 2".to_string(),
3633            ));
3634        }
3635        stream_options.temperature = Some(temperature);
3636    }
3637
3638    if let Some(value) = options
3639        .get("maxTokens")
3640        .or_else(|| options.get("max_tokens"))
3641        .filter(|value| !value.is_null())
3642    {
3643        let raw = value.as_u64().ok_or_else(|| {
3644            Error::validation("pi-ai completion maxTokens must be an unsigned integer".to_string())
3645        })?;
3646        let max_tokens = u32::try_from(raw).map_err(|_| {
3647            Error::validation("pi-ai completion maxTokens exceeds u32::MAX".to_string())
3648        })?;
3649        if max_tokens == 0 {
3650            return Err(Error::validation(
3651                "pi-ai completion maxTokens must be greater than zero".to_string(),
3652            ));
3653        }
3654        stream_options.max_tokens = Some(max_tokens);
3655    }
3656
3657    Ok(())
3658}
3659
3660fn build_pi_ai_completion_context(
3661    request: &ExtensionAiCompletionRequest,
3662) -> Result<Context<'static>> {
3663    let mut system_prompts = Vec::new();
3664    let mut messages = Vec::new();
3665    collect_pi_ai_context_messages(&request.context, &mut system_prompts, &mut messages)?;
3666
3667    if messages.is_empty() {
3668        return Err(Error::validation(
3669            "@mariozechner/pi-ai completion requires at least one user or assistant message"
3670                .to_string(),
3671        ));
3672    }
3673
3674    let system_prompt = system_prompts
3675        .into_iter()
3676        .filter(|text| !text.trim().is_empty())
3677        .collect::<Vec<_>>()
3678        .join("\n\n");
3679    Ok(Context::owned(
3680        if system_prompt.is_empty() {
3681            None
3682        } else {
3683            Some(system_prompt)
3684        },
3685        messages,
3686        Vec::new(),
3687    ))
3688}
3689
3690fn collect_pi_ai_context_messages(
3691    value: &Value,
3692    system_prompts: &mut Vec<String>,
3693    messages: &mut Vec<Message>,
3694) -> Result<()> {
3695    match value {
3696        Value::Null => {}
3697        Value::String(text) => push_pi_ai_user_message(text, messages),
3698        Value::Array(items) => {
3699            for item in items {
3700                push_pi_ai_message(item, system_prompts, messages)?;
3701            }
3702        }
3703        Value::Object(map) => {
3704            if let Some(system) = map
3705                .get("systemPrompt")
3706                .or_else(|| map.get("system_prompt"))
3707                .or_else(|| map.get("system"))
3708                .and_then(pi_ai_text_from_value)
3709            {
3710                system_prompts.push(system);
3711            }
3712
3713            if let Some(items) = map.get("messages").and_then(Value::as_array) {
3714                for item in items {
3715                    push_pi_ai_message(item, system_prompts, messages)?;
3716                }
3717            } else if let Some(prompt) = map
3718                .get("prompt")
3719                .or_else(|| map.get("input"))
3720                .or_else(|| map.get("message"))
3721                .and_then(pi_ai_text_from_value)
3722            {
3723                push_pi_ai_user_message(&prompt, messages);
3724            } else if map.contains_key("role") {
3725                push_pi_ai_message(value, system_prompts, messages)?;
3726            }
3727        }
3728        Value::Bool(_) | Value::Number(_) => push_pi_ai_user_message(&value.to_string(), messages),
3729    }
3730    Ok(())
3731}
3732
3733fn push_pi_ai_message(
3734    value: &Value,
3735    system_prompts: &mut Vec<String>,
3736    messages: &mut Vec<Message>,
3737) -> Result<()> {
3738    let Value::Object(map) = value else {
3739        if let Some(text) = pi_ai_text_from_value(value) {
3740            push_pi_ai_user_message(&text, messages);
3741        }
3742        return Ok(());
3743    };
3744
3745    let role = map
3746        .get("role")
3747        .and_then(Value::as_str)
3748        .unwrap_or("user")
3749        .trim()
3750        .to_ascii_lowercase();
3751    let content = map
3752        .get("content")
3753        .or_else(|| map.get("text"))
3754        .and_then(pi_ai_text_from_value)
3755        .unwrap_or_default();
3756
3757    match role.as_str() {
3758        "system" => {
3759            if !content.trim().is_empty() {
3760                system_prompts.push(content);
3761            }
3762        }
3763        "user" => push_pi_ai_user_message(&content, messages),
3764        "assistant" => push_pi_ai_assistant_message(&content, messages),
3765        other => {
3766            return Err(Error::validation(format!(
3767                "@mariozechner/pi-ai completion does not support {other:?} context messages"
3768            )));
3769        }
3770    }
3771    Ok(())
3772}
3773
3774fn push_pi_ai_user_message(text: &str, messages: &mut Vec<Message>) {
3775    messages.push(Message::User(UserMessage {
3776        content: UserContent::Text(text.to_string()),
3777        timestamp: Utc::now().timestamp_millis(),
3778    }));
3779}
3780
3781fn push_pi_ai_assistant_message(text: &str, messages: &mut Vec<Message>) {
3782    messages.push(Message::assistant(AssistantMessage {
3783        content: vec![ContentBlock::Text(TextContent::new(text.to_string()))],
3784        timestamp: Utc::now().timestamp_millis(),
3785        ..AssistantMessage::default()
3786    }));
3787}
3788
3789fn pi_ai_text_from_value(value: &Value) -> Option<String> {
3790    match value {
3791        Value::Null => None,
3792        Value::String(text) => Some(text.clone()),
3793        Value::Bool(_) | Value::Number(_) => Some(value.to_string()),
3794        Value::Array(items) => {
3795            let mut text = String::new();
3796            for item in items {
3797                if let Some(part) = pi_ai_text_from_value(item)
3798                    && !part.is_empty()
3799                {
3800                    text.push_str(&part);
3801                }
3802            }
3803            Some(text)
3804        }
3805        Value::Object(map) => map
3806            .get("text")
3807            .or_else(|| map.get("content"))
3808            .or_else(|| map.get("delta"))
3809            .and_then(pi_ai_text_from_value),
3810    }
3811}
3812
3813fn pi_ai_assistant_text(message: &AssistantMessage) -> String {
3814    let mut text = String::new();
3815    for block in &message.content {
3816        if let ContentBlock::Text(text_block) = block {
3817            text.push_str(&text_block.text);
3818        }
3819    }
3820    text
3821}
3822
3823fn pi_ai_assistant_error_message(message: &AssistantMessage) -> String {
3824    message
3825        .error_message
3826        .clone()
3827        .filter(|text| !text.trim().is_empty())
3828        .unwrap_or_else(|| {
3829            let text = pi_ai_assistant_text(message);
3830            if text.trim().is_empty() {
3831                "provider returned an error without a message".to_string()
3832            } else {
3833                text
3834            }
3835        })
3836}
3837
3838fn pi_ai_completion_response(message: &AssistantMessage, simple: bool) -> Result<Value> {
3839    let text = pi_ai_assistant_text(message);
3840    if simple {
3841        return Ok(Value::String(text));
3842    }
3843
3844    Ok(json!({
3845        "message": serde_json::to_value(message)?,
3846        "content": serde_json::to_value(&message.content)?,
3847        "text": text,
3848        "usage": serde_json::to_value(&message.usage)?,
3849        "model": message.model,
3850        "provider": message.provider,
3851        "api": message.api,
3852        "stopReason": message.stop_reason,
3853    }))
3854}
3855
3856#[cfg(test)]
3857mod message_queue_tests {
3858    use super::*;
3859
3860    fn user_message(text: &str) -> Message {
3861        Message::User(UserMessage {
3862            content: UserContent::Text(text.to_string()),
3863            timestamp: 0,
3864        })
3865    }
3866
3867    #[test]
3868    fn message_queue_one_at_a_time() {
3869        let mut queue = MessageQueue::new(QueueMode::OneAtATime, QueueMode::OneAtATime);
3870        queue.push_steering(user_message("a"));
3871        queue.push_steering(user_message("b"));
3872
3873        let first = queue.pop_steering();
3874        assert_eq!(first.len(), 1);
3875        assert!(matches!(
3876            first.first(),
3877            Some(Message::User(UserMessage { content, .. }))
3878                if matches!(content, UserContent::Text(text) if text == "a")
3879        ));
3880
3881        let second = queue.pop_steering();
3882        assert_eq!(second.len(), 1);
3883        assert!(matches!(
3884            second.first(),
3885            Some(Message::User(UserMessage { content, .. }))
3886                if matches!(content, UserContent::Text(text) if text == "b")
3887        ));
3888
3889        assert!(queue.pop_steering().is_empty());
3890    }
3891
3892    #[test]
3893    fn message_queue_all_mode() {
3894        let mut queue = MessageQueue::new(QueueMode::All, QueueMode::OneAtATime);
3895        queue.push_steering(user_message("a"));
3896        queue.push_steering(user_message("b"));
3897
3898        let drained = queue.pop_steering();
3899        assert_eq!(drained.len(), 2);
3900        assert!(queue.pop_steering().is_empty());
3901    }
3902
3903    #[test]
3904    fn message_queue_separates_kinds() {
3905        let mut queue = MessageQueue::new(QueueMode::OneAtATime, QueueMode::OneAtATime);
3906        queue.push_steering(user_message("steer"));
3907        queue.push_follow_up(user_message("follow"));
3908
3909        let steering = queue.pop_steering();
3910        assert_eq!(steering.len(), 1);
3911        assert_eq!(queue.pending_count(), 1);
3912
3913        let follow = queue.pop_follow_up();
3914        assert_eq!(follow.len(), 1);
3915        assert_eq!(queue.pending_count(), 0);
3916    }
3917
3918    #[test]
3919    fn message_queue_seq_increments() {
3920        let mut queue = MessageQueue::new(QueueMode::OneAtATime, QueueMode::OneAtATime);
3921        let first = queue.push_steering(user_message("a"));
3922        let second = queue.push_follow_up(user_message("b"));
3923        assert!(second > first);
3924    }
3925
3926    #[test]
3927    fn message_queue_seq_saturates_at_u64_max() {
3928        let mut queue = MessageQueue::new(QueueMode::OneAtATime, QueueMode::OneAtATime);
3929        queue.next_seq = u64::MAX;
3930
3931        let first = queue.push_steering(user_message("a"));
3932        let second = queue.push_follow_up(user_message("b"));
3933
3934        assert_eq!(first, u64::MAX);
3935        assert_eq!(second, u64::MAX);
3936        assert_eq!(queue.pending_count(), 2);
3937    }
3938
3939    #[test]
3940    fn message_queue_follow_up_all_mode_drains_entire_queue_in_order() {
3941        let mut queue = MessageQueue::new(QueueMode::OneAtATime, QueueMode::All);
3942        queue.push_follow_up(user_message("f1"));
3943        queue.push_follow_up(user_message("f2"));
3944
3945        let follow_up = queue.pop_follow_up();
3946        assert_eq!(follow_up.len(), 2);
3947        assert!(matches!(
3948            follow_up.first(),
3949            Some(Message::User(UserMessage { content, .. }))
3950                if matches!(content, UserContent::Text(text) if text == "f1")
3951        ));
3952        assert!(matches!(
3953            follow_up.get(1),
3954            Some(Message::User(UserMessage { content, .. }))
3955                if matches!(content, UserContent::Text(text) if text == "f2")
3956        ));
3957        assert!(queue.pop_follow_up().is_empty());
3958    }
3959}
3960
3961#[cfg(test)]
3962mod compatible_tool_parallelism_tests {
3963    use super::*;
3964
3965    #[test]
3966    fn compatible_tool_parallelism_preserves_historical_floor() {
3967        assert_eq!(resolve_compatible_tool_parallelism(None, 1), 8);
3968        assert_eq!(resolve_compatible_tool_parallelism(None, 8), 8);
3969    }
3970
3971    #[test]
3972    fn compatible_tool_parallelism_scales_on_many_core_hosts() {
3973        assert_eq!(resolve_compatible_tool_parallelism(None, 32), 32);
3974        assert_eq!(resolve_compatible_tool_parallelism(None, 64), 64);
3975        assert_eq!(resolve_compatible_tool_parallelism(None, 128), 64);
3976    }
3977
3978    #[test]
3979    fn compatible_tool_parallelism_accepts_bounded_override() {
3980        assert_eq!(resolve_compatible_tool_parallelism(Some("16"), 4), 16);
3981        assert_eq!(resolve_compatible_tool_parallelism(Some("512"), 64), 256);
3982        assert_eq!(resolve_compatible_tool_parallelism(Some("1"), 64), 1);
3983    }
3984
3985    #[test]
3986    fn compatible_tool_parallelism_ignores_invalid_override() {
3987        assert_eq!(
3988            resolve_compatible_tool_parallelism(Some("not-a-number"), 24),
3989            24
3990        );
3991        assert_eq!(resolve_compatible_tool_parallelism(Some("0"), 24), 24);
3992        assert_eq!(resolve_compatible_tool_parallelism(Some(" "), 24), 24);
3993    }
3994}
3995
3996#[cfg(test)]
3997mod tool_effect_batch_planning_tests {
3998    use super::*;
3999
4000    #[derive(Debug, Clone, Copy)]
4001    enum SyntheticOutcome {
4002        Success,
4003        Error,
4004    }
4005
4006    #[derive(Debug, Clone)]
4007    struct SyntheticToolCase {
4008        id: String,
4009        name: String,
4010        registered_effects: Option<ToolEffects>,
4011        outcome: SyntheticOutcome,
4012    }
4013
4014    #[derive(Debug, Clone, Copy)]
4015    enum BatchArrivalOrder {
4016        Forward,
4017        Reverse,
4018        RotateLeft(usize),
4019    }
4020
4021    #[derive(Debug, Clone, PartialEq, Eq)]
4022    struct TranscriptEntry {
4023        tool_call_id: String,
4024        tool_name: String,
4025        text: String,
4026        details: serde_json::Value,
4027        is_error: bool,
4028    }
4029
4030    fn batch_ranges(effects: &[ToolEffects]) -> Vec<(usize, usize)> {
4031        plan_tool_effect_batches(effects)
4032            .into_iter()
4033            .map(|batch| (batch.start, batch.end))
4034            .collect()
4035    }
4036
4037    fn batch_plan_json(effects: &[ToolEffects], parallelism_cap: usize) -> serde_json::Value {
4038        serde_json::to_value(tool_effect_batch_plan_evidence(effects, parallelism_cap))
4039            .expect("tool-effect batch evidence should serialize")
4040    }
4041
4042    fn synthetic_tool_case(
4043        index: usize,
4044        name: impl Into<String>,
4045        registered_effects: Option<ToolEffects>,
4046        outcome: SyntheticOutcome,
4047    ) -> SyntheticToolCase {
4048        SyntheticToolCase {
4049            id: format!("call-{index:03}"),
4050            name: name.into(),
4051            registered_effects,
4052            outcome,
4053        }
4054    }
4055
4056    fn effect_plan(cases: &[SyntheticToolCase]) -> Vec<ToolEffects> {
4057        cases
4058            .iter()
4059            .map(|case| case.registered_effects.unwrap_or_else(ToolEffects::write))
4060            .collect()
4061    }
4062
4063    fn make_tool_result(case: &SyntheticToolCase, index: usize) -> ToolResultMessage {
4064        let (content, is_error) = match case.outcome {
4065            SyntheticOutcome::Success => (format!("ok:{}", case.name), false),
4066            SyntheticOutcome::Error => (format!("error:{}", case.name), true),
4067        };
4068        ToolResultMessage {
4069            tool_call_id: case.id.clone(),
4070            tool_name: case.name.clone(),
4071            content: vec![ContentBlock::Text(TextContent::new(content))],
4072            details: Some(serde_json::json!({
4073                "ordinal": index,
4074                "tool": case.name,
4075                "status": if is_error { "error" } else { "ok" },
4076            })),
4077            is_error,
4078            timestamp: 42,
4079        }
4080    }
4081
4082    fn transcript_entry(message: &ToolResultMessage) -> TranscriptEntry {
4083        assert_eq!(message.content.len(), 1, "synthetic result content drifted");
4084        let text = message
4085            .content
4086            .first()
4087            .and_then(|block| match block {
4088                ContentBlock::Text(text) => Some(text.text.clone()),
4089                _ => None,
4090            })
4091            .unwrap_or_else(|| "non-text synthetic result".to_string());
4092        TranscriptEntry {
4093            tool_call_id: message.tool_call_id.clone(),
4094            tool_name: message.tool_name.clone(),
4095            text,
4096            details: message.details.clone().unwrap_or(serde_json::Value::Null),
4097            is_error: message.is_error,
4098        }
4099    }
4100
4101    fn sequential_oracle(cases: &[SyntheticToolCase]) -> Vec<TranscriptEntry> {
4102        cases
4103            .iter()
4104            .enumerate()
4105            .map(|(index, case)| transcript_entry(&make_tool_result(case, index)))
4106            .collect()
4107    }
4108
4109    fn reorder_batch(indices: &mut [usize], order: BatchArrivalOrder) {
4110        match order {
4111            BatchArrivalOrder::Forward => {}
4112            BatchArrivalOrder::Reverse => indices.reverse(),
4113            BatchArrivalOrder::RotateLeft(amount) => {
4114                if !indices.is_empty() {
4115                    indices.rotate_left(amount % indices.len());
4116                }
4117            }
4118        }
4119    }
4120
4121    fn scheduled_transcript(
4122        cases: &[SyntheticToolCase],
4123        order: BatchArrivalOrder,
4124    ) -> Vec<TranscriptEntry> {
4125        let effects = effect_plan(cases);
4126        let batches = plan_tool_effect_batches(&effects);
4127        let mut recorded_results: Vec<Option<ToolResultMessage>> = vec![None; cases.len()];
4128
4129        for batch in batches {
4130            let mut completion_order = (batch.start..batch.end).collect::<Vec<_>>();
4131            reorder_batch(&mut completion_order, order);
4132            let mut batch_results = completion_order
4133                .into_iter()
4134                .filter_map(|index| {
4135                    cases
4136                        .get(index)
4137                        .map(|case| (index, make_tool_result(case, index)))
4138                })
4139                .collect::<Vec<_>>();
4140            batch_results.sort_by_key(|(index, _)| *index);
4141            for (index, result) in batch_results {
4142                if let Some(slot) = recorded_results.get_mut(index) {
4143                    *slot = Some(result);
4144                }
4145            }
4146        }
4147
4148        assert!(
4149            recorded_results.iter().all(Option::is_some),
4150            "scheduled execution should record every result"
4151        );
4152        recorded_results
4153            .into_iter()
4154            .flatten()
4155            .map(|result| transcript_entry(&result))
4156            .collect()
4157    }
4158
4159    fn assert_barrier_effects_are_singleton_batches(cases: &[SyntheticToolCase]) {
4160        let effects = effect_plan(cases);
4161        for batch in plan_tool_effect_batches(&effects) {
4162            let batch_effects = effects
4163                .get(batch.start..batch.end)
4164                .unwrap_or(&[])
4165                .iter()
4166                .copied()
4167                .fold(ToolEffects::read(), ToolEffects::union);
4168            if !batch_effects.parallel_safe() {
4169                assert_eq!(
4170                    batch.end - batch.start,
4171                    1,
4172                    "barrier batch must serialize original index {}",
4173                    batch.start
4174                );
4175            }
4176        }
4177    }
4178
4179    #[test]
4180    fn read_and_network_effects_share_compatible_batch() {
4181        let ranges = batch_ranges(&[
4182            ToolEffects::read(),
4183            ToolEffects::network(),
4184            ToolEffects::read(),
4185        ]);
4186
4187        assert_eq!(ranges, vec![(0, 3)]);
4188    }
4189
4190    #[test]
4191    fn evidence_records_64_plus_compatible_batch_with_parallelism_cap() {
4192        let effects = (0..72)
4193            .map(|index| {
4194                if index % 3 == 0 {
4195                    ToolEffects::network()
4196                } else {
4197                    ToolEffects::read()
4198                }
4199            })
4200            .collect::<Vec<_>>();
4201
4202        assert_eq!(
4203            batch_plan_json(&effects, 64),
4204            serde_json::json!({
4205                "schema": TOOL_EFFECT_BATCH_PLAN_SCHEMA_V1,
4206                "toolCount": 72,
4207                "parallelismCap": 64,
4208                "batches": [
4209                    {
4210                        "start": 0,
4211                        "end": 72,
4212                        "len": 72,
4213                        "combinedEffects": ["read", "network"],
4214                        "parallelSafe": true
4215                    }
4216                ]
4217            })
4218        );
4219    }
4220
4221    #[test]
4222    fn write_effect_creates_deterministic_barrier() {
4223        let ranges = batch_ranges(&[
4224            ToolEffects::read(),
4225            ToolEffects::read(),
4226            ToolEffects::write(),
4227            ToolEffects::read(),
4228        ]);
4229
4230        assert_eq!(ranges, vec![(0, 2), (2, 3), (3, 4)]);
4231    }
4232
4233    #[test]
4234    fn append_and_process_effects_remain_serialized() {
4235        let ranges = batch_ranges(&[
4236            ToolEffects::append(),
4237            ToolEffects::append(),
4238            ToolEffects::process(),
4239            ToolEffects::read(),
4240        ]);
4241
4242        assert_eq!(ranges, vec![(0, 1), (1, 2), (2, 3), (3, 4)]);
4243    }
4244
4245    #[test]
4246    fn combined_process_write_effect_is_exclusive() {
4247        let ranges = batch_ranges(&[
4248            ToolEffects::read(),
4249            ToolEffects::process().union(ToolEffects::write()),
4250            ToolEffects::network(),
4251        ]);
4252
4253        assert_eq!(ranges, vec![(0, 1), (1, 2), (2, 3)]);
4254    }
4255
4256    #[test]
4257    fn evidence_records_barrier_reasons_for_mixed_effects() {
4258        let effects = [
4259            ToolEffects::read(),
4260            ToolEffects::network(),
4261            ToolEffects::write(),
4262            ToolEffects::append(),
4263            ToolEffects::process(),
4264            ToolEffects::read(),
4265            ToolEffects::process().union(ToolEffects::write()),
4266        ];
4267
4268        assert_eq!(
4269            batch_plan_json(&effects, 32),
4270            serde_json::json!({
4271                "schema": TOOL_EFFECT_BATCH_PLAN_SCHEMA_V1,
4272                "toolCount": 7,
4273                "parallelismCap": 32,
4274                "batches": [
4275                    {
4276                        "start": 0,
4277                        "end": 2,
4278                        "len": 2,
4279                        "combinedEffects": ["read", "network"],
4280                        "parallelSafe": true
4281                    },
4282                    {
4283                        "start": 2,
4284                        "end": 3,
4285                        "len": 1,
4286                        "combinedEffects": ["write"],
4287                        "parallelSafe": false,
4288                        "barrierReason": "write_barrier"
4289                    },
4290                    {
4291                        "start": 3,
4292                        "end": 4,
4293                        "len": 1,
4294                        "combinedEffects": ["append"],
4295                        "parallelSafe": false,
4296                        "barrierReason": "append_barrier"
4297                    },
4298                    {
4299                        "start": 4,
4300                        "end": 5,
4301                        "len": 1,
4302                        "combinedEffects": ["process"],
4303                        "parallelSafe": false,
4304                        "barrierReason": "process_barrier"
4305                    },
4306                    {
4307                        "start": 5,
4308                        "end": 6,
4309                        "len": 1,
4310                        "combinedEffects": ["read"],
4311                        "parallelSafe": true
4312                    },
4313                    {
4314                        "start": 6,
4315                        "end": 7,
4316                        "len": 1,
4317                        "combinedEffects": ["write", "process"],
4318                        "parallelSafe": false,
4319                        "barrierReason": "write_process_barrier"
4320                    }
4321                ]
4322            })
4323        );
4324    }
4325
4326    #[test]
4327    fn metamorphic_empty_tool_batch_matches_sequential_oracle() {
4328        let cases = Vec::new();
4329
4330        assert!(plan_tool_effect_batches(&effect_plan(&cases)).is_empty());
4331        assert_eq!(
4332            scheduled_transcript(&cases, BatchArrivalOrder::Forward),
4333            sequential_oracle(&cases)
4334        );
4335    }
4336
4337    #[test]
4338    fn metamorphic_mixed_effect_batches_match_sequential_oracle() {
4339        let cases = vec![
4340            synthetic_tool_case(
4341                0,
4342                "read",
4343                Some(ToolEffects::read()),
4344                SyntheticOutcome::Success,
4345            ),
4346            synthetic_tool_case(
4347                1,
4348                "network",
4349                Some(ToolEffects::network()),
4350                SyntheticOutcome::Success,
4351            ),
4352            synthetic_tool_case(
4353                2,
4354                "write",
4355                Some(ToolEffects::write()),
4356                SyntheticOutcome::Success,
4357            ),
4358            synthetic_tool_case(
4359                3,
4360                "read",
4361                Some(ToolEffects::read()),
4362                SyntheticOutcome::Success,
4363            ),
4364            synthetic_tool_case(
4365                4,
4366                "append",
4367                Some(ToolEffects::append()),
4368                SyntheticOutcome::Error,
4369            ),
4370            synthetic_tool_case(
4371                5,
4372                "network",
4373                Some(ToolEffects::network()),
4374                SyntheticOutcome::Success,
4375            ),
4376            synthetic_tool_case(
4377                6,
4378                "process",
4379                Some(ToolEffects::process()),
4380                SyntheticOutcome::Success,
4381            ),
4382            synthetic_tool_case(
4383                7,
4384                "read",
4385                Some(ToolEffects::read()),
4386                SyntheticOutcome::Error,
4387            ),
4388            synthetic_tool_case(8, "unknown", None, SyntheticOutcome::Success),
4389            synthetic_tool_case(
4390                9,
4391                "network",
4392                Some(ToolEffects::network()),
4393                SyntheticOutcome::Success,
4394            ),
4395        ];
4396
4397        assert_eq!(
4398            batch_ranges(&effect_plan(&cases)),
4399            vec![
4400                (0, 2),
4401                (2, 3),
4402                (3, 4),
4403                (4, 5),
4404                (5, 6),
4405                (6, 7),
4406                (7, 8),
4407                (8, 9),
4408                (9, 10)
4409            ]
4410        );
4411        let evidence = tool_effect_batch_plan_evidence(&effect_plan(&cases), 16);
4412        assert_eq!(evidence.schema, TOOL_EFFECT_BATCH_PLAN_SCHEMA_V1);
4413        assert_eq!(evidence.parallelism_cap, 16);
4414        assert_eq!(evidence.batches.len(), 9);
4415        assert!(evidence.batches.iter().any(|batch| {
4416            batch.barrier_reason == Some("append_barrier") && batch.combined_effects == ["append"]
4417        }));
4418        assert!(
4419            cases
4420                .iter()
4421                .any(|case| matches!(case.outcome, SyntheticOutcome::Error)),
4422            "mixed-effect fixture must include failure cases"
4423        );
4424        assert_barrier_effects_are_singleton_batches(&cases);
4425
4426        let oracle = sequential_oracle(&cases);
4427        assert_eq!(
4428            scheduled_transcript(&cases, BatchArrivalOrder::Reverse),
4429            oracle
4430        );
4431        assert_eq!(
4432            scheduled_transcript(&cases, BatchArrivalOrder::RotateLeft(1)),
4433            oracle
4434        );
4435    }
4436
4437    #[test]
4438    fn metamorphic_high_count_batches_keep_transcript_deterministic() {
4439        let cases = (0..96)
4440            .map(|index| match index % 12 {
4441                0 => synthetic_tool_case(
4442                    index,
4443                    format!("process-{index}"),
4444                    Some(ToolEffects::process()),
4445                    SyntheticOutcome::Success,
4446                ),
4447                5 => synthetic_tool_case(
4448                    index,
4449                    format!("append-{index}"),
4450                    Some(ToolEffects::append()),
4451                    SyntheticOutcome::Success,
4452                ),
4453                9 => synthetic_tool_case(
4454                    index,
4455                    format!("unknown-{index}"),
4456                    None,
4457                    SyntheticOutcome::Error,
4458                ),
4459                3 | 7 => synthetic_tool_case(
4460                    index,
4461                    format!("network-{index}"),
4462                    Some(ToolEffects::network()),
4463                    SyntheticOutcome::Success,
4464                ),
4465                _ => synthetic_tool_case(
4466                    index,
4467                    format!("read-{index}"),
4468                    Some(ToolEffects::read()),
4469                    SyntheticOutcome::Success,
4470                ),
4471            })
4472            .collect::<Vec<_>>();
4473
4474        assert_barrier_effects_are_singleton_batches(&cases);
4475        let oracle = sequential_oracle(&cases);
4476        assert_eq!(
4477            scheduled_transcript(&cases, BatchArrivalOrder::Forward),
4478            oracle
4479        );
4480        assert_eq!(
4481            scheduled_transcript(&cases, BatchArrivalOrder::Reverse),
4482            oracle
4483        );
4484        assert_eq!(
4485            scheduled_transcript(&cases, BatchArrivalOrder::RotateLeft(3)),
4486            oracle
4487        );
4488    }
4489}
4490
4491#[cfg(test)]
4492mod extensions_integration_tests {
4493    use super::*;
4494
4495    use crate::session::Session;
4496    use asupersync::runtime::RuntimeBuilder;
4497    use async_trait::async_trait;
4498    use futures::Stream;
4499    use serde_json::json;
4500    use std::path::Path;
4501    use std::pin::Pin;
4502    use std::sync::atomic::AtomicUsize;
4503    use std::time::Duration;
4504
4505    #[derive(Debug)]
4506    struct NoopProvider;
4507
4508    #[async_trait]
4509    #[allow(clippy::unnecessary_literal_bound)]
4510    impl Provider for NoopProvider {
4511        fn name(&self) -> &str {
4512            "test-provider"
4513        }
4514
4515        fn api(&self) -> &str {
4516            "test-api"
4517        }
4518
4519        fn model_id(&self) -> &str {
4520            "test-model"
4521        }
4522
4523        async fn stream(
4524            &self,
4525            _context: &Context<'_>,
4526            _options: &StreamOptions,
4527        ) -> crate::error::Result<
4528            Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
4529        > {
4530            Ok(Box::pin(futures::stream::empty()))
4531        }
4532    }
4533
4534    #[derive(Debug)]
4535    struct IdleCommandProvider;
4536
4537    #[async_trait]
4538    #[allow(clippy::unnecessary_literal_bound)]
4539    impl Provider for IdleCommandProvider {
4540        fn name(&self) -> &str {
4541            "test-provider"
4542        }
4543
4544        fn api(&self) -> &str {
4545            "test-api"
4546        }
4547
4548        fn model_id(&self) -> &str {
4549            "test-model"
4550        }
4551
4552        async fn stream(
4553            &self,
4554            _context: &Context<'_>,
4555            _options: &StreamOptions,
4556        ) -> crate::error::Result<
4557            Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
4558        > {
4559            let partial = AssistantMessage {
4560                content: Vec::new(),
4561                api: self.api().to_string(),
4562                provider: self.name().to_string(),
4563                model: self.model_id().to_string(),
4564                usage: Usage::default(),
4565                stop_reason: StopReason::Stop,
4566                error_message: None,
4567                timestamp: 0,
4568            };
4569            let done = AssistantMessage {
4570                content: vec![ContentBlock::Text(TextContent::new(
4571                    "resumed-response-0".to_string(),
4572                ))],
4573                api: self.api().to_string(),
4574                provider: self.name().to_string(),
4575                model: self.model_id().to_string(),
4576                usage: Usage::default(),
4577                stop_reason: StopReason::Stop,
4578                error_message: None,
4579                timestamp: 0,
4580            };
4581            Ok(Box::pin(futures::stream::iter(vec![
4582                Ok(StreamEvent::Start { partial }),
4583                Ok(StreamEvent::Done {
4584                    reason: StopReason::Stop,
4585                    message: done,
4586                }),
4587            ])))
4588        }
4589    }
4590
4591    #[derive(Debug)]
4592    struct CountingTool {
4593        calls: Arc<AtomicUsize>,
4594    }
4595
4596    #[async_trait]
4597    #[allow(clippy::unnecessary_literal_bound)]
4598    impl Tool for CountingTool {
4599        fn name(&self) -> &str {
4600            "count_tool"
4601        }
4602
4603        fn label(&self) -> &str {
4604            "count_tool"
4605        }
4606
4607        fn description(&self) -> &str {
4608            "counting tool"
4609        }
4610
4611        fn parameters(&self) -> serde_json::Value {
4612            json!({ "type": "object" })
4613        }
4614
4615        async fn execute(
4616            &self,
4617            _tool_call_id: &str,
4618            _input: serde_json::Value,
4619            _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
4620        ) -> Result<ToolOutput> {
4621            self.calls.fetch_add(1, Ordering::SeqCst);
4622            Ok(ToolOutput {
4623                content: vec![ContentBlock::Text(TextContent::new("ok"))],
4624                details: None,
4625                is_error: false,
4626            })
4627        }
4628    }
4629
4630    #[derive(Debug)]
4631    struct ToolUseProvider {
4632        stream_calls: AtomicUsize,
4633    }
4634
4635    impl ToolUseProvider {
4636        const fn new() -> Self {
4637            Self {
4638                stream_calls: AtomicUsize::new(0),
4639            }
4640        }
4641
4642        fn assistant_message(
4643            &self,
4644            stop_reason: StopReason,
4645            content: Vec<ContentBlock>,
4646        ) -> AssistantMessage {
4647            AssistantMessage {
4648                content,
4649                api: self.api().to_string(),
4650                provider: self.name().to_string(),
4651                model: self.model_id().to_string(),
4652                usage: Usage::default(),
4653                stop_reason,
4654                error_message: None,
4655                timestamp: 0,
4656            }
4657        }
4658    }
4659
4660    #[async_trait]
4661    #[allow(clippy::unnecessary_literal_bound)]
4662    impl Provider for ToolUseProvider {
4663        fn name(&self) -> &str {
4664            "test-provider"
4665        }
4666
4667        fn api(&self) -> &str {
4668            "test-api"
4669        }
4670
4671        fn model_id(&self) -> &str {
4672            "test-model"
4673        }
4674
4675        async fn stream(
4676            &self,
4677            _context: &Context<'_>,
4678            _options: &StreamOptions,
4679        ) -> crate::error::Result<
4680            Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
4681        > {
4682            let call_index = self.stream_calls.fetch_add(1, Ordering::SeqCst);
4683
4684            let partial = self.assistant_message(StopReason::Stop, Vec::new());
4685
4686            let (reason, message) = if call_index == 0 {
4687                let tool_calls = vec![
4688                    ToolCall {
4689                        id: "call-1".to_string(),
4690                        name: "count_tool".to_string(),
4691                        arguments: json!({}),
4692                        thought_signature: None,
4693                    },
4694                    ToolCall {
4695                        id: "call-2".to_string(),
4696                        name: "count_tool".to_string(),
4697                        arguments: json!({}),
4698                        thought_signature: None,
4699                    },
4700                ];
4701
4702                (
4703                    StopReason::ToolUse,
4704                    self.assistant_message(
4705                        StopReason::ToolUse,
4706                        tool_calls
4707                            .into_iter()
4708                            .map(ContentBlock::ToolCall)
4709                            .collect::<Vec<_>>(),
4710                    ),
4711                )
4712            } else {
4713                (
4714                    StopReason::Stop,
4715                    self.assistant_message(
4716                        StopReason::Stop,
4717                        vec![ContentBlock::Text(TextContent::new("done"))],
4718                    ),
4719                )
4720            };
4721
4722            let events = vec![
4723                Ok(StreamEvent::Start { partial }),
4724                Ok(StreamEvent::Done { reason, message }),
4725            ];
4726            Ok(Box::pin(futures::stream::iter(events)))
4727        }
4728    }
4729
4730    #[test]
4731    fn agent_session_enable_extensions_registers_extension_tools() {
4732        let runtime = RuntimeBuilder::current_thread()
4733            .build()
4734            .expect("runtime build");
4735
4736        runtime.block_on(async {
4737            let temp_dir = tempfile::tempdir().expect("tempdir");
4738            let entry_path = temp_dir.path().join("ext.mjs");
4739            std::fs::write(
4740                &entry_path,
4741                r#"
4742                export default function init(pi) {
4743                  pi.registerTool({
4744                    name: "hello_tool",
4745                    label: "hello_tool",
4746                    description: "test tool",
4747                    parameters: { type: "object", properties: { name: { type: "string" } } },
4748                    execute: async (_callId, input, _onUpdate, _abort, ctx) => {
4749                      const who = input && input.name ? String(input.name) : "world";
4750                      const cwd = ctx && ctx.cwd ? String(ctx.cwd) : "";
4751                      return {
4752                        content: [{ type: "text", text: `hello ${who}` }],
4753                        details: { from: "extension", cwd: cwd },
4754                        isError: false
4755                      };
4756                    }
4757                  });
4758                }
4759                "#,
4760            )
4761            .expect("write extension entry");
4762
4763            let provider = Arc::new(NoopProvider);
4764            let tools = ToolRegistry::new(&[], Path::new("."), None);
4765            let agent = Agent::new(provider, tools, AgentConfig::default());
4766            let session = Arc::new(Mutex::new(Session::in_memory()));
4767            let mut agent_session =
4768                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
4769
4770            agent_session
4771                .enable_extensions(&[], temp_dir.path(), None, &[entry_path])
4772                .await
4773                .expect("enable extensions");
4774
4775            let tool = agent_session
4776                .agent
4777                .tools
4778                .get("hello_tool")
4779                .expect("hello_tool registered");
4780
4781            let output = tool
4782                .execute("call-1", json!({ "name": "pi" }), None)
4783                .await
4784                .expect("execute tool");
4785
4786            assert!(!output.is_error);
4787            assert!(
4788                matches!(output.content.as_slice(), [ContentBlock::Text(_)]),
4789                "Expected single text content block, got {:?}",
4790                output.content
4791            );
4792            let [ContentBlock::Text(text)] = output.content.as_slice() else {
4793                return;
4794            };
4795            assert_eq!(text.text, "hello pi");
4796
4797            let details = output.details.expect("details present");
4798            assert_eq!(
4799                details.get("from").and_then(serde_json::Value::as_str),
4800                Some("extension")
4801            );
4802        });
4803    }
4804
4805    #[test]
4806    fn agent_session_enable_extensions_with_no_entries_clears_and_is_noop() {
4807        let runtime = RuntimeBuilder::current_thread()
4808            .build()
4809            .expect("runtime build");
4810
4811        runtime.block_on(async {
4812            let temp_dir = tempfile::tempdir().expect("tempdir");
4813            let provider = Arc::new(NoopProvider);
4814            let tools = ToolRegistry::new(&[], Path::new("."), None);
4815            let agent = Agent::new(provider, tools, AgentConfig::default());
4816            let session = Arc::new(Mutex::new(Session::in_memory()));
4817            let mut agent_session =
4818                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
4819
4820            // Manually inject a dummy extension state to verify clearing behavior.
4821            let dummy_manager = ExtensionManager::new();
4822            agent_session.extensions = Some(crate::extensions::ExtensionRegion::new(dummy_manager.clone()));
4823            agent_session.agent.extensions = Some(dummy_manager.clone());
4824            agent_session.extension_queue_modes = Some(Arc::new(std::sync::Mutex::new(ExtensionQueueModeState::new(
4825                QueueMode::OneAtATime,
4826                QueueMode::OneAtATime,
4827            ))));
4828            agent_session.extension_injected_queue = Some(Arc::new(std::sync::Mutex::new(ExtensionInjectedQueue::default())));
4829
4830            agent_session
4831                .enable_extensions(&[], temp_dir.path(), None, &[])
4832                .await
4833                .expect("empty extension list should be a no-op");
4834
4835            assert!(
4836                agent_session.extensions.is_none(),
4837                "no extension region should be created (and existing should be cleared) for an empty extension list"
4838            );
4839            assert!(
4840                agent_session.agent.extensions.is_none(),
4841                "agent should not report extensions active when nothing was requested"
4842            );
4843            assert!(
4844                agent_session.extension_queue_modes.is_none(),
4845                "empty extension list should clear queue mode mirrors"
4846            );
4847            assert!(
4848                agent_session.extension_injected_queue.is_none(),
4849                "empty extension list should clear injected extension queues"
4850            );
4851        });
4852    }
4853
4854    #[test]
4855    fn agent_session_enable_extensions_rejects_mixed_js_and_native_entries() {
4856        let runtime = RuntimeBuilder::current_thread()
4857            .build()
4858            .expect("runtime build");
4859
4860        runtime.block_on(async {
4861            let temp_dir = tempfile::tempdir().expect("tempdir");
4862            let js_entry = temp_dir.path().join("ext.mjs");
4863            let native_entry = temp_dir.path().join("ext.native.json");
4864            std::fs::write(
4865                &js_entry,
4866                r"
4867                export default function init(_pi) {}
4868                ",
4869            )
4870            .expect("write js extension entry");
4871            std::fs::write(&native_entry, "{}").expect("write native extension descriptor");
4872
4873            let provider = Arc::new(NoopProvider);
4874            let tools = ToolRegistry::new(&[], Path::new("."), None);
4875            let agent = Agent::new(provider, tools, AgentConfig::default());
4876            let session = Arc::new(Mutex::new(Session::in_memory()));
4877            let mut agent_session =
4878                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
4879
4880            let err = agent_session
4881                .enable_extensions(&[], temp_dir.path(), None, &[js_entry, native_entry])
4882                .await
4883                .expect_err("mixed extension runtimes should be rejected");
4884            let msg = err.to_string();
4885            assert!(
4886                msg.contains("Mixed extension runtimes are not supported"),
4887                "unexpected mixed-runtime error message: {msg}"
4888            );
4889        });
4890    }
4891
4892    #[test]
4893    fn extension_send_message_persists_custom_message_entry_when_idle() {
4894        let runtime = RuntimeBuilder::current_thread()
4895            .build()
4896            .expect("runtime build");
4897
4898        runtime.block_on(async {
4899            let temp_dir = tempfile::tempdir().expect("tempdir");
4900            let entry_path = temp_dir.path().join("ext.mjs");
4901            std::fs::write(
4902                &entry_path,
4903                r#"
4904                export default function init(pi) {
4905                  pi.registerTool({
4906                    name: "emit_message",
4907                    label: "emit_message",
4908                    description: "emit a custom message",
4909                    parameters: { type: "object" },
4910                    execute: async () => {
4911                      pi.sendMessage({
4912                        customType: "note",
4913                        content: "hello",
4914                        display: true,
4915                        details: { from: "test" }
4916                      }, {});
4917                      return { content: [{ type: "text", text: "ok" }], isError: false };
4918                    }
4919                  });
4920                }
4921                "#,
4922            )
4923            .expect("write extension entry");
4924
4925            let provider = Arc::new(NoopProvider);
4926            let tools = ToolRegistry::new(&[], Path::new("."), None);
4927            let agent = Agent::new(provider, tools, AgentConfig::default());
4928            let session = Arc::new(Mutex::new(Session::in_memory()));
4929            let mut agent_session = AgentSession::new(
4930                agent,
4931                Arc::clone(&session),
4932                false,
4933                ResolvedCompactionSettings::default(),
4934            );
4935
4936            agent_session
4937                .enable_extensions(&[], temp_dir.path(), None, &[entry_path])
4938                .await
4939                .expect("enable extensions");
4940
4941            let tool = agent_session
4942                .agent
4943                .tools
4944                .get("emit_message")
4945                .expect("emit_message registered");
4946
4947            let _ = tool
4948                .execute("call-1", json!({}), None)
4949                .await
4950                .expect("execute tool");
4951
4952            let cx = crate::agent_cx::AgentCx::for_request();
4953            let session_guard = session.lock(cx.cx()).await.expect("lock session");
4954            let messages = session_guard.to_messages_for_current_path();
4955
4956            assert!(
4957                messages.iter().any(|msg| {
4958                    matches!(
4959                        msg,
4960                        Message::Custom(CustomMessage { custom_type, content, display, details, .. })
4961                            if custom_type == "note"
4962                                && content == "hello"
4963                                && *display
4964                                && details
4965                                    .as_ref()
4966                                    .and_then(|v| v.get("from").and_then(Value::as_str))
4967                                    .is_some_and(|from| from.eq("test"))
4968                    )
4969                }),
4970                "expected custom message to be persisted, got {messages:?}"
4971            );
4972        });
4973    }
4974
4975    #[test]
4976    fn extension_send_message_persists_custom_message_entry_when_idle_after_await() {
4977        let runtime = RuntimeBuilder::current_thread()
4978            .build()
4979            .expect("runtime build");
4980
4981        runtime.block_on(async {
4982            let temp_dir = tempfile::tempdir().expect("tempdir");
4983            let entry_path = temp_dir.path().join("ext.mjs");
4984            std::fs::write(
4985                &entry_path,
4986                r#"
4987                export default function init(pi) {
4988                  pi.registerTool({
4989                    name: "emit_message",
4990                    label: "emit_message",
4991                    description: "emit a custom message",
4992                    parameters: { type: "object" },
4993                    execute: async () => {
4994                      await Promise.resolve();
4995                      pi.sendMessage({
4996                        customType: "note",
4997                        content: "hello-after-await",
4998                        display: true,
4999                        details: { from: "test" }
5000                      }, {});
5001                      return { content: [{ type: "text", text: "ok" }], isError: false };
5002                    }
5003                  });
5004                }
5005                "#,
5006            )
5007            .expect("write extension entry");
5008
5009            let provider = Arc::new(NoopProvider);
5010            let tools = ToolRegistry::new(&[], Path::new("."), None);
5011            let agent = Agent::new(provider, tools, AgentConfig::default());
5012            let session = Arc::new(Mutex::new(Session::in_memory()));
5013            let mut agent_session = AgentSession::new(
5014                agent,
5015                Arc::clone(&session),
5016                false,
5017                ResolvedCompactionSettings::default(),
5018            );
5019
5020            agent_session
5021                .enable_extensions(&[], temp_dir.path(), None, &[entry_path])
5022                .await
5023                .expect("enable extensions");
5024
5025            let tool = agent_session
5026                .agent
5027                .tools
5028                .get("emit_message")
5029                .expect("emit_message registered");
5030
5031            let _ = tool
5032                .execute("call-1", json!({}), None)
5033                .await
5034                .expect("execute tool");
5035
5036            let cx = crate::agent_cx::AgentCx::for_request();
5037            let session_guard = session.lock(cx.cx()).await.expect("lock session");
5038            let messages = session_guard.to_messages_for_current_path();
5039
5040            assert!(
5041                messages.iter().any(|msg| {
5042                    matches!(
5043                        msg,
5044                        Message::Custom(CustomMessage { custom_type, content, display, details, .. })
5045                            if custom_type == "note"
5046                                && content == "hello-after-await"
5047                                && *display
5048                                && details
5049                                    .as_ref()
5050                                    .and_then(|v| v.get("from").and_then(Value::as_str))
5051                                    .is_some_and(|from| from.eq("test"))
5052                    )
5053                }),
5054                "expected custom message to be persisted, got {messages:?}"
5055            );
5056        });
5057    }
5058
5059    #[test]
5060    fn agent_host_actions_send_message_inherits_cancelled_context_when_locked() {
5061        let runtime = RuntimeBuilder::current_thread()
5062            .build()
5063            .expect("runtime build");
5064
5065        runtime.block_on(async {
5066            let session = Arc::new(Mutex::new(Session::in_memory()));
5067            let actions = AgentSessionHostActions {
5068                session: Arc::clone(&session),
5069                injected: Arc::new(StdMutex::new(ExtensionInjectedQueue::default())),
5070                is_streaming: Arc::new(AtomicBool::new(false)),
5071                is_turn_active: Arc::new(AtomicBool::new(false)),
5072                pending_idle_actions: Arc::new(StdMutex::new(VecDeque::new())),
5073                ai_completion: Arc::new(StdMutex::new(ExtensionAiCompletionHostState {
5074                    provider: Arc::new(NoopProvider),
5075                    stream_options: StreamOptions::default(),
5076                    models: Vec::new(),
5077                })),
5078            };
5079
5080            let hold_cx = crate::agent_cx::AgentCx::for_request();
5081            let held_guard = session.lock(hold_cx.cx()).await.expect("lock session");
5082
5083            let ambient_cx = asupersync::Cx::for_testing();
5084            ambient_cx.set_cancel_requested(true);
5085            let _current = asupersync::Cx::set_current(Some(ambient_cx));
5086            let inner = asupersync::time::timeout(
5087                asupersync::time::wall_now(),
5088                Duration::from_millis(100),
5089                actions.send_message(ExtensionSendMessage {
5090                    extension_id: Some("ext".to_string()),
5091                    custom_type: "note".to_string(),
5092                    content: "blocked".to_string(),
5093                    display: false,
5094                    details: None,
5095                    deliver_as: Some(ExtensionDeliverAs::NextTurn),
5096                    trigger_turn: false,
5097                }),
5098            )
5099            .await;
5100            let outcome = inner.expect("cancelled helper should finish before timeout");
5101            let err = outcome.expect_err("session append should fail under inherited cancellation");
5102            assert!(
5103                err.to_string().contains("mutex lock cancelled"),
5104                "unexpected error: {err}"
5105            );
5106
5107            drop(held_guard);
5108
5109            let cx = crate::agent_cx::AgentCx::for_request();
5110            let guard = session.lock(cx.cx()).await.expect("lock session");
5111            assert!(
5112                guard.to_messages_for_current_path().is_empty(),
5113                "cancelled send_message should not append a message"
5114            );
5115        });
5116    }
5117
5118    #[derive(Debug, Default)]
5119    struct PiAiCapturedProviderContext {
5120        system_prompt: Option<String>,
5121        messages: Vec<Message>,
5122    }
5123
5124    #[derive(Debug)]
5125    struct PiAiCaptureProvider {
5126        calls: Arc<StdMutex<Vec<PiAiCapturedProviderContext>>>,
5127    }
5128
5129    #[async_trait]
5130    impl Provider for PiAiCaptureProvider {
5131        fn name(&self) -> &'static str {
5132            "capturing-provider"
5133        }
5134
5135        fn api(&self) -> &'static str {
5136            "test-api"
5137        }
5138
5139        fn model_id(&self) -> &'static str {
5140            "capture-model"
5141        }
5142
5143        async fn stream(
5144            &self,
5145            context: &Context<'_>,
5146            _options: &StreamOptions,
5147        ) -> crate::error::Result<
5148            std::pin::Pin<
5149                Box<dyn futures::Stream<Item = crate::error::Result<StreamEvent>> + Send>,
5150            >,
5151        > {
5152            self.calls
5153                .lock()
5154                .unwrap_or_else(std::sync::PoisonError::into_inner)
5155                .push(PiAiCapturedProviderContext {
5156                    system_prompt: context.system_prompt.as_ref().map(ToString::to_string),
5157                    messages: context.messages.iter().cloned().collect(),
5158                });
5159            let final_message = AssistantMessage {
5160                content: vec![ContentBlock::Text(TextContent::new("captured"))],
5161                api: "test-api".to_string(),
5162                provider: "capturing-provider".to_string(),
5163                model: "capture-model".to_string(),
5164                usage: Usage::default(),
5165                stop_reason: StopReason::Stop,
5166                error_message: None,
5167                timestamp: 0,
5168            };
5169            Ok(Box::pin(futures::stream::iter(vec![Ok(
5170                StreamEvent::Done {
5171                    reason: StopReason::Stop,
5172                    message: final_message,
5173                },
5174            )])))
5175        }
5176    }
5177
5178    #[test]
5179    fn agent_host_actions_complete_ai_streams_configured_provider() {
5180        let runtime = RuntimeBuilder::current_thread()
5181            .build()
5182            .expect("runtime build");
5183
5184        runtime.block_on(async {
5185            let session = Arc::new(Mutex::new(Session::in_memory()));
5186            let calls = Arc::new(StdMutex::new(Vec::new()));
5187            let provider = Arc::new(PiAiCaptureProvider {
5188                calls: Arc::clone(&calls),
5189            });
5190            let actions = AgentSessionHostActions {
5191                session,
5192                injected: Arc::new(StdMutex::new(ExtensionInjectedQueue::default())),
5193                is_streaming: Arc::new(AtomicBool::new(false)),
5194                is_turn_active: Arc::new(AtomicBool::new(false)),
5195                pending_idle_actions: Arc::new(StdMutex::new(VecDeque::new())),
5196                ai_completion: Arc::new(StdMutex::new(ExtensionAiCompletionHostState {
5197                    provider,
5198                    stream_options: StreamOptions::default(),
5199                    models: vec![json!({
5200                        "id": "capture-model",
5201                        "provider": "capturing-provider",
5202                        "api": "test-api",
5203                    })],
5204                })),
5205            };
5206
5207            let result = actions
5208                .complete_ai(ExtensionAiCompletionRequest {
5209                    model: json!({ "id": "capture-model" }),
5210                    context: json!({
5211                        "systemPrompt": "answer tersely",
5212                        "messages": [
5213                            { "role": "user", "content": "ping" }
5214                        ]
5215                    }),
5216                    options: json!({ "maxTokens": 16 }),
5217                    simple: false,
5218                })
5219                .await
5220                .expect("complete through provider");
5221
5222            assert_eq!(result["text"], json!("captured"));
5223            assert_eq!(result["provider"], json!("capturing-provider"));
5224            assert_eq!(result["api"], json!("test-api"));
5225
5226            let (captured_len, captured_system_prompt, captured_messages) = {
5227                let captured = match calls.lock() {
5228                    Ok(guard) => guard,
5229                    Err(poisoned) => poisoned.into_inner(),
5230                };
5231                (
5232                    captured.len(),
5233                    captured.first().and_then(|call| call.system_prompt.clone()),
5234                    captured
5235                        .first()
5236                        .map(|call| call.messages.clone())
5237                        .unwrap_or_default(),
5238                )
5239            };
5240            assert_eq!(captured_len, 1);
5241            assert_eq!(captured_system_prompt.as_deref(), Some("answer tersely"));
5242            assert_eq!(captured_messages.len(), 1);
5243            assert!(
5244                matches!(
5245                    captured_messages.first(),
5246                    Some(Message::User(UserMessage { content: UserContent::Text(text), .. }))
5247                        if text == "ping"
5248                ),
5249                "expected user message context, got {captured_messages:?}"
5250            );
5251
5252            let models = actions.list_ai_models().await.expect("list models");
5253            assert_eq!(models[0]["id"], json!("capture-model"));
5254        });
5255    }
5256
5257    #[test]
5258    fn extension_command_send_message_trigger_turn_runs_agent_turn_when_idle() {
5259        let runtime = RuntimeBuilder::current_thread()
5260            .build()
5261            .expect("runtime build");
5262
5263        runtime.block_on(async {
5264            let temp_dir = tempfile::tempdir().expect("tempdir");
5265            let entry_path = temp_dir.path().join("ext.mjs");
5266            std::fs::write(
5267                &entry_path,
5268                r#"
5269                export default function init(pi) {
5270                  pi.registerCommand("emit-now", {
5271                    description: "emit a custom message and trigger a turn",
5272                    handler: async () => {
5273                      await pi.events("sendMessage", {
5274                        message: {
5275                          customType: "note",
5276                          content: "turn-now",
5277                          display: true
5278                        },
5279                        options: {
5280                          deliverAs: "steer",
5281                          triggerTurn: true
5282                        }
5283                      });
5284                      return "queued";
5285                    }
5286                  });
5287                }
5288                "#,
5289            )
5290            .expect("write extension entry");
5291
5292            let provider = Arc::new(IdleCommandProvider);
5293            let tools = ToolRegistry::new(&[], Path::new("."), None);
5294            let agent = Agent::new(provider, tools, AgentConfig::default());
5295            let session = Arc::new(Mutex::new(Session::in_memory()));
5296            let mut agent_session = AgentSession::new(
5297                agent,
5298                Arc::clone(&session),
5299                false,
5300                ResolvedCompactionSettings::default(),
5301            );
5302
5303            agent_session
5304                .enable_extensions(&[], temp_dir.path(), None, &[entry_path])
5305                .await
5306                .expect("enable extensions");
5307
5308            let value = agent_session
5309                .execute_extension_command("emit-now", "", 5_000, |_| {})
5310                .await
5311                .expect("execute extension command");
5312            assert_eq!(value.as_str(), Some("queued"));
5313
5314            let cx = crate::agent_cx::AgentCx::for_request();
5315            let session_guard = session.lock(cx.cx()).await.expect("lock session");
5316            let messages = session_guard.to_messages_for_current_path();
5317
5318            assert!(
5319                messages.iter().any(|msg| {
5320                    matches!(
5321                        msg,
5322                        Message::Custom(CustomMessage { custom_type, content, .. })
5323                            if custom_type == "note" && content == "turn-now"
5324                    )
5325                }),
5326                "expected custom message prompt in session, got {messages:?}"
5327            );
5328            assert!(
5329                messages.iter().any(|msg| {
5330                    matches!(
5331                        msg,
5332                        Message::Assistant(assistant)
5333                            if assistant.content.iter().any(|block| matches!(
5334                                block,
5335                                ContentBlock::Text(TextContent { text, .. })
5336                                    if text.as_str().eq("resumed-response-0")
5337                            ))
5338                    )
5339                }),
5340                "expected assistant response after triggered turn, got {messages:?}"
5341            );
5342        });
5343    }
5344
5345    #[test]
5346    fn agent_extension_session_get_state_reports_agent_runtime_state() {
5347        let runtime = RuntimeBuilder::current_thread()
5348            .build()
5349            .expect("runtime build");
5350
5351        runtime.block_on(async {
5352            let mut session = Session::in_memory();
5353            session.set_model_header(
5354                Some("test-provider".to_string()),
5355                Some("test-model".to_string()),
5356                Some("high".to_string()),
5357            );
5358            session.append_message(crate::session::SessionMessage::User {
5359                content: UserContent::Text("hello".to_string()),
5360                timestamp: Some(1),
5361            });
5362            let session = Arc::new(Mutex::new(session));
5363
5364            let extension_session = AgentExtensionSession {
5365                handle: SessionHandle(Arc::clone(&session)),
5366                is_streaming: Arc::new(AtomicBool::new(true)),
5367                is_compacting: Arc::new(AtomicBool::new(true)),
5368                queue_modes: Arc::new(StdMutex::new(ExtensionQueueModeState::new(
5369                    QueueMode::All,
5370                    QueueMode::OneAtATime,
5371                ))),
5372                auto_compaction_enabled: true,
5373            };
5374
5375            let state = <AgentExtensionSession as crate::extensions::ExtensionSession>::get_state(
5376                &extension_session,
5377            )
5378            .await;
5379
5380            assert_eq!(state["model"]["provider"], "test-provider");
5381            assert_eq!(state["model"]["id"], "test-model");
5382            assert_eq!(state["thinkingLevel"], "high");
5383            assert_eq!(state["isStreaming"], true);
5384            assert_eq!(state["isCompacting"], true);
5385            assert_eq!(state["steeringMode"], "all");
5386            assert_eq!(state["followUpMode"], "one-at-a-time");
5387            assert_eq!(state["autoCompactionEnabled"], true);
5388            assert_eq!(state["messageCount"], 1);
5389        });
5390    }
5391
5392    #[test]
5393    fn agent_extension_session_get_state_uses_branch_local_model_and_thinking() {
5394        let runtime = RuntimeBuilder::current_thread()
5395            .build()
5396            .expect("runtime build");
5397
5398        runtime.block_on(async {
5399            let mut session = Session::in_memory();
5400            let root_id = session.append_message(crate::session::SessionMessage::User {
5401                content: UserContent::Text("root".to_string()),
5402                timestamp: Some(1),
5403            });
5404            session.append_model_change("openai".to_string(), "gpt-4o".to_string());
5405            let branch_a_thinking = session.append_thinking_level_change("low".to_string());
5406            session.set_model_header(
5407                Some("openai".to_string()),
5408                Some("gpt-4o".to_string()),
5409                Some("low".to_string()),
5410            );
5411
5412            assert!(session.create_branch_from(&root_id));
5413            session.append_model_change("anthropic".to_string(), "claude-sonnet-4-5".to_string());
5414            session.append_thinking_level_change("high".to_string());
5415            session.set_model_header(
5416                Some("anthropic".to_string()),
5417                Some("claude-sonnet-4-5".to_string()),
5418                Some("high".to_string()),
5419            );
5420
5421            assert!(session.navigate_to(&branch_a_thinking));
5422            let session = Arc::new(Mutex::new(session));
5423
5424            let extension_session = AgentExtensionSession {
5425                handle: SessionHandle(Arc::clone(&session)),
5426                is_streaming: Arc::new(AtomicBool::new(false)),
5427                is_compacting: Arc::new(AtomicBool::new(false)),
5428                queue_modes: Arc::new(StdMutex::new(ExtensionQueueModeState::new(
5429                    QueueMode::OneAtATime,
5430                    QueueMode::OneAtATime,
5431                ))),
5432                auto_compaction_enabled: false,
5433            };
5434
5435            let state = <AgentExtensionSession as crate::extensions::ExtensionSession>::get_state(
5436                &extension_session,
5437            )
5438            .await;
5439
5440            assert_eq!(state["model"]["provider"], "openai");
5441            assert_eq!(state["model"]["id"], "gpt-4o");
5442            assert_eq!(state["thinkingLevel"], "low");
5443        });
5444    }
5445
5446    #[test]
5447    fn agent_session_set_queue_modes_updates_extension_delivery_state() {
5448        let provider = Arc::new(NoopProvider);
5449        let tools = ToolRegistry::new(&[], Path::new("."), None);
5450        let agent = Agent::new(provider, tools, AgentConfig::default());
5451        let session = Arc::new(Mutex::new(Session::in_memory()));
5452        let mut agent_session =
5453            AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
5454
5455        let queue_modes = Arc::new(StdMutex::new(ExtensionQueueModeState::new(
5456            QueueMode::OneAtATime,
5457            QueueMode::OneAtATime,
5458        )));
5459        let injected_queue = Arc::new(StdMutex::new(ExtensionInjectedQueue::new(
5460            QueueMode::OneAtATime,
5461            QueueMode::OneAtATime,
5462        )));
5463        agent_session.extension_queue_modes = Some(Arc::clone(&queue_modes));
5464        agent_session.extension_injected_queue = Some(Arc::clone(&injected_queue));
5465
5466        agent_session.set_queue_modes(QueueMode::All, QueueMode::All);
5467
5468        assert_eq!(
5469            agent_session.agent.queue_modes(),
5470            (QueueMode::All, QueueMode::All)
5471        );
5472        let mirrored = queue_modes.lock().expect("lock queue mode mirror");
5473        assert_eq!(mirrored.steering_mode, QueueMode::All);
5474        assert_eq!(mirrored.follow_up_mode, QueueMode::All);
5475        drop(mirrored);
5476
5477        let queued_follow_up_len = {
5478            let mut queue = injected_queue.lock().expect("lock injected queue");
5479            queue.push_follow_up(Message::User(UserMessage {
5480                content: UserContent::Text("first".to_string()),
5481                timestamp: 0,
5482            }));
5483            queue.push_follow_up(Message::User(UserMessage {
5484                content: UserContent::Text("second".to_string()),
5485                timestamp: 0,
5486            }));
5487            queue.pop_follow_up().len()
5488        };
5489        assert_eq!(
5490            queued_follow_up_len, 2,
5491            "updated queue modes should apply to extension-injected follow-ups"
5492        );
5493    }
5494
5495    #[test]
5496    fn extension_command_send_user_message_runs_agent_turn_when_idle() {
5497        let runtime = RuntimeBuilder::current_thread()
5498            .build()
5499            .expect("runtime build");
5500
5501        runtime.block_on(async {
5502            let temp_dir = tempfile::tempdir().expect("tempdir");
5503            let entry_path = temp_dir.path().join("ext.mjs");
5504            std::fs::write(
5505                &entry_path,
5506                r#"
5507                export default function init(pi) {
5508                  pi.registerCommand("inject-user", {
5509                    description: "inject a user message",
5510                    handler: async () => {
5511                      await pi.events("sendUserMessage", {
5512                        text: "Please review the changes"
5513                      });
5514                      return "queued";
5515                    }
5516                  });
5517                }
5518                "#,
5519            )
5520            .expect("write extension entry");
5521
5522            let provider = Arc::new(IdleCommandProvider);
5523            let tools = ToolRegistry::new(&[], Path::new("."), None);
5524            let agent = Agent::new(provider, tools, AgentConfig::default());
5525            let session = Arc::new(Mutex::new(Session::in_memory()));
5526            let mut agent_session = AgentSession::new(
5527                agent,
5528                Arc::clone(&session),
5529                false,
5530                ResolvedCompactionSettings::default(),
5531            );
5532
5533            agent_session
5534                .enable_extensions(&[], temp_dir.path(), None, &[entry_path])
5535                .await
5536                .expect("enable extensions");
5537
5538            let value = agent_session
5539                .execute_extension_command("inject-user", "", 5_000, |_| {})
5540                .await
5541                .expect("execute extension command");
5542            assert_eq!(value.as_str(), Some("queued"));
5543
5544            let cx = crate::agent_cx::AgentCx::for_request();
5545            let session_guard = session.lock(cx.cx()).await.expect("lock session");
5546            let messages = session_guard.to_messages_for_current_path();
5547
5548            assert!(
5549                messages.iter().any(|msg| {
5550                    matches!(
5551                        msg,
5552                        Message::User(UserMessage {
5553                            content: UserContent::Text(text),
5554                            ..
5555                        }) if text == "Please review the changes"
5556                    )
5557                }),
5558                "expected injected user message in session, got {messages:?}"
5559            );
5560            assert!(
5561                messages.iter().any(|msg| {
5562                    matches!(
5563                        msg,
5564                        Message::Assistant(assistant)
5565                            if assistant.content.iter().any(|block| matches!(
5566                                block,
5567                                ContentBlock::Text(TextContent { text, .. })
5568                                    if text.as_str().eq("resumed-response-0")
5569                            ))
5570                    )
5571                }),
5572                "expected assistant response after injected user turn, got {messages:?}"
5573            );
5574        });
5575    }
5576
5577    #[test]
5578    fn send_user_message_steer_skips_remaining_tools() {
5579        let runtime = RuntimeBuilder::current_thread()
5580            .build()
5581            .expect("runtime build");
5582
5583        runtime.block_on(async {
5584            let temp_dir = tempfile::tempdir().expect("tempdir");
5585            let entry_path = temp_dir.path().join("ext.mjs");
5586            std::fs::write(
5587                &entry_path,
5588                r#"
5589                export default function init(pi) {
5590                  let sent = false;
5591                  pi.on("tool_call", async (event) => {
5592                    if (sent) return {};
5593                    if (Object.is(event && event.toolName, "count_tool")) {
5594                      sent = true;
5595                      await pi.events("sendUserMessage", {
5596                        text: "steer-now",
5597                        options: { deliverAs: "steer" }
5598                      });
5599                    }
5600                    return {};
5601                  });
5602                }
5603                "#,
5604            )
5605            .expect("write extension entry");
5606
5607            let provider = Arc::new(ToolUseProvider::new());
5608            let calls = Arc::new(AtomicUsize::new(0));
5609            let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
5610                calls: Arc::clone(&calls),
5611            })]);
5612            let agent = Agent::new(provider, tools, AgentConfig::default());
5613            let session = Arc::new(Mutex::new(Session::in_memory()));
5614            let mut agent_session =
5615                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
5616
5617            agent_session
5618                .enable_extensions(&[], temp_dir.path(), None, &[entry_path])
5619                .await
5620                .expect("enable extensions");
5621
5622            let _ = agent_session
5623                .run_text("go".to_string(), |_| {})
5624                .await
5625                .expect("run_text");
5626
5627            // A steer message should short-circuit remaining tool dispatch.
5628            assert_eq!(calls.load(Ordering::SeqCst), 1);
5629        });
5630    }
5631
5632    #[test]
5633    fn send_user_message_follow_up_does_not_skip_tools() {
5634        let runtime = RuntimeBuilder::current_thread()
5635            .build()
5636            .expect("runtime build");
5637
5638        runtime.block_on(async {
5639            let temp_dir = tempfile::tempdir().expect("tempdir");
5640            let entry_path = temp_dir.path().join("ext.mjs");
5641            std::fs::write(
5642                &entry_path,
5643                r#"
5644                export default function init(pi) {
5645                  let sent = false;
5646                  pi.on("tool_call", async (event) => {
5647                    if (sent) return {};
5648                    if (Object.is(event && event.toolName, "count_tool")) {
5649                      sent = true;
5650                      await pi.events("sendUserMessage", {
5651                        text: "follow-up",
5652                        options: { deliverAs: "followUp" }
5653                      });
5654                    }
5655                    return {};
5656                  });
5657                }
5658                "#,
5659            )
5660            .expect("write extension entry");
5661
5662            let provider = Arc::new(ToolUseProvider::new());
5663            let calls = Arc::new(AtomicUsize::new(0));
5664            let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
5665                calls: Arc::clone(&calls),
5666            })]);
5667            let agent = Agent::new(provider, tools, AgentConfig::default());
5668            let session = Arc::new(Mutex::new(Session::in_memory()));
5669            let mut agent_session =
5670                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
5671
5672            agent_session
5673                .enable_extensions(&[], temp_dir.path(), None, &[entry_path])
5674                .await
5675                .expect("enable extensions");
5676
5677            let _ = agent_session
5678                .run_text("go".to_string(), |_| {})
5679                .await
5680                .expect("run_text");
5681
5682            assert_eq!(calls.load(Ordering::SeqCst), 2);
5683        });
5684    }
5685
5686    fn test_turn_latency() -> SharedTurnLatencyAccumulator {
5687        Arc::new(StdMutex::new(TurnLatencyAccumulator::started()))
5688    }
5689
5690    #[test]
5691    fn latency_breakdown_reports_component_tail_percentiles() {
5692        let breakdown =
5693            TurnLatencyBreakdown::from_component_samples(250, &[10, 30, 20], &[40, 5], &[2], &[]);
5694
5695        assert_eq!(breakdown.schema, TURN_LATENCY_BREAKDOWN_SCHEMA_V1);
5696        assert_eq!(breakdown.provider_streaming.duration_ms, 60);
5697        assert_eq!(breakdown.provider_streaming.samples, 3);
5698        assert_eq!(breakdown.provider_streaming.tail_percentiles.p50_ms, 20);
5699        assert_eq!(breakdown.provider_streaming.tail_percentiles.p95_ms, 30);
5700        assert_eq!(breakdown.provider_streaming.tail_percentiles.p99_ms, 30);
5701        assert_eq!(breakdown.provider_streaming.tail_percentiles.p999_ms, 30);
5702        assert_eq!(breakdown.local_tools.duration_ms, 45);
5703        assert_eq!(breakdown.extension_hostcalls.duration_ms, 2);
5704        assert_eq!(breakdown.persistence.duration_ms, 0);
5705        assert_eq!(breakdown.dominant_component, "provider_streaming");
5706    }
5707
5708    #[test]
5709    fn latency_breakdown_serializes_without_provider_secrets() {
5710        let breakdown =
5711            TurnLatencyBreakdown::from_component_samples(125, &[100], &[20], &[5], &[0]);
5712        let serialized = serde_json::to_string(&breakdown).expect("serialize latency breakdown");
5713
5714        assert!(serialized.contains(TURN_LATENCY_BREAKDOWN_SCHEMA_V1));
5715        assert!(serialized.contains("providerStreaming"));
5716        assert!(serialized.contains("localTools"));
5717        assert!(serialized.contains("extensionHostcalls"));
5718        assert!(serialized.contains("persistence"));
5719        assert!(!serialized.contains("api_key"));
5720        assert!(!serialized.contains("authorization"));
5721        assert!(!serialized.contains("bearer"));
5722        assert!(!serialized.contains("sk-"));
5723    }
5724
5725    #[test]
5726    fn tool_call_hook_can_block_tool_execution() {
5727        let runtime = RuntimeBuilder::current_thread()
5728            .build()
5729            .expect("runtime build");
5730
5731        runtime.block_on(async {
5732            let temp_dir = tempfile::tempdir().expect("tempdir");
5733            let entry_path = temp_dir.path().join("ext.mjs");
5734            std::fs::write(
5735                &entry_path,
5736                r#"
5737                export default function init(pi) {
5738                  pi.on("tool_call", async (event) => {
5739                    if (Object.is(event && event.toolName, "count_tool")) {
5740                      return { block: true, reason: "blocked in test" };
5741                    }
5742                    return {};
5743                  });
5744                }
5745                "#,
5746            )
5747            .expect("write extension entry");
5748
5749            let provider = Arc::new(NoopProvider);
5750            let calls = Arc::new(AtomicUsize::new(0));
5751            let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
5752                calls: Arc::clone(&calls),
5753            })]);
5754            let agent = Agent::new(provider, tools, AgentConfig::default());
5755            let session = Arc::new(Mutex::new(Session::in_memory()));
5756            let mut agent_session =
5757                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
5758
5759            agent_session
5760                .enable_extensions(&[], temp_dir.path(), None, &[entry_path])
5761                .await
5762                .expect("enable extensions");
5763
5764            let tool_call = ToolCall {
5765                id: "call-1".to_string(),
5766                name: "count_tool".to_string(),
5767                arguments: json!({}),
5768                thought_signature: None,
5769            };
5770
5771            let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
5772            let (output, is_error) = agent_session
5773                .agent
5774                .execute_tool(tool_call, on_event, test_turn_latency())
5775                .await;
5776
5777            assert!(is_error);
5778            assert!(output.is_error);
5779            assert_eq!(calls.load(Ordering::SeqCst), 0);
5780
5781            assert_eq!(output.details, None);
5782            assert!(
5783                matches!(output.content.as_slice(), [ContentBlock::Text(_)]),
5784                "Expected text output, got {:?}",
5785                output.content
5786            );
5787            if let [ContentBlock::Text(text)] = output.content.as_slice() {
5788                assert_eq!(text.text, "Tool execution blocked: blocked in test");
5789            }
5790        });
5791    }
5792
5793    #[test]
5794    fn tool_call_hook_errors_fail_open() {
5795        let runtime = RuntimeBuilder::current_thread()
5796            .build()
5797            .expect("runtime build");
5798
5799        runtime.block_on(async {
5800            let temp_dir = tempfile::tempdir().expect("tempdir");
5801            let entry_path = temp_dir.path().join("ext.mjs");
5802            std::fs::write(
5803                &entry_path,
5804                r#"
5805                export default function init(pi) {
5806                  pi.on("tool_call", async (_event) => {
5807                    throw new Error("boom");
5808                  });
5809                }
5810                "#,
5811            )
5812            .expect("write extension entry");
5813
5814            let provider = Arc::new(NoopProvider);
5815            let calls = Arc::new(AtomicUsize::new(0));
5816            let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
5817                calls: Arc::clone(&calls),
5818            })]);
5819            let agent = Agent::new(provider, tools, AgentConfig::default());
5820            let session = Arc::new(Mutex::new(Session::in_memory()));
5821            let mut agent_session =
5822                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
5823
5824            agent_session
5825                .enable_extensions(&[], temp_dir.path(), None, &[entry_path])
5826                .await
5827                .expect("enable extensions");
5828
5829            let tool_call = ToolCall {
5830                id: "call-1".to_string(),
5831                name: "count_tool".to_string(),
5832                arguments: json!({}),
5833                thought_signature: None,
5834            };
5835
5836            let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
5837            let (output, is_error) = agent_session
5838                .agent
5839                .execute_tool(tool_call, on_event, test_turn_latency())
5840                .await;
5841
5842            assert!(!is_error);
5843            assert!(!output.is_error);
5844            assert_eq!(calls.load(Ordering::SeqCst), 1);
5845        });
5846    }
5847
5848    #[test]
5849    fn tool_call_hook_errors_fail_closed_when_configured() {
5850        let runtime = RuntimeBuilder::current_thread()
5851            .build()
5852            .expect("runtime build");
5853
5854        runtime.block_on(async {
5855            let temp_dir = tempfile::tempdir().expect("tempdir");
5856            let entry_path = temp_dir.path().join("ext.mjs");
5857            std::fs::write(
5858                &entry_path,
5859                r#"
5860                export default function init(pi) {
5861                  pi.on("tool_call", async (_event) => {
5862                    throw new Error("boom");
5863                  });
5864                }
5865                "#,
5866            )
5867            .expect("write extension entry");
5868
5869            let provider = Arc::new(NoopProvider);
5870            let calls = Arc::new(AtomicUsize::new(0));
5871            let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
5872                calls: Arc::clone(&calls),
5873            })]);
5874            let agent = Agent::new(
5875                provider,
5876                tools,
5877                AgentConfig {
5878                    fail_closed_hooks: true,
5879                    ..AgentConfig::default()
5880                },
5881            );
5882            let session = Arc::new(Mutex::new(Session::in_memory()));
5883            let mut agent_session =
5884                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
5885
5886            agent_session
5887                .enable_extensions(&[], temp_dir.path(), None, &[entry_path])
5888                .await
5889                .expect("enable extensions");
5890
5891            let tool_call = ToolCall {
5892                id: "call-1".to_string(),
5893                name: "count_tool".to_string(),
5894                arguments: json!({}),
5895                thought_signature: None,
5896            };
5897
5898            let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
5899            let (output, is_error) = agent_session
5900                .agent
5901                .execute_tool(tool_call, on_event, test_turn_latency())
5902                .await;
5903
5904            assert!(is_error);
5905            assert!(output.is_error);
5906            assert_eq!(calls.load(Ordering::SeqCst), 0);
5907            assert!(
5908                matches!(output.content.as_slice(), [ContentBlock::Text(_)]),
5909                "Expected text output, got {:?}",
5910                output.content
5911            );
5912            let [ContentBlock::Text(text)] = output.content.as_slice() else {
5913                return;
5914            };
5915            assert_eq!(text.text, "Tool execution blocked: extension hook failed");
5916        });
5917    }
5918
5919    #[test]
5920    fn tool_call_hook_absent_allows_tool_execution() {
5921        let runtime = RuntimeBuilder::current_thread()
5922            .build()
5923            .expect("runtime build");
5924
5925        runtime.block_on(async {
5926            let temp_dir = tempfile::tempdir().expect("tempdir");
5927            let entry_path = temp_dir.path().join("ext.mjs");
5928            std::fs::write(
5929                &entry_path,
5930                r"
5931                export default function init(_pi) {}
5932                ",
5933            )
5934            .expect("write extension entry");
5935
5936            let provider = Arc::new(NoopProvider);
5937            let calls = Arc::new(AtomicUsize::new(0));
5938            let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
5939                calls: Arc::clone(&calls),
5940            })]);
5941            let agent = Agent::new(provider, tools, AgentConfig::default());
5942            let session = Arc::new(Mutex::new(Session::in_memory()));
5943            let mut agent_session =
5944                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
5945
5946            agent_session
5947                .enable_extensions(&[], temp_dir.path(), None, &[entry_path])
5948                .await
5949                .expect("enable extensions");
5950
5951            let tool_call = ToolCall {
5952                id: "call-1".to_string(),
5953                name: "count_tool".to_string(),
5954                arguments: json!({}),
5955                thought_signature: None,
5956            };
5957
5958            let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
5959            let (output, is_error) = agent_session
5960                .agent
5961                .execute_tool(tool_call, on_event, test_turn_latency())
5962                .await;
5963
5964            assert!(!is_error);
5965            assert!(!output.is_error);
5966            assert_eq!(calls.load(Ordering::SeqCst), 1);
5967        });
5968    }
5969
5970    #[test]
5971    fn tool_approval_allow_executes_tool() {
5972        let runtime = RuntimeBuilder::current_thread()
5973            .build()
5974            .expect("runtime build");
5975
5976        runtime.block_on(async {
5977            let provider = Arc::new(NoopProvider);
5978            let calls = Arc::new(AtomicUsize::new(0));
5979            let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
5980                calls: Arc::clone(&calls),
5981            })]);
5982            let approval_calls = Arc::new(AtomicUsize::new(0));
5983            let approval_counter = Arc::clone(&approval_calls);
5984            let agent = Agent::new(
5985                provider,
5986                tools,
5987                AgentConfig {
5988                    tool_approval: Some(Arc::new(move |request| {
5989                        assert_eq!(request.tool_call_id, "call-1");
5990                        assert_eq!(request.tool_name, "count_tool");
5991                        approval_counter.fetch_add(1, Ordering::SeqCst);
5992                        Box::pin(async { ToolApprovalDecision::Allow })
5993                    })),
5994                    ..AgentConfig::default()
5995                },
5996            );
5997            let session = Arc::new(Mutex::new(Session::in_memory()));
5998            let agent_session =
5999                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
6000
6001            let tool_call = ToolCall {
6002                id: "call-1".to_string(),
6003                name: "count_tool".to_string(),
6004                arguments: json!({}),
6005                thought_signature: None,
6006            };
6007
6008            let events = Arc::new(std::sync::Mutex::new(Vec::new()));
6009            let events_for_handler = Arc::clone(&events);
6010            let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(move |event| {
6011                if let Ok(mut guard) = events_for_handler.lock() {
6012                    guard.push(event);
6013                }
6014            });
6015            let (output, is_error) = agent_session
6016                .agent
6017                .execute_tool(tool_call, on_event, test_turn_latency())
6018                .await;
6019
6020            assert!(!is_error);
6021            assert!(!output.is_error);
6022            assert_eq!(approval_calls.load(Ordering::SeqCst), 1);
6023            assert_eq!(calls.load(Ordering::SeqCst), 1);
6024            let saw_approval_update = events.lock().is_ok_and(|guard| {
6025                guard.iter().any(|event| {
6026                    matches!(
6027                        event,
6028                        AgentEvent::ToolExecutionUpdate {
6029                            partial_result,
6030                            ..
6031                        } if partial_result.details.as_ref().is_some_and(|details| {
6032                            details["schema"] == TOOL_APPROVAL_STATUS_SCHEMA_V1
6033                                && details["status"] == "approved"
6034                        })
6035                    )
6036                })
6037            });
6038            assert!(saw_approval_update);
6039        });
6040    }
6041
6042    #[test]
6043    fn tool_approval_deny_blocks_tool_execution() {
6044        let runtime = RuntimeBuilder::current_thread()
6045            .build()
6046            .expect("runtime build");
6047
6048        runtime.block_on(async {
6049            let provider = Arc::new(NoopProvider);
6050            let calls = Arc::new(AtomicUsize::new(0));
6051            let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
6052                calls: Arc::clone(&calls),
6053            })]);
6054            let agent = Agent::new(
6055                provider,
6056                tools,
6057                AgentConfig {
6058                    tool_approval: Some(Arc::new(|request| {
6059                        assert_eq!(request.tool_name, "count_tool");
6060                        Box::pin(async { ToolApprovalDecision::deny("denied by approval test") })
6061                    })),
6062                    ..AgentConfig::default()
6063                },
6064            );
6065            let session = Arc::new(Mutex::new(Session::in_memory()));
6066            let agent_session =
6067                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
6068
6069            let tool_call = ToolCall {
6070                id: "call-1".to_string(),
6071                name: "count_tool".to_string(),
6072                arguments: json!({}),
6073                thought_signature: None,
6074            };
6075
6076            let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
6077            let (output, is_error) = agent_session
6078                .agent
6079                .execute_tool(tool_call, on_event, test_turn_latency())
6080                .await;
6081
6082            assert!(is_error);
6083            assert!(output.is_error);
6084            assert_eq!(calls.load(Ordering::SeqCst), 0);
6085            assert_eq!(
6086                output.details.as_ref().unwrap()["schema"],
6087                TOOL_APPROVAL_DENIED_SCHEMA_V1
6088            );
6089            assert!(
6090                matches!(output.content.as_slice(), [ContentBlock::Text(text)] if text
6091                    .text
6092                    .contains("denied by approval test"))
6093            );
6094        });
6095    }
6096
6097    #[test]
6098    fn tool_call_hook_returns_empty_allows_tool_execution() {
6099        let runtime = RuntimeBuilder::current_thread()
6100            .build()
6101            .expect("runtime build");
6102
6103        runtime.block_on(async {
6104            let temp_dir = tempfile::tempdir().expect("tempdir");
6105            let entry_path = temp_dir.path().join("ext.mjs");
6106            std::fs::write(
6107                &entry_path,
6108                r#"
6109                export default function init(pi) {
6110                  pi.on("tool_call", async (_event) => ({}));
6111                }
6112                "#,
6113            )
6114            .expect("write extension entry");
6115
6116            let provider = Arc::new(NoopProvider);
6117            let calls = Arc::new(AtomicUsize::new(0));
6118            let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
6119                calls: Arc::clone(&calls),
6120            })]);
6121            let agent = Agent::new(provider, tools, AgentConfig::default());
6122            let session = Arc::new(Mutex::new(Session::in_memory()));
6123            let mut agent_session =
6124                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
6125
6126            agent_session
6127                .enable_extensions(&[], temp_dir.path(), None, &[entry_path])
6128                .await
6129                .expect("enable extensions");
6130
6131            let tool_call = ToolCall {
6132                id: "call-1".to_string(),
6133                name: "count_tool".to_string(),
6134                arguments: json!({}),
6135                thought_signature: None,
6136            };
6137
6138            let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
6139            let (output, is_error) = agent_session
6140                .agent
6141                .execute_tool(tool_call, on_event, test_turn_latency())
6142                .await;
6143
6144            assert!(!is_error);
6145            assert!(!output.is_error);
6146            assert_eq!(calls.load(Ordering::SeqCst), 1);
6147        });
6148    }
6149
6150    #[test]
6151    fn tool_call_hook_can_block_bash_tool_execution() {
6152        let runtime = RuntimeBuilder::current_thread()
6153            .build()
6154            .expect("runtime build");
6155
6156        runtime.block_on(async {
6157            let temp_dir = tempfile::tempdir().expect("tempdir");
6158            let entry_path = temp_dir.path().join("ext.mjs");
6159            std::fs::write(
6160                &entry_path,
6161                r#"
6162                export default function init(pi) {
6163                  pi.on("tool_call", async (event) => {
6164                    const name = event && event.toolName ? String(event.toolName) : "";
6165                    if (name === "bash") return { block: true, reason: "blocked bash in test" };
6166                    return {};
6167                  });
6168                }
6169                "#,
6170            )
6171            .expect("write extension entry");
6172
6173            let provider = Arc::new(NoopProvider);
6174            let tools = ToolRegistry::new(&["bash"], temp_dir.path(), None);
6175            let agent = Agent::new(provider, tools, AgentConfig::default());
6176            let session = Arc::new(Mutex::new(Session::in_memory()));
6177            let mut agent_session =
6178                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
6179
6180            agent_session
6181                .enable_extensions(&["bash"], temp_dir.path(), None, &[entry_path])
6182                .await
6183                .expect("enable extensions");
6184
6185            let tool_call = ToolCall {
6186                id: "call-1".to_string(),
6187                name: "bash".to_string(),
6188                arguments: json!({ "command": "printf 'hi' > blocked.txt" }),
6189                thought_signature: None,
6190            };
6191
6192            let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
6193            let (output, is_error) = agent_session
6194                .agent
6195                .execute_tool(tool_call, on_event, test_turn_latency())
6196                .await;
6197
6198            assert!(is_error);
6199            assert!(output.is_error);
6200            assert_eq!(output.details, None);
6201            assert!(
6202                !temp_dir.path().join("blocked.txt").exists(),
6203                "expected bash command not to run when blocked"
6204            );
6205            assert!(
6206                matches!(output.content.as_slice(), [ContentBlock::Text(_)]),
6207                "Expected text output, got {:?}",
6208                output.content
6209            );
6210            if let [ContentBlock::Text(text)] = output.content.as_slice() {
6211                assert_eq!(text.text, "Tool execution blocked: blocked bash in test");
6212            }
6213        });
6214    }
6215
6216    #[test]
6217    fn tool_result_hook_can_modify_tool_output() {
6218        let runtime = RuntimeBuilder::current_thread()
6219            .build()
6220            .expect("runtime build");
6221
6222        runtime.block_on(async {
6223            let temp_dir = tempfile::tempdir().expect("tempdir");
6224            let entry_path = temp_dir.path().join("ext.mjs");
6225            std::fs::write(
6226                &entry_path,
6227                r#"
6228                export default function init(pi) {
6229                  pi.on("tool_result", async (event) => {
6230                    if (Object.is(event && event.toolName, "count_tool")) {
6231                      return {
6232                        content: [{ type: "text", text: "modified" }],
6233                        details: { from: "tool_result" }
6234                      };
6235                    }
6236                    return {};
6237                  });
6238                }
6239                "#,
6240            )
6241            .expect("write extension entry");
6242
6243            let provider = Arc::new(NoopProvider);
6244            let calls = Arc::new(AtomicUsize::new(0));
6245            let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
6246                calls: Arc::clone(&calls),
6247            })]);
6248            let agent = Agent::new(provider, tools, AgentConfig::default());
6249            let session = Arc::new(Mutex::new(Session::in_memory()));
6250            let mut agent_session =
6251                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
6252
6253            agent_session
6254                .enable_extensions(&[], temp_dir.path(), None, &[entry_path])
6255                .await
6256                .expect("enable extensions");
6257
6258            let tool_call = ToolCall {
6259                id: "call-1".to_string(),
6260                name: "count_tool".to_string(),
6261                arguments: json!({}),
6262                thought_signature: None,
6263            };
6264
6265            let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
6266            let (output, is_error) = agent_session
6267                .agent
6268                .execute_tool(tool_call, on_event, test_turn_latency())
6269                .await;
6270
6271            assert!(!is_error);
6272            assert!(!output.is_error);
6273            assert_eq!(calls.load(Ordering::SeqCst), 1);
6274            assert_eq!(output.details, Some(json!({ "from": "tool_result" })));
6275
6276            assert!(
6277                matches!(output.content.as_slice(), [ContentBlock::Text(_)]),
6278                "Expected text output, got {:?}",
6279                output.content
6280            );
6281            if let [ContentBlock::Text(text)] = output.content.as_slice() {
6282                assert_eq!(text.text, "modified");
6283            }
6284        });
6285    }
6286
6287    #[test]
6288    fn tool_result_hook_can_modify_tool_not_found_error() {
6289        let runtime = RuntimeBuilder::current_thread()
6290            .build()
6291            .expect("runtime build");
6292
6293        runtime.block_on(async {
6294            let temp_dir = tempfile::tempdir().expect("tempdir");
6295            let entry_path = temp_dir.path().join("ext.mjs");
6296            std::fs::write(
6297                &entry_path,
6298                r#"
6299                export default function init(pi) {
6300                  pi.on("tool_result", async (event) => {
6301                    if (Object.is(event && event.toolName, "missing_tool") && event.isError) {
6302                      return {
6303                        content: [{ type: "text", text: "overridden" }],
6304                        details: { handled: true }
6305                      };
6306                    }
6307                    return {};
6308                  });
6309                }
6310                "#,
6311            )
6312            .expect("write extension entry");
6313
6314            let provider = Arc::new(NoopProvider);
6315            let tools = ToolRegistry::from_tools(Vec::new());
6316            let agent = Agent::new(provider, tools, AgentConfig::default());
6317            let session = Arc::new(Mutex::new(Session::in_memory()));
6318            let mut agent_session =
6319                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
6320
6321            agent_session
6322                .enable_extensions(&[], temp_dir.path(), None, &[entry_path])
6323                .await
6324                .expect("enable extensions");
6325
6326            let tool_call = ToolCall {
6327                id: "call-1".to_string(),
6328                name: "missing_tool".to_string(),
6329                arguments: json!({}),
6330                thought_signature: None,
6331            };
6332
6333            let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
6334            let (output, is_error) = agent_session
6335                .agent
6336                .execute_tool(tool_call, on_event, test_turn_latency())
6337                .await;
6338
6339            assert!(is_error);
6340            assert!(output.is_error);
6341            assert_eq!(output.details, Some(json!({ "handled": true })));
6342
6343            assert!(
6344                matches!(output.content.as_slice(), [ContentBlock::Text(_)]),
6345                "Expected text output, got {:?}",
6346                output.content
6347            );
6348            if let [ContentBlock::Text(text)] = output.content.as_slice() {
6349                assert_eq!(text.text, "overridden");
6350            }
6351        });
6352    }
6353
6354    #[test]
6355    fn tool_result_hook_errors_fail_open() {
6356        let runtime = RuntimeBuilder::current_thread()
6357            .build()
6358            .expect("runtime build");
6359
6360        runtime.block_on(async {
6361            let temp_dir = tempfile::tempdir().expect("tempdir");
6362            let entry_path = temp_dir.path().join("ext.mjs");
6363            std::fs::write(
6364                &entry_path,
6365                r#"
6366                export default function init(pi) {
6367                  pi.on("tool_result", async (_event) => {
6368                    throw new Error("boom");
6369                  });
6370                }
6371                "#,
6372            )
6373            .expect("write extension entry");
6374
6375            let provider = Arc::new(NoopProvider);
6376            let calls = Arc::new(AtomicUsize::new(0));
6377            let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
6378                calls: Arc::clone(&calls),
6379            })]);
6380            let agent = Agent::new(provider, tools, AgentConfig::default());
6381            let session = Arc::new(Mutex::new(Session::in_memory()));
6382            let mut agent_session =
6383                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
6384
6385            agent_session
6386                .enable_extensions(&[], temp_dir.path(), None, &[entry_path])
6387                .await
6388                .expect("enable extensions");
6389
6390            let tool_call = ToolCall {
6391                id: "call-1".to_string(),
6392                name: "count_tool".to_string(),
6393                arguments: json!({}),
6394                thought_signature: None,
6395            };
6396
6397            let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
6398            let (output, is_error) = agent_session
6399                .agent
6400                .execute_tool(tool_call, on_event, test_turn_latency())
6401                .await;
6402
6403            assert!(!is_error);
6404            assert!(!output.is_error);
6405            assert_eq!(calls.load(Ordering::SeqCst), 1);
6406
6407            assert_eq!(output.details, None);
6408            assert!(
6409                matches!(output.content.as_slice(), [ContentBlock::Text(_)]),
6410                "Expected text output, got {:?}",
6411                output.content
6412            );
6413            if let [ContentBlock::Text(text)] = output.content.as_slice() {
6414                assert_eq!(text.text, "ok");
6415            }
6416        });
6417    }
6418
6419    #[test]
6420    fn tool_result_hook_runs_on_blocked_tool_call() {
6421        let runtime = RuntimeBuilder::current_thread()
6422            .build()
6423            .expect("runtime build");
6424
6425        runtime.block_on(async {
6426            let temp_dir = tempfile::tempdir().expect("tempdir");
6427            let entry_path = temp_dir.path().join("ext.mjs");
6428            std::fs::write(
6429                &entry_path,
6430                r#"
6431                export default function init(pi) {
6432                  pi.on("tool_call", async (event) => {
6433                    if (Object.is(event && event.toolName, "count_tool")) {
6434                      return { block: true, reason: "blocked in test" };
6435                    }
6436                    return {};
6437                  });
6438
6439                  pi.on("tool_result", async (event) => {
6440                    if (Object.is(event && event.toolName, "count_tool") && event.isError) {
6441                      return { content: [{ type: "text", text: "override" }] };
6442                    }
6443                    return {};
6444                  });
6445                }
6446                "#,
6447            )
6448            .expect("write extension entry");
6449
6450            let provider = Arc::new(NoopProvider);
6451            let calls = Arc::new(AtomicUsize::new(0));
6452            let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
6453                calls: Arc::clone(&calls),
6454            })]);
6455            let agent = Agent::new(provider, tools, AgentConfig::default());
6456            let session = Arc::new(Mutex::new(Session::in_memory()));
6457            let mut agent_session =
6458                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
6459
6460            agent_session
6461                .enable_extensions(&[], temp_dir.path(), None, &[entry_path])
6462                .await
6463                .expect("enable extensions");
6464
6465            let tool_call = ToolCall {
6466                id: "call-1".to_string(),
6467                name: "count_tool".to_string(),
6468                arguments: json!({}),
6469                thought_signature: None,
6470            };
6471
6472            let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
6473            let (output, is_error) = agent_session
6474                .agent
6475                .execute_tool(tool_call, on_event, test_turn_latency())
6476                .await;
6477
6478            assert!(is_error);
6479            assert!(output.is_error);
6480            assert_eq!(calls.load(Ordering::SeqCst), 0);
6481
6482            assert!(
6483                matches!(output.content.as_slice(), [ContentBlock::Text(_)]),
6484                "Expected text output, got {:?}",
6485                output.content
6486            );
6487            if let [ContentBlock::Text(text)] = output.content.as_slice() {
6488                assert_eq!(text.text, "override");
6489            }
6490        });
6491    }
6492}
6493
6494#[cfg(test)]
6495mod abort_tests {
6496    use super::*;
6497    use crate::session::Session;
6498    use crate::tools::{Tool, ToolOutput, ToolRegistry, ToolUpdate};
6499    use asupersync::runtime::RuntimeBuilder;
6500    use async_trait::async_trait;
6501    use futures::Stream;
6502    use serde_json::json;
6503    use std::path::Path;
6504    use std::pin::Pin;
6505    use std::sync::Mutex as StdMutex;
6506    use std::sync::atomic::AtomicUsize;
6507    use std::task::{Context as TaskContext, Poll};
6508
6509    struct StartThenPending {
6510        start: Option<StreamEvent>,
6511    }
6512
6513    impl Stream for StartThenPending {
6514        type Item = crate::error::Result<StreamEvent>;
6515
6516        fn poll_next(
6517            mut self: Pin<&mut Self>,
6518            _cx: &mut TaskContext<'_>,
6519        ) -> Poll<Option<Self::Item>> {
6520            if let Some(event) = self.start.take() {
6521                return Poll::Ready(Some(Ok(event)));
6522            }
6523            Poll::Pending
6524        }
6525    }
6526
6527    #[derive(Debug)]
6528    struct HangingProvider;
6529
6530    #[async_trait]
6531    #[allow(clippy::unnecessary_literal_bound)]
6532    impl Provider for HangingProvider {
6533        fn name(&self) -> &str {
6534            "test-provider"
6535        }
6536
6537        fn api(&self) -> &str {
6538            "test-api"
6539        }
6540
6541        fn model_id(&self) -> &str {
6542            "test-model"
6543        }
6544
6545        async fn stream(
6546            &self,
6547            _context: &Context<'_>,
6548            _options: &StreamOptions,
6549        ) -> crate::error::Result<
6550            Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
6551        > {
6552            let partial = AssistantMessage {
6553                content: Vec::new(),
6554                api: self.api().to_string(),
6555                provider: self.name().to_string(),
6556                model: self.model_id().to_string(),
6557                usage: Usage::default(),
6558                stop_reason: StopReason::Stop,
6559                error_message: None,
6560                timestamp: 0,
6561            };
6562
6563            Ok(Box::pin(StartThenPending {
6564                start: Some(StreamEvent::Start { partial }),
6565            }))
6566        }
6567    }
6568
6569    #[derive(Debug)]
6570    struct CountingProvider {
6571        calls: Arc<std::sync::atomic::AtomicUsize>,
6572    }
6573
6574    #[async_trait]
6575    #[allow(clippy::unnecessary_literal_bound)]
6576    impl Provider for CountingProvider {
6577        fn name(&self) -> &str {
6578            "test-provider"
6579        }
6580
6581        fn api(&self) -> &str {
6582            "test-api"
6583        }
6584
6585        fn model_id(&self) -> &str {
6586            "test-model"
6587        }
6588
6589        async fn stream(
6590            &self,
6591            _context: &Context<'_>,
6592            _options: &StreamOptions,
6593        ) -> crate::error::Result<
6594            Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
6595        > {
6596            self.calls.fetch_add(1, Ordering::SeqCst);
6597            Ok(Box::pin(futures::stream::empty()))
6598        }
6599    }
6600
6601    #[derive(Debug)]
6602    struct PhasedProvider {
6603        pending_calls: usize,
6604        calls: AtomicUsize,
6605    }
6606
6607    impl PhasedProvider {
6608        const fn new(pending_calls: usize) -> Self {
6609            Self {
6610                pending_calls,
6611                calls: AtomicUsize::new(0),
6612            }
6613        }
6614
6615        fn base_message() -> AssistantMessage {
6616            AssistantMessage {
6617                content: Vec::new(),
6618                api: "test-api".to_string(),
6619                provider: "test-provider".to_string(),
6620                model: "test-model".to_string(),
6621                usage: Usage::default(),
6622                stop_reason: StopReason::Stop,
6623                error_message: None,
6624                timestamp: 0,
6625            }
6626        }
6627    }
6628
6629    #[async_trait]
6630    #[allow(clippy::unnecessary_literal_bound)]
6631    impl Provider for PhasedProvider {
6632        fn name(&self) -> &str {
6633            "test-provider"
6634        }
6635
6636        fn api(&self) -> &str {
6637            "test-api"
6638        }
6639
6640        fn model_id(&self) -> &str {
6641            "test-model"
6642        }
6643
6644        async fn stream(
6645            &self,
6646            _context: &Context<'_>,
6647            _options: &StreamOptions,
6648        ) -> crate::error::Result<
6649            Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
6650        > {
6651            let call = self.calls.fetch_add(1, Ordering::SeqCst);
6652            if call < self.pending_calls {
6653                return Ok(Box::pin(StartThenPending {
6654                    start: Some(StreamEvent::Start {
6655                        partial: Self::base_message(),
6656                    }),
6657                }));
6658            }
6659
6660            let partial = Self::base_message();
6661            let mut done = Self::base_message();
6662            done.content = vec![ContentBlock::Text(TextContent::new(format!(
6663                "resumed-response-{call}"
6664            )))];
6665
6666            Ok(Box::pin(futures::stream::iter(vec![
6667                Ok(StreamEvent::Start { partial }),
6668                Ok(StreamEvent::Done {
6669                    reason: StopReason::Stop,
6670                    message: done,
6671                }),
6672            ])))
6673        }
6674    }
6675
6676    #[derive(Debug)]
6677    struct ToolCallProvider;
6678
6679    #[async_trait]
6680    #[allow(clippy::unnecessary_literal_bound)]
6681    impl Provider for ToolCallProvider {
6682        fn name(&self) -> &str {
6683            "test-provider"
6684        }
6685
6686        fn api(&self) -> &str {
6687            "test-api"
6688        }
6689
6690        fn model_id(&self) -> &str {
6691            "test-model"
6692        }
6693
6694        async fn stream(
6695            &self,
6696            _context: &Context<'_>,
6697            _options: &StreamOptions,
6698        ) -> crate::error::Result<
6699            Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
6700        > {
6701            let message = AssistantMessage {
6702                content: vec![ContentBlock::ToolCall(ToolCall {
6703                    id: "call-1".to_string(),
6704                    name: "hanging_tool".to_string(),
6705                    arguments: json!({}),
6706                    thought_signature: None,
6707                })],
6708                api: "test-api".to_string(),
6709                provider: "test-provider".to_string(),
6710                model: "test-model".to_string(),
6711                usage: Usage::default(),
6712                stop_reason: StopReason::ToolUse,
6713                error_message: None,
6714                timestamp: 0,
6715            };
6716
6717            Ok(Box::pin(futures::stream::iter(vec![Ok(
6718                StreamEvent::Done {
6719                    reason: StopReason::ToolUse,
6720                    message,
6721                },
6722            )])))
6723        }
6724    }
6725
6726    #[derive(Debug)]
6727    struct HangingTool;
6728
6729    #[async_trait]
6730    #[allow(clippy::unnecessary_literal_bound)]
6731    impl Tool for HangingTool {
6732        fn name(&self) -> &str {
6733            "hanging_tool"
6734        }
6735
6736        fn label(&self) -> &str {
6737            "Hanging Tool"
6738        }
6739
6740        fn description(&self) -> &str {
6741            "Never completes unless aborted by the host"
6742        }
6743
6744        fn parameters(&self) -> serde_json::Value {
6745            json!({
6746                "type": "object",
6747                "properties": {},
6748                "additionalProperties": false
6749            })
6750        }
6751
6752        async fn execute(
6753            &self,
6754            _tool_call_id: &str,
6755            _input: serde_json::Value,
6756            _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
6757        ) -> crate::error::Result<ToolOutput> {
6758            futures::future::pending::<()>().await;
6759            unreachable!("hanging tool should be aborted by the agent")
6760        }
6761    }
6762
6763    fn event_tag(event: &AgentEvent) -> &'static str {
6764        match event {
6765            AgentEvent::AgentStart { .. } => "agent_start",
6766            AgentEvent::AgentEnd { error, .. } => {
6767                if error.as_deref() == Some("Aborted") {
6768                    "agent_end_aborted"
6769                } else {
6770                    "agent_end"
6771                }
6772            }
6773            AgentEvent::TurnStart { .. } => "turn_start",
6774            AgentEvent::TurnEnd { .. } => "turn_end",
6775            AgentEvent::MessageStart { .. } => "message_start",
6776            AgentEvent::MessageUpdate {
6777                assistant_message_event,
6778                ..
6779            } => match &assistant_message_event {
6780                AssistantMessageEvent::Error {
6781                    reason: StopReason::Aborted,
6782                    ..
6783                } => "assistant_error_aborted",
6784                AssistantMessageEvent::Done { .. } => "assistant_done",
6785                _ => "assistant_update",
6786            },
6787            AgentEvent::MessageEnd { .. } => "message_end",
6788            AgentEvent::ToolExecutionStart { .. } => "tool_start",
6789            AgentEvent::ToolExecutionUpdate { .. } => "tool_update",
6790            AgentEvent::ToolExecutionEnd { .. } => "tool_end",
6791            AgentEvent::AutoCompactionStart { .. } => "auto_compaction_start",
6792            AgentEvent::AutoCompactionEnd { .. } => "auto_compaction_end",
6793            AgentEvent::AutoRetryStart { .. } => "auto_retry_start",
6794            AgentEvent::AutoRetryEnd { .. } => "auto_retry_end",
6795            AgentEvent::ExtensionError { .. } => "extension_error",
6796        }
6797    }
6798
6799    fn assert_abort_resume_message_sequence(persisted: &[Message]) {
6800        assert_eq!(
6801            persisted.len(),
6802            6,
6803            "expected three user+assistant pairs, got: {persisted:?}"
6804        );
6805
6806        let assistant_states = persisted
6807            .iter()
6808            .filter_map(|message| match message {
6809                Message::Assistant(assistant) => Some(assistant.stop_reason),
6810                _ => None,
6811            })
6812            .collect::<Vec<_>>();
6813        assert_eq!(
6814            assistant_states,
6815            vec![StopReason::Aborted, StopReason::Aborted, StopReason::Stop]
6816        );
6817    }
6818
6819    fn assert_abort_resume_timeline_boundaries(timeline: &[String]) {
6820        assert!(
6821            timeline
6822                .iter()
6823                .any(|event| event.as_str().eq("run0:agent_end_aborted")),
6824            "missing aborted boundary for first run: {timeline:?}"
6825        );
6826        assert!(
6827            timeline
6828                .iter()
6829                .any(|event| event.as_str().eq("run1:agent_end_aborted")),
6830            "missing aborted boundary for second run: {timeline:?}"
6831        );
6832        assert!(
6833            timeline
6834                .iter()
6835                .any(|event| event.as_str().eq("run2:agent_end")),
6836            "missing successful boundary for resumed run: {timeline:?}"
6837        );
6838    }
6839
6840    #[test]
6841    fn abort_interrupts_in_flight_stream() {
6842        let runtime = RuntimeBuilder::current_thread()
6843            .build()
6844            .expect("runtime build");
6845        let handle = runtime.handle();
6846
6847        let started = Arc::new(Notify::new());
6848        let started_wait = started.notified();
6849
6850        let (abort_handle, abort_signal) = AbortHandle::new();
6851
6852        let provider = Arc::new(HangingProvider);
6853        let tools = ToolRegistry::new(&[], Path::new("."), None);
6854        let agent = Agent::new(provider, tools, AgentConfig::default());
6855        let session = Arc::new(Mutex::new(Session::in_memory()));
6856        let mut agent_session =
6857            AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
6858
6859        let started_tx = Arc::clone(&started);
6860        let join = handle.spawn(async move {
6861            agent_session
6862                .run_text_with_abort("hello".to_string(), Some(abort_signal), move |event| {
6863                    if matches!(
6864                        event,
6865                        AgentEvent::MessageStart {
6866                            message: Message::Assistant(_)
6867                        }
6868                    ) {
6869                        started_tx.notify_one();
6870                    }
6871                })
6872                .await
6873        });
6874
6875        runtime.block_on(async move {
6876            started_wait.await;
6877            abort_handle.abort();
6878
6879            let message = join.await.expect("run_text_with_abort");
6880            assert_eq!(message.stop_reason, StopReason::Aborted);
6881            assert_eq!(message.error_message.as_deref(), Some("Aborted"));
6882        });
6883    }
6884
6885    #[test]
6886    fn ambient_cancellation_interrupts_in_flight_stream() {
6887        let runtime = RuntimeBuilder::current_thread()
6888            .build()
6889            .expect("runtime build");
6890
6891        runtime.block_on(async move {
6892            let (started_tx, started_rx) = std::sync::mpsc::channel();
6893
6894            let provider = Arc::new(HangingProvider);
6895            let tools = ToolRegistry::new(&[], Path::new("."), None);
6896            let agent = Agent::new(provider, tools, AgentConfig::default());
6897            let session = Arc::new(Mutex::new(Session::in_memory()));
6898            let mut agent_session =
6899                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
6900
6901            let ambient_cx = asupersync::Cx::for_testing();
6902            let cancel_cx = ambient_cx.clone();
6903            let _current = asupersync::Cx::set_current(Some(ambient_cx));
6904
6905            let cancel_thread = std::thread::spawn(move || {
6906                started_rx
6907                    .recv_timeout(std::time::Duration::from_secs(1))
6908                    .expect("stream start");
6909                cancel_cx.set_cancel_requested(true);
6910            });
6911
6912            let run = agent_session.run_text_with_abort("hello".to_string(), None, move |event| {
6913                if matches!(
6914                    event,
6915                    AgentEvent::MessageStart {
6916                        message: Message::Assistant(_)
6917                    }
6918                ) {
6919                    let _ = started_tx.send(());
6920                }
6921            });
6922            futures::pin_mut!(run);
6923
6924            let message = asupersync::time::timeout(
6925                asupersync::time::wall_now(),
6926                std::time::Duration::from_secs(1),
6927                run,
6928            )
6929            .await
6930            .expect("ambient cancellation should finish before timeout")
6931            .expect("run_text_with_abort");
6932
6933            cancel_thread.join().expect("cancel thread");
6934
6935            assert_eq!(message.stop_reason, StopReason::Aborted);
6936            assert_eq!(message.error_message.as_deref(), Some("Aborted"));
6937        });
6938    }
6939
6940    #[test]
6941    fn abort_before_run_skips_provider_stream_call() {
6942        let runtime = RuntimeBuilder::current_thread()
6943            .build()
6944            .expect("runtime build");
6945
6946        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
6947        let provider = Arc::new(CountingProvider {
6948            calls: Arc::clone(&calls),
6949        });
6950        let tools = ToolRegistry::new(&[], Path::new("."), None);
6951        let agent = Agent::new(provider, tools, AgentConfig::default());
6952        let session = Arc::new(Mutex::new(Session::in_memory()));
6953        let mut agent_session =
6954            AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
6955
6956        let (abort_handle, abort_signal) = AbortHandle::new();
6957        abort_handle.abort();
6958
6959        runtime.block_on(async move {
6960            let message = agent_session
6961                .run_text_with_abort("hello".to_string(), Some(abort_signal), |_| {})
6962                .await
6963                .expect("run_text_with_abort");
6964            assert_eq!(message.stop_reason, StopReason::Aborted);
6965            assert_eq!(calls.load(Ordering::SeqCst), 0);
6966        });
6967    }
6968
6969    #[test]
6970    fn abort_then_resume_preserves_session_history() {
6971        let runtime = RuntimeBuilder::current_thread()
6972            .build()
6973            .expect("runtime build");
6974        let handle = runtime.handle();
6975
6976        runtime.block_on(async move {
6977            let provider = Arc::new(PhasedProvider::new(1));
6978            let tools = ToolRegistry::new(&[], Path::new("."), None);
6979            let agent = Agent::new(provider, tools, AgentConfig::default());
6980            let session = Arc::new(Mutex::new(Session::in_memory()));
6981            let mut agent_session = AgentSession::new(
6982                agent,
6983                Arc::clone(&session),
6984                false,
6985                ResolvedCompactionSettings::default(),
6986            );
6987
6988            let started = Arc::new(Notify::new());
6989            let (abort_handle, abort_signal) = AbortHandle::new();
6990            let started_for_abort = Arc::clone(&started);
6991            let abort_join = handle.spawn(async move {
6992                started_for_abort.notified().await;
6993                abort_handle.abort();
6994            });
6995
6996            let aborted = agent_session
6997                .run_text_with_abort("first".to_string(), Some(abort_signal), {
6998                    let started = Arc::clone(&started);
6999                    move |event| {
7000                        if matches!(
7001                            event,
7002                            AgentEvent::MessageStart {
7003                                message: Message::Assistant(_)
7004                            }
7005                        ) {
7006                            started.notify_one();
7007                        }
7008                    }
7009                })
7010                .await
7011                .expect("first run");
7012            abort_join.await;
7013
7014            assert_eq!(aborted.stop_reason, StopReason::Aborted);
7015            assert_eq!(aborted.error_message.as_deref(), Some("Aborted"));
7016
7017            let resumed = agent_session
7018                .run_text("second".to_string(), |_| {})
7019                .await
7020                .expect("resumed run");
7021            assert_eq!(resumed.stop_reason, StopReason::Stop);
7022            assert!(resumed.error_message.is_none());
7023
7024            let cx = crate::agent_cx::AgentCx::for_request();
7025            let persisted = session
7026                .lock(cx.cx())
7027                .await
7028                .expect("lock session")
7029                .to_messages_for_current_path();
7030
7031            assert_eq!(
7032                persisted.len(),
7033                4,
7034                "unexpected message history after abort+resume: {persisted:?}"
7035            );
7036            assert!(matches!(persisted.first(), Some(Message::User(_))));
7037            assert!(matches!(
7038                persisted.get(1),
7039                Some(Message::Assistant(assistant))
7040                    if matches!(assistant.stop_reason, StopReason::Aborted)
7041            ));
7042            assert!(matches!(persisted.get(2), Some(Message::User(_))));
7043            assert!(matches!(
7044                persisted.get(3),
7045                Some(Message::Assistant(assistant))
7046                    if matches!(assistant.stop_reason, StopReason::Stop)
7047                        && assistant.error_message.is_none()
7048            ));
7049        });
7050    }
7051
7052    #[test]
7053    fn repeated_abort_then_resume_has_consistent_timeline_and_state() {
7054        let runtime = RuntimeBuilder::current_thread()
7055            .build()
7056            .expect("runtime build");
7057        let handle = runtime.handle();
7058
7059        runtime.block_on(async move {
7060            let provider = Arc::new(PhasedProvider::new(2));
7061            let tools = ToolRegistry::new(&[], Path::new("."), None);
7062            let agent = Agent::new(provider, tools, AgentConfig::default());
7063            let session = Arc::new(Mutex::new(Session::in_memory()));
7064            let mut agent_session = AgentSession::new(
7065                agent,
7066                Arc::clone(&session),
7067                false,
7068                ResolvedCompactionSettings::default(),
7069            );
7070
7071            let timeline = Arc::new(StdMutex::new(Vec::<String>::new()));
7072
7073            for run_idx in 0..2 {
7074                let started = Arc::new(Notify::new());
7075                let (abort_handle, abort_signal) = AbortHandle::new();
7076                let started_for_abort = Arc::clone(&started);
7077                let abort_join = handle.spawn(async move {
7078                    started_for_abort.notified().await;
7079                    abort_handle.abort();
7080                });
7081
7082                let run_timeline = Arc::clone(&timeline);
7083                let aborted = agent_session
7084                    .run_text_with_abort(format!("abort-run-{run_idx}"), Some(abort_signal), {
7085                        let started = Arc::clone(&started);
7086                        move |event| {
7087                            if let Ok(mut events) = run_timeline.lock() {
7088                                events.push(format!("run{run_idx}:{}", event_tag(&event)));
7089                            }
7090                            if matches!(
7091                                event,
7092                                AgentEvent::MessageStart {
7093                                    message: Message::Assistant(_)
7094                                }
7095                            ) {
7096                                started.notify_one();
7097                            }
7098                        }
7099                    })
7100                    .await
7101                    .expect("aborted run");
7102                abort_join.await;
7103
7104                assert_eq!(
7105                    aborted.stop_reason,
7106                    StopReason::Aborted,
7107                    "run {run_idx} should abort cleanly"
7108                );
7109            }
7110
7111            let run_timeline = Arc::clone(&timeline);
7112            let resumed = agent_session
7113                .run_text("final-run".to_string(), move |event| {
7114                    if let Ok(mut events) = run_timeline.lock() {
7115                        events.push(format!("run2:{}", event_tag(&event)));
7116                    }
7117                })
7118                .await
7119                .expect("final resumed run");
7120            assert_eq!(resumed.stop_reason, StopReason::Stop);
7121            assert!(resumed.error_message.is_none());
7122
7123            let cx = crate::agent_cx::AgentCx::for_request();
7124            let persisted = session
7125                .lock(cx.cx())
7126                .await
7127                .expect("lock session")
7128                .to_messages_for_current_path();
7129
7130            assert_abort_resume_message_sequence(&persisted);
7131
7132            let timeline = timeline
7133                .lock()
7134                .unwrap_or_else(std::sync::PoisonError::into_inner)
7135                .clone();
7136            assert_abort_resume_timeline_boundaries(&timeline);
7137        });
7138    }
7139
7140    #[test]
7141    fn abort_during_tool_execution_records_aborted_tool_result() {
7142        let runtime = RuntimeBuilder::current_thread()
7143            .build()
7144            .expect("runtime build");
7145        let handle = runtime.handle();
7146
7147        runtime.block_on(async move {
7148            let provider = Arc::new(ToolCallProvider);
7149            let tools = ToolRegistry::from_tools(vec![Box::new(HangingTool)]);
7150            let agent = Agent::new(provider, tools, AgentConfig::default());
7151            let session = Arc::new(Mutex::new(Session::in_memory()));
7152            let mut agent_session = AgentSession::new(
7153                agent,
7154                Arc::clone(&session),
7155                false,
7156                ResolvedCompactionSettings::default(),
7157            );
7158
7159            let tool_started = Arc::new(Notify::new());
7160            let (abort_handle, abort_signal) = AbortHandle::new();
7161            let tool_started_for_abort = Arc::clone(&tool_started);
7162            let abort_join = handle.spawn(async move {
7163                tool_started_for_abort.notified().await;
7164                abort_handle.abort();
7165            });
7166
7167            let result = agent_session
7168                .run_text_with_abort("trigger tool".to_string(), Some(abort_signal), {
7169                    let tool_started = Arc::clone(&tool_started);
7170                    move |event| {
7171                        if matches!(event, AgentEvent::ToolExecutionStart { .. }) {
7172                            tool_started.notify_one();
7173                        }
7174                    }
7175                })
7176                .await
7177                .expect("tool-abort run");
7178            abort_join.await;
7179            assert_eq!(result.stop_reason, StopReason::Aborted);
7180
7181            let cx = crate::agent_cx::AgentCx::for_request();
7182            let persisted = session
7183                .lock(cx.cx())
7184                .await
7185                .expect("lock session")
7186                .to_messages_for_current_path();
7187
7188            let tool_result = persisted
7189                .iter()
7190                .find_map(|message| match message {
7191                    Message::ToolResult(result) => Some(result),
7192                    _ => None,
7193                })
7194                .expect("expected tool result message");
7195            assert!(tool_result.is_error);
7196            assert!(
7197                tool_result.content.iter().any(|block| {
7198                    matches!(
7199                        block,
7200                        ContentBlock::Text(text) if text.text.contains("Tool execution aborted")
7201                    )
7202                }),
7203                "missing aborted tool marker in tool output: {:?}",
7204                tool_result.content
7205            );
7206            let details = tool_result
7207                .details
7208                .as_ref()
7209                .expect("aborted tool result should include structured details");
7210            assert_eq!(details["schema"], TOOL_CANCELLATION_SCHEMA_V1);
7211            assert_eq!(details["status"], "cancelled");
7212            assert_eq!(details["reason"], "abort_signal");
7213            assert_eq!(details["toolName"], "hanging_tool");
7214            assert_eq!(details["cleanup"], "tool_result_recorded_no_success");
7215        });
7216    }
7217}
7218
7219#[cfg(test)]
7220mod turn_event_tests {
7221    use super::*;
7222    use crate::session::Session;
7223    use crate::tools::{Tool, ToolOutput, ToolRegistry, ToolUpdate};
7224    use asupersync::runtime::RuntimeBuilder;
7225    use async_trait::async_trait;
7226    use futures::Stream;
7227    use serde_json::json;
7228    use std::path::Path;
7229    use std::pin::Pin;
7230    use std::sync::atomic::AtomicUsize;
7231    // Note: Mutex from super::* is asupersync::sync::Mutex (for Session)
7232    // Use std::sync::Mutex directly for synchronous event capture
7233
7234    fn assistant_message(text: &str) -> AssistantMessage {
7235        AssistantMessage {
7236            content: vec![ContentBlock::Text(TextContent::new(text))],
7237            api: "test-api".to_string(),
7238            provider: "test-provider".to_string(),
7239            model: "test-model".to_string(),
7240            usage: Usage::default(),
7241            stop_reason: StopReason::Stop,
7242            error_message: None,
7243            timestamp: 0,
7244        }
7245    }
7246
7247    struct SingleShotProvider;
7248
7249    #[async_trait]
7250    #[allow(clippy::unnecessary_literal_bound)]
7251    impl Provider for SingleShotProvider {
7252        fn name(&self) -> &str {
7253            "test-provider"
7254        }
7255
7256        fn api(&self) -> &str {
7257            "test-api"
7258        }
7259
7260        fn model_id(&self) -> &str {
7261            "test-model"
7262        }
7263
7264        async fn stream(
7265            &self,
7266            _context: &Context<'_>,
7267            _options: &StreamOptions,
7268        ) -> crate::error::Result<
7269            Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
7270        > {
7271            let partial = assistant_message("");
7272            let final_message = assistant_message("hello");
7273            let events = vec![
7274                Ok(StreamEvent::Start { partial }),
7275                Ok(StreamEvent::Done {
7276                    reason: StopReason::Stop,
7277                    message: final_message,
7278                }),
7279            ];
7280            Ok(Box::pin(futures::stream::iter(events)))
7281        }
7282    }
7283
7284    struct StreamSetupErrorProvider;
7285
7286    #[async_trait]
7287    #[allow(clippy::unnecessary_literal_bound)]
7288    impl Provider for StreamSetupErrorProvider {
7289        fn name(&self) -> &str {
7290            "test-provider"
7291        }
7292
7293        fn api(&self) -> &str {
7294            "test-api"
7295        }
7296
7297        fn model_id(&self) -> &str {
7298            "test-model"
7299        }
7300
7301        async fn stream(
7302            &self,
7303            _context: &Context<'_>,
7304            _options: &StreamOptions,
7305        ) -> crate::error::Result<
7306            Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
7307        > {
7308            Err(Error::api("stream setup failed"))
7309        }
7310    }
7311
7312    #[derive(Debug)]
7313    struct EchoTool;
7314
7315    #[async_trait]
7316    #[allow(clippy::unnecessary_literal_bound)]
7317    impl Tool for EchoTool {
7318        fn name(&self) -> &str {
7319            "echo_tool"
7320        }
7321
7322        fn label(&self) -> &str {
7323            "echo_tool"
7324        }
7325
7326        fn description(&self) -> &str {
7327            "echo test tool"
7328        }
7329
7330        fn parameters(&self) -> serde_json::Value {
7331            json!({ "type": "object" })
7332        }
7333
7334        async fn execute(
7335            &self,
7336            _tool_call_id: &str,
7337            _input: serde_json::Value,
7338            _on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
7339        ) -> Result<ToolOutput> {
7340            Ok(ToolOutput {
7341                content: vec![ContentBlock::Text(TextContent::new("tool-ok"))],
7342                details: None,
7343                is_error: false,
7344            })
7345        }
7346    }
7347
7348    #[derive(Debug)]
7349    struct ToolTurnProvider {
7350        calls: AtomicUsize,
7351    }
7352
7353    impl ToolTurnProvider {
7354        const fn new() -> Self {
7355            Self {
7356                calls: AtomicUsize::new(0),
7357            }
7358        }
7359
7360        fn assistant_message_with(
7361            &self,
7362            stop_reason: StopReason,
7363            content: Vec<ContentBlock>,
7364        ) -> AssistantMessage {
7365            AssistantMessage {
7366                content,
7367                api: self.api().to_string(),
7368                provider: self.name().to_string(),
7369                model: self.model_id().to_string(),
7370                usage: Usage::default(),
7371                stop_reason,
7372                error_message: None,
7373                timestamp: 0,
7374            }
7375        }
7376    }
7377
7378    #[async_trait]
7379    #[allow(clippy::unnecessary_literal_bound)]
7380    impl Provider for ToolTurnProvider {
7381        fn name(&self) -> &str {
7382            "test-provider"
7383        }
7384
7385        fn api(&self) -> &str {
7386            "test-api"
7387        }
7388
7389        fn model_id(&self) -> &str {
7390            "test-model"
7391        }
7392
7393        async fn stream(
7394            &self,
7395            _context: &Context<'_>,
7396            _options: &StreamOptions,
7397        ) -> crate::error::Result<
7398            Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
7399        > {
7400            let call_index = self.calls.fetch_add(1, Ordering::SeqCst);
7401            let partial = self.assistant_message_with(StopReason::Stop, Vec::new());
7402            let done = if call_index == 0 {
7403                self.assistant_message_with(
7404                    StopReason::ToolUse,
7405                    vec![ContentBlock::ToolCall(ToolCall {
7406                        id: "tool-1".to_string(),
7407                        name: "echo_tool".to_string(),
7408                        arguments: json!({}),
7409                        thought_signature: None,
7410                    })],
7411                )
7412            } else {
7413                self.assistant_message_with(
7414                    StopReason::Stop,
7415                    vec![ContentBlock::Text(TextContent::new("final"))],
7416                )
7417            };
7418
7419            Ok(Box::pin(futures::stream::iter(vec![
7420                Ok(StreamEvent::Start { partial }),
7421                Ok(StreamEvent::Done {
7422                    reason: done.stop_reason,
7423                    message: done,
7424                }),
7425            ])))
7426        }
7427    }
7428
7429    #[test]
7430    fn turn_events_wrap_assistant_response() {
7431        let runtime = RuntimeBuilder::current_thread()
7432            .build()
7433            .expect("runtime build");
7434        let handle = runtime.handle();
7435
7436        let provider = Arc::new(SingleShotProvider);
7437        let tools = ToolRegistry::new(&[], Path::new("."), None);
7438        let agent = Agent::new(provider, tools, AgentConfig::default());
7439        let session = Arc::new(Mutex::new(Session::in_memory()));
7440        let mut agent_session =
7441            AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
7442
7443        let events: Arc<std::sync::Mutex<Vec<AgentEvent>>> =
7444            Arc::new(std::sync::Mutex::new(Vec::new()));
7445        let events_capture = Arc::clone(&events);
7446
7447        let join = handle.spawn(async move {
7448            agent_session
7449                .run_text("hello".to_string(), move |event| {
7450                    events_capture
7451                        .lock()
7452                        .unwrap_or_else(std::sync::PoisonError::into_inner)
7453                        .push(event);
7454                })
7455                .await
7456                .expect("run_text")
7457        });
7458
7459        runtime.block_on(async move {
7460            let message = join.await;
7461            assert_eq!(message.stop_reason, StopReason::Stop);
7462
7463            let events = events
7464                .lock()
7465                .unwrap_or_else(std::sync::PoisonError::into_inner);
7466            let turn_start_indices = events
7467                .iter()
7468                .enumerate()
7469                .filter_map(|(idx, event)| {
7470                    matches!(event, AgentEvent::TurnStart { .. }).then_some(idx)
7471                })
7472                .collect::<Vec<_>>();
7473            let turn_end_indices = events
7474                .iter()
7475                .enumerate()
7476                .filter_map(|(idx, event)| {
7477                    matches!(event, AgentEvent::TurnEnd { .. }).then_some(idx)
7478                })
7479                .collect::<Vec<_>>();
7480
7481            assert_eq!(turn_start_indices.len(), 1);
7482            assert_eq!(turn_end_indices.len(), 1);
7483            assert!(turn_start_indices[0] < turn_end_indices[0]);
7484
7485            let assistant_message_end = events
7486                .iter()
7487                .enumerate()
7488                .find_map(|(idx, event)| match event {
7489                    AgentEvent::MessageEnd {
7490                        message: Message::Assistant(_),
7491                    } => Some(idx),
7492                    _ => None,
7493                })
7494                .expect("assistant message end");
7495
7496            assert!(assistant_message_end < turn_end_indices[0]);
7497
7498            let (message_is_assistant, tool_results_empty) = {
7499                let turn_end_event = &events[turn_end_indices[0]];
7500                assert!(
7501                    matches!(turn_end_event, AgentEvent::TurnEnd { .. }),
7502                    "Expected TurnEnd event, got {turn_end_event:?}"
7503                );
7504                match turn_end_event {
7505                    AgentEvent::TurnEnd {
7506                        message,
7507                        tool_results,
7508                        ..
7509                    } => (
7510                        matches!(message, Message::Assistant(_)),
7511                        tool_results.is_empty(),
7512                    ),
7513                    _ => (false, false),
7514                }
7515            };
7516            drop(events);
7517            assert!(message_is_assistant);
7518            assert!(tool_results_empty);
7519        });
7520    }
7521
7522    #[test]
7523    fn stream_setup_errors_still_emit_turn_end_before_agent_end() {
7524        let runtime = RuntimeBuilder::current_thread()
7525            .build()
7526            .expect("runtime build");
7527        let handle = runtime.handle();
7528
7529        let provider = Arc::new(StreamSetupErrorProvider);
7530        let tools = ToolRegistry::new(&[], Path::new("."), None);
7531        let agent = Agent::new(provider, tools, AgentConfig::default());
7532        let session = Arc::new(Mutex::new(Session::in_memory()));
7533        let mut agent_session =
7534            AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
7535
7536        let events: Arc<std::sync::Mutex<Vec<AgentEvent>>> =
7537            Arc::new(std::sync::Mutex::new(Vec::new()));
7538        let events_capture = Arc::clone(&events);
7539
7540        let join = handle.spawn(async move {
7541            agent_session
7542                .run_text("hello".to_string(), move |event| {
7543                    events_capture
7544                        .lock()
7545                        .unwrap_or_else(std::sync::PoisonError::into_inner)
7546                        .push(event);
7547                })
7548                .await
7549                .expect_err("run_text should fail before streaming starts")
7550        });
7551
7552        runtime.block_on(async move {
7553            let err = join.await;
7554            assert!(
7555                err.to_string().contains("stream setup failed"),
7556                "unexpected error: {err}"
7557            );
7558
7559            let events = events
7560                .lock()
7561                .unwrap_or_else(std::sync::PoisonError::into_inner);
7562            let turn_start_idx = events
7563                .iter()
7564                .position(|event| matches!(event, AgentEvent::TurnStart { turn_index: 0, .. }))
7565                .expect("turn start");
7566            let turn_end_idx = events
7567                .iter()
7568                .position(|event| matches!(event, AgentEvent::TurnEnd { turn_index: 0, .. }))
7569                .expect("turn end");
7570            let agent_end_idx = events
7571                .iter()
7572                .position(|event| matches!(event, AgentEvent::AgentEnd { .. }))
7573                .expect("agent end");
7574
7575            assert!(turn_start_idx < turn_end_idx);
7576            assert!(turn_end_idx < agent_end_idx);
7577
7578            let assistant_message_end = events
7579                .iter()
7580                .position(|event| {
7581                    matches!(
7582                        event,
7583                        AgentEvent::MessageEnd {
7584                            message: Message::Assistant(_),
7585                        }
7586                    )
7587                })
7588                .expect("assistant message end");
7589            assert!(assistant_message_end < turn_end_idx);
7590
7591            match &events[turn_end_idx] {
7592                AgentEvent::TurnEnd {
7593                    message,
7594                    tool_results,
7595                    ..
7596                } => {
7597                    assert!(tool_results.is_empty());
7598                    assert!(
7599                        matches!(message, Message::Assistant(_)),
7600                        "expected assistant message in TurnEnd, got {message:?}"
7601                    );
7602                    let Message::Assistant(message) = message else {
7603                        return;
7604                    };
7605                    assert_eq!(message.stop_reason, StopReason::Error);
7606                    assert_eq!(
7607                        message.error_message.as_deref(),
7608                        Some("API error: stream setup failed")
7609                    );
7610                    assert_eq!(message.api, "test-api");
7611                    assert_eq!(message.provider, "test-provider");
7612                    assert_eq!(message.model, "test-model");
7613                }
7614                other => {
7615                    assert!(matches!(other, AgentEvent::TurnEnd { .. }));
7616                    return;
7617                }
7618            }
7619
7620            match &events[agent_end_idx] {
7621                AgentEvent::AgentEnd { error, .. } => {
7622                    assert_eq!(error.as_deref(), Some("API error: stream setup failed"));
7623                }
7624                other => {
7625                    assert!(matches!(other, AgentEvent::AgentEnd { .. }));
7626                }
7627            }
7628        });
7629    }
7630
7631    #[test]
7632    fn turn_events_include_tool_execution_and_tool_result_messages() {
7633        let runtime = RuntimeBuilder::current_thread()
7634            .build()
7635            .expect("runtime build");
7636        let handle = runtime.handle();
7637
7638        let provider = Arc::new(ToolTurnProvider::new());
7639        let tools = ToolRegistry::from_tools(vec![Box::new(EchoTool)]);
7640        let agent = Agent::new(provider, tools, AgentConfig::default());
7641        let session = Arc::new(Mutex::new(Session::in_memory()));
7642        let mut agent_session =
7643            AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
7644
7645        let events: Arc<std::sync::Mutex<Vec<AgentEvent>>> =
7646            Arc::new(std::sync::Mutex::new(Vec::new()));
7647        let events_capture = Arc::clone(&events);
7648
7649        let join = handle.spawn(async move {
7650            agent_session
7651                .run_text("hello".to_string(), move |event| {
7652                    events_capture
7653                        .lock()
7654                        .unwrap_or_else(std::sync::PoisonError::into_inner)
7655                        .push(event);
7656                })
7657                .await
7658                .expect("run_text")
7659        });
7660
7661        runtime.block_on(async move {
7662            let message = join.await;
7663            assert_eq!(message.stop_reason, StopReason::Stop);
7664
7665            let events = events
7666                .lock()
7667                .unwrap_or_else(std::sync::PoisonError::into_inner);
7668            let turn_start_count = events
7669                .iter()
7670                .filter(|event| matches!(event, AgentEvent::TurnStart { .. }))
7671                .count();
7672            let turn_end_count = events
7673                .iter()
7674                .filter(|event| matches!(event, AgentEvent::TurnEnd { .. }))
7675                .count();
7676            assert_eq!(
7677                turn_start_count, 2,
7678                "expected one tool turn and one final turn"
7679            );
7680            assert_eq!(
7681                turn_end_count, 2,
7682                "expected one tool turn and one final turn"
7683            );
7684
7685            let tool_start_idx = events
7686                .iter()
7687                .position(|event| matches!(event, AgentEvent::ToolExecutionStart { .. }))
7688                .expect("tool execution start event");
7689            let tool_end_idx = events
7690                .iter()
7691                .position(|event| matches!(event, AgentEvent::ToolExecutionEnd { .. }))
7692                .expect("tool execution end event");
7693            assert!(tool_start_idx < tool_end_idx);
7694
7695            let first_turn_end_idx = events
7696                .iter()
7697                .position(|event| matches!(event, AgentEvent::TurnEnd { turn_index: 0, .. }))
7698                .expect("first turn end");
7699            assert!(
7700                tool_end_idx < first_turn_end_idx,
7701                "tool execution should complete before first turn end"
7702            );
7703
7704            let first_turn_tool_results = events.iter().find_map(|event| match event {
7705                AgentEvent::TurnEnd {
7706                    turn_index,
7707                    tool_results,
7708                    ..
7709                } if turn_index.eq(&0) => Some(tool_results),
7710                _ => None,
7711            });
7712
7713            let first_turn_tool_results =
7714                first_turn_tool_results.expect("expected tool results for first turn");
7715            assert_eq!(first_turn_tool_results.len(), 1);
7716            let first_result = first_turn_tool_results.first().unwrap();
7717            if let Message::ToolResult(tr) = first_result {
7718                assert_eq!(tr.tool_name, "echo_tool");
7719                assert!(!tr.is_error);
7720            } else {
7721                unreachable!("expected Message::ToolResult, got {:?}", first_result);
7722            }
7723            drop(events);
7724        });
7725    }
7726}
7727
7728#[derive(Clone)]
7729struct AgentExtensionSession {
7730    handle: SessionHandle,
7731    is_streaming: Arc<AtomicBool>,
7732    is_compacting: Arc<AtomicBool>,
7733    queue_modes: Arc<StdMutex<ExtensionQueueModeState>>,
7734    auto_compaction_enabled: bool,
7735}
7736
7737impl AgentExtensionSession {
7738    fn current_queue_modes(&self) -> (QueueMode, QueueMode) {
7739        self.queue_modes
7740            .lock()
7741            .map_or((QueueMode::OneAtATime, QueueMode::OneAtATime), |state| {
7742                (state.steering_mode, state.follow_up_mode)
7743            })
7744    }
7745
7746    fn state_fallback(&self) -> Value {
7747        let (steering_mode, follow_up_mode) = self.current_queue_modes();
7748        json!({
7749            "model": null,
7750            "thinkingLevel": "off",
7751            "durabilityMode": "balanced",
7752            "isStreaming": self.is_streaming.load(std::sync::atomic::Ordering::SeqCst),
7753            "isCompacting": self.is_compacting.load(std::sync::atomic::Ordering::SeqCst),
7754            "steeringMode": steering_mode.as_str(),
7755            "followUpMode": follow_up_mode.as_str(),
7756            "sessionFile": null,
7757            "sessionId": "",
7758            "sessionName": null,
7759            "autoCompactionEnabled": self.auto_compaction_enabled,
7760            "messageCount": 0,
7761            "pendingMessageCount": 0,
7762        })
7763    }
7764}
7765
7766#[async_trait]
7767impl crate::extensions::ExtensionSession for AgentExtensionSession {
7768    async fn get_state(&self) -> Value {
7769        let (steering_mode, follow_up_mode) = self.current_queue_modes();
7770        let mut state =
7771            <SessionHandle as crate::extensions::ExtensionSession>::get_state(&self.handle).await;
7772        let Some(object) = state.as_object_mut() else {
7773            return self.state_fallback();
7774        };
7775
7776        object.insert(
7777            "isStreaming".to_string(),
7778            Value::Bool(self.is_streaming.load(std::sync::atomic::Ordering::SeqCst)),
7779        );
7780        object.insert(
7781            "isCompacting".to_string(),
7782            Value::Bool(self.is_compacting.load(std::sync::atomic::Ordering::SeqCst)),
7783        );
7784        object.insert(
7785            "steeringMode".to_string(),
7786            Value::String(steering_mode.as_str().to_string()),
7787        );
7788        object.insert(
7789            "followUpMode".to_string(),
7790            Value::String(follow_up_mode.as_str().to_string()),
7791        );
7792        object.insert(
7793            "autoCompactionEnabled".to_string(),
7794            Value::Bool(self.auto_compaction_enabled),
7795        );
7796
7797        state
7798    }
7799
7800    async fn get_messages(&self) -> Vec<crate::session::SessionMessage> {
7801        <SessionHandle as crate::extensions::ExtensionSession>::get_messages(&self.handle).await
7802    }
7803
7804    async fn get_entries(&self) -> Vec<Value> {
7805        <SessionHandle as crate::extensions::ExtensionSession>::get_entries(&self.handle).await
7806    }
7807
7808    async fn get_branch(&self) -> Vec<Value> {
7809        <SessionHandle as crate::extensions::ExtensionSession>::get_branch(&self.handle).await
7810    }
7811
7812    async fn set_name(&self, name: String) -> crate::error::Result<()> {
7813        <SessionHandle as crate::extensions::ExtensionSession>::set_name(&self.handle, name).await
7814    }
7815
7816    async fn append_message(
7817        &self,
7818        message: crate::session::SessionMessage,
7819    ) -> crate::error::Result<()> {
7820        <SessionHandle as crate::extensions::ExtensionSession>::append_message(
7821            &self.handle,
7822            message,
7823        )
7824        .await
7825    }
7826
7827    async fn append_custom_entry(
7828        &self,
7829        custom_type: String,
7830        data: Option<Value>,
7831    ) -> crate::error::Result<()> {
7832        <SessionHandle as crate::extensions::ExtensionSession>::append_custom_entry(
7833            &self.handle,
7834            custom_type,
7835            data,
7836        )
7837        .await
7838    }
7839
7840    async fn set_model(&self, provider: String, model_id: String) -> crate::error::Result<()> {
7841        <SessionHandle as crate::extensions::ExtensionSession>::set_model(
7842            &self.handle,
7843            provider,
7844            model_id,
7845        )
7846        .await
7847    }
7848
7849    async fn get_model(&self) -> (Option<String>, Option<String>) {
7850        <SessionHandle as crate::extensions::ExtensionSession>::get_model(&self.handle).await
7851    }
7852
7853    async fn set_thinking_level(&self, level: String) -> crate::error::Result<()> {
7854        <SessionHandle as crate::extensions::ExtensionSession>::set_thinking_level(
7855            &self.handle,
7856            level,
7857        )
7858        .await
7859    }
7860
7861    async fn get_thinking_level(&self) -> Option<String> {
7862        <SessionHandle as crate::extensions::ExtensionSession>::get_thinking_level(&self.handle)
7863            .await
7864    }
7865
7866    async fn set_label(
7867        &self,
7868        target_id: String,
7869        label: Option<String>,
7870    ) -> crate::error::Result<()> {
7871        <SessionHandle as crate::extensions::ExtensionSession>::set_label(
7872            &self.handle,
7873            target_id,
7874            label,
7875        )
7876        .await
7877    }
7878}
7879
7880impl AgentSession {
7881    pub const fn runtime_repair_mode_from_policy_mode(mode: RepairPolicyMode) -> RepairMode {
7882        match mode {
7883            RepairPolicyMode::Off => RepairMode::Off,
7884            RepairPolicyMode::Suggest => RepairMode::Suggest,
7885            RepairPolicyMode::AutoSafe => RepairMode::AutoSafe,
7886            RepairPolicyMode::AutoStrict => RepairMode::AutoStrict,
7887        }
7888    }
7889
7890    #[allow(clippy::too_many_arguments)]
7891    async fn start_js_extension_runtime(
7892        stage: &'static str,
7893        cwd: &std::path::Path,
7894        tools: Arc<ToolRegistry>,
7895        manager: ExtensionManager,
7896        policy: ExtensionPolicy,
7897        repair_mode: RepairMode,
7898        memory_limit_bytes: usize,
7899    ) -> Result<ExtensionRuntimeHandle> {
7900        let mut config = PiJsRuntimeConfig {
7901            cwd: cwd.display().to_string(),
7902            repair_mode,
7903            ..PiJsRuntimeConfig::default()
7904        };
7905        config.limits.memory_limit_bytes = Some(memory_limit_bytes).filter(|bytes| *bytes > 0);
7906
7907        let runtime =
7908            JsExtensionRuntimeHandle::start_with_policy(config, tools, manager, policy).await?;
7909        tracing::info!(
7910            event = "pi.extension_runtime.engine_decision",
7911            stage,
7912            requested = "quickjs",
7913            selected = "quickjs",
7914            fallback = false,
7915            "Extension runtime engine selected (legacy JS/TS)"
7916        );
7917        Ok(ExtensionRuntimeHandle::Js(runtime))
7918    }
7919
7920    #[allow(clippy::too_many_arguments)]
7921    async fn start_native_extension_runtime(
7922        stage: &'static str,
7923        _cwd: &std::path::Path,
7924        _tools: Arc<ToolRegistry>,
7925        _manager: ExtensionManager,
7926        _policy: ExtensionPolicy,
7927        _repair_mode: RepairMode,
7928        _memory_limit_bytes: usize,
7929    ) -> Result<ExtensionRuntimeHandle> {
7930        let runtime = NativeRustExtensionRuntimeHandle::start().await?;
7931        tracing::info!(
7932            event = "pi.extension_runtime.engine_decision",
7933            stage,
7934            requested = "native-rust",
7935            selected = "native-rust",
7936            fallback = false,
7937            "Extension runtime engine selected (native-rust)"
7938        );
7939        Ok(ExtensionRuntimeHandle::NativeRust(runtime))
7940    }
7941
7942    pub fn new(
7943        agent: Agent,
7944        session: Arc<Mutex<Session>>,
7945        save_enabled: bool,
7946        compaction_settings: ResolvedCompactionSettings,
7947    ) -> Self {
7948        let extension_ai_completion = Arc::new(StdMutex::new(ExtensionAiCompletionHostState {
7949            provider: agent.provider(),
7950            stream_options: agent.stream_options().clone(),
7951            models: Vec::new(),
7952        }));
7953
7954        Self {
7955            agent,
7956            session,
7957            save_enabled,
7958            input_source: InputSource::Interactive,
7959            extensions: None,
7960            extensions_is_streaming: Arc::new(AtomicBool::new(false)),
7961            extensions_is_compacting: Arc::new(AtomicBool::new(false)),
7962            extensions_turn_active: Arc::new(AtomicBool::new(false)),
7963            extensions_pending_idle_actions: Arc::new(StdMutex::new(VecDeque::new())),
7964            extension_queue_modes: None,
7965            extension_injected_queue: None,
7966            extension_ai_completion,
7967            compaction_settings,
7968            compaction_runtime: None,
7969            runtime_handle: None,
7970            compaction_worker: CompactionWorkerState::new(CompactionQuota::default()),
7971            model_registry: None,
7972            auth_storage: None,
7973            api_key_override: None,
7974            semantic_context_bundle: None,
7975        }
7976    }
7977
7978    pub const fn set_input_source(&mut self, source: InputSource) {
7979        self.input_source = source;
7980    }
7981
7982    #[must_use]
7983    pub fn with_runtime_handle(mut self, runtime_handle: RuntimeHandle) -> Self {
7984        self.compaction_runtime = None;
7985        self.runtime_handle = Some(runtime_handle);
7986        self
7987    }
7988
7989    #[must_use]
7990    pub fn with_model_registry(mut self, registry: ModelRegistry) -> Self {
7991        self.set_model_registry(registry);
7992        self
7993    }
7994
7995    #[must_use]
7996    pub fn with_auth_storage(mut self, auth: AuthStorage) -> Self {
7997        self.auth_storage = Some(auth);
7998        self
7999    }
8000
8001    pub fn set_model_registry(&mut self, registry: ModelRegistry) {
8002        self.set_extension_ai_models(pi_ai_model_registry_values(&registry));
8003        self.model_registry = Some(registry);
8004    }
8005
8006    pub fn set_auth_storage(&mut self, auth: AuthStorage) {
8007        self.auth_storage = Some(auth);
8008    }
8009
8010    #[must_use]
8011    pub fn with_api_key_override(mut self, api_key: Option<String>) -> Self {
8012        self.set_api_key_override(api_key);
8013        self
8014    }
8015
8016    pub fn set_api_key_override(&mut self, api_key: Option<String>) {
8017        self.api_key_override = normalize_api_key_opt(api_key);
8018    }
8019
8020    pub fn refresh_extension_completion_host_state(&self) {
8021        let Ok(mut state) = self.extension_ai_completion.lock() else {
8022            tracing::error!("extension completion host state mutex poisoned; keeping stale state");
8023            return;
8024        };
8025        state.provider = self.agent.provider();
8026        state.stream_options = self.agent.stream_options().clone();
8027    }
8028
8029    fn set_extension_ai_models(&self, models: Vec<Value>) {
8030        let Ok(mut state) = self.extension_ai_completion.lock() else {
8031            tracing::error!(
8032                "extension completion host state mutex poisoned; keeping stale model catalog"
8033            );
8034            return;
8035        };
8036        state.models = models;
8037    }
8038
8039    pub fn set_semantic_context_bundle(
8040        &mut self,
8041        injection: Option<SemanticContextBundleInjection>,
8042    ) {
8043        self.semantic_context_bundle = injection;
8044    }
8045
8046    pub const fn semantic_context_bundle(&self) -> Option<&SemanticContextBundleInjection> {
8047        self.semantic_context_bundle.as_ref()
8048    }
8049
8050    pub fn set_queue_modes(&mut self, steering_mode: QueueMode, follow_up_mode: QueueMode) {
8051        self.agent.set_queue_modes(steering_mode, follow_up_mode);
8052
8053        if let Some(queue_modes) = &self.extension_queue_modes
8054            && let Ok(mut state) = queue_modes.lock()
8055        {
8056            state.set_modes(steering_mode, follow_up_mode);
8057        }
8058
8059        if let Some(injected_queue) = &self.extension_injected_queue
8060            && let Ok(mut queue) = injected_queue.lock()
8061        {
8062            queue.set_modes(steering_mode, follow_up_mode);
8063        }
8064    }
8065
8066    pub const fn set_compaction_context_window(&mut self, context_window_tokens: u32) {
8067        self.compaction_settings.context_window_tokens = context_window_tokens;
8068    }
8069
8070    pub async fn set_provider_model(&mut self, provider_id: &str, model_id: &str) -> Result<()> {
8071        let already_active = {
8072            let provider = self.agent.provider();
8073            provider.name().eq(provider_id) && provider.model_id().eq(model_id)
8074        };
8075        let current_thinking = self
8076            .agent
8077            .stream_options()
8078            .thinking_level
8079            .unwrap_or_default();
8080
8081        let target_entry = self
8082            .model_registry
8083            .as_ref()
8084            .and_then(|registry| registry.find(provider_id, model_id));
8085        let next_thinking = if let Some(target_entry) = target_entry {
8086            let resolved_key = self.resolve_stream_api_key_for_model(&target_entry);
8087            if !already_active
8088                && model_requires_configured_credential(&target_entry)
8089                && resolved_key.is_none()
8090            {
8091                return Err(Error::auth(format!(
8092                    "Missing credentials for {provider_id}/{model_id}"
8093                )));
8094            }
8095            self.clamp_thinking_level_for_model(provider_id, model_id, current_thinking)
8096        } else if already_active {
8097            current_thinking
8098        } else {
8099            return Err(Error::validation(format!(
8100                "Unable to switch provider/model to {provider_id}/{model_id}"
8101            )));
8102        };
8103
8104        if !already_active {
8105            self.apply_session_model_selection(provider_id, model_id)?;
8106        }
8107        self.agent.stream_options_mut().thinking_level = Some(next_thinking);
8108        self.refresh_extension_completion_host_state();
8109
8110        {
8111            let cx = crate::agent_cx::AgentCx::for_request();
8112            let mut session = self
8113                .session
8114                .lock(cx.cx())
8115                .await
8116                .map_err(|e| Error::session(e.to_string()))?;
8117            let previous_model = session.effective_model_for_current_path();
8118            let previous_thinking = session
8119                .effective_thinking_level_for_current_path()
8120                .as_deref()
8121                .and_then(|value| value.parse::<crate::model::ThinkingLevel>().ok());
8122            if previous_model
8123                .as_ref()
8124                .map(|(provider, model_id)| (provider.as_str(), model_id.as_str()))
8125                != Some((provider_id, model_id))
8126            {
8127                session.append_model_change(provider_id.to_string(), model_id.to_string());
8128            }
8129            session.set_model_header(
8130                Some(provider_id.to_string()),
8131                Some(model_id.to_string()),
8132                Some(next_thinking.to_string()),
8133            );
8134            if !previous_thinking.is_some_and(|previous| previous.eq(&next_thinking)) {
8135                session.append_thinking_level_change(next_thinking.to_string());
8136            }
8137        }
8138
8139        self.persist_session().await
8140    }
8141
8142    /// Update the thinking/reasoning level for this session at runtime.
8143    ///
8144    /// Clamps the requested level to what the active model supports (e.g. a
8145    /// non-reasoning model is forced to `Off`), records a thinking-level change
8146    /// in session history when it actually changes, and persists the session.
8147    /// Mirrors [`crate::sdk::AgentSessionHandle::set_thinking_level`] but is
8148    /// callable directly on an [`AgentSession`] (e.g. from the ACP transport,
8149    /// which holds an `AgentSession` rather than an SDK handle).
8150    pub async fn set_thinking_level(&mut self, level: crate::model::ThinkingLevel) -> Result<()> {
8151        let cx = crate::agent_cx::AgentCx::for_request();
8152        let (effective_level, changed) = {
8153            let mut guard = self
8154                .session
8155                .lock(cx.cx())
8156                .await
8157                .map_err(|e| Error::session(e.to_string()))?;
8158            let (provider_id, model_id) =
8159                guard.effective_model_for_current_path().unwrap_or_else(|| {
8160                    let provider = self.agent.provider();
8161                    (provider.name().to_string(), provider.model_id().to_string())
8162                });
8163            let effective_level =
8164                self.clamp_thinking_level_for_model(&provider_id, &model_id, level);
8165            let level_string = effective_level.to_string();
8166            let changed = guard.effective_thinking_level_for_current_path().as_deref()
8167                != Some(level_string.as_str());
8168            guard.set_model_header(None, None, Some(level_string.clone()));
8169            if changed {
8170                guard.append_thinking_level_change(level_string);
8171            }
8172            (effective_level, changed)
8173        };
8174        self.agent.stream_options_mut().thinking_level = Some(effective_level);
8175        self.refresh_extension_completion_host_state();
8176        if changed {
8177            self.persist_session().await
8178        } else {
8179            Ok(())
8180        }
8181    }
8182
8183    pub(crate) fn clamp_thinking_level_for_model(
8184        &self,
8185        provider_id: &str,
8186        model_id: &str,
8187        level: crate::model::ThinkingLevel,
8188    ) -> crate::model::ThinkingLevel {
8189        self.model_registry
8190            .as_ref()
8191            .and_then(|registry| registry.find(provider_id, model_id))
8192            .map_or(level, |entry| entry.clamp_thinking_level(level))
8193    }
8194
8195    fn resolve_stream_api_key_for_model(&self, entry: &ModelEntry) -> Option<String> {
8196        let normalize = |key_opt: Option<String>| {
8197            key_opt.and_then(|key| {
8198                let trimmed = key.trim();
8199                (!trimmed.is_empty()).then(|| trimmed.to_string())
8200            })
8201        };
8202
8203        normalize(self.api_key_override.clone())
8204            .or_else(|| {
8205                self.auth_storage
8206                    .as_ref()
8207                    .and_then(|auth| normalize(auth.resolve_api_key(&entry.model.provider, None)))
8208            })
8209            .or_else(|| normalize(entry.api_key.clone()))
8210    }
8211
8212    pub(crate) async fn sync_runtime_selection_from_session_header(&mut self) -> Result<()> {
8213        let session_state = {
8214            let cx = crate::agent_cx::AgentCx::for_request();
8215            let session = self
8216                .session
8217                .lock(cx.cx())
8218                .await
8219                .map_err(|e| Error::session(e.to_string()))?;
8220            (
8221                session.effective_model_for_current_path(),
8222                session.effective_thinking_level_for_current_path(),
8223            )
8224        };
8225
8226        let (session_model, session_thinking) = session_state;
8227        let current_thinking = self
8228            .agent
8229            .stream_options()
8230            .thinking_level
8231            .unwrap_or_default();
8232
8233        if let Some((provider_id, model_id)) = session_model.as_ref() {
8234            self.apply_session_model_selection(provider_id, model_id)?;
8235        }
8236
8237        let parsed_session_thinking = session_thinking.as_deref().and_then(|raw| {
8238            raw.parse::<crate::model::ThinkingLevel>().map_or_else(
8239                |_| {
8240                    tracing::warn!("Ignoring invalid session thinking level: {raw}");
8241                    None
8242                },
8243                Some,
8244            )
8245        });
8246        let requested = parsed_session_thinking.unwrap_or(current_thinking);
8247
8248        let effective = if let Some((provider_id, model_id)) = session_model.as_ref() {
8249            self.clamp_thinking_level_for_model(provider_id, model_id, requested)
8250        } else {
8251            requested
8252        };
8253
8254        self.agent.stream_options_mut().thinking_level = Some(effective);
8255        self.refresh_extension_completion_host_state();
8256
8257        let thinking_changed = !effective.eq(&current_thinking);
8258        let persist_needed = if session_thinking.is_some() {
8259            !parsed_session_thinking.is_some_and(|parsed| parsed.eq(&effective))
8260        } else {
8261            thinking_changed
8262        };
8263        if !persist_needed {
8264            return Ok(());
8265        }
8266
8267        {
8268            let cx = crate::agent_cx::AgentCx::for_request();
8269            let mut session = self
8270                .session
8271                .lock(cx.cx())
8272                .await
8273                .map_err(|e| Error::session(e.to_string()))?;
8274            let previous_thinking = session
8275                .header
8276                .thinking_level
8277                .as_deref()
8278                .and_then(|value| value.parse::<crate::model::ThinkingLevel>().ok());
8279            session.set_model_header(None, None, Some(effective.to_string()));
8280            if thinking_changed
8281                && !previous_thinking.is_some_and(|previous| previous.eq(&effective))
8282            {
8283                session.append_thinking_level_change(effective.to_string());
8284            }
8285        }
8286
8287        self.persist_session().await
8288    }
8289
8290    fn apply_session_model_selection(&mut self, provider_id: &str, model_id: &str) -> Result<()> {
8291        if self.agent.provider().name().eq(provider_id)
8292            && self.agent.provider().model_id().eq(model_id)
8293        {
8294            return Ok(());
8295        }
8296
8297        let Some(registry) = &self.model_registry else {
8298            return Err(Error::validation(format!(
8299                "Unable to switch provider/model to {provider_id}/{model_id}"
8300            )));
8301        };
8302
8303        let Some(entry) = registry.find(provider_id, model_id) else {
8304            return Err(Error::validation(format!(
8305                "Unable to switch provider/model to {provider_id}/{model_id}"
8306            )));
8307        };
8308
8309        let resolved_key = self.resolve_stream_api_key_for_model(&entry);
8310        if model_requires_configured_credential(&entry) && resolved_key.is_none() {
8311            return Err(Error::auth(format!(
8312                "Missing credentials for {provider_id}/{model_id}"
8313            )));
8314        }
8315
8316        match crate::providers::create_provider(
8317            &entry,
8318            self.extensions.as_ref().map(ExtensionRegion::manager),
8319        ) {
8320            Ok(provider) => {
8321                tracing::info!("Updating agent provider to {provider_id}/{model_id}");
8322                self.agent.set_provider(provider);
8323
8324                let stream_options = self.agent.stream_options_mut();
8325                stream_options.api_key.clone_from(&resolved_key);
8326                stream_options.headers.clone_from(&entry.headers);
8327                // Track the new model's configured output cap so a runtime
8328                // model switch (e.g. RPC `set_model`) honors its registry
8329                // `maxTokens` instead of carrying over the previous model's
8330                // limit or falling back to the provider default.
8331                stream_options.max_tokens = Some(entry.model.max_tokens);
8332                self.refresh_extension_completion_host_state();
8333                Ok(())
8334            }
8335            Err(e) => Err(Error::validation(format!(
8336                "Unable to switch provider/model to {provider_id}/{model_id}: {e}"
8337            ))),
8338        }
8339    }
8340
8341    pub const fn save_enabled(&self) -> bool {
8342        self.save_enabled
8343    }
8344
8345    /// Force-run compaction synchronously (used by `/compact` slash command).
8346    pub async fn compact_now(
8347        &mut self,
8348        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
8349    ) -> Result<()> {
8350        self.compact_synchronous(Arc::new(on_event)).await
8351    }
8352
8353    pub async fn execute_extension_command(
8354        &mut self,
8355        command_name: &str,
8356        args: &str,
8357        timeout_ms: u64,
8358        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
8359    ) -> Result<Value> {
8360        self.execute_extension_command_with_abort(command_name, args, timeout_ms, None, on_event)
8361            .await
8362    }
8363
8364    pub async fn execute_extension_command_with_abort(
8365        &mut self,
8366        command_name: &str,
8367        args: &str,
8368        timeout_ms: u64,
8369        abort: Option<AbortSignal>,
8370        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
8371    ) -> Result<Value> {
8372        let manager = self
8373            .extensions
8374            .as_ref()
8375            .map(ExtensionRegion::manager)
8376            .ok_or_else(|| Error::extension("Extensions are disabled"))?
8377            .clone();
8378        let on_event: AgentEventHandler = Arc::new(on_event);
8379
8380        self.run_pending_idle_actions_with_abort(abort.clone(), Arc::clone(&on_event))
8381            .await?;
8382
8383        let command_result = manager
8384            .execute_command(command_name, args, timeout_ms)
8385            .await;
8386        let replay_result = self
8387            .run_pending_idle_actions_with_abort(abort, Arc::clone(&on_event))
8388            .await;
8389
8390        match command_result {
8391            Ok(value) => {
8392                replay_result?;
8393                Ok(value)
8394            }
8395            Err(err) => {
8396                if let Err(replay_err) = replay_result {
8397                    tracing::warn!(
8398                        "extension command follow-up replay failed after command error: {replay_err}"
8399                    );
8400                }
8401                Err(err)
8402            }
8403        }
8404    }
8405
8406    /// Two-phase non-blocking compaction.
8407    ///
8408    /// **Phase 1** — apply a completed background compaction result (if any).
8409    /// **Phase 2** — if quotas allow and the session needs compaction, start a
8410    /// new background compaction task.
8411    #[allow(clippy::too_many_lines)]
8412    async fn maybe_compact(&mut self, on_event: AgentEventHandler) -> Result<()> {
8413        if !self.compaction_settings.enabled {
8414            return Ok(());
8415        }
8416
8417        // Phase 1: apply completed background result.
8418        if let Some(outcome) = self.compaction_worker.try_recv().await {
8419            self.extensions_is_compacting
8420                .store(false, std::sync::atomic::Ordering::SeqCst);
8421            match outcome {
8422                Ok(result) => {
8423                    self.apply_compaction_result(result, Arc::clone(&on_event))
8424                        .await?;
8425                }
8426                Err(e) => {
8427                    on_event(AgentEvent::AutoCompactionEnd {
8428                        result: None,
8429                        aborted: false,
8430                        will_retry: false,
8431                        error_message: Some(e.to_string()),
8432                    });
8433                }
8434            }
8435        }
8436
8437        // Phase 2: start new background compaction if quotas allow.
8438        if !self.compaction_worker.can_start() {
8439            return Ok(());
8440        }
8441
8442        let (entries, preparation) = {
8443            let cx = crate::agent_cx::AgentCx::for_request();
8444            let mut session = self
8445                .session
8446                .lock(cx.cx())
8447                .await
8448                .map_err(|e| Error::session(e.to_string()))?;
8449            session.ensure_entry_ids();
8450            let entries = session
8451                .entries_for_current_path()
8452                .into_iter()
8453                .cloned()
8454                .collect::<Vec<_>>();
8455            let prep = compaction::prepare_compaction(&entries, self.compaction_settings.clone());
8456            (entries, prep)
8457        };
8458
8459        if let Some(prep) = preparation {
8460            let admission = self
8461                .compaction_worker
8462                .admission_decision(Some(&prep), &CompactionAdmissionSignals::default());
8463            if !admission.allowed {
8464                tracing::info!(
8465                    reason = admission.reason.as_str(),
8466                    tokens_before = admission.tokens_before,
8467                    "Background compaction admission denied"
8468                );
8469                return Ok(());
8470            }
8471
8472            on_event(AgentEvent::AutoCompactionStart {
8473                reason: format!("threshold;admission={}", admission.reason.as_str()),
8474            });
8475
8476            let before_outcome = self.dispatch_before_compact(&prep, &entries, None).await;
8477            if before_outcome.cancel {
8478                on_event(AgentEvent::AutoCompactionEnd {
8479                    result: None,
8480                    aborted: true,
8481                    will_retry: false,
8482                    error_message: None,
8483                });
8484                return Ok(());
8485            }
8486
8487            if let Some(compaction) = before_outcome.compaction {
8488                let result_value = Some(Self::auto_compaction_result_payload(
8489                    compaction.summary.clone(),
8490                    compaction.first_kept_entry_id.clone(),
8491                    compaction.tokens_before,
8492                    compaction.details.clone(),
8493                ));
8494                self.extensions_is_compacting
8495                    .store(true, std::sync::atomic::Ordering::SeqCst);
8496                let apply_result = self
8497                    .apply_compaction_entry(
8498                        compaction.summary,
8499                        compaction.first_kept_entry_id,
8500                        compaction.tokens_before,
8501                        compaction.details,
8502                        true,
8503                    )
8504                    .await;
8505                self.extensions_is_compacting
8506                    .store(false, std::sync::atomic::Ordering::SeqCst);
8507                apply_result?;
8508                on_event(AgentEvent::AutoCompactionEnd {
8509                    result: result_value,
8510                    aborted: false,
8511                    will_retry: false,
8512                    error_message: None,
8513                });
8514                return Ok(());
8515            }
8516
8517            let provider = self.agent.provider();
8518            let credential = self
8519                .agent
8520                .stream_options()
8521                .api_key
8522                .clone()
8523                .unwrap_or_default();
8524
8525            let runtime_handle = match self.compaction_runtime_handle() {
8526                Ok(runtime_handle) => runtime_handle,
8527                Err(e) => {
8528                    on_event(AgentEvent::AutoCompactionEnd {
8529                        result: None,
8530                        aborted: false,
8531                        will_retry: false,
8532                        error_message: Some(e.to_string()),
8533                    });
8534                    return Ok(());
8535                }
8536            };
8537
8538            self.compaction_worker
8539                .start(&runtime_handle, prep, provider, credential, None);
8540            self.extensions_is_compacting
8541                .store(true, std::sync::atomic::Ordering::SeqCst);
8542        }
8543
8544        Ok(())
8545    }
8546
8547    fn compaction_runtime_handle(&mut self) -> Result<RuntimeHandle> {
8548        if let Some(runtime_handle) = self.runtime_handle.clone() {
8549            return Ok(runtime_handle);
8550        }
8551
8552        let runtime = RuntimeBuilder::new().build().map_err(|e| {
8553            Error::session(format!("Background compaction runtime init failed: {e}"))
8554        })?;
8555        let runtime_handle = runtime.handle();
8556        self.compaction_runtime = Some(runtime);
8557        self.runtime_handle = Some(runtime_handle.clone());
8558        Ok(runtime_handle)
8559    }
8560
8561    fn auto_compaction_result_payload(
8562        summary: String,
8563        first_kept_entry_id: String,
8564        tokens_before: u64,
8565        details: Option<Value>,
8566    ) -> Value {
8567        let mut payload = serde_json::Map::new();
8568        payload.insert("summary".to_string(), Value::String(summary));
8569        payload.insert(
8570            "firstKeptEntryId".to_string(),
8571            Value::String(first_kept_entry_id),
8572        );
8573        payload.insert("tokensBefore".to_string(), Value::from(tokens_before));
8574        if let Some(details) = details {
8575            payload.insert("details".to_string(), details);
8576        }
8577        Value::Object(payload)
8578    }
8579
8580    async fn apply_compaction_entry(
8581        &self,
8582        summary: String,
8583        first_kept_entry_id: String,
8584        tokens_before: u64,
8585        details: Option<Value>,
8586        from_extension: bool,
8587    ) -> Result<()> {
8588        let cx = crate::agent_cx::AgentCx::for_request();
8589        let mut session = self
8590            .session
8591            .lock(cx.cx())
8592            .await
8593            .map_err(|e| Error::session(e.to_string()))?;
8594
8595        let from_hook = if from_extension { Some(true) } else { None };
8596        let entry_id = session.append_compaction(
8597            summary,
8598            first_kept_entry_id,
8599            tokens_before,
8600            details,
8601            from_hook,
8602        );
8603
8604        if self.save_enabled {
8605            session
8606                .flush_autosave(AutosaveFlushTrigger::Periodic)
8607                .await?;
8608        }
8609
8610        let compaction_entry = session.get_entry(&entry_id).and_then(|entry| {
8611            if let crate::session::SessionEntry::Compaction(compaction) = entry {
8612                Some(compaction.clone())
8613            } else {
8614                None
8615            }
8616        });
8617        drop(session);
8618
8619        if let (Some(region), Some(compaction_entry)) = (&self.extensions, compaction_entry) {
8620            let payload = json!({
8621                "compactionEntry": compaction_entry,
8622                "fromExtension": from_extension,
8623            });
8624            if let Err(err) = region
8625                .manager()
8626                .dispatch_event(ExtensionEventName::SessionCompact, Some(payload))
8627                .await
8628            {
8629                tracing::warn!("session_compact extension hook failed (fail-open): {err}");
8630            }
8631        }
8632
8633        Ok(())
8634    }
8635
8636    /// Apply a completed compaction result to the session.
8637    async fn apply_compaction_result(
8638        &self,
8639        result: compaction::CompactionResult,
8640        on_event: AgentEventHandler,
8641    ) -> Result<()> {
8642        let details = Some(compaction::compaction_details_to_value(&result.details)?);
8643        let result_value = Some(Self::auto_compaction_result_payload(
8644            result.summary.clone(),
8645            result.first_kept_entry_id.clone(),
8646            result.tokens_before,
8647            details.clone(),
8648        ));
8649
8650        self.apply_compaction_entry(
8651            result.summary,
8652            result.first_kept_entry_id,
8653            result.tokens_before,
8654            details,
8655            false,
8656        )
8657        .await?;
8658
8659        on_event(AgentEvent::AutoCompactionEnd {
8660            result: result_value,
8661            aborted: false,
8662            will_retry: false,
8663            error_message: None,
8664        });
8665
8666        Ok(())
8667    }
8668
8669    /// Run compaction synchronously (inline), blocking until completion.
8670    async fn compact_synchronous(&self, on_event: AgentEventHandler) -> Result<()> {
8671        if !self.compaction_settings.enabled {
8672            return Ok(());
8673        }
8674
8675        let (entries, preparation) = {
8676            let cx = crate::agent_cx::AgentCx::for_request();
8677            let mut session = self
8678                .session
8679                .lock(cx.cx())
8680                .await
8681                .map_err(|e| Error::session(e.to_string()))?;
8682            session.ensure_entry_ids();
8683            let entries = session
8684                .entries_for_current_path()
8685                .into_iter()
8686                .cloned()
8687                .collect::<Vec<_>>();
8688            let prep = compaction::prepare_compaction(&entries, self.compaction_settings.clone());
8689            (entries, prep)
8690        };
8691
8692        if let Some(prep) = preparation {
8693            on_event(AgentEvent::AutoCompactionStart {
8694                reason: "threshold".to_string(),
8695            });
8696
8697            let before_outcome = self.dispatch_before_compact(&prep, &entries, None).await;
8698            if before_outcome.cancel {
8699                on_event(AgentEvent::AutoCompactionEnd {
8700                    result: None,
8701                    aborted: true,
8702                    will_retry: false,
8703                    error_message: None,
8704                });
8705                return Err(Error::extension("Compaction cancelled".to_string()));
8706            }
8707
8708            if let Some(compaction) = before_outcome.compaction {
8709                let result_value = Some(Self::auto_compaction_result_payload(
8710                    compaction.summary.clone(),
8711                    compaction.first_kept_entry_id.clone(),
8712                    compaction.tokens_before,
8713                    compaction.details.clone(),
8714                ));
8715                self.extensions_is_compacting
8716                    .store(true, std::sync::atomic::Ordering::SeqCst);
8717                let apply_result = self
8718                    .apply_compaction_entry(
8719                        compaction.summary,
8720                        compaction.first_kept_entry_id,
8721                        compaction.tokens_before,
8722                        compaction.details,
8723                        true,
8724                    )
8725                    .await;
8726                self.extensions_is_compacting
8727                    .store(false, std::sync::atomic::Ordering::SeqCst);
8728                apply_result?;
8729                on_event(AgentEvent::AutoCompactionEnd {
8730                    result: result_value,
8731                    aborted: false,
8732                    will_retry: false,
8733                    error_message: None,
8734                });
8735                return Ok(());
8736            }
8737            self.extensions_is_compacting
8738                .store(true, std::sync::atomic::Ordering::SeqCst);
8739
8740            let provider = self.agent.provider();
8741            let credential = self
8742                .agent
8743                .stream_options()
8744                .api_key
8745                .clone()
8746                .unwrap_or_default();
8747
8748            let compaction_result = compaction::compact(prep, provider, &credential, None).await;
8749            self.extensions_is_compacting
8750                .store(false, std::sync::atomic::Ordering::SeqCst);
8751
8752            match compaction_result {
8753                Ok(result) => {
8754                    self.apply_compaction_result(result, Arc::clone(&on_event))
8755                        .await?;
8756                }
8757                Err(e) => {
8758                    on_event(AgentEvent::AutoCompactionEnd {
8759                        result: None,
8760                        aborted: false,
8761                        will_retry: false,
8762                        error_message: Some(e.to_string()),
8763                    });
8764                    return Err(e);
8765                }
8766            }
8767        }
8768        Ok(())
8769    }
8770
8771    fn resolve_extension_policy_for_enable(
8772        config: Option<&crate::config::Config>,
8773        policy: Option<ExtensionPolicy>,
8774    ) -> ExtensionPolicy {
8775        policy.unwrap_or_else(|| {
8776            config.map_or_else(
8777                || crate::config::Config::default().resolve_extension_policy(None),
8778                |cfg| cfg.resolve_extension_policy(None),
8779            )
8780        })
8781    }
8782
8783    pub async fn enable_extensions(
8784        &mut self,
8785        enabled_tools: &[&str],
8786        cwd: &std::path::Path,
8787        config: Option<&crate::config::Config>,
8788        extension_entries: &[std::path::PathBuf],
8789    ) -> Result<()> {
8790        self.enable_extensions_with_policy(
8791            enabled_tools,
8792            cwd,
8793            config,
8794            extension_entries,
8795            None,
8796            None,
8797            None,
8798        )
8799        .await
8800    }
8801
8802    #[allow(clippy::too_many_lines, clippy::too_many_arguments)]
8803    pub async fn enable_extensions_with_policy(
8804        &mut self,
8805        enabled_tools: &[&str],
8806        cwd: &std::path::Path,
8807        config: Option<&crate::config::Config>,
8808        extension_entries: &[std::path::PathBuf],
8809        policy: Option<ExtensionPolicy>,
8810        repair_policy: Option<RepairPolicyMode>,
8811        pre_warmed: Option<PreWarmedExtensionRuntime>,
8812    ) -> Result<()> {
8813        let mut js_specs: Vec<JsExtensionLoadSpec> = Vec::new();
8814        let mut native_specs: Vec<NativeRustExtensionLoadSpec> = Vec::new();
8815        #[cfg(feature = "wasm-host")]
8816        let mut wasm_specs: Vec<WasmExtensionLoadSpec> = Vec::new();
8817
8818        for entry in extension_entries {
8819            match resolve_extension_load_spec(entry)? {
8820                ExtensionLoadSpec::Js(spec) => js_specs.push(spec),
8821                ExtensionLoadSpec::NativeRust(spec) => native_specs.push(spec),
8822                #[cfg(feature = "wasm-host")]
8823                ExtensionLoadSpec::Wasm(spec) => wasm_specs.push(spec),
8824            }
8825        }
8826
8827        if !js_specs.is_empty() && !native_specs.is_empty() {
8828            return Err(Error::validation(
8829                "Mixed extension runtimes are not supported in one session yet. Use either JS/TS extensions (QuickJS) or native-rust descriptors (*.native.json), but not both at once."
8830                    .to_string(),
8831            ));
8832        }
8833
8834        #[cfg(feature = "wasm-host")]
8835        if js_specs.is_empty() && native_specs.is_empty() && wasm_specs.is_empty() {
8836            self.extensions = None;
8837            self.agent.extensions = None;
8838            self.extension_queue_modes = None;
8839            self.extension_injected_queue = None;
8840            return Ok(());
8841        }
8842
8843        #[cfg(not(feature = "wasm-host"))]
8844        if js_specs.is_empty() && native_specs.is_empty() {
8845            self.extensions = None;
8846            self.agent.extensions = None;
8847            self.extension_queue_modes = None;
8848            self.extension_injected_queue = None;
8849            return Ok(());
8850        }
8851
8852        let resolved_policy = Self::resolve_extension_policy_for_enable(config, policy);
8853        let resolved_repair_policy = repair_policy
8854            .or_else(|| config.map(|cfg| cfg.resolve_repair_policy(None)))
8855            .unwrap_or(RepairPolicyMode::AutoSafe);
8856        let runtime_repair_mode =
8857            Self::runtime_repair_mode_from_policy_mode(resolved_repair_policy);
8858        let memory_limit_bytes =
8859            (resolved_policy.max_memory_mb as usize).saturating_mul(1024 * 1024);
8860        let wants_js_runtime = !js_specs.is_empty();
8861
8862        // Either use the pre-warmed extension runtime (booted concurrently with startup)
8863        // or create a fresh runtime inline.
8864        #[allow(unused_variables)]
8865        let (manager, tools) = if let Some(pre) = pre_warmed {
8866            let manager = pre.manager;
8867            let tools = pre.tools;
8868            let runtime = match pre.runtime {
8869                ExtensionRuntimeHandle::NativeRust(runtime) => {
8870                    if wants_js_runtime {
8871                        tracing::warn!(
8872                            event = "pi.extension_runtime.prewarm.mismatch",
8873                            expected = "quickjs",
8874                            got = "native-rust",
8875                            "Pre-warmed runtime mismatched requested JS mode; creating quickjs runtime"
8876                        );
8877                        Self::start_js_extension_runtime(
8878                            "agent_enable_extensions_prewarm_mismatch",
8879                            cwd,
8880                            Arc::clone(&tools),
8881                            manager.clone(),
8882                            resolved_policy.clone(),
8883                            runtime_repair_mode,
8884                            memory_limit_bytes,
8885                        )
8886                        .await?
8887                    } else {
8888                        tracing::info!(
8889                            event = "pi.extension_runtime.engine_decision",
8890                            stage = "agent_enable_extensions_prewarmed",
8891                            requested = "native-rust",
8892                            selected = "native-rust",
8893                            fallback = false,
8894                            "Using pre-warmed extension runtime"
8895                        );
8896                        ExtensionRuntimeHandle::NativeRust(runtime)
8897                    }
8898                }
8899                ExtensionRuntimeHandle::Js(runtime) => {
8900                    if wants_js_runtime {
8901                        tracing::info!(
8902                            event = "pi.extension_runtime.engine_decision",
8903                            stage = "agent_enable_extensions_prewarmed",
8904                            requested = "quickjs",
8905                            selected = "quickjs",
8906                            fallback = false,
8907                            "Using pre-warmed extension runtime"
8908                        );
8909                        ExtensionRuntimeHandle::Js(runtime)
8910                    } else {
8911                        tracing::warn!(
8912                            event = "pi.extension_runtime.prewarm.mismatch",
8913                            expected = "native-rust",
8914                            got = "quickjs",
8915                            "Pre-warmed runtime mismatched requested native mode; creating native-rust runtime"
8916                        );
8917                        Self::start_native_extension_runtime(
8918                            "agent_enable_extensions_prewarm_mismatch",
8919                            cwd,
8920                            Arc::clone(&tools),
8921                            manager.clone(),
8922                            resolved_policy.clone(),
8923                            runtime_repair_mode,
8924                            memory_limit_bytes,
8925                        )
8926                        .await?
8927                    }
8928                }
8929            };
8930            manager.set_runtime(runtime);
8931            (manager, tools)
8932        } else {
8933            let manager = ExtensionManager::new();
8934            manager.set_cwd(cwd.display().to_string());
8935            let tools = Arc::new(ToolRegistry::new(enabled_tools, cwd, config));
8936
8937            if let Some(cfg) = config {
8938                let resolved_risk = cfg.resolve_extension_risk_with_metadata();
8939                tracing::info!(
8940                    event = "pi.extension_runtime_risk.config",
8941                    source = resolved_risk.source,
8942                    enabled = resolved_risk.settings.enabled,
8943                    alpha = resolved_risk.settings.alpha,
8944                    window_size = resolved_risk.settings.window_size,
8945                    ledger_limit = resolved_risk.settings.ledger_limit,
8946                    fail_closed = resolved_risk.settings.fail_closed,
8947                    "Resolved extension runtime risk settings"
8948                );
8949                manager.set_runtime_risk_config(resolved_risk.settings);
8950            }
8951
8952            let runtime = if wants_js_runtime {
8953                Self::start_js_extension_runtime(
8954                    "agent_enable_extensions_boot",
8955                    cwd,
8956                    Arc::clone(&tools),
8957                    manager.clone(),
8958                    resolved_policy.clone(),
8959                    runtime_repair_mode,
8960                    memory_limit_bytes,
8961                )
8962                .await?
8963            } else {
8964                Self::start_native_extension_runtime(
8965                    "agent_enable_extensions_boot",
8966                    cwd,
8967                    Arc::clone(&tools),
8968                    manager.clone(),
8969                    resolved_policy.clone(),
8970                    runtime_repair_mode,
8971                    memory_limit_bytes,
8972                )
8973                .await?
8974            };
8975            manager.set_runtime(runtime);
8976            (manager, tools)
8977        };
8978
8979        // Session, host actions, and message fetchers are always set here
8980        // (after runtime boot) — the JS runtime only needs these when
8981        // dispatching hostcalls, which happens during extension loading.
8982        let (steering_mode, follow_up_mode) = self.agent.queue_modes();
8983        let queue_modes = Arc::new(StdMutex::new(ExtensionQueueModeState::new(
8984            steering_mode,
8985            follow_up_mode,
8986        )));
8987        manager.set_session(Arc::new(AgentExtensionSession {
8988            handle: SessionHandle(self.session.clone()),
8989            is_streaming: Arc::clone(&self.extensions_is_streaming),
8990            is_compacting: Arc::clone(&self.extensions_is_compacting),
8991            queue_modes: Arc::clone(&queue_modes),
8992            auto_compaction_enabled: self.compaction_settings.enabled,
8993        }));
8994
8995        let injected = Arc::new(StdMutex::new(ExtensionInjectedQueue::new(
8996            steering_mode,
8997            follow_up_mode,
8998        )));
8999        let host_actions = AgentSessionHostActions {
9000            session: Arc::clone(&self.session),
9001            injected: Arc::clone(&injected),
9002            is_streaming: Arc::clone(&self.extensions_is_streaming),
9003            is_turn_active: Arc::clone(&self.extensions_turn_active),
9004            pending_idle_actions: Arc::clone(&self.extensions_pending_idle_actions),
9005            ai_completion: Arc::clone(&self.extension_ai_completion),
9006        };
9007        self.extension_queue_modes = Some(Arc::clone(&queue_modes));
9008        self.extension_injected_queue = Some(Arc::clone(&injected));
9009        manager.set_host_actions(Arc::new(host_actions));
9010        {
9011            let steering_queue = Arc::clone(&injected);
9012            let follow_up_queue = Arc::clone(&injected);
9013            let steering_fetcher = move || -> BoxFuture<'static, Vec<Message>> {
9014                let steering_queue = Arc::clone(&steering_queue);
9015                Box::pin(async move {
9016                    let Ok(mut queue) = steering_queue.lock() else {
9017                        return Vec::new();
9018                    };
9019                    queue.pop_steering()
9020                })
9021            };
9022            let follow_up_fetcher = move || -> BoxFuture<'static, Vec<Message>> {
9023                let follow_up_queue = Arc::clone(&follow_up_queue);
9024                Box::pin(async move {
9025                    let Ok(mut queue) = follow_up_queue.lock() else {
9026                        return Vec::new();
9027                    };
9028                    queue.pop_follow_up()
9029                })
9030            };
9031            self.agent.register_message_fetchers(
9032                Some(Arc::new(steering_fetcher)),
9033                Some(Arc::new(follow_up_fetcher)),
9034            );
9035        }
9036
9037        if !js_specs.is_empty() {
9038            manager.load_js_extensions(js_specs).await?;
9039        }
9040
9041        if !native_specs.is_empty() {
9042            manager.load_native_extensions(native_specs).await?;
9043        }
9044
9045        // Drain and log auto-repair diagnostics (bd-k5q5.8.11).
9046        if let Some(rt) = manager.runtime() {
9047            let events = rt.drain_repair_events().await;
9048            if !events.is_empty() {
9049                log_repair_diagnostics(&events);
9050            }
9051        }
9052
9053        #[cfg(feature = "wasm-host")]
9054        if !wasm_specs.is_empty() {
9055            let host = WasmExtensionHost::new(cwd, resolved_policy.clone())?;
9056            manager
9057                .load_wasm_extensions(&host, wasm_specs, Arc::clone(&tools))
9058                .await?;
9059        }
9060
9061        // Fire the `startup` lifecycle hook once extensions are loaded.
9062        // Fail-open: extension errors must not prevent the agent from running.
9063        let session_path = {
9064            let cx = crate::agent_cx::AgentCx::for_request();
9065            let session = self
9066                .session
9067                .lock(cx.cx())
9068                .await
9069                .map_err(|e| Error::extension(e.to_string()))?;
9070            session.path.as_ref().map(|p| p.display().to_string())
9071        };
9072
9073        if let Err(err) = manager
9074            .dispatch_event(
9075                ExtensionEventName::Startup,
9076                Some(serde_json::json!({
9077                    "version": env!("CARGO_PKG_VERSION"),
9078                    "sessionFile": session_path,
9079                })),
9080            )
9081            .await
9082        {
9083            tracing::warn!("startup extension hook failed (fail-open): {err}");
9084        }
9085
9086        if let Err(err) = manager
9087            .dispatch_event(ExtensionEventName::SessionStart, None)
9088            .await
9089        {
9090            tracing::warn!("session_start extension hook failed (fail-open): {err}");
9091        }
9092
9093        let ctx_payload = serde_json::json!({ "cwd": cwd.display().to_string() });
9094        let wrappers = collect_extension_tool_wrappers(&manager, ctx_payload).await?;
9095        self.agent.extend_tools(wrappers);
9096        self.agent.extensions = Some(manager.clone());
9097        self.extensions = Some(ExtensionRegion::new(manager));
9098        Ok(())
9099    }
9100
9101    pub async fn save_and_index(&mut self) -> Result<()> {
9102        if self.save_enabled {
9103            let cx = crate::agent_cx::AgentCx::for_request();
9104            let mut session = self
9105                .session
9106                .lock(cx.cx())
9107                .await
9108                .map_err(|e| Error::session(e.to_string()))?;
9109            session
9110                .flush_autosave(AutosaveFlushTrigger::Periodic)
9111                .await?;
9112        }
9113        Ok(())
9114    }
9115
9116    pub async fn persist_session(&mut self) -> Result<()> {
9117        if !self.save_enabled {
9118            return Ok(());
9119        }
9120        let cx = crate::agent_cx::AgentCx::for_request();
9121        let mut session = self
9122            .session
9123            .lock(cx.cx())
9124            .await
9125            .map_err(|e| Error::session(e.to_string()))?;
9126        session
9127            .flush_autosave(AutosaveFlushTrigger::Periodic)
9128            .await?;
9129        Ok(())
9130    }
9131
9132    pub async fn run_text(
9133        &mut self,
9134        input: String,
9135        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
9136    ) -> Result<AssistantMessage> {
9137        self.run_text_with_abort(input, None, on_event).await
9138    }
9139
9140    pub async fn run_text_with_abort(
9141        &mut self,
9142        input: String,
9143        abort: Option<AbortSignal>,
9144        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
9145    ) -> Result<AssistantMessage> {
9146        self.extensions_turn_active.store(true, Ordering::SeqCst);
9147        let result = async {
9148            let outcome = self.dispatch_input_event(input, Vec::new()).await?;
9149            let (text, images) = match outcome {
9150                InputEventOutcome::Continue { text, images } => (text, images),
9151                InputEventOutcome::Block { reason } => {
9152                    let message = reason.unwrap_or_else(|| "Input blocked".to_string());
9153                    return Err(Error::extension(message));
9154                }
9155            };
9156
9157            let base_system_prompt = self.agent.system_prompt().map(str::to_string);
9158            let BeforeAgentStartOutcome {
9159                messages: custom_messages,
9160                system_prompt,
9161            } = self
9162                .dispatch_before_agent_start(
9163                    &text,
9164                    &images,
9165                    base_system_prompt.as_deref().unwrap_or(""),
9166                )
9167                .await;
9168            if let Some(prompt) = system_prompt {
9169                self.agent.set_system_prompt(Some(prompt));
9170            } else {
9171                self.agent.set_system_prompt(base_system_prompt.clone());
9172            }
9173
9174            let result = if images.is_empty() {
9175                self.run_agent_with_text(text, abort, on_event, custom_messages)
9176                    .await
9177            } else {
9178                let content = Self::build_content_blocks_for_input(&text, &images);
9179                self.run_agent_with_content(content, abort, on_event, custom_messages)
9180                    .await
9181            };
9182
9183            self.agent.set_system_prompt(base_system_prompt);
9184            result
9185        }
9186        .await;
9187        self.extensions_turn_active.store(false, Ordering::SeqCst);
9188        result
9189    }
9190
9191    pub async fn run_with_content(
9192        &mut self,
9193        content: Vec<ContentBlock>,
9194        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
9195    ) -> Result<AssistantMessage> {
9196        self.run_with_content_with_abort(content, None, on_event)
9197            .await
9198    }
9199
9200    pub async fn run_with_content_with_abort(
9201        &mut self,
9202        content: Vec<ContentBlock>,
9203        abort: Option<AbortSignal>,
9204        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
9205    ) -> Result<AssistantMessage> {
9206        self.extensions_turn_active.store(true, Ordering::SeqCst);
9207        let result = async {
9208            let (text, images) = Self::split_content_blocks_for_input(&content);
9209            let outcome = self.dispatch_input_event(text, images).await?;
9210            let (text, images) = match outcome {
9211                InputEventOutcome::Continue { text, images } => (text, images),
9212                InputEventOutcome::Block { reason } => {
9213                    let message = reason.unwrap_or_else(|| "Input blocked".to_string());
9214                    return Err(Error::extension(message));
9215                }
9216            };
9217
9218            let base_system_prompt = self.agent.system_prompt().map(str::to_string);
9219            let BeforeAgentStartOutcome {
9220                messages: custom_messages,
9221                system_prompt,
9222            } = self
9223                .dispatch_before_agent_start(
9224                    &text,
9225                    &images,
9226                    base_system_prompt.as_deref().unwrap_or(""),
9227                )
9228                .await;
9229            if let Some(prompt) = system_prompt {
9230                self.agent.set_system_prompt(Some(prompt));
9231            } else {
9232                self.agent.set_system_prompt(base_system_prompt.clone());
9233            }
9234
9235            let content_for_agent = Self::build_content_blocks_for_input(&text, &images);
9236            let result = self
9237                .run_agent_with_content(content_for_agent, abort, on_event, custom_messages)
9238                .await;
9239
9240            self.agent.set_system_prompt(base_system_prompt);
9241            result
9242        }
9243        .await;
9244        self.extensions_turn_active.store(false, Ordering::SeqCst);
9245        result
9246    }
9247
9248    pub async fn revert_last_user_message(&mut self) -> Result<bool> {
9249        let cx = crate::agent_cx::AgentCx::for_request();
9250        let mut session = self
9251            .session
9252            .lock(cx.cx())
9253            .await
9254            .map_err(|e| Error::session(e.to_string()))?;
9255
9256        let reverted = session.revert_last_user_message();
9257        if reverted {
9258            let messages = session.to_messages_for_current_path();
9259            self.agent.replace_messages(messages);
9260        }
9261        Ok(reverted)
9262    }
9263
9264    /// Revert only the incomplete trailing assistant output of a failed request
9265    /// (the partial/error message from a transient connection drop), preserving
9266    /// the user prompt and every completed tool cycle. Used before a retry that
9267    /// *resumes* the turn (`run_continue_with_abort`) rather than replaying it
9268    /// from the user message (pi_agent_rust#125). Syncs the agent's in-memory
9269    /// transcript to the reverted session path so a subsequent resume streams
9270    /// from the last completed state.
9271    pub async fn revert_incomplete_response(&mut self) -> Result<bool> {
9272        let cx = crate::agent_cx::AgentCx::for_request();
9273        let mut session = self
9274            .session
9275            .lock(cx.cx())
9276            .await
9277            .map_err(|e| Error::session(e.to_string()))?;
9278
9279        let reverted = session.revert_incomplete_response();
9280        if reverted {
9281            let messages = session.to_messages_for_current_path();
9282            self.agent.replace_messages(messages);
9283        }
9284        Ok(reverted)
9285    }
9286
9287    async fn dispatch_input_event(
9288        &self,
9289        text: String,
9290        images: Vec<ImageContent>,
9291    ) -> Result<InputEventOutcome> {
9292        let Some(region) = &self.extensions else {
9293            return Ok(InputEventOutcome::Continue { text, images });
9294        };
9295
9296        let images_value = serde_json::to_value(&images).unwrap_or(Value::Null);
9297        let attachments_value = images_value.clone();
9298        let text_clone = text.clone();
9299        let payload = json!({
9300            "text": text,
9301            "content": text_clone,
9302            "images": images_value,
9303            "attachments": attachments_value,
9304            "source": self.input_source.as_str(),
9305        });
9306
9307        let response = region
9308            .manager()
9309            .dispatch_event_with_response(
9310                ExtensionEventName::Input,
9311                Some(payload),
9312                EXTENSION_EVENT_TIMEOUT_MS,
9313            )
9314            .await?;
9315
9316        Ok(apply_input_event_response(response, text, images))
9317    }
9318
9319    async fn dispatch_before_agent_start(
9320        &self,
9321        prompt: &str,
9322        images: &[ImageContent],
9323        system_prompt: &str,
9324    ) -> BeforeAgentStartOutcome {
9325        let Some(region) = &self.extensions else {
9326            return BeforeAgentStartOutcome {
9327                messages: Vec::new(),
9328                system_prompt: None,
9329            };
9330        };
9331
9332        let images_value = serde_json::to_value(images).unwrap_or(Value::Null);
9333        let payload = json!({
9334            "prompt": prompt,
9335            "images": images_value,
9336            "systemPrompt": system_prompt,
9337        });
9338
9339        let response = region
9340            .manager()
9341            .dispatch_event_with_response(
9342                ExtensionEventName::BeforeAgentStart,
9343                Some(payload),
9344                EXTENSION_EVENT_TIMEOUT_MS,
9345            )
9346            .await;
9347
9348        match response {
9349            Ok(value) => apply_before_agent_start_response(value, Utc::now().timestamp_millis()),
9350            Err(err) => {
9351                tracing::warn!("before_agent_start extension hook failed (fail-open): {err}");
9352                BeforeAgentStartOutcome {
9353                    messages: Vec::new(),
9354                    system_prompt: None,
9355                }
9356            }
9357        }
9358    }
9359
9360    async fn dispatch_before_compact(
9361        &self,
9362        preparation: &compaction::CompactionPreparation,
9363        branch_entries: &[crate::session::SessionEntry],
9364        custom_instructions: Option<&str>,
9365    ) -> SessionBeforeCompactOutcome {
9366        let Some(region) = &self.extensions else {
9367            return SessionBeforeCompactOutcome::default();
9368        };
9369
9370        let prep_value = compaction::compaction_preparation_to_value(preparation);
9371        let branch_entries_value =
9372            serde_json::to_value(branch_entries).unwrap_or(Value::Array(Vec::new()));
9373        let mut payload = serde_json::Map::new();
9374        payload.insert("preparation".to_string(), prep_value);
9375        payload.insert("branchEntries".to_string(), branch_entries_value);
9376        if let Some(custom_instructions) = custom_instructions {
9377            payload.insert(
9378                "customInstructions".to_string(),
9379                Value::String(custom_instructions.to_string()),
9380            );
9381        }
9382
9383        let response = region
9384            .manager()
9385            .dispatch_event_with_response(
9386                ExtensionEventName::SessionBeforeCompact,
9387                Some(Value::Object(payload)),
9388                EXTENSION_EVENT_TIMEOUT_MS,
9389            )
9390            .await;
9391
9392        match response {
9393            Ok(value) => apply_session_before_compact_response(value, preparation.tokens_before),
9394            Err(err) => {
9395                tracing::warn!("session_before_compact extension hook failed (fail-open): {err}");
9396                SessionBeforeCompactOutcome::default()
9397            }
9398        }
9399    }
9400
9401    fn prepare_semantic_context_prompt(&self) -> Option<PreparedSemanticContextPrompt> {
9402        let injection = self.semantic_context_bundle.as_ref()?;
9403        if !injection.enabled {
9404            return None;
9405        }
9406
9407        let provider = self.agent.provider();
9408        let shape = semantic_context_prompt_shape_for_provider(provider.api());
9409        let budget = semantic_context_prompt_budget_for_provider(provider.api(), injection);
9410        let revision = semantic_context_bundle_revision(&injection.bundle);
9411        let (prompt, stats) =
9412            render_semantic_context_prompt(&injection.bundle, injection, budget, &revision);
9413        if prompt.trim().is_empty() {
9414            tracing::warn!(
9415                event = "pi.semantic_context.prompt.skipped",
9416                provider = provider.name(),
9417                api = provider.api(),
9418                model = provider.model_id(),
9419                revision = %revision,
9420                max_bytes = budget.max_bytes,
9421                "semantic context bundle prompt skipped because prompt budget was too small"
9422            );
9423            return None;
9424        }
9425
9426        tracing::info!(
9427            event = "pi.semantic_context.prompt.injected",
9428            provider = provider.name(),
9429            api = provider.api(),
9430            model = provider.model_id(),
9431            revision = %revision,
9432            shape = ?shape,
9433            prompt_bytes = prompt.len(),
9434            selected_items = stats.selected_items_included,
9435            selected_items_omitted = stats.selected_items_omitted,
9436            validation_commands = stats.validation_commands_included,
9437            truncated = stats.truncated,
9438            "semantic context bundle attached to agent turn"
9439        );
9440
9441        let details = json!({
9442            "schema": SEMANTIC_CONTEXT_PROVENANCE_SCHEMA_V1,
9443            "bundleSchema": injection.bundle.schema.as_str(),
9444            "bundleRevision": revision.as_str(),
9445            "provider": {
9446                "name": provider.name(),
9447                "api": provider.api(),
9448                "model": provider.model_id(),
9449                "promptShape": shape,
9450            },
9451            "budget": {
9452                "requestedMaxItems": injection.max_prompt_items,
9453                "requestedMaxBytes": injection.max_prompt_bytes,
9454                "effectiveMaxItems": budget.max_items,
9455                "effectiveMaxBytes": budget.max_bytes,
9456            },
9457            "prompt": {
9458                "bytes": prompt.len(),
9459                "selectedItemsIncluded": stats.selected_items_included,
9460                "selectedItemsOmitted": stats.selected_items_omitted,
9461                "validationCommandsIncluded": stats.validation_commands_included,
9462                "validationCommandsOmitted": stats.validation_commands_omitted,
9463                "exclusionsIncluded": stats.exclusions_included,
9464                "exclusionsOmitted": stats.exclusions_omitted,
9465                "truncated": stats.truncated,
9466            },
9467            "bundle": {
9468                "selectedItems": injection.bundle.selected_items.len(),
9469                "excludedItems": injection.bundle.excluded_items.len(),
9470                "staleEvidenceSuppressions": injection.bundle.stale_evidence_suppressions.len(),
9471                "estimatedBytes": injection.bundle.estimated_bytes,
9472                "estimatedTokens": injection.bundle.estimated_tokens,
9473                "redactionStatus": injection.bundle.redaction_summary.overall_status,
9474                "inputFingerprintSha256": injection.bundle.invalidation_policy.input_fingerprint_sha256.as_str(),
9475                "cacheable": injection.bundle.invalidation_policy.cacheable,
9476                "workspaceId": injection.bundle.invalidation_policy.workspace_id.as_str(),
9477                "branch": injection.bundle.invalidation_policy.branch.as_deref(),
9478                "sessionId": injection.bundle.invalidation_policy.session_id.as_deref(),
9479            }
9480        });
9481
9482        Some(PreparedSemanticContextPrompt {
9483            prompt,
9484            revision,
9485            shape,
9486            details,
9487        })
9488    }
9489
9490    fn semantic_context_prompt_messages(
9491        prepared: &PreparedSemanticContextPrompt,
9492        timestamp: i64,
9493    ) -> Vec<Message> {
9494        match prepared.shape {
9495            SemanticContextPromptShape::CustomUserMessage => {
9496                vec![Message::Custom(CustomMessage {
9497                    content: prepared.prompt.clone(),
9498                    custom_type: SEMANTIC_CONTEXT_CUSTOM_TYPE.to_string(),
9499                    display: true,
9500                    details: Some(prepared.details.clone()),
9501                    timestamp,
9502                })]
9503            }
9504            SemanticContextPromptShape::SystemPromptAppend => {
9505                vec![Message::Custom(CustomMessage {
9506                    content: format!(
9507                        "Semantic context bundle revision {} attached to system prompt.",
9508                        prepared.revision
9509                    ),
9510                    custom_type: SEMANTIC_CONTEXT_CUSTOM_TYPE.to_string(),
9511                    display: false,
9512                    details: Some(prepared.details.clone()),
9513                    timestamp,
9514                })]
9515            }
9516        }
9517    }
9518
9519    fn semantic_context_system_prompt_for_turn(
9520        base_system_prompt: Option<String>,
9521        prepared: Option<&PreparedSemanticContextPrompt>,
9522    ) -> Option<String> {
9523        let Some(prepared) = prepared else {
9524            return base_system_prompt;
9525        };
9526        if !matches!(
9527            prepared.shape,
9528            SemanticContextPromptShape::SystemPromptAppend
9529        ) {
9530            return base_system_prompt;
9531        }
9532
9533        let mut prompt = base_system_prompt.unwrap_or_default();
9534        if !prompt.is_empty() {
9535            prompt.push_str("\n\n");
9536        }
9537        prompt.push_str(&prepared.prompt);
9538        Some(prompt)
9539    }
9540
9541    fn split_content_blocks_for_input(blocks: &[ContentBlock]) -> (String, Vec<ImageContent>) {
9542        let mut text = String::new();
9543        let mut images = Vec::new();
9544        for block in blocks {
9545            match block {
9546                ContentBlock::Text(text_block) if !text_block.text.trim().is_empty() => {
9547                    if !text.is_empty() {
9548                        text.push('\n');
9549                    }
9550                    text.push_str(&text_block.text);
9551                }
9552                ContentBlock::Image(image) => images.push(image.clone()),
9553                _ => {}
9554            }
9555        }
9556        (text, images)
9557    }
9558
9559    fn build_content_blocks_for_input(text: &str, images: &[ImageContent]) -> Vec<ContentBlock> {
9560        let mut content = Vec::new();
9561        if !text.trim().is_empty() {
9562            content.push(ContentBlock::Text(TextContent::new(text.to_string())));
9563        }
9564        for image in images {
9565            content.push(ContentBlock::Image(image.clone()));
9566        }
9567        content
9568    }
9569
9570    fn take_pending_idle_actions(&self) -> Vec<PendingIdleAction> {
9571        let Ok(mut actions) = self.extensions_pending_idle_actions.lock() else {
9572            return Vec::new();
9573        };
9574        actions.drain(..).collect()
9575    }
9576
9577    async fn run_pending_idle_actions_with_abort(
9578        &mut self,
9579        abort: Option<AbortSignal>,
9580        on_event: AgentEventHandler,
9581    ) -> Result<()> {
9582        let actions = self.take_pending_idle_actions();
9583        if actions.is_empty() {
9584            return Ok(());
9585        }
9586
9587        let previous_source = self.input_source;
9588        self.input_source = InputSource::Extension;
9589        let result = async {
9590            for action in actions {
9591                match action {
9592                    PendingIdleAction::CustomMessage(message) => {
9593                        let handler = Arc::clone(&on_event);
9594                        self.run_custom_message_with_abort(message, abort.clone(), move |event| {
9595                            handler(event);
9596                        })
9597                        .await?;
9598                    }
9599                    PendingIdleAction::UserText(text) => {
9600                        let handler = Arc::clone(&on_event);
9601                        self.run_text_with_abort(text, abort.clone(), move |event| {
9602                            handler(event);
9603                        })
9604                        .await?;
9605                    }
9606                }
9607            }
9608            Ok(())
9609        }
9610        .await;
9611        self.input_source = previous_source;
9612        result
9613    }
9614
9615    async fn run_custom_message_with_abort(
9616        &mut self,
9617        message: Message,
9618        abort: Option<AbortSignal>,
9619        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
9620    ) -> Result<AssistantMessage> {
9621        self.extensions_turn_active.store(true, Ordering::SeqCst);
9622        let result = async {
9623            let base_system_prompt = self.agent.system_prompt().map(str::to_string);
9624            let BeforeAgentStartOutcome {
9625                messages: custom_messages,
9626                system_prompt,
9627            } = self
9628                .dispatch_before_agent_start("", &[], base_system_prompt.as_deref().unwrap_or(""))
9629                .await;
9630            if let Some(prompt) = system_prompt {
9631                self.agent.set_system_prompt(Some(prompt));
9632            } else {
9633                self.agent.set_system_prompt(base_system_prompt.clone());
9634            }
9635
9636            let result = self
9637                .run_agent_with_prompt_message(message, abort, on_event, custom_messages)
9638                .await;
9639
9640            self.agent.set_system_prompt(base_system_prompt);
9641            result
9642        }
9643        .await;
9644        self.extensions_turn_active.store(false, Ordering::SeqCst);
9645        result
9646    }
9647
9648    async fn run_agent_with_prompt_message(
9649        &mut self,
9650        prompt_message: Message,
9651        abort: Option<AbortSignal>,
9652        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
9653        custom_messages: Vec<CustomMessage>,
9654    ) -> Result<AssistantMessage> {
9655        let on_event: AgentEventHandler = Arc::new(on_event);
9656        self.sync_runtime_selection_from_session_header().await?;
9657
9658        self.maybe_compact(Arc::clone(&on_event)).await?;
9659        let history = {
9660            let cx = crate::agent_cx::AgentCx::for_request();
9661            let session = self
9662                .session
9663                .lock(cx.cx())
9664                .await
9665                .map_err(|e| Error::session(e.to_string()))?;
9666            session.to_messages_for_current_path()
9667        };
9668        self.agent.replace_messages(history);
9669
9670        let start_len = self.agent.messages().len();
9671        let mut prompts = Vec::with_capacity(1 + custom_messages.len());
9672        prompts.push(prompt_message.clone());
9673        prompts.extend(custom_messages.into_iter().map(Message::Custom));
9674
9675        {
9676            let cx = crate::agent_cx::AgentCx::for_request();
9677            let mut session = self
9678                .session
9679                .lock(cx.cx())
9680                .await
9681                .map_err(|e| Error::session(e.to_string()))?;
9682            session.append_model_message(prompt_message.clone());
9683            if self.save_enabled {
9684                session.flush_autosave(AutosaveFlushTrigger::Manual).await?;
9685            }
9686        }
9687
9688        let semantic_context = self.prepare_semantic_context_prompt();
9689        let semantic_context_messages = semantic_context
9690            .as_ref()
9691            .map(|prepared| {
9692                Self::semantic_context_prompt_messages(prepared, Utc::now().timestamp_millis())
9693            })
9694            .unwrap_or_default();
9695        let streaming_guard = AtomicBoolGuard::activate(&self.extensions_is_streaming);
9696        let base_system_prompt = self.agent.system_prompt().map(str::to_string);
9697        self.agent
9698            .set_system_prompt(Self::semantic_context_system_prompt_for_turn(
9699                base_system_prompt.clone(),
9700                semantic_context.as_ref(),
9701            ));
9702        let on_event_for_run = Arc::clone(&on_event);
9703        prompts.extend(semantic_context_messages);
9704        let result = self
9705            .agent
9706            .run_with_messages_with_abort(prompts, abort, move |event| {
9707                on_event_for_run(event);
9708            })
9709            .await;
9710        drop(streaming_guard);
9711        self.agent.set_system_prompt(base_system_prompt);
9712
9713        let persist_result = self
9714            .persist_new_messages(start_len + 1, result.is_err())
9715            .await;
9716
9717        let result = result?;
9718        persist_result?;
9719        Ok(result)
9720    }
9721
9722    pub(crate) async fn run_agent_with_text(
9723        &mut self,
9724        input: String,
9725        abort: Option<AbortSignal>,
9726        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
9727        custom_messages: Vec<CustomMessage>,
9728    ) -> Result<AssistantMessage> {
9729        let on_event: AgentEventHandler = Arc::new(on_event);
9730        self.sync_runtime_selection_from_session_header().await?;
9731
9732        self.maybe_compact(Arc::clone(&on_event)).await?;
9733        let history = {
9734            let cx = crate::agent_cx::AgentCx::for_request();
9735            let session = self
9736                .session
9737                .lock(cx.cx())
9738                .await
9739                .map_err(|e| Error::session(e.to_string()))?;
9740            session.to_messages_for_current_path()
9741        };
9742        self.agent.replace_messages(history);
9743
9744        let start_len = self.agent.messages().len();
9745
9746        // Create and persist user message immediately to avoid data loss on API errors
9747        let user_message = Message::User(UserMessage {
9748            content: UserContent::Text(input),
9749            timestamp: Utc::now().timestamp_millis(),
9750        });
9751        let mut prompts = Vec::with_capacity(1 + custom_messages.len());
9752        prompts.push(user_message.clone());
9753        let semantic_context = self.prepare_semantic_context_prompt();
9754        let semantic_context_messages = semantic_context
9755            .as_ref()
9756            .map(|prepared| {
9757                Self::semantic_context_prompt_messages(prepared, Utc::now().timestamp_millis())
9758            })
9759            .unwrap_or_default();
9760        prompts.extend(semantic_context_messages);
9761        prompts.extend(custom_messages.into_iter().map(Message::Custom));
9762
9763        {
9764            let cx = crate::agent_cx::AgentCx::for_request();
9765            let mut session = self
9766                .session
9767                .lock(cx.cx())
9768                .await
9769                .map_err(|e| Error::session(e.to_string()))?;
9770            session.append_model_message(user_message.clone());
9771            if self.save_enabled {
9772                session.flush_autosave(AutosaveFlushTrigger::Manual).await?;
9773            }
9774        }
9775
9776        let streaming_guard = AtomicBoolGuard::activate(&self.extensions_is_streaming);
9777        let base_system_prompt = self.agent.system_prompt().map(str::to_string);
9778        self.agent
9779            .set_system_prompt(Self::semantic_context_system_prompt_for_turn(
9780                base_system_prompt.clone(),
9781                semantic_context.as_ref(),
9782            ));
9783        let on_event_for_run = Arc::clone(&on_event);
9784        let result = self
9785            .agent
9786            .run_with_messages_with_abort(prompts, abort, move |event| {
9787                on_event_for_run(event);
9788            })
9789            .await;
9790        drop(streaming_guard);
9791        self.agent.set_system_prompt(base_system_prompt);
9792
9793        // Persist any NEW messages (assistant/tools) generated before the agent stopped,
9794        // even if it stopped due to an error, skipping the user message we already saved.
9795        let persist_result = self
9796            .persist_new_messages(start_len + 1, result.is_err())
9797            .await;
9798
9799        let result = result?;
9800        persist_result?;
9801        Ok(result)
9802    }
9803
9804    pub(crate) async fn run_agent_with_content(
9805        &mut self,
9806        content: Vec<ContentBlock>,
9807        abort: Option<AbortSignal>,
9808        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
9809        custom_messages: Vec<CustomMessage>,
9810    ) -> Result<AssistantMessage> {
9811        let on_event: AgentEventHandler = Arc::new(on_event);
9812        self.sync_runtime_selection_from_session_header().await?;
9813
9814        self.maybe_compact(Arc::clone(&on_event)).await?;
9815        let history = {
9816            let cx = crate::agent_cx::AgentCx::for_request();
9817            let session = self
9818                .session
9819                .lock(cx.cx())
9820                .await
9821                .map_err(|e| Error::session(e.to_string()))?;
9822            session.to_messages_for_current_path()
9823        };
9824        self.agent.replace_messages(history);
9825
9826        let start_len = self.agent.messages().len();
9827
9828        // Create and persist user message immediately to avoid data loss on API errors
9829        let user_message = Message::User(UserMessage {
9830            content: UserContent::Blocks(content),
9831            timestamp: Utc::now().timestamp_millis(),
9832        });
9833        let mut prompts = Vec::with_capacity(1 + custom_messages.len());
9834        prompts.push(user_message.clone());
9835        let semantic_context = self.prepare_semantic_context_prompt();
9836        let semantic_context_messages = semantic_context
9837            .as_ref()
9838            .map(|prepared| {
9839                Self::semantic_context_prompt_messages(prepared, Utc::now().timestamp_millis())
9840            })
9841            .unwrap_or_default();
9842        prompts.extend(semantic_context_messages);
9843        prompts.extend(custom_messages.into_iter().map(Message::Custom));
9844
9845        {
9846            let cx = crate::agent_cx::AgentCx::for_request();
9847            let mut session = self
9848                .session
9849                .lock(cx.cx())
9850                .await
9851                .map_err(|e| Error::session(e.to_string()))?;
9852            session.append_model_message(user_message.clone());
9853            if self.save_enabled {
9854                session.flush_autosave(AutosaveFlushTrigger::Manual).await?;
9855            }
9856        }
9857
9858        let streaming_guard = AtomicBoolGuard::activate(&self.extensions_is_streaming);
9859        let base_system_prompt = self.agent.system_prompt().map(str::to_string);
9860        self.agent
9861            .set_system_prompt(Self::semantic_context_system_prompt_for_turn(
9862                base_system_prompt.clone(),
9863                semantic_context.as_ref(),
9864            ));
9865        let on_event_for_run = Arc::clone(&on_event);
9866        let result = self
9867            .agent
9868            .run_with_messages_with_abort(prompts, abort, move |event| {
9869                on_event_for_run(event);
9870            })
9871            .await;
9872        drop(streaming_guard);
9873        self.agent.set_system_prompt(base_system_prompt);
9874
9875        // Persist any NEW messages (assistant/tools) generated before the agent stopped,
9876        // even if it stopped due to an error, skipping the user message we already saved.
9877        let persist_result = self
9878            .persist_new_messages(start_len + 1, result.is_err())
9879            .await;
9880
9881        let result = result?;
9882        persist_result?;
9883        Ok(result)
9884    }
9885
9886    /// Resume the current turn after a transient failure WITHOUT adding a new
9887    /// user message: the agent loop continues from the last completed state
9888    /// (the user prompt plus any already-completed tool cycles that are still
9889    /// on the session path), so a retry re-issues only the failed provider
9890    /// request instead of replaying the whole turn. This is what makes
9891    /// auto-retry idempotent — no tool re-execution, no re-billing of prior
9892    /// work (pi_agent_rust#125).
9893    ///
9894    /// Callers should strip the failed request's incomplete output first via
9895    /// [`Self::revert_incomplete_response`] so the resume streams from a clean
9896    /// tail (no dangling error/partial assistant, no orphaned tool call).
9897    pub async fn run_continue_with_abort(
9898        &mut self,
9899        abort: Option<AbortSignal>,
9900        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
9901    ) -> Result<AssistantMessage> {
9902        let on_event: AgentEventHandler = Arc::new(on_event);
9903        self.sync_runtime_selection_from_session_header().await?;
9904
9905        // Rehydrate the agent transcript from the (already reverted) session
9906        // path so the resume streams from the last completed state.
9907        let history = {
9908            let cx = crate::agent_cx::AgentCx::for_request();
9909            let session = self
9910                .session
9911                .lock(cx.cx())
9912                .await
9913                .map_err(|e| Error::session(e.to_string()))?;
9914            session.to_messages_for_current_path()
9915        };
9916        self.agent.replace_messages(history);
9917        let start_len = self.agent.messages().len();
9918
9919        let streaming_guard = AtomicBoolGuard::activate(&self.extensions_is_streaming);
9920        let on_event_for_run = Arc::clone(&on_event);
9921        let result = self
9922            .agent
9923            .run_continue_with_abort(abort, move |event| {
9924                on_event_for_run(event);
9925            })
9926            .await;
9927        drop(streaming_guard);
9928
9929        // Persist any NEW messages generated by the resume, even on error.
9930        // No user message was added, so nothing to skip: persist from start_len.
9931        let persist_result = self.persist_new_messages(start_len, result.is_err()).await;
9932
9933        let result = result?;
9934        persist_result?;
9935        Ok(result)
9936    }
9937
9938    async fn persist_new_messages(&self, start_len: usize, run_failed: bool) -> Result<()> {
9939        let new_messages = self.agent.messages()[start_len..].to_vec();
9940        {
9941            let cx = crate::agent_cx::AgentCx::for_request();
9942            let mut session = self
9943                .session
9944                .lock(cx.cx())
9945                .await
9946                .map_err(|e| Error::session(e.to_string()))?;
9947            for message in new_messages {
9948                if run_failed && is_synthetic_empty_error_assistant(&message) {
9949                    continue;
9950                }
9951                session.append_model_message(message);
9952            }
9953            if self.save_enabled {
9954                session
9955                    .flush_autosave(AutosaveFlushTrigger::Periodic)
9956                    .await?;
9957            }
9958        }
9959        Ok(())
9960    }
9961}
9962
9963fn is_synthetic_empty_error_assistant(message: &Message) -> bool {
9964    matches!(
9965        message,
9966        Message::Assistant(assistant)
9967            if assistant.content.is_empty()
9968                && matches!(assistant.stop_reason, StopReason::Error)
9969                && assistant.error_message.is_some()
9970    )
9971}
9972
9973fn semantic_context_prompt_shape_for_provider(api: &str) -> SemanticContextPromptShape {
9974    match api {
9975        "bedrock-converse-stream" | "gitlab-chat" => SemanticContextPromptShape::SystemPromptAppend,
9976        _ => SemanticContextPromptShape::CustomUserMessage,
9977    }
9978}
9979
9980fn semantic_context_prompt_budget_for_provider(
9981    api: &str,
9982    injection: &SemanticContextBundleInjection,
9983) -> SemanticContextPromptBudget {
9984    let provider_max_bytes = match api {
9985        "gitlab-chat" => 8 * 1024,
9986        "bedrock-converse-stream" | "google-gemini" | "google-vertex" => 12 * 1024,
9987        "openai-responses" | "openai-completions" | "azure-openai" => 24 * 1024,
9988        "anthropic" => 32 * 1024,
9989        _ => DEFAULT_SEMANTIC_CONTEXT_PROMPT_MAX_BYTES,
9990    };
9991    let provider_max_items = match api {
9992        "gitlab-chat" => 8,
9993        "bedrock-converse-stream" | "google-gemini" | "google-vertex" => 12,
9994        _ => DEFAULT_SEMANTIC_CONTEXT_PROMPT_MAX_ITEMS,
9995    };
9996
9997    SemanticContextPromptBudget {
9998        max_items: injection
9999            .max_prompt_items
10000            .min(injection.bundle.budget.max_items)
10001            .min(provider_max_items),
10002        max_bytes: injection
10003            .max_prompt_bytes
10004            .min(injection.bundle.budget.max_bytes)
10005            .min(provider_max_bytes),
10006    }
10007}
10008
10009fn semantic_context_bundle_revision(bundle: &SemanticContextBundle) -> String {
10010    let bytes = serde_json::to_vec(bundle).unwrap_or_else(|_| {
10011        format!(
10012            "{}:{}:{}:{}",
10013            bundle.schema,
10014            bundle.invalidation_policy.input_fingerprint_sha256,
10015            bundle.selected_items.len(),
10016            bundle.estimated_bytes
10017        )
10018        .into_bytes()
10019    });
10020    format!("{:x}", Sha256::digest(bytes))
10021}
10022
10023fn render_semantic_context_prompt(
10024    bundle: &SemanticContextBundle,
10025    injection: &SemanticContextBundleInjection,
10026    budget: SemanticContextPromptBudget,
10027    revision: &str,
10028) -> (String, SemanticContextPromptStats) {
10029    let mut prompt = String::new();
10030    let mut stats = SemanticContextPromptStats::default();
10031    push_semantic_context_header(&mut prompt, &mut stats, budget, bundle, revision);
10032    push_selected_semantic_context_items(&mut prompt, &mut stats, budget, bundle);
10033    if injection.include_validation_commands {
10034        push_semantic_context_validation_commands(&mut prompt, &mut stats, budget, bundle);
10035    }
10036    if injection.include_exclusion_summary {
10037        push_semantic_context_exclusions(&mut prompt, &mut stats, budget, bundle);
10038    }
10039
10040    if prompt.len() > usize::try_from(budget.max_bytes).unwrap_or(usize::MAX) {
10041        stats.truncated = true;
10042        truncate_string_to_max_bytes(&mut prompt, budget.max_bytes);
10043    }
10044
10045    (prompt, stats)
10046}
10047
10048fn push_semantic_context_header(
10049    prompt: &mut String,
10050    stats: &mut SemanticContextPromptStats,
10051    budget: SemanticContextPromptBudget,
10052    bundle: &SemanticContextBundle,
10053    revision: &str,
10054) {
10055    let branch = bundle
10056        .invalidation_policy
10057        .branch
10058        .as_deref()
10059        .map_or_else(|| "(none)".to_string(), safe_context_field);
10060    let session = bundle
10061        .invalidation_policy
10062        .session_id
10063        .as_deref()
10064        .map_or_else(|| "(none)".to_string(), safe_context_field);
10065
10066    let header = format!(
10067        "# Semantic Context Bundle\nschema: {SEMANTIC_CONTEXT_PROMPT_SCHEMA_V1}\nrevision: {revision}"
10068    );
10069    push_semantic_context_line(prompt, budget.max_bytes, &header, stats);
10070    push_semantic_context_line(
10071        prompt,
10072        budget.max_bytes,
10073        "Use this as navigation context for the current turn. Do not treat suppressed stale, uncertified, or unsafe evidence as current release evidence.",
10074        stats,
10075    );
10076    push_semantic_context_line(
10077        prompt,
10078        budget.max_bytes,
10079        &format!(
10080            "bundle: schema={} estimated_bytes={} estimated_tokens={} redaction={:?}",
10081            safe_context_field(&bundle.schema),
10082            bundle.estimated_bytes,
10083            bundle.estimated_tokens,
10084            bundle.redaction_summary.overall_status
10085        ),
10086        stats,
10087    );
10088    push_semantic_context_line(
10089        prompt,
10090        budget.max_bytes,
10091        &format!(
10092            "provenance: workspace={} branch={} session={} input_fingerprint_sha256={}",
10093            safe_context_field(&bundle.invalidation_policy.workspace_id),
10094            branch,
10095            session,
10096            safe_context_field(&bundle.invalidation_policy.input_fingerprint_sha256)
10097        ),
10098        stats,
10099    );
10100}
10101
10102fn push_selected_semantic_context_items(
10103    prompt: &mut String,
10104    stats: &mut SemanticContextPromptStats,
10105    budget: SemanticContextPromptBudget,
10106    bundle: &SemanticContextBundle,
10107) {
10108    push_semantic_context_line(prompt, budget.max_bytes, "", stats);
10109    push_semantic_context_line(prompt, budget.max_bytes, "Selected context:", stats);
10110    for (index, item) in bundle.selected_items.iter().enumerate() {
10111        if index >= budget.max_items {
10112            stats.selected_items_omitted = stats
10113                .selected_items_omitted
10114                .saturating_add(bundle.selected_items.len().saturating_sub(index));
10115            break;
10116        }
10117        if push_semantic_context_item(prompt, stats, budget, item, index + 1) {
10118            stats.selected_items_included = stats.selected_items_included.saturating_add(1);
10119        } else {
10120            stats.selected_items_omitted = stats
10121                .selected_items_omitted
10122                .saturating_add(bundle.selected_items.len().saturating_sub(index));
10123            break;
10124        }
10125    }
10126    if bundle.selected_items.is_empty() {
10127        push_semantic_context_line(prompt, budget.max_bytes, "- (none)", stats);
10128    }
10129}
10130
10131fn push_semantic_context_validation_commands(
10132    prompt: &mut String,
10133    stats: &mut SemanticContextPromptStats,
10134    budget: SemanticContextPromptBudget,
10135    bundle: &SemanticContextBundle,
10136) {
10137    push_semantic_context_line(prompt, budget.max_bytes, "", stats);
10138    push_semantic_context_line(
10139        prompt,
10140        budget.max_bytes,
10141        "Suggested validation commands:",
10142        stats,
10143    );
10144    if bundle.suggested_validation_commands.is_empty() {
10145        push_semantic_context_line(prompt, budget.max_bytes, "- (none)", stats);
10146        return;
10147    }
10148
10149    for (index, command) in bundle.suggested_validation_commands.iter().enumerate() {
10150        let line = format!("- {}", safe_context_field(command));
10151        if push_semantic_context_line(prompt, budget.max_bytes, &line, stats) {
10152            stats.validation_commands_included =
10153                stats.validation_commands_included.saturating_add(1);
10154        } else {
10155            stats.validation_commands_omitted = bundle
10156                .suggested_validation_commands
10157                .len()
10158                .saturating_sub(index);
10159            break;
10160        }
10161    }
10162}
10163
10164fn push_semantic_context_exclusions(
10165    prompt: &mut String,
10166    stats: &mut SemanticContextPromptStats,
10167    budget: SemanticContextPromptBudget,
10168    bundle: &SemanticContextBundle,
10169) {
10170    push_semantic_context_line(prompt, budget.max_bytes, "", stats);
10171    push_semantic_context_line(
10172        prompt,
10173        budget.max_bytes,
10174        "Suppressed or excluded context:",
10175        stats,
10176    );
10177    if bundle.stale_evidence_suppressions.is_empty() && bundle.excluded_items.is_empty() {
10178        push_semantic_context_line(prompt, budget.max_bytes, "- (none)", stats);
10179        return;
10180    }
10181
10182    for (index, item) in bundle
10183        .stale_evidence_suppressions
10184        .iter()
10185        .chain(bundle.excluded_items.iter())
10186        .take(8)
10187        .enumerate()
10188    {
10189        let line = format!(
10190            "- {:?} {} :: {} reason={}",
10191            item.node_type,
10192            safe_context_field(&item.source_path),
10193            safe_context_field(&item.title),
10194            safe_context_field(&item.reason)
10195        );
10196        if push_semantic_context_line(prompt, budget.max_bytes, &line, stats) {
10197            stats.exclusions_included = stats.exclusions_included.saturating_add(1);
10198        } else {
10199            stats.exclusions_omitted = bundle
10200                .stale_evidence_suppressions
10201                .len()
10202                .saturating_add(bundle.excluded_items.len())
10203                .saturating_sub(index);
10204            break;
10205        }
10206    }
10207}
10208
10209fn push_semantic_context_item(
10210    prompt: &mut String,
10211    stats: &mut SemanticContextPromptStats,
10212    budget: SemanticContextPromptBudget,
10213    item: &ContextBundleItem,
10214    ordinal: usize,
10215) -> bool {
10216    let freshness = item.freshness_status.map_or_else(
10217        || "not_applicable".to_string(),
10218        |status| format!("{status:?}"),
10219    );
10220    let line = format!(
10221        "{ordinal}. {:?} {} :: {}",
10222        item.node_type,
10223        safe_context_field(&item.source_path),
10224        safe_context_field(&item.title)
10225    );
10226    let detail = format!(
10227        "   reason={} score={} tokens={} freshness={} redaction={:?}",
10228        safe_context_field(&item.reason),
10229        item.score,
10230        item.estimated_tokens,
10231        freshness,
10232        item.redaction_status
10233    );
10234    push_semantic_context_line(prompt, budget.max_bytes, &line, stats)
10235        && push_semantic_context_line(prompt, budget.max_bytes, &detail, stats)
10236}
10237
10238fn push_semantic_context_line(
10239    prompt: &mut String,
10240    max_bytes: u64,
10241    line: &str,
10242    stats: &mut SemanticContextPromptStats,
10243) -> bool {
10244    let max_bytes = usize::try_from(max_bytes).unwrap_or(usize::MAX);
10245    let required = line.len().saturating_add(1);
10246    if prompt.len().saturating_add(required) > max_bytes {
10247        stats.truncated = true;
10248        return false;
10249    }
10250    prompt.push_str(line);
10251    prompt.push('\n');
10252    true
10253}
10254
10255fn truncate_string_to_max_bytes(value: &mut String, max_bytes: u64) {
10256    let max_bytes = usize::try_from(max_bytes).unwrap_or(usize::MAX);
10257    if value.len() <= max_bytes {
10258        return;
10259    }
10260    let mut end = max_bytes;
10261    while !value.is_char_boundary(end) {
10262        end = end.saturating_sub(1);
10263    }
10264    value.truncate(end);
10265}
10266
10267fn safe_context_field(value: &str) -> String {
10268    let mut output = String::with_capacity(value.len().min(512));
10269    for ch in value.chars() {
10270        if matches!(ch, '\n' | '\r' | '\t') {
10271            output.push(' ');
10272        } else if ch.is_control() {
10273            output.push('?');
10274        } else {
10275            output.push(ch);
10276        }
10277        if output.len() >= 512 {
10278            output.push_str("...");
10279            break;
10280        }
10281    }
10282    output
10283}
10284
10285// ============================================================================
10286// Helper Functions
10287// ============================================================================
10288
10289/// Log a summary of auto-repair events that fired during extension loading.
10290///
10291/// Default: one-line summary.  Set `PI_AUTO_REPAIR_VERBOSE=1` for per-extension
10292/// detail.  Structured tracing events are always emitted regardless of verbosity.
10293fn log_repair_diagnostics(events: &[crate::extensions_js::ExtensionRepairEvent]) {
10294    use std::collections::BTreeMap;
10295
10296    // Always emit structured tracing events for each repair.
10297    for ev in events {
10298        tracing::info!(
10299            event = "extension.auto_repair",
10300            extension_id = %ev.extension_id,
10301            pattern = %ev.pattern,
10302            success = ev.success,
10303            original_error = %ev.original_error,
10304            repair_action = %ev.repair_action,
10305        );
10306    }
10307
10308    // Group by pattern for the summary line.
10309    let mut by_pattern: BTreeMap<String, Vec<&str>> = BTreeMap::new();
10310    for ev in events {
10311        by_pattern
10312            .entry(ev.pattern.to_string())
10313            .or_default()
10314            .push(&ev.extension_id);
10315    }
10316
10317    let verbose = std::env::var("PI_AUTO_REPAIR_VERBOSE")
10318        .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
10319
10320    if verbose {
10321        warn!(
10322            "[auto-repair] {} extension{} auto-repaired:",
10323            events.len(),
10324            if events.len() == 1 { "" } else { "s" }
10325        );
10326        for ev in events {
10327            warn!(
10328                "  {}: {} ({})",
10329                ev.pattern, ev.extension_id, ev.repair_action
10330            );
10331        }
10332    } else {
10333        // Compact one-line summary.
10334        let patterns: Vec<String> = by_pattern
10335            .iter()
10336            .map(|(pat, ids)| format!("{pat}:{}", ids.len()))
10337            .collect();
10338        tracing::info!(
10339            event = "extension.auto_repair.summary",
10340            count = events.len(),
10341            patterns = %patterns.join(", "),
10342            "auto-repaired {} extension(s)",
10343            events.len(),
10344        );
10345    }
10346}
10347
10348const BLOCK_IMAGES_PLACEHOLDER: &str = "Image reading is disabled.";
10349
10350#[derive(Debug, Default, Clone, Copy)]
10351struct ImageFilterStats {
10352    removed_images: usize,
10353    affected_messages: usize,
10354}
10355
10356fn filter_images_for_provider(messages: &mut [Message]) -> ImageFilterStats {
10357    let mut stats = ImageFilterStats::default();
10358    for message in messages {
10359        let removed = filter_images_from_message(message);
10360        if removed > 0 {
10361            stats.removed_images += removed;
10362            stats.affected_messages += 1;
10363        }
10364    }
10365    stats
10366}
10367
10368fn filter_images_from_message(message: &mut Message) -> usize {
10369    match message {
10370        Message::User(user) => match &mut user.content {
10371            UserContent::Text(_) => 0,
10372            UserContent::Blocks(blocks) => filter_image_blocks(blocks),
10373        },
10374        Message::Assistant(assistant) => {
10375            let assistant = Arc::make_mut(assistant);
10376            filter_image_blocks(&mut assistant.content)
10377        }
10378        Message::ToolResult(tool_result) => {
10379            filter_image_blocks(&mut Arc::make_mut(tool_result).content)
10380        }
10381        Message::Custom(_) => 0,
10382    }
10383}
10384
10385fn filter_image_blocks(blocks: &mut Vec<ContentBlock>) -> usize {
10386    let mut removed = 0usize;
10387    let mut filtered = Vec::with_capacity(blocks.len());
10388
10389    for block in blocks.drain(..) {
10390        match block {
10391            ContentBlock::Image(_) => {
10392                removed += 1;
10393                let previous_is_placeholder =
10394                    filtered
10395                        .last()
10396                        .is_some_and(|prev| matches!(prev, ContentBlock::Text(TextContent { text, .. }) if text.as_str().eq(BLOCK_IMAGES_PLACEHOLDER)));
10397                if !previous_is_placeholder {
10398                    filtered.push(ContentBlock::Text(TextContent::new(
10399                        BLOCK_IMAGES_PLACEHOLDER,
10400                    )));
10401                }
10402            }
10403            other => filtered.push(other),
10404        }
10405    }
10406
10407    *blocks = filtered;
10408    removed
10409}
10410
10411/// Extract tool calls from content blocks.
10412fn extract_tool_calls(content: &[ContentBlock]) -> Vec<ToolCall> {
10413    content
10414        .iter()
10415        .filter_map(|block| {
10416            if let ContentBlock::ToolCall(tc) = block {
10417                Some(tc.clone())
10418            } else {
10419                None
10420            }
10421        })
10422        .collect()
10423}
10424
10425// ============================================================================
10426// Tests
10427// ============================================================================
10428
10429#[cfg(test)]
10430mod tests {
10431    use super::*;
10432    use crate::auth::AuthCredential;
10433    use crate::provider::{InputType, Model, ModelCost};
10434    use asupersync::runtime::RuntimeBuilder;
10435    use async_trait::async_trait;
10436    use futures::Stream;
10437    use std::collections::BTreeSet;
10438    use std::collections::HashMap;
10439    use std::path::Path;
10440    use std::pin::Pin;
10441    use std::sync::{Arc as StdArc, Mutex as StdTestMutex};
10442
10443    fn user_message(text: &str) -> Message {
10444        Message::User(UserMessage {
10445            content: UserContent::Text(text.to_string()),
10446            timestamp: 0,
10447        })
10448    }
10449
10450    fn assert_user_text(message: &Message, expected: &str) {
10451        assert!(
10452            matches!(
10453                message,
10454                Message::User(UserMessage {
10455                    content: UserContent::Text(_),
10456                    ..
10457                })
10458            ),
10459            "expected user text message, got {message:?}"
10460        );
10461        if let Message::User(UserMessage {
10462            content: UserContent::Text(text),
10463            ..
10464        }) = message
10465        {
10466            assert_eq!(text, expected);
10467        }
10468    }
10469
10470    fn sample_image_block() -> ContentBlock {
10471        ContentBlock::Image(ImageContent {
10472            data: "aGVsbG8=".to_string(),
10473            mime_type: "image/png".to_string(),
10474        })
10475    }
10476
10477    fn image_count_in_message(message: &Message) -> usize {
10478        let count_images = |blocks: &[ContentBlock]| {
10479            blocks
10480                .iter()
10481                .filter(|block| matches!(block, ContentBlock::Image(_)))
10482                .count()
10483        };
10484        match message {
10485            Message::User(UserMessage {
10486                content: UserContent::Blocks(blocks),
10487                ..
10488            }) => count_images(blocks),
10489            Message::Assistant(msg) => count_images(&msg.content),
10490            Message::ToolResult(tool_result) => count_images(&tool_result.content),
10491            Message::User(UserMessage {
10492                content: UserContent::Text(_),
10493                ..
10494            })
10495            | Message::Custom(_) => 0,
10496        }
10497    }
10498
10499    fn assistant_message(text: &str) -> AssistantMessage {
10500        AssistantMessage {
10501            content: vec![ContentBlock::Text(TextContent::new(text))],
10502            api: "test-api".to_string(),
10503            provider: "test-provider".to_string(),
10504            model: "test-model".to_string(),
10505            usage: Usage::default(),
10506            stop_reason: StopReason::Stop,
10507            error_message: None,
10508            timestamp: 0,
10509        }
10510    }
10511
10512    #[derive(Debug)]
10513    struct SilentProvider;
10514
10515    #[async_trait]
10516    #[allow(clippy::unnecessary_literal_bound)]
10517    impl Provider for SilentProvider {
10518        fn name(&self) -> &str {
10519            "silent-provider"
10520        }
10521
10522        fn api(&self) -> &str {
10523            "test-api"
10524        }
10525
10526        fn model_id(&self) -> &str {
10527            "test-model"
10528        }
10529
10530        async fn stream(
10531            &self,
10532            _context: &Context<'_>,
10533            _options: &StreamOptions,
10534        ) -> crate::error::Result<
10535            Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
10536        > {
10537            Ok(Box::pin(futures::stream::empty()))
10538        }
10539    }
10540
10541    #[derive(Debug)]
10542    struct DeltaOnlyProvider;
10543
10544    #[async_trait]
10545    #[allow(clippy::unnecessary_literal_bound)]
10546    impl Provider for DeltaOnlyProvider {
10547        fn name(&self) -> &str {
10548            "test-provider"
10549        }
10550
10551        fn api(&self) -> &str {
10552            "test-api"
10553        }
10554
10555        fn model_id(&self) -> &str {
10556            "test-model"
10557        }
10558
10559        async fn stream(
10560            &self,
10561            _context: &Context<'_>,
10562            _options: &StreamOptions,
10563        ) -> crate::error::Result<
10564            Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
10565        > {
10566            let final_message = assistant_message("hello");
10567            let events = vec![
10568                Ok(StreamEvent::TextDelta {
10569                    content_index: 0,
10570                    delta: "hello".to_string(),
10571                }),
10572                Ok(StreamEvent::Done {
10573                    reason: StopReason::Stop,
10574                    message: final_message,
10575                }),
10576            ];
10577            Ok(Box::pin(futures::stream::iter(events)))
10578        }
10579    }
10580
10581    #[derive(Debug, Default)]
10582    struct CapturedProviderContext {
10583        system_prompt: Option<String>,
10584        messages: Vec<Message>,
10585    }
10586
10587    #[derive(Debug)]
10588    struct CapturingProvider {
10589        api: &'static str,
10590        calls: StdArc<StdTestMutex<Vec<CapturedProviderContext>>>,
10591    }
10592
10593    impl CapturingProvider {
10594        fn new(api: &'static str) -> Self {
10595            Self {
10596                api,
10597                calls: StdArc::new(StdTestMutex::new(Vec::new())),
10598            }
10599        }
10600
10601        fn calls(&self) -> StdArc<StdTestMutex<Vec<CapturedProviderContext>>> {
10602            StdArc::clone(&self.calls)
10603        }
10604    }
10605
10606    #[async_trait]
10607    #[allow(clippy::unnecessary_literal_bound)]
10608    impl Provider for CapturingProvider {
10609        fn name(&self) -> &str {
10610            "capturing-provider"
10611        }
10612
10613        fn api(&self) -> &str {
10614            self.api
10615        }
10616
10617        fn model_id(&self) -> &str {
10618            "capture-model"
10619        }
10620
10621        async fn stream(
10622            &self,
10623            context: &Context<'_>,
10624            _options: &StreamOptions,
10625        ) -> crate::error::Result<
10626            Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
10627        > {
10628            self.calls
10629                .lock()
10630                .expect("capture context lock")
10631                .push(CapturedProviderContext {
10632                    system_prompt: context.system_prompt.as_ref().map(ToString::to_string),
10633                    messages: context.messages.iter().cloned().collect(),
10634                });
10635            let final_message = assistant_message("captured");
10636            Ok(Box::pin(futures::stream::iter(vec![Ok(
10637                StreamEvent::Done {
10638                    reason: StopReason::Stop,
10639                    message: final_message,
10640                },
10641            )])))
10642        }
10643    }
10644
10645    fn sample_semantic_context_bundle() -> SemanticContextBundle {
10646        use crate::semantic_workspace_graph::{
10647            ContextBundleBudget, ContextBundleExclusion, ContextBundleInvalidationPolicy,
10648            ContextRedactionSummary, EvidenceFreshnessStatus, RedactionStatus, SemanticNodeType,
10649        };
10650
10651        SemanticContextBundle {
10652            schema: crate::semantic_workspace_graph::SEMANTIC_CONTEXT_BUNDLE_SCHEMA.to_string(),
10653            budget: ContextBundleBudget {
10654                max_items: 8,
10655                max_bytes: 4096,
10656            },
10657            selected_items: vec![
10658                ContextBundleItem {
10659                    node_id: "node-session".to_string(),
10660                    node_type: SemanticNodeType::CodeSymbol,
10661                    source_path: "src/agent.rs".to_string(),
10662                    title: "AgentSession::run_agent_with_text".to_string(),
10663                    reason: "query_match,related_to_bead_or_changed_path".to_string(),
10664                    score: 420,
10665                    estimated_bytes: 700,
10666                    estimated_tokens: 175,
10667                    freshness_status: None,
10668                    redaction_status: RedactionStatus::None,
10669                },
10670                ContextBundleItem {
10671                    node_id: "node-test".to_string(),
10672                    node_type: SemanticNodeType::TestCase,
10673                    source_path: "tests/agent_loop_reliability.rs".to_string(),
10674                    title: "semantic context session coverage".to_string(),
10675                    reason: "validation_context".to_string(),
10676                    score: 300,
10677                    estimated_bytes: 400,
10678                    estimated_tokens: 100,
10679                    freshness_status: Some(EvidenceFreshnessStatus::Current),
10680                    redaction_status: RedactionStatus::Redacted,
10681                },
10682            ],
10683            excluded_items: vec![ContextBundleExclusion {
10684                node_id: "stale-doc".to_string(),
10685                node_type: SemanticNodeType::DocSection,
10686                source_path: "README.md".to_string(),
10687                title: "obsolete drop-in claim".to_string(),
10688                reason: "suppressed_stale_or_unsafe_evidence".to_string(),
10689                score: 250,
10690                estimated_bytes: 300,
10691                freshness_status: Some(EvidenceFreshnessStatus::Uncertified),
10692                redaction_status: RedactionStatus::SensitiveOmitted,
10693            }],
10694            stale_evidence_suppressions: Vec::new(),
10695            redaction_summary: ContextRedactionSummary {
10696                policy_version: "test-policy".to_string(),
10697                overall_status: RedactionStatus::Redacted,
10698                selected_redacted_nodes: 1,
10699                selected_sensitive_omissions: 0,
10700                suppressed_unsafe_nodes: 0,
10701                redacted_metadata_keys: BTreeSet::from(["api_key".to_string()]),
10702                sensitive_path_kinds: BTreeSet::new(),
10703            },
10704            invalidation_policy: ContextBundleInvalidationPolicy {
10705                policy_version: "test-policy".to_string(),
10706                workspace_id: "workspace:test".to_string(),
10707                branch: Some("main".to_string()),
10708                session_id: Some("session-123".to_string()),
10709                input_fingerprint_sha256: "abc123".repeat(10),
10710                cache_ttl_seconds: 900,
10711                generated_at_utc: Some("2026-05-13T00:00:00Z".to_string()),
10712                expires_at_utc: Some("2026-05-13T00:15:00Z".to_string()),
10713                invalidates_on: vec!["input_fingerprint_change".to_string()],
10714                cacheable: true,
10715            },
10716            path_normalization: Vec::new(),
10717            suggested_validation_commands: vec![
10718                "cargo test agent_semantic_context".to_string(),
10719                "cargo check --all-targets".to_string(),
10720            ],
10721            estimated_bytes: 1100,
10722            estimated_tokens: 275,
10723        }
10724    }
10725
10726    #[test]
10727    fn delta_without_start_does_not_mutate_previous_message() {
10728        let runtime = RuntimeBuilder::current_thread()
10729            .build()
10730            .expect("runtime build");
10731
10732        runtime.block_on(async {
10733            let provider = Arc::new(DeltaOnlyProvider);
10734            let tools = ToolRegistry::from_tools(Vec::new());
10735            let mut agent = Agent::new(provider, tools, AgentConfig::default());
10736
10737            agent.add_message(Message::Assistant(Arc::new(assistant_message("prev"))));
10738
10739            agent
10740                .run_with_message_with_abort(user_message("hi"), None, |_| {})
10741                .await
10742                .expect("run");
10743
10744            let assistant_texts = agent
10745                .messages()
10746                .iter()
10747                .filter_map(|message| match message {
10748                    Message::Assistant(msg)
10749                        if matches!(msg.content.as_slice(), [ContentBlock::Text(_)]) =>
10750                    {
10751                        if let [ContentBlock::Text(text)] = msg.content.as_slice() {
10752                            Some(text.text.clone())
10753                        } else {
10754                            None
10755                        }
10756                    }
10757                    _ => None,
10758                })
10759                .collect::<Vec<_>>();
10760
10761            assert_eq!(
10762                assistant_texts.as_slice(),
10763                ["prev".to_string(), "hello".to_string()]
10764            );
10765        });
10766    }
10767
10768    #[test]
10769    fn semantic_context_bundle_injection_is_disabled_by_default() {
10770        let runtime = RuntimeBuilder::current_thread()
10771            .build()
10772            .expect("runtime build");
10773
10774        runtime.block_on(async {
10775            let provider = CapturingProvider::new("openai-responses");
10776            let calls = provider.calls();
10777            let agent = Agent::new(
10778                Arc::new(provider),
10779                ToolRegistry::from_tools(Vec::new()),
10780                AgentConfig::default(),
10781            );
10782            let session = Arc::new(Mutex::new(Session::in_memory()));
10783            let mut agent_session =
10784                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
10785
10786            agent_session
10787                .run_text("hello".to_string(), |_| {})
10788                .await
10789                .expect("run with default context settings");
10790
10791            let calls = match calls.lock() {
10792                Ok(calls) => calls,
10793                Err(poisoned) => poisoned.into_inner(),
10794            };
10795            assert_eq!(calls.len(), 1);
10796            assert_eq!(calls[0].messages.len(), 1);
10797            assert_user_text(&calls[0].messages[0], "hello");
10798            assert!(calls[0].system_prompt.is_none());
10799            drop(calls);
10800        });
10801    }
10802
10803    #[test]
10804    fn semantic_context_bundle_injection_adds_bounded_custom_message_and_session_provenance() {
10805        let runtime = RuntimeBuilder::current_thread()
10806            .build()
10807            .expect("runtime build");
10808
10809        runtime.block_on(async {
10810            let bundle = sample_semantic_context_bundle();
10811            let revision = semantic_context_bundle_revision(&bundle);
10812            let provider = CapturingProvider::new("openai-responses");
10813            let calls = provider.calls();
10814            let agent = Agent::new(
10815                Arc::new(provider),
10816                ToolRegistry::from_tools(Vec::new()),
10817                AgentConfig::default(),
10818            );
10819            let session = Arc::new(Mutex::new(Session::in_memory()));
10820            let mut agent_session = AgentSession::new(
10821                agent,
10822                Arc::clone(&session),
10823                false,
10824                ResolvedCompactionSettings::default(),
10825            );
10826            agent_session.set_semantic_context_bundle(Some(
10827                SemanticContextBundleInjection::enabled(bundle).with_prompt_budget(4, 2048),
10828            ));
10829
10830            agent_session
10831                .run_text("use context".to_string(), |_| {})
10832                .await
10833                .expect("run with context bundle");
10834
10835            {
10836                let calls = match calls.lock() {
10837                    Ok(calls) => calls,
10838                    Err(poisoned) => poisoned.into_inner(),
10839                };
10840                assert_eq!(calls.len(), 1);
10841                assert_eq!(calls[0].messages.len(), 2);
10842                assert_user_text(&calls[0].messages[0], "use context");
10843                let custom = match &calls[0].messages[1] {
10844                    Message::Custom(custom) => custom,
10845                    other => {
10846                        assert!(
10847                            matches!(other, Message::Custom(_)),
10848                            "expected custom semantic context message"
10849                        );
10850                        return;
10851                    }
10852                };
10853                assert_eq!(custom.custom_type, SEMANTIC_CONTEXT_CUSTOM_TYPE);
10854                assert!(custom.display);
10855                assert!(custom.content.len() <= 2048);
10856                assert!(custom.content.contains("Semantic Context Bundle"));
10857                assert!(custom.content.contains("src/agent.rs"));
10858                let details = custom.details.as_ref().expect("context provenance");
10859                assert_eq!(
10860                    details.get("bundleRevision").and_then(Value::as_str),
10861                    Some(revision.as_str())
10862                );
10863                assert_eq!(
10864                    details
10865                        .pointer("/provider/promptShape")
10866                        .and_then(Value::as_str),
10867                    Some("custom_user_message")
10868                );
10869                drop(calls);
10870            }
10871
10872            let cx = crate::agent_cx::AgentCx::for_request();
10873            let stored = session
10874                .lock(cx.cx())
10875                .await
10876                .expect("session lock")
10877                .to_messages_for_current_path();
10878            assert!(
10879                stored.iter().any(|message| matches!(
10880                    message,
10881                    Message::Custom(CustomMessage { custom_type, details, display: true, .. })
10882                        if custom_type == SEMANTIC_CONTEXT_CUSTOM_TYPE
10883                            && details
10884                                .as_ref()
10885                                .and_then(|value| value.get("bundleRevision"))
10886                                .and_then(Value::as_str)
10887                                == Some(revision.as_str())
10888                )),
10889                "semantic context provenance was not persisted in session messages: {stored:?}"
10890            );
10891        });
10892    }
10893
10894    #[test]
10895    fn semantic_context_bundle_uses_system_prompt_append_for_providers_without_custom_context() {
10896        let runtime = RuntimeBuilder::current_thread()
10897            .build()
10898            .expect("runtime build");
10899
10900        runtime.block_on(async {
10901            let bundle = sample_semantic_context_bundle();
10902            let revision = semantic_context_bundle_revision(&bundle);
10903            let provider = CapturingProvider::new("gitlab-chat");
10904            let calls = provider.calls();
10905            let agent = Agent::new(
10906                Arc::new(provider),
10907                ToolRegistry::from_tools(Vec::new()),
10908                AgentConfig {
10909                    system_prompt: Some("base prompt".to_string()),
10910                    ..AgentConfig::default()
10911                },
10912            );
10913            let session = Arc::new(Mutex::new(Session::in_memory()));
10914            let mut agent_session = AgentSession::new(
10915                agent,
10916                Arc::clone(&session),
10917                false,
10918                ResolvedCompactionSettings::default(),
10919            );
10920            agent_session.set_semantic_context_bundle(Some(
10921                SemanticContextBundleInjection::enabled(bundle).with_prompt_budget(4, 2048),
10922            ));
10923
10924            agent_session
10925                .run_text("gitlab turn".to_string(), |_| {})
10926                .await
10927                .expect("run with system prompt context");
10928
10929            {
10930                let calls = match calls.lock() {
10931                    Ok(calls) => calls,
10932                    Err(poisoned) => poisoned.into_inner(),
10933                };
10934                assert_eq!(calls.len(), 1);
10935                assert_eq!(calls[0].messages.len(), 1);
10936                assert_user_text(&calls[0].messages[0], "gitlab turn");
10937                let system_prompt = calls[0].system_prompt.as_deref().expect("system prompt");
10938                assert!(system_prompt.contains("base prompt"));
10939                assert!(system_prompt.contains("Semantic Context Bundle"));
10940                assert!(system_prompt.contains("src/agent.rs"));
10941                drop(calls);
10942            }
10943
10944            let cx = crate::agent_cx::AgentCx::for_request();
10945            let stored = session
10946                .lock(cx.cx())
10947                .await
10948                .expect("session lock")
10949                .to_messages_for_current_path();
10950            assert!(
10951                stored.iter().any(|message| matches!(
10952                    message,
10953                    Message::Custom(CustomMessage { custom_type, details, display: false, .. })
10954                        if custom_type == SEMANTIC_CONTEXT_CUSTOM_TYPE
10955                            && details
10956                                .as_ref()
10957                                .and_then(|value| value.get("bundleRevision"))
10958                                .and_then(Value::as_str)
10959                                == Some(revision.as_str())
10960                )),
10961                "hidden semantic context provenance was not persisted in session messages: {stored:?}"
10962            );
10963            assert_eq!(agent_session.agent.system_prompt(), Some("base prompt"));
10964        });
10965    }
10966
10967    #[test]
10968    fn enable_extensions_policy_resolution_defaults_to_permissive() {
10969        let policy = AgentSession::resolve_extension_policy_for_enable(None, None);
10970        assert_eq!(
10971            policy.mode,
10972            crate::extensions::ExtensionPolicyMode::Permissive
10973        );
10974    }
10975
10976    #[test]
10977    fn enable_extensions_policy_resolution_respects_config_default_toggle() {
10978        let config = crate::config::Config {
10979            extension_policy: Some(crate::config::ExtensionPolicyConfig {
10980                profile: None,
10981                default_permissive: Some(false),
10982                allow_dangerous: None,
10983            }),
10984            ..Default::default()
10985        };
10986        let policy = AgentSession::resolve_extension_policy_for_enable(Some(&config), None);
10987        assert_eq!(policy.mode, crate::extensions::ExtensionPolicyMode::Strict);
10988    }
10989
10990    #[test]
10991    fn enable_extensions_policy_resolution_prefers_explicit_policy() {
10992        let config = crate::config::Config {
10993            extension_policy: Some(crate::config::ExtensionPolicyConfig {
10994                profile: None,
10995                default_permissive: Some(false),
10996                allow_dangerous: None,
10997            }),
10998            ..Default::default()
10999        };
11000        let explicit = crate::extensions::PolicyProfile::Permissive.to_policy();
11001        let policy =
11002            AgentSession::resolve_extension_policy_for_enable(Some(&config), Some(explicit));
11003        assert_eq!(
11004            policy.mode,
11005            crate::extensions::ExtensionPolicyMode::Permissive
11006        );
11007    }
11008
11009    #[test]
11010    fn test_extract_tool_calls() {
11011        let content = vec![
11012            ContentBlock::Text(TextContent::new("Hello")),
11013            ContentBlock::ToolCall(ToolCall {
11014                id: "tc1".to_string(),
11015                name: "read".to_string(),
11016                arguments: serde_json::json!({"path": "file.txt"}),
11017                thought_signature: None,
11018            }),
11019            ContentBlock::Text(TextContent::new("World")),
11020            ContentBlock::ToolCall(ToolCall {
11021                id: "tc2".to_string(),
11022                name: "bash".to_string(),
11023                arguments: serde_json::json!({"command": "ls"}),
11024                thought_signature: None,
11025            }),
11026        ];
11027
11028        let tool_calls = extract_tool_calls(&content);
11029        assert_eq!(tool_calls.len(), 2);
11030        assert_eq!(tool_calls[0].name, "read");
11031        assert_eq!(tool_calls[1].name, "bash");
11032    }
11033
11034    #[test]
11035    fn test_agent_config_default() {
11036        // Tests don't mutate env (the crate forbids unsafe code, and
11037        // `std::env::set_var` is unsafe in 2024 edition); under typical
11038        // `cargo test` invocation `PI_MAX_TOOL_ITERATIONS` is unset, so
11039        // this assertion holds. If a developer's shell happens to export
11040        // that var, this test will reflect their effective default — which
11041        // is the correct behavior, not a bug.
11042        let config = AgentConfig::default();
11043        let expected = resolved_max_tool_iterations_default();
11044        assert_eq!(config.max_tool_iterations, expected);
11045        assert!(config.system_prompt.is_none());
11046        assert!(!config.block_images);
11047    }
11048
11049    #[test]
11050    fn resolve_max_tool_iterations_handles_unset_empty_and_whitespace() {
11051        assert_eq!(
11052            resolve_max_tool_iterations(None),
11053            MAX_TOOL_ITERATIONS_DEFAULT
11054        );
11055        assert_eq!(
11056            resolve_max_tool_iterations(Some("")),
11057            MAX_TOOL_ITERATIONS_DEFAULT
11058        );
11059        assert_eq!(
11060            resolve_max_tool_iterations(Some("    ")),
11061            MAX_TOOL_ITERATIONS_DEFAULT
11062        );
11063    }
11064
11065    #[test]
11066    fn resolve_max_tool_iterations_rejects_zero_and_invalid() {
11067        assert_eq!(
11068            resolve_max_tool_iterations(Some("0")),
11069            MAX_TOOL_ITERATIONS_DEFAULT
11070        );
11071        assert_eq!(
11072            resolve_max_tool_iterations(Some("not-a-number")),
11073            MAX_TOOL_ITERATIONS_DEFAULT
11074        );
11075        assert_eq!(
11076            resolve_max_tool_iterations(Some("-5")),
11077            MAX_TOOL_ITERATIONS_DEFAULT
11078        );
11079        assert_eq!(
11080            resolve_max_tool_iterations(Some("3.14")),
11081            MAX_TOOL_ITERATIONS_DEFAULT
11082        );
11083    }
11084
11085    #[test]
11086    fn resolve_max_tool_iterations_accepts_valid_overrides_and_trims_whitespace() {
11087        assert_eq!(resolve_max_tool_iterations(Some("1")), 1);
11088        assert_eq!(resolve_max_tool_iterations(Some("100")), 100);
11089        assert_eq!(resolve_max_tool_iterations(Some("  200  ")), 200);
11090        assert_eq!(resolve_max_tool_iterations(Some("999")), 999);
11091    }
11092
11093    #[test]
11094    fn resolve_max_tool_iterations_clamps_above_ceiling() {
11095        assert_eq!(
11096            resolve_max_tool_iterations(Some("99999")),
11097            MAX_TOOL_ITERATIONS_CEILING
11098        );
11099        // The ceiling itself should pass through unchanged.
11100        assert_eq!(
11101            resolve_max_tool_iterations(Some("1000")),
11102            MAX_TOOL_ITERATIONS_CEILING
11103        );
11104    }
11105
11106    #[test]
11107    fn clamp_max_tool_iterations_matches_resolve_semantics() {
11108        // None -> default, 0 -> default (with warning), >ceiling -> ceiling.
11109        assert_eq!(clamp_max_tool_iterations(None), MAX_TOOL_ITERATIONS_DEFAULT);
11110        assert_eq!(
11111            clamp_max_tool_iterations(Some(0)),
11112            MAX_TOOL_ITERATIONS_DEFAULT
11113        );
11114        assert_eq!(clamp_max_tool_iterations(Some(7)), 7);
11115        assert_eq!(
11116            clamp_max_tool_iterations(Some(usize::MAX)),
11117            MAX_TOOL_ITERATIONS_CEILING
11118        );
11119    }
11120
11121    #[test]
11122    fn iteration_warning_fires_at_80_percent_for_default_cap() {
11123        // Default cap = 50; (50 * 4) / 5 = 40 → warn at 40+.
11124        assert!(!should_warn_at_iteration_threshold(39, 50));
11125        assert!(should_warn_at_iteration_threshold(40, 50));
11126        assert!(should_warn_at_iteration_threshold(50, 50));
11127        // Off-by-one regression guard: not at 39 even with default cap.
11128        assert!(!should_warn_at_iteration_threshold(0, 50));
11129    }
11130
11131    #[test]
11132    fn iteration_warning_fires_at_80_percent_for_custom_caps() {
11133        for (cap, threshold) in [(100usize, 80usize), (200, 160), (1000, 800)] {
11134            assert!(
11135                !should_warn_at_iteration_threshold(threshold - 1, cap),
11136                "expected no warning below threshold (current=cap={cap}, threshold={threshold})"
11137            );
11138            assert!(
11139                should_warn_at_iteration_threshold(threshold, cap),
11140                "expected warning at threshold (cap={cap}, threshold={threshold})"
11141            );
11142        }
11143    }
11144
11145    #[test]
11146    fn iteration_warning_skipped_for_caps_below_minimum() {
11147        // For caps under ITERATION_WARN_MIN_CAP (5), the warning never
11148        // fires regardless of `current`. This avoids noise on tiny ceilings
11149        // where the warning would land on iteration 0 or 1.
11150        for cap in 0..ITERATION_WARN_MIN_CAP {
11151            for current in 0..=cap.saturating_add(2) {
11152                assert!(
11153                    !should_warn_at_iteration_threshold(current, cap),
11154                    "should not warn at current={current} cap={cap}"
11155                );
11156            }
11157        }
11158    }
11159
11160    #[test]
11161    fn iteration_warning_handles_minimum_warnable_cap_boundary() {
11162        // Cap == ITERATION_WARN_MIN_CAP (5): (5 * 4) / 5 = 4 → warn at 4+.
11163        assert!(!should_warn_at_iteration_threshold(3, 5));
11164        assert!(should_warn_at_iteration_threshold(4, 5));
11165        assert!(should_warn_at_iteration_threshold(5, 5));
11166    }
11167
11168    #[test]
11169    fn iteration_warning_handles_overflow_resistant_caps() {
11170        // SDK callers that write `AgentConfig::max_tool_iterations = usize::MAX`
11171        // directly bypass the resolvers' clamp. Without `saturating_mul`,
11172        // `max * 4` would wrap to a tiny number and the warning would fire
11173        // on iteration ~0. The saturating multiply pins the threshold at
11174        // (saturated) usize::MAX / 5, so the warning effectively never
11175        // fires for absurd caps — which is the safer default.
11176        assert!(!should_warn_at_iteration_threshold(1_000_000, usize::MAX));
11177        assert!(!should_warn_at_iteration_threshold(
11178            usize::MAX / 6,
11179            usize::MAX
11180        ));
11181        // Conversely, a current at the saturated threshold should fire.
11182        assert!(should_warn_at_iteration_threshold(
11183            usize::MAX / 5,
11184            usize::MAX
11185        ));
11186    }
11187
11188    #[test]
11189    fn iteration_handoff_steering_text_is_self_describing() {
11190        // Pinning the wording is intentional: this string is the load-bearing
11191        // contract between the runtime and the agent's iteration-aware-handoff
11192        // protocol. If it changes, downstream spec templates may need an
11193        // update, so the test forces a deliberate review on edits.
11194        let text = iteration_handoff_steering_text(42, 50);
11195        assert!(text.contains("[runtime]"));
11196        assert!(text.contains("Tool-iteration budget at >=80%"));
11197        assert!(text.contains("used 42 of 50"));
11198        assert!(text.contains("graceful handoff"));
11199        assert!(text.contains("incomplete-handoff"));
11200        assert!(text.contains("Do NOT compress"));
11201    }
11202
11203    #[test]
11204    fn filter_image_blocks_replaces_images_with_deduped_placeholder_text() {
11205        let mut blocks = vec![
11206            sample_image_block(),
11207            sample_image_block(),
11208            ContentBlock::Text(TextContent::new("tail")),
11209            sample_image_block(),
11210        ];
11211
11212        let removed = filter_image_blocks(&mut blocks);
11213
11214        assert_eq!(removed, 3);
11215        assert!(
11216            !blocks
11217                .iter()
11218                .any(|block| matches!(block, ContentBlock::Image(_)))
11219        );
11220        assert!(matches!(
11221            blocks.first(),
11222            Some(ContentBlock::Text(TextContent { text, .. }))
11223                if text.as_str().eq(BLOCK_IMAGES_PLACEHOLDER)
11224        ));
11225        assert!(matches!(
11226            blocks.get(1),
11227            Some(ContentBlock::Text(TextContent { text, .. })) if text.as_str().eq("tail")
11228        ));
11229        assert!(matches!(
11230            blocks.get(2),
11231            Some(ContentBlock::Text(TextContent { text, .. }))
11232                if text.as_str().eq(BLOCK_IMAGES_PLACEHOLDER)
11233        ));
11234    }
11235
11236    #[test]
11237    fn filter_images_for_provider_filters_images_from_all_block_message_types() {
11238        let mut messages = vec![
11239            Message::User(UserMessage {
11240                content: UserContent::Blocks(vec![
11241                    ContentBlock::Text(TextContent::new("hello")),
11242                    sample_image_block(),
11243                ]),
11244                timestamp: 0,
11245            }),
11246            Message::Assistant(Arc::new(AssistantMessage {
11247                content: vec![sample_image_block()],
11248                api: "test".to_string(),
11249                provider: "test".to_string(),
11250                model: "test".to_string(),
11251                usage: Usage::default(),
11252                stop_reason: StopReason::Stop,
11253                error_message: None,
11254                timestamp: 0,
11255            })),
11256            Message::tool_result(ToolResultMessage {
11257                tool_call_id: "tc1".to_string(),
11258                tool_name: "read".to_string(),
11259                content: vec![
11260                    sample_image_block(),
11261                    ContentBlock::Text(TextContent::new("ok")),
11262                ],
11263                details: None,
11264                is_error: false,
11265                timestamp: 0,
11266            }),
11267        ];
11268
11269        let stats = filter_images_for_provider(&mut messages);
11270
11271        assert_eq!(stats.removed_images, 3);
11272        assert_eq!(stats.affected_messages, 3);
11273        assert_eq!(
11274            messages.iter().map(image_count_in_message).sum::<usize>(),
11275            0,
11276            "no images should remain in provider-bound context"
11277        );
11278    }
11279
11280    #[test]
11281    fn build_context_strips_images_when_block_images_enabled() {
11282        let mut agent = Agent::new(
11283            Arc::new(SilentProvider),
11284            ToolRegistry::new(&[], Path::new("."), None),
11285            AgentConfig {
11286                system_prompt: None,
11287                max_tool_iterations: 50,
11288                stream_options: StreamOptions::default(),
11289                block_images: true,
11290                fail_closed_hooks: false,
11291                tool_approval: None,
11292            },
11293        );
11294        agent.add_message(Message::User(UserMessage {
11295            content: UserContent::Blocks(vec![sample_image_block()]),
11296            timestamp: 0,
11297        }));
11298
11299        let context = agent.build_context();
11300        assert_eq!(context.messages.len(), 1);
11301        assert_eq!(image_count_in_message(&context.messages[0]), 0);
11302        assert!(matches!(
11303            &context.messages[0],
11304            Message::User(UserMessage {
11305                content: UserContent::Blocks(blocks),
11306                ..
11307            }) if blocks
11308                .iter()
11309                .any(|block| matches!(block, ContentBlock::Text(TextContent { text, .. }) if text.as_str().eq(BLOCK_IMAGES_PLACEHOLDER)))
11310        ));
11311    }
11312
11313    #[test]
11314    fn build_context_keeps_images_when_block_images_disabled() {
11315        let mut agent = Agent::new(
11316            Arc::new(SilentProvider),
11317            ToolRegistry::new(&[], Path::new("."), None),
11318            AgentConfig {
11319                system_prompt: None,
11320                max_tool_iterations: 50,
11321                stream_options: StreamOptions::default(),
11322                block_images: false,
11323                fail_closed_hooks: false,
11324                tool_approval: None,
11325            },
11326        );
11327        agent.add_message(Message::User(UserMessage {
11328            content: UserContent::Blocks(vec![sample_image_block()]),
11329            timestamp: 0,
11330        }));
11331
11332        let context = agent.build_context();
11333        assert_eq!(context.messages.len(), 1);
11334        assert_eq!(image_count_in_message(&context.messages[0]), 1);
11335    }
11336
11337    #[test]
11338    fn auto_compaction_start_serializes_with_pi_mono_compatible_type_tag() {
11339        let event = AgentEvent::AutoCompactionStart {
11340            reason: "threshold".to_string(),
11341        };
11342        let json = serde_json::to_value(&event).unwrap();
11343        assert_eq!(json["type"], "auto_compaction_start");
11344        assert_eq!(json["reason"], "threshold");
11345    }
11346
11347    #[test]
11348    fn auto_compaction_end_serializes_with_pi_mono_compatible_fields() {
11349        let event = AgentEvent::AutoCompactionEnd {
11350            result: Some(serde_json::json!({"tokens_before": 5000, "tokens_after": 2000})),
11351            aborted: false,
11352            will_retry: false,
11353            error_message: None,
11354        };
11355        let json = serde_json::to_value(&event).unwrap();
11356        assert_eq!(json["type"], "auto_compaction_end");
11357        assert_eq!(json["aborted"], false);
11358        assert_eq!(json["willRetry"], false);
11359        assert!(json.get("errorMessage").is_none()); // skipped when None
11360        assert!(json["result"].is_object());
11361    }
11362
11363    #[test]
11364    fn auto_compaction_end_includes_error_message_when_present() {
11365        let event = AgentEvent::AutoCompactionEnd {
11366            result: None,
11367            aborted: true,
11368            will_retry: false,
11369            error_message: Some("Compaction failed".to_string()),
11370        };
11371        let json = serde_json::to_value(&event).unwrap();
11372        assert_eq!(json["type"], "auto_compaction_end");
11373        assert_eq!(json["aborted"], true);
11374        assert_eq!(json["errorMessage"], "Compaction failed");
11375    }
11376
11377    #[test]
11378    fn auto_retry_start_serializes_with_camel_case_fields() {
11379        let event = AgentEvent::AutoRetryStart {
11380            attempt: 1,
11381            max_attempts: 3,
11382            delay_ms: 2000,
11383            error_message: "Rate limited".to_string(),
11384        };
11385        let json = serde_json::to_value(&event).unwrap();
11386        assert_eq!(json["type"], "auto_retry_start");
11387        assert_eq!(json["attempt"], 1);
11388        assert_eq!(json["maxAttempts"], 3);
11389        assert_eq!(json["delayMs"], 2000);
11390        assert_eq!(json["errorMessage"], "Rate limited");
11391    }
11392
11393    #[test]
11394    fn auto_retry_end_serializes_success_and_omits_null_final_error() {
11395        let event = AgentEvent::AutoRetryEnd {
11396            success: true,
11397            attempt: 2,
11398            final_error: None,
11399        };
11400        let json = serde_json::to_value(&event).unwrap();
11401        assert_eq!(json["type"], "auto_retry_end");
11402        assert_eq!(json["success"], true);
11403        assert_eq!(json["attempt"], 2);
11404        assert!(json.get("finalError").is_none());
11405    }
11406
11407    #[test]
11408    fn auto_retry_end_includes_final_error_on_failure() {
11409        let event = AgentEvent::AutoRetryEnd {
11410            success: false,
11411            attempt: 3,
11412            final_error: Some("Max retries exceeded".to_string()),
11413        };
11414        let json = serde_json::to_value(&event).unwrap();
11415        assert_eq!(json["type"], "auto_retry_end");
11416        assert_eq!(json["success"], false);
11417        assert_eq!(json["attempt"], 3);
11418        assert_eq!(json["finalError"], "Max retries exceeded");
11419    }
11420
11421    #[test]
11422    fn message_queue_push_increments_seq_and_counts_both_queues() {
11423        let mut queue = MessageQueue::new(QueueMode::OneAtATime, QueueMode::OneAtATime);
11424        assert_eq!(queue.pending_count(), 0);
11425
11426        assert_eq!(queue.push_steering(user_message("s1")), 0);
11427        assert_eq!(queue.push_follow_up(user_message("f1")), 1);
11428        assert_eq!(queue.push_steering(user_message("s2")), 2);
11429
11430        assert_eq!(queue.pending_count(), 3);
11431    }
11432
11433    #[test]
11434    fn message_queue_pop_steering_one_at_a_time_preserves_order() {
11435        let mut queue = MessageQueue::new(QueueMode::OneAtATime, QueueMode::OneAtATime);
11436        queue.push_steering(user_message("s1"));
11437        queue.push_steering(user_message("s2"));
11438
11439        let first = queue.pop_steering();
11440        assert_eq!(first.len(), 1);
11441        assert_user_text(&first[0], "s1");
11442        assert_eq!(queue.pending_count(), 1);
11443
11444        let second = queue.pop_steering();
11445        assert_eq!(second.len(), 1);
11446        assert_user_text(&second[0], "s2");
11447        assert_eq!(queue.pending_count(), 0);
11448
11449        let empty = queue.pop_steering();
11450        assert!(empty.is_empty());
11451    }
11452
11453    #[test]
11454    fn message_queue_pop_respects_queue_modes_per_kind() {
11455        let mut queue = MessageQueue::new(QueueMode::All, QueueMode::OneAtATime);
11456        queue.push_steering(user_message("s1"));
11457        queue.push_steering(user_message("s2"));
11458        queue.push_follow_up(user_message("f1"));
11459        queue.push_follow_up(user_message("f2"));
11460
11461        let steering = queue.pop_steering();
11462        assert_eq!(steering.len(), 2);
11463        assert_user_text(&steering[0], "s1");
11464        assert_user_text(&steering[1], "s2");
11465        assert_eq!(queue.pending_count(), 2);
11466
11467        let follow_up = queue.pop_follow_up();
11468        assert_eq!(follow_up.len(), 1);
11469        assert_user_text(&follow_up[0], "f1");
11470        assert_eq!(queue.pending_count(), 1);
11471
11472        let follow_up = queue.pop_follow_up();
11473        assert_eq!(follow_up.len(), 1);
11474        assert_user_text(&follow_up[0], "f2");
11475        assert_eq!(queue.pending_count(), 0);
11476    }
11477
11478    #[test]
11479    fn message_queue_set_modes_applies_to_existing_messages() {
11480        let mut queue = MessageQueue::new(QueueMode::OneAtATime, QueueMode::OneAtATime);
11481        queue.push_steering(user_message("s1"));
11482        queue.push_steering(user_message("s2"));
11483
11484        let first = queue.pop_steering();
11485        assert_eq!(first.len(), 1);
11486        assert_user_text(&first[0], "s1");
11487
11488        queue.set_modes(QueueMode::All, QueueMode::OneAtATime);
11489        let remaining = queue.pop_steering();
11490        assert_eq!(remaining.len(), 1);
11491        assert_user_text(&remaining[0], "s2");
11492    }
11493
11494    fn build_switch_test_session(auth: &AuthStorage) -> AgentSession {
11495        let registry = ModelRegistry::load(auth, None);
11496        let current_entry = registry
11497            .find("anthropic", "claude-sonnet-4-5")
11498            .expect("anthropic model in registry");
11499        let provider = crate::providers::create_provider(&current_entry, None)
11500            .expect("create anthropic provider");
11501        let tools = ToolRegistry::new(&[], Path::new("."), None);
11502        let mut stream_options = StreamOptions {
11503            api_key: Some("stale-key".to_string()),
11504            ..Default::default()
11505        };
11506        let _ = stream_options
11507            .headers
11508            .insert("x-stale-header".to_string(), "stale-value".to_string());
11509        let agent = Agent::new(
11510            provider,
11511            tools,
11512            AgentConfig {
11513                system_prompt: None,
11514                max_tool_iterations: 50,
11515                stream_options,
11516                block_images: false,
11517                fail_closed_hooks: false,
11518                tool_approval: None,
11519            },
11520        );
11521
11522        let mut session = Session::in_memory();
11523        session.header.provider = Some("anthropic".to_string());
11524        session.header.model_id = Some("claude-sonnet-4-5".to_string());
11525
11526        let mut agent_session = AgentSession::new(
11527            agent,
11528            Arc::new(Mutex::new(session)),
11529            false,
11530            ResolvedCompactionSettings::default(),
11531        );
11532        agent_session.set_model_registry(registry);
11533        agent_session.set_auth_storage(auth.clone());
11534        agent_session
11535    }
11536
11537    #[test]
11538    fn compaction_runtime_handle_creates_fallback_runtime() {
11539        let dir = tempfile::tempdir().expect("tempdir");
11540        let auth_path = dir.path().join("auth.json");
11541        let auth = AuthStorage::load(auth_path).expect("load auth");
11542        let mut agent_session = build_switch_test_session(&auth);
11543
11544        assert!(agent_session.compaction_runtime.is_none());
11545        assert!(agent_session.runtime_handle.is_none());
11546
11547        let runtime_handle = agent_session
11548            .compaction_runtime_handle()
11549            .expect("create fallback compaction runtime");
11550        let join = runtime_handle.spawn(async { 7_u8 });
11551        assert_eq!(futures::executor::block_on(join), 7);
11552
11553        assert!(agent_session.compaction_runtime.is_some());
11554        assert!(agent_session.runtime_handle.is_some());
11555    }
11556
11557    #[test]
11558    fn apply_session_model_selection_updates_stream_credentials_and_headers() {
11559        let dir = tempfile::tempdir().expect("tempdir");
11560        let auth_path = dir.path().join("auth.json");
11561        let mut auth = AuthStorage::load(auth_path).expect("load auth");
11562        auth.set(
11563            "anthropic",
11564            AuthCredential::ApiKey {
11565                key: "anthropic-key".to_string(),
11566            },
11567        );
11568        auth.set(
11569            "openai",
11570            AuthCredential::ApiKey {
11571                key: "openai-key".to_string(),
11572            },
11573        );
11574
11575        let mut agent_session = build_switch_test_session(&auth);
11576        agent_session
11577            .apply_session_model_selection("openai", "gpt-4o")
11578            .expect("switch should update stream options");
11579
11580        assert_eq!(agent_session.agent.provider().name(), "openai");
11581        assert_eq!(agent_session.agent.provider().model_id(), "gpt-4o");
11582        assert_eq!(
11583            agent_session.agent.stream_options().api_key.as_deref(),
11584            Some("openai-key")
11585        );
11586        assert!(
11587            agent_session.agent.stream_options().headers.is_empty(),
11588            "stream headers should be refreshed from selected model entry"
11589        );
11590    }
11591
11592    #[test]
11593    fn apply_session_model_selection_clears_stale_key_for_keyless_target() {
11594        let dir = tempfile::tempdir().expect("tempdir");
11595        let auth_path = dir.path().join("auth.json");
11596        let mut auth = AuthStorage::load(auth_path).expect("load auth");
11597        auth.set(
11598            "anthropic",
11599            AuthCredential::ApiKey {
11600                key: "anthropic-key".to_string(),
11601            },
11602        );
11603
11604        let mut registry = ModelRegistry::load(&auth, None);
11605        registry.merge_entries(vec![ModelEntry {
11606            model: Model {
11607                id: "local-model".to_string(),
11608                name: "Local Model".to_string(),
11609                api: "openai-completions".to_string(),
11610                provider: "acme-local".to_string(),
11611                base_url: "https://example.invalid/v1".to_string(),
11612                reasoning: true,
11613                input: vec![InputType::Text],
11614                cost: ModelCost {
11615                    input: 0.0,
11616                    output: 0.0,
11617                    cache_read: 0.0,
11618                    cache_write: 0.0,
11619                },
11620                context_window: 128_000,
11621                max_tokens: 8_192,
11622                headers: HashMap::new(),
11623            },
11624            api_key: None,
11625            headers: HashMap::new(),
11626            auth_header: false,
11627            compat: None,
11628            oauth_config: None,
11629        }]);
11630
11631        let mut agent_session = build_switch_test_session(&auth);
11632        agent_session.set_model_registry(registry);
11633        agent_session
11634            .apply_session_model_selection("acme-local", "local-model")
11635            .expect("keyless local model should still activate");
11636
11637        assert_eq!(agent_session.agent.provider().name(), "acme-local");
11638        assert_eq!(
11639            agent_session.agent.stream_options().api_key,
11640            None,
11641            "stale key must be cleared when target model has no configured key"
11642        );
11643    }
11644
11645    #[test]
11646    fn apply_session_model_selection_treats_blank_model_key_as_missing_credential() {
11647        let dir = tempfile::tempdir().expect("tempdir");
11648        let auth_path = dir.path().join("auth.json");
11649        let auth = AuthStorage::load(auth_path).expect("load auth");
11650
11651        let mut registry = ModelRegistry::load(&auth, None);
11652        registry.merge_entries(vec![ModelEntry {
11653            model: Model {
11654                id: "blank-model".to_string(),
11655                name: "Blank Model".to_string(),
11656                api: "openai-completions".to_string(),
11657                provider: "acme".to_string(),
11658                base_url: "https://example.invalid/v1".to_string(),
11659                reasoning: true,
11660                input: vec![InputType::Text],
11661                cost: ModelCost {
11662                    input: 0.0,
11663                    output: 0.0,
11664                    cache_read: 0.0,
11665                    cache_write: 0.0,
11666                },
11667                context_window: 128_000,
11668                max_tokens: 8_192,
11669                headers: HashMap::new(),
11670            },
11671            api_key: Some("   ".to_string()),
11672            headers: HashMap::new(),
11673            auth_header: true,
11674            compat: None,
11675            oauth_config: None,
11676        }]);
11677
11678        let mut agent_session = build_switch_test_session(&auth);
11679        agent_session.set_model_registry(registry);
11680        let err = agent_session
11681            .apply_session_model_selection("acme", "blank-model")
11682            .expect_err("blank keys must not satisfy credential requirements");
11683
11684        assert!(
11685            err.to_string()
11686                .contains("Missing credentials for acme/blank-model"),
11687            "unexpected error: {err}"
11688        );
11689        assert_eq!(agent_session.agent.provider().name(), "anthropic");
11690        assert_eq!(
11691            agent_session.agent.stream_options().api_key,
11692            Some("stale-key".to_string()),
11693            "failed switches must preserve the prior runtime credentials"
11694        );
11695    }
11696
11697    #[test]
11698    fn set_provider_model_preserves_session_header_when_switch_fails() {
11699        let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
11700            .build()
11701            .expect("build runtime");
11702
11703        runtime.block_on(async {
11704            let dir = tempfile::tempdir().expect("tempdir");
11705            let auth_path = dir.path().join("auth.json");
11706            let auth = AuthStorage::load(auth_path).expect("load auth");
11707            let mut agent_session = build_switch_test_session(&auth);
11708
11709            {
11710                let cx = crate::agent_cx::AgentCx::for_request();
11711                let mut session = agent_session
11712                    .session
11713                    .lock(cx.cx())
11714                    .await
11715                    .expect("session lock");
11716                session.header.provider = Some("anthropic".to_string());
11717                session.header.model_id = Some("claude-sonnet-4-5".to_string());
11718            }
11719
11720            let err = agent_session
11721                .set_provider_model("missing-provider", "missing-model")
11722                .await
11723                .expect_err("missing model should not switch");
11724            assert!(
11725                err.to_string()
11726                    .contains("Unable to switch provider/model to missing-provider/missing-model"),
11727                "unexpected error: {err}"
11728            );
11729            assert_eq!(agent_session.agent.provider().name(), "anthropic");
11730            assert_eq!(
11731                agent_session.agent.provider().model_id(),
11732                "claude-sonnet-4-5"
11733            );
11734
11735            let cx = crate::agent_cx::AgentCx::for_request();
11736            let session = agent_session
11737                .session
11738                .lock(cx.cx())
11739                .await
11740                .expect("session lock");
11741            assert_eq!(session.header.provider.as_deref(), Some("anthropic"));
11742            assert_eq!(
11743                session.header.model_id.as_deref(),
11744                Some("claude-sonnet-4-5")
11745            );
11746        });
11747    }
11748
11749    #[test]
11750    fn set_provider_model_rejects_missing_credentials_without_switching() {
11751        let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
11752            .build()
11753            .expect("build runtime");
11754
11755        runtime.block_on(async {
11756            let dir = tempfile::tempdir().expect("tempdir");
11757            let auth_path = dir.path().join("auth.json");
11758            let auth = AuthStorage::load(auth_path).expect("load auth");
11759            let mut agent_session = build_switch_test_session(&auth);
11760
11761            {
11762                let cx = crate::agent_cx::AgentCx::for_request();
11763                let mut session = agent_session
11764                    .session
11765                    .lock(cx.cx())
11766                    .await
11767                    .expect("session lock");
11768                session.header.provider = Some("anthropic".to_string());
11769                session.header.model_id = Some("claude-sonnet-4-5".to_string());
11770            }
11771
11772            let err = agent_session
11773                .set_provider_model("openai", "gpt-4o")
11774                .await
11775                .expect_err("missing credentials should abort model switch");
11776            assert!(
11777                err.to_string()
11778                    .contains("Missing credentials for openai/gpt-4o"),
11779                "unexpected error: {err}"
11780            );
11781            assert_eq!(agent_session.agent.provider().name(), "anthropic");
11782            assert_eq!(
11783                agent_session.agent.provider().model_id(),
11784                "claude-sonnet-4-5"
11785            );
11786
11787            let cx = crate::agent_cx::AgentCx::for_request();
11788            let session = agent_session
11789                .session
11790                .lock(cx.cx())
11791                .await
11792                .expect("session lock");
11793            assert_eq!(session.header.provider.as_deref(), Some("anthropic"));
11794            assert_eq!(
11795                session.header.model_id.as_deref(),
11796                Some("claude-sonnet-4-5")
11797            );
11798        });
11799    }
11800
11801    #[test]
11802    fn set_provider_model_clamps_thinking_for_non_reasoning_targets() {
11803        let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
11804            .build()
11805            .expect("build runtime");
11806
11807        runtime.block_on(async {
11808            let dir = tempfile::tempdir().expect("tempdir");
11809            let auth_path = dir.path().join("auth.json");
11810            let auth = AuthStorage::load(auth_path).expect("load auth");
11811
11812            let mut registry = ModelRegistry::load(&auth, None);
11813            registry.merge_entries(vec![ModelEntry {
11814                model: Model {
11815                    id: "plain-model".to_string(),
11816                    name: "Plain Model".to_string(),
11817                    api: "openai-completions".to_string(),
11818                    provider: "acme".to_string(),
11819                    base_url: "https://example.invalid/v1".to_string(),
11820                    reasoning: false,
11821                    input: vec![InputType::Text],
11822                    cost: ModelCost {
11823                        input: 0.0,
11824                        output: 0.0,
11825                        cache_read: 0.0,
11826                        cache_write: 0.0,
11827                    },
11828                    context_window: 128_000,
11829                    max_tokens: 8_192,
11830                    headers: HashMap::new(),
11831                },
11832                api_key: None,
11833                headers: HashMap::new(),
11834                auth_header: false,
11835                compat: None,
11836                oauth_config: None,
11837            }]);
11838
11839            let mut agent_session = build_switch_test_session(&auth);
11840            agent_session.set_model_registry(registry);
11841            agent_session.agent.stream_options_mut().thinking_level =
11842                Some(crate::model::ThinkingLevel::High);
11843
11844            {
11845                let cx = crate::agent_cx::AgentCx::for_request();
11846                let mut session = agent_session
11847                    .session
11848                    .lock(cx.cx())
11849                    .await
11850                    .expect("session lock");
11851                session.header.thinking_level = Some("high".to_string());
11852            }
11853
11854            agent_session
11855                .set_provider_model("acme", "plain-model")
11856                .await
11857                .expect("switch should clamp unsupported thinking");
11858
11859            assert_eq!(agent_session.agent.provider().name(), "acme");
11860            assert_eq!(agent_session.agent.provider().model_id(), "plain-model");
11861            assert_eq!(
11862                agent_session.agent.stream_options().thinking_level,
11863                Some(crate::model::ThinkingLevel::Off)
11864            );
11865
11866            let cx = crate::agent_cx::AgentCx::for_request();
11867            let session = agent_session
11868                .session
11869                .lock(cx.cx())
11870                .await
11871                .expect("session lock");
11872            assert_eq!(session.header.provider.as_deref(), Some("acme"));
11873            assert_eq!(session.header.model_id.as_deref(), Some("plain-model"));
11874            assert_eq!(session.header.thinking_level.as_deref(), Some("off"));
11875        });
11876    }
11877
11878    #[test]
11879    fn set_provider_model_records_model_change_once() {
11880        let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
11881            .build()
11882            .expect("build runtime");
11883
11884        runtime.block_on(async {
11885            let dir = tempfile::tempdir().expect("tempdir");
11886            let auth_path = dir.path().join("auth.json");
11887            let mut auth = AuthStorage::load(auth_path).expect("load auth");
11888            auth.set(
11889                "anthropic",
11890                AuthCredential::ApiKey {
11891                    key: "anthropic-key".to_string(),
11892                },
11893            );
11894            auth.set(
11895                "openai",
11896                AuthCredential::ApiKey {
11897                    key: "openai-key".to_string(),
11898                },
11899            );
11900
11901            let mut agent_session = build_switch_test_session(&auth);
11902            agent_session
11903                .set_provider_model("openai", "gpt-4o")
11904                .await
11905                .expect("switch model");
11906            agent_session
11907                .set_provider_model("openai", "gpt-4o")
11908                .await
11909                .expect("repeat same model");
11910
11911            let cx = crate::agent_cx::AgentCx::for_request();
11912            let session = agent_session
11913                .session
11914                .lock(cx.cx())
11915                .await
11916                .expect("session lock");
11917            let model_changes = session
11918                .entries_for_current_path()
11919                .iter()
11920                .filter(|entry| matches!(entry, crate::session::SessionEntry::ModelChange(_)))
11921                .count();
11922            assert_eq!(model_changes, 1);
11923        });
11924    }
11925
11926    #[test]
11927    fn sync_runtime_selection_from_session_header_clamps_and_normalizes_thinking() {
11928        let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
11929            .build()
11930            .expect("build runtime");
11931
11932        runtime.block_on(async {
11933            let dir = tempfile::tempdir().expect("tempdir");
11934            let auth_path = dir.path().join("auth.json");
11935            let auth = AuthStorage::load(auth_path).expect("load auth");
11936
11937            let mut registry = ModelRegistry::load(&auth, None);
11938            registry.merge_entries(vec![ModelEntry {
11939                model: Model {
11940                    id: "plain-model".to_string(),
11941                    name: "Plain Model".to_string(),
11942                    api: "openai-completions".to_string(),
11943                    provider: "acme".to_string(),
11944                    base_url: "https://example.invalid/v1".to_string(),
11945                    reasoning: false,
11946                    input: vec![InputType::Text],
11947                    cost: ModelCost {
11948                        input: 0.0,
11949                        output: 0.0,
11950                        cache_read: 0.0,
11951                        cache_write: 0.0,
11952                    },
11953                    context_window: 128_000,
11954                    max_tokens: 8_192,
11955                    headers: HashMap::new(),
11956                },
11957                api_key: None,
11958                headers: HashMap::new(),
11959                auth_header: false,
11960                compat: None,
11961                oauth_config: None,
11962            }]);
11963
11964            let mut agent_session = build_switch_test_session(&auth);
11965            agent_session.set_model_registry(registry);
11966            agent_session.agent.stream_options_mut().thinking_level =
11967                Some(crate::model::ThinkingLevel::High);
11968
11969            {
11970                let cx = crate::agent_cx::AgentCx::for_request();
11971                let mut session = agent_session
11972                    .session
11973                    .lock(cx.cx())
11974                    .await
11975                    .expect("session lock");
11976                session.header.provider = Some("acme".to_string());
11977                session.header.model_id = Some("plain-model".to_string());
11978                session.header.thinking_level = Some("high".to_string());
11979            }
11980
11981            agent_session
11982                .sync_runtime_selection_from_session_header()
11983                .await
11984                .expect("sync runtime selection");
11985
11986            assert_eq!(agent_session.agent.provider().name(), "acme");
11987            assert_eq!(agent_session.agent.provider().model_id(), "plain-model");
11988            assert_eq!(
11989                agent_session.agent.stream_options().thinking_level,
11990                Some(crate::model::ThinkingLevel::Off)
11991            );
11992
11993            let cx = crate::agent_cx::AgentCx::for_request();
11994            let session = agent_session
11995                .session
11996                .lock(cx.cx())
11997                .await
11998                .expect("session lock");
11999            assert_eq!(session.header.thinking_level.as_deref(), Some("off"));
12000            let thinking_changes = session
12001                .entries_for_current_path()
12002                .iter()
12003                .filter(|entry| {
12004                    matches!(entry, crate::session::SessionEntry::ThinkingLevelChange(_))
12005                })
12006                .count();
12007            assert_eq!(thinking_changes, 1);
12008        });
12009    }
12010
12011    #[test]
12012    fn sync_runtime_selection_from_session_header_clamps_current_thinking_when_header_omits_it() {
12013        let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
12014            .build()
12015            .expect("build runtime");
12016
12017        runtime.block_on(async {
12018            let dir = tempfile::tempdir().expect("tempdir");
12019            let auth_path = dir.path().join("auth.json");
12020            let auth = AuthStorage::load(auth_path).expect("load auth");
12021
12022            let mut registry = ModelRegistry::load(&auth, None);
12023            registry.merge_entries(vec![ModelEntry {
12024                model: Model {
12025                    id: "plain-model".to_string(),
12026                    name: "Plain Model".to_string(),
12027                    api: "openai-completions".to_string(),
12028                    provider: "acme".to_string(),
12029                    base_url: "https://example.invalid/v1".to_string(),
12030                    reasoning: false,
12031                    input: vec![InputType::Text],
12032                    cost: ModelCost {
12033                        input: 0.0,
12034                        output: 0.0,
12035                        cache_read: 0.0,
12036                        cache_write: 0.0,
12037                    },
12038                    context_window: 128_000,
12039                    max_tokens: 8_192,
12040                    headers: HashMap::new(),
12041                },
12042                api_key: None,
12043                headers: HashMap::new(),
12044                auth_header: false,
12045                compat: None,
12046                oauth_config: None,
12047            }]);
12048
12049            let mut agent_session = build_switch_test_session(&auth);
12050            agent_session.set_model_registry(registry);
12051            agent_session.agent.stream_options_mut().thinking_level =
12052                Some(crate::model::ThinkingLevel::High);
12053
12054            {
12055                let cx = crate::agent_cx::AgentCx::for_request();
12056                let mut session = agent_session
12057                    .session
12058                    .lock(cx.cx())
12059                    .await
12060                    .expect("session lock");
12061                session.header.provider = Some("acme".to_string());
12062                session.header.model_id = Some("plain-model".to_string());
12063                session.header.thinking_level = None;
12064            }
12065
12066            agent_session
12067                .sync_runtime_selection_from_session_header()
12068                .await
12069                .expect("sync runtime selection");
12070
12071            assert_eq!(agent_session.agent.provider().name(), "acme");
12072            assert_eq!(agent_session.agent.provider().model_id(), "plain-model");
12073            assert_eq!(
12074                agent_session.agent.stream_options().thinking_level,
12075                Some(crate::model::ThinkingLevel::Off)
12076            );
12077
12078            let cx = crate::agent_cx::AgentCx::for_request();
12079            let session = agent_session
12080                .session
12081                .lock(cx.cx())
12082                .await
12083                .expect("session lock");
12084            assert_eq!(session.header.thinking_level.as_deref(), Some("off"));
12085            let thinking_changes = session
12086                .entries_for_current_path()
12087                .iter()
12088                .filter(|entry| {
12089                    matches!(entry, crate::session::SessionEntry::ThinkingLevelChange(_))
12090                })
12091                .count();
12092            assert_eq!(thinking_changes, 1);
12093        });
12094    }
12095
12096    #[test]
12097    fn sync_runtime_selection_from_session_header_rejects_missing_credentials() {
12098        let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
12099            .build()
12100            .expect("build runtime");
12101
12102        runtime.block_on(async {
12103            let dir = tempfile::tempdir().expect("tempdir");
12104            let auth_path = dir.path().join("auth.json");
12105            let auth = AuthStorage::load(auth_path).expect("load auth");
12106            let mut agent_session = build_switch_test_session(&auth);
12107
12108            {
12109                let cx = crate::agent_cx::AgentCx::for_request();
12110                let mut session = agent_session
12111                    .session
12112                    .lock(cx.cx())
12113                    .await
12114                    .expect("session lock");
12115                session.header.provider = Some("openai".to_string());
12116                session.header.model_id = Some("gpt-4o".to_string());
12117            }
12118
12119            let err = agent_session
12120                .sync_runtime_selection_from_session_header()
12121                .await
12122                .expect_err("sync should reject switching to a credentialed target without a key");
12123            assert!(
12124                err.to_string()
12125                    .contains("Missing credentials for openai/gpt-4o"),
12126                "unexpected error: {err}"
12127            );
12128            assert_eq!(agent_session.agent.provider().name(), "anthropic");
12129            assert_eq!(
12130                agent_session.agent.provider().model_id(),
12131                "claude-sonnet-4-5"
12132            );
12133
12134            let cx = crate::agent_cx::AgentCx::for_request();
12135            let session = agent_session
12136                .session
12137                .lock(cx.cx())
12138                .await
12139                .expect("session lock");
12140            assert_eq!(session.header.provider.as_deref(), Some("openai"));
12141            assert_eq!(session.header.model_id.as_deref(), Some("gpt-4o"));
12142        });
12143    }
12144
12145    #[test]
12146    fn set_provider_model_allows_current_model_without_registry() {
12147        let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
12148            .build()
12149            .expect("build runtime");
12150
12151        runtime.block_on(async {
12152            let dir = tempfile::tempdir().expect("tempdir");
12153            let auth_path = dir.path().join("auth.json");
12154            let auth = AuthStorage::load(auth_path).expect("load auth");
12155            let mut agent_session = build_switch_test_session(&auth);
12156            agent_session.model_registry = None;
12157            agent_session.agent.stream_options_mut().thinking_level =
12158                Some(crate::model::ThinkingLevel::High);
12159
12160            agent_session
12161                .set_provider_model("anthropic", "claude-sonnet-4-5")
12162                .await
12163                .expect("re-persisting the current model should succeed without a registry");
12164
12165            assert_eq!(agent_session.agent.provider().name(), "anthropic");
12166            assert_eq!(
12167                agent_session.agent.provider().model_id(),
12168                "claude-sonnet-4-5"
12169            );
12170            assert_eq!(
12171                agent_session.agent.stream_options().thinking_level,
12172                Some(crate::model::ThinkingLevel::High)
12173            );
12174
12175            let cx = crate::agent_cx::AgentCx::for_request();
12176            let session = agent_session
12177                .session
12178                .lock(cx.cx())
12179                .await
12180                .expect("session lock");
12181            assert_eq!(session.header.provider.as_deref(), Some("anthropic"));
12182            assert_eq!(
12183                session.header.model_id.as_deref(),
12184                Some("claude-sonnet-4-5")
12185            );
12186            assert_eq!(session.header.thinking_level.as_deref(), Some("high"));
12187        });
12188    }
12189
12190    #[test]
12191    fn auto_compaction_start_serializes_to_pi_mono_format() {
12192        let event = AgentEvent::AutoCompactionStart {
12193            reason: "threshold".to_string(),
12194        };
12195        let json = serde_json::to_value(&event).unwrap();
12196        assert_eq!(json["type"], "auto_compaction_start");
12197        assert_eq!(json["reason"], "threshold");
12198    }
12199
12200    #[test]
12201    fn auto_compaction_end_serializes_to_pi_mono_format() {
12202        let event = AgentEvent::AutoCompactionEnd {
12203            result: Some(serde_json::json!({
12204                "summary": "Compacted",
12205                "firstKeptEntryId": "abc123",
12206                "tokensBefore": 50000,
12207                "details": { "readFiles": [], "modifiedFiles": [] }
12208            })),
12209            aborted: false,
12210            will_retry: true,
12211            error_message: None,
12212        };
12213        let json = serde_json::to_value(&event).unwrap();
12214        assert_eq!(json["type"], "auto_compaction_end");
12215        assert!(json["result"].is_object());
12216        assert_eq!(json["aborted"], false);
12217        assert_eq!(json["willRetry"], true);
12218        assert!(json.get("errorMessage").is_none());
12219    }
12220
12221    #[test]
12222    fn auto_compaction_end_with_error_serializes_error_message() {
12223        let event = AgentEvent::AutoCompactionEnd {
12224            result: None,
12225            aborted: false,
12226            will_retry: false,
12227            error_message: Some("compaction failed".to_string()),
12228        };
12229        let json = serde_json::to_value(&event).unwrap();
12230        assert_eq!(json["type"], "auto_compaction_end");
12231        assert!(json.get("result").is_none());
12232        assert_eq!(json["errorMessage"], "compaction failed");
12233    }
12234
12235    #[test]
12236    fn apply_compaction_result_emits_structured_result_payload() {
12237        let runtime = RuntimeBuilder::current_thread()
12238            .build()
12239            .expect("runtime build");
12240
12241        runtime.block_on(async {
12242            let provider = Arc::new(SilentProvider);
12243            let tools = ToolRegistry::new(&[], Path::new("."), None);
12244            let agent = Agent::new(provider, tools, AgentConfig::default());
12245            let session = Arc::new(Mutex::new(Session::in_memory()));
12246            let agent_session =
12247                AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
12248
12249            let events: Arc<std::sync::Mutex<Vec<AgentEvent>>> =
12250                Arc::new(std::sync::Mutex::new(Vec::new()));
12251            let sink = Arc::clone(&events);
12252            let on_event: AgentEventHandler = Arc::new(move |event| {
12253                sink.lock().expect("lock compaction events").push(event);
12254            });
12255
12256            let result = compaction::CompactionResult {
12257                summary: "Compacted 10 messages into 2".to_string(),
12258                first_kept_entry_id: "entry-5".to_string(),
12259                tokens_before: 12_000,
12260                details: compaction::CompactionDetails {
12261                    read_files: vec!["src/main.rs".to_string()],
12262                    modified_files: vec!["src/agent.rs".to_string()],
12263                },
12264            };
12265
12266            agent_session
12267                .apply_compaction_result(result, on_event)
12268                .await
12269                .expect("apply compaction result");
12270
12271            let payload = {
12272                let guard = events.lock().expect("lock captured events");
12273                guard
12274                    .iter()
12275                    .find_map(|event| match event {
12276                        AgentEvent::AutoCompactionEnd {
12277                            result: Some(result),
12278                            ..
12279                        } => Some(result.clone()),
12280                        _ => None,
12281                    })
12282                    .expect("auto compaction end payload")
12283            };
12284
12285            assert_eq!(payload["summary"], "Compacted 10 messages into 2");
12286            assert_eq!(payload["firstKeptEntryId"], "entry-5");
12287            assert_eq!(payload["tokensBefore"], 12_000);
12288            assert_eq!(payload["details"]["readFiles"], json!(["src/main.rs"]));
12289            assert_eq!(payload["details"]["modifiedFiles"], json!(["src/agent.rs"]));
12290        });
12291    }
12292
12293    #[test]
12294    fn auto_retry_start_serializes_to_pi_mono_format() {
12295        let event = AgentEvent::AutoRetryStart {
12296            attempt: 2,
12297            max_attempts: 3,
12298            delay_ms: 4000,
12299            error_message: "rate limited".to_string(),
12300        };
12301        let json = serde_json::to_value(&event).unwrap();
12302        assert_eq!(json["type"], "auto_retry_start");
12303        assert_eq!(json["attempt"], 2);
12304        assert_eq!(json["maxAttempts"], 3);
12305        assert_eq!(json["delayMs"], 4000);
12306        assert_eq!(json["errorMessage"], "rate limited");
12307    }
12308
12309    #[test]
12310    fn auto_retry_end_success_serializes_to_pi_mono_format() {
12311        let event = AgentEvent::AutoRetryEnd {
12312            success: true,
12313            attempt: 2,
12314            final_error: None,
12315        };
12316        let json = serde_json::to_value(&event).unwrap();
12317        assert_eq!(json["type"], "auto_retry_end");
12318        assert_eq!(json["success"], true);
12319        assert_eq!(json["attempt"], 2);
12320        assert!(json.get("finalError").is_none());
12321    }
12322
12323    #[test]
12324    fn auto_retry_end_failure_serializes_final_error() {
12325        let event = AgentEvent::AutoRetryEnd {
12326            success: false,
12327            attempt: 3,
12328            final_error: Some("max retries exceeded".to_string()),
12329        };
12330        let json = serde_json::to_value(&event).unwrap();
12331        assert_eq!(json["type"], "auto_retry_end");
12332        assert_eq!(json["success"], false);
12333        assert_eq!(json["attempt"], 3);
12334        assert_eq!(json["finalError"], "max retries exceeded");
12335    }
12336}