1use 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;
73const MAX_STEERING_QUEUE_SIZE: usize = 100;
75const MAX_FOLLOW_UP_QUEUE_SIZE: usize = 100;
77const MAX_AGENT_MESSAGES: usize = 10_000;
79pub const TURN_LATENCY_BREAKDOWN_SCHEMA_V1: &str = "pi.agent.turn_latency_breakdown.v1";
81pub 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#[derive(Debug, Clone, Serialize, Default)]
157#[serde(rename_all = "camelCase")]
158pub struct LatencyPercentiles {
159 pub p50_ms: u64,
161 pub p95_ms: u64,
163 pub p99_ms: u64,
165 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#[derive(Debug, Clone, Serialize, Default)]
182#[serde(rename_all = "camelCase")]
183pub struct LatencyComponentBreakdown {
184 pub duration_ms: u64,
186 pub samples: usize,
188 pub tail_percentiles: LatencyPercentiles,
190}
191
192impl LatencyComponentBreakdown {
193 #[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#[derive(Debug, Clone, Serialize)]
206#[serde(rename_all = "camelCase")]
207pub struct TurnLatencyBreakdown {
208 pub schema: &'static str,
210 pub total_ms: u64,
212 pub provider_streaming: LatencyComponentBreakdown,
214 pub local_tools: LatencyComponentBreakdown,
216 pub extension_hostcalls: LatencyComponentBreakdown,
218 pub persistence: LatencyComponentBreakdown,
220 pub dominant_component: String,
222}
223
224impl TurnLatencyBreakdown {
225 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
386#[serde(rename_all = "camelCase")]
387pub struct ToolEffectBatchEvidence {
388 pub start: usize,
390 pub end: usize,
392 pub len: usize,
394 pub combined_effects: Vec<&'static str>,
396 pub parallel_safe: bool,
398 #[serde(skip_serializing_if = "Option::is_none")]
400 pub barrier_reason: Option<&'static str>,
401}
402
403#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
405#[serde(rename_all = "camelCase")]
406pub struct ToolEffectBatchPlanEvidence {
407 pub schema: &'static str,
409 pub tool_count: usize,
411 pub parallelism_cap: usize,
413 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#[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
496pub const MAX_TOOL_ITERATIONS_DEFAULT: usize = 50;
508
509pub const MAX_TOOL_ITERATIONS_CEILING: usize = 1_000;
515
516const ITERATION_WARN_NUMERATOR: usize = 4;
521const ITERATION_WARN_DENOMINATOR: usize = 5;
522
523const ITERATION_WARN_MIN_CAP: usize = 5;
527
528pub fn resolved_max_tool_iterations_default() -> usize {
534 resolve_max_tool_iterations(std::env::var("PI_MAX_TOOL_ITERATIONS").ok().as_deref())
535}
536
537pub 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
571pub 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
597pub 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
612pub 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#[derive(Clone)]
628pub struct AgentConfig {
629 pub system_prompt: Option<String>,
631
632 pub max_tool_iterations: usize,
634
635 pub stream_options: StreamOptions,
637
638 pub block_images: bool,
640
641 pub fail_closed_hooks: bool,
643
644 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#[derive(Debug, Clone)]
663pub struct ToolApprovalRequest {
664 pub tool_call_id: String,
665 pub tool_name: String,
666 pub arguments: Value,
667}
668
669#[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#[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
783pub 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#[derive(Debug, Clone, Serialize)]
934#[serde(tag = "type", rename_all = "snake_case")]
935pub enum AgentEvent {
936 AgentStart {
938 #[serde(rename = "sessionId")]
939 session_id: Arc<str>,
940 },
941 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 TurnStart {
951 #[serde(rename = "sessionId")]
952 session_id: Arc<str>,
953 #[serde(rename = "turnIndex")]
954 turn_index: usize,
955 timestamp: i64,
956 },
957 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 MessageStart { message: Message },
971 MessageUpdate {
973 message: Message,
974 #[serde(rename = "assistantMessageEvent")]
975 assistant_message_event: AssistantMessageEvent,
976 },
977 MessageEnd { message: Message },
979 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 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 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 AutoCompactionStart { reason: String },
1009 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 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 AutoRetryEnd {
1031 success: bool,
1032 attempt: u32,
1033 #[serde(rename = "finalError", skip_serializing_if = "Option::is_none")]
1034 final_error: Option<String>,
1035 },
1036 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#[derive(Debug, Clone)]
1051pub struct AbortHandle {
1052 inner: Arc<AbortSignalInner>,
1053}
1054
1055#[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 #[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 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 #[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
1112pub struct Agent {
1114 provider: Arc<dyn Provider>,
1116
1117 tools: ToolRegistry,
1119
1120 config: AgentConfig,
1122
1123 extensions: Option<ExtensionManager>,
1125
1126 messages: Vec<Message>,
1128
1129 steering_fetchers: Vec<MessageFetcher>,
1131
1132 follow_up_fetchers: Vec<MessageFetcher>,
1134
1135 message_queue: MessageQueue,
1137
1138 cached_tool_defs: Option<Vec<ToolDef>>,
1140}
1141
1142impl Agent {
1143 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 #[must_use]
1160 pub fn messages(&self) -> &[Message] {
1161 &self.messages
1162 }
1163
1164 pub fn clear_messages(&mut self) {
1166 self.messages.clear();
1167 }
1168
1169 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 pub fn replace_messages(&mut self, messages: Vec<Message>) {
1183 self.messages = messages;
1184 }
1185
1186 pub fn set_provider(&mut self, provider: Arc<dyn Provider>) {
1188 self.provider = provider;
1189 }
1190
1191 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 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; }
1216
1217 pub fn queue_steering(&mut self, message: Message) -> u64 {
1219 self.message_queue.push_steering(message)
1220 }
1221
1222 pub fn queue_follow_up(&mut self, message: Message) -> u64 {
1224 self.message_queue.push_follow_up(message)
1225 }
1226
1227 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 #[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 fn build_context(&mut self) -> Context<'_> {
1267 let messages: Cow<'_, [Message]> = if self.config.block_images {
1268 let mut msgs = self.messages.clone();
1269 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 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 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 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 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 let user_message = Message::User(UserMessage {
1345 content: UserContent::Text(user_input.into()),
1346 timestamp: Utc::now().timestamp_millis(),
1347 });
1348
1349 self.run_loop(vec![user_message], Arc::new(on_event), abort)
1351 .await
1352 }
1353
1354 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 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 let user_message = Message::User(UserMessage {
1373 content: UserContent::Blocks(content),
1374 timestamp: Utc::now().timestamp_millis(),
1375 });
1376
1377 self.run_loop(vec![user_message], Arc::new(on_event), abort)
1379 .await
1380 }
1381
1382 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 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 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 #[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 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 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 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 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 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 pending_messages = self.drain_steering_messages().await;
1836 }
1837 }
1838
1839 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 #[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 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 let mut sent_start = false;
1988 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 }
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 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 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 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 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 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 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 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(), }
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 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 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 for (index, tool_call) in tool_calls.iter().enumerate() {
2809 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 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 results.push(self.skip_tool_call(tool_call, &on_event, new_messages));
2825 } else {
2826 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 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 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
3256struct ToolExecutionOutcome {
3261 tool_results: Vec<Arc<ToolResultMessage>>,
3262 steering_messages: Option<Vec<Message>>,
3263}
3264
3265pub struct PreWarmedExtensionRuntime {
3270 pub manager: ExtensionManager,
3272 pub runtime: ExtensionRuntimeHandle,
3274 pub tools: Arc<ToolRegistry>,
3276}
3277
3278struct 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 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 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 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 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(®istry));
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 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(¤t_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 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 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 #[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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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
10285fn log_repair_diagnostics(events: &[crate::extensions_js::ExtensionRepairEvent]) {
10294 use std::collections::BTreeMap;
10295
10296 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 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 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
10411fn 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#[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 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 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 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 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 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 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 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 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 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 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()); 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(¤t_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}