use crate::auth::AuthStorage;
use crate::compaction::{self, ResolvedCompactionSettings};
use crate::compaction_worker::{
CompactionAdmissionReason, CompactionAdmissionSignals, CompactionOrigin, CompactionQuota,
CompactionWorkerState,
};
use crate::error::{Error, Result};
use crate::extension_events::{
BeforeAgentStartOutcome, InputEventOutcome, SessionBeforeCompactOutcome,
apply_before_agent_start_response, apply_input_event_response,
apply_session_before_compact_response,
};
use crate::extension_tools::collect_extension_tool_wrappers;
use crate::extensions::{
EXTENSION_EVENT_TIMEOUT_MS, ExtensionAiCompletionRequest, ExtensionDeliverAs,
ExtensionEventName, ExtensionHostActions, ExtensionLoadSpec, ExtensionManager, ExtensionPolicy,
ExtensionRegion, ExtensionRuntimeHandle, ExtensionSendMessage, ExtensionSendUserMessage,
JsExtensionLoadSpec, JsExtensionRuntimeHandle, NativeRustExtensionLoadSpec,
NativeRustExtensionRuntimeHandle, RepairPolicyMode, SessionActionOrigin,
SessionActionOriginSource, resolve_extension_load_spec,
};
#[cfg(feature = "wasm-host")]
use crate::extensions::{WasmExtensionHost, WasmExtensionLoadSpec};
use crate::extensions_js::{PiJsRuntimeConfig, RepairMode};
use crate::model::{
AssistantMessage, AssistantMessageEvent, ContentBlock, CustomMessage, ImageContent, Message,
StopReason, StreamEvent, TextContent, ThinkingContent, ToolCall, ToolResultMessage, Usage,
UserContent, UserMessage,
};
use crate::models::{
ModelEntry, ModelRegistry, model_requires_configured_credential, normalize_api_key_opt,
};
use crate::provider::{Context, Provider, StreamOptions, ToolDef};
use crate::semantic_workspace_graph::{ContextBundleItem, SemanticContextBundle};
use crate::session::{AutosaveFlushTrigger, Session, SessionHandle};
use crate::tools::{Tool, ToolEffects, ToolOutput, ToolRegistry, ToolUpdate};
use asupersync::runtime::{Runtime, RuntimeBuilder, RuntimeHandle};
use asupersync::sync::{Mutex, Notify, OwnedMutexGuard};
use async_trait::async_trait;
use chrono::Utc;
use futures::FutureExt;
use futures::StreamExt;
use futures::future::BoxFuture;
use futures::stream;
use serde::Serialize;
use serde_json::{Value, json};
use sha2::{Digest as _, Sha256};
use std::borrow::Cow;
use std::collections::VecDeque;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use tracing::warn;
const MIN_COMPATIBLE_TOOL_PARALLELISM: usize = 8;
const MAX_AUTO_COMPATIBLE_TOOL_PARALLELISM: usize = 64;
const MAX_CONFIGURED_COMPATIBLE_TOOL_PARALLELISM: usize = 256;
const MAX_STEERING_QUEUE_SIZE: usize = 100;
const MAX_FOLLOW_UP_QUEUE_SIZE: usize = 100;
const MAX_AGENT_MESSAGES: usize = 10_000;
pub const TURN_LATENCY_BREAKDOWN_SCHEMA_V1: &str = "pi.agent.turn_latency_breakdown.v1";
pub const TOOL_EFFECT_BATCH_PLAN_SCHEMA_V1: &str = "pi.agent.tool_effect_batch_plan.v1";
const TOOL_CANCELLATION_SCHEMA_V1: &str = "pi.tool.cancellation.v1";
const TOOL_APPROVAL_DENIED_SCHEMA_V1: &str = "pi.tool.approval_denied.v1";
const TOOL_APPROVAL_STATUS_SCHEMA_V1: &str = "pi.tool.approval_status.v1";
const SEMANTIC_CONTEXT_PROMPT_SCHEMA_V1: &str = "pi.semantic_context_prompt.v1";
const SEMANTIC_CONTEXT_PROVENANCE_SCHEMA_V1: &str = "pi.semantic_context_provenance.v1";
const SEMANTIC_CONTEXT_CUSTOM_TYPE: &str = "semantic_context_bundle";
fn context_excluded_custom_message(message: &CustomMessage) -> bool {
!message.display && message.custom_type == SEMANTIC_CONTEXT_CUSTOM_TYPE
}
const DEFAULT_SEMANTIC_CONTEXT_PROMPT_MAX_BYTES: u64 = 16 * 1024;
const DEFAULT_SEMANTIC_CONTEXT_PROMPT_MAX_ITEMS: usize = 16;
pub(crate) fn append_dialect_repair_telemetry(
session: &mut Session,
repairs: &[crate::dialects::RepairEntry],
) {
for entry in repairs {
session.append_custom_entry(
"dialect_repair".to_string(),
Some(serde_json::json!({
"tool": entry.tool,
"strippedBytes": entry.stripped_bytes,
"remainingTextBytes": entry.remaining_text_bytes,
})),
);
}
}
fn normalize_before_provider_request_response(response: Value) -> Option<Value> {
if response.is_null() {
return None;
}
match response {
Value::Object(mut object) => object.remove("payload").map_or_else(
|| Some(Value::Object(object)),
|inner| (!inner.is_null()).then_some(inner),
),
other => {
tracing::warn!(
"before_provider_request handler returned a non-object rewrite (ignored): {other}"
);
None
}
}
}
fn compatible_tool_parallelism_limit() -> usize {
static LIMIT: OnceLock<usize> = OnceLock::new();
*LIMIT.get_or_init(|| {
let host_parallelism = std::thread::available_parallelism()
.map_or(MIN_COMPATIBLE_TOOL_PARALLELISM, |parallelism| {
parallelism.get()
});
resolve_compatible_tool_parallelism(
std::env::var("PI_MAX_CONCURRENT_COMPATIBLE_TOOLS")
.ok()
.as_deref(),
host_parallelism,
)
})
}
fn resolve_compatible_tool_parallelism(
raw_override: Option<&str>,
host_parallelism: usize,
) -> usize {
let host_default = host_parallelism.clamp(
MIN_COMPATIBLE_TOOL_PARALLELISM,
MAX_AUTO_COMPATIBLE_TOOL_PARALLELISM,
);
let Some(raw) = raw_override.map(str::trim).filter(|raw| !raw.is_empty()) else {
return host_default;
};
match raw.parse::<usize>() {
Ok(0) => {
warn!(
value = raw,
"Ignoring PI_MAX_CONCURRENT_COMPATIBLE_TOOLS=0; using host-scaled default"
);
host_default
}
Ok(limit) => limit.clamp(1, MAX_CONFIGURED_COMPATIBLE_TOOL_PARALLELISM),
Err(err) => {
warn!(
value = raw,
error = %err,
"Ignoring invalid PI_MAX_CONCURRENT_COMPATIBLE_TOOLS; using host-scaled default"
);
host_default
}
}
}
fn duration_millis_saturating(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
fn duration_micros_saturating(duration: Duration) -> u64 {
u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)
}
fn record_global_latency(counter: &crate::session_metrics::TimingCounter, duration: Duration) {
if crate::session_metrics::global().enabled() {
counter.record(duration_micros_saturating(duration));
}
}
#[derive(Debug, Clone, Serialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct LatencyPercentiles {
pub p50_ms: u64,
pub p95_ms: u64,
pub p99_ms: u64,
pub p999_ms: u64,
}
impl LatencyPercentiles {
fn from_samples(samples: &[u64]) -> Self {
Self {
p50_ms: percentile_nearest_rank(samples, 50),
p95_ms: percentile_nearest_rank(samples, 95),
p99_ms: percentile_nearest_rank(samples, 99),
p999_ms: percentile_nearest_rank_per_mille(samples, 999),
}
}
}
#[derive(Debug, Clone, Serialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct LatencyComponentBreakdown {
pub duration_ms: u64,
pub samples: usize,
pub tail_percentiles: LatencyPercentiles,
}
impl LatencyComponentBreakdown {
#[must_use]
pub fn from_millis_samples(samples: &[u64]) -> Self {
Self {
duration_ms: samples.iter().copied().fold(0u64, u64::saturating_add),
samples: samples.len(),
tail_percentiles: LatencyPercentiles::from_samples(samples),
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TurnLatencyBreakdown {
pub schema: &'static str,
pub total_ms: u64,
pub provider_streaming: LatencyComponentBreakdown,
pub local_tools: LatencyComponentBreakdown,
pub extension_hostcalls: LatencyComponentBreakdown,
pub persistence: LatencyComponentBreakdown,
pub dominant_component: String,
}
impl TurnLatencyBreakdown {
#[must_use]
pub fn from_component_samples(
total_ms: u64,
provider_streaming_ms: &[u64],
local_tool_ms: &[u64],
extension_hostcall_ms: &[u64],
persistence_ms: &[u64],
) -> Self {
let provider_streaming =
LatencyComponentBreakdown::from_millis_samples(provider_streaming_ms);
let local_tools = LatencyComponentBreakdown::from_millis_samples(local_tool_ms);
let extension_hostcalls =
LatencyComponentBreakdown::from_millis_samples(extension_hostcall_ms);
let persistence = LatencyComponentBreakdown::from_millis_samples(persistence_ms);
let dominant_component = dominant_latency_component(
&provider_streaming,
&local_tools,
&extension_hostcalls,
&persistence,
);
Self {
schema: TURN_LATENCY_BREAKDOWN_SCHEMA_V1,
total_ms,
provider_streaming,
local_tools,
extension_hostcalls,
persistence,
dominant_component,
}
}
}
fn percentile_nearest_rank(samples: &[u64], percentile: usize) -> u64 {
if samples.is_empty() {
return 0;
}
let mut sorted = samples.to_vec();
sorted.sort_unstable();
let len = sorted.len();
let rank = percentile
.saturating_mul(len)
.div_ceil(100)
.saturating_sub(1)
.min(len.saturating_sub(1));
sorted[rank]
}
fn percentile_nearest_rank_per_mille(samples: &[u64], permille: usize) -> u64 {
if samples.is_empty() {
return 0;
}
let mut sorted = samples.to_vec();
sorted.sort_unstable();
let len = sorted.len();
let rank = permille
.saturating_mul(len)
.div_ceil(1000)
.saturating_sub(1)
.min(len.saturating_sub(1));
sorted[rank]
}
fn dominant_latency_component(
provider_streaming: &LatencyComponentBreakdown,
local_tools: &LatencyComponentBreakdown,
extension_hostcalls: &LatencyComponentBreakdown,
persistence: &LatencyComponentBreakdown,
) -> String {
[
("provider_streaming", provider_streaming.duration_ms),
("local_tools", local_tools.duration_ms),
("extension_hostcalls", extension_hostcalls.duration_ms),
("persistence", persistence.duration_ms),
]
.into_iter()
.max_by_key(|(_, duration_ms)| *duration_ms)
.filter(|(_, duration_ms)| *duration_ms > 0)
.map_or_else(|| "none".to_string(), |(name, _)| name.to_string())
}
#[derive(Debug)]
struct TurnLatencyAccumulator {
started_at: Instant,
provider_streaming_ms: Vec<u64>,
local_tool_ms: Vec<u64>,
extension_hostcall_ms: Vec<u64>,
persistence_ms: Vec<u64>,
}
impl TurnLatencyAccumulator {
fn started() -> Self {
Self {
started_at: Instant::now(),
provider_streaming_ms: Vec::new(),
local_tool_ms: Vec::new(),
extension_hostcall_ms: Vec::new(),
persistence_ms: Vec::new(),
}
}
fn snapshot(&self) -> TurnLatencyBreakdown {
TurnLatencyBreakdown::from_component_samples(
duration_millis_saturating(self.started_at.elapsed()),
&self.provider_streaming_ms,
&self.local_tool_ms,
&self.extension_hostcall_ms,
&self.persistence_ms,
)
}
}
type SharedTurnLatencyAccumulator = Arc<StdMutex<TurnLatencyAccumulator>>;
fn snapshot_turn_latency(
latency: &SharedTurnLatencyAccumulator,
) -> Option<Box<TurnLatencyBreakdown>> {
latency.lock().ok().map(|guard| Box::new(guard.snapshot()))
}
fn record_provider_streaming_latency(latency: &SharedTurnLatencyAccumulator, duration: Duration) {
if let Ok(mut guard) = latency.lock() {
guard
.provider_streaming_ms
.push(duration_millis_saturating(duration));
}
let metrics = crate::session_metrics::global();
record_global_latency(&metrics.provider_streaming, duration);
}
fn record_local_tool_latency(latency: &SharedTurnLatencyAccumulator, duration: Duration) {
if let Ok(mut guard) = latency.lock() {
guard
.local_tool_ms
.push(duration_millis_saturating(duration));
}
let metrics = crate::session_metrics::global();
record_global_latency(&metrics.local_tools, duration);
}
fn record_extension_hostcall_latency(latency: &SharedTurnLatencyAccumulator, duration: Duration) {
if let Ok(mut guard) = latency.lock() {
guard
.extension_hostcall_ms
.push(duration_millis_saturating(duration));
}
let metrics = crate::session_metrics::global();
record_global_latency(&metrics.extension_hostcalls, duration);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ToolEffectBatch {
start: usize,
end: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolEffectBatchEvidence {
pub start: usize,
pub end: usize,
pub len: usize,
pub combined_effects: Vec<&'static str>,
pub parallel_safe: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub barrier_reason: Option<&'static str>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolEffectBatchPlanEvidence {
pub schema: &'static str,
pub tool_count: usize,
pub parallelism_cap: usize,
pub batches: Vec<ToolEffectBatchEvidence>,
}
fn plan_tool_effect_batches(effects: &[ToolEffects]) -> Vec<ToolEffectBatch> {
let Some((&first_effects, remaining_effects)) = effects.split_first() else {
return Vec::new();
};
let mut batches = Vec::new();
let mut start = 0;
let mut active_effects = first_effects;
for (offset, candidate_effects) in remaining_effects.iter().copied().enumerate() {
let index = offset + 1;
if active_effects.compatible_with(candidate_effects) {
active_effects = active_effects.union(candidate_effects);
} else {
batches.push(ToolEffectBatch { start, end: index });
start = index;
active_effects = candidate_effects;
}
}
batches.push(ToolEffectBatch {
start,
end: effects.len(),
});
batches
}
fn combined_tool_effects(effects: &[ToolEffects]) -> Option<ToolEffects> {
effects.iter().copied().reduce(ToolEffects::union)
}
const fn tool_effect_barrier_reason(effects: ToolEffects) -> Option<&'static str> {
if effects.parallel_safe() {
return None;
}
match (effects.writes(), effects.appends(), effects.processes()) {
(true, true, true) => Some("write_append_process_barrier"),
(true, true, false) => Some("write_append_barrier"),
(true, false, true) => Some("write_process_barrier"),
(false, true, true) => Some("append_process_barrier"),
(true, false, false) => Some("write_barrier"),
(false, true, false) => Some("append_barrier"),
(false, false, true) => Some("process_barrier"),
(false, false, false) => Some("undeclared_effects_barrier"),
}
}
#[must_use]
pub fn tool_effect_batch_plan_evidence(
effects: &[ToolEffects],
parallelism_cap: usize,
) -> ToolEffectBatchPlanEvidence {
let batches = plan_tool_effect_batches(effects)
.into_iter()
.map(|batch| {
let combined_effects = effects
.get(batch.start..batch.end)
.and_then(combined_tool_effects)
.unwrap_or_else(ToolEffects::read);
ToolEffectBatchEvidence {
start: batch.start,
end: batch.end,
len: batch.end.saturating_sub(batch.start),
combined_effects: combined_effects.labels(),
parallel_safe: combined_effects.parallel_safe(),
barrier_reason: tool_effect_barrier_reason(combined_effects),
}
})
.collect();
ToolEffectBatchPlanEvidence {
schema: TOOL_EFFECT_BATCH_PLAN_SCHEMA_V1,
tool_count: effects.len(),
parallelism_cap,
batches,
}
}
pub const MAX_TOOL_ITERATIONS_DEFAULT: usize = 50;
pub const MAX_TOOL_ITERATIONS_CEILING: usize = 1_000;
pub const MAX_PAUSE_TURN_CONTINUATIONS: usize = 3;
const ITERATION_WARN_NUMERATOR: usize = 4;
const ITERATION_WARN_DENOMINATOR: usize = 5;
const ITERATION_WARN_MIN_CAP: usize = 5;
pub fn resolved_max_tool_iterations_default() -> usize {
resolve_max_tool_iterations(std::env::var("PI_MAX_TOOL_ITERATIONS").ok().as_deref())
}
pub fn resolve_max_tool_iterations(raw_override: Option<&str>) -> usize {
let Some(raw) = raw_override.map(str::trim).filter(|raw| !raw.is_empty()) else {
return MAX_TOOL_ITERATIONS_DEFAULT;
};
match raw.parse::<usize>() {
Ok(0) => {
warn!(
"PI_MAX_TOOL_ITERATIONS=0 is invalid; falling back to {}",
MAX_TOOL_ITERATIONS_DEFAULT
);
MAX_TOOL_ITERATIONS_DEFAULT
}
Ok(n) if n > MAX_TOOL_ITERATIONS_CEILING => {
warn!(
"PI_MAX_TOOL_ITERATIONS={n} exceeds ceiling {MAX_TOOL_ITERATIONS_CEILING}; clamping to {MAX_TOOL_ITERATIONS_CEILING}"
);
MAX_TOOL_ITERATIONS_CEILING
}
Ok(n) => n,
Err(err) => {
warn!(
"PI_MAX_TOOL_ITERATIONS={raw:?} is not a valid usize ({err}); falling back to {}",
MAX_TOOL_ITERATIONS_DEFAULT
);
MAX_TOOL_ITERATIONS_DEFAULT
}
}
}
pub fn clamp_max_tool_iterations(value: Option<usize>) -> usize {
match value {
None => MAX_TOOL_ITERATIONS_DEFAULT,
Some(0) => {
warn!(
"--max-tool-iterations=0 is invalid; falling back to {}",
MAX_TOOL_ITERATIONS_DEFAULT
);
MAX_TOOL_ITERATIONS_DEFAULT
}
Some(n) if n > MAX_TOOL_ITERATIONS_CEILING => {
warn!(
"--max-tool-iterations={n} exceeds ceiling {MAX_TOOL_ITERATIONS_CEILING}; clamping to {MAX_TOOL_ITERATIONS_CEILING}"
);
MAX_TOOL_ITERATIONS_CEILING
}
Some(n) => n,
}
}
pub const fn should_warn_at_iteration_threshold(current: usize, max: usize) -> bool {
max >= ITERATION_WARN_MIN_CAP
&& current >= max.saturating_mul(ITERATION_WARN_NUMERATOR) / ITERATION_WARN_DENOMINATOR
}
pub fn iteration_handoff_steering_text(current: usize, max: usize) -> String {
format!(
"[runtime] Tool-iteration budget at >=80% (used {current} of {max}). \
Per the iteration-aware-handoff protocol in your spec, begin graceful \
handoff now: commit current work, post a one-line status note, and \
write an incomplete-handoff envelope with what's done / what remains \
/ next-agent starting position. Do NOT compress remaining work into \
the last few iterations."
)
}
#[derive(Clone)]
pub struct AgentConfig {
pub system_prompt: Option<String>,
pub max_tool_iterations: usize,
pub stream_options: StreamOptions,
pub model_accepts_images: bool,
pub block_images: bool,
pub fail_closed_hooks: bool,
pub tool_approval: Option<ToolApprovalHandler>,
pub keyword_settings: Option<crate::magic_keywords::KeywordSettings>,
pub max_time: Option<std::time::Duration>,
pub turn_recovery: crate::turn_recovery::TurnRecoveryMode,
pub approval_state: Option<crate::approval::ApprovalState>,
pub bash_settings: Option<crate::config::BashSettings>,
pub secrets: Option<crate::secrets::SecretsSettings>,
}
impl fmt::Debug for AgentConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AgentConfig")
.field("system_prompt", &self.system_prompt)
.field("max_tool_iterations", &self.max_tool_iterations)
.field("stream_options", &self.stream_options)
.field("block_images", &self.block_images)
.field("model_accepts_images", &self.model_accepts_images)
.field("fail_closed_hooks", &self.fail_closed_hooks)
.field("tool_approval", &self.tool_approval.is_some())
.field("keyword_settings", &self.keyword_settings)
.field("max_time", &self.max_time)
.field("turn_recovery", &self.turn_recovery)
.field("approval_state", &self.approval_state)
.field("bash_settings", &self.bash_settings)
.field("secrets", &self.secrets)
.finish()
}
}
#[derive(Debug, Clone)]
pub struct ToolApprovalRequest {
pub tool_call_id: String,
pub tool_name: String,
pub arguments: Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolApprovalDecision {
Allow,
Deny { reason: String },
}
impl ToolApprovalDecision {
#[must_use]
pub fn deny(reason: impl Into<String>) -> Self {
Self::Deny {
reason: reason.into(),
}
}
}
pub type ToolApprovalHandler =
Arc<dyn Fn(ToolApprovalRequest) -> BoxFuture<'static, ToolApprovalDecision> + Send + Sync>;
impl Default for AgentConfig {
fn default() -> Self {
Self {
system_prompt: None,
max_tool_iterations: resolved_max_tool_iterations_default(),
stream_options: StreamOptions::default(),
block_images: false,
model_accepts_images: true,
fail_closed_hooks: false,
tool_approval: None,
keyword_settings: None,
max_time: None,
turn_recovery: crate::turn_recovery::TurnRecoveryMode::default(),
approval_state: None,
bash_settings: None,
secrets: None,
}
}
}
#[derive(Debug, Clone)]
pub struct SemanticContextBundleInjection {
pub enabled: bool,
pub bundle: SemanticContextBundle,
pub max_prompt_items: usize,
pub max_prompt_bytes: u64,
pub include_exclusion_summary: bool,
pub include_validation_commands: bool,
}
impl SemanticContextBundleInjection {
pub fn enabled(bundle: SemanticContextBundle) -> Self {
let max_prompt_items = bundle
.budget
.max_items
.min(DEFAULT_SEMANTIC_CONTEXT_PROMPT_MAX_ITEMS);
let max_prompt_bytes = bundle
.budget
.max_bytes
.min(DEFAULT_SEMANTIC_CONTEXT_PROMPT_MAX_BYTES);
Self {
enabled: true,
bundle,
max_prompt_items,
max_prompt_bytes,
include_exclusion_summary: true,
include_validation_commands: true,
}
}
pub const fn disabled(bundle: SemanticContextBundle) -> Self {
Self {
enabled: false,
bundle,
max_prompt_items: DEFAULT_SEMANTIC_CONTEXT_PROMPT_MAX_ITEMS,
max_prompt_bytes: DEFAULT_SEMANTIC_CONTEXT_PROMPT_MAX_BYTES,
include_exclusion_summary: true,
include_validation_commands: true,
}
}
#[must_use]
pub const fn with_prompt_budget(mut self, max_items: usize, max_bytes: u64) -> Self {
self.max_prompt_items = max_items;
self.max_prompt_bytes = max_bytes;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SemanticContextPromptShape {
CustomUserMessage,
SystemPromptAppend,
}
#[derive(Debug, Clone)]
struct PreparedSemanticContextPrompt {
prompt: String,
revision: String,
shape: SemanticContextPromptShape,
details: Value,
}
#[derive(Debug, Clone, Copy)]
struct SemanticContextPromptBudget {
max_items: usize,
max_bytes: u64,
}
#[derive(Debug, Default, Clone, Copy)]
struct SemanticContextPromptStats {
selected_items_included: usize,
selected_items_omitted: usize,
validation_commands_included: usize,
validation_commands_omitted: usize,
exclusions_included: usize,
exclusions_omitted: usize,
truncated: bool,
}
#[derive(Debug, Clone)]
pub struct QueuedAgentMessage {
message: Message,
keyword_scan_source: Option<String>,
persistence_identity: Arc<OnceLock<QueuedPersistenceIdentity>>,
}
#[derive(Debug, Clone)]
struct QueuedPersistenceIdentity {
entry_id: String,
timestamp: String,
parent_id: Option<String>,
}
impl QueuedAgentMessage {
#[must_use]
pub fn authored(message: Message, keyword_scan_source: impl Into<String>) -> Self {
let keyword_scan_source =
matches!(&message, Message::User(_)).then(|| keyword_scan_source.into());
Self {
message,
keyword_scan_source,
persistence_identity: Arc::new(OnceLock::new()),
}
}
#[must_use]
pub fn from_authored_message(message: Message) -> Self {
let keyword_scan_source = match &message {
Message::User(UserMessage { content, .. }) => Some(match content {
UserContent::Text(text) => text.clone(),
UserContent::Blocks(blocks) => blocks
.iter()
.filter_map(|block| match block {
ContentBlock::Text(text) => Some(text.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n"),
}),
_ => None,
};
Self {
message,
keyword_scan_source,
persistence_identity: Arc::new(OnceLock::new()),
}
}
#[must_use]
pub fn generated(message: Message) -> Self {
Self {
message,
keyword_scan_source: None,
persistence_identity: Arc::new(OnceLock::new()),
}
}
#[must_use]
pub const fn message(&self) -> &Message {
&self.message
}
#[must_use]
pub(crate) fn bind_persistence_identity(
&self,
parent_id: Option<String>,
) -> (String, String, Option<String>) {
let identity = self
.persistence_identity
.get_or_init(|| QueuedPersistenceIdentity {
entry_id: uuid::Uuid::new_v4().to_string(),
timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
parent_id,
});
(
identity.entry_id.clone(),
identity.timestamp.clone(),
identity.parent_id.clone(),
)
}
#[must_use]
pub(crate) fn persistence_entry_id(&self) -> Option<&str> {
self.persistence_identity
.get()
.map(|identity| identity.entry_id.as_str())
}
fn shares_persistence_identity(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.persistence_identity, &other.persistence_identity)
}
#[must_use]
pub fn keyword_scan_source(&self) -> Option<&str> {
self.keyword_scan_source.as_deref()
}
#[must_use]
pub fn text_for_display(&self) -> Option<&str> {
self.keyword_scan_source().or_else(|| match &self.message {
Message::User(user) => match &user.content {
UserContent::Text(text) => Some(text.as_str()),
UserContent::Blocks(blocks) => blocks.iter().find_map(|block| match block {
ContentBlock::Text(text) => Some(text.text.as_str()),
_ => None,
}),
},
_ => None,
})
}
#[must_use]
pub fn into_message(self) -> Message {
self.message
}
}
pub type MessageFetcher =
Arc<dyn Fn() -> BoxFuture<'static, Vec<QueuedAgentMessage>> + Send + Sync + 'static>;
type AgentEventHandler = Arc<dyn Fn(AgentEvent) + Send + Sync + 'static>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueueMode {
All,
OneAtATime,
}
impl QueueMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::All => "all",
Self::OneAtATime => "one-at-a-time",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputSource {
Interactive,
Rpc,
Extension,
}
impl InputSource {
pub const fn as_str(self) -> &'static str {
match self {
Self::Interactive => "interactive",
Self::Rpc => "rpc",
Self::Extension => "extension",
}
}
}
#[derive(Debug, Clone, Copy)]
enum QueueKind {
Steering,
FollowUp,
}
#[derive(Debug, Clone)]
struct SequencedQueuedMessage {
seq: u64,
enqueued_at: i64,
job_owner_session_id: Option<String>,
delivery: QueuedAgentMessage,
}
#[derive(Debug)]
struct MessageQueue {
steering: VecDeque<SequencedQueuedMessage>,
follow_up: VecDeque<SequencedQueuedMessage>,
steering_mode: QueueMode,
follow_up_mode: QueueMode,
next_seq: u64,
}
impl MessageQueue {
const fn new(steering_mode: QueueMode, follow_up_mode: QueueMode) -> Self {
Self {
steering: VecDeque::new(),
follow_up: VecDeque::new(),
steering_mode,
follow_up_mode,
next_seq: 0,
}
}
const fn set_modes(&mut self, steering_mode: QueueMode, follow_up_mode: QueueMode) {
self.steering_mode = steering_mode;
self.follow_up_mode = follow_up_mode;
}
fn pending_count(&self) -> usize {
self.steering.len() + self.follow_up.len()
}
fn next_entry(
&mut self,
delivery: QueuedAgentMessage,
job_owner_session_id: Option<String>,
) -> SequencedQueuedMessage {
let seq = self.next_seq;
self.next_seq = self.next_seq.saturating_add(1);
SequencedQueuedMessage {
seq,
enqueued_at: Utc::now().timestamp_millis(),
job_owner_session_id,
delivery,
}
}
fn push(&mut self, kind: QueueKind, delivery: QueuedAgentMessage) -> u64 {
let entry = self.next_entry(delivery, None);
let seq = entry.seq;
match kind {
QueueKind::Steering => {
if self.steering.len() >= MAX_STEERING_QUEUE_SIZE {
tracing::warn!(
"Steering queue full ({} messages), dropping oldest message",
MAX_STEERING_QUEUE_SIZE
);
self.steering.pop_front();
}
self.steering.push_back(entry);
}
QueueKind::FollowUp => {
let ordinary_count = self
.follow_up
.iter()
.filter(|entry| entry.job_owner_session_id.is_none())
.count();
if ordinary_count >= MAX_FOLLOW_UP_QUEUE_SIZE {
tracing::warn!(
"Follow-up queue full ({} messages), dropping oldest message",
MAX_FOLLOW_UP_QUEUE_SIZE
);
if let Some(oldest) = self
.follow_up
.iter()
.position(|entry| entry.job_owner_session_id.is_none())
{
let _ = self.follow_up.remove(oldest);
}
}
self.follow_up.push_back(entry);
}
}
seq
}
fn push_steering(&mut self, delivery: QueuedAgentMessage) -> u64 {
self.push(QueueKind::Steering, delivery)
}
fn push_steering_lossless(&mut self, delivery: QueuedAgentMessage) -> u64 {
let entry = self.next_entry(delivery, None);
let seq = entry.seq;
self.steering.push_back(entry);
seq
}
fn restore_steering_front_lossless(&mut self, deliveries: Vec<QueuedAgentMessage>) {
let mut restored = deliveries
.into_iter()
.map(|delivery| self.next_entry(delivery, None))
.collect::<VecDeque<_>>();
restored.append(&mut self.steering);
self.steering = restored;
}
fn push_follow_up(&mut self, delivery: QueuedAgentMessage) -> u64 {
self.push(QueueKind::FollowUp, delivery)
}
fn push_follow_up_lossless(&mut self, delivery: QueuedAgentMessage) -> u64 {
let entry = self.next_entry(delivery, None);
let seq = entry.seq;
self.follow_up.push_back(entry);
seq
}
fn push_job_follow_up_lossless(
&mut self,
owner_session_id: String,
delivery: QueuedAgentMessage,
) -> u64 {
let entry = self.next_entry(delivery, Some(owner_session_id));
let seq = entry.seq;
self.follow_up.push_back(entry);
seq
}
fn has_job_follow_up(&self) -> bool {
self.follow_up
.iter()
.any(|entry| entry.job_owner_session_id.is_some())
}
fn take_job_follow_ups_except(
&mut self,
owner_session_id: Option<&str>,
) -> Vec<(String, QueuedAgentMessage)> {
let mut retained = VecDeque::with_capacity(self.follow_up.len());
let mut released = Vec::new();
while let Some(entry) = self.follow_up.pop_front() {
let should_release = entry
.job_owner_session_id
.as_deref()
.is_some_and(|owner| Some(owner) != owner_session_id);
if should_release {
if let Some(owner) = entry.job_owner_session_id {
released.push((owner, entry.delivery));
}
} else {
retained.push_back(entry);
}
}
self.follow_up = retained;
released
}
fn pop_steering(&mut self) -> Vec<QueuedAgentMessage> {
self.pop_kind(QueueKind::Steering)
}
fn pop_follow_up(&mut self) -> Vec<QueuedAgentMessage> {
self.pop_kind(QueueKind::FollowUp)
}
fn follow_up_batch_len(&self) -> usize {
match self.follow_up_mode {
QueueMode::All => self.follow_up.len(),
QueueMode::OneAtATime => usize::from(!self.follow_up.is_empty()),
}
}
fn pop_kind(&mut self, kind: QueueKind) -> Vec<QueuedAgentMessage> {
let (queue, mode) = match kind {
QueueKind::Steering => (&mut self.steering, self.steering_mode),
QueueKind::FollowUp => (&mut self.follow_up, self.follow_up_mode),
};
match mode {
QueueMode::All => queue.drain(..).map(|entry| entry.delivery).collect(),
QueueMode::OneAtATime => queue
.pop_front()
.into_iter()
.map(|entry| entry.delivery)
.collect(),
}
}
fn discard_persistence_ids(&mut self, entry_ids: &std::collections::HashSet<String>) -> usize {
let before = self.pending_count();
self.steering.retain(|entry| {
!entry
.delivery
.persistence_entry_id()
.is_some_and(|id| entry_ids.contains(id))
});
self.follow_up.retain(|entry| {
!entry
.delivery
.persistence_entry_id()
.is_some_and(|id| entry_ids.contains(id))
});
before.saturating_sub(self.pending_count())
}
fn contains_delivery(&self, delivery: &QueuedAgentMessage) -> bool {
self.steering
.iter()
.chain(&self.follow_up)
.any(|entry| entry.delivery.shares_persistence_identity(delivery))
}
}
impl AgentEvent {
pub fn to_json_stream_line(&self) -> serde_json::Result<String> {
#[derive(Serialize)]
struct DeltaOnlyMessageUpdate<'a> {
#[serde(rename = "type")]
kind: &'static str,
#[serde(rename = "assistantMessageEvent")]
assistant_message_event: crate::model::AssistantMessageEventDelta<'a>,
}
match self {
Self::MessageUpdate {
assistant_message_event,
..
} => serde_json::to_string(&DeltaOnlyMessageUpdate {
kind: "message_update",
assistant_message_event: assistant_message_event.delta_only(),
}),
other => serde_json::to_string(other),
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentEvent {
AgentStart {
#[serde(rename = "sessionId")]
session_id: Arc<str>,
},
AgentEnd {
#[serde(rename = "sessionId")]
session_id: Arc<str>,
messages: Vec<Message>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
TurnStart {
#[serde(rename = "sessionId")]
session_id: Arc<str>,
#[serde(rename = "turnIndex")]
turn_index: usize,
timestamp: i64,
},
TurnEnd {
#[serde(rename = "sessionId")]
session_id: Arc<str>,
#[serde(rename = "turnIndex")]
turn_index: usize,
message: Message,
#[serde(rename = "toolResults")]
tool_results: Vec<Message>,
#[serde(rename = "latencyBreakdown", skip_serializing_if = "Option::is_none")]
latency_breakdown: Option<Box<TurnLatencyBreakdown>>,
},
MessageStart { message: Message },
MessageUpdate {
message: Message,
#[serde(rename = "assistantMessageEvent")]
assistant_message_event: AssistantMessageEvent,
},
MessageEnd { message: Message },
ToolExecutionStart {
#[serde(rename = "toolCallId")]
tool_call_id: String,
#[serde(rename = "toolName")]
tool_name: String,
args: serde_json::Value,
},
ToolExecutionUpdate {
#[serde(rename = "toolCallId")]
tool_call_id: String,
#[serde(rename = "toolName")]
tool_name: String,
args: serde_json::Value,
#[serde(rename = "partialResult")]
partial_result: ToolOutput,
},
ToolExecutionEnd {
#[serde(rename = "toolCallId")]
tool_call_id: String,
#[serde(rename = "toolName")]
tool_name: String,
result: ToolOutput,
#[serde(rename = "isError")]
is_error: bool,
},
AutoCompactionStart { reason: String },
AutoCompactionEnd {
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
aborted: bool,
#[serde(rename = "willRetry")]
will_retry: bool,
#[serde(rename = "errorMessage", skip_serializing_if = "Option::is_none")]
error_message: Option<String>,
},
AutoRetryStart {
attempt: u32,
#[serde(rename = "maxAttempts")]
max_attempts: u32,
#[serde(rename = "delayMs")]
delay_ms: u64,
#[serde(rename = "errorMessage")]
error_message: String,
},
AutoRetryEnd {
success: bool,
attempt: u32,
#[serde(rename = "finalError", skip_serializing_if = "Option::is_none")]
final_error: Option<String>,
},
FailoverStart {
#[serde(rename = "fromProvider")]
from_provider: String,
#[serde(rename = "fromModel")]
from_model: String,
#[serde(rename = "toProvider")]
to_provider: String,
#[serde(rename = "toModel")]
to_model: String,
class: String,
attempt: u32,
#[serde(rename = "chainIndex")]
chain_index: u32,
},
FailoverEnd {
success: bool,
provider: String,
model: String,
#[serde(rename = "restoredPrimary")]
restored_primary: bool,
},
AdvisorNote { level: String, rationale: String },
ProviderError {
#[serde(rename = "sessionId")]
session_id: Arc<str>,
provider: String,
model: String,
#[serde(flatten)]
summary: crate::error::ProviderErrorSummary,
message: String,
},
ExtensionError {
#[serde(rename = "extensionId", skip_serializing_if = "Option::is_none")]
extension_id: Option<String>,
event: String,
error: String,
},
}
#[derive(Debug, Clone)]
pub struct AbortHandle {
inner: Arc<AbortSignalInner>,
}
#[derive(Debug, Clone)]
pub struct AbortSignal {
inner: Arc<AbortSignalInner>,
}
#[derive(Debug)]
struct AbortSignalInner {
aborted: AtomicBool,
notify: Notify,
}
impl AbortHandle {
#[must_use]
pub fn new() -> (Self, AbortSignal) {
let inner = Arc::new(AbortSignalInner {
aborted: AtomicBool::new(false),
notify: Notify::new(),
});
(
Self {
inner: Arc::clone(&inner),
},
AbortSignal { inner },
)
}
pub fn abort(&self) {
if !self.inner.aborted.swap(true, Ordering::SeqCst) {
self.inner.notify.notify_waiters();
}
}
}
impl AbortSignal {
#[must_use]
pub fn is_aborted(&self) -> bool {
self.inner.aborted.load(Ordering::SeqCst)
}
pub async fn wait(&self) {
if self.is_aborted() {
return;
}
loop {
self.inner.notify.notified().await;
if self.is_aborted() {
return;
}
}
}
}
pub struct Agent {
provider: Arc<dyn Provider>,
tools: crate::tools::SharedToolRegistry,
config: AgentConfig,
extensions: Option<ExtensionManager>,
messages: Vec<Message>,
steering_fetchers: Vec<MessageFetcher>,
initial_follow_up_fetcher: Option<MessageFetcher>,
follow_up_fetchers: Vec<MessageFetcher>,
job_session_scope: crate::jobs::JobSessionScope,
message_queue: MessageQueue,
cached_tool_defs: Option<(u64, Vec<ToolDef>)>,
scoped_rules: Option<ScopedRuleState>,
promoted_tools: Arc<StdMutex<std::collections::HashSet<String>>>,
tool_defs_generation: Arc<std::sync::atomic::AtomicU64>,
plan_state: crate::plan::PlanState,
repair_ledger: Arc<StdMutex<crate::dialects::RepairLedger>>,
dialect_repair_sequence: AtomicU64,
tool_call_dialect: crate::dialects::Dialect,
keyword_ledger: Vec<crate::magic_keywords::KeywordActivation>,
retry_keyword_activations: Vec<crate::magic_keywords::KeywordActivation>,
magic_keyword_scan_override: Option<String>,
keyword_max_thinking_level: crate::model::ThinkingLevel,
secrets_vault: crate::secrets::SecretVault,
}
struct ScopedRuleState {
rules: Vec<crate::context_files::ForeignRule>,
matcher: crate::context_files::ScopedRuleMatcher,
workspace_root: PathBuf,
activated: std::collections::HashSet<usize>,
}
const PATH_LIKE_INPUT_KEYS: [&str; 6] =
["path", "file_path", "filePath", "file", "directory", "cwd"];
fn path_like_inputs(arguments: &Value) -> Vec<&str> {
let Some(object) = arguments.as_object() else {
return Vec::new();
};
PATH_LIKE_INPUT_KEYS
.iter()
.filter_map(|key| object.get(*key))
.flat_map(|value| match value {
Value::String(_) => std::slice::from_ref(value).iter(),
Value::Array(entries) => entries.iter(),
_ => [].iter(),
})
.filter_map(Value::as_str)
.filter(|candidate| !candidate.is_empty())
.collect()
}
impl Agent {
pub fn new(provider: Arc<dyn Provider>, tools: ToolRegistry, config: AgentConfig) -> Self {
Self::with_shared_tools(
provider,
crate::tools::SharedToolRegistry::new(tools),
config,
)
}
#[must_use]
pub fn shared_tools(&self) -> crate::tools::SharedToolRegistry {
self.tools.clone()
}
pub fn with_shared_tools(
provider: Arc<dyn Provider>,
tools: crate::tools::SharedToolRegistry,
config: AgentConfig,
) -> Self {
let keyword_max_thinking_level = config
.stream_options
.thinking_level
.unwrap_or(crate::model::ThinkingLevel::Off);
let job_session_scope = tools.snapshot().job_session_scope();
Self {
provider,
tools,
config,
extensions: None,
messages: Vec::new(),
steering_fetchers: Vec::new(),
initial_follow_up_fetcher: None,
follow_up_fetchers: Vec::new(),
job_session_scope,
message_queue: MessageQueue::new(QueueMode::OneAtATime, QueueMode::OneAtATime),
cached_tool_defs: None,
scoped_rules: None,
promoted_tools: Arc::new(StdMutex::new(std::collections::HashSet::new())),
tool_defs_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
plan_state: crate::plan::PlanState::new(),
repair_ledger: Arc::new(StdMutex::new(crate::dialects::RepairLedger::default())),
dialect_repair_sequence: AtomicU64::new(0),
tool_call_dialect: crate::dialects::Dialect::Native,
keyword_ledger: Vec::new(),
retry_keyword_activations: Vec::new(),
magic_keyword_scan_override: None,
keyword_max_thinking_level,
secrets_vault: crate::secrets::SecretVault::default(),
}
}
fn maybe_repair_dialect_tool_calls(&self, msg: AssistantMessage) -> AssistantMessage {
use crate::dialects::{Dialect, extract_text_tool_calls, strip_candidates};
if !extract_tool_calls(&msg.content).is_empty() {
return msg; }
if !matches!(msg.stop_reason, StopReason::Stop) {
return msg;
}
if self.tools.snapshot().tools().is_empty() {
return msg;
}
if self.tool_call_dialect != Dialect::Xmlish {
return msg;
}
let candidate_block =
msg.content
.iter()
.enumerate()
.find_map(|(index, block)| match block {
ContentBlock::Text(text) => {
let candidates = extract_text_tool_calls(&text.text, &|name| {
self.tools.snapshot().get(name).is_some()
});
(!candidates.is_empty()).then_some((index, candidates))
}
_ => None,
});
let Some((block_index, candidates)) = candidate_block else {
return msg;
};
let ContentBlock::Text(original_text) = &msg.content[block_index] else {
unreachable!("candidate search only returns text blocks");
};
let remaining = strip_candidates(&original_text.text, &candidates);
let repair_sequence = self.dialect_repair_sequence.fetch_add(1, Ordering::Relaxed);
let mut replacement = Vec::with_capacity(1 + candidates.len());
if !remaining.is_empty() {
replacement.push(ContentBlock::Text(TextContent::new(remaining.clone())));
}
for (index, candidate) in candidates.iter().enumerate() {
replacement.push(ContentBlock::ToolCall(ToolCall {
id: format!("dialect-repair-{repair_sequence}-{index}"),
name: candidate.name.clone(),
arguments: candidate.arguments.clone(),
thought_signature: None,
}));
}
let mut content = msg.content.clone();
content.splice(block_index..=block_index, replacement);
if let Ok(mut ledger) = self.repair_ledger.lock() {
for candidate in &candidates {
ledger.record(
&candidate.name,
candidate.end - candidate.start,
remaining.len(),
);
}
}
tracing::info!(
event = "pi.dialect.repair",
tools = ?candidates.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
remaining_text_bytes = remaining.len(),
"Repaired text-emitted tool call into structured call"
);
AssistantMessage { content, ..msg }
}
#[must_use]
pub fn plan_state(&self) -> crate::plan::PlanState {
self.plan_state.clone()
}
pub fn reset_session_scoped_state(&mut self, plan_mode: crate::plan::PlanMode) {
self.secrets_vault = crate::secrets::SecretVault::default();
self.plan_state.reset_for_session(plan_mode);
}
#[must_use]
pub fn approval_state(&self) -> Option<crate::approval::ApprovalState> {
self.config.approval_state.clone()
}
pub fn repair_ledger_is_empty(&self) -> Result<bool> {
self.repair_ledger
.lock()
.map(|ledger| ledger.entries.is_empty())
.map_err(|_| Error::session("dialect repair ledger mutex poisoned"))
}
pub fn drain_repair_ledger(&self) -> Result<Vec<crate::dialects::RepairEntry>> {
self.repair_ledger
.lock()
.map(|mut ledger| std::mem::take(&mut ledger.entries))
.map_err(|_| Error::session("dialect repair ledger mutex poisoned"))
}
pub fn drain_keyword_ledger(&mut self) -> Vec<crate::magic_keywords::KeywordActivation> {
std::mem::take(&mut self.keyword_ledger)
}
pub fn set_foreign_scoped_rules(
&mut self,
rules: Vec<crate::context_files::ForeignRule>,
workspace_root: PathBuf,
) {
let matcher = crate::context_files::ScopedRuleMatcher::new(&rules);
if matcher.is_empty() {
self.scoped_rules = None;
return;
}
self.scoped_rules = Some(ScopedRuleState {
rules,
matcher,
workspace_root,
activated: std::collections::HashSet::new(),
});
}
fn activate_scoped_rules_for_tool_calls(&mut self, tool_calls: &[ToolCall]) {
let Some(state) = &mut self.scoped_rules else {
return;
};
let mut newly_activated = Vec::new();
for tool_call in tool_calls {
for path in path_like_inputs(&tool_call.arguments) {
for index in state
.matcher
.matching_rules(Path::new(&path), &state.workspace_root)
{
if state.activated.insert(index) {
newly_activated.push(index);
}
}
}
}
for index in newly_activated {
let Some(rule) = state.rules.get(index) else {
continue;
};
let text = format!(
"<imported-rule source=\"{}\" format=\"{}\">\n{}\n</imported-rule>\nThis workspace rule applies to files you are now working with. Follow it for the rest of the session.",
rule.source,
rule.format.label(),
rule.content.trim()
);
tracing::info!(
event = "pi.context_files.scoped_rule_activated",
source = %rule.source,
format = rule.format.label(),
"imported scoped rule activated"
);
self.message_queue
.push_steering(QueuedAgentMessage::generated(Message::User(UserMessage {
content: UserContent::Text(text),
timestamp: Utc::now().timestamp_millis(),
})));
}
}
#[must_use]
pub fn messages(&self) -> &[Message] {
&self.messages
}
pub fn clear_messages(&mut self) {
self.messages.clear();
}
pub fn truncate_messages(&mut self, len: usize) {
self.messages.truncate(len);
}
pub fn add_message(&mut self, message: Message) {
if self.messages.len() >= MAX_AGENT_MESSAGES {
tracing::warn!(
"Agent message history full ({} messages), dropping oldest message",
MAX_AGENT_MESSAGES
);
self.messages.remove(0);
}
self.messages.push(message);
}
pub fn replace_messages(&mut self, messages: Vec<Message>) {
self.messages = messages;
}
pub fn set_provider(&mut self, provider: Arc<dyn Provider>) {
self.provider = provider;
self.keyword_max_thinking_level = crate::model::ThinkingLevel::Off;
self.tool_call_dialect = crate::dialects::Dialect::Native;
}
pub const fn set_keyword_max_thinking_level(&mut self, level: crate::model::ThinkingLevel) {
self.keyword_max_thinking_level = level;
}
pub fn set_magic_keyword_scan_override(&mut self, source: Option<String>) {
self.magic_keyword_scan_override = source;
}
pub const fn set_tool_call_dialect(&mut self, dialect: crate::dialects::Dialect) {
self.tool_call_dialect = dialect;
}
pub const fn set_model_accepts_images(&mut self, accepts_images: bool) {
self.config.model_accepts_images = accepts_images;
}
#[must_use]
pub const fn model_accepts_images(&self) -> bool {
self.config.model_accepts_images
}
#[must_use]
pub const fn tool_call_dialect(&self) -> crate::dialects::Dialect {
self.tool_call_dialect
}
pub fn register_message_fetchers(
&mut self,
steering: Option<MessageFetcher>,
follow_up: Option<MessageFetcher>,
) {
if let Some(fetcher) = steering {
self.steering_fetchers.push(fetcher);
}
if let Some(fetcher) = follow_up {
self.follow_up_fetchers.push(fetcher);
}
}
pub(crate) fn register_initial_follow_up_fetcher(&mut self, fetcher: MessageFetcher) {
self.initial_follow_up_fetcher = Some(fetcher);
}
pub(crate) fn has_staged_follow_up(&self) -> bool {
self.message_queue.follow_up_batch_len() > 0
}
pub fn extend_tools<I>(&mut self, tools: I)
where
I: IntoIterator<Item = Box<dyn Tool>>,
{
self.tools.update(|registry| registry.extend(tools));
self.cached_tool_defs = None; }
pub fn set_tool_approval(&mut self, handler: Option<ToolApprovalHandler>) {
self.config.tool_approval = handler;
}
#[must_use]
pub fn has_tool(&self, name: &str) -> bool {
self.tools.snapshot().get(name).is_some()
}
pub fn queue_steering(&mut self, message: Message) -> u64 {
self.message_queue
.push_steering(QueuedAgentMessage::from_authored_message(message))
}
pub fn queue_follow_up(&mut self, message: Message) -> u64 {
self.message_queue
.push_follow_up(QueuedAgentMessage::from_authored_message(message))
}
fn queue_generated_steering(&mut self, message: Message) -> u64 {
self.message_queue
.push_steering(QueuedAgentMessage::generated(message))
}
pub const fn set_queue_modes(&mut self, steering: QueueMode, follow_up: QueueMode) {
self.message_queue.set_modes(steering, follow_up);
}
pub const fn queue_modes(&self) -> (QueueMode, QueueMode) {
(
self.message_queue.steering_mode,
self.message_queue.follow_up_mode,
)
}
#[must_use]
pub fn queued_message_count(&self) -> usize {
self.message_queue.pending_count()
}
pub(crate) fn discard_queued_persistence_ids(
&mut self,
entry_ids: &std::collections::HashSet<String>,
) -> usize {
self.message_queue.discard_persistence_ids(entry_ids)
}
pub(crate) fn has_staged_delivery(&self, delivery: &QueuedAgentMessage) -> bool {
self.message_queue.contains_delivery(delivery)
}
pub fn provider(&self) -> Arc<dyn Provider> {
Arc::clone(&self.provider)
}
#[must_use]
pub fn mutation_recorder(&self) -> Option<Arc<crate::undo::FileMutationRecorder>> {
self.tools.snapshot().mutation_recorder()
}
pub const fn stream_options(&self) -> &StreamOptions {
&self.config.stream_options
}
pub const fn stream_options_mut(&mut self) -> &mut StreamOptions {
&mut self.config.stream_options
}
pub fn system_prompt(&self) -> Option<&str> {
self.config.system_prompt.as_deref()
}
pub fn set_system_prompt(&mut self, system_prompt: Option<String>) {
self.config.system_prompt = system_prompt;
}
fn build_context(&mut self) -> Context<'_> {
let has_excluded = self
.messages
.iter()
.any(|m| matches!(m, Message::Custom(c) if context_excluded_custom_message(c)));
let messages: Cow<'_, [Message]> = if self.config.block_images || has_excluded {
let mut msgs = self.messages.clone();
msgs.retain(|m| match m {
Message::Custom(c) => !context_excluded_custom_message(c),
_ => true,
});
if self.config.block_images {
let stats = filter_images_for_provider(&mut msgs);
if stats.removed_images > 0 {
tracing::debug!(
filtered_images = stats.removed_images,
affected_messages = stats.affected_messages,
"Filtered image content from outbound provider context (images.block_images=true)"
);
}
}
Cow::Owned(msgs)
} else {
Cow::Borrowed(self.messages.as_slice())
};
let messages = if self.config.model_accepts_images {
messages
} else {
let mut owned = messages.into_owned();
let _stats = crate::compaction_snap::strip_snapcompact_images(&mut owned, false);
std::borrow::Cow::Owned(owned)
};
let generation = self
.tool_defs_generation
.load(std::sync::atomic::Ordering::SeqCst)
^ self.tools.version().rotate_left(32);
let cache_fresh = self
.cached_tool_defs
.as_ref()
.is_some_and(|cached| cached.0 == generation);
if !cache_fresh {
let promoted = self
.promoted_tools
.lock()
.map(|set| set.clone())
.unwrap_or_default();
let registry = self.tools.snapshot();
let defs: Vec<ToolDef> = registry
.tools()
.iter()
.filter(|t| !registry.is_discoverable(t.name()) || promoted.contains(t.name()))
.map(|t| ToolDef {
name: t.name().to_string(),
description: t.description().to_string(),
parameters: t.parameters(),
})
.collect();
self.cached_tool_defs = Some((generation, defs));
}
let tools = Cow::Borrowed(
self.cached_tool_defs
.as_ref()
.map(|(_, defs)| defs.as_slice())
.expect("cache populated above"),
);
Context {
system_prompt: self.config.system_prompt.as_deref().map(Cow::Borrowed),
messages,
tools,
}
}
pub async fn run(
&mut self,
user_input: impl Into<String>,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<AssistantMessage> {
self.run_with_abort(user_input, None, on_event).await
}
pub async fn run_with_abort(
&mut self,
user_input: impl Into<String>,
abort: Option<AbortSignal>,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<AssistantMessage> {
let user_message = Message::User(UserMessage {
content: UserContent::Text(user_input.into()),
timestamp: Utc::now().timestamp_millis(),
});
self.run_loop(vec![user_message], Arc::new(on_event), abort)
.await
}
pub async fn run_with_content(
&mut self,
content: Vec<ContentBlock>,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<AssistantMessage> {
self.run_with_content_with_abort(content, None, on_event)
.await
}
pub async fn run_with_content_with_abort(
&mut self,
content: Vec<ContentBlock>,
abort: Option<AbortSignal>,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<AssistantMessage> {
let user_message = Message::User(UserMessage {
content: UserContent::Blocks(content),
timestamp: Utc::now().timestamp_millis(),
});
self.run_loop(vec![user_message], Arc::new(on_event), abort)
.await
}
pub async fn run_with_message_with_abort(
&mut self,
message: Message,
abort: Option<AbortSignal>,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<AssistantMessage> {
self.run_loop(vec![message], Arc::new(on_event), abort)
.await
}
pub async fn run_with_messages_with_abort(
&mut self,
messages: Vec<Message>,
abort: Option<AbortSignal>,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<AssistantMessage> {
self.run_loop(messages, Arc::new(on_event), abort).await
}
pub async fn run_continue_with_abort(
&mut self,
abort: Option<AbortSignal>,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<AssistantMessage> {
self.run_loop(Vec::new(), Arc::new(on_event), abort).await
}
pub async fn run_continue_with_follow_up_with_abort(
&mut self,
abort: Option<AbortSignal>,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<AssistantMessage> {
self.run_continue_with_follow_up_on_ready_with_abort(abort, || true, on_event)
.await
}
pub(crate) async fn run_continue_with_follow_up_on_ready_with_abort(
&mut self,
abort: Option<AbortSignal>,
on_ready: impl FnOnce() -> bool + Send + 'static,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<AssistantMessage> {
self.run_loop_with_initial_follow_up(
Vec::new(),
true,
Some(Box::new(on_ready)),
Arc::new(on_event),
abort,
)
.await
}
fn apply_secrets_outbound(
&mut self,
mut context: Context<'static>,
) -> Result<Context<'static>> {
let mode = crate::secrets::SecretsMode::from_setting(
self.config.secrets.as_ref().and_then(|s| s.mode.as_deref()),
);
if mode == crate::secrets::SecretsMode::Off {
return Ok(context);
}
let extra = crate::secrets::compile_extra_patterns(
self.config
.secrets
.as_ref()
.and_then(|s| s.extra_patterns.as_deref())
.unwrap_or(&[]),
);
let mut total = 0usize;
let mut labels: Vec<String> = Vec::new();
if let Some(prompt) = context.system_prompt.as_deref() {
let out = Self::secrets_transform_text(
prompt,
&mut self.secrets_vault,
mode,
&extra,
&mut total,
&mut labels,
)?;
context.system_prompt = Some(std::borrow::Cow::Owned(out));
}
for message in context.messages.to_mut().iter_mut() {
match message {
Message::User(user) => {
Self::secrets_transform_user_content(
&mut user.content,
&mut self.secrets_vault,
mode,
&extra,
&mut total,
&mut labels,
)?;
}
Message::Assistant(assistant) => {
let assistant_mut = Arc::make_mut(assistant);
for block in &mut assistant_mut.content {
match block {
ContentBlock::Text(t) => {
t.text = Self::secrets_transform_text(
&t.text,
&mut self.secrets_vault,
mode,
&extra,
&mut total,
&mut labels,
)?;
}
ContentBlock::Thinking(t) => {
t.thinking = Self::secrets_transform_text(
&t.thinking,
&mut self.secrets_vault,
mode,
&extra,
&mut total,
&mut labels,
)?;
}
_ => {}
}
}
}
Message::ToolResult(result) => {
let result_mut = Arc::make_mut(result);
for block in &mut result_mut.content {
if let ContentBlock::Text(t) = block {
t.text = Self::secrets_transform_text(
&t.text,
&mut self.secrets_vault,
mode,
&extra,
&mut total,
&mut labels,
)?;
}
}
}
Message::Custom(_) => {}
}
}
if total > 0 {
tracing::info!(
event = "pi.secrets.outbound",
detections = total,
rules = ?labels,
"secrets obfuscated in outbound context (redacted)"
);
}
Ok(context)
}
fn secrets_transform_text(
text: &str,
vault: &mut crate::secrets::SecretVault,
mode: crate::secrets::SecretsMode,
extra: &[regex::Regex],
total: &mut usize,
labels: &mut Vec<String>,
) -> Result<String> {
if mode == crate::secrets::SecretsMode::Block {
crate::secrets::gate_outbound(text, mode, extra)?;
}
let (out, audit) = crate::secrets::obfuscate(text, vault, extra);
*total += audit.detections;
for rule in audit.rules {
if !labels.contains(&rule) {
labels.push(rule);
}
}
Ok(out)
}
fn secrets_transform_user_content(
content: &mut UserContent,
vault: &mut crate::secrets::SecretVault,
mode: crate::secrets::SecretsMode,
extra: &[regex::Regex],
total: &mut usize,
labels: &mut Vec<String>,
) -> Result<()> {
match content {
UserContent::Text(text) => {
*text = Self::secrets_transform_text(text, vault, mode, extra, total, labels)?;
}
UserContent::Blocks(blocks) => {
for block in blocks.iter_mut() {
if let ContentBlock::Text(t) = block {
t.text = Self::secrets_transform_text(
&t.text, vault, mode, extra, total, labels,
)?;
}
}
}
}
Ok(())
}
fn build_abort_message(&self, partial: Option<&AssistantMessage>) -> AssistantMessage {
let mut message = partial.cloned().unwrap_or_else(|| AssistantMessage {
content: Vec::new(),
api: self.provider.api().to_string(),
provider: self.provider.name().to_string(),
model: self.provider.model_id().to_string(),
usage: Usage::default(),
stop_reason: StopReason::Aborted,
stop_details: None,
error_message: Some("Aborted".to_string()),
timestamp: Utc::now().timestamp_millis(),
});
message.stop_reason = StopReason::Aborted;
message.error_message = Some("Aborted".to_string());
message.timestamp = Utc::now().timestamp_millis();
message
}
fn build_error_message(
&self,
partial: Option<&AssistantMessage>,
error_message: impl Into<String>,
) -> AssistantMessage {
let error_message = error_message.into();
let mut message = partial.cloned().unwrap_or_else(|| AssistantMessage {
content: Vec::new(),
api: self.provider.api().to_string(),
provider: self.provider.name().to_string(),
model: self.provider.model_id().to_string(),
usage: Usage::default(),
stop_reason: StopReason::Error,
stop_details: None,
error_message: Some(error_message.clone()),
timestamp: Utc::now().timestamp_millis(),
});
message.stop_reason = StopReason::Error;
message.error_message = Some(error_message);
message.timestamp = Utc::now().timestamp_millis();
message
}
fn build_provider_error_event(&self, session_id: &Arc<str>, message: &str) -> AgentEvent {
let provider = self.provider.name().to_string();
AgentEvent::ProviderError {
session_id: Arc::clone(session_id),
summary: crate::error::ProviderErrorSummary::from_error_text(Some(&provider), message),
provider,
model: self.provider.model_id().to_string(),
message: message.to_string(),
}
}
async fn run_loop(
&mut self,
prompts: Vec<Message>,
on_event: AgentEventHandler,
abort: Option<AbortSignal>,
) -> Result<AssistantMessage> {
self.run_loop_with_initial_follow_up(prompts, false, None, on_event, abort)
.await
}
async fn run_loop_with_initial_follow_up(
&mut self,
prompts: Vec<Message>,
initial_follow_up: bool,
initial_queue_ready: Option<Box<dyn FnOnce() -> bool + Send>>,
on_event: AgentEventHandler,
abort: Option<AbortSignal>,
) -> Result<AssistantMessage> {
if !prompts.is_empty() || initial_follow_up {
self.retry_keyword_activations.clear();
}
let saved_thinking = self.config.stream_options.thinking_level;
let saved_system_prompt = self.config.system_prompt.clone();
let result = self
.run_loop_inner(
prompts,
initial_follow_up,
initial_queue_ready,
on_event,
abort,
)
.await;
self.config.stream_options.thinking_level = saved_thinking;
self.config.system_prompt = saved_system_prompt;
if result.as_ref().is_ok_and(|message| {
!matches!(message.stop_reason, StopReason::Error | StopReason::Aborted)
}) {
self.retry_keyword_activations.clear();
}
result
}
fn apply_magic_keyword_effects(&mut self, hits: &[crate::magic_keywords::KeywordActivation]) {
let keyword_max_thinking_level = self.keyword_max_thinking_level;
for hit in hits {
if hit.action == "ultrathink" {
self.stream_options_mut().thinking_level = Some(keyword_max_thinking_level);
}
}
let directives =
crate::magic_keywords::directives_for(hits, self.config.keyword_settings.as_ref());
if !directives.is_empty() {
let block = directives.join("\n");
match &mut self.config.system_prompt {
Some(existing) => {
existing.push_str("\n\n");
existing.push_str(&block);
}
none_slot => {
*none_slot = Some(block);
}
}
}
}
fn apply_magic_keywords_for_message(
&mut self,
message: &Message,
turn_keyword_words: &mut std::collections::HashSet<String>,
) {
let Message::User(user) = message else {
return;
};
let scan_text = match &user.content {
UserContent::Text(text) => Cow::Borrowed(text.as_str()),
UserContent::Blocks(blocks) => Cow::Owned(
blocks
.iter()
.filter_map(|block| match block {
ContentBlock::Text(text) => Some(text.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n"),
),
};
self.apply_magic_keywords_for_text(&scan_text, turn_keyword_words);
}
fn apply_magic_keywords_for_delivery(
&mut self,
delivery: &QueuedAgentMessage,
turn_keyword_words: &mut std::collections::HashSet<String>,
) {
if let Some(source) = delivery.keyword_scan_source() {
self.apply_magic_keywords_for_text(source, turn_keyword_words);
}
}
fn apply_magic_keywords_for_text(
&mut self,
scan_text: &str,
turn_keyword_words: &mut std::collections::HashSet<String>,
) {
let hits: Vec<_> =
crate::magic_keywords::detect(scan_text, self.config.keyword_settings.as_ref())
.into_iter()
.filter(|hit| turn_keyword_words.insert(hit.word.clone()))
.collect();
if hits.is_empty() {
return;
}
self.apply_magic_keyword_effects(&hits);
self.retry_keyword_activations.extend(hits.iter().cloned());
self.keyword_ledger.extend(hits);
}
#[allow(clippy::too_many_lines)]
async fn run_loop_inner(
&mut self,
prompts: Vec<Message>,
initial_follow_up: bool,
mut initial_queue_ready: Option<Box<dyn FnOnce() -> bool + Send>>,
on_event: AgentEventHandler,
abort: Option<AbortSignal>,
) -> Result<AssistantMessage> {
let loop_cx = crate::agent_cx::AgentCx::for_current_or_request();
let session_id: Arc<str> = self
.config
.stream_options
.session_id
.as_deref()
.unwrap_or("")
.into();
let mut iterations = 0usize;
let mut pause_turn_continuations = 0usize;
let mut warned_at_handoff_threshold = false;
let mut turn_index: usize = 0;
let mut new_messages: Vec<Message> = Vec::with_capacity(prompts.len() + 8);
let mut last_assistant: Option<Arc<AssistantMessage>> = None;
let mut turn_keyword_words = std::collections::HashSet::new();
let turn_baseline_thinking = self.config.stream_options.thinking_level;
let turn_baseline_system_prompt = self.config.system_prompt.clone();
if prompts.is_empty() && !self.retry_keyword_activations.is_empty() {
let retry_hits = self.retry_keyword_activations.clone();
turn_keyword_words.extend(retry_hits.iter().map(|hit| hit.word.clone()));
self.apply_magic_keyword_effects(&retry_hits);
}
let mut follow_up_staged = false;
let mut required_initial_follow_up_pending = initial_follow_up;
if initial_follow_up {
self.fetch_initial_follow_up_messages().await;
follow_up_staged = self.message_queue.follow_up_batch_len() > 0;
if !follow_up_staged {
return Err(Error::session(
"accepted follow-up was unavailable at continuation boundary",
));
}
if let Some(on_ready) = initial_queue_ready.take()
&& !on_ready()
{
return Err(Error::session(
"required follow-up source was unavailable at continuation boundary",
));
}
}
let agent_start_event = AgentEvent::AgentStart {
session_id: session_id.clone(),
};
self.dispatch_extension_lifecycle_event(&agent_start_event)
.await;
on_event(agent_start_event);
let mut keyword_scan_override = self.magic_keyword_scan_override.take();
for prompt in prompts {
if matches!(prompt, Message::User(_))
&& let Some(source) = keyword_scan_override.take()
{
self.apply_magic_keywords_for_text(&source, &mut turn_keyword_words);
} else {
self.apply_magic_keywords_for_message(&prompt, &mut turn_keyword_words);
}
self.messages.push(prompt.clone());
on_event(AgentEvent::MessageStart {
message: prompt.clone(),
});
on_event(AgentEvent::MessageEnd {
message: prompt.clone(),
});
new_messages.push(prompt);
}
let mut pending_messages = if initial_follow_up {
Vec::new()
} else {
self.drain_steering_messages().await
};
let mut turn_recovery =
crate::turn_recovery::TurnRecoveryState::new(self.config.turn_recovery);
let run_started = std::time::Instant::now();
let max_time = self.config.max_time;
'agent: loop {
let mut has_more_tool_calls = true;
let mut steering_after_tools: Option<Vec<QueuedAgentMessage>> = None;
while has_more_tool_calls || !pending_messages.is_empty() {
if !required_initial_follow_up_pending
&& let Some(cap) = max_time
&& run_started.elapsed() >= cap
{
self.message_queue
.restore_steering_front_lossless(std::mem::take(&mut pending_messages));
let marker = format!(
"time cap reached after {}s (--max-time); stopping at the turn boundary",
cap.as_secs()
);
tracing::info!("{marker}");
let assistant = Arc::new(AssistantMessage {
content: vec![crate::model::ContentBlock::Text(TextContent::new(format!(
"[time cap reached] {marker}"
)))],
timestamp: Utc::now().timestamp_millis(),
..AssistantMessage::default()
});
let marker_message = Message::Assistant(Arc::clone(&assistant));
self.messages.push(marker_message.clone());
new_messages.push(marker_message.clone());
on_event(AgentEvent::MessageStart {
message: marker_message.clone(),
});
on_event(AgentEvent::MessageEnd {
message: marker_message,
});
last_assistant = Some(assistant);
break 'agent;
}
let delivering_follow_up = follow_up_staged;
if delivering_follow_up {
let current_follow_up = self.pop_follow_up_for_current_session().await;
follow_up_staged = false;
required_initial_follow_up_pending = false;
if current_follow_up.is_empty() {
break;
}
self.config.stream_options.thinking_level = turn_baseline_thinking;
self.config
.system_prompt
.clone_from(&turn_baseline_system_prompt);
turn_keyword_words.clear();
pending_messages = current_follow_up;
}
let current_turn_index = turn_index;
let turn_latency = Arc::new(StdMutex::new(TurnLatencyAccumulator::started()));
let turn_start_event = AgentEvent::TurnStart {
session_id: session_id.clone(),
turn_index: current_turn_index,
timestamp: Utc::now().timestamp_millis(),
};
self.dispatch_extension_lifecycle_event(&turn_start_event)
.await;
on_event(turn_start_event);
for delivery in std::mem::take(&mut pending_messages) {
self.apply_magic_keywords_for_delivery(&delivery, &mut turn_keyword_words);
let message = delivery.into_message();
if let Message::User(user) = &message
&& let crate::model::UserContent::Text(text) = &user.content
&& let Some(rest) = text.strip_prefix("[ADVISOR:")
{
let level = rest.split(']').next().unwrap_or("NOTE").to_string();
let rationale = rest
.split_once(']')
.map(|(_, tail)| tail.trim().to_string())
.unwrap_or_default();
on_event(AgentEvent::AdvisorNote { level, rationale });
}
self.messages.push(message.clone());
on_event(AgentEvent::MessageStart {
message: message.clone(),
});
on_event(AgentEvent::MessageEnd {
message: message.clone(),
});
new_messages.push(message);
}
if abort.as_ref().is_some_and(AbortSignal::is_aborted) {
let abort_message = self.build_abort_message(None);
let message = Message::assistant(abort_message.clone());
self.messages.push(message.clone());
new_messages.push(message.clone());
on_event(AgentEvent::MessageStart {
message: message.clone(),
});
on_event(AgentEvent::MessageEnd {
message: message.clone(),
});
let turn_end_event = AgentEvent::TurnEnd {
session_id: session_id.clone(),
turn_index: current_turn_index,
message,
tool_results: Vec::new(),
latency_breakdown: snapshot_turn_latency(&turn_latency),
};
self.dispatch_extension_lifecycle_event(&turn_end_event)
.await;
on_event(turn_end_event);
let agent_end_event = AgentEvent::AgentEnd {
session_id: session_id.clone(),
messages: std::mem::take(&mut new_messages),
error: Some(
abort_message
.error_message
.clone()
.unwrap_or_else(|| "Aborted".to_string()),
),
};
self.dispatch_extension_lifecycle_event(&agent_end_event)
.await;
on_event(agent_end_event);
return Ok(abort_message);
}
let provider_streaming_started_at = Instant::now();
let assistant_result = self
.stream_assistant_response(Arc::clone(&on_event), abort.clone(), &loop_cx)
.await;
record_provider_streaming_latency(
&turn_latency,
provider_streaming_started_at.elapsed(),
);
let assistant_message = match assistant_result {
Ok(msg) => self.maybe_repair_dialect_tool_calls(msg),
Err(err) => {
let err_string = err.to_string();
let steering_to_add = self.drain_steering_messages().await;
for delivery in steering_to_add {
self.apply_magic_keywords_for_delivery(
&delivery,
&mut turn_keyword_words,
);
let message = delivery.into_message();
self.messages.push(message.clone());
on_event(AgentEvent::MessageStart {
message: message.clone(),
});
on_event(AgentEvent::MessageEnd {
message: message.clone(),
});
new_messages.push(message);
}
let error_message = self.build_error_message(None, err_string.clone());
let assistant_event_message = Message::assistant(error_message.clone());
self.messages.push(assistant_event_message.clone());
new_messages.push(assistant_event_message.clone());
on_event(AgentEvent::MessageStart {
message: assistant_event_message.clone(),
});
on_event(AgentEvent::MessageEnd {
message: assistant_event_message.clone(),
});
on_event(self.build_provider_error_event(&session_id, &err_string));
let turn_end_event = AgentEvent::TurnEnd {
session_id: session_id.clone(),
turn_index: current_turn_index,
message: assistant_event_message,
tool_results: Vec::new(),
latency_breakdown: snapshot_turn_latency(&turn_latency),
};
self.dispatch_extension_lifecycle_event(&turn_end_event)
.await;
on_event(turn_end_event);
let agent_end_event = AgentEvent::AgentEnd {
session_id: session_id.clone(),
messages: std::mem::take(&mut new_messages),
error: Some(err_string),
};
self.dispatch_extension_lifecycle_event(&agent_end_event)
.await;
on_event(agent_end_event);
return Err(err);
}
};
let assistant_arc = Arc::new(assistant_message);
last_assistant = Some(Arc::clone(&assistant_arc));
let assistant_event_message = Message::Assistant(Arc::clone(&assistant_arc));
new_messages.push(assistant_event_message.clone());
if matches!(
assistant_arc.stop_reason,
StopReason::Error | StopReason::Aborted
) {
let steering_to_add = self.drain_steering_messages().await;
for delivery in steering_to_add {
self.apply_magic_keywords_for_delivery(&delivery, &mut turn_keyword_words);
let message = delivery.into_message();
self.messages.push(message.clone());
on_event(AgentEvent::MessageStart {
message: message.clone(),
});
on_event(AgentEvent::MessageEnd {
message: message.clone(),
});
new_messages.push(message);
}
if assistant_arc.stop_reason == StopReason::Error {
let message = assistant_arc
.error_message
.clone()
.unwrap_or_else(|| "Request failed".to_string());
on_event(self.build_provider_error_event(&session_id, &message));
}
let turn_end_event = AgentEvent::TurnEnd {
session_id: session_id.clone(),
turn_index: current_turn_index,
message: assistant_event_message.clone(),
tool_results: Vec::new(),
latency_breakdown: snapshot_turn_latency(&turn_latency),
};
self.dispatch_extension_lifecycle_event(&turn_end_event)
.await;
on_event(turn_end_event);
let agent_end_event = AgentEvent::AgentEnd {
session_id: session_id.clone(),
messages: std::mem::take(&mut new_messages),
error: assistant_arc.error_message.clone(),
};
self.dispatch_extension_lifecycle_event(&agent_end_event)
.await;
on_event(agent_end_event);
return Ok(Arc::unwrap_or_clone(assistant_arc));
}
let tool_calls = extract_tool_calls(&assistant_arc.content);
let pause_turn = assistant_arc.stop_reason == StopReason::PauseTurn;
let execute_local_tools = !pause_turn && !tool_calls.is_empty();
has_more_tool_calls = execute_local_tools;
if pause_turn {
pause_turn_continuations = pause_turn_continuations.saturating_add(1);
if pause_turn_continuations <= MAX_PAUSE_TURN_CONTINUATIONS {
has_more_tool_calls = true;
} else {
tracing::warn!(
pause_turn_continuations,
max = MAX_PAUSE_TURN_CONTINUATIONS,
"pause_turn continuation limit reached"
);
}
}
let mut tool_results: Vec<Arc<ToolResultMessage>> = Vec::new();
if execute_local_tools {
iterations += 1;
if !warned_at_handoff_threshold
&& should_warn_at_iteration_threshold(
iterations,
self.config.max_tool_iterations,
)
{
warned_at_handoff_threshold = true;
let warning = Message::User(UserMessage {
content: UserContent::Text(iteration_handoff_steering_text(
iterations,
self.config.max_tool_iterations,
)),
timestamp: Utc::now().timestamp_millis(),
});
self.message_queue
.push_steering(QueuedAgentMessage::generated(warning));
tracing::warn!(
iterations,
max = self.config.max_tool_iterations,
"tool-iteration budget at >=80%; injected handoff steering message"
);
}
if iterations > self.config.max_tool_iterations {
let error_message = format!(
"Maximum tool iterations ({}) exceeded",
self.config.max_tool_iterations
);
let mut stop_message = (*assistant_arc).clone();
stop_message.stop_reason = StopReason::Error;
stop_message.error_message = Some(error_message.clone());
stop_message
.content
.retain(|b| !matches!(b, crate::model::ContentBlock::ToolCall(_)));
let stop_arc = Arc::new(stop_message.clone());
let stop_event_message = Message::Assistant(Arc::clone(&stop_arc));
if let Some(last @ Message::Assistant(_)) = self
.messages
.iter_mut()
.rev()
.find(|m| matches!(m, Message::Assistant(_)))
{
*last = stop_event_message.clone();
}
if let Some(last @ Message::Assistant(_)) = new_messages.last_mut() {
*last = stop_event_message.clone();
}
let steering_to_add = self.drain_steering_messages().await;
for delivery in steering_to_add {
self.apply_magic_keywords_for_delivery(
&delivery,
&mut turn_keyword_words,
);
let message = delivery.into_message();
self.messages.push(message.clone());
on_event(AgentEvent::MessageStart {
message: message.clone(),
});
on_event(AgentEvent::MessageEnd {
message: message.clone(),
});
new_messages.push(message);
}
let turn_end_event = AgentEvent::TurnEnd {
session_id: session_id.clone(),
turn_index: current_turn_index,
message: stop_event_message,
tool_results: Vec::new(),
latency_breakdown: snapshot_turn_latency(&turn_latency),
};
self.dispatch_extension_lifecycle_event(&turn_end_event)
.await;
on_event(turn_end_event);
let agent_end_event = AgentEvent::AgentEnd {
session_id: session_id.clone(),
messages: std::mem::take(&mut new_messages),
error: Some(error_message),
};
self.dispatch_extension_lifecycle_event(&agent_end_event)
.await;
on_event(agent_end_event);
return Ok(stop_message);
}
let outcome = match self
.execute_tool_calls(
&tool_calls,
Arc::clone(&on_event),
&mut new_messages,
abort.clone(),
Arc::clone(&turn_latency),
)
.await
{
Ok(outcome) => outcome,
Err(err) => {
let steering_to_add = self.drain_steering_messages().await;
for delivery in steering_to_add {
self.apply_magic_keywords_for_delivery(
&delivery,
&mut turn_keyword_words,
);
let message = delivery.into_message();
self.messages.push(message.clone());
on_event(AgentEvent::MessageStart {
message: message.clone(),
});
on_event(AgentEvent::MessageEnd {
message: message.clone(),
});
new_messages.push(message);
}
let turn_end_event = AgentEvent::TurnEnd {
session_id: session_id.clone(),
turn_index: current_turn_index,
message: assistant_event_message.clone(),
tool_results: Vec::new(),
latency_breakdown: snapshot_turn_latency(&turn_latency),
};
self.dispatch_extension_lifecycle_event(&turn_end_event)
.await;
on_event(turn_end_event);
let agent_end_event = AgentEvent::AgentEnd {
session_id: session_id.clone(),
messages: std::mem::take(&mut new_messages),
error: Some(err.to_string()),
};
self.dispatch_extension_lifecycle_event(&agent_end_event)
.await;
on_event(agent_end_event);
return Err(err);
}
};
tool_results = outcome.tool_results;
steering_after_tools = outcome.steering_messages;
}
let tool_messages = tool_results
.iter()
.map(|r| Message::ToolResult(Arc::clone(r)))
.collect::<Vec<_>>();
let turn_end_event = AgentEvent::TurnEnd {
session_id: session_id.clone(),
turn_index: current_turn_index,
message: assistant_event_message.clone(),
tool_results: tool_messages,
latency_breakdown: snapshot_turn_latency(&turn_latency),
};
self.dispatch_extension_lifecycle_event(&turn_end_event)
.await;
on_event(turn_end_event);
turn_index = turn_index.saturating_add(1);
if let Some(steering) = steering_after_tools.take() {
pending_messages = steering;
} else {
pending_messages = self.drain_steering_messages().await;
}
if pending_messages.is_empty() && !has_more_tool_calls {
let text = assistant_text_content(&assistant_arc.content);
if let Some(action) = turn_recovery.evaluate(assistant_arc.stop_reason, &text) {
pending_messages =
vec![QueuedAgentMessage::generated(Message::User(UserMessage {
content: UserContent::Text(action.nudge_text),
timestamp: Utc::now().timestamp_millis(),
}))];
}
}
}
if let Some(cap) = max_time
&& run_started.elapsed() >= cap
{
let marker = format!(
"time cap reached after {}s (--max-time); stopping at the turn boundary",
cap.as_secs()
);
tracing::info!("{marker}");
let assistant = Arc::new(AssistantMessage {
content: vec![crate::model::ContentBlock::Text(TextContent::new(format!(
"[time cap reached] {marker}"
)))],
timestamp: Utc::now().timestamp_millis(),
..AssistantMessage::default()
});
let marker_message = Message::Assistant(Arc::clone(&assistant));
self.messages.push(marker_message.clone());
new_messages.push(marker_message.clone());
on_event(AgentEvent::MessageStart {
message: marker_message.clone(),
});
on_event(AgentEvent::MessageEnd {
message: marker_message,
});
last_assistant = Some(assistant);
break;
}
follow_up_staged = self.stage_follow_up_messages().await;
if !follow_up_staged {
break;
}
}
let Some(final_arc) = last_assistant else {
return Err(Error::api("Agent completed without assistant message"));
};
let agent_end_event = AgentEvent::AgentEnd {
session_id: session_id.clone(),
messages: new_messages,
error: None,
};
self.dispatch_extension_lifecycle_event(&agent_end_event)
.await;
on_event(agent_end_event);
Ok(Arc::unwrap_or_clone(final_arc))
}
async fn fetch_messages(&self, fetcher: Option<&MessageFetcher>) -> Vec<QueuedAgentMessage> {
if let Some(fetcher) = fetcher {
(fetcher)().await
} else {
Vec::new()
}
}
async fn dispatch_extension_lifecycle_event(&self, event: &AgentEvent) {
let Some(extensions) = &self.extensions else {
return;
};
let name = match event {
AgentEvent::AgentStart { .. } => ExtensionEventName::AgentStart,
AgentEvent::AgentEnd { .. } => ExtensionEventName::AgentEnd,
AgentEvent::TurnStart { .. } => ExtensionEventName::TurnStart,
AgentEvent::TurnEnd { .. } => ExtensionEventName::TurnEnd,
_ => return,
};
let payload = match serde_json::to_value(event) {
Ok(payload) => payload,
Err(err) => {
tracing::warn!("failed to serialize agent lifecycle event (fail-open): {err}");
return;
}
};
if let Err(err) = extensions.dispatch_event(name, Some(payload)).await {
tracing::warn!("agent lifecycle extension hook failed (fail-open): {err}");
}
}
fn build_before_provider_request_hook(
extensions: ExtensionManager,
) -> crate::provider::BeforeProviderRequestHook {
crate::provider::BeforeProviderRequestHook::new(move |event| {
let extensions = extensions.clone();
Box::pin(async move {
let payload = match serde_json::to_value(&event) {
Ok(payload) => payload,
Err(err) => {
tracing::warn!(
"failed to serialize before_provider_request event (fail-open): {err}"
);
return None;
}
};
let response = match extensions
.dispatch_event_with_response(
ExtensionEventName::BeforeProviderRequest,
Some(payload),
EXTENSION_EVENT_TIMEOUT_MS,
)
.await
{
Ok(response) => response?,
Err(err) => {
tracing::warn!(
"before_provider_request extension hook failed (fail-open): {err}"
);
return None;
}
};
normalize_before_provider_request_response(response)
})
})
}
async fn dispatch_context_event(&self, messages: &[Message]) -> Option<Vec<Message>> {
let Some(extensions) = &self.extensions else {
return None;
};
let payload = json!({ "messages": messages });
let response = extensions
.dispatch_event_with_response(
ExtensionEventName::Context,
Some(payload),
EXTENSION_EVENT_TIMEOUT_MS,
)
.await
.ok()?;
let value = response?;
if value.is_null() {
return None;
}
let messages_value = if let Some(obj) = value.as_object() {
obj.get("messages").cloned()?
} else if value.is_array() {
value
} else {
return None;
};
if messages_value.is_null() {
return Some(Vec::new());
}
match serde_json::from_value(messages_value) {
Ok(messages) => Some(messages),
Err(err) => {
tracing::warn!("context extension hook returned invalid messages: {err}");
None
}
}
}
async fn drain_steering_messages(&mut self) -> Vec<QueuedAgentMessage> {
for fetcher in &self.steering_fetchers {
let fetched = self.fetch_messages(Some(fetcher)).await;
for message in fetched {
self.message_queue.push_steering_lossless(message);
}
}
self.message_queue.pop_steering()
}
async fn stage_follow_up_messages(&mut self) -> bool {
let mut owning_surface_ready = self.message_queue.follow_up_batch_len() > 0;
if !owning_surface_ready {
let owning_surface = self
.fetch_messages(self.initial_follow_up_fetcher.as_ref())
.await;
owning_surface_ready = !owning_surface.is_empty();
for message in owning_surface {
self.message_queue.push_follow_up_lossless(message);
}
}
if owning_surface_ready {
self.fetch_job_follow_up_messages().await;
return self.message_queue.follow_up_batch_len() > 0;
}
self.fetch_additive_follow_up_messages().await;
self.message_queue.follow_up_batch_len() > 0
}
async fn fetch_additive_follow_up_messages(&mut self) {
for fetcher in &self.follow_up_fetchers {
let fetched = self.fetch_messages(Some(fetcher)).await;
for message in fetched {
self.message_queue.push_follow_up(message);
}
}
self.fetch_job_follow_up_messages().await;
}
async fn fetch_job_follow_up_messages(&mut self) {
let Ok(owner_session_id) = self.job_session_scope.session_id().await else {
self.restore_job_follow_ups_except(None);
return;
};
self.restore_job_follow_ups_except(Some(&owner_session_id));
if self.message_queue.has_job_follow_up() {
return;
}
for notice in crate::jobs::take_completion_notices(&owner_session_id) {
self.message_queue.push_job_follow_up_lossless(
owner_session_id.clone(),
QueuedAgentMessage::generated(notice),
);
}
}
async fn pop_follow_up_for_current_session(&mut self) -> Vec<QueuedAgentMessage> {
let owner_session_id = self.job_session_scope.session_id().await.ok();
self.restore_job_follow_ups_except(owner_session_id.as_deref());
self.message_queue.pop_follow_up()
}
fn restore_job_follow_ups_except(&mut self, owner_session_id: Option<&str>) {
let stale = self
.message_queue
.take_job_follow_ups_except(owner_session_id);
if stale.is_empty() {
return;
}
crate::jobs::restore_completion_notices(
stale
.into_iter()
.map(|(owner, delivery)| (owner, delivery.into_message()))
.collect(),
);
}
async fn fetch_initial_follow_up_messages(&mut self) {
let fetched = self
.fetch_messages(self.initial_follow_up_fetcher.as_ref())
.await;
for message in fetched {
self.message_queue.push_follow_up_lossless(message);
}
}
#[allow(clippy::too_many_lines)]
async fn stream_assistant_response(
&mut self,
on_event: AgentEventHandler,
abort: Option<AbortSignal>,
checkpoint_cx: &crate::agent_cx::AgentCx,
) -> Result<AssistantMessage> {
let provider = Arc::clone(&self.provider);
let mut stream_options = self.config.stream_options.clone();
if let Some(extensions) = &self.extensions {
stream_options.before_provider_request =
Some(Self::build_before_provider_request_hook(extensions.clone()));
}
let (system_prompt, tools, base_messages) = {
let context = self.build_context();
(
context.system_prompt.as_deref().map(str::to_string),
context.tools.to_vec(),
context.messages.to_vec(),
)
};
let messages = self
.dispatch_context_event(&base_messages)
.await
.unwrap_or(base_messages);
let context = Context::owned(system_prompt, messages, tools);
let context = self.apply_secrets_outbound(context)?;
let mut stream = provider.stream(&context, &stream_options).await?;
let mut added_partial = false;
let mut sent_start = false;
let mut tool_call_raw_args: std::collections::HashMap<usize, String> =
std::collections::HashMap::new();
let mut tool_call_started = false;
'stream: loop {
if checkpoint_cx.checkpoint().is_err() {
let last_partial = if added_partial {
match self
.messages
.iter()
.rev()
.find(|m| matches!(m, Message::Assistant(_)))
{
Some(Message::Assistant(a)) => Some(a.as_ref()),
_ => None,
}
} else {
None
};
let abort_arc = Arc::new(self.build_abort_message(last_partial));
if !sent_start {
on_event(AgentEvent::MessageStart {
message: Message::Assistant(Arc::clone(&abort_arc)),
});
self.messages
.push(Message::Assistant(Arc::clone(&abort_arc)));
added_partial = true;
}
on_event(AgentEvent::MessageUpdate {
message: Message::Assistant(Arc::clone(&abort_arc)),
assistant_message_event: AssistantMessageEvent::Error {
reason: StopReason::Aborted,
error: Arc::clone(&abort_arc),
},
});
return Ok(self.finalize_assistant_message(
Arc::try_unwrap(abort_arc).unwrap_or_else(|a| (*a).clone()),
&on_event,
added_partial,
));
}
let event_result = if let Some(signal) = abort.as_ref() {
let abort_fut = signal.wait().fuse();
let event_fut = stream.next().fuse();
futures::pin_mut!(abort_fut, event_fut);
match futures::future::select(abort_fut, event_fut).await {
futures::future::Either::Left(((), _event_fut)) => {
let last_partial = if added_partial {
match self
.messages
.iter()
.rev()
.find(|m| matches!(m, Message::Assistant(_)))
{
Some(Message::Assistant(a)) => Some(a.as_ref()),
_ => None,
}
} else {
None
};
let abort_arc = Arc::new(self.build_abort_message(last_partial));
if !sent_start {
on_event(AgentEvent::MessageStart {
message: Message::Assistant(Arc::clone(&abort_arc)),
});
self.messages
.push(Message::Assistant(Arc::clone(&abort_arc)));
added_partial = true;
}
on_event(AgentEvent::MessageUpdate {
message: Message::Assistant(Arc::clone(&abort_arc)),
assistant_message_event: AssistantMessageEvent::Error {
reason: StopReason::Aborted,
error: Arc::clone(&abort_arc),
},
});
return Ok(self.finalize_assistant_message(
Arc::try_unwrap(abort_arc).unwrap_or_else(|a| (*a).clone()),
&on_event,
added_partial,
));
}
futures::future::Either::Right((event, _abort_fut)) => event,
}
} else {
let event_fut = stream.next().fuse();
futures::pin_mut!(event_fut);
loop {
let now = checkpoint_cx
.cx()
.timer_driver()
.map_or_else(asupersync::time::wall_now, |timer| timer.now());
let tick_fut =
asupersync::time::sleep(now, std::time::Duration::from_millis(25)).fuse();
futures::pin_mut!(tick_fut);
match futures::future::select(tick_fut, &mut event_fut).await {
futures::future::Either::Left(((), _event_fut)) => {
if checkpoint_cx.checkpoint().is_err() {
continue 'stream;
}
}
futures::future::Either::Right((result, _tick_fut)) => break result,
}
}
};
let Some(event_result) = event_result else {
break;
};
let event = match event_result {
Ok(e) => e,
Err(err) => {
let partial = if added_partial {
match self
.messages
.iter()
.rev()
.find(|m| matches!(m, Message::Assistant(_)))
{
Some(Message::Assistant(a)) => Some(a.as_ref()),
_ => None,
}
} else {
None
};
let msg = self.build_error_message(partial, err.to_string());
return Ok(self.finalize_assistant_message(msg, &on_event, added_partial));
}
};
match event {
StreamEvent::Start { partial } => {
if added_partial {
if let Some(Message::Assistant(msg_arc)) = self
.messages
.iter_mut()
.rev()
.find(|m| matches!(m, Message::Assistant(_)))
{
let msg = Arc::make_mut(msg_arc);
if msg.content.is_empty() {
*msg = partial;
} else {
msg.api = partial.api;
msg.provider = partial.provider;
msg.model = partial.model;
msg.usage = partial.usage;
msg.stop_reason = partial.stop_reason;
msg.error_message = partial.error_message;
msg.timestamp = partial.timestamp;
}
let shared = Arc::clone(msg_arc);
if !sent_start {
on_event(AgentEvent::MessageStart {
message: Message::Assistant(Arc::clone(&shared)),
});
sent_start = true;
}
on_event(AgentEvent::MessageUpdate {
message: Message::Assistant(Arc::clone(&shared)),
assistant_message_event: AssistantMessageEvent::Start {
partial: shared,
},
});
} else {
let shared = Arc::new(partial);
self.update_partial_message(Arc::clone(&shared), &mut added_partial);
on_event(AgentEvent::MessageStart {
message: Message::Assistant(Arc::clone(&shared)),
});
sent_start = true;
on_event(AgentEvent::MessageUpdate {
message: Message::Assistant(Arc::clone(&shared)),
assistant_message_event: AssistantMessageEvent::Start {
partial: shared,
},
});
}
} else {
let shared = Arc::new(partial);
self.update_partial_message(Arc::clone(&shared), &mut added_partial);
on_event(AgentEvent::MessageStart {
message: Message::Assistant(Arc::clone(&shared)),
});
sent_start = true;
on_event(AgentEvent::MessageUpdate {
message: Message::Assistant(Arc::clone(&shared)),
assistant_message_event: AssistantMessageEvent::Start {
partial: shared,
},
});
}
}
StreamEvent::TextStart { content_index, .. } => {
self.seed_partial_message_if_missing(&mut added_partial);
if let Some(Message::Assistant(msg_arc)) = self
.messages
.iter_mut()
.rev()
.find(|m| matches!(m, Message::Assistant(_)))
{
let msg = Arc::make_mut(msg_arc);
if content_index == msg.content.len() {
msg.content.push(ContentBlock::Text(TextContent::new("")));
}
let shared = Arc::clone(msg_arc);
if !sent_start {
on_event(AgentEvent::MessageStart {
message: Message::Assistant(Arc::clone(&shared)),
});
sent_start = true;
}
on_event(AgentEvent::MessageUpdate {
message: Message::Assistant(Arc::clone(&shared)),
assistant_message_event: AssistantMessageEvent::TextStart {
content_index,
partial: shared,
},
});
}
}
StreamEvent::TextDelta {
content_index,
delta,
..
} => {
self.seed_partial_message_if_missing(&mut added_partial);
if let Some(Message::Assistant(msg_arc)) = self
.messages
.iter_mut()
.rev()
.find(|m| matches!(m, Message::Assistant(_)))
{
{
let msg = Arc::make_mut(msg_arc);
if msg.content.get(content_index).is_none()
&& content_index == msg.content.len()
{
msg.content.push(ContentBlock::Text(TextContent::new("")));
}
if let Some(ContentBlock::Text(text)) =
msg.content.get_mut(content_index)
{
text.text.push_str(&delta);
}
}
let shared = Arc::clone(msg_arc);
if !sent_start {
on_event(AgentEvent::MessageStart {
message: Message::Assistant(Arc::clone(&shared)),
});
sent_start = true;
}
on_event(AgentEvent::MessageUpdate {
message: Message::Assistant(Arc::clone(&shared)),
assistant_message_event: AssistantMessageEvent::TextDelta {
content_index,
delta,
partial: shared,
},
});
}
}
StreamEvent::TextEnd {
content_index,
content,
..
} => {
self.seed_partial_message_if_missing(&mut added_partial);
if let Some(Message::Assistant(msg_arc)) = self
.messages
.iter_mut()
.rev()
.find(|m| matches!(m, Message::Assistant(_)))
{
{
let msg = Arc::make_mut(msg_arc);
if msg.content.get(content_index).is_none()
&& content_index == msg.content.len()
{
msg.content.push(ContentBlock::Text(TextContent::new("")));
}
if let Some(ContentBlock::Text(text)) =
msg.content.get_mut(content_index)
{
text.text.clone_from(&content);
}
}
let shared = Arc::clone(msg_arc);
if !sent_start {
on_event(AgentEvent::MessageStart {
message: Message::Assistant(Arc::clone(&shared)),
});
sent_start = true;
}
on_event(AgentEvent::MessageUpdate {
message: Message::Assistant(Arc::clone(&shared)),
assistant_message_event: AssistantMessageEvent::TextEnd {
content_index,
content,
partial: shared,
},
});
}
}
StreamEvent::ThinkingStart { content_index, .. } => {
self.seed_partial_message_if_missing(&mut added_partial);
if let Some(Message::Assistant(msg_arc)) = self
.messages
.iter_mut()
.rev()
.find(|m| matches!(m, Message::Assistant(_)))
{
let msg = Arc::make_mut(msg_arc);
if content_index == msg.content.len() {
msg.content.push(ContentBlock::Thinking(ThinkingContent {
thinking: String::new(),
thinking_signature: None,
}));
}
let shared = Arc::clone(msg_arc);
if !sent_start {
on_event(AgentEvent::MessageStart {
message: Message::Assistant(Arc::clone(&shared)),
});
sent_start = true;
}
on_event(AgentEvent::MessageUpdate {
message: Message::Assistant(Arc::clone(&shared)),
assistant_message_event: AssistantMessageEvent::ThinkingStart {
content_index,
partial: shared,
},
});
}
}
StreamEvent::ThinkingDelta {
content_index,
delta,
..
} => {
self.seed_partial_message_if_missing(&mut added_partial);
if let Some(Message::Assistant(msg_arc)) = self
.messages
.iter_mut()
.rev()
.find(|m| matches!(m, Message::Assistant(_)))
{
{
let msg = Arc::make_mut(msg_arc);
if msg.content.get(content_index).is_none()
&& content_index == msg.content.len()
{
msg.content.push(ContentBlock::Thinking(ThinkingContent {
thinking: String::new(),
thinking_signature: None,
}));
}
if let Some(ContentBlock::Thinking(thinking)) =
msg.content.get_mut(content_index)
{
thinking.thinking.push_str(&delta);
}
}
let shared = Arc::clone(msg_arc);
if !sent_start {
on_event(AgentEvent::MessageStart {
message: Message::Assistant(Arc::clone(&shared)),
});
sent_start = true;
}
on_event(AgentEvent::MessageUpdate {
message: Message::Assistant(Arc::clone(&shared)),
assistant_message_event: AssistantMessageEvent::ThinkingDelta {
content_index,
delta,
partial: shared,
},
});
}
}
StreamEvent::ThinkingEnd {
content_index,
content,
..
} => {
self.seed_partial_message_if_missing(&mut added_partial);
if let Some(Message::Assistant(msg_arc)) = self
.messages
.iter_mut()
.rev()
.find(|m| matches!(m, Message::Assistant(_)))
{
{
let msg = Arc::make_mut(msg_arc);
if msg.content.get(content_index).is_none()
&& content_index == msg.content.len()
{
msg.content.push(ContentBlock::Thinking(ThinkingContent {
thinking: String::new(),
thinking_signature: None,
}));
}
if let Some(ContentBlock::Thinking(thinking)) =
msg.content.get_mut(content_index)
{
thinking.thinking.clone_from(&content);
}
}
let shared = Arc::clone(msg_arc);
if !sent_start {
on_event(AgentEvent::MessageStart {
message: Message::Assistant(Arc::clone(&shared)),
});
sent_start = true;
}
on_event(AgentEvent::MessageUpdate {
message: Message::Assistant(Arc::clone(&shared)),
assistant_message_event: AssistantMessageEvent::ThinkingEnd {
content_index,
content,
partial: shared,
},
});
}
}
StreamEvent::ToolCallStart {
content_index,
id,
name,
} => {
tool_call_started = true;
self.seed_partial_message_if_missing(&mut added_partial);
if let Some(Message::Assistant(msg_arc)) = self
.messages
.iter_mut()
.rev()
.find(|m| matches!(m, Message::Assistant(_)))
{
let msg = Arc::make_mut(msg_arc);
if content_index == msg.content.len() {
msg.content.push(ContentBlock::ToolCall(ToolCall {
id,
name,
arguments: serde_json::Value::Null,
thought_signature: None,
}));
} else if let Some(ContentBlock::ToolCall(tc)) =
msg.content.get_mut(content_index)
{
if tc.id.is_empty() {
tc.id = id;
}
if tc.name.is_empty() {
tc.name = name;
}
}
let shared = Arc::clone(msg_arc);
if !sent_start {
on_event(AgentEvent::MessageStart {
message: Message::Assistant(Arc::clone(&shared)),
});
sent_start = true;
}
on_event(AgentEvent::MessageUpdate {
message: Message::Assistant(Arc::clone(&shared)),
assistant_message_event: AssistantMessageEvent::ToolCallStart {
content_index,
partial: shared,
},
});
}
}
StreamEvent::ToolCallDelta {
content_index,
delta,
..
} => {
tool_call_started = true;
self.seed_partial_message_if_missing(&mut added_partial);
if let Some(Message::Assistant(msg_arc)) = self
.messages
.iter_mut()
.rev()
.find(|m| matches!(m, Message::Assistant(_)))
{
{
let msg = Arc::make_mut(msg_arc);
if msg.content.get(content_index).is_none()
&& content_index == msg.content.len()
{
msg.content.push(ContentBlock::ToolCall(ToolCall {
id: String::new(),
name: String::new(),
arguments: serde_json::Value::Null,
thought_signature: None,
}));
}
let raw = tool_call_raw_args.entry(content_index).or_default();
raw.push_str(&delta);
if let Some(partial_args) =
crate::providers::openai::complete_partial_json(raw)
&& let Some(ContentBlock::ToolCall(tc)) =
msg.content.get_mut(content_index)
{
tc.arguments = partial_args;
}
}
let shared = Arc::clone(msg_arc);
if !sent_start {
on_event(AgentEvent::MessageStart {
message: Message::Assistant(Arc::clone(&shared)),
});
sent_start = true;
}
on_event(AgentEvent::MessageUpdate {
message: Message::Assistant(Arc::clone(&shared)),
assistant_message_event: AssistantMessageEvent::ToolCallDelta {
content_index,
delta,
partial: shared,
},
});
}
}
StreamEvent::ToolCallEnd {
content_index,
tool_call,
..
} => {
tool_call_started = true;
self.seed_partial_message_if_missing(&mut added_partial);
if let Some(Message::Assistant(msg_arc)) = self
.messages
.iter_mut()
.rev()
.find(|m| matches!(m, Message::Assistant(_)))
{
{
let msg = Arc::make_mut(msg_arc);
if msg.content.get(content_index).is_none()
&& content_index == msg.content.len()
{
msg.content.push(ContentBlock::ToolCall(ToolCall {
id: String::new(),
name: String::new(),
arguments: serde_json::Value::Null,
thought_signature: None,
}));
}
if let Some(ContentBlock::ToolCall(tc)) =
msg.content.get_mut(content_index)
{
*tc = tool_call.clone();
}
}
let shared = Arc::clone(msg_arc);
if !sent_start {
on_event(AgentEvent::MessageStart {
message: Message::Assistant(Arc::clone(&shared)),
});
sent_start = true;
}
on_event(AgentEvent::MessageUpdate {
message: Message::Assistant(Arc::clone(&shared)),
assistant_message_event: AssistantMessageEvent::ToolCallEnd {
content_index,
tool_call,
partial: shared,
},
});
}
}
StreamEvent::Done { mut message, .. } => {
if is_truncated_before_tool_call(&message, tool_call_started) {
message.stop_reason = StopReason::Error;
message.error_message = Some(TRUNCATED_TOOL_CALL_ERROR.to_string());
}
return Ok(self.finalize_assistant_message(message, &on_event, added_partial));
}
StreamEvent::Error { error, .. } => {
return Ok(self.finalize_assistant_message(error, &on_event, added_partial));
}
}
}
if added_partial
&& let Some(Message::Assistant(last_msg)) = self
.messages
.iter()
.rev()
.find(|m| matches!(m, Message::Assistant(_)))
{
let mut final_msg = (**last_msg).clone();
let truncated_tool_call = is_truncated_before_tool_call(&final_msg, tool_call_started);
final_msg.stop_reason = StopReason::Error;
final_msg.error_message = Some(if truncated_tool_call {
TRUNCATED_TOOL_CALL_ERROR.to_string()
} else {
"Stream ended without Done event".to_string()
});
return Ok(self.finalize_assistant_message(final_msg, &on_event, true));
}
Err(Error::api("Stream ended without Done event"))
}
fn seed_partial_message_if_missing(&mut self, added_partial: &mut bool) {
if *added_partial {
return;
}
let message = AssistantMessage {
content: Vec::new(),
api: self.provider.api().to_string(),
provider: self.provider.name().to_string(),
model: self.provider.model_id().to_string(),
usage: Usage::default(),
stop_reason: StopReason::Stop,
stop_details: None,
error_message: None,
timestamp: Utc::now().timestamp_millis(),
};
self.messages.push(Message::Assistant(Arc::new(message)));
*added_partial = true;
}
fn update_partial_message(
&mut self,
partial: Arc<AssistantMessage>,
added_partial: &mut bool,
) -> bool {
if *added_partial {
if let Some(target) = self
.messages
.iter_mut()
.rev()
.find(|m| matches!(m, Message::Assistant(_)))
{
*target = Message::Assistant(partial);
} else {
tracing::warn!("update_partial_message: expected an Assistant message in history");
self.messages.push(Message::Assistant(partial));
}
false
} else {
self.messages.push(Message::Assistant(partial));
*added_partial = true;
true
}
}
fn finalize_assistant_message(
&mut self,
mut message: AssistantMessage,
on_event: &Arc<dyn Fn(AgentEvent) + Send + Sync>,
added_partial: bool,
) -> AssistantMessage {
if let Some(rates) = self.provider.model_cost() {
rates.price_usage(&mut message.usage);
}
let arc = Arc::new(message);
if added_partial {
if let Some(target) = self
.messages
.iter_mut()
.rev()
.find(|m| matches!(m, Message::Assistant(_)))
{
*target = Message::Assistant(Arc::clone(&arc));
} else {
tracing::warn!(
"finalize_assistant_message: expected an Assistant message in history"
);
self.messages.push(Message::Assistant(Arc::clone(&arc)));
on_event(AgentEvent::MessageStart {
message: Message::Assistant(Arc::clone(&arc)),
});
}
} else {
self.messages.push(Message::Assistant(Arc::clone(&arc)));
on_event(AgentEvent::MessageStart {
message: Message::Assistant(Arc::clone(&arc)),
});
}
on_event(AgentEvent::MessageEnd {
message: Message::Assistant(Arc::clone(&arc)),
});
Arc::try_unwrap(arc).unwrap_or_else(|a| (*a).clone())
}
async fn execute_tool_batch(
&self,
batch: Vec<(usize, ToolCall)>,
on_event: AgentEventHandler,
abort: Option<AbortSignal>,
latency: SharedTurnLatencyAccumulator,
) -> Vec<(usize, (ToolOutput, bool))> {
let parallelism = compatible_tool_parallelism_limit();
let futures = batch.into_iter().map(|(idx, tc)| {
let on_event = Arc::clone(&on_event);
let latency = Arc::clone(&latency);
async move { (idx, self.execute_tool_owned(tc, on_event, latency).await) }
});
if let Some(signal) = abort.as_ref() {
use futures::future::{Either, select};
let all_fut = stream::iter(futures)
.buffer_unordered(parallelism)
.collect::<Vec<_>>()
.fuse();
let abort_fut = signal.wait().fuse();
futures::pin_mut!(all_fut, abort_fut);
match select(all_fut, abort_fut).await {
Either::Left((batch_results, _)) => batch_results,
Either::Right(_) => Vec::new(), }
} else {
stream::iter(futures)
.buffer_unordered(parallelism)
.collect::<Vec<_>>()
.await
}
}
#[allow(clippy::too_many_lines)]
async fn execute_tool_calls(
&mut self,
tool_calls: &[ToolCall],
on_event: AgentEventHandler,
new_messages: &mut Vec<Message>,
abort: Option<AbortSignal>,
latency: SharedTurnLatencyAccumulator,
) -> Result<ToolExecutionOutcome> {
let mut results = Vec::new();
let mut steering_messages: Option<Vec<QueuedAgentMessage>> = None;
for tool_call in tool_calls {
crate::crash::record_operation(format!(
"tool {} ({} arg bytes)",
tool_call.name,
tool_call.arguments.to_string().len()
));
on_event(AgentEvent::ToolExecutionStart {
tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
args: tool_call.arguments.clone(),
});
}
let effect_plan = tool_calls
.iter()
.map(|tool_call| {
self.tools
.snapshot()
.get(&tool_call.name)
.map_or_else(ToolEffects::write, Tool::effects)
})
.collect::<Vec<_>>();
let effect_batches = plan_tool_effect_batches(&effect_plan);
let mut recorded_results: Vec<Option<Arc<ToolResultMessage>>> =
vec![None; tool_calls.len()];
for effect_batch in effect_batches {
if abort.as_ref().is_some_and(AbortSignal::is_aborted) {
break;
}
let steering = self.drain_steering_messages().await;
if !steering.is_empty() {
steering_messages = Some(steering);
break;
}
let batch_len = effect_batch.end.saturating_sub(effect_batch.start);
let batch = tool_calls
.iter()
.cloned()
.enumerate()
.skip(effect_batch.start)
.take(batch_len)
.collect();
let mut batch_results = self
.execute_tool_batch(
batch,
Arc::clone(&on_event),
abort.clone(),
Arc::clone(&latency),
)
.await;
batch_results.sort_by_key(|(idx, _)| *idx);
for (idx, (output, is_error)) in batch_results {
if let (Some(tool_call), Some(recorded_result)) =
(tool_calls.get(idx), recorded_results.get_mut(idx))
{
*recorded_result = Some(self.record_tool_result(
tool_call,
output,
is_error,
&on_event,
new_messages,
));
}
}
}
self.activate_scoped_rules_for_tool_calls(tool_calls);
for (index, tool_call) in tool_calls.iter().enumerate() {
if steering_messages.is_none() && !abort.as_ref().is_some_and(AbortSignal::is_aborted) {
let steering = self.drain_steering_messages().await;
if !steering.is_empty() {
steering_messages = Some(steering);
}
}
if let Some(tool_result) = recorded_results.get_mut(index).and_then(Option::take) {
results.push(tool_result);
} else if steering_messages.is_some() {
results.push(self.skip_tool_call(tool_call, &on_event, new_messages));
} else {
let output = ToolOutput {
content: vec![ContentBlock::Text(TextContent::new(
"Tool execution aborted",
))],
details: Some(Self::tool_cancellation_details(
&tool_call.name,
"abort_signal",
)),
is_error: true,
};
on_event(AgentEvent::ToolExecutionUpdate {
tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
args: tool_call.arguments.clone(),
partial_result: ToolOutput {
content: output.content.clone(),
details: output.details.clone(),
is_error: true,
},
});
on_event(AgentEvent::ToolExecutionEnd {
tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
result: ToolOutput {
content: output.content.clone(),
details: output.details.clone(),
is_error: true,
},
is_error: true,
});
let tool_result = Arc::new(ToolResultMessage {
tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
content: output.content,
details: output.details,
is_error: true,
timestamp: Utc::now().timestamp_millis(),
});
let msg = Message::ToolResult(Arc::clone(&tool_result));
self.messages.push(msg.clone());
on_event(AgentEvent::MessageStart {
message: msg.clone(),
});
let end_msg = msg.clone();
new_messages.push(msg);
on_event(AgentEvent::MessageEnd { message: end_msg });
results.push(tool_result);
}
}
Ok(ToolExecutionOutcome {
tool_results: results,
steering_messages,
})
}
fn record_tool_result(
&mut self,
tool_call: &ToolCall,
output: ToolOutput,
is_error: bool,
on_event: &AgentEventHandler,
new_messages: &mut Vec<Message>,
) -> Arc<ToolResultMessage> {
on_event(AgentEvent::ToolExecutionUpdate {
tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
args: tool_call.arguments.clone(),
partial_result: ToolOutput {
content: output.content.clone(),
details: output.details.clone(),
is_error,
},
});
let tool_result = Arc::new(ToolResultMessage {
tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
content: output.content,
details: output.details,
is_error,
timestamp: Utc::now().timestamp_millis(),
});
on_event(AgentEvent::ToolExecutionEnd {
tool_call_id: tool_result.tool_call_id.clone(),
tool_name: tool_result.tool_name.clone(),
result: ToolOutput {
content: tool_result.content.clone(),
details: tool_result.details.clone(),
is_error,
},
is_error,
});
let msg = Message::ToolResult(Arc::clone(&tool_result));
self.messages.push(msg.clone());
on_event(AgentEvent::MessageStart {
message: msg.clone(),
});
new_messages.push(msg.clone());
on_event(AgentEvent::MessageEnd { message: msg });
tool_result
}
async fn execute_tool(
&self,
tool_call: ToolCall,
on_event: AgentEventHandler,
latency: SharedTurnLatencyAccumulator,
) -> (ToolOutput, bool) {
let extensions = self.extensions.clone();
let tool_call = self.restore_secrets_inbound(tool_call);
let approval_denied_output = self
.request_tool_approval(&tool_call, Arc::clone(&on_event))
.await;
let (mut output, is_error) = if let Some(output) = approval_denied_output {
(output, true)
} else if let Some(extensions) = &extensions {
let hook_started_at = Instant::now();
let hook_outcome = Self::dispatch_tool_call_hook(
extensions,
&tool_call,
self.config.fail_closed_hooks,
)
.await;
record_extension_hostcall_latency(&latency, hook_started_at.elapsed());
if let Some(blocked_output) = hook_outcome {
(blocked_output, true)
} else {
let tool_started_at = Instant::now();
let outcome = self
.execute_tool_without_hooks(&tool_call, Arc::clone(&on_event))
.await;
record_local_tool_latency(&latency, tool_started_at.elapsed());
outcome
}
} else {
let tool_started_at = Instant::now();
let outcome = self
.execute_tool_without_hooks(&tool_call, Arc::clone(&on_event))
.await;
record_local_tool_latency(&latency, tool_started_at.elapsed());
outcome
};
if let Some(extensions) = &extensions {
let hook_started_at = Instant::now();
Self::apply_tool_result_hook(extensions, &tool_call, &mut output, is_error).await;
record_extension_hostcall_latency(&latency, hook_started_at.elapsed());
}
self.mask_secrets_in_output(&mut output);
(output, is_error)
}
pub fn restore_secrets_inbound(&self, mut tool_call: ToolCall) -> ToolCall {
if crate::secrets::SecretsMode::from_setting(
self.config.secrets.as_ref().and_then(|s| s.mode.as_deref()),
) == crate::secrets::SecretsMode::Off
{
return tool_call;
}
tool_call.arguments = Self::restore_json_value(&self.secrets_vault, tool_call.arguments);
tool_call
}
fn restore_json_value(
vault: &crate::secrets::SecretVault,
value: serde_json::Value,
) -> serde_json::Value {
match value {
serde_json::Value::String(text) => serde_json::Value::String(vault.restore(&text)),
serde_json::Value::Array(items) => serde_json::Value::Array(
items
.into_iter()
.map(|item| Self::restore_json_value(vault, item))
.collect(),
),
serde_json::Value::Object(map) => serde_json::Value::Object(
map.into_iter()
.map(|(key, item)| (vault.restore(&key), Self::restore_json_value(vault, item)))
.collect(),
),
other => other,
}
}
pub fn mask_secrets_in_output(&self, output: &mut ToolOutput) {
if crate::secrets::SecretsMode::from_setting(
self.config.secrets.as_ref().and_then(|s| s.mode.as_deref()),
) == crate::secrets::SecretsMode::Off
{
return;
}
for block in &mut output.content {
if let ContentBlock::Text(t) = block {
t.text = self.secrets_vault.mask(&t.text);
}
}
if let Some(details) = &mut output.details
&& let Some(text) = serde_json::to_string(details).ok()
{
let masked = self.secrets_vault.mask(&text);
if masked != text
&& let Ok(value) = serde_json::from_str(&masked)
{
*details = value;
}
}
}
pub fn secrets_transform_outbound_text(&mut self, text: &str) -> Result<String> {
let mode = crate::secrets::SecretsMode::from_setting(
self.config.secrets.as_ref().and_then(|s| s.mode.as_deref()),
);
if mode == crate::secrets::SecretsMode::Off {
return Ok(text.to_string());
}
let extra = crate::secrets::compile_extra_patterns(
self.config
.secrets
.as_ref()
.and_then(|s| s.extra_patterns.as_deref())
.unwrap_or(&[]),
);
let mut total = 0usize;
let mut labels: Vec<String> = Vec::new();
Self::secrets_transform_text(
text,
&mut self.secrets_vault,
mode,
&extra,
&mut total,
&mut labels,
)
}
#[must_use]
pub fn mask_secrets_text(&self, text: &str) -> String {
if crate::secrets::SecretsMode::from_setting(
self.config.secrets.as_ref().and_then(|s| s.mode.as_deref()),
) == crate::secrets::SecretsMode::Off
{
return text.to_string();
}
self.secrets_vault.mask(text)
}
#[allow(clippy::too_many_lines)]
async fn request_tool_approval(
&self,
tool_call: &ToolCall,
on_event: AgentEventHandler,
) -> Option<ToolOutput> {
if let Some(approval_state) = &self.config.approval_state {
let effects = self.effects_for_call(tool_call);
let evaluation = approval_state.evaluate(
&tool_call.name,
&tool_call.arguments,
effects,
Some(&self.plan_state),
self.config.bash_settings.as_ref(),
);
match evaluation {
crate::approval::ApprovalEvaluation::HardBlocked { reason } => {
return Some(Self::tool_approval_denied_output(&format!(
"Refused by policy gate: {reason}"
)));
}
crate::approval::ApprovalEvaluation::AutoApproved { mode, reason } => {
let audit_details = crate::approval::ApprovalState::audit_payload(
&tool_call.id,
&tool_call.name,
&crate::approval::ApprovalEvaluation::AutoApproved { mode, reason },
);
on_event(AgentEvent::ToolExecutionUpdate {
tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
args: tool_call.arguments.clone(),
partial_result: ToolOutput {
content: Vec::new(),
details: Some(audit_details),
is_error: false,
},
});
return None;
}
crate::approval::ApprovalEvaluation::RequiresApproval {
mode,
reason,
is_dual_confirm,
danger_classes: _,
} => {
if let Some(approval) = &self.config.tool_approval {
let request = ToolApprovalRequest {
tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
arguments: tool_call.arguments.clone(),
};
match approval(request).await {
ToolApprovalDecision::Allow => {
if is_dual_confirm {
let cmd = tool_call
.arguments
.get("command")
.or_else(|| tool_call.arguments.get("cmd"))
.and_then(Value::as_str)
.unwrap_or("");
let token = format!("{}:{cmd}", tool_call.name);
approval_state.record_confirmation(&token);
}
on_event(AgentEvent::ToolExecutionUpdate {
tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
args: tool_call.arguments.clone(),
partial_result: ToolOutput {
content: Vec::new(),
details: Some(json!({
"schema": TOOL_APPROVAL_STATUS_SCHEMA_V1,
"status": "approved",
"mode": mode.as_str(),
})),
is_error: false,
},
});
return None;
}
ToolApprovalDecision::Deny { reason } => {
return Some(Self::tool_approval_denied_output(&reason));
}
}
}
return Some(Self::tool_approval_denied_output(&format!(
"Approval required in {mode} mode: {reason}"
)));
}
}
}
let Some(approval) = &self.config.tool_approval else {
return None;
};
let request = ToolApprovalRequest {
tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
arguments: tool_call.arguments.clone(),
};
match approval(request).await {
ToolApprovalDecision::Allow => {
on_event(AgentEvent::ToolExecutionUpdate {
tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
args: tool_call.arguments.clone(),
partial_result: ToolOutput {
content: Vec::new(),
details: Some(json!({
"schema": TOOL_APPROVAL_STATUS_SCHEMA_V1,
"status": "approved",
})),
is_error: false,
},
});
None
}
ToolApprovalDecision::Deny { reason } => {
Some(Self::tool_approval_denied_output(&reason))
}
}
}
async fn execute_tool_owned(
&self,
tool_call: ToolCall,
on_event: AgentEventHandler,
latency: SharedTurnLatencyAccumulator,
) -> (ToolOutput, bool) {
self.execute_tool(tool_call, on_event, latency).await
}
async fn dispatch_xdev(&self, tool_call: &ToolCall) -> Option<ToolOutput> {
let args = &tool_call.arguments;
let action = args.get("action").and_then(Value::as_str).unwrap_or("list");
match action {
"run" => {
let name = args.get("name").and_then(Value::as_str).unwrap_or("");
if name.is_empty() {
return Some(Self::xdev_text_output("xdev run requires a `name`", true));
}
let registry = self.tools.snapshot();
if !registry.is_discoverable(name) {
return Some(Self::xdev_text_output(
&if registry.get(name).is_some() {
format!("{name:?} is already in the live schema — call it directly.")
} else {
format!("No discoverable tool named {name:?}; use xdev list.")
},
true,
));
}
let inner_args = args.get("args").cloned().unwrap_or_else(|| json!({}));
let Some(inner) = registry.get(name) else {
return Some(Self::xdev_text_output(
&format!("Tool {name:?} not registered"),
true,
));
};
let mut output = inner
.execute(&tool_call.id, inner_args, None)
.await
.unwrap_or_else(|err| Self::xdev_text_output(&err.to_string(), true));
output.details = Some(json!({
"dispatchedVia": "xdev",
"tool": name,
}));
Some(output)
}
"promote" => {
let name = args.get("name").and_then(Value::as_str).unwrap_or("");
if name.is_empty() {
return Some(Self::xdev_text_output(
"xdev promote requires a `name`",
true,
));
}
let registry = self.tools.snapshot();
if !registry.is_discoverable(name) {
return Some(Self::xdev_text_output(
&if registry.get(name).is_some() {
format!("{name:?} is already in the live schema.")
} else {
format!("No discoverable tool named {name:?}; use xdev list.")
},
true,
));
}
if let Ok(mut set) = self.promoted_tools.lock() {
set.insert(name.to_string());
}
self.tool_defs_generation
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Some(Self::xdev_text_output(
&format!(
"Promoted {name:?} into the live schema for the rest of this session. You can now call it directly."
),
false,
))
}
_ => None,
}
}
fn xdev_text_output(text: &str, is_error: bool) -> ToolOutput {
ToolOutput {
content: vec![ContentBlock::Text(TextContent::new(text))],
details: None,
is_error,
}
}
fn effects_for_call(&self, tool_call: &ToolCall) -> crate::tools::ToolEffects {
if tool_call.name == "xdev" {
let action = tool_call
.arguments
.get("action")
.and_then(Value::as_str)
.unwrap_or("list");
if action != "run" {
return crate::tools::ToolEffects::read();
}
let inner = tool_call
.arguments
.get("name")
.and_then(Value::as_str)
.unwrap_or("");
return self
.tools
.snapshot()
.get(inner)
.map_or_else(crate::tools::ToolEffects::read, crate::tools::Tool::effects);
}
self.tools
.snapshot()
.get(&tool_call.name)
.map_or_else(crate::tools::ToolEffects::read, crate::tools::Tool::effects)
}
async fn execute_tool_without_hooks(
&self,
tool_call: &ToolCall,
on_event: AgentEventHandler,
) -> (ToolOutput, bool) {
if !self
.plan_state
.allows_effects(self.effects_for_call(tool_call))
{
return (
Self::xdev_text_output(
&crate::plan::PlanState::block_message(&tool_call.name),
true,
),
true,
);
}
if tool_call.name == "xdev"
&& let Some(output) = self.dispatch_xdev(tool_call).await
{
let is_error = output.is_error;
return (output, is_error);
}
let registry = self.tools.snapshot();
let Some(tool) = registry.get(&tool_call.name) else {
return (Self::tool_not_found_output(&tool_call.name), true);
};
let tool_name = tool_call.name.clone();
let tool_id = tool_call.id.clone();
let tool_args = tool_call.arguments.clone();
let on_event = Arc::clone(&on_event);
let update_callback = move |update: ToolUpdate| {
on_event(AgentEvent::ToolExecutionUpdate {
tool_call_id: tool_id.clone(),
tool_name: tool_name.clone(),
args: tool_args.clone(),
partial_result: ToolOutput {
content: update.content,
details: update.details,
is_error: false,
},
});
};
let _artifact_session_guard =
self.config
.stream_options
.session_id
.as_deref()
.map(|session_id| {
crate::tools::register_tool_output_artifact_session(&tool_call.id, session_id)
});
match tool
.execute(
&tool_call.id,
tool_call.arguments.clone(),
Some(Box::new(update_callback)),
)
.await
{
Ok(output) => {
let is_error = output.is_error;
(output, is_error)
}
Err(e) => (
ToolOutput {
content: vec![ContentBlock::Text(TextContent::new(format!("Error: {e}")))],
details: None,
is_error: true,
},
true,
),
}
}
fn tool_not_found_output(tool_name: &str) -> ToolOutput {
ToolOutput {
content: vec![ContentBlock::Text(TextContent::new(format!(
"Error: Tool '{tool_name}' not found"
)))],
details: None,
is_error: true,
}
}
fn tool_cancellation_details(tool_name: &str, reason: &str) -> Value {
json!({
"schema": TOOL_CANCELLATION_SCHEMA_V1,
"status": "cancelled",
"reason": reason,
"toolName": tool_name,
"cleanup": "tool_result_recorded_no_success",
})
}
async fn dispatch_tool_call_hook(
extensions: &ExtensionManager,
tool_call: &ToolCall,
fail_closed_hooks: bool,
) -> Option<ToolOutput> {
match extensions
.dispatch_tool_call(tool_call, EXTENSION_EVENT_TIMEOUT_MS)
.await
{
Ok(Some(result)) if result.block => {
Some(Self::tool_call_blocked_output(result.reason.as_deref()))
}
Ok(_) => None,
Err(err) => {
if fail_closed_hooks {
tracing::warn!(
error = ?err,
"tool_call extension hook failed (fail-closed)"
);
Some(Self::tool_call_blocked_output(Some(
"extension hook failed",
)))
} else {
tracing::warn!("tool_call extension hook failed (fail-open): {err}");
None
}
}
}
}
fn tool_call_blocked_output(reason: Option<&str>) -> ToolOutput {
let reason = reason.map(str::trim).filter(|reason| !reason.is_empty());
let message = reason.map_or_else(
|| "Tool execution was blocked by an extension".to_string(),
|reason| format!("Tool execution blocked: {reason}"),
);
ToolOutput {
content: vec![ContentBlock::Text(TextContent::new(message))],
details: None,
is_error: true,
}
}
fn tool_approval_denied_output(reason: &str) -> ToolOutput {
let reason = reason.trim();
let reason = if reason.is_empty() {
"tool approval denied"
} else {
reason
};
ToolOutput {
content: vec![ContentBlock::Text(TextContent::new(format!(
"Tool execution denied: {reason}"
)))],
details: Some(json!({
"schema": TOOL_APPROVAL_DENIED_SCHEMA_V1,
"status": "denied",
"reason": reason,
})),
is_error: true,
}
}
async fn apply_tool_result_hook(
extensions: &ExtensionManager,
tool_call: &ToolCall,
output: &mut ToolOutput,
is_error: bool,
) {
match extensions
.dispatch_tool_result(tool_call, &*output, is_error, EXTENSION_EVENT_TIMEOUT_MS)
.await
{
Ok(Some(result)) => {
if let Some(content) = result.content {
output.content = content;
}
if let Some(details) = result.details {
output.details = Some(details);
}
}
Ok(None) => {}
Err(err) => tracing::warn!("tool_result extension hook failed (fail-open): {err}"),
}
}
fn skip_tool_call(
&mut self,
tool_call: &ToolCall,
on_event: &Arc<dyn Fn(AgentEvent) + Send + Sync>,
new_messages: &mut Vec<Message>,
) -> Arc<ToolResultMessage> {
let output = ToolOutput {
content: vec![ContentBlock::Text(TextContent::new(
"Skipped due to queued user message.",
))],
details: None,
is_error: true,
};
on_event(AgentEvent::ToolExecutionUpdate {
tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
args: tool_call.arguments.clone(),
partial_result: output.clone(),
});
on_event(AgentEvent::ToolExecutionEnd {
tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
result: output.clone(),
is_error: true,
});
let tool_result = Arc::new(ToolResultMessage {
tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
content: output.content,
details: output.details,
is_error: true,
timestamp: Utc::now().timestamp_millis(),
});
let msg = Message::ToolResult(Arc::clone(&tool_result));
self.messages.push(msg.clone());
new_messages.push(msg.clone());
on_event(AgentEvent::MessageStart {
message: msg.clone(),
});
on_event(AgentEvent::MessageEnd { message: msg });
tool_result
}
}
struct ToolExecutionOutcome {
tool_results: Vec<Arc<ToolResultMessage>>,
steering_messages: Option<Vec<QueuedAgentMessage>>,
}
pub struct PreWarmedExtensionRuntime {
pub manager: ExtensionManager,
pub runtime: ExtensionRuntimeHandle,
pub tools: crate::tools::SharedToolRegistry,
}
#[derive(Clone)]
pub struct ExtensionHostConfiguration {
pub ui_handler: Option<Arc<dyn crate::extension_dispatcher::ExtensionUiHandler>>,
pub persist_permission_decisions: bool,
pub cli_flags: Vec<crate::cli::ExtensionCliFlag>,
}
impl Default for ExtensionHostConfiguration {
fn default() -> Self {
Self {
ui_handler: None,
persist_permission_decisions: true,
cli_flags: Vec::new(),
}
}
}
struct AtomicBoolGuard(Option<Arc<AtomicBool>>);
impl AtomicBoolGuard {
fn activate(flag: &Arc<AtomicBool>) -> Self {
flag.store(true, Ordering::SeqCst);
Self(Some(Arc::clone(flag)))
}
fn keep_active(mut self) {
self.0.take();
}
}
impl Drop for AtomicBoolGuard {
fn drop(&mut self) {
if let Some(flag) = &self.0 {
flag.store(false, Ordering::SeqCst);
}
}
}
pub struct AgentSession {
pub agent: Agent,
pub session: Arc<Mutex<Session>>,
save_enabled: bool,
input_source: InputSource,
pub extensions: Option<ExtensionRegion>,
mcp_manager: Option<Arc<crate::mcp::McpManager>>,
extensions_is_streaming: Arc<AtomicBool>,
extensions_is_compacting: Arc<AtomicBool>,
extensions_turn_active: Arc<AtomicBool>,
extensions_pending_idle_actions: Arc<StdMutex<VecDeque<PendingIdleAction>>>,
extension_queue_modes: Option<Arc<StdMutex<ExtensionQueueModeState>>>,
extension_injected_queue: Option<Arc<StdMutex<ExtensionInjectedQueue>>>,
extension_ai_completion: Arc<StdMutex<ExtensionAiCompletionHostState>>,
compaction_settings: ResolvedCompactionSettings,
compaction_runtime: Option<Runtime>,
pub advisor: Option<crate::advisor::AdvisorRuntime>,
runtime_handle: Option<RuntimeHandle>,
compaction_worker: CompactionWorkerState,
model_registry: Option<ModelRegistry>,
auth_storage: Option<AuthStorage>,
api_key_override: Option<String>,
semantic_context_bundle: Option<SemanticContextBundleInjection>,
provider_admission: ProviderAdmissionGate,
session_action_admission: SessionActionAdmissionGate,
}
#[derive(Clone, Debug)]
pub(crate) struct ProviderAdmissionGate {
permit: Arc<Mutex<()>>,
reason: Arc<StdMutex<Option<String>>>,
}
impl Default for ProviderAdmissionGate {
fn default() -> Self {
Self {
permit: Arc::new(Mutex::new(())),
reason: Arc::new(StdMutex::new(None)),
}
}
}
impl ProviderAdmissionGate {
pub(crate) fn reason(&self) -> Option<String> {
self.reason
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub(crate) fn ensure_allowed(&self) -> Result<()> {
if let Some(reason) = self.reason() {
return Err(Error::session_persistence(format!(
"provider re-entry is quarantined after an indeterminate Session transition: {reason}"
)));
}
Ok(())
}
pub(crate) fn block(&self, reason: String) {
*self
.reason
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(reason);
}
pub(crate) fn clear(&self) {
*self
.reason
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
pub(crate) async fn acquire(&self, cx: &asupersync::Cx) -> Result<OwnedMutexGuard<()>> {
OwnedMutexGuard::lock(Arc::clone(&self.permit), cx)
.await
.map_err(|err| Error::session(format!("provider admission lock failed: {err}")))
}
pub(crate) async fn begin_transition(
&self,
reason: String,
cx: &asupersync::Cx,
) -> Result<OwnedMutexGuard<()>> {
let permit = self.acquire(cx).await?;
self.ensure_allowed()?;
self.block(reason);
Ok(permit)
}
}
#[derive(Clone, Debug)]
pub(crate) struct SessionActionAdmissionGate {
permit: Arc<Mutex<()>>,
origin_source: SessionActionOriginSource,
#[cfg(test)]
pending_generation_checks: Arc<std::sync::atomic::AtomicUsize>,
}
#[cfg(test)]
struct PendingSessionActionGenerationCheck {
count: Arc<std::sync::atomic::AtomicUsize>,
}
#[cfg(test)]
impl Drop for PendingSessionActionGenerationCheck {
fn drop(&mut self) {
self.count.fetch_sub(1, Ordering::SeqCst);
}
}
impl Default for SessionActionAdmissionGate {
fn default() -> Self {
Self {
permit: Arc::new(Mutex::new(())),
origin_source: SessionActionOriginSource::default(),
#[cfg(test)]
pending_generation_checks: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
}
}
}
impl SessionActionAdmissionGate {
pub(crate) fn generation(&self) -> u64 {
self.origin_source.generation()
}
pub(crate) fn origin_source(&self) -> SessionActionOriginSource {
self.origin_source.clone()
}
pub(crate) fn capture_origin(&self) -> SessionActionOrigin {
self.origin_source.capture()
}
pub(crate) async fn acquire(&self, cx: &asupersync::Cx) -> Result<OwnedMutexGuard<()>> {
OwnedMutexGuard::lock(Arc::clone(&self.permit), cx)
.await
.map_err(|err| Error::session(format!("session action admission lock failed: {err}")))
}
async fn acquire_origin(&self, origin: &SessionActionOrigin) -> Result<OwnedMutexGuard<()>> {
#[cfg(test)]
let _pending_generation_check = {
self.pending_generation_checks
.fetch_add(1, Ordering::SeqCst);
PendingSessionActionGenerationCheck {
count: Arc::clone(&self.pending_generation_checks),
}
};
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let permit = self.acquire(cx.cx()).await?;
if !self.origin_source.accepts(origin) {
return Err(Error::session(
"active Session changed before the extension action could be applied",
));
}
Ok(permit)
}
#[cfg(test)]
fn pending_generation_check_count(&self) -> usize {
self.pending_generation_checks.load(Ordering::SeqCst)
}
pub(crate) fn advance_generation(&self) {
self.origin_source.advance();
}
}
struct PreparedModelSelection {
entry: Option<ModelEntry>,
provider: Option<Arc<dyn Provider>>,
resolved_key: Option<String>,
provider_id: String,
model_id: String,
thinking_level: crate::model::ThinkingLevel,
}
#[derive(Debug, Clone, Copy)]
struct ExtensionQueueModeState {
steering_mode: QueueMode,
follow_up_mode: QueueMode,
}
impl ExtensionQueueModeState {
const fn new(steering_mode: QueueMode, follow_up_mode: QueueMode) -> Self {
Self {
steering_mode,
follow_up_mode,
}
}
const fn set_modes(&mut self, steering_mode: QueueMode, follow_up_mode: QueueMode) {
self.steering_mode = steering_mode;
self.follow_up_mode = follow_up_mode;
}
}
#[derive(Debug)]
struct ExtensionInjectedQueue {
steering: VecDeque<Message>,
follow_up: VecDeque<Message>,
steering_mode: QueueMode,
follow_up_mode: QueueMode,
}
impl ExtensionInjectedQueue {
const fn new(steering_mode: QueueMode, follow_up_mode: QueueMode) -> Self {
Self {
steering: VecDeque::new(),
follow_up: VecDeque::new(),
steering_mode,
follow_up_mode,
}
}
const fn set_modes(&mut self, steering_mode: QueueMode, follow_up_mode: QueueMode) {
self.steering_mode = steering_mode;
self.follow_up_mode = follow_up_mode;
}
fn push_steering(&mut self, message: Message) {
if self.steering.len() >= MAX_STEERING_QUEUE_SIZE {
tracing::warn!(
"Extension steering queue full ({} messages), dropping oldest message",
MAX_STEERING_QUEUE_SIZE
);
self.steering.pop_front();
}
self.steering.push_back(message);
}
fn push_follow_up(&mut self, message: Message) {
if self.follow_up.len() >= MAX_FOLLOW_UP_QUEUE_SIZE {
tracing::warn!(
"Extension follow-up queue full ({} messages), dropping oldest message",
MAX_FOLLOW_UP_QUEUE_SIZE
);
self.follow_up.pop_front();
}
self.follow_up.push_back(message);
}
fn pop_steering(&mut self) -> Vec<Message> {
match self.steering_mode {
QueueMode::All => self.steering.drain(..).collect(),
QueueMode::OneAtATime => self.steering.pop_front().into_iter().collect(),
}
}
fn pop_follow_up(&mut self) -> Vec<Message> {
match self.follow_up_mode {
QueueMode::All => self.follow_up.drain(..).collect(),
QueueMode::OneAtATime => self.follow_up.pop_front().into_iter().collect(),
}
}
}
impl Default for ExtensionInjectedQueue {
fn default() -> Self {
Self::new(QueueMode::OneAtATime, QueueMode::OneAtATime)
}
}
#[derive(Debug)]
enum PendingIdleAction {
CustomMessage(Message),
UserText(String),
}
#[derive(Clone)]
struct AgentSessionHostActions {
session: Arc<Mutex<Session>>,
injected: Arc<StdMutex<ExtensionInjectedQueue>>,
is_streaming: Arc<AtomicBool>,
is_turn_active: Arc<AtomicBool>,
pending_idle_actions: Arc<StdMutex<VecDeque<PendingIdleAction>>>,
ai_completion: Arc<StdMutex<ExtensionAiCompletionHostState>>,
provider_admission: ProviderAdmissionGate,
session_action_admission: SessionActionAdmissionGate,
}
#[derive(Clone)]
struct ExtensionAiCompletionHostState {
provider: Arc<dyn Provider>,
stream_options: StreamOptions,
models: Vec<Value>,
}
impl AgentSessionHostActions {
async fn acquire_provider_admission(&self) -> Result<OwnedMutexGuard<()>> {
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let permit = self.provider_admission.acquire(cx.cx()).await?;
self.provider_admission.ensure_allowed()?;
Ok(permit)
}
async fn acquire_session_action_admission(
&self,
origin: Option<SessionActionOrigin>,
) -> Result<OwnedMutexGuard<()>> {
let origin = origin.ok_or_else(|| {
Error::session("extension Session action is missing trusted task provenance")
})?;
self.session_action_admission.acquire_origin(&origin).await
}
fn enqueue(&self, deliver_as: Option<ExtensionDeliverAs>, message: Message) {
let deliver_as = deliver_as.unwrap_or(ExtensionDeliverAs::Steer);
let Ok(mut queue) = self.injected.lock() else {
tracing::error!("injected queue mutex poisoned; dropping extension message");
return;
};
match deliver_as {
ExtensionDeliverAs::FollowUp => {
queue.push_follow_up(message);
}
ExtensionDeliverAs::Steer | ExtensionDeliverAs::NextTurn => {
queue.push_steering(message);
}
}
}
async fn append_to_session(&self, message: Message) -> Result<()> {
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let mut session = self
.session
.lock(cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
session.append_model_message(message);
Ok(())
}
fn queue_pending_idle_action(&self, action: PendingIdleAction) {
let Ok(mut actions) = self.pending_idle_actions.lock() else {
tracing::error!("pending idle actions mutex poisoned; dropping idle action");
return;
};
actions.push_back(action);
}
}
#[async_trait]
impl ExtensionHostActions for AgentSessionHostActions {
async fn send_message(
&self,
message: ExtensionSendMessage,
origin: Option<SessionActionOrigin>,
) -> Result<()> {
let _session_action_permit = self.acquire_session_action_admission(origin).await?;
let custom_message = Message::Custom(CustomMessage {
content: message.content,
custom_type: message.custom_type,
display: message.display,
details: message.details,
timestamp: Utc::now().timestamp_millis(),
});
if matches!(message.deliver_as, Some(ExtensionDeliverAs::NextTurn)) {
return self.append_to_session(custom_message).await;
}
if self.is_streaming.load(Ordering::SeqCst) {
self.enqueue(message.deliver_as, custom_message);
return Ok(());
}
if self.is_turn_active.load(Ordering::SeqCst) {
return self.append_to_session(custom_message).await;
}
if message.trigger_turn {
self.queue_pending_idle_action(PendingIdleAction::CustomMessage(custom_message));
return Ok(());
}
self.append_to_session(custom_message).await
}
async fn send_user_message(
&self,
message: ExtensionSendUserMessage,
origin: Option<SessionActionOrigin>,
) -> Result<()> {
let _session_action_permit = self.acquire_session_action_admission(origin).await?;
let text = message.text;
let user_message = Message::User(UserMessage {
content: UserContent::Text(text.clone()),
timestamp: Utc::now().timestamp_millis(),
});
if self.is_streaming.load(Ordering::SeqCst) {
self.enqueue(message.deliver_as, user_message);
return Ok(());
}
if self.is_turn_active.load(Ordering::SeqCst) {
return self.append_to_session(user_message).await;
}
self.queue_pending_idle_action(PendingIdleAction::UserText(text));
Ok(())
}
async fn complete_ai(&self, request: ExtensionAiCompletionRequest) -> Result<Value> {
let _provider_admission = self.acquire_provider_admission().await?;
let (provider, mut stream_options) = {
let state = self.ai_completion.lock().map_err(|_| {
Error::extension("extension completion host state mutex poisoned".to_string())
})?;
(Arc::clone(&state.provider), state.stream_options.clone())
};
apply_pi_ai_completion_options(&request.options, &mut stream_options)?;
let context = build_pi_ai_completion_context(&request)?;
let provider_name = provider.name().to_string();
let mut events = provider.stream(&context, &stream_options).await?;
let mut streamed_text = String::new();
while let Some(event) = events.next().await {
match event.map_err(|err| Error::provider(provider_name.clone(), err.to_string()))? {
StreamEvent::TextDelta { delta, .. } => streamed_text.push_str(&delta),
StreamEvent::TextEnd { content, .. } => {
streamed_text.push_str(&content);
}
StreamEvent::Done { message, .. } => {
if message.stop_reason == StopReason::Error {
return Err(Error::provider(
provider_name,
pi_ai_assistant_error_message(&message),
));
}
return pi_ai_completion_response(&message, request.simple);
}
StreamEvent::Error { error, .. } => {
return Err(Error::provider(
provider_name,
pi_ai_assistant_error_message(&error),
));
}
StreamEvent::Start { .. }
| StreamEvent::TextStart { .. }
| StreamEvent::ThinkingStart { .. }
| StreamEvent::ThinkingDelta { .. }
| StreamEvent::ThinkingEnd { .. }
| StreamEvent::ToolCallStart { .. }
| StreamEvent::ToolCallDelta { .. }
| StreamEvent::ToolCallEnd { .. } => {}
}
}
let suffix = if streamed_text.is_empty() {
String::new()
} else {
format!(" after streaming {} text bytes", streamed_text.len())
};
Err(Error::provider(
provider_name,
format!("pi-ai completion stream ended without Done event{suffix}"),
))
}
async fn list_ai_models(&self) -> Result<Value> {
let state = self.ai_completion.lock().map_err(|_| {
Error::extension("extension completion host state mutex poisoned".to_string())
})?;
if state.models.is_empty() {
return Ok(json!([{
"id": state.provider.model_id(),
"name": state.provider.model_id(),
"api": state.provider.api(),
"provider": state.provider.name(),
}]));
}
Ok(Value::Array(state.models.clone()))
}
async fn compact_session(&self, preparation: Value) -> Result<Value> {
let _provider_admission = self.acquire_provider_admission().await?;
let preparation = crate::compaction::compaction_preparation_from_value(&preparation)?;
let (provider, api_key) = {
let state = self.ai_completion.lock().map_err(|_| {
Error::extension("extension completion host state mutex poisoned".to_string())
})?;
(
Arc::clone(&state.provider),
state.stream_options.api_key.clone().unwrap_or_default(),
)
};
let result = crate::compaction::compact(preparation, provider, &api_key, None).await?;
serde_json::to_value(&result).map_err(|err| {
Error::extension(format!("serialize extension compaction result: {err}"))
})
}
async fn compact_session_native(&self, preparation: Value, request: Value) -> Result<Value> {
let _provider_admission = self.acquire_provider_admission().await?;
let preparation = crate::compaction::compaction_preparation_from_value(&preparation)?;
let (provider, stream_options) = {
let state = self.ai_completion.lock().map_err(|_| {
Error::extension("extension completion host state mutex poisoned".to_string())
})?;
(Arc::clone(&state.provider), state.stream_options.clone())
};
if provider.api() != "openai-responses" {
return Err(Error::validation(format!(
"native compaction requires the session's provider to use the openai-responses API (current: {})",
provider.api()
)));
}
let sanitized = sanitize_native_compact_request(&request, provider.model_id())?;
let response = provider.compact_native(&sanitized, &stream_options).await?;
let (read_files, modified_files) =
crate::compaction::compute_file_lists(&preparation.file_ops);
shape_native_compact_result(
&response,
&preparation.first_kept_entry_id,
preparation.tokens_before,
read_files,
modified_files,
)
}
}
const NATIVE_COMPACT_REQUEST_ALLOWLIST: &[&str] = &[
"model",
"instructions",
"input",
"tools",
"parallel_tool_calls",
"reasoning",
"service_tier",
"prompt_cache_key",
"text",
];
const NATIVE_COMPACT_FORBIDDEN_KEYS: &[&str] = &[
"api_key",
"apikey",
"authorization",
"auth",
"bearer",
"credentials",
"header",
"headers",
"key",
"token",
];
fn sanitize_native_compact_request(request: &Value, session_model_id: &str) -> Result<Value> {
let Some(obj) = request.as_object() else {
return Err(Error::validation(
"native compact request must be a JSON object".to_string(),
));
};
for key in obj.keys() {
let lowered = key.to_ascii_lowercase();
if NATIVE_COMPACT_FORBIDDEN_KEYS.contains(&lowered.as_str()) {
return Err(Error::validation(format!(
"native compact request must not carry credential material (offending key: `{key}`); the host supplies auth from the session"
)));
}
}
match obj.get("model") {
Some(Value::String(model)) if model == session_model_id => {}
Some(Value::String(model)) => {
return Err(Error::validation(format!(
"native compact request model `{model}` does not match the session model `{session_model_id}`; the bridge always compacts with the session's own model"
)));
}
_ => {
return Err(Error::validation(
"native compact request must carry a string `model` matching the session model"
.to_string(),
));
}
}
if !obj.get("input").is_some_and(Value::is_array) {
return Err(Error::validation(
"native compact request must carry an `input` array".to_string(),
));
}
if let Some(instructions) = obj.get("instructions")
&& !(instructions.is_string() || instructions.is_null())
{
return Err(Error::validation(
"native compact request `instructions` must be a string when present".to_string(),
));
}
let mut sanitized = serde_json::Map::new();
let mut dropped: Vec<&str> = Vec::new();
for (key, value) in obj {
if NATIVE_COMPACT_REQUEST_ALLOWLIST.contains(&key.as_str()) {
if !value.is_null() {
sanitized.insert(key.clone(), value.clone());
}
} else {
dropped.push(key.as_str());
}
}
if !dropped.is_empty() {
tracing::debug!(
dropped = ?dropped,
"native compact request: dropped non-allowlisted fields"
);
}
Ok(Value::Object(sanitized))
}
const NATIVE_COMPACT_FALLBACK_SUMMARY: &str = "[OpenAI native compaction checkpoint]";
fn shape_native_compact_result(
response: &Value,
first_kept_entry_id: &str,
tokens_before: u64,
read_files: Vec<String>,
modified_files: Vec<String>,
) -> Result<Value> {
let Some(output) = response.get("output").and_then(Value::as_array) else {
return Err(Error::provider(
"openai-responses".to_string(),
"native compact response is missing the `output` array".to_string(),
));
};
let mut summary = String::new();
for item in output {
if item.get("type").and_then(Value::as_str) == Some("message")
&& item.get("role").and_then(Value::as_str) == Some("assistant")
&& let Some(content) = item.get("content").and_then(Value::as_array)
{
for part in content {
if part.get("type").and_then(Value::as_str) == Some("output_text")
&& let Some(text) = part.get("text").and_then(Value::as_str)
{
if !summary.is_empty() {
summary.push('\n');
}
summary.push_str(text);
}
}
}
}
if summary.trim().is_empty() {
summary = NATIVE_COMPACT_FALLBACK_SUMMARY.to_string();
}
let mut details = serde_json::Map::new();
details.insert(
"strategy".to_string(),
Value::String("openai-responses-native".to_string()),
);
details.insert("compactedWindow".to_string(), Value::Array(output.clone()));
if let Some(id) = response.get("id").and_then(Value::as_str)
&& !id.is_empty()
{
details.insert(
"compactResponseId".to_string(),
Value::String(id.to_string()),
);
}
if let Some(created_at) = response.get("created_at")
&& !created_at.is_null()
{
details.insert("createdAt".to_string(), created_at.clone());
}
details.insert(
"readFiles".to_string(),
Value::Array(read_files.into_iter().map(Value::String).collect()),
);
details.insert(
"modifiedFiles".to_string(),
Value::Array(modified_files.into_iter().map(Value::String).collect()),
);
Ok(serde_json::json!({
"summary": summary,
"firstKeptEntryId": first_kept_entry_id,
"tokensBefore": tokens_before,
"details": Value::Object(details),
}))
}
fn pi_ai_model_entry_value(entry: &ModelEntry) -> Value {
json!({
"id": entry.model.id,
"name": entry.model.name,
"api": entry.model.api,
"provider": entry.model.provider,
"baseUrl": entry.model.base_url,
"reasoning": entry.model.reasoning,
"input": entry.model.input,
"cost": entry.model.cost,
"contextWindow": entry.model.context_window,
"maxTokens": entry.model.max_tokens,
"authHeader": entry.auth_header,
"hasCredentials": entry.api_key.is_some(),
})
}
fn pi_ai_model_registry_values(registry: &ModelRegistry) -> Vec<Value> {
registry
.models()
.iter()
.map(pi_ai_model_entry_value)
.collect()
}
fn apply_pi_ai_completion_options(
options: &Value,
stream_options: &mut StreamOptions,
) -> Result<()> {
if let Some(value) = options
.get("temperature")
.or_else(|| options.get("temp"))
.filter(|value| !value.is_null())
{
let temperature = serde_json::from_value::<f32>(value.clone()).map_err(|err| {
Error::validation(format!(
"pi-ai completion temperature must be numeric: {err}"
))
})?;
if !(0.0..=2.0).contains(&temperature) {
return Err(Error::validation(
"pi-ai completion temperature must be between 0 and 2".to_string(),
));
}
stream_options.temperature = Some(temperature);
}
if let Some(value) = options
.get("maxTokens")
.or_else(|| options.get("max_tokens"))
.filter(|value| !value.is_null())
{
let raw = value.as_u64().ok_or_else(|| {
Error::validation("pi-ai completion maxTokens must be an unsigned integer".to_string())
})?;
let max_tokens = u32::try_from(raw).map_err(|_| {
Error::validation("pi-ai completion maxTokens exceeds u32::MAX".to_string())
})?;
if max_tokens == 0 {
return Err(Error::validation(
"pi-ai completion maxTokens must be greater than zero".to_string(),
));
}
stream_options.max_tokens = Some(max_tokens);
}
Ok(())
}
fn build_pi_ai_completion_context(
request: &ExtensionAiCompletionRequest,
) -> Result<Context<'static>> {
let mut system_prompts = Vec::new();
let mut messages = Vec::new();
collect_pi_ai_context_messages(&request.context, &mut system_prompts, &mut messages)?;
if messages.is_empty() {
return Err(Error::validation(
"@mariozechner/pi-ai completion requires at least one user or assistant message"
.to_string(),
));
}
let system_prompt = system_prompts
.into_iter()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n");
Ok(Context::owned(
if system_prompt.is_empty() {
None
} else {
Some(system_prompt)
},
messages,
Vec::new(),
))
}
fn collect_pi_ai_context_messages(
value: &Value,
system_prompts: &mut Vec<String>,
messages: &mut Vec<Message>,
) -> Result<()> {
match value {
Value::Null => {}
Value::String(text) => push_pi_ai_user_message(text, messages),
Value::Array(items) => {
for item in items {
push_pi_ai_message(item, system_prompts, messages)?;
}
}
Value::Object(map) => {
if let Some(system) = map
.get("systemPrompt")
.or_else(|| map.get("system_prompt"))
.or_else(|| map.get("system"))
.and_then(pi_ai_text_from_value)
{
system_prompts.push(system);
}
if let Some(items) = map.get("messages").and_then(Value::as_array) {
for item in items {
push_pi_ai_message(item, system_prompts, messages)?;
}
} else if let Some(prompt) = map
.get("prompt")
.or_else(|| map.get("input"))
.or_else(|| map.get("message"))
.and_then(pi_ai_text_from_value)
{
push_pi_ai_user_message(&prompt, messages);
} else if map.contains_key("role") {
push_pi_ai_message(value, system_prompts, messages)?;
}
}
Value::Bool(_) | Value::Number(_) => push_pi_ai_user_message(&value.to_string(), messages),
}
Ok(())
}
fn push_pi_ai_message(
value: &Value,
system_prompts: &mut Vec<String>,
messages: &mut Vec<Message>,
) -> Result<()> {
let Value::Object(map) = value else {
if let Some(text) = pi_ai_text_from_value(value) {
push_pi_ai_user_message(&text, messages);
}
return Ok(());
};
let role = map
.get("role")
.and_then(Value::as_str)
.unwrap_or("user")
.trim()
.to_ascii_lowercase();
let content = map
.get("content")
.or_else(|| map.get("text"))
.and_then(pi_ai_text_from_value)
.unwrap_or_default();
match role.as_str() {
"system" => {
if !content.trim().is_empty() {
system_prompts.push(content);
}
}
"user" => push_pi_ai_user_message(&content, messages),
"assistant" => push_pi_ai_assistant_message(&content, messages),
other => {
return Err(Error::validation(format!(
"@mariozechner/pi-ai completion does not support {other:?} context messages"
)));
}
}
Ok(())
}
fn push_pi_ai_user_message(text: &str, messages: &mut Vec<Message>) {
messages.push(Message::User(UserMessage {
content: UserContent::Text(text.to_string()),
timestamp: Utc::now().timestamp_millis(),
}));
}
fn push_pi_ai_assistant_message(text: &str, messages: &mut Vec<Message>) {
messages.push(Message::assistant(AssistantMessage {
content: vec![ContentBlock::Text(TextContent::new(text.to_string()))],
timestamp: Utc::now().timestamp_millis(),
stop_details: None,
..AssistantMessage::default()
}));
}
fn pi_ai_text_from_value(value: &Value) -> Option<String> {
match value {
Value::Null => None,
Value::String(text) => Some(text.clone()),
Value::Bool(_) | Value::Number(_) => Some(value.to_string()),
Value::Array(items) => {
let mut text = String::new();
for item in items {
if let Some(part) = pi_ai_text_from_value(item)
&& !part.is_empty()
{
text.push_str(&part);
}
}
Some(text)
}
Value::Object(map) => map
.get("text")
.or_else(|| map.get("content"))
.or_else(|| map.get("delta"))
.and_then(pi_ai_text_from_value),
}
}
fn pi_ai_assistant_text(message: &AssistantMessage) -> String {
let mut text = String::new();
for block in &message.content {
if let ContentBlock::Text(text_block) = block {
text.push_str(&text_block.text);
}
}
text
}
fn pi_ai_assistant_error_message(message: &AssistantMessage) -> String {
message
.error_message
.clone()
.filter(|text| !text.trim().is_empty())
.unwrap_or_else(|| {
let text = pi_ai_assistant_text(message);
if text.trim().is_empty() {
"provider returned an error without a message".to_string()
} else {
text
}
})
}
fn pi_ai_completion_response(message: &AssistantMessage, simple: bool) -> Result<Value> {
let text = pi_ai_assistant_text(message);
if simple {
return Ok(Value::String(text));
}
Ok(json!({
"message": serde_json::to_value(message)?,
"content": serde_json::to_value(&message.content)?,
"text": text,
"usage": serde_json::to_value(&message.usage)?,
"model": message.model,
"provider": message.provider,
"api": message.api,
"stopReason": message.stop_reason,
}))
}
#[cfg(test)]
mod message_queue_tests {
use super::*;
fn user_message(text: &str) -> Message {
Message::User(UserMessage {
content: UserContent::Text(text.to_string()),
timestamp: 0,
})
}
fn queued_user_message(text: &str) -> QueuedAgentMessage {
QueuedAgentMessage::from_authored_message(user_message(text))
}
#[test]
fn direct_authored_block_message_preserves_all_text_for_scanning() {
let queued = QueuedAgentMessage::from_authored_message(Message::User(UserMessage {
content: UserContent::Blocks(vec![
ContentBlock::Text(TextContent::new("please orchestrate")),
ContentBlock::Image(crate::model::ImageContent {
data: "aGVsbG8=".to_string(),
mime_type: "image/png".to_string(),
}),
ContentBlock::Text(TextContent::new("then workflowz")),
]),
timestamp: 0,
}));
assert_eq!(
queued.keyword_scan_source(),
Some("please orchestrate\nthen workflowz")
);
}
#[test]
fn message_queue_one_at_a_time() {
let mut queue = MessageQueue::new(QueueMode::OneAtATime, QueueMode::OneAtATime);
queue.push_steering(queued_user_message("a"));
queue.push_steering(queued_user_message("b"));
let first = queue.pop_steering();
assert_eq!(first.len(), 1);
assert!(matches!(
first.first().map(QueuedAgentMessage::message),
Some(Message::User(UserMessage { content, .. }))
if matches!(content, UserContent::Text(text) if text == "a")
));
let second = queue.pop_steering();
assert_eq!(second.len(), 1);
assert!(matches!(
second.first().map(QueuedAgentMessage::message),
Some(Message::User(UserMessage { content, .. }))
if matches!(content, UserContent::Text(text) if text == "b")
));
assert!(queue.pop_steering().is_empty());
}
#[test]
fn message_queue_all_mode() {
let mut queue = MessageQueue::new(QueueMode::All, QueueMode::OneAtATime);
queue.push_steering(queued_user_message("a"));
queue.push_steering(queued_user_message("b"));
let drained = queue.pop_steering();
assert_eq!(drained.len(), 2);
assert!(queue.pop_steering().is_empty());
}
#[test]
fn message_queue_separates_kinds() {
let mut queue = MessageQueue::new(QueueMode::OneAtATime, QueueMode::OneAtATime);
queue.push_steering(queued_user_message("steer"));
queue.push_follow_up(queued_user_message("follow"));
let steering = queue.pop_steering();
assert_eq!(steering.len(), 1);
assert_eq!(queue.pending_count(), 1);
let follow = queue.pop_follow_up();
assert_eq!(follow.len(), 1);
assert_eq!(queue.pending_count(), 0);
}
#[test]
fn queued_message_clones_share_lazy_persistence_identity_for_cleanup() {
let delivery = queued_user_message("durably acknowledged");
let staged_clone = delivery.clone();
let mut queue = MessageQueue::new(QueueMode::OneAtATime, QueueMode::OneAtATime);
queue.push_steering(staged_clone);
assert!(queue.contains_delivery(&delivery));
let (entry_id, timestamp, parent_id) =
delivery.bind_persistence_identity(Some("parent-entry".to_string()));
assert_eq!(parent_id.as_deref(), Some("parent-entry"));
assert!(!timestamp.is_empty());
assert_eq!(
queue.steering[0].delivery.persistence_entry_id(),
Some(entry_id.as_str()),
"a staged Agent clone must observe the identity bound by RPC durability"
);
let entry_ids = std::collections::HashSet::from([entry_id]);
assert_eq!(queue.discard_persistence_ids(&entry_ids), 1);
assert_eq!(queue.pending_count(), 0);
}
#[test]
fn message_queue_seq_increments() {
let mut queue = MessageQueue::new(QueueMode::OneAtATime, QueueMode::OneAtATime);
let first = queue.push_steering(queued_user_message("a"));
let second = queue.push_follow_up(queued_user_message("b"));
assert!(second > first);
}
#[test]
fn message_queue_seq_saturates_at_u64_max() {
let mut queue = MessageQueue::new(QueueMode::OneAtATime, QueueMode::OneAtATime);
queue.next_seq = u64::MAX;
let first = queue.push_steering(queued_user_message("a"));
let second = queue.push_follow_up(queued_user_message("b"));
assert_eq!(first, u64::MAX);
assert_eq!(second, u64::MAX);
assert_eq!(queue.pending_count(), 2);
}
#[test]
fn message_queue_follow_up_all_mode_drains_entire_queue_in_order() {
let mut queue = MessageQueue::new(QueueMode::OneAtATime, QueueMode::All);
queue.push_follow_up(queued_user_message("f1"));
queue.push_follow_up(queued_user_message("f2"));
let follow_up = queue.pop_follow_up();
assert_eq!(follow_up.len(), 2);
assert!(matches!(
follow_up.first().map(QueuedAgentMessage::message),
Some(Message::User(UserMessage { content, .. }))
if matches!(content, UserContent::Text(text) if text == "f1")
));
assert!(matches!(
follow_up.get(1).map(QueuedAgentMessage::message),
Some(Message::User(UserMessage { content, .. }))
if matches!(content, UserContent::Text(text) if text == "f2")
));
assert!(queue.pop_follow_up().is_empty());
}
#[test]
fn ordinary_overflow_does_not_evict_an_older_job_notice() {
let mut queue = MessageQueue::new(QueueMode::OneAtATime, QueueMode::All);
queue.push_job_follow_up_lossless(
"owner-a".to_string(),
QueuedAgentMessage::generated(user_message("job-notice")),
);
for index in 0..=MAX_FOLLOW_UP_QUEUE_SIZE {
queue.push_follow_up(queued_user_message(&format!("ordinary-{index}")));
}
assert_eq!(
queue
.follow_up
.iter()
.filter(|entry| entry.job_owner_session_id.is_none())
.count(),
MAX_FOLLOW_UP_QUEUE_SIZE
);
assert_eq!(
queue
.follow_up
.iter()
.filter(|entry| entry.job_owner_session_id.as_deref() == Some("owner-a"))
.count(),
1,
"ordinary admission pressure must evict only ordinary follow-ups"
);
let drained = queue.pop_follow_up();
assert!(drained.iter().any(|delivery| {
matches!(
delivery.message(),
Message::User(UserMessage {
content: UserContent::Text(text),
..
}) if text == "job-notice"
)
}));
}
}
#[cfg(test)]
mod compatible_tool_parallelism_tests {
use super::*;
#[test]
fn compatible_tool_parallelism_preserves_historical_floor() {
assert_eq!(resolve_compatible_tool_parallelism(None, 1), 8);
assert_eq!(resolve_compatible_tool_parallelism(None, 8), 8);
}
#[test]
fn compatible_tool_parallelism_scales_on_many_core_hosts() {
assert_eq!(resolve_compatible_tool_parallelism(None, 32), 32);
assert_eq!(resolve_compatible_tool_parallelism(None, 64), 64);
assert_eq!(resolve_compatible_tool_parallelism(None, 128), 64);
}
#[test]
fn compatible_tool_parallelism_accepts_bounded_override() {
assert_eq!(resolve_compatible_tool_parallelism(Some("16"), 4), 16);
assert_eq!(resolve_compatible_tool_parallelism(Some("512"), 64), 256);
assert_eq!(resolve_compatible_tool_parallelism(Some("1"), 64), 1);
}
#[test]
fn compatible_tool_parallelism_ignores_invalid_override() {
assert_eq!(
resolve_compatible_tool_parallelism(Some("not-a-number"), 24),
24
);
assert_eq!(resolve_compatible_tool_parallelism(Some("0"), 24), 24);
assert_eq!(resolve_compatible_tool_parallelism(Some(" "), 24), 24);
}
}
#[cfg(test)]
mod tool_effect_batch_planning_tests {
use super::*;
#[derive(Debug, Clone, Copy)]
enum SyntheticOutcome {
Success,
Error,
}
#[derive(Debug, Clone)]
struct SyntheticToolCase {
id: String,
name: String,
registered_effects: Option<ToolEffects>,
outcome: SyntheticOutcome,
}
#[derive(Debug, Clone, Copy)]
enum BatchArrivalOrder {
Forward,
Reverse,
RotateLeft(usize),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct TranscriptEntry {
tool_call_id: String,
tool_name: String,
text: String,
details: serde_json::Value,
is_error: bool,
}
fn batch_ranges(effects: &[ToolEffects]) -> Vec<(usize, usize)> {
plan_tool_effect_batches(effects)
.into_iter()
.map(|batch| (batch.start, batch.end))
.collect()
}
fn batch_plan_json(effects: &[ToolEffects], parallelism_cap: usize) -> serde_json::Value {
serde_json::to_value(tool_effect_batch_plan_evidence(effects, parallelism_cap))
.expect("tool-effect batch evidence should serialize")
}
fn synthetic_tool_case(
index: usize,
name: impl Into<String>,
registered_effects: Option<ToolEffects>,
outcome: SyntheticOutcome,
) -> SyntheticToolCase {
SyntheticToolCase {
id: format!("call-{index:03}"),
name: name.into(),
registered_effects,
outcome,
}
}
fn effect_plan(cases: &[SyntheticToolCase]) -> Vec<ToolEffects> {
cases
.iter()
.map(|case| case.registered_effects.unwrap_or_else(ToolEffects::write))
.collect()
}
fn make_tool_result(case: &SyntheticToolCase, index: usize) -> ToolResultMessage {
let (content, is_error) = match case.outcome {
SyntheticOutcome::Success => (format!("ok:{}", case.name), false),
SyntheticOutcome::Error => (format!("error:{}", case.name), true),
};
ToolResultMessage {
tool_call_id: case.id.clone(),
tool_name: case.name.clone(),
content: vec![ContentBlock::Text(TextContent::new(content))],
details: Some(serde_json::json!({
"ordinal": index,
"tool": case.name,
"status": if is_error { "error" } else { "ok" },
})),
is_error,
timestamp: 42,
}
}
fn transcript_entry(message: &ToolResultMessage) -> TranscriptEntry {
assert_eq!(message.content.len(), 1, "synthetic result content drifted");
let text = message
.content
.first()
.and_then(|block| match block {
ContentBlock::Text(text) => Some(text.text.clone()),
_ => None,
})
.unwrap_or_else(|| "non-text synthetic result".to_string());
TranscriptEntry {
tool_call_id: message.tool_call_id.clone(),
tool_name: message.tool_name.clone(),
text,
details: message.details.clone().unwrap_or(serde_json::Value::Null),
is_error: message.is_error,
}
}
fn sequential_oracle(cases: &[SyntheticToolCase]) -> Vec<TranscriptEntry> {
cases
.iter()
.enumerate()
.map(|(index, case)| transcript_entry(&make_tool_result(case, index)))
.collect()
}
fn reorder_batch(indices: &mut [usize], order: BatchArrivalOrder) {
match order {
BatchArrivalOrder::Forward => {}
BatchArrivalOrder::Reverse => indices.reverse(),
BatchArrivalOrder::RotateLeft(amount) => {
if !indices.is_empty() {
indices.rotate_left(amount % indices.len());
}
}
}
}
fn scheduled_transcript(
cases: &[SyntheticToolCase],
order: BatchArrivalOrder,
) -> Vec<TranscriptEntry> {
let effects = effect_plan(cases);
let batches = plan_tool_effect_batches(&effects);
let mut recorded_results: Vec<Option<ToolResultMessage>> = vec![None; cases.len()];
for batch in batches {
let mut completion_order = (batch.start..batch.end).collect::<Vec<_>>();
reorder_batch(&mut completion_order, order);
let mut batch_results = completion_order
.into_iter()
.filter_map(|index| {
cases
.get(index)
.map(|case| (index, make_tool_result(case, index)))
})
.collect::<Vec<_>>();
batch_results.sort_by_key(|(index, _)| *index);
for (index, result) in batch_results {
if let Some(slot) = recorded_results.get_mut(index) {
*slot = Some(result);
}
}
}
assert!(
recorded_results.iter().all(Option::is_some),
"scheduled execution should record every result"
);
recorded_results
.into_iter()
.flatten()
.map(|result| transcript_entry(&result))
.collect()
}
fn assert_barrier_effects_are_singleton_batches(cases: &[SyntheticToolCase]) {
let effects = effect_plan(cases);
for batch in plan_tool_effect_batches(&effects) {
let batch_effects = effects
.get(batch.start..batch.end)
.unwrap_or(&[])
.iter()
.copied()
.fold(ToolEffects::read(), ToolEffects::union);
if !batch_effects.parallel_safe() {
assert_eq!(
batch.end - batch.start,
1,
"barrier batch must serialize original index {}",
batch.start
);
}
}
}
#[test]
fn read_and_network_effects_share_compatible_batch() {
let ranges = batch_ranges(&[
ToolEffects::read(),
ToolEffects::network(),
ToolEffects::read(),
]);
assert_eq!(ranges, vec![(0, 3)]);
}
#[test]
fn evidence_records_64_plus_compatible_batch_with_parallelism_cap() {
let effects = (0..72)
.map(|index| {
if index % 3 == 0 {
ToolEffects::network()
} else {
ToolEffects::read()
}
})
.collect::<Vec<_>>();
assert_eq!(
batch_plan_json(&effects, 64),
serde_json::json!({
"schema": TOOL_EFFECT_BATCH_PLAN_SCHEMA_V1,
"toolCount": 72,
"parallelismCap": 64,
"batches": [
{
"start": 0,
"end": 72,
"len": 72,
"combinedEffects": ["read", "network"],
"parallelSafe": true
}
]
})
);
}
#[test]
fn write_effect_creates_deterministic_barrier() {
let ranges = batch_ranges(&[
ToolEffects::read(),
ToolEffects::read(),
ToolEffects::write(),
ToolEffects::read(),
]);
assert_eq!(ranges, vec![(0, 2), (2, 3), (3, 4)]);
}
#[test]
fn append_and_process_effects_remain_serialized() {
let ranges = batch_ranges(&[
ToolEffects::append(),
ToolEffects::append(),
ToolEffects::process(),
ToolEffects::read(),
]);
assert_eq!(ranges, vec![(0, 1), (1, 2), (2, 3), (3, 4)]);
}
#[test]
fn combined_process_write_effect_is_exclusive() {
let ranges = batch_ranges(&[
ToolEffects::read(),
ToolEffects::process().union(ToolEffects::write()),
ToolEffects::network(),
]);
assert_eq!(ranges, vec![(0, 1), (1, 2), (2, 3)]);
}
#[test]
fn evidence_records_barrier_reasons_for_mixed_effects() {
let effects = [
ToolEffects::read(),
ToolEffects::network(),
ToolEffects::write(),
ToolEffects::append(),
ToolEffects::process(),
ToolEffects::read(),
ToolEffects::process().union(ToolEffects::write()),
];
assert_eq!(
batch_plan_json(&effects, 32),
serde_json::json!({
"schema": TOOL_EFFECT_BATCH_PLAN_SCHEMA_V1,
"toolCount": 7,
"parallelismCap": 32,
"batches": [
{
"start": 0,
"end": 2,
"len": 2,
"combinedEffects": ["read", "network"],
"parallelSafe": true
},
{
"start": 2,
"end": 3,
"len": 1,
"combinedEffects": ["write"],
"parallelSafe": false,
"barrierReason": "write_barrier"
},
{
"start": 3,
"end": 4,
"len": 1,
"combinedEffects": ["append"],
"parallelSafe": false,
"barrierReason": "append_barrier"
},
{
"start": 4,
"end": 5,
"len": 1,
"combinedEffects": ["process"],
"parallelSafe": false,
"barrierReason": "process_barrier"
},
{
"start": 5,
"end": 6,
"len": 1,
"combinedEffects": ["read"],
"parallelSafe": true
},
{
"start": 6,
"end": 7,
"len": 1,
"combinedEffects": ["write", "process"],
"parallelSafe": false,
"barrierReason": "write_process_barrier"
}
]
})
);
}
#[test]
fn metamorphic_empty_tool_batch_matches_sequential_oracle() {
let cases = Vec::new();
assert!(plan_tool_effect_batches(&effect_plan(&cases)).is_empty());
assert_eq!(
scheduled_transcript(&cases, BatchArrivalOrder::Forward),
sequential_oracle(&cases)
);
}
#[test]
fn metamorphic_mixed_effect_batches_match_sequential_oracle() {
let cases = vec![
synthetic_tool_case(
0,
"read",
Some(ToolEffects::read()),
SyntheticOutcome::Success,
),
synthetic_tool_case(
1,
"network",
Some(ToolEffects::network()),
SyntheticOutcome::Success,
),
synthetic_tool_case(
2,
"write",
Some(ToolEffects::write()),
SyntheticOutcome::Success,
),
synthetic_tool_case(
3,
"read",
Some(ToolEffects::read()),
SyntheticOutcome::Success,
),
synthetic_tool_case(
4,
"append",
Some(ToolEffects::append()),
SyntheticOutcome::Error,
),
synthetic_tool_case(
5,
"network",
Some(ToolEffects::network()),
SyntheticOutcome::Success,
),
synthetic_tool_case(
6,
"process",
Some(ToolEffects::process()),
SyntheticOutcome::Success,
),
synthetic_tool_case(
7,
"read",
Some(ToolEffects::read()),
SyntheticOutcome::Error,
),
synthetic_tool_case(8, "unknown", None, SyntheticOutcome::Success),
synthetic_tool_case(
9,
"network",
Some(ToolEffects::network()),
SyntheticOutcome::Success,
),
];
assert_eq!(
batch_ranges(&effect_plan(&cases)),
vec![
(0, 2),
(2, 3),
(3, 4),
(4, 5),
(5, 6),
(6, 7),
(7, 8),
(8, 9),
(9, 10)
]
);
let evidence = tool_effect_batch_plan_evidence(&effect_plan(&cases), 16);
assert_eq!(evidence.schema, TOOL_EFFECT_BATCH_PLAN_SCHEMA_V1);
assert_eq!(evidence.parallelism_cap, 16);
assert_eq!(evidence.batches.len(), 9);
assert!(evidence.batches.iter().any(|batch| {
batch.barrier_reason == Some("append_barrier") && batch.combined_effects == ["append"]
}));
assert!(
cases
.iter()
.any(|case| matches!(case.outcome, SyntheticOutcome::Error)),
"mixed-effect fixture must include failure cases"
);
assert_barrier_effects_are_singleton_batches(&cases);
let oracle = sequential_oracle(&cases);
assert_eq!(
scheduled_transcript(&cases, BatchArrivalOrder::Reverse),
oracle
);
assert_eq!(
scheduled_transcript(&cases, BatchArrivalOrder::RotateLeft(1)),
oracle
);
}
#[test]
fn metamorphic_high_count_batches_keep_transcript_deterministic() {
let cases = (0..96)
.map(|index| match index % 12 {
0 => synthetic_tool_case(
index,
format!("process-{index}"),
Some(ToolEffects::process()),
SyntheticOutcome::Success,
),
5 => synthetic_tool_case(
index,
format!("append-{index}"),
Some(ToolEffects::append()),
SyntheticOutcome::Success,
),
9 => synthetic_tool_case(
index,
format!("unknown-{index}"),
None,
SyntheticOutcome::Error,
),
3 | 7 => synthetic_tool_case(
index,
format!("network-{index}"),
Some(ToolEffects::network()),
SyntheticOutcome::Success,
),
_ => synthetic_tool_case(
index,
format!("read-{index}"),
Some(ToolEffects::read()),
SyntheticOutcome::Success,
),
})
.collect::<Vec<_>>();
assert_barrier_effects_are_singleton_batches(&cases);
let oracle = sequential_oracle(&cases);
assert_eq!(
scheduled_transcript(&cases, BatchArrivalOrder::Forward),
oracle
);
assert_eq!(
scheduled_transcript(&cases, BatchArrivalOrder::Reverse),
oracle
);
assert_eq!(
scheduled_transcript(&cases, BatchArrivalOrder::RotateLeft(3)),
oracle
);
}
}
#[cfg(test)]
mod extensions_integration_tests {
use super::*;
use crate::session::Session;
use asupersync::runtime::RuntimeBuilder;
use async_trait::async_trait;
use futures::Stream;
use serde_json::json;
use std::path::Path;
use std::pin::Pin;
use std::sync::atomic::AtomicUsize;
use std::time::{Duration, Instant};
async fn wait_for_session_action_generation_capture(gate: &SessionActionAdmissionGate) {
let deadline = Instant::now() + Duration::from_secs(5);
while gate.pending_generation_check_count() == 0 {
assert!(
Instant::now() < deadline,
"session action did not capture its source generation before the deadline"
);
asupersync::time::sleep(asupersync::time::wall_now(), Duration::from_millis(1)).await;
}
}
async fn wait_for_session_action_generation_release(gate: &SessionActionAdmissionGate) {
let deadline = Instant::now() + Duration::from_secs(5);
while gate.pending_generation_check_count() != 0 {
assert!(
Instant::now() < deadline,
"cancelled Session action did not release its pending admission before the deadline"
);
asupersync::time::sleep(asupersync::time::wall_now(), Duration::from_millis(1)).await;
}
}
#[test]
fn scoped_rules_activate_once_per_rule_via_tool_call_paths() {
let tmp = tempfile::tempdir().expect("tempdir");
let provider: Arc<dyn Provider> = Arc::new(NoopProvider);
let tools = ToolRegistry::new(&[], tmp.path(), None);
let mut agent = Agent::new(provider, tools, AgentConfig::default());
agent.set_foreign_scoped_rules(
vec![crate::context_files::ForeignRule {
content: "Use strict TypeScript.".to_string(),
globs: vec!["*.ts".to_string()],
always_apply: false,
source: ".cursor/rules/ts.mdc".to_string(),
format: crate::context_files::ForeignRuleFormat::CursorMdc,
}],
tmp.path().to_path_buf(),
);
let tool_call = |path: &str| ToolCall {
id: "call".to_string(),
name: "read".to_string(),
arguments: json!({ "path": path }),
thought_signature: None,
};
agent.activate_scoped_rules_for_tool_calls(&[tool_call("README.md")]);
assert!(
agent.message_queue.pop_steering().is_empty(),
"non-matching path must not activate the rule"
);
agent.activate_scoped_rules_for_tool_calls(&[tool_call("src/main.ts")]);
let delivered = agent.message_queue.pop_steering();
assert_eq!(delivered.len(), 1, "matching path activates the rule once");
assert_eq!(delivered[0].keyword_scan_source(), None);
let Message::User(user) = delivered[0].message() else {
panic!("rule must arrive as a user steering message");
};
let UserContent::Text(text) = &user.content else {
panic!("rule steering must be text");
};
assert!(text.contains("imported-rule"));
assert!(text.contains(".cursor/rules/ts.mdc"));
assert!(text.contains("Use strict TypeScript."));
agent.activate_scoped_rules_for_tool_calls(&[tool_call("other/lib.ts")]);
assert!(
agent.message_queue.pop_steering().is_empty(),
"an activated rule must not re-queue"
);
}
#[test]
fn before_provider_request_response_shapes_normalize() {
let direct = json!({"model": "m", "input": []});
assert_eq!(
normalize_before_provider_request_response(direct.clone()),
Some(direct)
);
let wrapped = json!({"payload": {"model": "m", "input": []}});
assert_eq!(
normalize_before_provider_request_response(wrapped),
Some(json!({"model": "m", "input": []}))
);
assert_eq!(
normalize_before_provider_request_response(json!({"payload": null})),
None
);
assert_eq!(
normalize_before_provider_request_response(Value::Null),
None
);
assert_eq!(
normalize_before_provider_request_response(json!("not an object")),
None
);
}
#[derive(Debug)]
struct NoopProvider;
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for NoopProvider {
fn name(&self) -> &str {
"test-provider"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"test-model"
}
async fn stream(
&self,
_context: &Context<'_>,
_options: &StreamOptions,
) -> crate::error::Result<
Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
> {
Ok(Box::pin(futures::stream::empty()))
}
}
#[derive(Debug)]
struct IdleCommandProvider;
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for IdleCommandProvider {
fn name(&self) -> &str {
"test-provider"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"test-model"
}
async fn stream(
&self,
_context: &Context<'_>,
_options: &StreamOptions,
) -> crate::error::Result<
Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
> {
let partial = AssistantMessage {
content: Vec::new(),
api: self.api().to_string(),
provider: self.name().to_string(),
model: self.model_id().to_string(),
usage: Usage::default(),
stop_reason: StopReason::Stop,
stop_details: None,
error_message: None,
timestamp: 0,
};
let done = AssistantMessage {
content: vec![ContentBlock::Text(TextContent::new(
"resumed-response-0".to_string(),
))],
api: self.api().to_string(),
provider: self.name().to_string(),
model: self.model_id().to_string(),
usage: Usage::default(),
stop_reason: StopReason::Stop,
stop_details: None,
error_message: None,
timestamp: 0,
};
Ok(Box::pin(futures::stream::iter(vec![
Ok(StreamEvent::Start { partial }),
Ok(StreamEvent::Done {
reason: StopReason::Stop,
message: done,
}),
])))
}
}
#[derive(Debug)]
struct CountingTool {
calls: Arc<AtomicUsize>,
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Tool for CountingTool {
fn name(&self) -> &str {
"count_tool"
}
fn label(&self) -> &str {
"count_tool"
}
fn description(&self) -> &str {
"counting tool"
}
fn parameters(&self) -> serde_json::Value {
json!({ "type": "object" })
}
async fn execute(
&self,
_tool_call_id: &str,
_input: serde_json::Value,
_on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
) -> Result<ToolOutput> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent::new("ok"))],
details: None,
is_error: false,
})
}
}
#[derive(Debug)]
struct ToolUseProvider {
stream_calls: AtomicUsize,
}
impl ToolUseProvider {
const fn new() -> Self {
Self {
stream_calls: AtomicUsize::new(0),
}
}
fn assistant_message(
&self,
stop_reason: StopReason,
content: Vec<ContentBlock>,
) -> AssistantMessage {
AssistantMessage {
content,
api: self.api().to_string(),
provider: self.name().to_string(),
model: self.model_id().to_string(),
usage: Usage::default(),
stop_reason,
stop_details: None,
error_message: None,
timestamp: 0,
}
}
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for ToolUseProvider {
fn name(&self) -> &str {
"test-provider"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"test-model"
}
async fn stream(
&self,
_context: &Context<'_>,
_options: &StreamOptions,
) -> crate::error::Result<
Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
> {
let call_index = self.stream_calls.fetch_add(1, Ordering::SeqCst);
let partial = self.assistant_message(StopReason::Stop, Vec::new());
let (reason, message) = if call_index == 0 {
let tool_calls = vec![
ToolCall {
id: "call-1".to_string(),
name: "count_tool".to_string(),
arguments: json!({}),
thought_signature: None,
},
ToolCall {
id: "call-2".to_string(),
name: "count_tool".to_string(),
arguments: json!({}),
thought_signature: None,
},
];
(
StopReason::ToolUse,
self.assistant_message(
StopReason::ToolUse,
tool_calls
.into_iter()
.map(ContentBlock::ToolCall)
.collect::<Vec<_>>(),
),
)
} else {
(
StopReason::Stop,
self.assistant_message(
StopReason::Stop,
vec![ContentBlock::Text(TextContent::new("done"))],
),
)
};
let events = vec![
Ok(StreamEvent::Start { partial }),
Ok(StreamEvent::Done { reason, message }),
];
Ok(Box::pin(futures::stream::iter(events)))
}
}
#[test]
fn agent_session_enable_extensions_registers_extension_tools() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("ext.mjs");
std::fs::write(
&entry_path,
r#"
export default function init(pi) {
pi.registerTool({
name: "hello_tool",
label: "hello_tool",
description: "test tool",
parameters: { type: "object", properties: { name: { type: "string" } } },
execute: async (_callId, input, _onUpdate, _abort, ctx) => {
const who = input && input.name ? String(input.name) : "world";
const cwd = ctx && ctx.cwd ? String(ctx.cwd) : "";
return {
content: [{ type: "text", text: `hello ${who}` }],
details: { from: "extension", cwd: cwd },
isError: false
};
}
});
}
"#,
)
.expect("write extension entry");
let provider = Arc::new(NoopProvider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[entry_path])
.await
.expect("enable extensions");
let registry = agent_session.agent.tools.snapshot();
let tool = registry.get("hello_tool").expect("hello_tool registered");
let output = tool
.execute("call-1", json!({ "name": "pi" }), None)
.await
.expect("execute tool");
assert!(!output.is_error);
assert!(
matches!(output.content.as_slice(), [ContentBlock::Text(_)]),
"Expected single text content block, got {:?}",
output.content
);
let [ContentBlock::Text(text)] = output.content.as_slice() else {
return;
};
assert_eq!(text.text, "hello pi");
let details = output.details.expect("details present");
assert_eq!(
details.get("from").and_then(serde_json::Value::as_str),
Some("extension")
);
});
}
#[test]
fn agent_session_enable_extensions_with_no_entries_clears_and_is_noop() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let provider = Arc::new(NoopProvider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
let dummy_manager = ExtensionManager::new();
agent_session.extensions = Some(crate::extensions::ExtensionRegion::new(dummy_manager.clone()));
agent_session.agent.extensions = Some(dummy_manager.clone());
agent_session.extension_queue_modes = Some(Arc::new(std::sync::Mutex::new(ExtensionQueueModeState::new(
QueueMode::OneAtATime,
QueueMode::OneAtATime,
))));
agent_session.extension_injected_queue = Some(Arc::new(std::sync::Mutex::new(ExtensionInjectedQueue::default())));
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[])
.await
.expect("empty extension list should be a no-op");
assert!(
agent_session.extensions.is_none(),
"no extension region should be created (and existing should be cleared) for an empty extension list"
);
assert!(
agent_session.agent.extensions.is_none(),
"agent should not report extensions active when nothing was requested"
);
assert!(
agent_session.extension_queue_modes.is_none(),
"empty extension list should clear queue mode mirrors"
);
assert!(
agent_session.extension_injected_queue.is_none(),
"empty extension list should clear injected extension queues"
);
});
}
#[test]
fn agent_session_enable_extensions_rejects_mixed_js_and_native_entries() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let js_entry = temp_dir.path().join("ext.mjs");
let native_entry = temp_dir.path().join("ext.native.json");
std::fs::write(
&js_entry,
r"
export default function init(_pi) {}
",
)
.expect("write js extension entry");
std::fs::write(&native_entry, "{}").expect("write native extension descriptor");
let provider = Arc::new(NoopProvider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
let err = agent_session
.enable_extensions(&[], temp_dir.path(), None, &[js_entry, native_entry])
.await
.expect_err("mixed extension runtimes should be rejected");
let msg = err.to_string();
assert!(
msg.contains("Mixed extension runtimes are not supported"),
"unexpected mixed-runtime error message: {msg}"
);
});
}
#[test]
fn extension_send_message_persists_custom_message_entry_when_idle() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("ext.mjs");
std::fs::write(
&entry_path,
r#"
export default function init(pi) {
pi.registerTool({
name: "emit_message",
label: "emit_message",
description: "emit a custom message",
parameters: { type: "object" },
execute: async () => {
pi.sendMessage({
customType: "note",
content: "hello",
display: true,
details: { from: "test" }
}, {});
return { content: [{ type: "text", text: "ok" }], isError: false };
}
});
}
"#,
)
.expect("write extension entry");
let provider = Arc::new(NoopProvider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session = AgentSession::new(
agent,
Arc::clone(&session),
false,
ResolvedCompactionSettings::default(),
);
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[entry_path])
.await
.expect("enable extensions");
let registry = agent_session.agent.tools.snapshot();
let tool = registry
.get("emit_message")
.expect("emit_message registered");
let _ = tool
.execute("call-1", json!({}), None)
.await
.expect("execute tool");
let cx = crate::agent_cx::AgentCx::for_request();
let session_guard = session.lock(cx.cx()).await.expect("lock session");
let messages = session_guard.to_messages_for_current_path();
assert!(
messages.iter().any(|msg| {
matches!(
msg,
Message::Custom(CustomMessage { custom_type, content, display, details, .. })
if custom_type == "note"
&& content == "hello"
&& *display
&& details
.as_ref()
.and_then(|v| v.get("from").and_then(Value::as_str))
.is_some_and(|from| from.eq("test"))
)
}),
"expected custom message to be persisted, got {messages:?}"
);
});
}
#[test]
fn extension_send_message_persists_custom_message_entry_when_idle_after_await() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("ext.mjs");
std::fs::write(
&entry_path,
r#"
export default function init(pi) {
pi.registerTool({
name: "emit_message",
label: "emit_message",
description: "emit a custom message",
parameters: { type: "object" },
execute: async () => {
await Promise.resolve();
pi.sendMessage({
customType: "note",
content: "hello-after-await",
display: true,
details: { from: "test" }
}, {});
return { content: [{ type: "text", text: "ok" }], isError: false };
}
});
}
"#,
)
.expect("write extension entry");
let provider = Arc::new(NoopProvider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session = AgentSession::new(
agent,
Arc::clone(&session),
false,
ResolvedCompactionSettings::default(),
);
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[entry_path])
.await
.expect("enable extensions");
let registry = agent_session.agent.tools.snapshot();
let tool = registry
.get("emit_message")
.expect("emit_message registered");
let _ = tool
.execute("call-1", json!({}), None)
.await
.expect("execute tool");
let cx = crate::agent_cx::AgentCx::for_request();
let session_guard = session.lock(cx.cx()).await.expect("lock session");
let messages = session_guard.to_messages_for_current_path();
assert!(
messages.iter().any(|msg| {
matches!(
msg,
Message::Custom(CustomMessage { custom_type, content, display, details, .. })
if custom_type == "note"
&& content == "hello-after-await"
&& *display
&& details
.as_ref()
.and_then(|v| v.get("from").and_then(Value::as_str))
.is_some_and(|from| from.eq("test"))
)
}),
"expected custom message to be persisted, got {messages:?}"
);
});
}
#[test]
fn agent_host_actions_send_message_inherits_cancelled_context_when_locked() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let session = Arc::new(Mutex::new(Session::in_memory()));
let session_action_admission = SessionActionAdmissionGate::default();
let origin = session_action_admission.capture_origin();
let actions = AgentSessionHostActions {
session: Arc::clone(&session),
injected: Arc::new(StdMutex::new(ExtensionInjectedQueue::default())),
is_streaming: Arc::new(AtomicBool::new(false)),
is_turn_active: Arc::new(AtomicBool::new(false)),
pending_idle_actions: Arc::new(StdMutex::new(VecDeque::new())),
ai_completion: Arc::new(StdMutex::new(ExtensionAiCompletionHostState {
provider: Arc::new(NoopProvider),
stream_options: StreamOptions::default(),
models: Vec::new(),
})),
provider_admission: ProviderAdmissionGate::default(),
session_action_admission,
};
let hold_cx = crate::agent_cx::AgentCx::for_request();
let held_guard = session.lock(hold_cx.cx()).await.expect("lock session");
let ambient_cx = asupersync::Cx::for_testing();
ambient_cx.set_cancel_requested(true);
let _current = asupersync::Cx::set_current(Some(ambient_cx));
let inner = asupersync::time::timeout(
asupersync::time::wall_now(),
Duration::from_millis(100),
actions.send_message(
ExtensionSendMessage {
extension_id: Some("ext".to_string()),
custom_type: "note".to_string(),
content: "blocked".to_string(),
display: false,
details: None,
deliver_as: Some(ExtensionDeliverAs::NextTurn),
trigger_turn: false,
},
Some(origin),
),
)
.await;
let outcome = inner.expect("cancelled helper should finish before timeout");
let err = outcome.expect_err("session append should fail under inherited cancellation");
assert!(
err.to_string().contains("mutex lock cancelled"),
"unexpected error: {err}"
);
drop(held_guard);
let cx = crate::agent_cx::AgentCx::for_request();
let guard = session.lock(cx.cx()).await.expect("lock session");
assert!(
guard.to_messages_for_current_path().is_empty(),
"cancelled send_message should not append a message"
);
});
}
#[test]
fn agent_host_actions_reject_message_delayed_across_session_transition() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
let runtime_handle = runtime.handle();
runtime.block_on(async move {
let session = Arc::new(Mutex::new(Session::in_memory()));
let session_action_admission = SessionActionAdmissionGate::default();
let actions = AgentSessionHostActions {
session: Arc::clone(&session),
injected: Arc::new(StdMutex::new(ExtensionInjectedQueue::default())),
is_streaming: Arc::new(AtomicBool::new(false)),
is_turn_active: Arc::new(AtomicBool::new(false)),
pending_idle_actions: Arc::new(StdMutex::new(VecDeque::new())),
ai_completion: Arc::new(StdMutex::new(ExtensionAiCompletionHostState {
provider: Arc::new(NoopProvider),
stream_options: StreamOptions::default(),
models: Vec::new(),
})),
provider_admission: ProviderAdmissionGate::default(),
session_action_admission: session_action_admission.clone(),
};
let transition_cx = crate::agent_cx::AgentCx::for_request();
let transition_permit = session_action_admission
.acquire(transition_cx.cx())
.await
.expect("transition admission");
let source_origin = session_action_admission.capture_origin();
let delayed_actions = actions.clone();
let hostcall = runtime_handle.spawn(async move {
delayed_actions
.send_message(
ExtensionSendMessage {
extension_id: Some("ext".to_string()),
custom_type: "note".to_string(),
content: "must wait for transition".to_string(),
display: false,
details: None,
deliver_as: Some(ExtensionDeliverAs::NextTurn),
trigger_turn: false,
},
Some(source_origin),
)
.await
});
wait_for_session_action_generation_capture(&session_action_admission).await;
{
let cx = crate::agent_cx::AgentCx::for_request();
let guard = session.lock(cx.cx()).await.expect("session lock");
assert!(
guard.to_messages_for_current_path().is_empty(),
"host action must not mutate the Session while transition admission is held"
);
}
session_action_admission.advance_generation();
drop(transition_permit);
let err = hostcall
.await
.expect_err("action from the previous Session generation must be rejected");
assert!(
err.to_string().contains("active Session changed"),
"unexpected delayed-action error: {err}"
);
actions
.send_message(
ExtensionSendMessage {
extension_id: Some("ext".to_string()),
custom_type: "note".to_string(),
content: "belongs to the new session".to_string(),
display: false,
details: None,
deliver_as: Some(ExtensionDeliverAs::NextTurn),
trigger_turn: false,
},
Some(session_action_admission.capture_origin()),
)
.await
.expect("new-generation action");
let cx = crate::agent_cx::AgentCx::for_request();
let guard = session.lock(cx.cx()).await.expect("session lock");
let messages = guard.to_messages_for_current_path();
let custom_contents = messages
.iter()
.filter_map(|message| match message {
Message::Custom(CustomMessage { content, .. }) => Some(content.as_str()),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(custom_contents, vec!["belongs to the new session"]);
});
}
#[test]
fn session_action_admission_rejects_foreign_origin_at_same_generation() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let gate = SessionActionAdmissionGate::default();
let foreign_gate = SessionActionAdmissionGate::default();
assert_eq!(gate.generation(), foreign_gate.generation());
let Err(err) = gate.acquire_origin(&foreign_gate.capture_origin()).await else {
panic!("a same-counter token from another Session gate must be rejected")
};
assert!(
err.to_string().contains("active Session changed"),
"unexpected foreign-origin error: {err}"
);
let current_permit = gate
.acquire_origin(&gate.capture_origin())
.await
.expect("the gate's own current origin must remain valid");
drop(current_permit);
});
}
#[derive(Debug, Default)]
pub(super) struct PiAiCapturedProviderContext {
pub(super) system_prompt: Option<String>,
pub(super) messages: Vec<Message>,
}
#[derive(Debug)]
pub(super) struct PiAiCaptureProvider {
pub(super) calls: Arc<StdMutex<Vec<PiAiCapturedProviderContext>>>,
}
#[async_trait]
impl Provider for PiAiCaptureProvider {
fn name(&self) -> &'static str {
"capturing-provider"
}
fn api(&self) -> &'static str {
"test-api"
}
fn model_id(&self) -> &'static str {
"capture-model"
}
async fn stream(
&self,
context: &Context<'_>,
_options: &StreamOptions,
) -> crate::error::Result<
std::pin::Pin<
Box<dyn futures::Stream<Item = crate::error::Result<StreamEvent>> + Send>,
>,
> {
self.calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(PiAiCapturedProviderContext {
system_prompt: context.system_prompt.as_ref().map(ToString::to_string),
messages: context.messages.iter().cloned().collect(),
});
let final_message = AssistantMessage {
content: vec![ContentBlock::Text(TextContent::new("captured"))],
api: "test-api".to_string(),
provider: "capturing-provider".to_string(),
model: "capture-model".to_string(),
usage: Usage::default(),
stop_reason: StopReason::Stop,
stop_details: None,
error_message: None,
timestamp: 0,
};
Ok(Box::pin(futures::stream::iter(vec![Ok(
StreamEvent::Done {
reason: StopReason::Stop,
message: final_message,
},
)])))
}
}
#[test]
fn agent_host_actions_complete_ai_streams_configured_provider() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let session = Arc::new(Mutex::new(Session::in_memory()));
let calls = Arc::new(StdMutex::new(Vec::new()));
let provider = Arc::new(PiAiCaptureProvider {
calls: Arc::clone(&calls),
});
let actions = AgentSessionHostActions {
session,
injected: Arc::new(StdMutex::new(ExtensionInjectedQueue::default())),
is_streaming: Arc::new(AtomicBool::new(false)),
is_turn_active: Arc::new(AtomicBool::new(false)),
pending_idle_actions: Arc::new(StdMutex::new(VecDeque::new())),
ai_completion: Arc::new(StdMutex::new(ExtensionAiCompletionHostState {
provider,
stream_options: StreamOptions::default(),
models: vec![json!({
"id": "capture-model",
"provider": "capturing-provider",
"api": "test-api",
})],
})),
provider_admission: ProviderAdmissionGate::default(),
session_action_admission: SessionActionAdmissionGate::default(),
};
let result = actions
.complete_ai(ExtensionAiCompletionRequest {
model: json!({ "id": "capture-model" }),
context: json!({
"systemPrompt": "answer tersely",
"messages": [
{ "role": "user", "content": "ping" }
]
}),
options: json!({ "maxTokens": 16 }),
simple: false,
})
.await
.expect("complete through provider");
assert_eq!(result["text"], json!("captured"));
assert_eq!(result["provider"], json!("capturing-provider"));
assert_eq!(result["api"], json!("test-api"));
let (captured_len, captured_system_prompt, captured_messages) = {
let captured = match calls.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
(
captured.len(),
captured.first().and_then(|call| call.system_prompt.clone()),
captured
.first()
.map(|call| call.messages.clone())
.unwrap_or_default(),
)
};
assert_eq!(captured_len, 1);
assert_eq!(captured_system_prompt.as_deref(), Some("answer tersely"));
assert_eq!(captured_messages.len(), 1);
assert!(
matches!(
captured_messages.first(),
Some(Message::User(UserMessage { content: UserContent::Text(text), .. }))
if text == "ping"
),
"expected user message context, got {captured_messages:?}"
);
let models = actions.list_ai_models().await.expect("list models");
assert_eq!(models[0]["id"], json!("capture-model"));
actions
.provider_admission
.block("test quarantine".to_string());
let blocked = actions
.complete_ai(ExtensionAiCompletionRequest {
model: json!({ "id": "capture-model" }),
context: json!({
"messages": [{ "role": "user", "content": "blocked" }]
}),
options: json!({}),
simple: true,
})
.await
.expect_err("quarantined host action must not call the provider");
assert!(blocked.is_session_persistence());
assert_eq!(
calls.lock().expect("calls lock").len(),
1,
"quarantine must reject before provider.stream"
);
});
}
#[test]
fn agent_host_actions_compact_session_bridges_native_compaction() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let session = Arc::new(Mutex::new(Session::in_memory()));
let calls = Arc::new(StdMutex::new(Vec::new()));
let provider = Arc::new(PiAiCaptureProvider {
calls: Arc::clone(&calls),
});
let actions = AgentSessionHostActions {
session,
injected: Arc::new(StdMutex::new(ExtensionInjectedQueue::default())),
is_streaming: Arc::new(AtomicBool::new(false)),
is_turn_active: Arc::new(AtomicBool::new(false)),
pending_idle_actions: Arc::new(StdMutex::new(VecDeque::new())),
ai_completion: Arc::new(StdMutex::new(ExtensionAiCompletionHostState {
provider,
stream_options: StreamOptions::default(),
models: Vec::new(),
})),
provider_admission: ProviderAdmissionGate::default(),
session_action_admission: SessionActionAdmissionGate::default(),
};
let preparation = json!({
"firstKeptEntryId": "entry-9",
"messagesToSummarize": [
{ "role": "user", "content": "investigate the flaky scheduler test" }
],
"turnPrefixMessages": [],
"isSplitTurn": false,
"tokensBefore": 4200,
"previousSummary": "## Goal\nShip the scheduler fix",
"fileOps": {
"read": ["src/lib.rs"],
"written": [],
"edited": ["src/agent.rs"]
},
"settings": {
"enabled": true,
"contextWindowTokens": 128_000,
"reserveTokens": 10_240,
"keepRecentTokens": 12_800
}
});
let result = actions
.compact_session(preparation)
.await
.expect("compact through session provider");
let summary = result["summary"].as_str().expect("summary string");
assert!(
summary.contains("captured"),
"summary must come from the session provider: {summary}"
);
assert_eq!(result["firstKeptEntryId"], json!("entry-9"));
assert_eq!(result["tokensBefore"], json!(4200));
assert_eq!(result["details"]["readFiles"], json!(["src/lib.rs"]));
assert_eq!(result["details"]["modifiedFiles"], json!(["src/agent.rs"]));
let captured_len = match calls.lock() {
Ok(guard) => guard.len(),
Err(poisoned) => poisoned.into_inner().len(),
};
assert_eq!(captured_len, 1);
let err = actions
.compact_session(json!({ "firstKeptEntryId": "" }))
.await
.expect_err("empty firstKeptEntryId must be rejected");
assert!(
err.to_string()
.contains("`firstKeptEntryId` must be a non-empty string"),
"unexpected error: {err}"
);
let err = actions
.compact_session(json!("not-an-object"))
.await
.expect_err("non-object preparation must be rejected");
assert!(
err.to_string()
.contains("compaction preparation must be a JSON object"),
"unexpected error: {err}"
);
});
}
#[test]
fn native_compact_request_sanitizer_enforces_contract() {
for key in ["apiKey", "api_key", "Authorization", "headers", "token"] {
let request = json!({
"model": "m-1",
"input": [],
(*key): "secret",
});
let err = sanitize_native_compact_request(&request, "m-1")
.expect_err("credential key must be rejected");
assert!(
err.to_string().contains("credential material"),
"unexpected error for {key}: {err}"
);
}
let err = sanitize_native_compact_request(&json!({ "model": "other", "input": [] }), "m-1")
.expect_err("model mismatch must be rejected");
assert!(err.to_string().contains("does not match the session model"));
let err = sanitize_native_compact_request(&json!({ "input": [] }), "m-1")
.expect_err("missing model must be rejected");
assert!(err.to_string().contains("string `model`"));
let err = sanitize_native_compact_request(&json!({ "model": "m-1" }), "m-1")
.expect_err("missing input must be rejected");
assert!(err.to_string().contains("`input` array"));
assert!(sanitize_native_compact_request(&json!("nope"), "m-1").is_err());
let sanitized = sanitize_native_compact_request(
&json!({
"model": "m-1",
"instructions": "compact",
"input": [{ "type": "message" }],
"tools": [],
"parallel_tool_calls": true,
"reasoning": { "effort": "low" },
"service_tier": "default",
"prompt_cache_key": "cache-1",
"text": { "verbosity": "low" },
"stream": true,
"store": false,
"extension_junk": 42,
}),
"m-1",
)
.expect("valid request sanitizes");
let sanitized = sanitized.as_object().expect("object");
assert_eq!(sanitized.get("model"), Some(&json!("m-1")));
assert_eq!(sanitized.get("prompt_cache_key"), Some(&json!("cache-1")));
assert!(sanitized.contains_key("reasoning"));
assert!(!sanitized.contains_key("stream"), "stream must be dropped");
assert!(!sanitized.contains_key("store"), "store must be dropped");
assert!(!sanitized.contains_key("extension_junk"));
}
#[test]
fn native_compact_result_shaping_round_trips_window() {
let response = json!({
"id": "resp_9",
"created_at": 1_723_000_000,
"output": [
{ "type": "message", "role": "user",
"content": [{ "type": "input_text", "text": "opaque item" }] },
{ "type": "message", "role": "assistant", "status": "completed",
"content": [{ "type": "output_text", "text": "native summary" }] }
]
});
let shaped = shape_native_compact_result(
&response,
"entry-7",
120_000,
vec!["src/lib.rs".to_string()],
vec!["src/agent.rs".to_string()],
)
.expect("well-formed response shapes");
assert_eq!(shaped["summary"], json!("native summary"));
assert_eq!(shaped["firstKeptEntryId"], json!("entry-7"));
assert_eq!(shaped["tokensBefore"], json!(120_000));
assert_eq!(
shaped["details"]["strategy"],
json!("openai-responses-native")
);
assert_eq!(
shaped["details"]["compactedWindow"], response["output"],
"window must be echoed verbatim"
);
assert_eq!(shaped["details"]["compactResponseId"], json!("resp_9"));
assert_eq!(shaped["details"]["createdAt"], json!(1_723_000_000));
assert_eq!(shaped["details"]["readFiles"], json!(["src/lib.rs"]));
assert_eq!(shaped["details"]["modifiedFiles"], json!(["src/agent.rs"]));
let opaque = json!({
"output": [
{ "type": "message", "role": "user",
"content": [{ "type": "input_text", "text": "window 1" }] }
]
});
let shaped = shape_native_compact_result(&opaque, "entry-1", 10, vec![], vec![])
.expect("opaque response shapes");
assert_eq!(shaped["summary"], json!(NATIVE_COMPACT_FALLBACK_SUMMARY));
assert!(shaped["details"].get("compactResponseId").is_none());
let err = shape_native_compact_result(&json!({ "id": "x" }), "entry-1", 10, vec![], vec![])
.expect_err("missing output must error");
assert!(err.to_string().contains("`output` array"));
}
#[test]
fn agent_host_actions_compact_session_native_requires_responses_api() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let session = Arc::new(Mutex::new(Session::in_memory()));
let calls = Arc::new(StdMutex::new(Vec::new()));
let provider = Arc::new(PiAiCaptureProvider {
calls: Arc::clone(&calls),
});
let actions = AgentSessionHostActions {
session,
injected: Arc::new(StdMutex::new(ExtensionInjectedQueue::default())),
is_streaming: Arc::new(AtomicBool::new(false)),
is_turn_active: Arc::new(AtomicBool::new(false)),
pending_idle_actions: Arc::new(StdMutex::new(VecDeque::new())),
ai_completion: Arc::new(StdMutex::new(ExtensionAiCompletionHostState {
provider,
stream_options: StreamOptions::default(),
models: Vec::new(),
})),
provider_admission: ProviderAdmissionGate::default(),
session_action_admission: SessionActionAdmissionGate::default(),
};
let preparation = json!({
"firstKeptEntryId": "entry-9",
"messagesToSummarize": [],
"turnPrefixMessages": [],
"isSplitTurn": false,
"tokensBefore": 4200,
"previousSummary": null,
"fileOps": { "read": [], "written": [], "edited": [] },
"settings": {
"enabled": true,
"contextWindowTokens": 128_000,
"reserveTokens": 10_240,
"keepRecentTokens": 12_800
}
});
let request = json!({ "model": "capture-model", "input": [] });
let err = actions
.compact_session_native(preparation.clone(), request)
.await
.expect_err("non-responses API must be refused");
assert!(
err.to_string().contains("openai-responses"),
"unexpected error: {err}"
);
let err = actions
.compact_session_native(json!({ "firstKeptEntryId": "" }), json!({}))
.await
.expect_err("malformed preparation must be rejected");
assert!(
err.to_string()
.contains("`firstKeptEntryId` must be a non-empty string"),
"unexpected error: {err}"
);
let captured_len = match calls.lock() {
Ok(guard) => guard.len(),
Err(poisoned) => poisoned.into_inner().len(),
};
assert_eq!(captured_len, 0, "no provider call may happen on refusal");
});
}
#[test]
fn extension_command_send_message_trigger_turn_runs_agent_turn_when_idle() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("ext.mjs");
std::fs::write(
&entry_path,
r#"
export default function init(pi) {
pi.registerCommand("emit-now", {
description: "emit a custom message and trigger a turn",
handler: async () => {
await pi.events("sendMessage", {
message: {
customType: "note",
content: "turn-now",
display: true
},
options: {
deliverAs: "steer",
triggerTurn: true
}
});
return "queued";
}
});
}
"#,
)
.expect("write extension entry");
let provider = Arc::new(IdleCommandProvider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session = AgentSession::new(
agent,
Arc::clone(&session),
false,
ResolvedCompactionSettings::default(),
);
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[entry_path])
.await
.expect("enable extensions");
let value = agent_session
.execute_extension_command("emit-now", "", 5_000, |_| {})
.await
.expect("execute extension command");
assert_eq!(value.as_str(), Some("queued"));
let cx = crate::agent_cx::AgentCx::for_request();
let session_guard = session.lock(cx.cx()).await.expect("lock session");
let messages = session_guard.to_messages_for_current_path();
assert!(
messages.iter().any(|msg| {
matches!(
msg,
Message::Custom(CustomMessage { custom_type, content, .. })
if custom_type == "note" && content == "turn-now"
)
}),
"expected custom message prompt in session, got {messages:?}"
);
assert!(
messages.iter().any(|msg| {
matches!(
msg,
Message::Assistant(assistant)
if assistant.content.iter().any(|block| matches!(
block,
ContentBlock::Text(TextContent { text, .. })
if text.as_str().eq("resumed-response-0")
))
)
}),
"expected assistant response after triggered turn, got {messages:?}"
);
});
}
#[test]
fn agent_extension_session_get_state_reports_agent_runtime_state() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let mut session = Session::in_memory();
session.set_model_header(
Some("test-provider".to_string()),
Some("test-model".to_string()),
Some("high".to_string()),
);
session.append_message(crate::session::SessionMessage::User {
content: UserContent::Text("hello".to_string()),
timestamp: Some(1),
});
let session = Arc::new(Mutex::new(session));
let extension_session = AgentExtensionSession {
handle: SessionHandle(Arc::clone(&session)),
session_action_admission: SessionActionAdmissionGate::default(),
is_streaming: Arc::new(AtomicBool::new(true)),
is_compacting: Arc::new(AtomicBool::new(true)),
queue_modes: Arc::new(StdMutex::new(ExtensionQueueModeState::new(
QueueMode::All,
QueueMode::OneAtATime,
))),
auto_compaction_enabled: true,
};
let state = <AgentExtensionSession as crate::extensions::ExtensionSession>::get_state(
&extension_session,
)
.await;
assert_eq!(state["model"]["provider"], "test-provider");
assert_eq!(state["model"]["id"], "test-model");
assert_eq!(state["thinkingLevel"], "high");
assert_eq!(state["isStreaming"], true);
assert_eq!(state["isCompacting"], true);
assert_eq!(state["steeringMode"], "all");
assert_eq!(state["followUpMode"], "one-at-a-time");
assert_eq!(state["autoCompactionEnabled"], true);
assert_eq!(state["messageCount"], 1);
});
}
#[test]
fn agent_extension_session_get_state_uses_branch_local_model_and_thinking() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let mut session = Session::in_memory();
let root_id = session.append_message(crate::session::SessionMessage::User {
content: UserContent::Text("root".to_string()),
timestamp: Some(1),
});
session.append_model_change("openai".to_string(), "gpt-4o".to_string());
let branch_a_thinking = session.append_thinking_level_change("low".to_string());
session.set_model_header(
Some("openai".to_string()),
Some("gpt-4o".to_string()),
Some("low".to_string()),
);
assert!(session.create_branch_from(&root_id));
session.append_model_change("anthropic".to_string(), "claude-sonnet-4-5".to_string());
session.append_thinking_level_change("high".to_string());
session.set_model_header(
Some("anthropic".to_string()),
Some("claude-sonnet-4-5".to_string()),
Some("high".to_string()),
);
assert!(session.navigate_to(&branch_a_thinking));
let session = Arc::new(Mutex::new(session));
let extension_session = AgentExtensionSession {
handle: SessionHandle(Arc::clone(&session)),
session_action_admission: SessionActionAdmissionGate::default(),
is_streaming: Arc::new(AtomicBool::new(false)),
is_compacting: Arc::new(AtomicBool::new(false)),
queue_modes: Arc::new(StdMutex::new(ExtensionQueueModeState::new(
QueueMode::OneAtATime,
QueueMode::OneAtATime,
))),
auto_compaction_enabled: false,
};
let state = <AgentExtensionSession as crate::extensions::ExtensionSession>::get_state(
&extension_session,
)
.await;
assert_eq!(state["model"]["provider"], "openai");
assert_eq!(state["model"]["id"], "gpt-4o");
assert_eq!(state["thinkingLevel"], "low");
});
}
#[test]
fn agent_extension_session_rejects_mutation_delayed_across_session_transition() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
let runtime_handle = runtime.handle();
runtime.block_on(async move {
let session = Arc::new(Mutex::new(Session::in_memory()));
let session_action_admission = SessionActionAdmissionGate::default();
let extension_session = AgentExtensionSession {
handle: SessionHandle(Arc::clone(&session)),
session_action_admission: session_action_admission.clone(),
is_streaming: Arc::new(AtomicBool::new(false)),
is_compacting: Arc::new(AtomicBool::new(false)),
queue_modes: Arc::new(StdMutex::new(ExtensionQueueModeState::new(
QueueMode::OneAtATime,
QueueMode::OneAtATime,
))),
auto_compaction_enabled: false,
};
let transition_cx = crate::agent_cx::AgentCx::for_request();
let transition_permit = session_action_admission
.acquire(transition_cx.cx())
.await
.expect("transition admission");
let source_origin = session_action_admission.capture_origin();
let delayed_session = extension_session.clone();
let hostcall = runtime_handle.spawn(async move {
<AgentExtensionSession as crate::extensions::ExtensionSession>::append_custom_entry(
&delayed_session,
"stale-note".to_string(),
Some(json!({"owner": "source"})),
Some(source_origin),
)
.await
});
wait_for_session_action_generation_capture(&session_action_admission).await;
let replacement_session_id = {
let cx = crate::agent_cx::AgentCx::for_request();
let mut guard = session.lock(cx.cx()).await.expect("session lock");
*guard = Session::in_memory();
guard.header.id.clone()
};
session_action_admission.advance_generation();
drop(transition_permit);
let err = hostcall
.await
.expect_err("mutation from the previous Session generation must be rejected");
assert!(
err.to_string().contains("active Session changed"),
"unexpected delayed-mutation error: {err}"
);
let cx = crate::agent_cx::AgentCx::for_request();
let guard = session
.lock(cx.cx())
.await
.expect("replacement session lock");
assert_eq!(guard.header.id, replacement_session_id);
assert!(
guard.entries_for_current_path().iter().all(|entry| {
!matches!(
entry,
crate::session::SessionEntry::Custom(custom)
if custom.custom_type == "stale-note"
)
}),
"stale extension mutation crossed into the replacement Session"
);
});
}
#[test]
#[allow(clippy::too_many_lines)]
fn extension_command_rejects_real_js_timer_promise_mutation_delayed_across_transition() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
let runtime_handle = runtime.handle();
runtime.block_on(async move {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("delayed-session-mutation.mjs");
std::fs::write(
&entry_path,
r#"
export default function init(pi) {
pi.registerCommand("append-late", {
description: "append a custom Session entry",
handler: async () => {
await new Promise((resolve, reject) => {
setTimeout(() => {
Promise.resolve()
.then(() => pi.session("appendEntry", {
customType: "stale-js-note",
data: { owner: "source" }
}))
.then(resolve, reject);
}, 0);
});
return "appended";
}
});
}
"#,
)
.expect("write extension entry");
let provider = Arc::new(NoopProvider);
let tools = ToolRegistry::new(&[], temp_dir.path(), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session = AgentSession::new(
agent,
Arc::clone(&session),
false,
ResolvedCompactionSettings::default(),
);
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[entry_path])
.await
.expect("enable transition extension");
let session_action_admission = agent_session.session_action_admission_gate();
let extension_manager = agent_session
.extensions
.as_ref()
.expect("extension region")
.manager()
.clone();
let transition_cx = crate::agent_cx::AgentCx::for_request();
let transition_permit = session_action_admission
.acquire(transition_cx.cx())
.await
.expect("transition admission");
let delayed_manager = extension_manager.clone();
let hostcall = runtime_handle.spawn(async move {
delayed_manager
.execute_command("append-late", "", 15_000)
.await
});
wait_for_session_action_generation_capture(&session_action_admission).await;
let replacement_session_id = {
let cx = crate::agent_cx::AgentCx::for_request();
let mut guard = session.lock(cx.cx()).await.expect("session lock");
*guard = Session::in_memory();
guard.header.id.clone()
};
session_action_admission.advance_generation();
drop(transition_permit);
let err = hostcall
.await
.expect_err("stale real-JS timer/Promise Session mutation must be rejected");
assert!(
err.to_string().contains("active Session changed"),
"unexpected real-JS timer/Promise mutation error: {err}"
);
let cx = crate::agent_cx::AgentCx::for_request();
let guard = session
.lock(cx.cx())
.await
.expect("replacement session lock");
assert_eq!(guard.header.id, replacement_session_id);
assert!(
guard.entries_for_current_path().iter().all(|entry| {
!matches!(
entry,
crate::session::SessionEntry::Custom(custom)
if custom.custom_type == "stale-js-note"
)
}),
"stale real-JS mutation crossed into the replacement Session"
);
drop(guard);
let current_result = extension_manager
.execute_command("append-late", "", 5_000)
.await
.expect("current-generation real-JS mutation");
assert_eq!(current_result, Value::String("appended".to_string()));
let cx = crate::agent_cx::AgentCx::for_request();
let guard = session
.lock(cx.cx())
.await
.expect("replacement session lock after current mutation");
assert_eq!(guard.header.id, replacement_session_id);
assert_eq!(
guard
.entries_for_current_path()
.iter()
.filter(|entry| matches!(
entry,
crate::session::SessionEntry::Custom(custom)
if custom.custom_type == "stale-js-note"
))
.count(),
1,
"the provenance fence must admit the current generation exactly once"
);
});
}
#[test]
#[allow(clippy::too_many_lines)]
fn extension_command_deadline_cancels_blocked_session_hostcalls_and_reuses_shard() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
let runtime_handle = runtime.handle();
runtime.block_on(async move {
for amac_enabled in [false, true] {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("deadline-session-mutation.mjs");
std::fs::write(
&entry_path,
r#"
export default function init(pi) {
const state = {
resolved: 0,
rejected: 0,
finallyCount: 0,
code: null
};
pi.registerCommand("deadline-held", {
description: "attempt two Session mutations under one deadline",
handler: async () => {
try {
await Promise.all([
pi.session("appendEntry", {
customType: "deadline-note",
data: { slot: 1 }
}),
pi.session("appendEntry", {
customType: "deadline-note",
data: { slot: 2 }
})
]);
state.resolved += 1;
} catch (error) {
state.rejected += 1;
state.code = error && error.code ? error.code : null;
throw error;
} finally {
state.finallyCount += 1;
}
}
});
pi.registerCommand("deadline-probe", {
description: "report deadline command settlement counts",
handler: async () => ({ ...state })
});
pi.registerCommand("reuse-probe", {
description: "prove the same shard remains usable",
handler: async () => "reused"
});
}
"#,
)
.expect("write extension entry");
let provider = Arc::new(NoopProvider);
let tools = ToolRegistry::new(&[], temp_dir.path(), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session = AgentSession::new(
agent,
Arc::clone(&session),
false,
ResolvedCompactionSettings::default(),
);
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[entry_path])
.await
.expect("enable deadline extension");
let session_action_admission = agent_session.session_action_admission_gate();
let extension_manager = agent_session
.extensions
.as_ref()
.expect("extension region")
.manager()
.clone();
let js_runtime = extension_manager.js_runtime().expect("QuickJS runtime");
js_runtime
.set_hostcall_amac_enabled_for_tests(amac_enabled)
.await
.expect("set hostcall AMAC mode");
let transition_cx = crate::agent_cx::AgentCx::for_request();
let transition_permit = session_action_admission
.acquire(transition_cx.cx())
.await
.expect("hold Session action admission");
let delayed_manager = extension_manager.clone();
let hostcall = runtime_handle.spawn(async move {
delayed_manager
.execute_command("deadline-held", "", 500)
.await
});
wait_for_session_action_generation_capture(&session_action_admission).await;
let err = asupersync::time::timeout(
asupersync::time::wall_now(),
Duration::from_secs(5),
Box::pin(hostcall),
)
.await
.expect("blocked Session hostcall deadline watchdog expired")
.expect_err("blocked Session hostcalls must obey the root command deadline");
let error_message = err.to_string();
assert!(
error_message.contains("timeout") || error_message.contains("timed out"),
"unexpected deadline error with AMAC {amac_enabled}: {err}"
);
wait_for_session_action_generation_release(&session_action_admission).await;
drop(transition_permit);
let expected_state = json!({
"resolved": 0,
"rejected": 1,
"finallyCount": 1,
"code": "timeout"
});
let first_probe = extension_manager
.execute_command("deadline-probe", "", 5_000)
.await
.expect("first settlement probe");
assert_eq!(
first_probe, expected_state,
"deadline Promise must settle exactly once with AMAC {amac_enabled}"
);
let second_probe = extension_manager
.execute_command("deadline-probe", "", 5_000)
.await
.expect("second settlement probe");
assert_eq!(
second_probe, expected_state,
"late work changed settlement state with AMAC {amac_enabled}"
);
let cx = crate::agent_cx::AgentCx::for_request();
let guard = session.lock(cx.cx()).await.expect("session lock");
assert_eq!(
guard
.entries_for_current_path()
.iter()
.filter(|entry| matches!(
entry,
crate::session::SessionEntry::Custom(custom)
if custom.custom_type == "deadline-note"
))
.count(),
0,
"cancelled hostcalls mutated the Session with AMAC {amac_enabled}"
);
drop(guard);
let reuse = extension_manager
.execute_command("reuse-probe", "", 5_000)
.await
.expect("reuse command on the same shard");
assert_eq!(reuse, Value::String("reused".to_string()));
assert!(
js_runtime.shutdown(Duration::from_secs(1)).await,
"deadline test runtime did not shut down with AMAC {amac_enabled}"
);
}
});
}
#[test]
fn agent_session_set_queue_modes_updates_extension_delivery_state() {
let provider = Arc::new(NoopProvider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
let queue_modes = Arc::new(StdMutex::new(ExtensionQueueModeState::new(
QueueMode::OneAtATime,
QueueMode::OneAtATime,
)));
let injected_queue = Arc::new(StdMutex::new(ExtensionInjectedQueue::new(
QueueMode::OneAtATime,
QueueMode::OneAtATime,
)));
agent_session.extension_queue_modes = Some(Arc::clone(&queue_modes));
agent_session.extension_injected_queue = Some(Arc::clone(&injected_queue));
agent_session.set_queue_modes(QueueMode::All, QueueMode::All);
assert_eq!(
agent_session.agent.queue_modes(),
(QueueMode::All, QueueMode::All)
);
let mirrored = queue_modes.lock().expect("lock queue mode mirror");
assert_eq!(mirrored.steering_mode, QueueMode::All);
assert_eq!(mirrored.follow_up_mode, QueueMode::All);
drop(mirrored);
let queued_follow_up_len = {
let mut queue = injected_queue.lock().expect("lock injected queue");
queue.push_follow_up(Message::User(UserMessage {
content: UserContent::Text("first".to_string()),
timestamp: 0,
}));
queue.push_follow_up(Message::User(UserMessage {
content: UserContent::Text("second".to_string()),
timestamp: 0,
}));
queue.pop_follow_up().len()
};
assert_eq!(
queued_follow_up_len, 2,
"updated queue modes should apply to extension-injected follow-ups"
);
}
#[test]
fn extension_command_send_user_message_runs_agent_turn_when_idle() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("ext.mjs");
std::fs::write(
&entry_path,
r#"
export default function init(pi) {
pi.registerCommand("inject-user", {
description: "inject a user message",
handler: async () => {
await pi.events("sendUserMessage", {
text: "Please review the changes"
});
return "queued";
}
});
}
"#,
)
.expect("write extension entry");
let provider = Arc::new(IdleCommandProvider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session = AgentSession::new(
agent,
Arc::clone(&session),
false,
ResolvedCompactionSettings::default(),
);
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[entry_path])
.await
.expect("enable extensions");
let value = agent_session
.execute_extension_command("inject-user", "", 5_000, |_| {})
.await
.expect("execute extension command");
assert_eq!(value.as_str(), Some("queued"));
let cx = crate::agent_cx::AgentCx::for_request();
let session_guard = session.lock(cx.cx()).await.expect("lock session");
let messages = session_guard.to_messages_for_current_path();
assert!(
messages.iter().any(|msg| {
matches!(
msg,
Message::User(UserMessage {
content: UserContent::Text(text),
..
}) if text == "Please review the changes"
)
}),
"expected injected user message in session, got {messages:?}"
);
assert!(
messages.iter().any(|msg| {
matches!(
msg,
Message::Assistant(assistant)
if assistant.content.iter().any(|block| matches!(
block,
ContentBlock::Text(TextContent { text, .. })
if text.as_str().eq("resumed-response-0")
))
)
}),
"expected assistant response after injected user turn, got {messages:?}"
);
});
}
#[test]
fn send_user_message_steer_skips_remaining_tools() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("ext.mjs");
std::fs::write(
&entry_path,
r#"
export default function init(pi) {
let sent = false;
pi.on("tool_call", async (event) => {
if (sent) return {};
if (Object.is(event && event.toolName, "count_tool")) {
sent = true;
await pi.events("sendUserMessage", {
text: "steer-now",
options: { deliverAs: "steer" }
});
}
return {};
});
}
"#,
)
.expect("write extension entry");
let provider = Arc::new(ToolUseProvider::new());
let calls = Arc::new(AtomicUsize::new(0));
let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
calls: Arc::clone(&calls),
})]);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[entry_path])
.await
.expect("enable extensions");
let _ = agent_session
.run_text("go".to_string(), |_| {})
.await
.expect("run_text");
assert_eq!(calls.load(Ordering::SeqCst), 1);
});
}
struct TruncatingProvider {
stream_calls: AtomicUsize,
truncated_replies: usize,
}
impl TruncatingProvider {
const fn new(truncated_replies: usize) -> Self {
Self {
stream_calls: AtomicUsize::new(0),
truncated_replies,
}
}
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for TruncatingProvider {
fn name(&self) -> &str {
"test-provider"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"test-model"
}
async fn stream(
&self,
_context: &Context<'_>,
_options: &StreamOptions,
) -> crate::error::Result<
Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
> {
let call_index = self.stream_calls.fetch_add(1, Ordering::SeqCst);
let (reason, text) = if call_index < self.truncated_replies {
(StopReason::Length, "```rust\nfn main() {")
} else {
(StopReason::Stop, "}\n```\nAll done.")
};
let message = AssistantMessage {
content: vec![ContentBlock::Text(TextContent::new(text))],
api: "test-api".to_string(),
provider: "test-provider".to_string(),
model: "test-model".to_string(),
usage: Usage::default(),
stop_reason: reason,
stop_details: None,
error_message: None,
timestamp: 0,
};
let partial = AssistantMessage {
content: Vec::new(),
api: "test-api".to_string(),
provider: "test-provider".to_string(),
model: "test-model".to_string(),
usage: Usage::default(),
stop_reason: StopReason::Stop,
stop_details: None,
error_message: None,
timestamp: 0,
};
let events = vec![
Ok(StreamEvent::Start { partial }),
Ok(StreamEvent::Done { reason, message }),
];
Ok(Box::pin(futures::stream::iter(events)))
}
}
#[test]
fn turn_recovery_auto_continues_budget_truncation() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = Arc::new(TruncatingProvider::new(1));
let tools = ToolRegistry::from_tools(vec![]);
let provider_dyn: Arc<dyn Provider> = provider.clone();
let mut agent = Agent::new(provider_dyn, tools, AgentConfig::default());
let final_message = agent.run("write main", |_| {}).await.expect("run");
assert_eq!(final_message.stop_reason, StopReason::Stop);
assert_eq!(provider.stream_calls.load(Ordering::SeqCst), 2);
let nudges = agent
.messages()
.iter()
.filter(|message| {
matches!(message, Message::User(user)
if matches!(&user.content, crate::model::UserContent::Text(text)
if text.contains("auto-continue")))
})
.count();
assert_eq!(nudges, 1, "exactly one nudge recorded");
});
}
#[test]
fn turn_recovery_cap_stops_after_two_continuations() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = Arc::new(TruncatingProvider::new(10));
let tools = ToolRegistry::from_tools(vec![]);
let provider_dyn: Arc<dyn Provider> = provider.clone();
let mut agent = Agent::new(provider_dyn, tools, AgentConfig::default());
let final_message = agent.run("write main", |_| {}).await.expect("run");
assert_eq!(final_message.stop_reason, StopReason::Length);
assert_eq!(
provider.stream_calls.load(Ordering::SeqCst),
3,
"initial call + two capped continuations"
);
});
}
#[test]
fn turn_recovery_off_leaves_truncation_alone() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = Arc::new(TruncatingProvider::new(10));
let tools = ToolRegistry::from_tools(vec![]);
let config = AgentConfig {
turn_recovery: crate::turn_recovery::TurnRecoveryMode::Off,
..Default::default()
};
let provider_dyn: Arc<dyn Provider> = provider.clone();
let mut agent = Agent::new(provider_dyn, tools, config);
let final_message = agent.run("write main", |_| {}).await.expect("run");
assert_eq!(final_message.stop_reason, StopReason::Length);
assert_eq!(provider.stream_calls.load(Ordering::SeqCst), 1);
});
}
#[test]
fn send_user_message_follow_up_does_not_skip_tools() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("ext.mjs");
std::fs::write(
&entry_path,
r#"
export default function init(pi) {
let sent = false;
pi.on("tool_call", async (event) => {
if (sent) return {};
if (Object.is(event && event.toolName, "count_tool")) {
sent = true;
await pi.events("sendUserMessage", {
text: "follow-up",
options: { deliverAs: "followUp" }
});
}
return {};
});
}
"#,
)
.expect("write extension entry");
let provider = Arc::new(ToolUseProvider::new());
let calls = Arc::new(AtomicUsize::new(0));
let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
calls: Arc::clone(&calls),
})]);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[entry_path])
.await
.expect("enable extensions");
let _ = agent_session
.run_text("go".to_string(), |_| {})
.await
.expect("run_text");
assert_eq!(calls.load(Ordering::SeqCst), 2);
});
}
fn test_turn_latency() -> SharedTurnLatencyAccumulator {
Arc::new(StdMutex::new(TurnLatencyAccumulator::started()))
}
#[test]
fn latency_breakdown_reports_component_tail_percentiles() {
let breakdown =
TurnLatencyBreakdown::from_component_samples(250, &[10, 30, 20], &[40, 5], &[2], &[]);
assert_eq!(breakdown.schema, TURN_LATENCY_BREAKDOWN_SCHEMA_V1);
assert_eq!(breakdown.provider_streaming.duration_ms, 60);
assert_eq!(breakdown.provider_streaming.samples, 3);
assert_eq!(breakdown.provider_streaming.tail_percentiles.p50_ms, 20);
assert_eq!(breakdown.provider_streaming.tail_percentiles.p95_ms, 30);
assert_eq!(breakdown.provider_streaming.tail_percentiles.p99_ms, 30);
assert_eq!(breakdown.provider_streaming.tail_percentiles.p999_ms, 30);
assert_eq!(breakdown.local_tools.duration_ms, 45);
assert_eq!(breakdown.extension_hostcalls.duration_ms, 2);
assert_eq!(breakdown.persistence.duration_ms, 0);
assert_eq!(breakdown.dominant_component, "provider_streaming");
}
#[test]
fn latency_breakdown_serializes_without_provider_secrets() {
let breakdown =
TurnLatencyBreakdown::from_component_samples(125, &[100], &[20], &[5], &[0]);
let serialized = serde_json::to_string(&breakdown).expect("serialize latency breakdown");
assert!(serialized.contains(TURN_LATENCY_BREAKDOWN_SCHEMA_V1));
assert!(serialized.contains("providerStreaming"));
assert!(serialized.contains("localTools"));
assert!(serialized.contains("extensionHostcalls"));
assert!(serialized.contains("persistence"));
assert!(!serialized.contains("api_key"));
assert!(!serialized.contains("authorization"));
assert!(!serialized.contains("bearer"));
assert!(!serialized.contains("sk-"));
}
#[test]
fn tool_call_hook_can_block_tool_execution() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("ext.mjs");
std::fs::write(
&entry_path,
r#"
export default function init(pi) {
pi.on("tool_call", async (event) => {
if (Object.is(event && event.toolName, "count_tool")) {
return { block: true, reason: "blocked in test" };
}
return {};
});
}
"#,
)
.expect("write extension entry");
let provider = Arc::new(NoopProvider);
let calls = Arc::new(AtomicUsize::new(0));
let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
calls: Arc::clone(&calls),
})]);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[entry_path])
.await
.expect("enable extensions");
let tool_call = ToolCall {
id: "call-1".to_string(),
name: "count_tool".to_string(),
arguments: json!({}),
thought_signature: None,
};
let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
let (output, is_error) = agent_session
.agent
.execute_tool(tool_call, on_event, test_turn_latency())
.await;
assert!(is_error);
assert!(output.is_error);
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert_eq!(output.details, None);
assert!(
matches!(output.content.as_slice(), [ContentBlock::Text(_)]),
"Expected text output, got {:?}",
output.content
);
if let [ContentBlock::Text(text)] = output.content.as_slice() {
assert_eq!(text.text, "Tool execution blocked: blocked in test");
}
});
}
#[test]
fn tool_call_hook_errors_fail_open() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("ext.mjs");
std::fs::write(
&entry_path,
r#"
export default function init(pi) {
pi.on("tool_call", async (_event) => {
throw new Error("boom");
});
}
"#,
)
.expect("write extension entry");
let provider = Arc::new(NoopProvider);
let calls = Arc::new(AtomicUsize::new(0));
let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
calls: Arc::clone(&calls),
})]);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[entry_path])
.await
.expect("enable extensions");
let tool_call = ToolCall {
id: "call-1".to_string(),
name: "count_tool".to_string(),
arguments: json!({}),
thought_signature: None,
};
let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
let (output, is_error) = agent_session
.agent
.execute_tool(tool_call, on_event, test_turn_latency())
.await;
assert!(!is_error);
assert!(!output.is_error);
assert_eq!(calls.load(Ordering::SeqCst), 1);
});
}
#[test]
fn tool_call_hook_errors_fail_closed_when_configured() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("ext.mjs");
std::fs::write(
&entry_path,
r#"
export default function init(pi) {
pi.on("tool_call", async (_event) => {
throw new Error("boom");
});
}
"#,
)
.expect("write extension entry");
let provider = Arc::new(NoopProvider);
let calls = Arc::new(AtomicUsize::new(0));
let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
calls: Arc::clone(&calls),
})]);
let agent = Agent::new(
provider,
tools,
AgentConfig {
fail_closed_hooks: true,
..AgentConfig::default()
},
);
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[entry_path])
.await
.expect("enable extensions");
let tool_call = ToolCall {
id: "call-1".to_string(),
name: "count_tool".to_string(),
arguments: json!({}),
thought_signature: None,
};
let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
let (output, is_error) = agent_session
.agent
.execute_tool(tool_call, on_event, test_turn_latency())
.await;
assert!(is_error);
assert!(output.is_error);
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert!(
matches!(output.content.as_slice(), [ContentBlock::Text(_)]),
"Expected text output, got {:?}",
output.content
);
let [ContentBlock::Text(text)] = output.content.as_slice() else {
return;
};
assert_eq!(text.text, "Tool execution blocked: extension hook failed");
});
}
#[test]
fn tool_call_hook_absent_allows_tool_execution() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("ext.mjs");
std::fs::write(
&entry_path,
r"
export default function init(_pi) {}
",
)
.expect("write extension entry");
let provider = Arc::new(NoopProvider);
let calls = Arc::new(AtomicUsize::new(0));
let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
calls: Arc::clone(&calls),
})]);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[entry_path])
.await
.expect("enable extensions");
let tool_call = ToolCall {
id: "call-1".to_string(),
name: "count_tool".to_string(),
arguments: json!({}),
thought_signature: None,
};
let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
let (output, is_error) = agent_session
.agent
.execute_tool(tool_call, on_event, test_turn_latency())
.await;
assert!(!is_error);
assert!(!output.is_error);
assert_eq!(calls.load(Ordering::SeqCst), 1);
});
}
#[test]
fn tool_approval_allow_executes_tool() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = Arc::new(NoopProvider);
let calls = Arc::new(AtomicUsize::new(0));
let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
calls: Arc::clone(&calls),
})]);
let approval_calls = Arc::new(AtomicUsize::new(0));
let approval_counter = Arc::clone(&approval_calls);
let agent = Agent::new(
provider,
tools,
AgentConfig {
tool_approval: Some(Arc::new(move |request| {
assert_eq!(request.tool_call_id, "call-1");
assert_eq!(request.tool_name, "count_tool");
approval_counter.fetch_add(1, Ordering::SeqCst);
Box::pin(async { ToolApprovalDecision::Allow })
})),
..AgentConfig::default()
},
);
let session = Arc::new(Mutex::new(Session::in_memory()));
let agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
let tool_call = ToolCall {
id: "call-1".to_string(),
name: "count_tool".to_string(),
arguments: json!({}),
thought_signature: None,
};
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
let events_for_handler = Arc::clone(&events);
let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(move |event| {
if let Ok(mut guard) = events_for_handler.lock() {
guard.push(event);
}
});
let (output, is_error) = agent_session
.agent
.execute_tool(tool_call, on_event, test_turn_latency())
.await;
assert!(!is_error);
assert!(!output.is_error);
assert_eq!(approval_calls.load(Ordering::SeqCst), 1);
assert_eq!(calls.load(Ordering::SeqCst), 1);
let saw_approval_update = events.lock().is_ok_and(|guard| {
guard.iter().any(|event| {
matches!(
event,
AgentEvent::ToolExecutionUpdate {
partial_result,
..
} if partial_result.details.as_ref().is_some_and(|details| {
details["schema"] == TOOL_APPROVAL_STATUS_SCHEMA_V1
&& details["status"] == "approved"
})
)
})
});
assert!(saw_approval_update);
});
}
#[test]
fn tool_approval_deny_blocks_tool_execution() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = Arc::new(NoopProvider);
let calls = Arc::new(AtomicUsize::new(0));
let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
calls: Arc::clone(&calls),
})]);
let agent = Agent::new(
provider,
tools,
AgentConfig {
tool_approval: Some(Arc::new(|request| {
assert_eq!(request.tool_name, "count_tool");
Box::pin(async { ToolApprovalDecision::deny("denied by approval test") })
})),
..AgentConfig::default()
},
);
let session = Arc::new(Mutex::new(Session::in_memory()));
let agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
let tool_call = ToolCall {
id: "call-1".to_string(),
name: "count_tool".to_string(),
arguments: json!({}),
thought_signature: None,
};
let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
let (output, is_error) = agent_session
.agent
.execute_tool(tool_call, on_event, test_turn_latency())
.await;
assert!(is_error);
assert!(output.is_error);
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert_eq!(
output.details.as_ref().unwrap()["schema"],
TOOL_APPROVAL_DENIED_SCHEMA_V1
);
assert!(
matches!(output.content.as_slice(), [ContentBlock::Text(text)] if text
.text
.contains("denied by approval test"))
);
});
}
#[test]
fn ask_mode_with_gating_state_consults_tool_approval_handler() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = Arc::new(NoopProvider);
let calls = Arc::new(AtomicUsize::new(0));
let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
calls: Arc::clone(&calls),
})]);
let approval_calls = Arc::new(AtomicUsize::new(0));
let approval_counter = Arc::clone(&approval_calls);
let agent = Agent::new(
provider,
tools,
AgentConfig {
approval_state: Some(crate::approval::ApprovalState::new(
crate::approval::ApprovalMode::AlwaysAsk,
false,
Vec::new(),
)),
tool_approval: Some(Arc::new(move |request| {
assert_eq!(request.tool_name, "count_tool");
approval_counter.fetch_add(1, Ordering::SeqCst);
Box::pin(async { ToolApprovalDecision::Allow })
})),
..AgentConfig::default()
},
);
let session = Arc::new(Mutex::new(Session::in_memory()));
let agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
let tool_call = ToolCall {
id: "call-1".to_string(),
name: "count_tool".to_string(),
arguments: json!({}),
thought_signature: None,
};
let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
let (output, is_error) = agent_session
.agent
.execute_tool(tool_call, on_event, test_turn_latency())
.await;
assert!(!is_error, "an approved gated call must execute");
assert!(!output.is_error);
assert_eq!(approval_calls.load(Ordering::SeqCst), 1);
assert_eq!(calls.load(Ordering::SeqCst), 1);
});
}
#[test]
fn ask_mode_without_handler_denies_with_explicit_reason() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = Arc::new(NoopProvider);
let calls = Arc::new(AtomicUsize::new(0));
let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
calls: Arc::clone(&calls),
})]);
let agent = Agent::new(
provider,
tools,
AgentConfig {
approval_state: Some(crate::approval::ApprovalState::new(
crate::approval::ApprovalMode::AlwaysAsk,
false,
Vec::new(),
)),
tool_approval: None,
..AgentConfig::default()
},
);
let session = Arc::new(Mutex::new(Session::in_memory()));
let agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
let tool_call = ToolCall {
id: "call-1".to_string(),
name: "count_tool".to_string(),
arguments: json!({}),
thought_signature: None,
};
let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
let (output, is_error) = agent_session
.agent
.execute_tool(tool_call, on_event, test_turn_latency())
.await;
assert!(is_error);
assert!(output.is_error);
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert!(
matches!(output.content.as_slice(), [ContentBlock::Text(text)] if text
.text
.contains("Approval required"))
);
});
}
#[test]
fn tool_call_hook_returns_empty_allows_tool_execution() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("ext.mjs");
std::fs::write(
&entry_path,
r#"
export default function init(pi) {
pi.on("tool_call", async (_event) => ({}));
}
"#,
)
.expect("write extension entry");
let provider = Arc::new(NoopProvider);
let calls = Arc::new(AtomicUsize::new(0));
let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
calls: Arc::clone(&calls),
})]);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[entry_path])
.await
.expect("enable extensions");
let tool_call = ToolCall {
id: "call-1".to_string(),
name: "count_tool".to_string(),
arguments: json!({}),
thought_signature: None,
};
let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
let (output, is_error) = agent_session
.agent
.execute_tool(tool_call, on_event, test_turn_latency())
.await;
assert!(!is_error);
assert!(!output.is_error);
assert_eq!(calls.load(Ordering::SeqCst), 1);
});
}
#[test]
fn tool_call_hook_can_block_bash_tool_execution() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("ext.mjs");
std::fs::write(
&entry_path,
r#"
export default function init(pi) {
pi.on("tool_call", async (event) => {
const name = event && event.toolName ? String(event.toolName) : "";
if (name === "bash") return { block: true, reason: "blocked bash in test" };
return {};
});
}
"#,
)
.expect("write extension entry");
let provider = Arc::new(NoopProvider);
let tools = ToolRegistry::new(&["bash"], temp_dir.path(), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
agent_session
.enable_extensions(&["bash"], temp_dir.path(), None, &[entry_path])
.await
.expect("enable extensions");
let tool_call = ToolCall {
id: "call-1".to_string(),
name: "bash".to_string(),
arguments: json!({ "command": "printf 'hi' > blocked.txt" }),
thought_signature: None,
};
let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
let (output, is_error) = agent_session
.agent
.execute_tool(tool_call, on_event, test_turn_latency())
.await;
assert!(is_error);
assert!(output.is_error);
assert_eq!(output.details, None);
assert!(
!temp_dir.path().join("blocked.txt").exists(),
"expected bash command not to run when blocked"
);
assert!(
matches!(output.content.as_slice(), [ContentBlock::Text(_)]),
"Expected text output, got {:?}",
output.content
);
if let [ContentBlock::Text(text)] = output.content.as_slice() {
assert_eq!(text.text, "Tool execution blocked: blocked bash in test");
}
});
}
#[test]
fn tool_result_hook_can_modify_tool_output() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("ext.mjs");
std::fs::write(
&entry_path,
r#"
export default function init(pi) {
pi.on("tool_result", async (event) => {
if (Object.is(event && event.toolName, "count_tool")) {
return {
content: [{ type: "text", text: "modified" }],
details: { from: "tool_result" }
};
}
return {};
});
}
"#,
)
.expect("write extension entry");
let provider = Arc::new(NoopProvider);
let calls = Arc::new(AtomicUsize::new(0));
let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
calls: Arc::clone(&calls),
})]);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[entry_path])
.await
.expect("enable extensions");
let tool_call = ToolCall {
id: "call-1".to_string(),
name: "count_tool".to_string(),
arguments: json!({}),
thought_signature: None,
};
let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
let (output, is_error) = agent_session
.agent
.execute_tool(tool_call, on_event, test_turn_latency())
.await;
assert!(!is_error);
assert!(!output.is_error);
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert_eq!(output.details, Some(json!({ "from": "tool_result" })));
assert!(
matches!(output.content.as_slice(), [ContentBlock::Text(_)]),
"Expected text output, got {:?}",
output.content
);
if let [ContentBlock::Text(text)] = output.content.as_slice() {
assert_eq!(text.text, "modified");
}
});
}
#[test]
fn tool_result_hook_can_modify_tool_not_found_error() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("ext.mjs");
std::fs::write(
&entry_path,
r#"
export default function init(pi) {
pi.on("tool_result", async (event) => {
if (Object.is(event && event.toolName, "missing_tool") && event.isError) {
return {
content: [{ type: "text", text: "overridden" }],
details: { handled: true }
};
}
return {};
});
}
"#,
)
.expect("write extension entry");
let provider = Arc::new(NoopProvider);
let tools = ToolRegistry::from_tools(Vec::new());
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[entry_path])
.await
.expect("enable extensions");
let tool_call = ToolCall {
id: "call-1".to_string(),
name: "missing_tool".to_string(),
arguments: json!({}),
thought_signature: None,
};
let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
let (output, is_error) = agent_session
.agent
.execute_tool(tool_call, on_event, test_turn_latency())
.await;
assert!(is_error);
assert!(output.is_error);
assert_eq!(output.details, Some(json!({ "handled": true })));
assert!(
matches!(output.content.as_slice(), [ContentBlock::Text(_)]),
"Expected text output, got {:?}",
output.content
);
if let [ContentBlock::Text(text)] = output.content.as_slice() {
assert_eq!(text.text, "overridden");
}
});
}
#[test]
fn tool_result_hook_errors_fail_open() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("ext.mjs");
std::fs::write(
&entry_path,
r#"
export default function init(pi) {
pi.on("tool_result", async (_event) => {
throw new Error("boom");
});
}
"#,
)
.expect("write extension entry");
let provider = Arc::new(NoopProvider);
let calls = Arc::new(AtomicUsize::new(0));
let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
calls: Arc::clone(&calls),
})]);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[entry_path])
.await
.expect("enable extensions");
let tool_call = ToolCall {
id: "call-1".to_string(),
name: "count_tool".to_string(),
arguments: json!({}),
thought_signature: None,
};
let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
let (output, is_error) = agent_session
.agent
.execute_tool(tool_call, on_event, test_turn_latency())
.await;
assert!(!is_error);
assert!(!output.is_error);
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert_eq!(output.details, None);
assert!(
matches!(output.content.as_slice(), [ContentBlock::Text(_)]),
"Expected text output, got {:?}",
output.content
);
if let [ContentBlock::Text(text)] = output.content.as_slice() {
assert_eq!(text.text, "ok");
}
});
}
#[test]
fn tool_result_hook_runs_on_blocked_tool_call() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("ext.mjs");
std::fs::write(
&entry_path,
r#"
export default function init(pi) {
pi.on("tool_call", async (event) => {
if (Object.is(event && event.toolName, "count_tool")) {
return { block: true, reason: "blocked in test" };
}
return {};
});
pi.on("tool_result", async (event) => {
if (Object.is(event && event.toolName, "count_tool") && event.isError) {
return { content: [{ type: "text", text: "override" }] };
}
return {};
});
}
"#,
)
.expect("write extension entry");
let provider = Arc::new(NoopProvider);
let calls = Arc::new(AtomicUsize::new(0));
let tools = ToolRegistry::from_tools(vec![Box::new(CountingTool {
calls: Arc::clone(&calls),
})]);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[entry_path])
.await
.expect("enable extensions");
let tool_call = ToolCall {
id: "call-1".to_string(),
name: "count_tool".to_string(),
arguments: json!({}),
thought_signature: None,
};
let on_event: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(|_| {});
let (output, is_error) = agent_session
.agent
.execute_tool(tool_call, on_event, test_turn_latency())
.await;
assert!(is_error);
assert!(output.is_error);
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert!(
matches!(output.content.as_slice(), [ContentBlock::Text(_)]),
"Expected text output, got {:?}",
output.content
);
if let [ContentBlock::Text(text)] = output.content.as_slice() {
assert_eq!(text.text, "override");
}
});
}
}
#[cfg(test)]
mod abort_tests {
use super::*;
use crate::session::Session;
use crate::tools::{Tool, ToolOutput, ToolRegistry, ToolUpdate};
use asupersync::runtime::RuntimeBuilder;
use async_trait::async_trait;
use futures::Stream;
use serde_json::json;
use std::path::Path;
use std::pin::Pin;
use std::sync::Mutex as StdMutex;
use std::sync::atomic::AtomicUsize;
use std::task::{Context as TaskContext, Poll};
struct StartThenPending {
start: Option<StreamEvent>,
}
impl Stream for StartThenPending {
type Item = crate::error::Result<StreamEvent>;
fn poll_next(
mut self: Pin<&mut Self>,
_cx: &mut TaskContext<'_>,
) -> Poll<Option<Self::Item>> {
if let Some(event) = self.start.take() {
return Poll::Ready(Some(Ok(event)));
}
Poll::Pending
}
}
#[derive(Debug)]
struct HangingProvider;
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for HangingProvider {
fn name(&self) -> &str {
"test-provider"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"test-model"
}
async fn stream(
&self,
_context: &Context<'_>,
_options: &StreamOptions,
) -> crate::error::Result<
Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
> {
let partial = AssistantMessage {
content: Vec::new(),
api: self.api().to_string(),
provider: self.name().to_string(),
model: self.model_id().to_string(),
usage: Usage::default(),
stop_reason: StopReason::Stop,
stop_details: None,
error_message: None,
timestamp: 0,
};
Ok(Box::pin(StartThenPending {
start: Some(StreamEvent::Start { partial }),
}))
}
}
#[derive(Debug)]
struct CountingProvider {
calls: Arc<std::sync::atomic::AtomicUsize>,
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for CountingProvider {
fn name(&self) -> &str {
"test-provider"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"test-model"
}
async fn stream(
&self,
_context: &Context<'_>,
_options: &StreamOptions,
) -> crate::error::Result<
Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(Box::pin(futures::stream::empty()))
}
}
#[derive(Debug)]
struct PhasedProvider {
pending_calls: usize,
calls: AtomicUsize,
}
impl PhasedProvider {
const fn new(pending_calls: usize) -> Self {
Self {
pending_calls,
calls: AtomicUsize::new(0),
}
}
fn base_message() -> AssistantMessage {
AssistantMessage {
content: Vec::new(),
api: "test-api".to_string(),
provider: "test-provider".to_string(),
model: "test-model".to_string(),
usage: Usage::default(),
stop_reason: StopReason::Stop,
stop_details: None,
error_message: None,
timestamp: 0,
}
}
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for PhasedProvider {
fn name(&self) -> &str {
"test-provider"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"test-model"
}
async fn stream(
&self,
_context: &Context<'_>,
_options: &StreamOptions,
) -> crate::error::Result<
Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
> {
let call = self.calls.fetch_add(1, Ordering::SeqCst);
if call < self.pending_calls {
return Ok(Box::pin(StartThenPending {
start: Some(StreamEvent::Start {
partial: Self::base_message(),
}),
}));
}
let partial = Self::base_message();
let mut done = Self::base_message();
done.content = vec![ContentBlock::Text(TextContent::new(format!(
"resumed-response-{call}"
)))];
Ok(Box::pin(futures::stream::iter(vec![
Ok(StreamEvent::Start { partial }),
Ok(StreamEvent::Done {
reason: StopReason::Stop,
message: done,
}),
])))
}
}
#[derive(Debug)]
struct ToolCallProvider;
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for ToolCallProvider {
fn name(&self) -> &str {
"test-provider"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"test-model"
}
async fn stream(
&self,
_context: &Context<'_>,
_options: &StreamOptions,
) -> crate::error::Result<
Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
> {
let message = AssistantMessage {
content: vec![ContentBlock::ToolCall(ToolCall {
id: "call-1".to_string(),
name: "hanging_tool".to_string(),
arguments: json!({}),
thought_signature: None,
})],
api: "test-api".to_string(),
provider: "test-provider".to_string(),
model: "test-model".to_string(),
usage: Usage::default(),
stop_reason: StopReason::ToolUse,
stop_details: None,
error_message: None,
timestamp: 0,
};
Ok(Box::pin(futures::stream::iter(vec![Ok(
StreamEvent::Done {
reason: StopReason::ToolUse,
message,
},
)])))
}
}
#[derive(Debug)]
struct HangingTool;
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Tool for HangingTool {
fn name(&self) -> &str {
"hanging_tool"
}
fn label(&self) -> &str {
"Hanging Tool"
}
fn description(&self) -> &str {
"Never completes unless aborted by the host"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {},
"additionalProperties": false
})
}
async fn execute(
&self,
_tool_call_id: &str,
_input: serde_json::Value,
_on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
) -> crate::error::Result<ToolOutput> {
futures::future::pending::<()>().await;
unreachable!("hanging tool should be aborted by the agent")
}
}
fn event_tag(event: &AgentEvent) -> &'static str {
match event {
AgentEvent::AgentStart { .. } => "agent_start",
AgentEvent::AgentEnd { error, .. } => {
if error.as_deref() == Some("Aborted") {
"agent_end_aborted"
} else {
"agent_end"
}
}
AgentEvent::TurnStart { .. } => "turn_start",
AgentEvent::TurnEnd { .. } => "turn_end",
AgentEvent::MessageStart { .. } => "message_start",
AgentEvent::MessageUpdate {
assistant_message_event,
..
} => match &assistant_message_event {
AssistantMessageEvent::Error {
reason: StopReason::Aborted,
..
} => "assistant_error_aborted",
AssistantMessageEvent::Done { .. } => "assistant_done",
_ => "assistant_update",
},
AgentEvent::MessageEnd { .. } => "message_end",
AgentEvent::ToolExecutionStart { .. } => "tool_start",
AgentEvent::ToolExecutionUpdate { .. } => "tool_update",
AgentEvent::ToolExecutionEnd { .. } => "tool_end",
AgentEvent::AutoCompactionStart { .. } => "auto_compaction_start",
AgentEvent::AutoCompactionEnd { .. } => "auto_compaction_end",
AgentEvent::AutoRetryStart { .. } => "auto_retry_start",
AgentEvent::AutoRetryEnd { .. } => "auto_retry_end",
AgentEvent::FailoverStart { .. } => "failover_start",
AgentEvent::FailoverEnd { .. } => "failover_end",
AgentEvent::AdvisorNote { .. } => "advisor_note",
AgentEvent::ProviderError { .. } => "provider_error",
AgentEvent::ExtensionError { .. } => "extension_error",
}
}
fn assert_abort_resume_message_sequence(persisted: &[Message]) {
assert_eq!(
persisted.len(),
6,
"expected three user+assistant pairs, got: {persisted:?}"
);
let assistant_states = persisted
.iter()
.filter_map(|message| match message {
Message::Assistant(assistant) => Some(assistant.stop_reason),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
assistant_states,
vec![StopReason::Aborted, StopReason::Aborted, StopReason::Stop]
);
}
fn assert_abort_resume_timeline_boundaries(timeline: &[String]) {
assert!(
timeline
.iter()
.any(|event| event.as_str().eq("run0:agent_end_aborted")),
"missing aborted boundary for first run: {timeline:?}"
);
assert!(
timeline
.iter()
.any(|event| event.as_str().eq("run1:agent_end_aborted")),
"missing aborted boundary for second run: {timeline:?}"
);
assert!(
timeline
.iter()
.any(|event| event.as_str().eq("run2:agent_end")),
"missing successful boundary for resumed run: {timeline:?}"
);
}
#[test]
fn abort_interrupts_in_flight_stream() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
let handle = runtime.handle();
let started = Arc::new(Notify::new());
let started_wait = started.notified();
let (abort_handle, abort_signal) = AbortHandle::new();
let provider = Arc::new(HangingProvider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
let started_tx = Arc::clone(&started);
let join = handle.spawn(async move {
agent_session
.run_text_with_abort("hello".to_string(), Some(abort_signal), move |event| {
if matches!(
event,
AgentEvent::MessageStart {
message: Message::Assistant(_)
}
) {
started_tx.notify_one();
}
})
.await
});
runtime.block_on(async move {
started_wait.await;
abort_handle.abort();
let message = join.await.expect("run_text_with_abort");
assert_eq!(message.stop_reason, StopReason::Aborted);
assert_eq!(message.error_message.as_deref(), Some("Aborted"));
});
}
#[test]
fn ambient_cancellation_interrupts_in_flight_stream() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async move {
let (started_tx, started_rx) = std::sync::mpsc::channel();
let provider = Arc::new(HangingProvider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
let ambient_cx = asupersync::Cx::for_testing();
let cancel_cx = ambient_cx.clone();
let _current = asupersync::Cx::set_current(Some(ambient_cx));
let cancel_thread = std::thread::spawn(move || {
started_rx
.recv_timeout(std::time::Duration::from_secs(1))
.expect("stream start");
cancel_cx.set_cancel_requested(true);
});
let run = agent_session.run_text_with_abort("hello".to_string(), None, move |event| {
if matches!(
event,
AgentEvent::MessageStart {
message: Message::Assistant(_)
}
) {
let _ = started_tx.send(());
}
});
futures::pin_mut!(run);
let message = asupersync::time::timeout(
asupersync::time::wall_now(),
std::time::Duration::from_secs(1),
run,
)
.await
.expect("ambient cancellation should finish before timeout")
.expect("run_text_with_abort");
cancel_thread.join().expect("cancel thread");
assert_eq!(message.stop_reason, StopReason::Aborted);
assert_eq!(message.error_message.as_deref(), Some("Aborted"));
});
}
#[test]
fn abort_before_run_skips_provider_stream_call() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let provider = Arc::new(CountingProvider {
calls: Arc::clone(&calls),
});
let tools = ToolRegistry::new(&[], Path::new("."), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
let (abort_handle, abort_signal) = AbortHandle::new();
abort_handle.abort();
runtime.block_on(async move {
let message = agent_session
.run_text_with_abort("hello".to_string(), Some(abort_signal), |_| {})
.await
.expect("run_text_with_abort");
assert_eq!(message.stop_reason, StopReason::Aborted);
assert_eq!(calls.load(Ordering::SeqCst), 0);
});
}
#[test]
fn abort_then_resume_preserves_session_history() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
let handle = runtime.handle();
runtime.block_on(async move {
let provider = Arc::new(PhasedProvider::new(1));
let tools = ToolRegistry::new(&[], Path::new("."), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session = AgentSession::new(
agent,
Arc::clone(&session),
false,
ResolvedCompactionSettings::default(),
);
let started = Arc::new(Notify::new());
let (abort_handle, abort_signal) = AbortHandle::new();
let started_for_abort = Arc::clone(&started);
let abort_join = handle.spawn(async move {
started_for_abort.notified().await;
abort_handle.abort();
});
let aborted = agent_session
.run_text_with_abort("first".to_string(), Some(abort_signal), {
let started = Arc::clone(&started);
move |event| {
if matches!(
event,
AgentEvent::MessageStart {
message: Message::Assistant(_)
}
) {
started.notify_one();
}
}
})
.await
.expect("first run");
abort_join.await;
assert_eq!(aborted.stop_reason, StopReason::Aborted);
assert_eq!(aborted.error_message.as_deref(), Some("Aborted"));
let resumed = agent_session
.run_text("second".to_string(), |_| {})
.await
.expect("resumed run");
assert_eq!(resumed.stop_reason, StopReason::Stop);
assert!(resumed.error_message.is_none());
let cx = crate::agent_cx::AgentCx::for_request();
let persisted = session
.lock(cx.cx())
.await
.expect("lock session")
.to_messages_for_current_path();
assert_eq!(
persisted.len(),
4,
"unexpected message history after abort+resume: {persisted:?}"
);
assert!(matches!(persisted.first(), Some(Message::User(_))));
assert!(matches!(
persisted.get(1),
Some(Message::Assistant(assistant))
if matches!(assistant.stop_reason, StopReason::Aborted)
));
assert!(matches!(persisted.get(2), Some(Message::User(_))));
assert!(matches!(
persisted.get(3),
Some(Message::Assistant(assistant))
if matches!(assistant.stop_reason, StopReason::Stop)
&& assistant.error_message.is_none()
));
});
}
#[test]
fn repeated_abort_then_resume_has_consistent_timeline_and_state() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
let handle = runtime.handle();
runtime.block_on(async move {
let provider = Arc::new(PhasedProvider::new(2));
let tools = ToolRegistry::new(&[], Path::new("."), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session = AgentSession::new(
agent,
Arc::clone(&session),
false,
ResolvedCompactionSettings::default(),
);
let timeline = Arc::new(StdMutex::new(Vec::<String>::new()));
for run_idx in 0..2 {
let started = Arc::new(Notify::new());
let (abort_handle, abort_signal) = AbortHandle::new();
let started_for_abort = Arc::clone(&started);
let abort_join = handle.spawn(async move {
started_for_abort.notified().await;
abort_handle.abort();
});
let run_timeline = Arc::clone(&timeline);
let aborted = agent_session
.run_text_with_abort(format!("abort-run-{run_idx}"), Some(abort_signal), {
let started = Arc::clone(&started);
move |event| {
if let Ok(mut events) = run_timeline.lock() {
events.push(format!("run{run_idx}:{}", event_tag(&event)));
}
if matches!(
event,
AgentEvent::MessageStart {
message: Message::Assistant(_)
}
) {
started.notify_one();
}
}
})
.await
.expect("aborted run");
abort_join.await;
assert_eq!(
aborted.stop_reason,
StopReason::Aborted,
"run {run_idx} should abort cleanly"
);
}
let run_timeline = Arc::clone(&timeline);
let resumed = agent_session
.run_text("final-run".to_string(), move |event| {
if let Ok(mut events) = run_timeline.lock() {
events.push(format!("run2:{}", event_tag(&event)));
}
})
.await
.expect("final resumed run");
assert_eq!(resumed.stop_reason, StopReason::Stop);
assert!(resumed.error_message.is_none());
let cx = crate::agent_cx::AgentCx::for_request();
let persisted = session
.lock(cx.cx())
.await
.expect("lock session")
.to_messages_for_current_path();
assert_abort_resume_message_sequence(&persisted);
let timeline = timeline
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
assert_abort_resume_timeline_boundaries(&timeline);
});
}
#[test]
fn abort_during_tool_execution_records_aborted_tool_result() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
let handle = runtime.handle();
runtime.block_on(async move {
let provider = Arc::new(ToolCallProvider);
let tools = ToolRegistry::from_tools(vec![Box::new(HangingTool)]);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session = AgentSession::new(
agent,
Arc::clone(&session),
false,
ResolvedCompactionSettings::default(),
);
let tool_started = Arc::new(Notify::new());
let (abort_handle, abort_signal) = AbortHandle::new();
let tool_started_for_abort = Arc::clone(&tool_started);
let abort_join = handle.spawn(async move {
tool_started_for_abort.notified().await;
abort_handle.abort();
});
let result = agent_session
.run_text_with_abort("trigger tool".to_string(), Some(abort_signal), {
let tool_started = Arc::clone(&tool_started);
move |event| {
if matches!(event, AgentEvent::ToolExecutionStart { .. }) {
tool_started.notify_one();
}
}
})
.await
.expect("tool-abort run");
abort_join.await;
assert_eq!(result.stop_reason, StopReason::Aborted);
let cx = crate::agent_cx::AgentCx::for_request();
let persisted = session
.lock(cx.cx())
.await
.expect("lock session")
.to_messages_for_current_path();
let tool_result = persisted
.iter()
.find_map(|message| match message {
Message::ToolResult(result) => Some(result),
_ => None,
})
.expect("expected tool result message");
assert!(tool_result.is_error);
assert!(
tool_result.content.iter().any(|block| {
matches!(
block,
ContentBlock::Text(text) if text.text.contains("Tool execution aborted")
)
}),
"missing aborted tool marker in tool output: {:?}",
tool_result.content
);
let details = tool_result
.details
.as_ref()
.expect("aborted tool result should include structured details");
assert_eq!(details["schema"], TOOL_CANCELLATION_SCHEMA_V1);
assert_eq!(details["status"], "cancelled");
assert_eq!(details["reason"], "abort_signal");
assert_eq!(details["toolName"], "hanging_tool");
assert_eq!(details["cleanup"], "tool_result_recorded_no_success");
});
}
}
#[cfg(test)]
mod turn_event_tests {
use super::*;
use crate::session::Session;
use crate::tools::{Tool, ToolOutput, ToolRegistry, ToolUpdate};
use asupersync::runtime::RuntimeBuilder;
use async_trait::async_trait;
use futures::Stream;
use serde_json::json;
use std::path::Path;
use std::pin::Pin;
use std::sync::atomic::AtomicUsize;
fn assistant_message(text: &str) -> AssistantMessage {
AssistantMessage {
content: vec![ContentBlock::Text(TextContent::new(text))],
api: "test-api".to_string(),
provider: "test-provider".to_string(),
model: "test-model".to_string(),
usage: Usage::default(),
stop_reason: StopReason::Stop,
stop_details: None,
error_message: None,
timestamp: 0,
}
}
struct SingleShotProvider;
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for SingleShotProvider {
fn name(&self) -> &str {
"test-provider"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"test-model"
}
async fn stream(
&self,
_context: &Context<'_>,
_options: &StreamOptions,
) -> crate::error::Result<
Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
> {
let partial = assistant_message("");
let final_message = assistant_message("hello");
let events = vec![
Ok(StreamEvent::Start { partial }),
Ok(StreamEvent::Done {
reason: StopReason::Stop,
message: final_message,
}),
];
Ok(Box::pin(futures::stream::iter(events)))
}
}
struct PauseThenStopProvider {
calls: AtomicUsize,
pause_turns: usize,
pause_has_tool_call: bool,
contexts: std::sync::Mutex<Vec<Vec<Message>>>,
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for PauseThenStopProvider {
fn name(&self) -> &str {
"test-provider"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"test-model"
}
async fn stream(
&self,
context: &Context<'_>,
_options: &StreamOptions,
) -> crate::error::Result<
Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
> {
self.contexts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(context.messages.to_vec());
let call = self.calls.fetch_add(1, Ordering::SeqCst);
let partial = assistant_message("");
let mut done = if call < self.pause_turns {
assistant_message("server tool is still working")
} else {
assistant_message("completed after pause")
};
done.stop_reason = if call < self.pause_turns {
StopReason::PauseTurn
} else {
StopReason::Stop
};
if call < self.pause_turns && self.pause_has_tool_call {
done.content = vec![ContentBlock::ToolCall(ToolCall {
id: "server-tool-1".to_string(),
name: "server_tool".to_string(),
arguments: json!({}),
thought_signature: None,
})];
}
Ok(Box::pin(futures::stream::iter(vec![
Ok(StreamEvent::Start { partial }),
Ok(StreamEvent::Done {
reason: done.stop_reason,
message: done,
}),
])))
}
}
struct StreamSetupErrorProvider;
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for StreamSetupErrorProvider {
fn name(&self) -> &str {
"test-provider"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"test-model"
}
async fn stream(
&self,
_context: &Context<'_>,
_options: &StreamOptions,
) -> crate::error::Result<
Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
> {
Err(Error::api("stream setup failed"))
}
}
#[derive(Debug)]
struct EchoTool;
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Tool for EchoTool {
fn name(&self) -> &str {
"echo_tool"
}
fn label(&self) -> &str {
"echo_tool"
}
fn description(&self) -> &str {
"echo test tool"
}
fn parameters(&self) -> serde_json::Value {
json!({ "type": "object" })
}
async fn execute(
&self,
_tool_call_id: &str,
_input: serde_json::Value,
_on_update: Option<Box<dyn Fn(ToolUpdate) + Send + Sync>>,
) -> Result<ToolOutput> {
Ok(ToolOutput {
content: vec![ContentBlock::Text(TextContent::new("tool-ok"))],
details: None,
is_error: false,
})
}
}
#[derive(Debug)]
struct ToolTurnProvider {
calls: AtomicUsize,
}
impl ToolTurnProvider {
const fn new() -> Self {
Self {
calls: AtomicUsize::new(0),
}
}
fn assistant_message_with(
&self,
stop_reason: StopReason,
content: Vec<ContentBlock>,
) -> AssistantMessage {
AssistantMessage {
content,
api: self.api().to_string(),
provider: self.name().to_string(),
model: self.model_id().to_string(),
usage: Usage::default(),
stop_reason,
stop_details: None,
error_message: None,
timestamp: 0,
}
}
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for ToolTurnProvider {
fn name(&self) -> &str {
"test-provider"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"test-model"
}
async fn stream(
&self,
_context: &Context<'_>,
_options: &StreamOptions,
) -> crate::error::Result<
Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
> {
let call_index = self.calls.fetch_add(1, Ordering::SeqCst);
let partial = self.assistant_message_with(StopReason::Stop, Vec::new());
let done = if call_index == 0 {
self.assistant_message_with(
StopReason::ToolUse,
vec![ContentBlock::ToolCall(ToolCall {
id: "tool-1".to_string(),
name: "echo_tool".to_string(),
arguments: json!({}),
thought_signature: None,
})],
)
} else {
self.assistant_message_with(
StopReason::Stop,
vec![ContentBlock::Text(TextContent::new("final"))],
)
};
Ok(Box::pin(futures::stream::iter(vec![
Ok(StreamEvent::Start { partial }),
Ok(StreamEvent::Done {
reason: done.stop_reason,
message: done,
}),
])))
}
}
#[test]
fn pause_turn_resubmits_the_assistant_response_without_a_synthetic_user_message() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
let provider = Arc::new(PauseThenStopProvider {
calls: AtomicUsize::new(0),
pause_turns: 1,
pause_has_tool_call: false,
contexts: std::sync::Mutex::new(Vec::new()),
});
let provider_for_assertions = Arc::clone(&provider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let mut agent = Agent::new(provider, tools, AgentConfig::default());
runtime.block_on(async move {
let result = agent
.run("search for current news", |_| {})
.await
.expect("pause continuation succeeds");
assert_eq!(result.stop_reason, StopReason::Stop);
assert!(matches!(
result.content.as_slice(),
[ContentBlock::Text(text)] if text.text == "completed after pause"
));
});
assert_eq!(provider_for_assertions.calls.load(Ordering::SeqCst), 2);
let contexts = provider_for_assertions
.contexts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
assert_eq!(contexts.len(), 2);
assert!(matches!(contexts[0].as_slice(), [Message::User(_)]));
assert!(matches!(
contexts[1].as_slice(),
[Message::User(_), Message::Assistant(message)]
if message.stop_reason == StopReason::PauseTurn
&& matches!(message.content.as_slice(), [ContentBlock::Text(text)] if text.text == "server tool is still working")
));
}
#[test]
fn pause_turn_never_executes_its_tool_call_as_a_local_tool() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
let provider = Arc::new(PauseThenStopProvider {
calls: AtomicUsize::new(0),
pause_turns: 1,
pause_has_tool_call: true,
contexts: std::sync::Mutex::new(Vec::new()),
});
let provider_for_assertions = Arc::clone(&provider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let mut agent = Agent::new(provider, tools, AgentConfig::default());
runtime.block_on(async move {
let result = agent
.run("wait for the server tool", |_| {})
.await
.expect("pause continuation succeeds without local tool execution");
assert_eq!(result.stop_reason, StopReason::Stop);
});
let contexts = provider_for_assertions
.contexts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
assert!(matches!(
contexts[1].as_slice(),
[Message::User(_), Message::Assistant(message)]
if matches!(message.content.as_slice(), [ContentBlock::ToolCall(call)] if call.name == "server_tool")
));
}
#[test]
fn pause_turn_continuation_budget_stops_an_unbounded_server_tool_loop() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
let provider = Arc::new(PauseThenStopProvider {
calls: AtomicUsize::new(0),
pause_turns: MAX_PAUSE_TURN_CONTINUATIONS + 1,
pause_has_tool_call: false,
contexts: std::sync::Mutex::new(Vec::new()),
});
let provider_for_assertions = Arc::clone(&provider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let mut agent = Agent::new(provider, tools, AgentConfig::default());
runtime.block_on(async move {
let result = agent
.run("run the server tool", |_| {})
.await
.expect("bounded pause continuation succeeds");
assert_eq!(result.stop_reason, StopReason::PauseTurn);
});
assert_eq!(
provider_for_assertions.calls.load(Ordering::SeqCst),
MAX_PAUSE_TURN_CONTINUATIONS + 1
);
}
#[test]
fn turn_events_wrap_assistant_response() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
let handle = runtime.handle();
let provider = Arc::new(SingleShotProvider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
let events: Arc<std::sync::Mutex<Vec<AgentEvent>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let events_capture = Arc::clone(&events);
let join = handle.spawn(async move {
agent_session
.run_text("hello".to_string(), move |event| {
events_capture
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(event);
})
.await
.expect("run_text")
});
runtime.block_on(async move {
let message = join.await;
assert_eq!(message.stop_reason, StopReason::Stop);
let events = events
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let turn_start_indices = events
.iter()
.enumerate()
.filter_map(|(idx, event)| {
matches!(event, AgentEvent::TurnStart { .. }).then_some(idx)
})
.collect::<Vec<_>>();
let turn_end_indices = events
.iter()
.enumerate()
.filter_map(|(idx, event)| {
matches!(event, AgentEvent::TurnEnd { .. }).then_some(idx)
})
.collect::<Vec<_>>();
assert_eq!(turn_start_indices.len(), 1);
assert_eq!(turn_end_indices.len(), 1);
assert!(turn_start_indices[0] < turn_end_indices[0]);
let assistant_message_end = events
.iter()
.enumerate()
.find_map(|(idx, event)| match event {
AgentEvent::MessageEnd {
message: Message::Assistant(_),
} => Some(idx),
_ => None,
})
.expect("assistant message end");
assert!(assistant_message_end < turn_end_indices[0]);
let (message_is_assistant, tool_results_empty) = {
let turn_end_event = &events[turn_end_indices[0]];
assert!(
matches!(turn_end_event, AgentEvent::TurnEnd { .. }),
"Expected TurnEnd event, got {turn_end_event:?}"
);
match turn_end_event {
AgentEvent::TurnEnd {
message,
tool_results,
..
} => (
matches!(message, Message::Assistant(_)),
tool_results.is_empty(),
),
_ => (false, false),
}
};
drop(events);
assert!(message_is_assistant);
assert!(tool_results_empty);
});
}
#[test]
fn stream_setup_errors_still_emit_turn_end_before_agent_end() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
let handle = runtime.handle();
let provider = Arc::new(StreamSetupErrorProvider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
let events: Arc<std::sync::Mutex<Vec<AgentEvent>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let events_capture = Arc::clone(&events);
let join = handle.spawn(async move {
agent_session
.run_text("hello".to_string(), move |event| {
events_capture
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(event);
})
.await
.expect_err("run_text should fail before streaming starts")
});
runtime.block_on(async move {
let err = join.await;
assert!(
err.to_string().contains("stream setup failed"),
"unexpected error: {err}"
);
let events = events
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let turn_start_idx = events
.iter()
.position(|event| matches!(event, AgentEvent::TurnStart { turn_index: 0, .. }))
.expect("turn start");
let turn_end_idx = events
.iter()
.position(|event| matches!(event, AgentEvent::TurnEnd { turn_index: 0, .. }))
.expect("turn end");
let agent_end_idx = events
.iter()
.position(|event| matches!(event, AgentEvent::AgentEnd { .. }))
.expect("agent end");
assert!(turn_start_idx < turn_end_idx);
assert!(turn_end_idx < agent_end_idx);
let assistant_message_end = events
.iter()
.position(|event| {
matches!(
event,
AgentEvent::MessageEnd {
message: Message::Assistant(_),
}
)
})
.expect("assistant message end");
assert!(assistant_message_end < turn_end_idx);
match &events[turn_end_idx] {
AgentEvent::TurnEnd {
message,
tool_results,
..
} => {
assert!(tool_results.is_empty());
assert!(
matches!(message, Message::Assistant(_)),
"expected assistant message in TurnEnd, got {message:?}"
);
let Message::Assistant(message) = message else {
return;
};
assert_eq!(message.stop_reason, StopReason::Error);
assert_eq!(
message.error_message.as_deref(),
Some("API error: stream setup failed")
);
assert_eq!(message.api, "test-api");
assert_eq!(message.provider, "test-provider");
assert_eq!(message.model, "test-model");
}
other => {
assert!(matches!(other, AgentEvent::TurnEnd { .. }));
return;
}
}
match &events[agent_end_idx] {
AgentEvent::AgentEnd { error, .. } => {
assert_eq!(error.as_deref(), Some("API error: stream setup failed"));
}
other => {
assert!(matches!(other, AgentEvent::AgentEnd { .. }));
}
}
});
}
#[test]
fn turn_events_include_tool_execution_and_tool_result_messages() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
let handle = runtime.handle();
let provider = Arc::new(ToolTurnProvider::new());
let tools = ToolRegistry::from_tools(vec![Box::new(EchoTool)]);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
let events: Arc<std::sync::Mutex<Vec<AgentEvent>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let events_capture = Arc::clone(&events);
let join = handle.spawn(async move {
agent_session
.run_text("hello".to_string(), move |event| {
events_capture
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(event);
})
.await
.expect("run_text")
});
runtime.block_on(async move {
let message = join.await;
assert_eq!(message.stop_reason, StopReason::Stop);
let events = events
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let turn_start_count = events
.iter()
.filter(|event| matches!(event, AgentEvent::TurnStart { .. }))
.count();
let turn_end_count = events
.iter()
.filter(|event| matches!(event, AgentEvent::TurnEnd { .. }))
.count();
assert_eq!(
turn_start_count, 2,
"expected one tool turn and one final turn"
);
assert_eq!(
turn_end_count, 2,
"expected one tool turn and one final turn"
);
let tool_start_idx = events
.iter()
.position(|event| matches!(event, AgentEvent::ToolExecutionStart { .. }))
.expect("tool execution start event");
let tool_end_idx = events
.iter()
.position(|event| matches!(event, AgentEvent::ToolExecutionEnd { .. }))
.expect("tool execution end event");
assert!(tool_start_idx < tool_end_idx);
let first_turn_end_idx = events
.iter()
.position(|event| matches!(event, AgentEvent::TurnEnd { turn_index: 0, .. }))
.expect("first turn end");
assert!(
tool_end_idx < first_turn_end_idx,
"tool execution should complete before first turn end"
);
let first_turn_tool_results = events.iter().find_map(|event| match event {
AgentEvent::TurnEnd {
turn_index,
tool_results,
..
} if turn_index.eq(&0) => Some(tool_results),
_ => None,
});
let first_turn_tool_results =
first_turn_tool_results.expect("expected tool results for first turn");
assert_eq!(first_turn_tool_results.len(), 1);
let first_result = first_turn_tool_results.first().unwrap();
if let Message::ToolResult(tr) = first_result {
assert_eq!(tr.tool_name, "echo_tool");
assert!(!tr.is_error);
} else {
unreachable!("expected Message::ToolResult, got {:?}", first_result);
}
drop(events);
});
}
}
#[derive(Clone)]
struct AgentExtensionSession {
handle: SessionHandle,
session_action_admission: SessionActionAdmissionGate,
is_streaming: Arc<AtomicBool>,
is_compacting: Arc<AtomicBool>,
queue_modes: Arc<StdMutex<ExtensionQueueModeState>>,
auto_compaction_enabled: bool,
}
impl AgentExtensionSession {
async fn acquire_session_action_admission(
&self,
origin: Option<SessionActionOrigin>,
) -> Result<OwnedMutexGuard<()>> {
let origin = origin.ok_or_else(|| {
Error::session("extension Session action is missing trusted task provenance")
})?;
self.session_action_admission.acquire_origin(&origin).await
}
fn current_queue_modes(&self) -> (QueueMode, QueueMode) {
self.queue_modes
.lock()
.map_or((QueueMode::OneAtATime, QueueMode::OneAtATime), |state| {
(state.steering_mode, state.follow_up_mode)
})
}
fn state_fallback(&self) -> Value {
let (steering_mode, follow_up_mode) = self.current_queue_modes();
json!({
"model": null,
"thinkingLevel": "off",
"durabilityMode": "balanced",
"isStreaming": self.is_streaming.load(std::sync::atomic::Ordering::SeqCst),
"isCompacting": self.is_compacting.load(std::sync::atomic::Ordering::SeqCst),
"steeringMode": steering_mode.as_str(),
"followUpMode": follow_up_mode.as_str(),
"sessionFile": null,
"sessionId": "",
"sessionName": null,
"autoCompactionEnabled": self.auto_compaction_enabled,
"messageCount": 0,
"pendingMessageCount": 0,
})
}
}
#[async_trait]
impl crate::extensions::ExtensionSession for AgentExtensionSession {
async fn get_state(&self) -> Value {
let (steering_mode, follow_up_mode) = self.current_queue_modes();
let mut state =
<SessionHandle as crate::extensions::ExtensionSession>::get_state(&self.handle).await;
let Some(object) = state.as_object_mut() else {
return self.state_fallback();
};
object.insert(
"isStreaming".to_string(),
Value::Bool(self.is_streaming.load(std::sync::atomic::Ordering::SeqCst)),
);
object.insert(
"isCompacting".to_string(),
Value::Bool(self.is_compacting.load(std::sync::atomic::Ordering::SeqCst)),
);
object.insert(
"steeringMode".to_string(),
Value::String(steering_mode.as_str().to_string()),
);
object.insert(
"followUpMode".to_string(),
Value::String(follow_up_mode.as_str().to_string()),
);
object.insert(
"autoCompactionEnabled".to_string(),
Value::Bool(self.auto_compaction_enabled),
);
state
}
async fn get_messages(&self) -> Vec<crate::session::SessionMessage> {
<SessionHandle as crate::extensions::ExtensionSession>::get_messages(&self.handle).await
}
async fn get_entries(&self) -> Vec<Value> {
<SessionHandle as crate::extensions::ExtensionSession>::get_entries(&self.handle).await
}
async fn get_branch(&self) -> Vec<Value> {
<SessionHandle as crate::extensions::ExtensionSession>::get_branch(&self.handle).await
}
async fn set_name(
&self,
name: String,
origin: Option<SessionActionOrigin>,
) -> crate::error::Result<()> {
let _session_action_permit = self.acquire_session_action_admission(origin).await?;
<SessionHandle as crate::extensions::ExtensionSession>::set_name(&self.handle, name, None)
.await
}
async fn append_message(
&self,
message: crate::session::SessionMessage,
origin: Option<SessionActionOrigin>,
) -> crate::error::Result<()> {
let _session_action_permit = self.acquire_session_action_admission(origin).await?;
<SessionHandle as crate::extensions::ExtensionSession>::append_message(
&self.handle,
message,
None,
)
.await
}
async fn append_custom_entry(
&self,
custom_type: String,
data: Option<Value>,
origin: Option<SessionActionOrigin>,
) -> crate::error::Result<()> {
let _session_action_permit = self.acquire_session_action_admission(origin).await?;
<SessionHandle as crate::extensions::ExtensionSession>::append_custom_entry(
&self.handle,
custom_type,
data,
None,
)
.await
}
async fn set_model(
&self,
provider: String,
model_id: String,
origin: Option<SessionActionOrigin>,
) -> crate::error::Result<()> {
let _session_action_permit = self.acquire_session_action_admission(origin).await?;
<SessionHandle as crate::extensions::ExtensionSession>::set_model(
&self.handle,
provider,
model_id,
None,
)
.await
}
async fn get_model(&self) -> (Option<String>, Option<String>) {
<SessionHandle as crate::extensions::ExtensionSession>::get_model(&self.handle).await
}
async fn set_thinking_level(
&self,
level: String,
origin: Option<SessionActionOrigin>,
) -> crate::error::Result<()> {
let _session_action_permit = self.acquire_session_action_admission(origin).await?;
<SessionHandle as crate::extensions::ExtensionSession>::set_thinking_level(
&self.handle,
level,
None,
)
.await
}
async fn get_thinking_level(&self) -> Option<String> {
<SessionHandle as crate::extensions::ExtensionSession>::get_thinking_level(&self.handle)
.await
}
async fn set_label(
&self,
target_id: String,
label: Option<String>,
origin: Option<SessionActionOrigin>,
) -> crate::error::Result<()> {
let _session_action_permit = self.acquire_session_action_admission(origin).await?;
<SessionHandle as crate::extensions::ExtensionSession>::set_label(
&self.handle,
target_id,
label,
None,
)
.await
}
}
fn finish_turn_persistence<T>(result: Result<T>, persist_result: Result<()>) -> Result<T> {
match persist_result {
Ok(()) => result,
Err(persist_err) => {
let message = match result {
Ok(_) => persist_err.to_string(),
Err(primary_err) => {
format!("{persist_err}; primary provider/tool turn also failed: {primary_err}")
}
};
Err(Error::session_persistence(message))
}
}
}
#[cfg(test)]
mod finish_turn_persistence_tests {
use super::*;
#[test]
fn persistence_failure_is_terminal_without_hiding_primary_turn_error() {
let err = finish_turn_persistence::<()>(
Err(Error::provider("test", "provider failed")),
Err(Error::session("disk flush failed")),
)
.expect_err("persistence failure must remain terminal");
assert!(err.is_session_persistence());
let message = err.to_string();
assert!(message.contains("disk flush failed"));
assert!(message.contains("provider failed"));
}
}
impl AgentSession {
fn job_session_id_resolver(session: &Arc<Mutex<Session>>) -> crate::jobs::JobSessionIdResolver {
let job_session = Arc::clone(session);
Arc::new(move || {
let job_session = Arc::clone(&job_session);
Box::pin(async move {
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let session = OwnedMutexGuard::lock(job_session, cx.cx()).await.ok()?;
Some(session.header.id.clone())
})
})
}
pub const fn runtime_repair_mode_from_policy_mode(mode: RepairPolicyMode) -> RepairMode {
match mode {
RepairPolicyMode::Off => RepairMode::Off,
RepairPolicyMode::Suggest => RepairMode::Suggest,
RepairPolicyMode::AutoSafe => RepairMode::AutoSafe,
RepairPolicyMode::AutoStrict => RepairMode::AutoStrict,
}
}
#[allow(clippy::too_many_arguments)]
async fn start_js_extension_runtime(
stage: &'static str,
cwd: &std::path::Path,
tools: crate::tools::SharedToolRegistry,
manager: ExtensionManager,
policy: ExtensionPolicy,
repair_mode: RepairMode,
memory_limit_bytes: usize,
) -> Result<ExtensionRuntimeHandle> {
let mut config = PiJsRuntimeConfig {
cwd: cwd.display().to_string(),
repair_mode,
..PiJsRuntimeConfig::default()
};
config.limits.memory_limit_bytes = Some(memory_limit_bytes).filter(|bytes| *bytes > 0);
let runtime =
JsExtensionRuntimeHandle::start_with_policy(config, tools, manager, policy).await?;
tracing::info!(
event = "pi.extension_runtime.engine_decision",
stage,
requested = "quickjs",
selected = "quickjs",
fallback = false,
"Extension runtime engine selected (legacy JS/TS)"
);
Ok(ExtensionRuntimeHandle::Js(runtime))
}
#[allow(clippy::too_many_arguments)]
async fn start_native_extension_runtime(
stage: &'static str,
_cwd: &std::path::Path,
_tools: crate::tools::SharedToolRegistry,
_manager: ExtensionManager,
_policy: ExtensionPolicy,
_repair_mode: RepairMode,
_memory_limit_bytes: usize,
) -> Result<ExtensionRuntimeHandle> {
let runtime = NativeRustExtensionRuntimeHandle::start().await?;
tracing::info!(
event = "pi.extension_runtime.engine_decision",
stage,
requested = "native-rust",
selected = "native-rust",
fallback = false,
"Extension runtime engine selected (native-rust)"
);
Ok(ExtensionRuntimeHandle::NativeRust(runtime))
}
pub fn new(
agent: Agent,
session: Arc<Mutex<Session>>,
save_enabled: bool,
compaction_settings: ResolvedCompactionSettings,
) -> Self {
agent
.tools
.snapshot()
.bind_job_session_resolver(Self::job_session_id_resolver(&session));
let extension_ai_completion = Arc::new(StdMutex::new(ExtensionAiCompletionHostState {
provider: agent.provider(),
stream_options: agent.stream_options().clone(),
models: Vec::new(),
}));
Self {
agent,
session,
save_enabled,
input_source: InputSource::Interactive,
extensions: None,
mcp_manager: None,
extensions_is_streaming: Arc::new(AtomicBool::new(false)),
extensions_is_compacting: Arc::new(AtomicBool::new(false)),
extensions_turn_active: Arc::new(AtomicBool::new(false)),
extensions_pending_idle_actions: Arc::new(StdMutex::new(VecDeque::new())),
extension_queue_modes: None,
extension_injected_queue: None,
extension_ai_completion,
compaction_settings,
compaction_runtime: None,
advisor: None,
runtime_handle: None,
compaction_worker: CompactionWorkerState::new(CompactionQuota::default()),
model_registry: None,
auth_storage: None,
api_key_override: None,
semantic_context_bundle: None,
provider_admission: ProviderAdmissionGate::default(),
session_action_admission: SessionActionAdmissionGate::default(),
}
}
pub const fn set_input_source(&mut self, source: InputSource) {
self.input_source = source;
}
pub fn set_mcp_manager(&mut self, manager: Arc<crate::mcp::McpManager>) {
self.mcp_manager = Some(manager);
}
#[must_use]
pub fn mcp_manager(&self) -> Option<Arc<crate::mcp::McpManager>> {
self.mcp_manager.clone()
}
#[must_use]
pub fn with_runtime_handle(mut self, runtime_handle: RuntimeHandle) -> Self {
self.compaction_runtime = None;
self.runtime_handle = Some(runtime_handle);
self
}
#[must_use]
pub const fn runtime_handle(&self) -> Option<&RuntimeHandle> {
self.runtime_handle.as_ref()
}
#[must_use]
pub fn with_model_registry(mut self, registry: ModelRegistry) -> Self {
self.set_model_registry(registry);
self
}
#[must_use]
pub fn with_auth_storage(mut self, auth: AuthStorage) -> Self {
self.auth_storage = Some(auth);
self
}
pub fn set_model_registry(&mut self, registry: ModelRegistry) {
let provider = self.agent.provider();
let entry = registry.find(provider.name(), provider.model_id());
let keyword_max = entry
.as_ref()
.map_or(self.agent.keyword_max_thinking_level, |entry| {
entry.clamp_thinking_level(crate::model::ThinkingLevel::Max)
});
self.agent.set_keyword_max_thinking_level(keyword_max);
self.agent.set_tool_call_dialect(entry.as_ref().map_or_else(
|| self.agent.tool_call_dialect(),
ModelEntry::tool_call_dialect,
));
self.set_extension_ai_models(pi_ai_model_registry_values(®istry));
if let Some(region) = &self.extensions {
region
.manager()
.set_extension_models(pi_ai_model_registry_values(®istry));
}
self.model_registry = Some(registry);
}
pub fn set_auth_storage(&mut self, auth: AuthStorage) {
self.auth_storage = Some(auth);
}
#[must_use]
pub fn with_api_key_override(mut self, api_key: Option<String>) -> Self {
self.set_api_key_override(api_key);
self
}
pub fn set_api_key_override(&mut self, api_key: Option<String>) {
self.api_key_override = normalize_api_key_opt(api_key);
}
pub fn refresh_extension_completion_host_state(&self) {
let Ok(mut state) = self.extension_ai_completion.lock() else {
tracing::error!("extension completion host state mutex poisoned; keeping stale state");
return;
};
state.provider = self.agent.provider();
state.stream_options = self.agent.stream_options().clone();
}
fn set_extension_ai_models(&self, models: Vec<Value>) {
let Ok(mut state) = self.extension_ai_completion.lock() else {
tracing::error!(
"extension completion host state mutex poisoned; keeping stale model catalog"
);
return;
};
state.models = models;
}
pub fn set_semantic_context_bundle(
&mut self,
injection: Option<SemanticContextBundleInjection>,
) {
self.semantic_context_bundle = injection;
}
pub const fn semantic_context_bundle(&self) -> Option<&SemanticContextBundleInjection> {
self.semantic_context_bundle.as_ref()
}
pub fn set_queue_modes(&mut self, steering_mode: QueueMode, follow_up_mode: QueueMode) {
self.agent.set_queue_modes(steering_mode, follow_up_mode);
if let Some(queue_modes) = &self.extension_queue_modes
&& let Ok(mut state) = queue_modes.lock()
{
state.set_modes(steering_mode, follow_up_mode);
}
if let Some(injected_queue) = &self.extension_injected_queue
&& let Ok(mut queue) = injected_queue.lock()
{
queue.set_modes(steering_mode, follow_up_mode);
}
}
pub const fn set_compaction_context_window(&mut self, context_window_tokens: u32) {
self.compaction_settings.context_window_tokens = context_window_tokens;
}
pub const fn compaction_settings(&self) -> &ResolvedCompactionSettings {
&self.compaction_settings
}
pub(crate) fn provider_admission_gate(&self) -> ProviderAdmissionGate {
self.provider_admission.clone()
}
pub(crate) fn session_action_admission_gate(&self) -> SessionActionAdmissionGate {
self.session_action_admission.clone()
}
pub(crate) fn ensure_provider_reentry_allowed(&self) -> Result<()> {
self.provider_admission.ensure_allowed()
}
fn quarantine_provider_reentry(&self, reason: String) {
self.provider_admission.block(reason);
}
fn clear_provider_reentry_quarantine(&self) {
self.provider_admission.clear();
}
fn prepare_model_selection(
&self,
provider_id: &str,
model_id: &str,
requested_thinking: crate::model::ThinkingLevel,
) -> Result<PreparedModelSelection> {
let active_provider = self.agent.provider();
let requested_matches_active =
active_provider.name().eq(provider_id) && active_provider.model_id().eq(model_id);
let entry = self
.model_registry
.as_ref()
.and_then(|registry| registry.find(provider_id, model_id));
let Some(entry) = entry else {
if !requested_matches_active {
return Err(Error::validation(format!(
"Unable to switch provider/model to {provider_id}/{model_id}"
)));
}
return Ok(PreparedModelSelection {
entry: None,
provider: None,
resolved_key: self.agent.stream_options().api_key.clone(),
provider_id: active_provider.name().to_string(),
model_id: active_provider.model_id().to_string(),
thinking_level: requested_thinking,
});
};
let canonical_provider_id = entry.model.provider.clone();
let canonical_model_id = entry.model.id.clone();
let already_active = active_provider.name().eq(&canonical_provider_id)
&& active_provider.model_id().eq(&canonical_model_id);
let requires_credential = model_requires_configured_credential(&entry);
let resolved_key = self.resolve_stream_api_key_for_model(&entry).or_else(|| {
(already_active && requires_credential)
.then(|| self.agent.stream_options().api_key.clone())
.flatten()
});
if requires_credential && resolved_key.is_none() {
return Err(Error::auth(format!(
"Missing credentials for {provider_id}/{model_id}"
)));
}
let provider = if already_active {
None
} else {
Some(
crate::providers::create_provider(
&entry,
self.extensions.as_ref().map(ExtensionRegion::manager),
)
.map_err(|e| {
Error::validation(format!(
"Unable to switch provider/model to {provider_id}/{model_id}: {e}"
))
})?,
)
};
let thinking_level = entry.clamp_thinking_level(requested_thinking);
Ok(PreparedModelSelection {
entry: Some(entry),
provider,
resolved_key,
provider_id: canonical_provider_id,
model_id: canonical_model_id,
thinking_level,
})
}
fn install_prepared_model_selection(&mut self, prepared: PreparedModelSelection) {
let active_provider = self.agent.provider();
if active_provider.name() != prepared.provider_id
|| active_provider.model_id() != prepared.model_id
{
self.invalidate_background_compaction();
}
let PreparedModelSelection {
entry,
provider,
resolved_key,
provider_id,
model_id,
thinking_level,
} = prepared;
if let Some(provider) = provider {
tracing::info!("Updating agent provider to {provider_id}/{model_id}");
self.agent.set_provider(provider);
}
if let Some(entry) = entry {
self.agent.set_keyword_max_thinking_level(
entry.clamp_thinking_level(crate::model::ThinkingLevel::Max),
);
self.agent.set_tool_call_dialect(entry.tool_call_dialect());
self.agent.set_model_accepts_images(
entry
.model
.input
.contains(&crate::provider::InputType::Image),
);
{
let stream_options = self.agent.stream_options_mut();
stream_options.api_key = resolved_key;
stream_options.headers.clone_from(&entry.headers);
stream_options.max_tokens = Some(entry.model.max_tokens);
stream_options.thinking_level = Some(thinking_level);
}
self.set_compaction_context_window(if entry.model.context_window == 0 {
ResolvedCompactionSettings::default().context_window_tokens
} else {
entry.model.context_window
});
} else {
self.agent.stream_options_mut().thinking_level = Some(thinking_level);
}
if let Some(region) = &self.extensions {
region
.manager()
.set_current_model(Some(provider_id), Some(model_id));
}
self.refresh_extension_completion_host_state();
}
pub async fn set_provider_model(&mut self, provider_id: &str, model_id: &str) -> Result<()> {
self.ensure_provider_reentry_allowed()?;
let current_thinking = self
.agent
.stream_options()
.thinking_level
.unwrap_or_default();
let prepared = self.prepare_model_selection(provider_id, model_id, current_thinking)?;
let target_provider_id = prepared.provider_id.clone();
let target_model_id = prepared.model_id.clone();
let next_thinking = prepared.thinking_level;
let active_provider = self.agent.provider();
let runtime_model_changed = active_provider.name() != target_provider_id
|| active_provider.model_id() != target_model_id;
let cx = crate::agent_cx::AgentCx::for_request();
let save_enabled = self.save_enabled;
let session_store = Arc::clone(&self.session);
let mut session = OwnedMutexGuard::lock(session_store, cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
let mut candidate = session.clone();
let previous_model = candidate.effective_model_for_current_path();
let previous_thinking = candidate
.effective_thinking_level_for_current_path()
.as_deref()
.and_then(|value| value.parse::<crate::model::ThinkingLevel>().ok());
if previous_model
.as_ref()
.map(|(provider, model_id)| (provider.as_str(), model_id.as_str()))
!= Some((target_provider_id.as_str(), target_model_id.as_str()))
{
candidate.append_model_change(target_provider_id.clone(), target_model_id.clone());
}
candidate.set_model_header(
Some(target_provider_id),
Some(target_model_id),
Some(next_thinking.to_string()),
);
if !previous_thinking.is_some_and(|previous| previous.eq(&next_thinking)) {
candidate.append_thinking_level_change(next_thinking.to_string());
}
if runtime_model_changed {
self.invalidate_background_compaction();
}
let _provider_transition = self
.provider_admission
.begin_transition(
"model selection persistence was interrupted before live installation completed"
.to_string(),
cx.cx(),
)
.await?;
if save_enabled
&& let Err(first_err) = candidate.save().await
&& let Err(retry_err) = candidate.save().await
{
let reason = format!(
"model selection persistence remained indeterminate after an idempotent retry: first failure: {first_err}; retry failure: {retry_err}"
);
self.quarantine_provider_reentry(reason.clone());
return Err(Error::session_persistence(reason));
}
*session = candidate;
drop(session);
self.install_prepared_model_selection(prepared);
self.clear_provider_reentry_quarantine();
Ok(())
}
pub async fn set_thinking_level(&mut self, level: crate::model::ThinkingLevel) -> Result<()> {
self.ensure_provider_reentry_allowed()?;
let cx = crate::agent_cx::AgentCx::for_request();
let save_enabled = self.save_enabled;
let session_store = Arc::clone(&self.session);
let mut session = OwnedMutexGuard::lock(session_store, cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
let mut candidate = session.clone();
let (provider_id, model_id) =
candidate
.effective_model_for_current_path()
.unwrap_or_else(|| {
let provider = self.agent.provider();
(provider.name().to_string(), provider.model_id().to_string())
});
let effective_level = self.clamp_thinking_level_for_model(&provider_id, &model_id, level);
let level_string = effective_level.to_string();
let changed = candidate
.effective_thinking_level_for_current_path()
.as_deref()
!= Some(level_string.as_str());
candidate.set_model_header(None, None, Some(level_string.clone()));
if changed {
candidate.append_thinking_level_change(level_string);
}
let _provider_transition = if changed {
Some(
self.provider_admission
.begin_transition(
"thinking-level persistence was interrupted before live installation completed"
.to_string(),
cx.cx(),
)
.await?,
)
} else {
None
};
if save_enabled
&& changed
&& let Err(first_err) = candidate.save().await
&& let Err(retry_err) = candidate.save().await
{
let reason = format!(
"thinking-level persistence remained indeterminate after an idempotent retry: first failure: {first_err}; retry failure: {retry_err}"
);
self.quarantine_provider_reentry(reason.clone());
return Err(Error::session_persistence(reason));
}
*session = candidate;
self.agent.stream_options_mut().thinking_level = Some(effective_level);
self.refresh_extension_completion_host_state();
if changed {
self.clear_provider_reentry_quarantine();
}
Ok(())
}
pub(crate) fn clamp_thinking_level_for_model(
&self,
provider_id: &str,
model_id: &str,
level: crate::model::ThinkingLevel,
) -> crate::model::ThinkingLevel {
self.model_registry
.as_ref()
.and_then(|registry| registry.find(provider_id, model_id))
.map_or(level, |entry| entry.clamp_thinking_level(level))
}
fn resolve_stream_api_key_for_model(&self, entry: &ModelEntry) -> Option<String> {
let normalize = |key_opt: Option<String>| {
key_opt.and_then(|key| {
let trimmed = key.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})
};
normalize(self.api_key_override.clone())
.or_else(|| {
self.auth_storage
.as_ref()
.and_then(|auth| normalize(auth.resolve_api_key(&entry.model.provider, None)))
})
.or_else(|| normalize(entry.api_key.clone()))
}
pub(crate) async fn sync_runtime_selection_from_session_header(&mut self) -> Result<()> {
self.ensure_provider_reentry_allowed()?;
let cx = crate::agent_cx::AgentCx::for_request();
let session_store = Arc::clone(&self.session);
let mut session = OwnedMutexGuard::lock(session_store, cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
let mut candidate = session.clone();
let session_model = candidate.effective_model_for_current_path();
let session_thinking = candidate.effective_thinking_level_for_current_path();
let current_thinking = self
.agent
.stream_options()
.thinking_level
.unwrap_or_default();
let parsed_session_thinking = session_thinking.as_deref().and_then(|raw| {
raw.parse::<crate::model::ThinkingLevel>().map_or_else(
|_| {
tracing::warn!("Ignoring invalid session thinking level: {raw}");
None
},
Some,
)
});
let requested = parsed_session_thinking.unwrap_or(current_thinking);
let (requested_provider_id, requested_model_id) = session_model.unwrap_or_else(|| {
let provider = self.agent.provider();
(provider.name().to_string(), provider.model_id().to_string())
});
let prepared =
self.prepare_model_selection(&requested_provider_id, &requested_model_id, requested)?;
let canonical_provider_id = prepared.provider_id.clone();
let canonical_model_id = prepared.model_id.clone();
let effective = prepared.thinking_level;
let effective_string = effective.to_string();
let active_provider = self.agent.provider();
let runtime_model_changed = active_provider.name() != canonical_provider_id
|| active_provider.model_id() != canonical_model_id;
let previous_model = candidate.effective_model_for_current_path();
let previous_thinking = candidate
.effective_thinking_level_for_current_path()
.as_deref()
.and_then(|value| value.parse::<crate::model::ThinkingLevel>().ok());
let model_changed = previous_model
.as_ref()
.map(|(provider, model_id)| (provider.as_str(), model_id.as_str()))
!= Some((canonical_provider_id.as_str(), canonical_model_id.as_str()));
let thinking_changed = !previous_thinking.is_some_and(|level| level.eq(&effective));
let header_changed = candidate.header.provider.as_deref()
!= Some(canonical_provider_id.as_str())
|| candidate.header.model_id.as_deref() != Some(canonical_model_id.as_str())
|| candidate.header.thinking_level.as_deref() != Some(effective_string.as_str());
if model_changed {
candidate
.append_model_change(canonical_provider_id.clone(), canonical_model_id.clone());
}
candidate.set_model_header(
Some(canonical_provider_id),
Some(canonical_model_id),
Some(effective_string.clone()),
);
if thinking_changed {
candidate.append_thinking_level_change(effective_string);
}
let persist_needed = model_changed || thinking_changed || header_changed;
let save_enabled = self.save_enabled;
if runtime_model_changed {
self.invalidate_background_compaction();
}
let _provider_transition = self
.provider_admission
.begin_transition(
"Session-header synchronization persistence was interrupted before live installation completed"
.to_string(),
cx.cx(),
)
.await?;
if save_enabled
&& persist_needed
&& let Err(first_err) = candidate.save().await
&& let Err(retry_err) = candidate.save().await
{
let reason = format!(
"Session-header synchronization persistence remained indeterminate after an idempotent retry: first failure: {first_err}; retry failure: {retry_err}"
);
self.quarantine_provider_reentry(reason.clone());
return Err(Error::session_persistence(reason));
}
*session = candidate;
drop(session);
self.install_prepared_model_selection(prepared);
self.clear_provider_reentry_quarantine();
Ok(())
}
pub const fn save_enabled(&self) -> bool {
self.save_enabled
}
pub(crate) fn invalidate_background_compaction(&mut self) {
self.compaction_worker.invalidate_for_context_switch();
self.extensions_is_compacting
.store(false, std::sync::atomic::Ordering::SeqCst);
}
async fn current_compaction_origin(&self) -> Result<CompactionOrigin> {
let provider = self.agent.provider();
let cx = crate::agent_cx::AgentCx::for_request();
let session = self
.session
.lock(cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
Ok(CompactionOrigin {
session_id: session.header.id.clone(),
provider_id: provider.name().to_string(),
model_id: provider.model_id().to_string(),
snapshot_leaf_id: session.leaf_id().map(str::to_string),
})
}
async fn compaction_origin_matches_current(
&self,
origin: &CompactionOrigin,
) -> Result<(bool, CompactionOrigin)> {
let provider = self.agent.provider();
let cx = crate::agent_cx::AgentCx::for_request();
let session = self
.session
.lock(cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
let current = CompactionOrigin {
session_id: session.header.id.clone(),
provider_id: provider.name().to_string(),
model_id: provider.model_id().to_string(),
snapshot_leaf_id: session.leaf_id().map(str::to_string),
};
let snapshot_is_ancestor = origin.snapshot_leaf_id.as_ref().map_or_else(
|| session.leaf_id().is_none(),
|snapshot_leaf_id| {
session.entries_for_current_path().iter().any(|entry| {
entry
.base_id()
.is_some_and(|entry_id| entry_id == snapshot_leaf_id)
})
},
);
let matches = origin.session_id == current.session_id
&& origin.provider_id == current.provider_id
&& origin.model_id == current.model_id
&& snapshot_is_ancestor;
Ok((matches, current))
}
pub async fn compact_now(
&mut self,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<()> {
self.ensure_provider_reentry_allowed()?;
self.compact_synchronous(Arc::new(on_event)).await
}
pub async fn execute_extension_command(
&mut self,
command_name: &str,
args: &str,
timeout_ms: u64,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<Value> {
self.execute_extension_command_with_abort(command_name, args, timeout_ms, None, on_event)
.await
}
pub(crate) fn has_pending_extension_idle_actions(&self) -> bool {
self.extensions_pending_idle_actions
.lock()
.map_or(true, |actions| !actions.is_empty())
}
pub async fn execute_extension_command_with_abort(
&mut self,
command_name: &str,
args: &str,
timeout_ms: u64,
abort: Option<AbortSignal>,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<Value> {
self.ensure_provider_reentry_allowed()?;
let manager = self
.extensions
.as_ref()
.map(ExtensionRegion::manager)
.ok_or_else(|| Error::extension("Extensions are disabled"))?
.clone();
let on_event: AgentEventHandler = Arc::new(on_event);
self.run_pending_idle_actions_with_abort(abort.clone(), Arc::clone(&on_event))
.await?;
let command_result = manager
.execute_command(command_name, args, timeout_ms)
.await;
let replay_result = self
.run_pending_idle_actions_with_abort(abort, Arc::clone(&on_event))
.await;
match command_result {
Ok(value) => {
replay_result?;
Ok(value)
}
Err(err) => {
if let Err(replay_err) = replay_result {
tracing::warn!(
"extension command follow-up replay failed after command error: {replay_err}"
);
}
Err(err)
}
}
}
#[allow(clippy::too_many_lines)]
async fn maybe_compact(&mut self, on_event: AgentEventHandler) -> Result<()> {
if !self.compaction_settings.enabled {
return Ok(());
}
if let Some((origin, outcome)) = self.compaction_worker.try_recv_bound().await {
let _terminal_compacting_guard =
AtomicBoolGuard::activate(&self.extensions_is_compacting);
let origin_description = origin.as_ref().map_or_else(
|| "missing origin metadata".to_string(),
|origin| {
format!(
"session {}/{}/{} at leaf {}",
origin.session_id,
origin.provider_id,
origin.model_id,
origin.snapshot_leaf_id.as_deref().unwrap_or("<root>")
)
},
);
let Some(origin) = origin else {
let current_origin = self.current_compaction_origin().await?;
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: true,
will_retry: false,
error_message: Some(format!(
"Discarded background compaction with {origin_description}; current context is session {}/{}/{} at leaf {}",
current_origin.session_id,
current_origin.provider_id,
current_origin.model_id,
current_origin
.snapshot_leaf_id
.as_deref()
.unwrap_or("<root>")
)),
});
return Ok(());
};
let (origin_matches, current_origin) =
self.compaction_origin_matches_current(&origin).await?;
if !origin_matches {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: true,
will_retry: false,
error_message: Some(format!(
"Discarded stale background compaction from {origin_description}; current context is session {}/{}/{} at leaf {}",
current_origin.session_id,
current_origin.provider_id,
current_origin.model_id,
current_origin
.snapshot_leaf_id
.as_deref()
.unwrap_or("<root>")
)),
});
return Ok(());
}
match outcome {
Ok(result) => {
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let provider_admission = match self.provider_admission.acquire(cx.cx()).await {
Ok(provider_admission) => provider_admission,
Err(err) => {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: false,
will_retry: false,
error_message: Some(err.to_string()),
});
return Err(err);
}
};
if let Err(err) = self.provider_admission.ensure_allowed() {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: false,
will_retry: false,
error_message: Some(err.to_string()),
});
return Err(err);
}
self.apply_compaction_result(result, Arc::clone(&on_event), provider_admission)
.await?;
self.compaction_worker.mark_applied_success();
}
Err(e) => {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: false,
will_retry: false,
error_message: Some(e.to_string()),
});
}
}
}
if !self.compaction_worker.can_start() {
return self.force_local_compaction_if_oversized(on_event).await;
}
let active_provider = self.agent.provider();
let origin_provider_id = active_provider.name().to_string();
let origin_model_id = active_provider.model_id().to_string();
let (entries, preparation, origin) = {
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = self
.session
.lock(cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
session.ensure_entry_ids();
let entries = session
.entries_for_current_path()
.into_iter()
.cloned()
.collect::<Vec<_>>();
let prep = compaction::prepare_compaction(&entries, self.compaction_settings.clone());
let origin = CompactionOrigin {
session_id: session.header.id.clone(),
provider_id: origin_provider_id,
model_id: origin_model_id,
snapshot_leaf_id: session.leaf_id().map(str::to_string),
};
(entries, prep, origin)
};
if let Some(prep) = preparation {
let admission = self
.compaction_worker
.admission_decision(Some(&prep), &CompactionAdmissionSignals::default());
if !admission.allowed {
tracing::info!(
reason = admission.reason.as_str(),
tokens_before = admission.tokens_before,
"Background compaction admission denied"
);
return Ok(());
}
on_event(AgentEvent::AutoCompactionStart {
reason: format!("threshold;admission={}", admission.reason.as_str()),
});
let compacting_guard = AtomicBoolGuard::activate(&self.extensions_is_compacting);
let before_outcome = self.dispatch_before_compact(&prep, &entries, None).await;
if before_outcome.cancel {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: true,
will_retry: false,
error_message: None,
});
return Ok(());
}
let (origin_matches, current_origin) =
self.compaction_origin_matches_current(&origin).await?;
if !origin_matches {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: true,
will_retry: false,
error_message: Some(format!(
"Compaction context changed during session_before_compact; snapshot session {}/{}/{} at leaf {}, current session {}/{}/{} at leaf {}",
origin.session_id,
origin.provider_id,
origin.model_id,
origin.snapshot_leaf_id.as_deref().unwrap_or("<root>"),
current_origin.session_id,
current_origin.provider_id,
current_origin.model_id,
current_origin
.snapshot_leaf_id
.as_deref()
.unwrap_or("<root>")
)),
});
return Ok(());
}
if let Some(compaction) = before_outcome.compaction {
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let provider_admission = match self.provider_admission.acquire(cx.cx()).await {
Ok(provider_admission) => provider_admission,
Err(err) => {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: false,
will_retry: false,
error_message: Some(err.to_string()),
});
return Err(err);
}
};
let apply_result = self
.apply_compaction_entry(
compaction.summary.clone(),
compaction.first_kept_entry_id.clone(),
compaction.tokens_before,
compaction.details.clone(),
true,
provider_admission,
)
.await;
let tokens_after = match apply_result {
Ok(tokens_after) => tokens_after,
Err(err) => {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: false,
will_retry: false,
error_message: Some(err.to_string()),
});
return Err(err);
}
};
let result_value = Some(Self::auto_compaction_result_payload(
compaction.summary,
compaction.first_kept_entry_id,
compaction.tokens_before,
tokens_after,
compaction.details,
));
on_event(AgentEvent::AutoCompactionEnd {
result: result_value,
aborted: false,
will_retry: false,
error_message: None,
});
return Ok(());
}
let provider = self.agent.provider();
let credential = self
.agent
.stream_options()
.api_key
.clone()
.unwrap_or_default();
let runtime_handle = match self.compaction_runtime_handle() {
Ok(runtime_handle) => runtime_handle,
Err(e) => {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: false,
will_retry: false,
error_message: Some(e.to_string()),
});
return Ok(());
}
};
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let provider_permit = match self.provider_admission.acquire(cx.cx()).await {
Ok(provider_permit) => provider_permit,
Err(err) => {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: false,
will_retry: false,
error_message: Some(err.to_string()),
});
return Err(err);
}
};
if let Err(err) = self.provider_admission.ensure_allowed() {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: false,
will_retry: false,
error_message: Some(err.to_string()),
});
return Err(err);
}
if let Err(err) = self.compaction_worker.start_for_origin(
origin,
provider_permit,
&runtime_handle,
prep,
provider,
credential,
None,
) {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: false,
will_retry: false,
error_message: Some(err.to_string()),
});
return Err(err);
}
compacting_guard.keep_active();
}
Ok(())
}
async fn force_local_compaction_if_oversized(&self, on_event: AgentEventHandler) -> Result<()> {
let decision = self
.compaction_worker
.admission_decision(None, &CompactionAdmissionSignals::default());
if decision.reason == CompactionAdmissionReason::Pending {
return Ok(());
}
let preparation = {
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = self
.session
.lock(cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
session.ensure_entry_ids();
let entries = session
.entries_for_current_path()
.into_iter()
.cloned()
.collect::<Vec<_>>();
compaction::prepare_compaction(&entries, self.compaction_settings.clone())
};
let Some(prep) = preparation else {
return Ok(());
};
if !compaction::requires_forced_local_compaction(prep.tokens_before, &prep.settings) {
return Ok(());
}
tracing::warn!(
blocked_reason = decision.reason.as_str(),
tokens_before = prep.tokens_before,
context_window_tokens = prep.settings.context_window_tokens,
"Background compaction quota-blocked while context is far over the window; applying deterministic local compaction"
);
on_event(AgentEvent::AutoCompactionStart {
reason: format!("forced_local;blocked={}", decision.reason.as_str()),
});
let _compacting_guard = AtomicBoolGuard::activate(&self.extensions_is_compacting);
let result = compaction::compact_local(prep);
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let provider_admission = match self.provider_admission.acquire(cx.cx()).await {
Ok(provider_admission) => provider_admission,
Err(err) => {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: false,
will_retry: false,
error_message: Some(err.to_string()),
});
return Err(err);
}
};
self.apply_compaction_result(result, Arc::clone(&on_event), provider_admission)
.await
}
fn compaction_runtime_handle(&mut self) -> Result<RuntimeHandle> {
if let Some(runtime_handle) = self.runtime_handle.clone() {
return Ok(runtime_handle);
}
let runtime = RuntimeBuilder::new().build().map_err(|e| {
Error::session(format!("Background compaction runtime init failed: {e}"))
})?;
let runtime_handle = runtime.handle();
self.compaction_runtime = Some(runtime);
self.runtime_handle = Some(runtime_handle.clone());
Ok(runtime_handle)
}
fn auto_compaction_result_payload(
summary: String,
first_kept_entry_id: String,
tokens_before: u64,
tokens_after: u64,
details: Option<Value>,
) -> Value {
let mut payload = serde_json::Map::new();
payload.insert("summary".to_string(), Value::String(summary));
payload.insert(
"firstKeptEntryId".to_string(),
Value::String(first_kept_entry_id),
);
payload.insert("tokensBefore".to_string(), Value::from(tokens_before));
payload.insert("tokensAfter".to_string(), Value::from(tokens_after));
if let Some(details) = details {
payload.insert("details".to_string(), details);
}
Value::Object(payload)
}
async fn apply_compaction_entry(
&self,
summary: String,
first_kept_entry_id: String,
tokens_before: u64,
details: Option<Value>,
from_extension: bool,
provider_admission: OwnedMutexGuard<()>,
) -> Result<u64> {
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = OwnedMutexGuard::lock(Arc::clone(&self.session), cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
self.provider_admission.ensure_allowed()?;
let mut candidate = session.clone();
let from_hook = if from_extension { Some(true) } else { None };
let entry_id = candidate.append_compaction(
summary,
first_kept_entry_id,
tokens_before,
details,
from_hook,
);
if self.save_enabled {
self.provider_admission.block(
"compaction persistence was interrupted before live installation completed"
.to_string(),
);
if let Err(first_err) = candidate.save().await
&& let Err(retry_err) = candidate.save().await
{
let reason = format!(
"compaction persistence remained indeterminate after an idempotent retry: first failure: {first_err}; retry failure: {retry_err}"
);
self.provider_admission.block(reason.clone());
return Err(Error::session_persistence(reason));
}
}
let tokens_after =
compaction::estimate_entries_context_tokens(&candidate.entries_for_current_path());
let compaction_entry = candidate.get_entry(&entry_id).and_then(|entry| {
if let crate::session::SessionEntry::Compaction(compaction) = entry {
Some(compaction.clone())
} else {
None
}
});
*session = candidate;
if self.save_enabled {
self.provider_admission.clear();
}
drop(session);
drop(provider_admission);
if let (Some(region), Some(compaction_entry)) = (&self.extensions, compaction_entry) {
let payload = json!({
"compactionEntry": compaction_entry,
"fromExtension": from_extension,
});
if let Err(err) = region
.manager()
.dispatch_event(ExtensionEventName::SessionCompact, Some(payload))
.await
{
tracing::warn!("session_compact extension hook failed (fail-open): {err}");
}
}
Ok(tokens_after)
}
async fn apply_compaction_result(
&self,
result: compaction::CompactionResult,
on_event: AgentEventHandler,
provider_admission: OwnedMutexGuard<()>,
) -> Result<()> {
let details = match compaction::compaction_details_to_value(&result.details) {
Ok(details) => Some(details),
Err(err) => {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: false,
will_retry: false,
error_message: Some(err.to_string()),
});
return Err(err);
}
};
let tokens_after = match self
.apply_compaction_entry(
result.summary.clone(),
result.first_kept_entry_id.clone(),
result.tokens_before,
details.clone(),
false,
provider_admission,
)
.await
{
Ok(tokens_after) => tokens_after,
Err(err) => {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: false,
will_retry: false,
error_message: Some(err.to_string()),
});
return Err(err);
}
};
let result_value = Some(Self::auto_compaction_result_payload(
result.summary,
result.first_kept_entry_id,
result.tokens_before,
tokens_after,
details,
));
on_event(AgentEvent::AutoCompactionEnd {
result: result_value,
aborted: false,
will_retry: false,
error_message: None,
});
Ok(())
}
#[allow(clippy::too_many_lines)]
async fn compact_synchronous(&mut self, on_event: AgentEventHandler) -> Result<()> {
if !self.compaction_settings.enabled {
return Ok(());
}
let (entries, preparation) = {
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = self
.session
.lock(cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
session.ensure_entry_ids();
let entries = session
.entries_for_current_path()
.into_iter()
.cloned()
.collect::<Vec<_>>();
let prep = compaction::prepare_compaction(&entries, self.compaction_settings.clone());
(entries, prep)
};
if let Some(prep) = preparation {
on_event(AgentEvent::AutoCompactionStart {
reason: "threshold".to_string(),
});
let _compacting_guard = AtomicBoolGuard::activate(&self.extensions_is_compacting);
let before_outcome = self.dispatch_before_compact(&prep, &entries, None).await;
if before_outcome.cancel {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: true,
will_retry: false,
error_message: None,
});
return Err(Error::extension("Compaction cancelled".to_string()));
}
if let Some(compaction) = before_outcome.compaction {
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let provider_admission = match self.provider_admission.acquire(cx.cx()).await {
Ok(provider_admission) => provider_admission,
Err(err) => {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: false,
will_retry: false,
error_message: Some(err.to_string()),
});
return Err(err);
}
};
let apply_result = self
.apply_compaction_entry(
compaction.summary.clone(),
compaction.first_kept_entry_id.clone(),
compaction.tokens_before,
compaction.details.clone(),
true,
provider_admission,
)
.await;
let tokens_after = match apply_result {
Ok(tokens_after) => tokens_after,
Err(err) => {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: false,
will_retry: false,
error_message: Some(err.to_string()),
});
return Err(err);
}
};
let result_value = Some(Self::auto_compaction_result_payload(
compaction.summary,
compaction.first_kept_entry_id,
compaction.tokens_before,
tokens_after,
compaction.details,
));
on_event(AgentEvent::AutoCompactionEnd {
result: result_value,
aborted: false,
will_retry: false,
error_message: None,
});
return Ok(());
}
let provider = self.agent.provider();
let credential = self
.agent
.stream_options()
.api_key
.clone()
.unwrap_or_default();
self.invalidate_background_compaction();
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let provider_admission = match self.provider_admission.acquire(cx.cx()).await {
Ok(provider_admission) => provider_admission,
Err(err) => {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: false,
will_retry: false,
error_message: Some(err.to_string()),
});
return Err(err);
}
};
if let Err(err) = self.provider_admission.ensure_allowed() {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: false,
will_retry: false,
error_message: Some(err.to_string()),
});
return Err(err);
}
let compaction_result = compaction::compact(prep, provider, &credential, None).await;
match compaction_result {
Ok(result) => {
self.apply_compaction_result(result, Arc::clone(&on_event), provider_admission)
.await?;
}
Err(e) => {
on_event(AgentEvent::AutoCompactionEnd {
result: None,
aborted: false,
will_retry: false,
error_message: Some(e.to_string()),
});
return Err(e);
}
}
}
Ok(())
}
fn resolve_extension_policy_for_enable(
config: Option<&crate::config::Config>,
policy: Option<ExtensionPolicy>,
) -> ExtensionPolicy {
policy.unwrap_or_else(|| {
config.map_or_else(
|| crate::config::Config::default().resolve_extension_policy(None),
|cfg| cfg.resolve_extension_policy(None),
)
})
}
pub async fn enable_extensions(
&mut self,
enabled_tools: &[&str],
cwd: &std::path::Path,
config: Option<&crate::config::Config>,
extension_entries: &[std::path::PathBuf],
) -> Result<()> {
self.enable_extensions_with_policy(
enabled_tools,
cwd,
config,
extension_entries,
None,
None,
None,
ExtensionHostConfiguration::default(),
)
.await
}
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
pub async fn enable_extensions_with_policy(
&mut self,
_enabled_tools: &[&str],
cwd: &std::path::Path,
config: Option<&crate::config::Config>,
extension_entries: &[std::path::PathBuf],
policy: Option<ExtensionPolicy>,
repair_policy: Option<RepairPolicyMode>,
pre_warmed: Option<PreWarmedExtensionRuntime>,
host: ExtensionHostConfiguration,
) -> Result<()> {
let mut js_specs: Vec<JsExtensionLoadSpec> = Vec::new();
let mut native_specs: Vec<NativeRustExtensionLoadSpec> = Vec::new();
#[cfg(feature = "wasm-host")]
let mut wasm_specs: Vec<WasmExtensionLoadSpec> = Vec::new();
for entry in extension_entries {
match resolve_extension_load_spec(entry)? {
ExtensionLoadSpec::Js(spec) => js_specs.push(spec),
ExtensionLoadSpec::NativeRust(spec) => native_specs.push(spec),
#[cfg(feature = "wasm-host")]
ExtensionLoadSpec::Wasm(spec) => wasm_specs.push(spec),
}
}
if !js_specs.is_empty() && !native_specs.is_empty() {
return Err(Error::validation(
"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."
.to_string(),
));
}
#[cfg(feature = "wasm-host")]
if js_specs.is_empty() && native_specs.is_empty() && wasm_specs.is_empty() {
self.extensions = None;
self.agent.extensions = None;
self.extension_queue_modes = None;
self.extension_injected_queue = None;
return Ok(());
}
#[cfg(not(feature = "wasm-host"))]
if js_specs.is_empty() && native_specs.is_empty() {
self.extensions = None;
self.agent.extensions = None;
self.extension_queue_modes = None;
self.extension_injected_queue = None;
return Ok(());
}
let resolved_policy = Self::resolve_extension_policy_for_enable(config, policy);
let resolved_repair_policy = repair_policy
.or_else(|| config.map(|cfg| cfg.resolve_repair_policy(None)))
.unwrap_or(RepairPolicyMode::AutoSafe);
let runtime_repair_mode =
Self::runtime_repair_mode_from_policy_mode(resolved_repair_policy);
let memory_limit_bytes =
(resolved_policy.max_memory_mb as usize).saturating_mul(1024 * 1024);
let wants_js_runtime = !js_specs.is_empty();
#[allow(unused_variables)]
let (manager, tools) = if let Some(pre) = pre_warmed {
let manager = pre.manager;
let tools = pre.tools;
let runtime = match pre.runtime {
ExtensionRuntimeHandle::NativeRust(runtime) => {
if wants_js_runtime {
tracing::warn!(
event = "pi.extension_runtime.prewarm.mismatch",
expected = "quickjs",
got = "native-rust",
"Pre-warmed runtime mismatched requested JS mode; creating quickjs runtime"
);
Self::start_js_extension_runtime(
"agent_enable_extensions_prewarm_mismatch",
cwd,
tools.clone(),
manager.clone(),
resolved_policy.clone(),
runtime_repair_mode,
memory_limit_bytes,
)
.await?
} else {
tracing::info!(
event = "pi.extension_runtime.engine_decision",
stage = "agent_enable_extensions_prewarmed",
requested = "native-rust",
selected = "native-rust",
fallback = false,
"Using pre-warmed extension runtime"
);
ExtensionRuntimeHandle::NativeRust(runtime)
}
}
ExtensionRuntimeHandle::Js(runtime) => {
if wants_js_runtime {
tracing::info!(
event = "pi.extension_runtime.engine_decision",
stage = "agent_enable_extensions_prewarmed",
requested = "quickjs",
selected = "quickjs",
fallback = false,
"Using pre-warmed extension runtime"
);
ExtensionRuntimeHandle::Js(runtime)
} else {
tracing::warn!(
event = "pi.extension_runtime.prewarm.mismatch",
expected = "native-rust",
got = "quickjs",
"Pre-warmed runtime mismatched requested native mode; creating native-rust runtime"
);
Self::start_native_extension_runtime(
"agent_enable_extensions_prewarm_mismatch",
cwd,
tools.clone(),
manager.clone(),
resolved_policy.clone(),
runtime_repair_mode,
memory_limit_bytes,
)
.await?
}
}
};
manager.set_runtime(runtime);
(manager, tools)
} else {
let manager = ExtensionManager::new();
manager.set_cwd(cwd.display().to_string());
let tools = self.agent.shared_tools();
if let Some(cfg) = config {
let resolved_risk = cfg.resolve_extension_risk_with_metadata();
tracing::info!(
event = "pi.extension_runtime_risk.config",
source = resolved_risk.source,
enabled = resolved_risk.settings.enabled,
alpha = resolved_risk.settings.alpha,
window_size = resolved_risk.settings.window_size,
ledger_limit = resolved_risk.settings.ledger_limit,
fail_closed = resolved_risk.settings.fail_closed,
"Resolved extension runtime risk settings"
);
manager.set_runtime_risk_config(resolved_risk.settings);
}
let runtime = if wants_js_runtime {
Self::start_js_extension_runtime(
"agent_enable_extensions_boot",
cwd,
tools.clone(),
manager.clone(),
resolved_policy.clone(),
runtime_repair_mode,
memory_limit_bytes,
)
.await?
} else {
Self::start_native_extension_runtime(
"agent_enable_extensions_boot",
cwd,
tools.clone(),
manager.clone(),
resolved_policy.clone(),
runtime_repair_mode,
memory_limit_bytes,
)
.await?
};
manager.set_runtime(runtime);
(manager, tools)
};
if let Some(handler) = host.ui_handler {
manager.set_ui_handler(handler);
}
manager.set_policy_prompt_persistence(host.persist_permission_decisions);
tools
.snapshot()
.bind_job_session_resolver(Self::job_session_id_resolver(&self.session));
let (steering_mode, follow_up_mode) = self.agent.queue_modes();
let queue_modes = Arc::new(StdMutex::new(ExtensionQueueModeState::new(
steering_mode,
follow_up_mode,
)));
manager.set_session_action_origin_source(self.session_action_admission.origin_source());
manager.set_session(Arc::new(AgentExtensionSession {
handle: SessionHandle(self.session.clone()),
session_action_admission: self.session_action_admission.clone(),
is_streaming: Arc::clone(&self.extensions_is_streaming),
is_compacting: Arc::clone(&self.extensions_is_compacting),
queue_modes: Arc::clone(&queue_modes),
auto_compaction_enabled: self.compaction_settings.enabled,
}));
if let Some(registry) = &self.model_registry {
manager.set_extension_models(pi_ai_model_registry_values(registry));
}
manager.set_current_model(
Some(self.agent.provider().name().to_string()),
Some(self.agent.provider().model_id().to_string()),
);
manager.set_system_prompt(self.agent.system_prompt().map(ToString::to_string));
let injected = Arc::new(StdMutex::new(ExtensionInjectedQueue::new(
steering_mode,
follow_up_mode,
)));
let host_actions = AgentSessionHostActions {
session: Arc::clone(&self.session),
injected: Arc::clone(&injected),
is_streaming: Arc::clone(&self.extensions_is_streaming),
is_turn_active: Arc::clone(&self.extensions_turn_active),
pending_idle_actions: Arc::clone(&self.extensions_pending_idle_actions),
ai_completion: Arc::clone(&self.extension_ai_completion),
provider_admission: self.provider_admission.clone(),
session_action_admission: self.session_action_admission.clone(),
};
self.extension_queue_modes = Some(Arc::clone(&queue_modes));
self.extension_injected_queue = Some(Arc::clone(&injected));
manager.set_host_actions(Arc::new(host_actions));
{
let steering_queue = Arc::clone(&injected);
let follow_up_queue = Arc::clone(&injected);
let steering_fetcher = move || -> BoxFuture<'static, Vec<QueuedAgentMessage>> {
let steering_queue = Arc::clone(&steering_queue);
Box::pin(async move {
let Ok(mut queue) = steering_queue.lock() else {
return Vec::new();
};
queue
.pop_steering()
.into_iter()
.map(QueuedAgentMessage::generated)
.collect()
})
};
let follow_up_fetcher = move || -> BoxFuture<'static, Vec<QueuedAgentMessage>> {
let follow_up_queue = Arc::clone(&follow_up_queue);
Box::pin(async move {
let Ok(mut queue) = follow_up_queue.lock() else {
return Vec::new();
};
queue
.pop_follow_up()
.into_iter()
.map(QueuedAgentMessage::generated)
.collect()
})
};
self.agent.register_message_fetchers(
Some(Arc::new(steering_fetcher)),
Some(Arc::new(follow_up_fetcher)),
);
}
if !js_specs.is_empty() {
manager.load_js_extensions(js_specs).await?;
}
if !native_specs.is_empty() {
manager.load_native_extensions(native_specs).await?;
}
if let Some(rt) = manager.runtime() {
let events = rt.drain_repair_events().await;
if !events.is_empty() {
log_repair_diagnostics(&events);
}
}
#[cfg(feature = "wasm-host")]
if !wasm_specs.is_empty() {
let host = WasmExtensionHost::new(cwd, resolved_policy.clone())?;
manager
.load_wasm_extensions(&host, wasm_specs, tools.clone())
.await?;
}
crate::extensions::apply_cli_flags(&manager, &host.cli_flags).await?;
let session_path = {
let cx = crate::agent_cx::AgentCx::for_request();
let session = self
.session
.lock(cx.cx())
.await
.map_err(|e| Error::extension(e.to_string()))?;
session.path.as_ref().map(|p| p.display().to_string())
};
if let Err(err) = manager
.dispatch_event(
ExtensionEventName::Startup,
Some(serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"sessionFile": session_path,
})),
)
.await
{
tracing::warn!("startup extension hook failed (fail-open): {err}");
}
if let Err(err) = manager
.dispatch_event(ExtensionEventName::SessionStart, None)
.await
{
tracing::warn!("session_start extension hook failed (fail-open): {err}");
}
let ctx_payload = serde_json::json!({ "cwd": cwd.display().to_string() });
let wrappers = collect_extension_tool_wrappers(&manager, ctx_payload).await?;
self.agent.extend_tools(wrappers);
self.agent.extensions = Some(manager.clone());
self.extensions = Some(ExtensionRegion::new(manager));
Ok(())
}
pub async fn save_and_index(&mut self) -> Result<()> {
if self.save_enabled {
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = OwnedMutexGuard::lock(Arc::clone(&self.session), cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
session
.flush_autosave(AutosaveFlushTrigger::Periodic)
.await?;
}
Ok(())
}
pub async fn persist_session(&mut self) -> Result<()> {
if !self.save_enabled {
return Ok(());
}
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = OwnedMutexGuard::lock(Arc::clone(&self.session), cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
session
.flush_autosave(AutosaveFlushTrigger::Periodic)
.await?;
Ok(())
}
pub async fn run_text(
&mut self,
input: String,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<AssistantMessage> {
self.run_text_with_abort(input, None, on_event).await
}
async fn maybe_advise_turn(&mut self) {
if self.advisor.is_none() {
return;
}
let digest = crate::advisor::build_digest(self.agent.messages());
if digest.is_trivial() {
return;
}
let turn_index = self.agent.messages().len() as u64;
let Some(runtime) = self.advisor.as_mut() else {
return;
};
let outcome = runtime.review_turn(&digest, turn_index).await;
if std::env::var_os("PI_DEBUG_ADVISOR").is_some() {
eprintln!(
"[advisor] digest tools={} trivial={} outcome={}",
digest.tool_call_count,
digest.is_trivial(),
match &outcome {
crate::advisor::AdvisorOutcome::Inject(v) =>
format!("inject:{}", v.level.as_str()),
crate::advisor::AdvisorOutcome::Quiet => "quiet".to_string(),
crate::advisor::AdvisorOutcome::Failed => "failed".to_string(),
}
);
}
let verdict = match outcome {
crate::advisor::AdvisorOutcome::Inject(verdict) => verdict,
crate::advisor::AdvisorOutcome::Quiet => return,
crate::advisor::AdvisorOutcome::Failed => {
if let Some(notice) = runtime.disabled_notice.clone() {
let msg = crate::model::Message::User(crate::model::UserMessage {
content: crate::model::UserContent::Text(format!(
"[advisor disabled: {notice}]"
)),
timestamp: chrono::Utc::now().timestamp_millis(),
});
self.agent.queue_generated_steering(msg);
}
return;
}
};
let level_str = verdict.level.as_str().to_string();
let injection = crate::advisor::format_injection(&verdict);
let message = crate::model::Message::User(crate::model::UserMessage {
content: crate::model::UserContent::Text(injection.clone()),
timestamp: chrono::Utc::now().timestamp_millis(),
});
self.agent.queue_generated_steering(message);
let cx = pi::agent_cx::AgentCx::for_request();
if let Ok(mut inner) = self.session.lock(cx.cx()).await {
inner.append_custom_entry(
"advisor_note".to_string(),
Some(serde_json::json!({
"level": level_str,
"rationale": verdict.rationale,
})),
);
}
}
pub async fn run_text_with_abort(
&mut self,
input: String,
abort: Option<AbortSignal>,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<AssistantMessage> {
self.ensure_provider_reentry_allowed()?;
self.extensions_turn_active.store(true, Ordering::SeqCst);
let result = async {
let keyword_scan_override = self.agent.magic_keyword_scan_override.take();
let outcome = self.dispatch_input_event(input, Vec::new()).await?;
let (text, images) = match outcome {
InputEventOutcome::Continue { text, images } => (text, images),
InputEventOutcome::Block { reason } => {
let message = reason.unwrap_or_else(|| "Input blocked".to_string());
return Err(Error::extension(message));
}
};
let base_system_prompt = self.agent.system_prompt().map(str::to_string);
let BeforeAgentStartOutcome {
messages: custom_messages,
system_prompt,
} = self
.dispatch_before_agent_start(
&text,
&images,
base_system_prompt.as_deref().unwrap_or(""),
)
.await;
if let Some(prompt) = system_prompt {
self.agent.set_system_prompt(Some(prompt));
} else {
self.agent.set_system_prompt(base_system_prompt.clone());
}
self.agent.magic_keyword_scan_override = keyword_scan_override;
let result = if images.is_empty() {
self.run_agent_with_text(text, abort, on_event, custom_messages)
.await
} else {
let content = Self::build_content_blocks_for_input(&text, &images);
self.run_agent_with_content(content, abort, on_event, custom_messages)
.await
};
let _ = self.agent.magic_keyword_scan_override.take();
self.agent.set_system_prompt(base_system_prompt);
match result {
Ok(message) => {
if !matches!(message.stop_reason, StopReason::Error | StopReason::Aborted) {
self.maybe_advise_turn().await;
}
Ok(message)
}
Err(err) => Err(err),
}
}
.await;
self.extensions_turn_active.store(false, Ordering::SeqCst);
result
}
pub async fn run_with_content(
&mut self,
content: Vec<ContentBlock>,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<AssistantMessage> {
self.run_with_content_with_abort(content, None, on_event)
.await
}
pub async fn run_with_content_with_abort(
&mut self,
content: Vec<ContentBlock>,
abort: Option<AbortSignal>,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<AssistantMessage> {
self.ensure_provider_reentry_allowed()?;
self.extensions_turn_active.store(true, Ordering::SeqCst);
let result = async {
let keyword_scan_override = self.agent.magic_keyword_scan_override.take();
let (text, images) = Self::split_content_blocks_for_input(&content);
let outcome = self.dispatch_input_event(text, images).await?;
let (text, images) = match outcome {
InputEventOutcome::Continue { text, images } => (text, images),
InputEventOutcome::Block { reason } => {
let message = reason.unwrap_or_else(|| "Input blocked".to_string());
return Err(Error::extension(message));
}
};
let base_system_prompt = self.agent.system_prompt().map(str::to_string);
let BeforeAgentStartOutcome {
messages: custom_messages,
system_prompt,
} = self
.dispatch_before_agent_start(
&text,
&images,
base_system_prompt.as_deref().unwrap_or(""),
)
.await;
if let Some(prompt) = system_prompt {
self.agent.set_system_prompt(Some(prompt));
} else {
self.agent.set_system_prompt(base_system_prompt.clone());
}
self.agent.magic_keyword_scan_override = keyword_scan_override;
let content_for_agent = Self::build_content_blocks_for_input(&text, &images);
let result = self
.run_agent_with_content(content_for_agent, abort, on_event, custom_messages)
.await;
let _ = self.agent.magic_keyword_scan_override.take();
self.agent.set_system_prompt(base_system_prompt);
match result {
Ok(message) => {
if !matches!(message.stop_reason, StopReason::Error | StopReason::Aborted) {
self.maybe_advise_turn().await;
}
Ok(message)
}
Err(err) => Err(err),
}
}
.await;
self.extensions_turn_active.store(false, Ordering::SeqCst);
result
}
pub async fn revert_last_user_message(&mut self) -> Result<bool> {
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = self
.session
.lock(cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
let reverted = session.revert_last_user_message();
if reverted {
let messages = session.to_messages_for_current_path();
self.agent.replace_messages(messages);
}
Ok(reverted)
}
pub async fn revert_incomplete_response(&mut self) -> Result<bool> {
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = self
.session
.lock(cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
let reverted = session.revert_incomplete_response();
if reverted {
let messages = session.to_messages_for_current_path();
self.agent.replace_messages(messages);
}
Ok(reverted)
}
async fn dispatch_input_event(
&self,
text: String,
images: Vec<ImageContent>,
) -> Result<InputEventOutcome> {
let Some(region) = &self.extensions else {
return Ok(InputEventOutcome::Continue { text, images });
};
let images_value = serde_json::to_value(&images).unwrap_or(Value::Null);
let attachments_value = images_value.clone();
let text_clone = text.clone();
let payload = json!({
"text": text,
"content": text_clone,
"images": images_value,
"attachments": attachments_value,
"source": self.input_source.as_str(),
});
let response = region
.manager()
.dispatch_event_with_response(
ExtensionEventName::Input,
Some(payload),
EXTENSION_EVENT_TIMEOUT_MS,
)
.await?;
Ok(apply_input_event_response(response, text, images))
}
async fn dispatch_before_agent_start(
&self,
prompt: &str,
images: &[ImageContent],
system_prompt: &str,
) -> BeforeAgentStartOutcome {
let Some(region) = &self.extensions else {
return BeforeAgentStartOutcome {
messages: Vec::new(),
system_prompt: None,
};
};
let images_value = serde_json::to_value(images).unwrap_or(Value::Null);
let payload = json!({
"prompt": prompt,
"images": images_value,
"systemPrompt": system_prompt,
});
let response = region
.manager()
.dispatch_event_with_response(
ExtensionEventName::BeforeAgentStart,
Some(payload),
EXTENSION_EVENT_TIMEOUT_MS,
)
.await;
match response {
Ok(value) => apply_before_agent_start_response(value, Utc::now().timestamp_millis()),
Err(err) => {
tracing::warn!("before_agent_start extension hook failed (fail-open): {err}");
BeforeAgentStartOutcome {
messages: Vec::new(),
system_prompt: None,
}
}
}
}
async fn dispatch_before_compact(
&self,
preparation: &compaction::CompactionPreparation,
branch_entries: &[crate::session::SessionEntry],
custom_instructions: Option<&str>,
) -> SessionBeforeCompactOutcome {
let Some(region) = &self.extensions else {
return SessionBeforeCompactOutcome::default();
};
let prep_value = compaction::compaction_preparation_to_value(preparation);
let branch_entries_value =
serde_json::to_value(branch_entries).unwrap_or(Value::Array(Vec::new()));
let mut payload = serde_json::Map::new();
payload.insert("preparation".to_string(), prep_value);
payload.insert("branchEntries".to_string(), branch_entries_value);
if let Some(custom_instructions) = custom_instructions {
payload.insert(
"customInstructions".to_string(),
Value::String(custom_instructions.to_string()),
);
}
let response = region
.manager()
.dispatch_event_with_response(
ExtensionEventName::SessionBeforeCompact,
Some(Value::Object(payload)),
ExtensionEventName::SessionBeforeCompact.default_timeout_ms(),
)
.await;
match response {
Ok(value) => apply_session_before_compact_response(value, preparation.tokens_before),
Err(err) => {
tracing::warn!("session_before_compact extension hook failed (fail-open): {err}");
SessionBeforeCompactOutcome::default()
}
}
}
fn prepare_semantic_context_prompt(&self) -> Option<PreparedSemanticContextPrompt> {
let injection = self.semantic_context_bundle.as_ref()?;
if !injection.enabled {
return None;
}
let provider = self.agent.provider();
let shape = semantic_context_prompt_shape_for_provider(provider.api());
let budget = semantic_context_prompt_budget_for_provider(provider.api(), injection);
let revision = semantic_context_bundle_revision(&injection.bundle);
let (prompt, stats) =
render_semantic_context_prompt(&injection.bundle, injection, budget, &revision);
if prompt.trim().is_empty() {
tracing::warn!(
event = "pi.semantic_context.prompt.skipped",
provider = provider.name(),
api = provider.api(),
model = provider.model_id(),
revision = %revision,
max_bytes = budget.max_bytes,
"semantic context bundle prompt skipped because prompt budget was too small"
);
return None;
}
tracing::info!(
event = "pi.semantic_context.prompt.injected",
provider = provider.name(),
api = provider.api(),
model = provider.model_id(),
revision = %revision,
shape = ?shape,
prompt_bytes = prompt.len(),
selected_items = stats.selected_items_included,
selected_items_omitted = stats.selected_items_omitted,
validation_commands = stats.validation_commands_included,
truncated = stats.truncated,
"semantic context bundle attached to agent turn"
);
let details = json!({
"schema": SEMANTIC_CONTEXT_PROVENANCE_SCHEMA_V1,
"bundleSchema": injection.bundle.schema.as_str(),
"bundleRevision": revision.as_str(),
"provider": {
"name": provider.name(),
"api": provider.api(),
"model": provider.model_id(),
"promptShape": shape,
},
"budget": {
"requestedMaxItems": injection.max_prompt_items,
"requestedMaxBytes": injection.max_prompt_bytes,
"effectiveMaxItems": budget.max_items,
"effectiveMaxBytes": budget.max_bytes,
},
"prompt": {
"bytes": prompt.len(),
"selectedItemsIncluded": stats.selected_items_included,
"selectedItemsOmitted": stats.selected_items_omitted,
"validationCommandsIncluded": stats.validation_commands_included,
"validationCommandsOmitted": stats.validation_commands_omitted,
"exclusionsIncluded": stats.exclusions_included,
"exclusionsOmitted": stats.exclusions_omitted,
"truncated": stats.truncated,
},
"bundle": {
"selectedItems": injection.bundle.selected_items.len(),
"excludedItems": injection.bundle.excluded_items.len(),
"staleEvidenceSuppressions": injection.bundle.stale_evidence_suppressions.len(),
"estimatedBytes": injection.bundle.estimated_bytes,
"estimatedTokens": injection.bundle.estimated_tokens,
"redactionStatus": injection.bundle.redaction_summary.overall_status,
"inputFingerprintSha256": injection.bundle.invalidation_policy.input_fingerprint_sha256.as_str(),
"cacheable": injection.bundle.invalidation_policy.cacheable,
"workspaceId": injection.bundle.invalidation_policy.workspace_id.as_str(),
"branch": injection.bundle.invalidation_policy.branch.as_deref(),
"sessionId": injection.bundle.invalidation_policy.session_id.as_deref(),
}
});
Some(PreparedSemanticContextPrompt {
prompt,
revision,
shape,
details,
})
}
fn semantic_context_prompt_messages(
prepared: &PreparedSemanticContextPrompt,
timestamp: i64,
) -> Vec<Message> {
match prepared.shape {
SemanticContextPromptShape::CustomUserMessage => {
vec![Message::Custom(CustomMessage {
content: prepared.prompt.clone(),
custom_type: SEMANTIC_CONTEXT_CUSTOM_TYPE.to_string(),
display: true,
details: Some(prepared.details.clone()),
timestamp,
})]
}
SemanticContextPromptShape::SystemPromptAppend => {
vec![Message::Custom(CustomMessage {
content: format!(
"Semantic context bundle revision {} attached to system prompt.",
prepared.revision
),
custom_type: SEMANTIC_CONTEXT_CUSTOM_TYPE.to_string(),
display: false,
details: Some(prepared.details.clone()),
timestamp,
})]
}
}
}
fn semantic_context_system_prompt_for_turn(
base_system_prompt: Option<String>,
prepared: Option<&PreparedSemanticContextPrompt>,
) -> Option<String> {
let Some(prepared) = prepared else {
return base_system_prompt;
};
if !matches!(
prepared.shape,
SemanticContextPromptShape::SystemPromptAppend
) {
return base_system_prompt;
}
let mut prompt = base_system_prompt.unwrap_or_default();
if !prompt.is_empty() {
prompt.push_str("\n\n");
}
prompt.push_str(&prepared.prompt);
Some(prompt)
}
fn split_content_blocks_for_input(blocks: &[ContentBlock]) -> (String, Vec<ImageContent>) {
let mut text = String::new();
let mut images = Vec::new();
for block in blocks {
match block {
ContentBlock::Text(text_block) if !text_block.text.trim().is_empty() => {
if !text.is_empty() {
text.push('\n');
}
text.push_str(&text_block.text);
}
ContentBlock::Image(image) => images.push(image.clone()),
_ => {}
}
}
(text, images)
}
fn build_content_blocks_for_input(text: &str, images: &[ImageContent]) -> Vec<ContentBlock> {
let mut content = Vec::new();
if !text.trim().is_empty() {
content.push(ContentBlock::Text(TextContent::new(text.to_string())));
}
for image in images {
content.push(ContentBlock::Image(image.clone()));
}
content
}
fn take_pending_idle_actions(&self) -> Vec<PendingIdleAction> {
let Ok(mut actions) = self.extensions_pending_idle_actions.lock() else {
return Vec::new();
};
actions.drain(..).collect()
}
async fn run_pending_idle_actions_with_abort(
&mut self,
abort: Option<AbortSignal>,
on_event: AgentEventHandler,
) -> Result<()> {
let actions = self.take_pending_idle_actions();
if actions.is_empty() {
return Ok(());
}
let previous_source = self.input_source;
self.input_source = InputSource::Extension;
let result = async {
for action in actions {
match action {
PendingIdleAction::CustomMessage(message) => {
let handler = Arc::clone(&on_event);
self.run_custom_message_with_abort(message, abort.clone(), move |event| {
handler(event);
})
.await?;
}
PendingIdleAction::UserText(text) => {
let handler = Arc::clone(&on_event);
self.agent
.set_magic_keyword_scan_override(Some(String::new()));
self.run_text_with_abort(text, abort.clone(), move |event| {
handler(event);
})
.await?;
}
}
}
Ok(())
}
.await;
self.input_source = previous_source;
result
}
async fn run_custom_message_with_abort(
&mut self,
message: Message,
abort: Option<AbortSignal>,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<AssistantMessage> {
self.ensure_provider_reentry_allowed()?;
self.extensions_turn_active.store(true, Ordering::SeqCst);
let result = async {
let base_system_prompt = self.agent.system_prompt().map(str::to_string);
let BeforeAgentStartOutcome {
messages: custom_messages,
system_prompt,
} = self
.dispatch_before_agent_start("", &[], base_system_prompt.as_deref().unwrap_or(""))
.await;
if let Some(prompt) = system_prompt {
self.agent.set_system_prompt(Some(prompt));
} else {
self.agent.set_system_prompt(base_system_prompt.clone());
}
let result = self
.run_agent_with_prompt_message(message, abort, on_event, custom_messages)
.await;
self.agent.set_system_prompt(base_system_prompt);
result
}
.await;
self.extensions_turn_active.store(false, Ordering::SeqCst);
result
}
async fn run_agent_with_prompt_message(
&mut self,
prompt_message: Message,
abort: Option<AbortSignal>,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
custom_messages: Vec<CustomMessage>,
) -> Result<AssistantMessage> {
let on_event: AgentEventHandler = Arc::new(on_event);
self.sync_runtime_selection_from_session_header().await?;
self.maybe_compact(Arc::clone(&on_event)).await?;
let history = {
let cx = crate::agent_cx::AgentCx::for_request();
let session = self
.session
.lock(cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
session.to_messages_for_current_path()
};
self.agent.replace_messages(history);
let start_len = self.agent.messages().len();
let mut prompts = Vec::with_capacity(1 + custom_messages.len());
prompts.push(prompt_message.clone());
prompts.extend(custom_messages.into_iter().map(Message::Custom));
{
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = OwnedMutexGuard::lock(Arc::clone(&self.session), cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
session.append_model_message(prompt_message.clone());
if self.save_enabled {
session.flush_autosave(AutosaveFlushTrigger::Manual).await?;
}
}
let semantic_context = self.prepare_semantic_context_prompt();
let semantic_context_messages = semantic_context
.as_ref()
.map(|prepared| {
Self::semantic_context_prompt_messages(prepared, Utc::now().timestamp_millis())
})
.unwrap_or_default();
let streaming_guard = AtomicBoolGuard::activate(&self.extensions_is_streaming);
let base_system_prompt = self.agent.system_prompt().map(str::to_string);
self.agent
.set_system_prompt(Self::semantic_context_system_prompt_for_turn(
base_system_prompt.clone(),
semantic_context.as_ref(),
));
let on_event_for_run = Arc::clone(&on_event);
prompts.extend(semantic_context_messages);
let result = self
.agent
.run_with_messages_with_abort(prompts, abort, move |event| {
on_event_for_run(event);
})
.await;
drop(streaming_guard);
self.agent.set_system_prompt(base_system_prompt);
let run_incomplete = result.as_ref().map_or(true, |message| {
matches!(message.stop_reason, StopReason::Error | StopReason::Aborted)
});
let persist_result = self
.persist_turn_artifacts(start_len + 1, result.is_err(), run_incomplete)
.await;
finish_turn_persistence(result, persist_result)
}
pub(crate) async fn run_agent_with_text(
&mut self,
input: String,
abort: Option<AbortSignal>,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
custom_messages: Vec<CustomMessage>,
) -> Result<AssistantMessage> {
let on_event: AgentEventHandler = Arc::new(on_event);
self.sync_runtime_selection_from_session_header().await?;
self.maybe_compact(Arc::clone(&on_event)).await?;
let history = {
let cx = crate::agent_cx::AgentCx::for_request();
let session = self
.session
.lock(cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
session.to_messages_for_current_path()
};
self.agent.replace_messages(history);
let start_len = self.agent.messages().len();
let user_message = Message::User(UserMessage {
content: UserContent::Text(input),
timestamp: Utc::now().timestamp_millis(),
});
let mut prompts = Vec::with_capacity(1 + custom_messages.len());
prompts.push(user_message.clone());
let semantic_context = self.prepare_semantic_context_prompt();
let semantic_context_messages = semantic_context
.as_ref()
.map(|prepared| {
Self::semantic_context_prompt_messages(prepared, Utc::now().timestamp_millis())
})
.unwrap_or_default();
prompts.extend(semantic_context_messages);
prompts.extend(custom_messages.into_iter().map(Message::Custom));
{
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = OwnedMutexGuard::lock(Arc::clone(&self.session), cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
session.append_model_message(user_message.clone());
if self.save_enabled {
session.flush_autosave(AutosaveFlushTrigger::Manual).await?;
}
}
let streaming_guard = AtomicBoolGuard::activate(&self.extensions_is_streaming);
let base_system_prompt = self.agent.system_prompt().map(str::to_string);
self.agent
.set_system_prompt(Self::semantic_context_system_prompt_for_turn(
base_system_prompt.clone(),
semantic_context.as_ref(),
));
let on_event_for_run = Arc::clone(&on_event);
let result = self
.agent
.run_with_messages_with_abort(prompts, abort, move |event| {
on_event_for_run(event);
})
.await;
drop(streaming_guard);
self.agent.set_system_prompt(base_system_prompt);
let run_incomplete = result.as_ref().map_or(true, |message| {
matches!(message.stop_reason, StopReason::Error | StopReason::Aborted)
});
let persist_result = self
.persist_turn_artifacts(start_len + 1, result.is_err(), run_incomplete)
.await;
finish_turn_persistence(result, persist_result)
}
pub(crate) async fn run_agent_with_content(
&mut self,
content: Vec<ContentBlock>,
abort: Option<AbortSignal>,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
custom_messages: Vec<CustomMessage>,
) -> Result<AssistantMessage> {
let on_event: AgentEventHandler = Arc::new(on_event);
self.sync_runtime_selection_from_session_header().await?;
self.maybe_compact(Arc::clone(&on_event)).await?;
let history = {
let cx = crate::agent_cx::AgentCx::for_request();
let session = self
.session
.lock(cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
session.to_messages_for_current_path()
};
self.agent.replace_messages(history);
let start_len = self.agent.messages().len();
let user_message = Message::User(UserMessage {
content: UserContent::Blocks(content),
timestamp: Utc::now().timestamp_millis(),
});
let mut prompts = Vec::with_capacity(1 + custom_messages.len());
prompts.push(user_message.clone());
let semantic_context = self.prepare_semantic_context_prompt();
let semantic_context_messages = semantic_context
.as_ref()
.map(|prepared| {
Self::semantic_context_prompt_messages(prepared, Utc::now().timestamp_millis())
})
.unwrap_or_default();
prompts.extend(semantic_context_messages);
prompts.extend(custom_messages.into_iter().map(Message::Custom));
{
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = OwnedMutexGuard::lock(Arc::clone(&self.session), cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
session.append_model_message(user_message.clone());
if self.save_enabled {
session.flush_autosave(AutosaveFlushTrigger::Manual).await?;
}
}
let streaming_guard = AtomicBoolGuard::activate(&self.extensions_is_streaming);
let base_system_prompt = self.agent.system_prompt().map(str::to_string);
self.agent
.set_system_prompt(Self::semantic_context_system_prompt_for_turn(
base_system_prompt.clone(),
semantic_context.as_ref(),
));
let on_event_for_run = Arc::clone(&on_event);
let result = self
.agent
.run_with_messages_with_abort(prompts, abort, move |event| {
on_event_for_run(event);
})
.await;
drop(streaming_guard);
self.agent.set_system_prompt(base_system_prompt);
let run_incomplete = result.as_ref().map_or(true, |message| {
matches!(message.stop_reason, StopReason::Error | StopReason::Aborted)
});
let persist_result = self
.persist_turn_artifacts(start_len + 1, result.is_err(), run_incomplete)
.await;
finish_turn_persistence(result, persist_result)
}
pub async fn run_continue_with_abort(
&mut self,
abort: Option<AbortSignal>,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<AssistantMessage> {
self.run_continue_with_follow_up_with_abort(false, abort, || true, on_event)
.await
}
pub(crate) async fn run_continue_with_follow_up_with_abort(
&mut self,
follow_up_first: bool,
abort: Option<AbortSignal>,
on_ready: impl FnOnce() -> bool + Send + 'static,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<AssistantMessage> {
self.ensure_provider_reentry_allowed()?;
let on_event: AgentEventHandler = Arc::new(on_event);
self.sync_runtime_selection_from_session_header().await?;
let history = {
let cx = crate::agent_cx::AgentCx::for_request();
let session = self
.session
.lock(cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
session.to_messages_for_current_path()
};
self.agent.replace_messages(history);
let start_len = self.agent.messages().len();
let streaming_guard = AtomicBoolGuard::activate(&self.extensions_is_streaming);
let on_event_for_run = Arc::clone(&on_event);
let result = if follow_up_first {
self.agent
.run_continue_with_follow_up_on_ready_with_abort(abort, on_ready, move |event| {
on_event_for_run(event);
})
.await
} else {
on_ready();
self.agent
.run_continue_with_abort(abort, move |event| {
on_event_for_run(event);
})
.await
};
drop(streaming_guard);
let run_incomplete = result.as_ref().map_or(true, |message| {
matches!(message.stop_reason, StopReason::Error | StopReason::Aborted)
});
let persist_result = self
.persist_turn_artifacts(start_len, result.is_err(), run_incomplete)
.await;
finish_turn_persistence(result, persist_result)
}
async fn persist_turn_artifacts(
&mut self,
start_len: usize,
run_failed: bool,
run_incomplete: bool,
) -> Result<()> {
let mut new_messages = self.agent.messages()[start_len..].to_vec();
let incomplete_assistant = if run_incomplete {
new_messages
.iter()
.rposition(is_incomplete_assistant_message)
.map(|index| new_messages.remove(index))
} else {
None
};
{
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = OwnedMutexGuard::lock(Arc::clone(&self.session), cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
let repairs = self.agent.drain_repair_ledger()?;
let activations = self.agent.drain_keyword_ledger();
for message in new_messages {
if run_failed && is_synthetic_empty_error_assistant(&message) {
continue;
}
session.append_model_message(message);
}
append_dialect_repair_telemetry(&mut session, &repairs);
crate::magic_keywords::append_session_telemetry(&mut session, &activations);
if let Some(message) = incomplete_assistant
&& !(run_failed && is_synthetic_empty_error_assistant(&message))
{
session.append_model_message(message);
}
if self.save_enabled {
session
.flush_autosave(AutosaveFlushTrigger::Periodic)
.await?;
}
}
Ok(())
}
}
fn is_incomplete_assistant_message(message: &Message) -> bool {
matches!(
message,
Message::Assistant(assistant)
if matches!(assistant.stop_reason, StopReason::Error | StopReason::Aborted)
)
}
fn is_synthetic_empty_error_assistant(message: &Message) -> bool {
matches!(
message,
Message::Assistant(assistant)
if assistant.content.is_empty()
&& matches!(assistant.stop_reason, StopReason::Error)
&& assistant.error_message.is_some()
)
}
fn semantic_context_prompt_shape_for_provider(api: &str) -> SemanticContextPromptShape {
match api {
"bedrock-converse-stream" | "gitlab-chat" => SemanticContextPromptShape::SystemPromptAppend,
_ => SemanticContextPromptShape::CustomUserMessage,
}
}
fn semantic_context_prompt_budget_for_provider(
api: &str,
injection: &SemanticContextBundleInjection,
) -> SemanticContextPromptBudget {
let provider_max_bytes = match api {
"gitlab-chat" => 8 * 1024,
"bedrock-converse-stream" | "google-gemini" | "google-vertex" => 12 * 1024,
"openai-responses" | "openai-completions" | "azure-openai" => 24 * 1024,
"anthropic" => 32 * 1024,
_ => DEFAULT_SEMANTIC_CONTEXT_PROMPT_MAX_BYTES,
};
let provider_max_items = match api {
"gitlab-chat" => 8,
"bedrock-converse-stream" | "google-gemini" | "google-vertex" => 12,
_ => DEFAULT_SEMANTIC_CONTEXT_PROMPT_MAX_ITEMS,
};
SemanticContextPromptBudget {
max_items: injection
.max_prompt_items
.min(injection.bundle.budget.max_items)
.min(provider_max_items),
max_bytes: injection
.max_prompt_bytes
.min(injection.bundle.budget.max_bytes)
.min(provider_max_bytes),
}
}
fn semantic_context_bundle_revision(bundle: &SemanticContextBundle) -> String {
let bytes = serde_json::to_vec(bundle).unwrap_or_else(|_| {
format!(
"{}:{}:{}:{}",
bundle.schema,
bundle.invalidation_policy.input_fingerprint_sha256,
bundle.selected_items.len(),
bundle.estimated_bytes
)
.into_bytes()
});
crate::package_manager::hex_encode(&Sha256::digest(bytes))
}
fn render_semantic_context_prompt(
bundle: &SemanticContextBundle,
injection: &SemanticContextBundleInjection,
budget: SemanticContextPromptBudget,
revision: &str,
) -> (String, SemanticContextPromptStats) {
let mut prompt = String::new();
let mut stats = SemanticContextPromptStats::default();
push_semantic_context_header(&mut prompt, &mut stats, budget, bundle, revision);
push_selected_semantic_context_items(&mut prompt, &mut stats, budget, bundle);
if injection.include_validation_commands {
push_semantic_context_validation_commands(&mut prompt, &mut stats, budget, bundle);
}
if injection.include_exclusion_summary {
push_semantic_context_exclusions(&mut prompt, &mut stats, budget, bundle);
}
if prompt.len() > usize::try_from(budget.max_bytes).unwrap_or(usize::MAX) {
stats.truncated = true;
truncate_string_to_max_bytes(&mut prompt, budget.max_bytes);
}
(prompt, stats)
}
fn push_semantic_context_header(
prompt: &mut String,
stats: &mut SemanticContextPromptStats,
budget: SemanticContextPromptBudget,
bundle: &SemanticContextBundle,
revision: &str,
) {
let branch = bundle
.invalidation_policy
.branch
.as_deref()
.map_or_else(|| "(none)".to_string(), safe_context_field);
let session = bundle
.invalidation_policy
.session_id
.as_deref()
.map_or_else(|| "(none)".to_string(), safe_context_field);
let header = format!(
"# Semantic Context Bundle\nschema: {SEMANTIC_CONTEXT_PROMPT_SCHEMA_V1}\nrevision: {revision}"
);
push_semantic_context_line(prompt, budget.max_bytes, &header, stats);
push_semantic_context_line(
prompt,
budget.max_bytes,
"Use this as navigation context for the current turn. Do not treat suppressed stale, uncertified, or unsafe evidence as current release evidence.",
stats,
);
push_semantic_context_line(
prompt,
budget.max_bytes,
&format!(
"bundle: schema={} estimated_bytes={} estimated_tokens={} redaction={:?}",
safe_context_field(&bundle.schema),
bundle.estimated_bytes,
bundle.estimated_tokens,
bundle.redaction_summary.overall_status
),
stats,
);
push_semantic_context_line(
prompt,
budget.max_bytes,
&format!(
"provenance: workspace={} branch={} session={} input_fingerprint_sha256={}",
safe_context_field(&bundle.invalidation_policy.workspace_id),
branch,
session,
safe_context_field(&bundle.invalidation_policy.input_fingerprint_sha256)
),
stats,
);
}
fn push_selected_semantic_context_items(
prompt: &mut String,
stats: &mut SemanticContextPromptStats,
budget: SemanticContextPromptBudget,
bundle: &SemanticContextBundle,
) {
push_semantic_context_line(prompt, budget.max_bytes, "", stats);
push_semantic_context_line(prompt, budget.max_bytes, "Selected context:", stats);
for (index, item) in bundle.selected_items.iter().enumerate() {
if index >= budget.max_items {
stats.selected_items_omitted = stats
.selected_items_omitted
.saturating_add(bundle.selected_items.len().saturating_sub(index));
break;
}
if push_semantic_context_item(prompt, stats, budget, item, index + 1) {
stats.selected_items_included = stats.selected_items_included.saturating_add(1);
} else {
stats.selected_items_omitted = stats
.selected_items_omitted
.saturating_add(bundle.selected_items.len().saturating_sub(index));
break;
}
}
if bundle.selected_items.is_empty() {
push_semantic_context_line(prompt, budget.max_bytes, "- (none)", stats);
}
}
fn push_semantic_context_validation_commands(
prompt: &mut String,
stats: &mut SemanticContextPromptStats,
budget: SemanticContextPromptBudget,
bundle: &SemanticContextBundle,
) {
push_semantic_context_line(prompt, budget.max_bytes, "", stats);
push_semantic_context_line(
prompt,
budget.max_bytes,
"Suggested validation commands:",
stats,
);
if bundle.suggested_validation_commands.is_empty() {
push_semantic_context_line(prompt, budget.max_bytes, "- (none)", stats);
return;
}
for (index, command) in bundle.suggested_validation_commands.iter().enumerate() {
let line = format!("- {}", safe_context_field(command));
if push_semantic_context_line(prompt, budget.max_bytes, &line, stats) {
stats.validation_commands_included =
stats.validation_commands_included.saturating_add(1);
} else {
stats.validation_commands_omitted = bundle
.suggested_validation_commands
.len()
.saturating_sub(index);
break;
}
}
}
fn push_semantic_context_exclusions(
prompt: &mut String,
stats: &mut SemanticContextPromptStats,
budget: SemanticContextPromptBudget,
bundle: &SemanticContextBundle,
) {
push_semantic_context_line(prompt, budget.max_bytes, "", stats);
push_semantic_context_line(
prompt,
budget.max_bytes,
"Suppressed or excluded context:",
stats,
);
let mut seen = std::collections::BTreeSet::new();
let unique_exclusions = bundle
.stale_evidence_suppressions
.iter()
.chain(bundle.excluded_items.iter())
.filter(|item| {
seen.insert((
item.node_type,
item.source_path.as_str(),
item.title.as_str(),
item.reason.as_str(),
))
})
.collect::<Vec<_>>();
if unique_exclusions.is_empty() {
push_semantic_context_line(prompt, budget.max_bytes, "- (none)", stats);
return;
}
let mut included = 0_usize;
for item in unique_exclusions.iter().take(8) {
let line = format!(
"- {:?} {} :: {} reason={}",
item.node_type,
safe_context_field(&item.source_path),
safe_context_field(&item.title),
safe_context_field(&item.reason)
);
if push_semantic_context_line(prompt, budget.max_bytes, &line, stats) {
included = included.saturating_add(1);
} else {
break;
}
}
stats.exclusions_included = stats.exclusions_included.saturating_add(included);
stats.exclusions_omitted = stats
.exclusions_omitted
.saturating_add(unique_exclusions.len().saturating_sub(included));
}
fn push_semantic_context_item(
prompt: &mut String,
stats: &mut SemanticContextPromptStats,
budget: SemanticContextPromptBudget,
item: &ContextBundleItem,
ordinal: usize,
) -> bool {
let freshness = item.freshness_status.map_or_else(
|| "not_applicable".to_string(),
|status| format!("{status:?}"),
);
let line = format!(
"{ordinal}. {:?} {} :: {}",
item.node_type,
safe_context_field(&item.source_path),
safe_context_field(&item.title)
);
let detail = format!(
" reason={} score={} tokens={} freshness={} redaction={:?}",
safe_context_field(&item.reason),
item.score,
item.estimated_tokens,
freshness,
item.redaction_status
);
push_semantic_context_line(prompt, budget.max_bytes, &line, stats)
&& push_semantic_context_line(prompt, budget.max_bytes, &detail, stats)
}
fn push_semantic_context_line(
prompt: &mut String,
max_bytes: u64,
line: &str,
stats: &mut SemanticContextPromptStats,
) -> bool {
let max_bytes = usize::try_from(max_bytes).unwrap_or(usize::MAX);
let required = line.len().saturating_add(1);
if prompt.len().saturating_add(required) > max_bytes {
stats.truncated = true;
return false;
}
prompt.push_str(line);
prompt.push('\n');
true
}
fn truncate_string_to_max_bytes(value: &mut String, max_bytes: u64) {
let max_bytes = usize::try_from(max_bytes).unwrap_or(usize::MAX);
if value.len() <= max_bytes {
return;
}
let mut end = max_bytes;
while !value.is_char_boundary(end) {
end = end.saturating_sub(1);
}
value.truncate(end);
}
fn safe_context_field(value: &str) -> String {
let mut output = String::with_capacity(value.len().min(512));
for ch in value.chars() {
if matches!(ch, '\n' | '\r' | '\t') {
output.push(' ');
} else if ch.is_control() {
output.push('?');
} else {
output.push(ch);
}
if output.len() >= 512 {
output.push_str("...");
break;
}
}
output
}
fn log_repair_diagnostics(events: &[crate::extensions_js::ExtensionRepairEvent]) {
use std::collections::BTreeMap;
for ev in events {
tracing::info!(
event = "extension.auto_repair",
extension_id = %ev.extension_id,
pattern = %ev.pattern,
success = ev.success,
original_error = %ev.original_error,
repair_action = %ev.repair_action,
);
}
let mut by_pattern: BTreeMap<String, Vec<&str>> = BTreeMap::new();
for ev in events {
by_pattern
.entry(ev.pattern.to_string())
.or_default()
.push(&ev.extension_id);
}
let verbose = std::env::var("PI_AUTO_REPAIR_VERBOSE")
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
if verbose {
warn!(
"[auto-repair] {} extension{} auto-repaired:",
events.len(),
if events.len() == 1 { "" } else { "s" }
);
for ev in events {
warn!(
" {}: {} ({})",
ev.pattern, ev.extension_id, ev.repair_action
);
}
} else {
let patterns: Vec<String> = by_pattern
.iter()
.map(|(pat, ids)| format!("{pat}:{}", ids.len()))
.collect();
tracing::info!(
event = "extension.auto_repair.summary",
count = events.len(),
patterns = %patterns.join(", "),
"auto-repaired {} extension(s)",
events.len(),
);
}
}
const BLOCK_IMAGES_PLACEHOLDER: &str = "Image reading is disabled.";
#[derive(Debug, Default, Clone, Copy)]
struct ImageFilterStats {
removed_images: usize,
affected_messages: usize,
}
fn filter_images_for_provider(messages: &mut [Message]) -> ImageFilterStats {
let mut stats = ImageFilterStats::default();
for message in messages {
let removed = filter_images_from_message(message);
if removed > 0 {
stats.removed_images += removed;
stats.affected_messages += 1;
}
}
stats
}
fn filter_images_from_message(message: &mut Message) -> usize {
match message {
Message::User(user) => match &mut user.content {
UserContent::Text(_) => 0,
UserContent::Blocks(blocks) => filter_image_blocks(blocks),
},
Message::Assistant(assistant) => {
let assistant = Arc::make_mut(assistant);
filter_image_blocks(&mut assistant.content)
}
Message::ToolResult(tool_result) => {
filter_image_blocks(&mut Arc::make_mut(tool_result).content)
}
Message::Custom(_) => 0,
}
}
fn filter_image_blocks(blocks: &mut Vec<ContentBlock>) -> usize {
let mut removed = 0usize;
let mut filtered = Vec::with_capacity(blocks.len());
for block in blocks.drain(..) {
match block {
ContentBlock::Image(_) => {
removed += 1;
let previous_is_placeholder =
filtered
.last()
.is_some_and(|prev| matches!(prev, ContentBlock::Text(TextContent { text, .. }) if text.as_str().eq(BLOCK_IMAGES_PLACEHOLDER)));
if !previous_is_placeholder {
filtered.push(ContentBlock::Text(TextContent::new(
BLOCK_IMAGES_PLACEHOLDER,
)));
}
}
other => filtered.push(other),
}
}
*blocks = filtered;
removed
}
const TRUNCATED_TOOL_CALL_ERROR: &str = "Model output truncated before tool call completed (provider stop reason: length); \
the incomplete tool call was not executed";
fn is_complete_tool_call(tool_call: &ToolCall) -> bool {
!tool_call.name.trim().is_empty() && !tool_call.arguments.is_null()
}
fn is_truncated_before_tool_call(message: &AssistantMessage, tool_call_started: bool) -> bool {
if message.stop_reason != StopReason::Length {
return false;
}
let mut saw_incomplete = false;
for block in &message.content {
if let ContentBlock::ToolCall(tool_call) = block {
if is_complete_tool_call(tool_call) {
return false;
}
saw_incomplete = true;
}
}
tool_call_started || saw_incomplete
}
fn assistant_text_content(content: &[ContentBlock]) -> String {
let mut text = String::new();
for block in content {
if let ContentBlock::Text(part) = block {
if !text.is_empty() {
text.push('\n');
}
text.push_str(&part.text);
}
}
text
}
fn extract_tool_calls(content: &[ContentBlock]) -> Vec<ToolCall> {
content
.iter()
.filter_map(|block| {
if let ContentBlock::ToolCall(tc) = block {
Some(tc.clone())
} else {
None
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::extensions_integration_tests::PiAiCaptureProvider;
use super::*;
use crate::auth::AuthCredential;
use crate::provider::{InputType, Model, ModelCost};
use asupersync::runtime::RuntimeBuilder;
use async_trait::async_trait;
use futures::Stream;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::path::Path;
use std::pin::Pin;
use std::sync::{Arc as StdArc, Mutex as StdTestMutex};
fn user_message(text: &str) -> Message {
Message::User(UserMessage {
content: UserContent::Text(text.to_string()),
timestamp: 0,
})
}
fn queued_user_message(text: &str) -> QueuedAgentMessage {
QueuedAgentMessage::from_authored_message(user_message(text))
}
fn assert_user_text(message: &Message, expected: &str) {
assert!(
matches!(
message,
Message::User(UserMessage {
content: UserContent::Text(_),
..
})
),
"expected user text message, got {message:?}"
);
if let Message::User(UserMessage {
content: UserContent::Text(text),
..
}) = message
{
assert_eq!(text, expected);
}
}
fn sample_image_block() -> ContentBlock {
ContentBlock::Image(ImageContent {
data: "aGVsbG8=".to_string(),
mime_type: "image/png".to_string(),
})
}
fn image_count_in_message(message: &Message) -> usize {
let count_images = |blocks: &[ContentBlock]| {
blocks
.iter()
.filter(|block| matches!(block, ContentBlock::Image(_)))
.count()
};
match message {
Message::User(UserMessage {
content: UserContent::Blocks(blocks),
..
}) => count_images(blocks),
Message::Assistant(msg) => count_images(&msg.content),
Message::ToolResult(tool_result) => count_images(&tool_result.content),
Message::User(UserMessage {
content: UserContent::Text(_),
..
})
| Message::Custom(_) => 0,
}
}
fn assistant_message(text: &str) -> AssistantMessage {
AssistantMessage {
content: vec![ContentBlock::Text(TextContent::new(text))],
api: "test-api".to_string(),
provider: "test-provider".to_string(),
model: "test-model".to_string(),
usage: Usage::default(),
stop_reason: StopReason::Stop,
stop_details: None,
error_message: None,
timestamp: 0,
}
}
#[derive(Debug)]
struct SilentProvider;
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for SilentProvider {
fn name(&self) -> &str {
"silent-provider"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"test-model"
}
async fn stream(
&self,
_context: &Context<'_>,
_options: &StreamOptions,
) -> crate::error::Result<
Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
> {
Ok(Box::pin(futures::stream::empty()))
}
}
#[derive(Debug)]
struct CountingStopProvider {
stream_calls: StdArc<std::sync::atomic::AtomicUsize>,
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for CountingStopProvider {
fn name(&self) -> &str {
"counting-stop-provider"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"test-model"
}
async fn stream(
&self,
_context: &Context<'_>,
_options: &StreamOptions,
) -> crate::error::Result<
Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
> {
self.stream_calls.fetch_add(1, Ordering::SeqCst);
Ok(Box::pin(futures::stream::iter([Ok(StreamEvent::Done {
reason: StopReason::Stop,
message: assistant_message("done"),
})])))
}
}
#[derive(Debug)]
struct PricedUsageProvider {
provider_reported_total: Option<f64>,
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for PricedUsageProvider {
fn name(&self) -> &str {
"priced-provider"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"test-model"
}
fn model_cost(&self) -> Option<crate::provider::ModelCost> {
Some(crate::provider::ModelCost {
input: 2.0,
output: 10.0,
cache_read: 0.2,
cache_write: 2.5,
})
}
async fn stream(
&self,
_context: &Context<'_>,
_options: &StreamOptions,
) -> crate::error::Result<
Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
> {
let mut message = assistant_message("done");
message.usage = Usage {
input: 1_000_000,
output: 100_000,
cache_read: 500_000,
cache_write: 0,
total_tokens: 1_600_000,
cost: crate::model::Cost {
total: self.provider_reported_total.unwrap_or(0.0),
..crate::model::Cost::default()
},
};
Ok(Box::pin(futures::stream::iter([Ok(StreamEvent::Done {
reason: StopReason::Stop,
message,
})])))
}
}
fn run_priced_turn(provider_reported_total: Option<f64>) -> crate::model::Usage {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = StdArc::new(PricedUsageProvider {
provider_reported_total,
});
let mut agent = Agent::new(
provider,
ToolRegistry::from_tools(Vec::new()),
AgentConfig::default(),
);
let ended = StdArc::new(std::sync::Mutex::new(None));
let ended_for_events = StdArc::clone(&ended);
agent
.run("price me", move |event| {
if let AgentEvent::MessageEnd {
message: Message::Assistant(message),
} = event
{
*ended_for_events.lock().unwrap() = Some(message.usage.clone());
}
})
.await
.expect("agent run");
let usage = ended.lock().unwrap().clone().expect("message_end usage");
let stored = match agent.messages().last() {
Some(Message::Assistant(message)) => message.usage.clone(),
other => panic!("expected assistant message, got {other:?}"),
};
assert!(
(stored.cost.total - usage.cost.total).abs() < 1e-12,
"history and message_end must agree"
);
usage
})
}
#[test]
fn finished_turn_prices_usage_from_catalog_rates() {
let usage = run_priced_turn(None);
assert!((usage.cost.input - 2.0).abs() < 1e-9);
assert!((usage.cost.output - 1.0).abs() < 1e-9);
assert!((usage.cost.cache_read - 0.1).abs() < 1e-9);
assert!(usage.cost.cache_write.abs() < 1e-12);
assert!((usage.cost.total - 3.1).abs() < 1e-9);
}
#[test]
fn finished_turn_keeps_provider_reported_cost_total() {
let usage = run_priced_turn(Some(2.75));
assert!((usage.cost.total - 2.75).abs() < 1e-12);
assert!((usage.cost.input - 2.0).abs() < 1e-9);
}
#[test]
fn background_job_follow_ups_track_the_live_agent_session() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let first_session = Session::in_memory();
let first_id = first_session.header.id.clone();
let second_id = format!("session-b-{}", uuid::Uuid::new_v4().simple());
let session = Arc::new(Mutex::new(first_session));
let agent = Agent::new(
Arc::new(SilentProvider),
ToolRegistry::from_tools(Vec::new()),
AgentConfig::default(),
);
let mut agent_session = AgentSession::new(
agent,
Arc::clone(&session),
false,
ResolvedCompactionSettings::default(),
);
crate::jobs::push_completion_notice(&first_id, "first-session-notice")
.expect("first notice");
crate::jobs::push_completion_notice(&second_id, "second-session-notice")
.expect("second notice");
agent_session
.agent
.fetch_additive_follow_up_messages()
.await;
assert!(
agent_session.agent.has_staged_follow_up(),
"the first owner's notice must be staged before the session switch"
);
{
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let mut guard = session.lock(cx.cx()).await.expect("session lock");
guard.header.id.clone_from(&second_id);
}
let stale_delivery = agent_session
.agent
.pop_follow_up_for_current_session()
.await;
assert!(
stale_delivery.is_empty(),
"delivery-time owner validation must not expose the staged first-session notice"
);
agent_session
.agent
.fetch_additive_follow_up_messages()
.await;
let second_delivery = agent_session
.agent
.pop_follow_up_for_current_session()
.await;
assert_eq!(second_delivery.len(), 1);
assert_user_text(second_delivery[0].message(), "second-session-notice");
assert!(second_delivery.iter().all(|delivery| {
!matches!(
delivery.message(),
Message::User(UserMessage {
content: UserContent::Text(text),
..
}) if text == "first-session-notice"
)
}));
{
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let mut guard = session.lock(cx.cx()).await.expect("session lock");
guard.header.id.clone_from(&first_id);
}
agent_session
.agent
.fetch_additive_follow_up_messages()
.await;
let restored_first_delivery = agent_session
.agent
.pop_follow_up_for_current_session()
.await;
assert_eq!(restored_first_delivery.len(), 1);
assert_user_text(restored_first_delivery[0].message(), "first-session-notice");
assert!(crate::jobs::take_completion_notices(&first_id).is_empty());
assert!(crate::jobs::take_completion_notices(&second_id).is_empty());
});
}
#[test]
fn stale_only_job_handoff_does_not_invoke_the_provider_empty() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let first_id = format!("stale-owner-a-{}", uuid::Uuid::new_v4().simple());
let second_id = format!("stale-owner-b-{}", uuid::Uuid::new_v4().simple());
let resolver_calls = StdArc::new(std::sync::atomic::AtomicUsize::new(0));
let resolver_calls_for_scope = StdArc::clone(&resolver_calls);
let first_id_for_scope = first_id.clone();
let second_id_for_scope = second_id.clone();
let tools = ToolRegistry::from_tools(Vec::new());
tools.bind_job_session_resolver(StdArc::new(move || {
let resolved = if resolver_calls_for_scope.fetch_add(1, Ordering::SeqCst) == 0 {
first_id_for_scope.clone()
} else {
second_id_for_scope.clone()
};
Box::pin(async move { Some(resolved) })
}));
let stream_calls = StdArc::new(std::sync::atomic::AtomicUsize::new(0));
let provider = StdArc::new(CountingStopProvider {
stream_calls: StdArc::clone(&stream_calls),
});
let mut agent = Agent::new(provider, tools, AgentConfig::default());
let turn_starts = StdArc::new(std::sync::atomic::AtomicUsize::new(0));
let turn_starts_for_events = StdArc::clone(&turn_starts);
crate::jobs::push_completion_notice(&first_id, "stale-only-notice")
.expect("first owner notice");
agent
.run("initial prompt", move |event| {
if matches!(event, AgentEvent::TurnStart { .. }) {
turn_starts_for_events.fetch_add(1, Ordering::SeqCst);
}
})
.await
.expect("agent run");
assert_eq!(
stream_calls.load(Ordering::SeqCst),
1,
"a stale-only staged batch must not create an empty provider turn"
);
assert_eq!(
turn_starts.load(Ordering::SeqCst),
1,
"a stale-only staged batch must not emit an unmatched turn start"
);
let restored = crate::jobs::take_completion_notices(&first_id);
assert_eq!(restored.len(), 1);
assert_user_text(&restored[0], "stale-only-notice");
assert!(crate::jobs::take_completion_notices(&second_id).is_empty());
});
}
#[test]
fn background_job_notice_survives_a_full_additive_handoff() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let session_state = Session::in_memory();
let session_id = session_state.header.id.clone();
let session = Arc::new(Mutex::new(session_state));
let mut agent = Agent::new(
Arc::new(SilentProvider),
ToolRegistry::from_tools(Vec::new()),
AgentConfig::default(),
);
let ordinary_fetcher: MessageFetcher = Arc::new(|| {
Box::pin(async move {
(0..=MAX_FOLLOW_UP_QUEUE_SIZE)
.map(|index| {
QueuedAgentMessage::generated(Message::User(UserMessage {
content: UserContent::Text(format!("ordinary-{index}")),
timestamp: 0,
}))
})
.collect()
})
});
agent.register_message_fetchers(None, Some(ordinary_fetcher));
agent.set_queue_modes(QueueMode::OneAtATime, QueueMode::All);
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
crate::jobs::push_completion_notice(&session_id, "bounded-job-notice")
.expect("job notice");
agent_session
.agent
.fetch_additive_follow_up_messages()
.await;
let deliveries = agent_session.agent.message_queue.pop_follow_up();
assert_eq!(deliveries.len(), MAX_FOLLOW_UP_QUEUE_SIZE + 1);
assert!(deliveries.iter().any(|delivery| matches!(
delivery.message(),
Message::User(UserMessage {
content: UserContent::Text(text),
..
}) if text == "bounded-job-notice"
)));
});
}
#[test]
fn busy_owning_follow_up_source_does_not_starve_job_notices() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let session_state = Session::in_memory();
let session_id = session_state.header.id.clone();
let session = Arc::new(Mutex::new(session_state));
let mut agent = Agent::new(
Arc::new(SilentProvider),
ToolRegistry::from_tools(Vec::new()),
AgentConfig::default(),
);
let owning_fetcher: MessageFetcher = Arc::new(|| {
Box::pin(async move {
vec![QueuedAgentMessage::generated(Message::User(UserMessage {
content: UserContent::Text("owning-follow-up".to_string()),
timestamp: 0,
}))]
})
});
agent.register_initial_follow_up_fetcher(owning_fetcher);
agent.set_queue_modes(QueueMode::OneAtATime, QueueMode::All);
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
crate::jobs::push_completion_notice(&session_id, "non-starved-job-notice")
.expect("job notice");
assert!(agent_session.agent.stage_follow_up_messages().await);
let deliveries = agent_session.agent.message_queue.pop_follow_up();
assert_eq!(deliveries.len(), 2);
assert_user_text(deliveries[0].message(), "owning-follow-up");
assert_user_text(deliveries[1].message(), "non-starved-job-notice");
assert!(crate::jobs::take_completion_notices(&session_id).is_empty());
});
}
#[test]
fn one_at_a_time_job_handoff_keeps_only_one_registry_batch_staged() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let session_state = Session::in_memory();
let session_id = session_state.header.id.clone();
let session = Arc::new(Mutex::new(session_state));
let mut agent = Agent::new(
Arc::new(SilentProvider),
ToolRegistry::from_tools(Vec::new()),
AgentConfig::default(),
);
agent.set_queue_modes(QueueMode::OneAtATime, QueueMode::OneAtATime);
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
for round in 0..3 {
for index in 0..crate::jobs::MAX_COMPLETION_NOTICES_PER_SESSION {
crate::jobs::push_completion_notice(
&session_id,
format!("job-notice-{round}-{index}"),
)
.expect("job notice");
}
assert!(agent_session.agent.stage_follow_up_messages().await);
let staged_jobs = agent_session
.agent
.message_queue
.follow_up
.iter()
.filter(|entry| entry.job_owner_session_id.is_some())
.count();
assert!(
staged_jobs <= crate::jobs::MAX_COMPLETION_NOTICES_PER_SESSION,
"the Agent queue must retain at most one bounded registry batch"
);
assert_eq!(
agent_session.agent.message_queue.pop_follow_up().len(),
1,
"one-at-a-time mode must consume exactly one staged notice"
);
}
let retained_registry_batch = crate::jobs::take_completion_notices(&session_id);
assert_eq!(
retained_registry_batch.len(),
crate::jobs::MAX_COMPLETION_NOTICES_PER_SESSION,
"the registry must retain only its independent bounded batch"
);
});
}
#[derive(Debug)]
struct DeltaOnlyProvider;
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for DeltaOnlyProvider {
fn name(&self) -> &str {
"test-provider"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"test-model"
}
async fn stream(
&self,
_context: &Context<'_>,
_options: &StreamOptions,
) -> crate::error::Result<
Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
> {
let final_message = assistant_message("hello");
let events = vec![
Ok(StreamEvent::TextDelta {
content_index: 0,
delta: "hello".to_string(),
}),
Ok(StreamEvent::Done {
reason: StopReason::Stop,
message: final_message,
}),
];
Ok(Box::pin(futures::stream::iter(events)))
}
}
#[derive(Debug, Clone, Default)]
struct CapturedProviderContext {
system_prompt: Option<String>,
messages: Vec<Message>,
thinking_level: Option<crate::model::ThinkingLevel>,
}
#[derive(Debug)]
struct CapturingProvider {
api: &'static str,
calls: StdArc<StdTestMutex<Vec<CapturedProviderContext>>>,
}
impl CapturingProvider {
fn new(api: &'static str) -> Self {
Self {
api,
calls: StdArc::new(StdTestMutex::new(Vec::new())),
}
}
fn calls(&self) -> StdArc<StdTestMutex<Vec<CapturedProviderContext>>> {
StdArc::clone(&self.calls)
}
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for CapturingProvider {
fn name(&self) -> &str {
"capturing-provider"
}
fn api(&self) -> &str {
self.api
}
fn model_id(&self) -> &str {
"capture-model"
}
async fn stream(
&self,
context: &Context<'_>,
options: &StreamOptions,
) -> crate::error::Result<
Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
> {
self.calls
.lock()
.expect("capture context lock")
.push(CapturedProviderContext {
system_prompt: context.system_prompt.as_ref().map(ToString::to_string),
messages: context.messages.iter().cloned().collect(),
thinking_level: options.thinking_level,
});
let final_message = assistant_message("captured");
Ok(Box::pin(futures::stream::iter(vec![Ok(
StreamEvent::Done {
reason: StopReason::Stop,
message: final_message,
},
)])))
}
}
fn sample_semantic_context_bundle() -> SemanticContextBundle {
use crate::semantic_workspace_graph::{
ContextBundleBudget, ContextBundleExclusion, ContextBundleInvalidationPolicy,
ContextRedactionSummary, EvidenceFreshnessStatus, RedactionStatus, SemanticNodeType,
};
SemanticContextBundle {
schema: crate::semantic_workspace_graph::SEMANTIC_CONTEXT_BUNDLE_SCHEMA.to_string(),
budget: ContextBundleBudget {
max_items: 8,
max_bytes: 4096,
},
selected_items: vec![
ContextBundleItem {
node_id: "node-session".to_string(),
node_type: SemanticNodeType::CodeSymbol,
source_path: "src/agent.rs".to_string(),
title: "AgentSession::run_agent_with_text".to_string(),
reason: "query_match,related_to_bead_or_changed_path".to_string(),
score: 420,
estimated_bytes: 700,
estimated_tokens: 175,
freshness_status: None,
redaction_status: RedactionStatus::None,
},
ContextBundleItem {
node_id: "node-test".to_string(),
node_type: SemanticNodeType::TestCase,
source_path: "tests/agent_loop_reliability.rs".to_string(),
title: "semantic context session coverage".to_string(),
reason: "validation_context".to_string(),
score: 300,
estimated_bytes: 400,
estimated_tokens: 100,
freshness_status: Some(EvidenceFreshnessStatus::Current),
redaction_status: RedactionStatus::Redacted,
},
],
excluded_items: vec![ContextBundleExclusion {
node_id: "stale-doc".to_string(),
node_type: SemanticNodeType::DocSection,
source_path: "README.md".to_string(),
title: "obsolete drop-in claim".to_string(),
reason: "suppressed_stale_or_unsafe_evidence".to_string(),
score: 250,
estimated_bytes: 300,
freshness_status: Some(EvidenceFreshnessStatus::Uncertified),
redaction_status: RedactionStatus::SensitiveOmitted,
}],
stale_evidence_suppressions: Vec::new(),
redaction_summary: ContextRedactionSummary {
policy_version: "test-policy".to_string(),
overall_status: RedactionStatus::Redacted,
selected_redacted_nodes: 1,
selected_sensitive_omissions: 0,
suppressed_unsafe_nodes: 0,
redacted_metadata_keys: BTreeSet::from(["api_key".to_string()]),
sensitive_path_kinds: BTreeSet::new(),
},
invalidation_policy: ContextBundleInvalidationPolicy {
policy_version: "test-policy".to_string(),
workspace_id: "workspace:test".to_string(),
branch: Some("main".to_string()),
session_id: Some("session-123".to_string()),
input_fingerprint_sha256: "abc123".repeat(10),
cache_ttl_seconds: 900,
generated_at_utc: Some("2026-05-13T00:00:00Z".to_string()),
expires_at_utc: Some("2026-05-13T00:15:00Z".to_string()),
invalidates_on: vec!["input_fingerprint_change".to_string()],
cacheable: true,
},
path_normalization: Vec::new(),
suggested_validation_commands: vec![
"cargo test agent_semantic_context".to_string(),
"cargo check --all-targets".to_string(),
],
estimated_bytes: 1100,
estimated_tokens: 275,
}
}
#[test]
fn semantic_context_exclusion_prompt_deduplicates_before_row_cap() {
let mut bundle = sample_semantic_context_bundle();
let duplicate = bundle.excluded_items[0].clone();
bundle.stale_evidence_suppressions.push(duplicate);
for index in 0..8 {
let mut exclusion = bundle.excluded_items[0].clone();
exclusion.node_id = format!("excluded-{index}");
exclusion.source_path = format!("docs/evidence/excluded-{index}.json");
exclusion.title = format!("excluded evidence {index}");
bundle.excluded_items.push(exclusion);
}
let mut prompt = String::new();
let mut stats = SemanticContextPromptStats::default();
push_semantic_context_exclusions(
&mut prompt,
&mut stats,
SemanticContextPromptBudget {
max_items: 16,
max_bytes: 64 * 1024,
},
&bundle,
);
assert_eq!(
prompt
.matches(
"README.md :: obsolete drop-in claim reason=suppressed_stale_or_unsafe_evidence",
)
.count(),
1,
"the same exclusion from both bundle collections must render once"
);
assert_eq!(stats.exclusions_included, 8);
assert_eq!(
stats.exclusions_omitted, 1,
"the row cap must count omitted unique exclusions, not raw duplicate records"
);
}
#[test]
fn delta_without_start_does_not_mutate_previous_message() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = Arc::new(DeltaOnlyProvider);
let tools = ToolRegistry::from_tools(Vec::new());
let mut agent = Agent::new(provider, tools, AgentConfig::default());
agent.add_message(Message::Assistant(Arc::new(assistant_message("prev"))));
agent
.run_with_message_with_abort(user_message("hi"), None, |_| {})
.await
.expect("run");
let assistant_texts = agent
.messages()
.iter()
.filter_map(|message| match message {
Message::Assistant(msg)
if matches!(msg.content.as_slice(), [ContentBlock::Text(_)]) =>
{
if let [ContentBlock::Text(text)] = msg.content.as_slice() {
Some(text.text.clone())
} else {
None
}
}
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
assistant_texts.as_slice(),
["prev".to_string(), "hello".to_string()]
);
});
}
#[derive(Debug)]
struct TruncatedToolCallProvider {
stream_calls: StdArc<std::sync::atomic::AtomicUsize>,
}
impl TruncatedToolCallProvider {
fn new() -> Self {
Self {
stream_calls: StdArc::new(std::sync::atomic::AtomicUsize::new(0)),
}
}
fn calls(&self) -> StdArc<std::sync::atomic::AtomicUsize> {
StdArc::clone(&self.stream_calls)
}
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for TruncatedToolCallProvider {
fn name(&self) -> &str {
"test-provider"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"test-model"
}
async fn stream(
&self,
_context: &Context<'_>,
_options: &StreamOptions,
) -> crate::error::Result<
Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
> {
let call_index = self.stream_calls.fetch_add(1, Ordering::SeqCst);
if call_index > 0 {
let events = vec![Ok(StreamEvent::Done {
reason: StopReason::Stop,
message: assistant_message("recovered"),
})];
return Ok(Box::pin(futures::stream::iter(events)));
}
let mut truncated = assistant_message("Let me read that file");
truncated.stop_reason = StopReason::Length;
truncated.content.push(ContentBlock::ToolCall(ToolCall {
id: "call-1".to_string(),
name: "read_file".to_string(),
arguments: Value::Null,
thought_signature: None,
}));
let events = vec![
Ok(StreamEvent::Start {
partial: assistant_message(""),
}),
Ok(StreamEvent::TextDelta {
content_index: 0,
delta: "Let me read that file".to_string(),
}),
Ok(StreamEvent::ToolCallStart {
content_index: 1,
id: "call-1".to_string(),
name: "read_file".to_string(),
}),
Ok(StreamEvent::ToolCallDelta {
content_index: 1,
delta: "{\"path\": \"/etc/ho".to_string(),
}),
Ok(StreamEvent::Done {
reason: StopReason::Length,
message: truncated,
}),
];
Ok(Box::pin(futures::stream::iter(events)))
}
}
#[derive(Debug)]
struct TruncatedTextProvider;
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for TruncatedTextProvider {
fn name(&self) -> &str {
"test-provider"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"test-model"
}
async fn stream(
&self,
_context: &Context<'_>,
_options: &StreamOptions,
) -> crate::error::Result<
Pin<Box<dyn Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
> {
let mut truncated = assistant_message("The answer is fourty-tw");
truncated.stop_reason = StopReason::Length;
let events = vec![
Ok(StreamEvent::Start {
partial: assistant_message(""),
}),
Ok(StreamEvent::TextDelta {
content_index: 0,
delta: "The answer is fourty-tw".to_string(),
}),
Ok(StreamEvent::Done {
reason: StopReason::Length,
message: truncated,
}),
];
Ok(Box::pin(futures::stream::iter(events)))
}
}
#[test]
fn truncated_tool_call_finalizes_as_error() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = TruncatedToolCallProvider::new();
let stream_calls = provider.calls();
let tools = ToolRegistry::from_tools(Vec::new());
let mut agent = Agent::new(Arc::new(provider), tools, AgentConfig::default());
let result = agent
.run_with_message_with_abort(user_message("read /etc/hosts"), None, |_| {})
.await
.expect("run");
assert_eq!(
result.stop_reason,
StopReason::Error,
"truncated tool call must not finalize as a normal turn"
);
let error_message = result
.error_message
.as_deref()
.expect("truncated turn carries an error message");
assert!(
error_message.contains("truncated before tool call completed"),
"unexpected error message: {error_message}"
);
assert_eq!(
stream_calls.load(Ordering::SeqCst),
1,
"agent kept running past the truncated tool call"
);
let last = agent
.messages()
.iter()
.rev()
.find_map(|message| match message {
Message::Assistant(assistant) => Some(Arc::clone(assistant)),
_ => None,
})
.expect("assistant message in history");
assert_eq!(last.stop_reason, StopReason::Error);
assert!(
last.content
.iter()
.any(|block| matches!(block, ContentBlock::Text(text)
if text.text == "Let me read that file")),
"partial text was dropped: {:?}",
last.content
);
});
}
#[test]
fn truncated_text_without_tool_call_stays_a_length_stop() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = Arc::new(TruncatedTextProvider);
let tools = ToolRegistry::from_tools(Vec::new());
let mut agent = Agent::new(provider, tools, AgentConfig::default());
let result = agent
.run_with_message_with_abort(user_message("what is the answer"), None, |_| {})
.await
.expect("run");
assert_eq!(
result.stop_reason,
StopReason::Length,
"a plain length stop must not be rewritten into an error"
);
assert_eq!(result.error_message, None);
assert!(
result
.content
.iter()
.any(|block| matches!(block, ContentBlock::Text(text)
if text.text == "The answer is fourty-tw")),
"partial text was dropped: {:?}",
result.content
);
});
}
#[test]
fn semantic_context_bundle_injection_is_disabled_by_default() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = CapturingProvider::new("openai-responses");
let calls = provider.calls();
let agent = Agent::new(
Arc::new(provider),
ToolRegistry::from_tools(Vec::new()),
AgentConfig::default(),
);
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
agent_session
.run_text("hello".to_string(), |_| {})
.await
.expect("run with default context settings");
let recorded_calls = {
let guard = match calls.lock() {
Ok(calls) => calls,
Err(poisoned) => poisoned.into_inner(),
};
guard.clone()
};
assert_eq!(recorded_calls.len(), 1);
assert_eq!(recorded_calls[0].messages.len(), 1);
assert_user_text(&recorded_calls[0].messages[0], "hello");
assert!(recorded_calls[0].system_prompt.is_none());
});
}
#[test]
fn queued_steering_and_follow_up_messages_apply_magic_keywords_on_delivery() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = CapturingProvider::new("openai-responses");
let calls = provider.calls();
let mut agent = Agent::new(
Arc::new(provider),
ToolRegistry::from_tools(Vec::new()),
AgentConfig {
stream_options: StreamOptions {
thinking_level: Some(crate::model::ThinkingLevel::Low),
..StreamOptions::default()
},
..AgentConfig::default()
},
);
agent.set_keyword_max_thinking_level(crate::model::ThinkingLevel::High);
agent.queue_steering(user_message("ultrathink before continuing"));
agent.queue_follow_up(user_message("orchestrate the follow-up"));
agent
.run_with_message_with_abort(user_message("start normally"), None, |_| {})
.await
.expect("queued messages complete");
let recorded_calls = {
let guard = match calls.lock() {
Ok(calls) => calls,
Err(poisoned) => poisoned.into_inner(),
};
guard.clone()
};
assert_eq!(
recorded_calls.len(),
2,
"follow-up must trigger a second provider turn"
);
assert_eq!(
recorded_calls[0].thinking_level,
Some(crate::model::ThinkingLevel::High),
"queued steering ultrathink must affect its first outbound request"
);
assert!(
recorded_calls[0]
.system_prompt
.as_deref()
.is_none_or(|prompt| !prompt.contains("invoked `orchestrate`")),
"the later follow-up directive must not leak into the first request"
);
assert!(
recorded_calls[1]
.system_prompt
.as_deref()
.is_some_and(|prompt| prompt.contains("invoked `orchestrate`")),
"queued follow-up orchestrate must affect its outbound request"
);
let activations = agent.drain_keyword_ledger();
assert_eq!(activations.len(), 2);
assert_eq!(activations[0].word, "ultrathink");
assert_eq!(activations[1].word, "orchestrate");
});
}
#[test]
#[allow(clippy::too_many_lines)]
fn continuation_follow_up_first_precedes_provider_and_later_steering() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = CapturingProvider::new("openai-responses");
let calls = provider.calls();
let mut agent = Agent::new(
Arc::new(provider),
ToolRegistry::from_tools(Vec::new()),
AgentConfig {
stream_options: StreamOptions {
thinking_level: Some(crate::model::ThinkingLevel::Low),
..StreamOptions::default()
},
..AgentConfig::default()
},
);
agent.set_keyword_max_thinking_level(crate::model::ThinkingLevel::High);
agent.set_queue_modes(QueueMode::All, QueueMode::All);
let queued_steering = Arc::new(StdTestMutex::new(Some(
QueuedAgentMessage::from_authored_message(user_message("steer after handoff")),
)));
let steering_fetcher_state = Arc::clone(&queued_steering);
let steering_fetcher =
move || -> futures::future::BoxFuture<'static, Vec<QueuedAgentMessage>> {
let steering_fetcher_state = Arc::clone(&steering_fetcher_state);
Box::pin(async move {
steering_fetcher_state
.lock()
.ok()
.and_then(|mut queued| queued.take())
.into_iter()
.collect()
})
};
let first_visible = "expanded payload without a magic word";
let second_visible = "generated ultrathink payload";
let queued_follow_up = Arc::new(StdTestMutex::new(Some(vec![
QueuedAgentMessage::authored(
user_message(first_visible),
"please orchestrate this follow-up",
),
QueuedAgentMessage::generated(user_message(second_visible)),
])));
let follow_up_fetcher_state = Arc::clone(&queued_follow_up);
let follow_up_fetcher =
move || -> futures::future::BoxFuture<'static, Vec<QueuedAgentMessage>> {
let follow_up_fetcher_state = Arc::clone(&follow_up_fetcher_state);
Box::pin(async move {
follow_up_fetcher_state
.lock()
.ok()
.and_then(|mut queued| queued.take())
.unwrap_or_default()
})
};
agent.register_message_fetchers(Some(Arc::new(steering_fetcher)), None);
agent.register_initial_follow_up_fetcher(Arc::new(follow_up_fetcher));
agent
.run_continue_with_follow_up_with_abort(None, |_| {})
.await
.expect("follow-up-first continuation");
let recorded_calls = {
let guard = match calls.lock() {
Ok(calls) => calls,
Err(poisoned) => poisoned.into_inner(),
};
guard.clone()
};
assert_eq!(
recorded_calls.len(),
2,
"initial follow-up and later steering must each drive one provider request"
);
assert_eq!(recorded_calls[0].messages.len(), 2);
assert_user_text(&recorded_calls[0].messages[0], first_visible);
assert_user_text(&recorded_calls[0].messages[1], second_visible);
assert!(
recorded_calls[0].messages.iter().all(|message| {
!matches!(
message,
Message::User(UserMessage { content: UserContent::Text(text), .. })
if text == "steer after handoff"
)
}),
"steering admitted for the resumed turn must not precede its initial follow-up"
);
assert!(recorded_calls[1].messages.iter().any(|message| {
matches!(
message,
Message::User(UserMessage { content: UserContent::Text(text), .. })
if text == "steer after handoff"
)
}));
assert_eq!(
recorded_calls[0].thinking_level,
Some(crate::model::ThinkingLevel::Low),
"generated ultrathink bytes must remain inert"
);
assert!(
recorded_calls[0]
.system_prompt
.as_deref()
.is_some_and(|prompt| prompt.contains("invoked `orchestrate`")),
"the exact authored source must activate its directive"
);
});
}
#[test]
fn continuation_preflights_owning_source_before_consuming_staged_follow_up() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = CapturingProvider::new("openai-responses");
let calls = provider.calls();
let mut agent = Agent::new(
Arc::new(provider),
ToolRegistry::from_tools(Vec::new()),
AgentConfig::default(),
);
agent.set_queue_modes(QueueMode::All, QueueMode::All);
agent.queue_follow_up(user_message("older staged follow-up"));
let owning_source_fetched = StdArc::new(AtomicBool::new(false));
let fetched_for_source = StdArc::clone(&owning_source_fetched);
let queued_owning = StdArc::new(StdTestMutex::new(Some(queued_user_message(
"new owning follow-up",
))));
let queued_for_source = StdArc::clone(&queued_owning);
let follow_up_fetcher =
move || -> futures::future::BoxFuture<'static, Vec<QueuedAgentMessage>> {
fetched_for_source.store(true, Ordering::SeqCst);
let queued_for_source = StdArc::clone(&queued_for_source);
Box::pin(async move {
queued_for_source
.lock()
.ok()
.and_then(|mut queued| queued.take())
.into_iter()
.collect()
})
};
agent.register_initial_follow_up_fetcher(Arc::new(follow_up_fetcher));
let events = StdArc::new(StdTestMutex::new(Vec::new()));
let events_for_failed_preflight = StdArc::clone(&events);
let result = agent
.run_continue_with_follow_up_on_ready_with_abort(
None,
|| false,
move |event| {
events_for_failed_preflight
.lock()
.expect("event capture")
.push(event);
},
)
.await;
assert!(result.is_err(), "failed source preflight must stop the run");
assert!(
owning_source_fetched.load(Ordering::SeqCst),
"an older staged batch must not bypass the owning fetcher"
);
assert!(events.lock().expect("event capture").is_empty());
assert!(calls.lock().expect("provider calls").is_empty());
assert_eq!(agent.message_queue.follow_up.len(), 2);
assert_user_text(
&agent.message_queue.follow_up[0].delivery.message,
"older staged follow-up",
);
assert_user_text(
&agent.message_queue.follow_up[1].delivery.message,
"new owning follow-up",
);
agent
.run_continue_with_follow_up_with_abort(None, |_| {})
.await
.expect("resume retained follow-ups after successful preflight");
let recorded_calls = calls.lock().expect("provider calls").clone();
assert_eq!(recorded_calls.len(), 1);
assert_eq!(recorded_calls[0].messages.len(), 2);
assert_user_text(&recorded_calls[0].messages[0], "older staged follow-up");
assert_user_text(&recorded_calls[0].messages[1], "new owning follow-up");
});
}
#[test]
fn max_time_restores_undelivered_steering_for_next_production_run() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = CapturingProvider::new("openai-responses");
let calls = provider.calls();
let mut agent = Agent::new(
Arc::new(provider),
ToolRegistry::from_tools(Vec::new()),
AgentConfig {
max_time: Some(std::time::Duration::ZERO),
..AgentConfig::default()
},
);
agent.set_queue_modes(QueueMode::OneAtATime, QueueMode::All);
agent.queue_steering(user_message("accepted steering one"));
agent.queue_steering(user_message("accepted steering two"));
agent.queue_steering(user_message("accepted steering three"));
let events = StdArc::new(StdTestMutex::new(Vec::new()));
let events_for_capped_run = StdArc::clone(&events);
let capped = agent
.run_with_message_with_abort(user_message("initial prompt"), None, move |event| {
events_for_capped_run
.lock()
.expect("event capture")
.push(event);
})
.await
.expect("time-capped run");
assert!(assistant_text_content(&capped.content).contains("time cap reached"));
assert!(calls.lock().expect("provider calls").is_empty());
assert_eq!(agent.message_queue.steering.len(), 3);
assert_user_text(
&agent.message_queue.steering[0].delivery.message,
"accepted steering one",
);
assert_user_text(
&agent.message_queue.steering[1].delivery.message,
"accepted steering two",
);
assert_user_text(
&agent.message_queue.steering[2].delivery.message,
"accepted steering three",
);
let captured_events = { events.lock().expect("event capture").clone() };
assert_eq!(
captured_events
.iter()
.filter(|event| matches!(event, AgentEvent::AgentStart { .. }))
.count(),
1
);
assert_eq!(
captured_events
.iter()
.filter(|event| matches!(event, AgentEvent::AgentEnd { .. }))
.count(),
1
);
assert!(captured_events.iter().all(|event| !matches!(
event,
AgentEvent::TurnStart { .. } | AgentEvent::TurnEnd { .. }
)));
agent.config.max_time = None;
agent.set_queue_modes(QueueMode::All, QueueMode::All);
agent
.run_with_message_with_abort(user_message("resume prompt"), None, |_| {})
.await
.expect("next production run");
assert!(agent.message_queue.steering.is_empty());
let recorded_calls = calls.lock().expect("provider calls").clone();
assert_eq!(recorded_calls.len(), 1);
let user_texts = recorded_calls[0]
.messages
.iter()
.filter_map(|message| match message {
Message::User(UserMessage {
content: UserContent::Text(text),
..
}) => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
user_texts,
vec![
"initial prompt",
"resume prompt",
"accepted steering one",
"accepted steering two",
"accepted steering three"
]
);
});
}
#[test]
fn continuation_follow_up_first_preserves_full_admitted_batch() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = CapturingProvider::new("openai-responses");
let calls = provider.calls();
let mut agent = Agent::new(
Arc::new(provider),
ToolRegistry::from_tools(Vec::new()),
AgentConfig::default(),
);
agent.set_queue_modes(QueueMode::All, QueueMode::All);
let expected = (0..128)
.map(|index| format!("admitted follow-up {index}"))
.collect::<Vec<_>>();
let queued_follow_up = Arc::new(StdTestMutex::new(Some(
expected
.iter()
.map(|text| {
QueuedAgentMessage::from_authored_message(user_message(text.as_str()))
})
.collect::<Vec<_>>(),
)));
let follow_up_fetcher_state = Arc::clone(&queued_follow_up);
let follow_up_fetcher =
move || -> futures::future::BoxFuture<'static, Vec<QueuedAgentMessage>> {
let follow_up_fetcher_state = Arc::clone(&follow_up_fetcher_state);
Box::pin(async move {
follow_up_fetcher_state
.lock()
.ok()
.and_then(|mut queued| queued.take())
.unwrap_or_default()
})
};
agent.register_initial_follow_up_fetcher(Arc::new(follow_up_fetcher));
agent
.run_continue_with_follow_up_with_abort(None, |_| {})
.await
.expect("full admitted follow-up batch");
let recorded_calls = {
let guard = match calls.lock() {
Ok(calls) => calls,
Err(poisoned) => poisoned.into_inner(),
};
guard.clone()
};
assert_eq!(recorded_calls.len(), 1);
assert_eq!(recorded_calls[0].messages.len(), expected.len());
for (message, expected_text) in recorded_calls[0].messages.iter().zip(&expected) {
assert_user_text(message, expected_text);
}
});
}
#[test]
fn owning_steering_fetcher_preserves_full_admitted_batch() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = CapturingProvider::new("openai-responses");
let calls = provider.calls();
let mut agent = Agent::new(
Arc::new(provider),
ToolRegistry::from_tools(Vec::new()),
AgentConfig::default(),
);
agent.set_queue_modes(QueueMode::All, QueueMode::All);
let expected = (0..128)
.map(|index| format!("admitted steering {index}"))
.collect::<Vec<_>>();
let queued = Arc::new(StdTestMutex::new(Some(
expected
.iter()
.map(|text| {
QueuedAgentMessage::from_authored_message(user_message(text.as_str()))
})
.collect::<Vec<_>>(),
)));
let fetcher_state = Arc::clone(&queued);
let fetcher =
move || -> futures::future::BoxFuture<'static, Vec<QueuedAgentMessage>> {
let fetcher_state = Arc::clone(&fetcher_state);
Box::pin(async move {
fetcher_state
.lock()
.ok()
.and_then(|mut queued| queued.take())
.unwrap_or_default()
})
};
agent.register_message_fetchers(Some(Arc::new(fetcher)), None);
agent
.run_with_message_with_abort(user_message("initial turn"), None, |_| {})
.await
.expect("full admitted steering batch");
let recorded_calls = match calls.lock() {
Ok(calls) => calls.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
};
assert_eq!(recorded_calls.len(), 1);
assert_eq!(recorded_calls[0].messages.len(), expected.len() + 1);
assert_user_text(&recorded_calls[0].messages[0], "initial turn");
for (message, expected_text) in recorded_calls[0].messages[1..].iter().zip(&expected) {
assert_user_text(message, expected_text);
}
});
}
#[test]
fn ordinary_idle_boundary_preserves_full_primary_follow_up_batch() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = CapturingProvider::new("openai-responses");
let calls = provider.calls();
let mut agent = Agent::new(
Arc::new(provider),
ToolRegistry::from_tools(Vec::new()),
AgentConfig::default(),
);
agent.set_queue_modes(QueueMode::All, QueueMode::All);
let expected = (0..128)
.map(|index| format!("ordinary follow-up {index}"))
.collect::<Vec<_>>();
let queued_follow_up = Arc::new(StdTestMutex::new(Some(
expected
.iter()
.map(|text| {
QueuedAgentMessage::from_authored_message(user_message(text.as_str()))
})
.collect::<Vec<_>>(),
)));
let follow_up_fetcher_state = Arc::clone(&queued_follow_up);
let follow_up_fetcher =
move || -> futures::future::BoxFuture<'static, Vec<QueuedAgentMessage>> {
let follow_up_fetcher_state = Arc::clone(&follow_up_fetcher_state);
Box::pin(async move {
follow_up_fetcher_state
.lock()
.ok()
.and_then(|mut queued| queued.take())
.unwrap_or_default()
})
};
agent.register_initial_follow_up_fetcher(Arc::new(follow_up_fetcher));
agent
.run_with_message_with_abort(user_message("initial turn"), None, |_| {})
.await
.expect("ordinary follow-up delivery");
let recorded_calls = {
let guard = match calls.lock() {
Ok(calls) => calls,
Err(poisoned) => poisoned.into_inner(),
};
guard.clone()
};
assert_eq!(recorded_calls.len(), 2);
let second_call = &recorded_calls[1].messages;
assert!(second_call.len() >= expected.len());
let delivered = &second_call[second_call.len() - expected.len()..];
for (message, expected_text) in delivered.iter().zip(&expected) {
assert_user_text(message, expected_text);
}
});
}
#[test]
#[allow(clippy::too_many_lines)]
fn ordinary_follow_up_batch_remains_staged_and_resumes_after_max_time() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = CapturingProvider::new("openai-responses");
let calls = provider.calls();
let mut agent = Agent::new(
Arc::new(provider),
ToolRegistry::from_tools(Vec::new()),
AgentConfig {
max_time: Some(std::time::Duration::from_millis(100)),
..AgentConfig::default()
},
);
agent.set_queue_modes(QueueMode::All, QueueMode::All);
let expected = (0..128)
.map(|index| format!("staged follow-up {index}"))
.collect::<Vec<_>>();
let queued_follow_up = Arc::new(StdTestMutex::new(Some(
expected
.iter()
.map(|text| {
QueuedAgentMessage::from_authored_message(user_message(text.as_str()))
})
.collect::<Vec<_>>(),
)));
let follow_up_fetcher_state = Arc::clone(&queued_follow_up);
let follow_up_fetcher =
move || -> futures::future::BoxFuture<'static, Vec<QueuedAgentMessage>> {
let follow_up_fetcher_state = Arc::clone(&follow_up_fetcher_state);
Box::pin(async move {
std::thread::sleep(std::time::Duration::from_millis(150));
follow_up_fetcher_state
.lock()
.ok()
.and_then(|mut queued| queued.take())
.unwrap_or_default()
})
};
agent.register_initial_follow_up_fetcher(Arc::new(follow_up_fetcher));
let events = StdArc::new(StdTestMutex::new(Vec::new()));
let events_for_run = StdArc::clone(&events);
let result = agent
.run_with_message_with_abort(user_message("initial turn"), None, move |event| {
events_for_run.lock().expect("event capture").push(event);
})
.await
.expect("max-time stop");
assert!(
assistant_text_content(&result.content).contains("time cap reached"),
"the returned terminal message must report the cap"
);
let calls_len = match calls.lock() {
Ok(calls) => calls.len(),
Err(poisoned) => poisoned.into_inner().len(),
};
assert_eq!(
calls_len, 1,
"the expired time cap must prevent a follow-up provider turn"
);
assert_eq!(
agent.message_queue.follow_up.len(),
128,
"the accepted batch must remain staged for a later run"
);
for (entry, expected_text) in agent.message_queue.follow_up.iter().zip(&expected) {
assert_user_text(&entry.delivery.message, expected_text);
}
let captured_events = { events.lock().expect("event capture").clone() };
assert_eq!(
captured_events
.iter()
.filter(|event| matches!(event, AgentEvent::AgentStart { .. }))
.count(),
1
);
let agent_ends = captured_events
.iter()
.filter_map(|event| match event {
AgentEvent::AgentEnd {
messages, error, ..
} => Some((messages, error)),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
agent_ends.len(),
1,
"AgentStart must have one matching AgentEnd"
);
assert!(agent_ends[0].1.is_none());
assert!(agent_ends[0].0.iter().any(|message| {
matches!(
message,
Message::Assistant(assistant)
if assistant_text_content(&assistant.content).contains("time cap reached")
)
}));
agent
.run_continue_with_follow_up_with_abort(None, |_| {})
.await
.expect("resume staged follow-up batch");
assert!(agent.message_queue.follow_up.is_empty());
let recorded_calls = match calls.lock() {
Ok(calls) => calls.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
};
assert_eq!(recorded_calls.len(), 2);
let resumed = &recorded_calls[1].messages;
assert!(resumed.len() >= expected.len());
for (message, expected_text) in resumed[resumed.len() - expected.len()..]
.iter()
.zip(&expected)
{
assert_user_text(message, expected_text);
}
});
}
#[test]
fn fetched_message_scans_only_explicit_authored_source() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = CapturingProvider::new("openai-responses");
let calls = provider.calls();
let mut agent = Agent::new(
Arc::new(provider),
ToolRegistry::from_tools(Vec::new()),
AgentConfig {
stream_options: StreamOptions {
thinking_level: Some(crate::model::ThinkingLevel::Low),
..StreamOptions::default()
},
..AgentConfig::default()
},
);
agent.set_keyword_max_thinking_level(crate::model::ThinkingLevel::High);
let expanded = "generated ultrathink and workflowz bytes".to_string();
let queued = Arc::new(StdTestMutex::new(Some(QueuedAgentMessage::authored(
user_message(&expanded),
"please orchestrate this queued turn",
))));
let fetch_queue = Arc::clone(&queued);
let fetcher =
move || -> futures::future::BoxFuture<'static, Vec<QueuedAgentMessage>> {
let fetch_queue = Arc::clone(&fetch_queue);
Box::pin(async move {
fetch_queue
.lock()
.ok()
.and_then(|mut queued| queued.take())
.into_iter()
.collect()
})
};
agent.register_message_fetchers(Some(Arc::new(fetcher)), None);
agent
.run_with_message_with_abort(user_message("start normally"), None, |_| {})
.await
.expect("source-aware queued message completes");
let recorded_calls = {
let guard = match calls.lock() {
Ok(calls) => calls,
Err(poisoned) => poisoned.into_inner(),
};
guard.clone()
};
assert_eq!(recorded_calls.len(), 1);
assert_eq!(
recorded_calls[0].thinking_level,
Some(crate::model::ThinkingLevel::Low),
"generated ultrathink must not raise effort"
);
let system_prompt = recorded_calls[0]
.system_prompt
.as_deref()
.expect("directive");
assert!(system_prompt.contains("invoked `orchestrate`"));
assert!(!system_prompt.contains("invoked `workflowz`"));
assert!(recorded_calls[0].messages.iter().any(|message| {
matches!(
message,
Message::User(UserMessage {
content: UserContent::Text(text),
..
}) if text == &expanded
)
}));
let activations = agent.drain_keyword_ledger();
assert_eq!(activations.len(), 1);
assert_eq!(activations[0].word, "orchestrate");
});
}
#[test]
fn fetched_generated_message_reaches_provider_without_keyword_effects() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = CapturingProvider::new("openai-responses");
let calls = provider.calls();
let mut agent = Agent::new(
Arc::new(provider),
ToolRegistry::from_tools(Vec::new()),
AgentConfig {
stream_options: StreamOptions {
thinking_level: Some(crate::model::ThinkingLevel::Low),
..StreamOptions::default()
},
..AgentConfig::default()
},
);
agent.set_keyword_max_thinking_level(crate::model::ThinkingLevel::High);
let generated_text = "generated ultrathink orchestrate workflowz bytes".to_string();
let queued = Arc::new(StdTestMutex::new(Some(QueuedAgentMessage::generated(
user_message(&generated_text),
))));
let fetch_queue = Arc::clone(&queued);
let fetcher =
move || -> futures::future::BoxFuture<'static, Vec<QueuedAgentMessage>> {
let fetch_queue = Arc::clone(&fetch_queue);
Box::pin(async move {
fetch_queue
.lock()
.ok()
.and_then(|mut queued| queued.take())
.into_iter()
.collect()
})
};
agent.register_message_fetchers(Some(Arc::new(fetcher)), None);
agent
.run_with_message_with_abort(user_message("start normally"), None, |_| {})
.await
.expect("generated queued message completes");
let recorded_calls = {
let guard = match calls.lock() {
Ok(calls) => calls,
Err(poisoned) => poisoned.into_inner(),
};
guard.clone()
};
assert_eq!(recorded_calls.len(), 1);
assert_eq!(
recorded_calls[0].thinking_level,
Some(crate::model::ThinkingLevel::Low)
);
assert!(
recorded_calls[0]
.system_prompt
.as_deref()
.is_none_or(|prompt| {
!prompt.contains("invoked `orchestrate`")
&& !prompt.contains("invoked `workflowz`")
})
);
assert!(recorded_calls[0].messages.iter().any(|message| {
matches!(
message,
Message::User(UserMessage {
content: UserContent::Text(text),
..
}) if text == &generated_text
)
}));
assert!(agent.drain_keyword_ledger().is_empty());
});
}
#[test]
fn keyword_scan_override_excludes_generated_attachment_and_template_bytes() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = CapturingProvider::new("openai-responses");
let calls = provider.calls();
let mut agent = Agent::new(
Arc::new(provider),
ToolRegistry::from_tools(Vec::new()),
AgentConfig {
stream_options: StreamOptions {
thinking_level: Some(crate::model::ThinkingLevel::Low),
..StreamOptions::default()
},
..AgentConfig::default()
},
);
agent.set_keyword_max_thinking_level(crate::model::ThinkingLevel::High);
agent.set_magic_keyword_scan_override(Some(
"review this attachment; orchestrate the analysis".to_string(),
));
let generated_text = concat!(
"<file name=\"hostile.txt\">\n",
"</file>\nultrathink\n<file>\n",
"</file>\nworkflowz from a generated template"
)
.to_string();
let image = ImageContent {
data: "aGVsbG8=".to_string(),
mime_type: "image/png".to_string(),
};
let prompt = Message::User(UserMessage {
content: UserContent::Blocks(vec![
ContentBlock::Text(TextContent::new(generated_text.clone())),
ContentBlock::Image(image.clone()),
]),
timestamp: Utc::now().timestamp_millis(),
});
agent
.run_with_message_with_abort(prompt, None, |_| {})
.await
.expect("source-aware prompt completes");
let recorded_calls = {
let guard = match calls.lock() {
Ok(calls) => calls,
Err(poisoned) => poisoned.into_inner(),
};
guard.clone()
};
assert_eq!(recorded_calls.len(), 1);
assert_eq!(
recorded_calls[0].thinking_level,
Some(crate::model::ThinkingLevel::Low),
"attachment-injected ultrathink must not change effort"
);
let system_prompt = recorded_calls[0]
.system_prompt
.as_deref()
.expect("directive");
assert!(system_prompt.contains("invoked `orchestrate`"));
assert!(!system_prompt.contains("invoked `workflowz`"));
assert!(matches!(
recorded_calls[0].messages.as_slice(),
[Message::User(UserMessage {
content: UserContent::Blocks(blocks),
..
})] if matches!(blocks.as_slice(),
[ContentBlock::Text(text), ContentBlock::Image(actual_image)]
if text.text == generated_text && actual_image.data == image.data)
));
let activations = agent.drain_keyword_ledger();
assert_eq!(activations.len(), 1);
assert_eq!(activations[0].word, "orchestrate");
});
}
#[test]
fn semantic_context_bundle_injection_adds_bounded_custom_message_and_session_provenance() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let bundle = sample_semantic_context_bundle();
let revision = semantic_context_bundle_revision(&bundle);
let provider = CapturingProvider::new("openai-responses");
let calls = provider.calls();
let agent = Agent::new(
Arc::new(provider),
ToolRegistry::from_tools(Vec::new()),
AgentConfig::default(),
);
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session = AgentSession::new(
agent,
Arc::clone(&session),
false,
ResolvedCompactionSettings::default(),
);
agent_session.set_semantic_context_bundle(Some(
SemanticContextBundleInjection::enabled(bundle).with_prompt_budget(4, 2048),
));
agent_session
.run_text("use context".to_string(), |_| {})
.await
.expect("run with context bundle");
{
let calls = match calls.lock() {
Ok(calls) => calls,
Err(poisoned) => poisoned.into_inner(),
};
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].messages.len(), 2);
assert_user_text(&calls[0].messages[0], "use context");
let custom = match &calls[0].messages[1] {
Message::Custom(custom) => custom,
other => {
assert!(
matches!(other, Message::Custom(_)),
"expected custom semantic context message"
);
return;
}
};
assert_eq!(custom.custom_type, SEMANTIC_CONTEXT_CUSTOM_TYPE);
assert!(custom.display);
assert!(custom.content.len() <= 2048);
assert!(custom.content.contains("Semantic Context Bundle"));
assert!(custom.content.contains("src/agent.rs"));
let details = custom.details.as_ref().expect("context provenance");
assert_eq!(
details.get("bundleRevision").and_then(Value::as_str),
Some(revision.as_str())
);
assert_eq!(
details
.pointer("/provider/promptShape")
.and_then(Value::as_str),
Some("custom_user_message")
);
drop(calls);
}
let cx = crate::agent_cx::AgentCx::for_request();
let stored = session
.lock(cx.cx())
.await
.expect("session lock")
.to_messages_for_current_path();
assert!(
stored.iter().any(|message| matches!(
message,
Message::Custom(CustomMessage { custom_type, details, display: true, .. })
if custom_type == SEMANTIC_CONTEXT_CUSTOM_TYPE
&& details
.as_ref()
.and_then(|value| value.get("bundleRevision"))
.and_then(Value::as_str)
== Some(revision.as_str())
)),
"semantic context provenance was not persisted in session messages: {stored:?}"
);
});
}
#[test]
fn semantic_context_bundle_uses_system_prompt_append_for_providers_without_custom_context() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let bundle = sample_semantic_context_bundle();
let revision = semantic_context_bundle_revision(&bundle);
let provider = CapturingProvider::new("gitlab-chat");
let calls = provider.calls();
let agent = Agent::new(
Arc::new(provider),
ToolRegistry::from_tools(Vec::new()),
AgentConfig {
system_prompt: Some("base prompt".to_string()),
..AgentConfig::default()
},
);
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session = AgentSession::new(
agent,
Arc::clone(&session),
false,
ResolvedCompactionSettings::default(),
);
agent_session.set_semantic_context_bundle(Some(
SemanticContextBundleInjection::enabled(bundle).with_prompt_budget(4, 2048),
));
agent_session
.run_text("gitlab turn".to_string(), |_| {})
.await
.expect("run with system prompt context");
{
let calls = match calls.lock() {
Ok(calls) => calls,
Err(poisoned) => poisoned.into_inner(),
};
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].messages.len(), 1);
assert_user_text(&calls[0].messages[0], "gitlab turn");
let system_prompt = calls[0].system_prompt.as_deref().expect("system prompt");
assert!(system_prompt.contains("base prompt"));
assert!(system_prompt.contains("Semantic Context Bundle"));
assert!(system_prompt.contains("src/agent.rs"));
drop(calls);
}
let cx = crate::agent_cx::AgentCx::for_request();
let stored = session
.lock(cx.cx())
.await
.expect("session lock")
.to_messages_for_current_path();
assert!(
stored.iter().any(|message| matches!(
message,
Message::Custom(CustomMessage { custom_type, details, display: false, .. })
if custom_type == SEMANTIC_CONTEXT_CUSTOM_TYPE
&& details
.as_ref()
.and_then(|value| value.get("bundleRevision"))
.and_then(Value::as_str)
== Some(revision.as_str())
)),
"hidden semantic context provenance was not persisted in session messages: {stored:?}"
);
assert_eq!(agent_session.agent.system_prompt(), Some("base prompt"));
});
}
#[test]
fn enable_extensions_policy_resolution_defaults_to_permissive() {
let policy = AgentSession::resolve_extension_policy_for_enable(None, None);
assert_eq!(
policy.mode,
crate::extensions::ExtensionPolicyMode::Permissive
);
}
#[test]
fn enable_extensions_policy_resolution_respects_config_default_toggle() {
let config = crate::config::Config {
extension_policy: Some(crate::config::ExtensionPolicyConfig {
profile: None,
default_permissive: Some(false),
allow_dangerous: None,
}),
..Default::default()
};
let policy = AgentSession::resolve_extension_policy_for_enable(Some(&config), None);
assert_eq!(policy.mode, crate::extensions::ExtensionPolicyMode::Strict);
}
#[test]
fn enable_extensions_policy_resolution_prefers_explicit_policy() {
let config = crate::config::Config {
extension_policy: Some(crate::config::ExtensionPolicyConfig {
profile: None,
default_permissive: Some(false),
allow_dangerous: None,
}),
..Default::default()
};
let explicit = crate::extensions::PolicyProfile::Permissive.to_policy();
let policy =
AgentSession::resolve_extension_policy_for_enable(Some(&config), Some(explicit));
assert_eq!(
policy.mode,
crate::extensions::ExtensionPolicyMode::Permissive
);
}
#[test]
fn test_extract_tool_calls() {
let content = vec![
ContentBlock::Text(TextContent::new("Hello")),
ContentBlock::ToolCall(ToolCall {
id: "tc1".to_string(),
name: "read".to_string(),
arguments: serde_json::json!({"path": "file.txt"}),
thought_signature: None,
}),
ContentBlock::Text(TextContent::new("World")),
ContentBlock::ToolCall(ToolCall {
id: "tc2".to_string(),
name: "bash".to_string(),
arguments: serde_json::json!({"command": "ls"}),
thought_signature: None,
}),
];
let tool_calls = extract_tool_calls(&content);
assert_eq!(tool_calls.len(), 2);
assert_eq!(tool_calls[0].name, "read");
assert_eq!(tool_calls[1].name, "bash");
}
#[test]
fn test_agent_config_default() {
let config = AgentConfig::default();
let expected = resolved_max_tool_iterations_default();
assert_eq!(config.max_tool_iterations, expected);
assert!(config.system_prompt.is_none());
assert!(!config.block_images);
}
#[test]
fn resolve_max_tool_iterations_handles_unset_empty_and_whitespace() {
assert_eq!(
resolve_max_tool_iterations(None),
MAX_TOOL_ITERATIONS_DEFAULT
);
assert_eq!(
resolve_max_tool_iterations(Some("")),
MAX_TOOL_ITERATIONS_DEFAULT
);
assert_eq!(
resolve_max_tool_iterations(Some(" ")),
MAX_TOOL_ITERATIONS_DEFAULT
);
}
#[test]
fn resolve_max_tool_iterations_rejects_zero_and_invalid() {
assert_eq!(
resolve_max_tool_iterations(Some("0")),
MAX_TOOL_ITERATIONS_DEFAULT
);
assert_eq!(
resolve_max_tool_iterations(Some("not-a-number")),
MAX_TOOL_ITERATIONS_DEFAULT
);
assert_eq!(
resolve_max_tool_iterations(Some("-5")),
MAX_TOOL_ITERATIONS_DEFAULT
);
assert_eq!(
resolve_max_tool_iterations(Some("3.14")),
MAX_TOOL_ITERATIONS_DEFAULT
);
}
#[test]
fn resolve_max_tool_iterations_accepts_valid_overrides_and_trims_whitespace() {
assert_eq!(resolve_max_tool_iterations(Some("1")), 1);
assert_eq!(resolve_max_tool_iterations(Some("100")), 100);
assert_eq!(resolve_max_tool_iterations(Some(" 200 ")), 200);
assert_eq!(resolve_max_tool_iterations(Some("999")), 999);
}
#[test]
fn resolve_max_tool_iterations_clamps_above_ceiling() {
assert_eq!(
resolve_max_tool_iterations(Some("99999")),
MAX_TOOL_ITERATIONS_CEILING
);
assert_eq!(
resolve_max_tool_iterations(Some("1000")),
MAX_TOOL_ITERATIONS_CEILING
);
}
#[test]
fn clamp_max_tool_iterations_matches_resolve_semantics() {
assert_eq!(clamp_max_tool_iterations(None), MAX_TOOL_ITERATIONS_DEFAULT);
assert_eq!(
clamp_max_tool_iterations(Some(0)),
MAX_TOOL_ITERATIONS_DEFAULT
);
assert_eq!(clamp_max_tool_iterations(Some(7)), 7);
assert_eq!(
clamp_max_tool_iterations(Some(usize::MAX)),
MAX_TOOL_ITERATIONS_CEILING
);
}
#[test]
fn iteration_warning_fires_at_80_percent_for_default_cap() {
assert!(!should_warn_at_iteration_threshold(39, 50));
assert!(should_warn_at_iteration_threshold(40, 50));
assert!(should_warn_at_iteration_threshold(50, 50));
assert!(!should_warn_at_iteration_threshold(0, 50));
}
#[test]
fn iteration_warning_fires_at_80_percent_for_custom_caps() {
for (cap, threshold) in [(100usize, 80usize), (200, 160), (1000, 800)] {
assert!(
!should_warn_at_iteration_threshold(threshold - 1, cap),
"expected no warning below threshold (current=cap={cap}, threshold={threshold})"
);
assert!(
should_warn_at_iteration_threshold(threshold, cap),
"expected warning at threshold (cap={cap}, threshold={threshold})"
);
}
}
#[test]
fn iteration_warning_skipped_for_caps_below_minimum() {
for cap in 0..ITERATION_WARN_MIN_CAP {
for current in 0..=cap.saturating_add(2) {
assert!(
!should_warn_at_iteration_threshold(current, cap),
"should not warn at current={current} cap={cap}"
);
}
}
}
#[test]
fn iteration_warning_handles_minimum_warnable_cap_boundary() {
assert!(!should_warn_at_iteration_threshold(3, 5));
assert!(should_warn_at_iteration_threshold(4, 5));
assert!(should_warn_at_iteration_threshold(5, 5));
}
#[test]
fn iteration_warning_handles_overflow_resistant_caps() {
assert!(!should_warn_at_iteration_threshold(1_000_000, usize::MAX));
assert!(!should_warn_at_iteration_threshold(
usize::MAX / 6,
usize::MAX
));
assert!(should_warn_at_iteration_threshold(
usize::MAX / 5,
usize::MAX
));
}
#[test]
fn iteration_handoff_steering_text_is_self_describing() {
let text = iteration_handoff_steering_text(42, 50);
assert!(text.contains("[runtime]"));
assert!(text.contains("Tool-iteration budget at >=80%"));
assert!(text.contains("used 42 of 50"));
assert!(text.contains("graceful handoff"));
assert!(text.contains("incomplete-handoff"));
assert!(text.contains("Do NOT compress"));
}
#[test]
fn filter_image_blocks_replaces_images_with_deduped_placeholder_text() {
let mut blocks = vec![
sample_image_block(),
sample_image_block(),
ContentBlock::Text(TextContent::new("tail")),
sample_image_block(),
];
let removed = filter_image_blocks(&mut blocks);
assert_eq!(removed, 3);
assert!(
!blocks
.iter()
.any(|block| matches!(block, ContentBlock::Image(_)))
);
assert!(matches!(
blocks.first(),
Some(ContentBlock::Text(TextContent { text, .. }))
if text.as_str().eq(BLOCK_IMAGES_PLACEHOLDER)
));
assert!(matches!(
blocks.get(1),
Some(ContentBlock::Text(TextContent { text, .. })) if text.as_str().eq("tail")
));
assert!(matches!(
blocks.get(2),
Some(ContentBlock::Text(TextContent { text, .. }))
if text.as_str().eq(BLOCK_IMAGES_PLACEHOLDER)
));
}
#[test]
fn filter_images_for_provider_filters_images_from_all_block_message_types() {
let mut messages = vec![
Message::User(UserMessage {
content: UserContent::Blocks(vec![
ContentBlock::Text(TextContent::new("hello")),
sample_image_block(),
]),
timestamp: 0,
}),
Message::Assistant(Arc::new(AssistantMessage {
content: vec![sample_image_block()],
api: "test".to_string(),
provider: "test".to_string(),
model: "test".to_string(),
usage: Usage::default(),
stop_reason: StopReason::Stop,
stop_details: None,
error_message: None,
timestamp: 0,
})),
Message::tool_result(ToolResultMessage {
tool_call_id: "tc1".to_string(),
tool_name: "read".to_string(),
content: vec![
sample_image_block(),
ContentBlock::Text(TextContent::new("ok")),
],
details: None,
is_error: false,
timestamp: 0,
}),
];
let stats = filter_images_for_provider(&mut messages);
assert_eq!(stats.removed_images, 3);
assert_eq!(stats.affected_messages, 3);
assert_eq!(
messages.iter().map(image_count_in_message).sum::<usize>(),
0,
"no images should remain in provider-bound context"
);
}
#[test]
fn build_context_strips_images_when_block_images_enabled() {
let mut agent = Agent::new(
Arc::new(SilentProvider),
ToolRegistry::new(&[], Path::new("."), None),
AgentConfig {
system_prompt: None,
max_tool_iterations: 50,
stream_options: StreamOptions::default(),
block_images: true,
model_accepts_images: true,
fail_closed_hooks: false,
tool_approval: None,
keyword_settings: None,
max_time: None,
turn_recovery: crate::turn_recovery::TurnRecoveryMode::default(),
approval_state: None,
bash_settings: None,
secrets: None,
},
);
agent.add_message(Message::User(UserMessage {
content: UserContent::Blocks(vec![sample_image_block()]),
timestamp: 0,
}));
let context = agent.build_context();
assert_eq!(context.messages.len(), 1);
assert_eq!(image_count_in_message(&context.messages[0]), 0);
assert!(matches!(
&context.messages[0],
Message::User(UserMessage {
content: UserContent::Blocks(blocks),
..
}) if blocks
.iter()
.any(|block| matches!(block, ContentBlock::Text(TextContent { text, .. }) if text.as_str().eq(BLOCK_IMAGES_PLACEHOLDER)))
));
}
#[test]
fn build_context_keeps_images_when_block_images_disabled() {
let mut agent = Agent::new(
Arc::new(SilentProvider),
ToolRegistry::new(&[], Path::new("."), None),
AgentConfig {
system_prompt: None,
max_tool_iterations: 50,
stream_options: StreamOptions::default(),
block_images: false,
model_accepts_images: true,
fail_closed_hooks: false,
tool_approval: None,
keyword_settings: None,
max_time: None,
turn_recovery: crate::turn_recovery::TurnRecoveryMode::default(),
approval_state: None,
bash_settings: None,
secrets: None,
},
);
agent.add_message(Message::User(UserMessage {
content: UserContent::Blocks(vec![sample_image_block()]),
timestamp: 0,
}));
let context = agent.build_context();
assert_eq!(context.messages.len(), 1);
assert_eq!(image_count_in_message(&context.messages[0]), 1);
}
#[test]
fn auto_compaction_start_serializes_with_pi_mono_compatible_type_tag() {
let event = AgentEvent::AutoCompactionStart {
reason: "threshold".to_string(),
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "auto_compaction_start");
assert_eq!(json["reason"], "threshold");
}
#[test]
fn auto_compaction_end_serializes_with_pi_mono_compatible_fields() {
let event = AgentEvent::AutoCompactionEnd {
result: Some(serde_json::json!({"tokens_before": 5000, "tokens_after": 2000})),
aborted: false,
will_retry: false,
error_message: None,
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "auto_compaction_end");
assert_eq!(json["aborted"], false);
assert_eq!(json["willRetry"], false);
assert!(json.get("errorMessage").is_none()); assert!(json["result"].is_object());
}
#[test]
fn auto_compaction_end_includes_error_message_when_present() {
let event = AgentEvent::AutoCompactionEnd {
result: None,
aborted: true,
will_retry: false,
error_message: Some("Compaction failed".to_string()),
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "auto_compaction_end");
assert_eq!(json["aborted"], true);
assert_eq!(json["errorMessage"], "Compaction failed");
}
#[test]
fn auto_retry_start_serializes_with_camel_case_fields() {
let event = AgentEvent::AutoRetryStart {
attempt: 1,
max_attempts: 3,
delay_ms: 2000,
error_message: "Rate limited".to_string(),
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "auto_retry_start");
assert_eq!(json["attempt"], 1);
assert_eq!(json["maxAttempts"], 3);
assert_eq!(json["delayMs"], 2000);
assert_eq!(json["errorMessage"], "Rate limited");
}
#[test]
fn auto_retry_end_serializes_success_and_omits_null_final_error() {
let event = AgentEvent::AutoRetryEnd {
success: true,
attempt: 2,
final_error: None,
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "auto_retry_end");
assert_eq!(json["success"], true);
assert_eq!(json["attempt"], 2);
assert!(json.get("finalError").is_none());
}
#[test]
fn auto_retry_end_includes_final_error_on_failure() {
let event = AgentEvent::AutoRetryEnd {
success: false,
attempt: 3,
final_error: Some("Max retries exceeded".to_string()),
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "auto_retry_end");
assert_eq!(json["success"], false);
assert_eq!(json["attempt"], 3);
assert_eq!(json["finalError"], "Max retries exceeded");
}
#[test]
fn message_queue_push_increments_seq_and_counts_both_queues() {
let mut queue = MessageQueue::new(QueueMode::OneAtATime, QueueMode::OneAtATime);
assert_eq!(queue.pending_count(), 0);
assert_eq!(queue.push_steering(queued_user_message("s1")), 0);
assert_eq!(queue.push_follow_up(queued_user_message("f1")), 1);
assert_eq!(queue.push_steering(queued_user_message("s2")), 2);
assert_eq!(queue.pending_count(), 3);
}
#[test]
fn message_queue_pop_steering_one_at_a_time_preserves_order() {
let mut queue = MessageQueue::new(QueueMode::OneAtATime, QueueMode::OneAtATime);
queue.push_steering(queued_user_message("s1"));
queue.push_steering(queued_user_message("s2"));
let first = queue.pop_steering();
assert_eq!(first.len(), 1);
assert_user_text(first[0].message(), "s1");
assert_eq!(queue.pending_count(), 1);
let second = queue.pop_steering();
assert_eq!(second.len(), 1);
assert_user_text(second[0].message(), "s2");
assert_eq!(queue.pending_count(), 0);
let empty = queue.pop_steering();
assert!(empty.is_empty());
}
#[test]
fn message_queue_pop_respects_queue_modes_per_kind() {
let mut queue = MessageQueue::new(QueueMode::All, QueueMode::OneAtATime);
queue.push_steering(queued_user_message("s1"));
queue.push_steering(queued_user_message("s2"));
queue.push_follow_up(queued_user_message("f1"));
queue.push_follow_up(queued_user_message("f2"));
let steering = queue.pop_steering();
assert_eq!(steering.len(), 2);
assert_user_text(steering[0].message(), "s1");
assert_user_text(steering[1].message(), "s2");
assert_eq!(queue.pending_count(), 2);
let follow_up = queue.pop_follow_up();
assert_eq!(follow_up.len(), 1);
assert_user_text(follow_up[0].message(), "f1");
assert_eq!(queue.pending_count(), 1);
let follow_up = queue.pop_follow_up();
assert_eq!(follow_up.len(), 1);
assert_user_text(follow_up[0].message(), "f2");
assert_eq!(queue.pending_count(), 0);
}
#[test]
fn message_queue_set_modes_applies_to_existing_messages() {
let mut queue = MessageQueue::new(QueueMode::OneAtATime, QueueMode::OneAtATime);
queue.push_steering(queued_user_message("s1"));
queue.push_steering(queued_user_message("s2"));
let first = queue.pop_steering();
assert_eq!(first.len(), 1);
assert_user_text(first[0].message(), "s1");
queue.set_modes(QueueMode::All, QueueMode::OneAtATime);
let remaining = queue.pop_steering();
assert_eq!(remaining.len(), 1);
assert_user_text(remaining[0].message(), "s2");
}
fn build_switch_test_session(auth: &AuthStorage) -> AgentSession {
let registry = ModelRegistry::load(auth, None);
let current_entry = registry
.find("anthropic", "claude-sonnet-4-5")
.expect("anthropic model in registry");
let provider = crate::providers::create_provider(¤t_entry, None)
.expect("create anthropic provider");
let tools = ToolRegistry::new(&[], Path::new("."), None);
let mut stream_options = StreamOptions {
api_key: Some("stale-key".to_string()),
..Default::default()
};
let _ = stream_options
.headers
.insert("x-stale-header".to_string(), "stale-value".to_string());
let agent = Agent::new(
provider,
tools,
AgentConfig {
system_prompt: None,
max_tool_iterations: 50,
stream_options,
block_images: false,
model_accepts_images: true,
fail_closed_hooks: false,
tool_approval: None,
keyword_settings: None,
max_time: None,
turn_recovery: crate::turn_recovery::TurnRecoveryMode::default(),
approval_state: None,
bash_settings: None,
secrets: None,
},
);
let mut session = Session::in_memory();
session.header.provider = Some("anthropic".to_string());
session.header.model_id = Some("claude-sonnet-4-5".to_string());
let mut agent_session = AgentSession::new(
agent,
Arc::new(Mutex::new(session)),
false,
ResolvedCompactionSettings::default(),
);
agent_session.set_model_registry(registry);
agent_session.set_auth_storage(auth.clone());
agent_session
}
#[test]
fn compaction_runtime_handle_creates_fallback_runtime() {
let dir = tempfile::tempdir().expect("tempdir");
let auth_path = dir.path().join("auth.json");
let auth = AuthStorage::load(auth_path).expect("load auth");
let mut agent_session = build_switch_test_session(&auth);
assert!(agent_session.compaction_runtime.is_none());
assert!(agent_session.runtime_handle.is_none());
let runtime_handle = agent_session
.compaction_runtime_handle()
.expect("create fallback compaction runtime");
let join = runtime_handle.spawn(async { 7_u8 });
assert_eq!(futures::executor::block_on(join), 7);
assert!(agent_session.compaction_runtime.is_some());
assert!(agent_session.runtime_handle.is_some());
}
#[test]
fn provider_transition_waits_for_active_provider_permit() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("build runtime");
let runtime_handle = runtime.handle();
runtime.block_on(async move {
let gate = ProviderAdmissionGate::default();
let active_cx = crate::agent_cx::AgentCx::for_request();
let active_provider = gate
.acquire(active_cx.cx())
.await
.expect("acquire active provider permit");
let transition_gate = gate.clone();
let transition = runtime_handle.spawn(async move {
let transition_cx = crate::agent_cx::AgentCx::for_request();
let permit = transition_gate
.begin_transition(
"test transition interrupted".to_string(),
transition_cx.cx(),
)
.await
.expect("begin transition");
drop(permit);
});
asupersync::time::sleep(
asupersync::time::wall_now(),
std::time::Duration::from_millis(10),
)
.await;
assert!(
!transition.is_finished(),
"transition must not pass an active provider future"
);
drop(active_provider);
transition.await;
assert_eq!(
gate.reason().as_deref(),
Some("test transition interrupted")
);
gate.clear();
assert!(gate.reason().is_none());
});
}
#[test]
fn prepared_model_selection_updates_stream_credentials_and_headers() {
let dir = tempfile::tempdir().expect("tempdir");
let auth_path = dir.path().join("auth.json");
let mut auth = AuthStorage::load(auth_path).expect("load auth");
auth.set(
"anthropic",
AuthCredential::ApiKey {
key: "anthropic-key".to_string(),
},
);
auth.set(
"openai",
AuthCredential::ApiKey {
key: "openai-key".to_string(),
},
);
let mut agent_session = build_switch_test_session(&auth);
let prepared = agent_session
.prepare_model_selection("openai", "gpt-4o", crate::model::ThinkingLevel::Off)
.expect("prepare switch");
agent_session.install_prepared_model_selection(prepared);
assert_eq!(agent_session.agent.provider().name(), "openai");
assert_eq!(agent_session.agent.provider().model_id(), "gpt-4o");
assert_eq!(
agent_session.agent.stream_options().api_key.as_deref(),
Some("openai-key")
);
assert!(
agent_session.agent.stream_options().headers.is_empty(),
"stream headers should be refreshed from selected model entry"
);
}
#[test]
fn prepared_model_selection_clears_stale_key_for_keyless_target() {
let dir = tempfile::tempdir().expect("tempdir");
let auth_path = dir.path().join("auth.json");
let mut auth = AuthStorage::load(auth_path).expect("load auth");
auth.set(
"anthropic",
AuthCredential::ApiKey {
key: "anthropic-key".to_string(),
},
);
let mut registry = ModelRegistry::load(&auth, None);
registry.merge_entries(vec![ModelEntry {
model: Model {
id: "local-model".to_string(),
name: "Local Model".to_string(),
api: "openai-completions".to_string(),
provider: "acme-local".to_string(),
base_url: "https://example.invalid/v1".to_string(),
reasoning: true,
input: vec![InputType::Text],
cost: ModelCost {
input: 0.0,
output: 0.0,
cache_read: 0.0,
cache_write: 0.0,
},
context_window: 128_000,
max_tokens: 8_192,
headers: HashMap::new(),
},
api_key: None,
headers: HashMap::new(),
auth_header: false,
compat: None,
oauth_config: None,
}]);
let mut agent_session = build_switch_test_session(&auth);
agent_session.set_model_registry(registry);
let prepared = agent_session
.prepare_model_selection(
"acme-local",
"local-model",
crate::model::ThinkingLevel::Off,
)
.expect("prepare keyless local model");
agent_session.install_prepared_model_selection(prepared);
assert_eq!(agent_session.agent.provider().name(), "acme-local");
assert_eq!(
agent_session.agent.stream_options().api_key,
None,
"stale key must be cleared when target model has no configured key"
);
agent_session.agent.stream_options_mut().api_key = Some("stale-again".to_string());
let prepared = agent_session
.prepare_model_selection(
"acme-local",
"local-model",
crate::model::ThinkingLevel::Off,
)
.expect("prepare already-active keyless model");
agent_session.install_prepared_model_selection(prepared);
assert_eq!(
agent_session.agent.stream_options().api_key,
None,
"an already-active keyless model must not retain a stale credential"
);
}
#[test]
fn prepared_model_selection_treats_blank_model_key_as_missing_credential() {
let dir = tempfile::tempdir().expect("tempdir");
let auth_path = dir.path().join("auth.json");
let auth = AuthStorage::load(auth_path).expect("load auth");
let mut registry = ModelRegistry::load(&auth, None);
registry.merge_entries(vec![ModelEntry {
model: Model {
id: "blank-model".to_string(),
name: "Blank Model".to_string(),
api: "openai-completions".to_string(),
provider: "acme".to_string(),
base_url: "https://example.invalid/v1".to_string(),
reasoning: true,
input: vec![InputType::Text],
cost: ModelCost {
input: 0.0,
output: 0.0,
cache_read: 0.0,
cache_write: 0.0,
},
context_window: 128_000,
max_tokens: 8_192,
headers: HashMap::new(),
},
api_key: Some(" ".to_string()),
headers: HashMap::new(),
auth_header: true,
compat: None,
oauth_config: None,
}]);
let mut agent_session = build_switch_test_session(&auth);
agent_session.set_model_registry(registry);
let err = agent_session
.prepare_model_selection("acme", "blank-model", crate::model::ThinkingLevel::Off)
.err()
.expect("blank keys must not satisfy credential requirements");
assert!(
err.to_string()
.contains("Missing credentials for acme/blank-model"),
"unexpected error: {err}"
);
assert_eq!(agent_session.agent.provider().name(), "anthropic");
assert_eq!(
agent_session.agent.stream_options().api_key,
Some("stale-key".to_string()),
"failed switches must preserve the prior runtime credentials"
);
}
#[test]
fn set_provider_model_preserves_session_header_when_switch_fails() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("build runtime");
runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let auth_path = dir.path().join("auth.json");
let auth = AuthStorage::load(auth_path).expect("load auth");
let mut agent_session = build_switch_test_session(&auth);
{
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
session.header.provider = Some("anthropic".to_string());
session.header.model_id = Some("claude-sonnet-4-5".to_string());
}
let err = agent_session
.set_provider_model("missing-provider", "missing-model")
.await
.expect_err("missing model should not switch");
assert!(
err.to_string()
.contains("Unable to switch provider/model to missing-provider/missing-model"),
"unexpected error: {err}"
);
assert_eq!(agent_session.agent.provider().name(), "anthropic");
assert_eq!(
agent_session.agent.provider().model_id(),
"claude-sonnet-4-5"
);
let cx = crate::agent_cx::AgentCx::for_request();
let session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
assert_eq!(session.header.provider.as_deref(), Some("anthropic"));
assert_eq!(
session.header.model_id.as_deref(),
Some("claude-sonnet-4-5")
);
});
}
#[test]
fn set_provider_model_rejects_missing_credentials_without_switching() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("build runtime");
runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let auth_path = dir.path().join("auth.json");
let auth = AuthStorage::load(auth_path).expect("load auth");
let mut agent_session = build_switch_test_session(&auth);
{
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
session.header.provider = Some("anthropic".to_string());
session.header.model_id = Some("claude-sonnet-4-5".to_string());
}
let err = agent_session
.set_provider_model("openai", "gpt-4o")
.await
.expect_err("missing credentials should abort model switch");
assert!(
err.to_string()
.contains("Missing credentials for openai/gpt-4o"),
"unexpected error: {err}"
);
assert_eq!(agent_session.agent.provider().name(), "anthropic");
assert_eq!(
agent_session.agent.provider().model_id(),
"claude-sonnet-4-5"
);
let cx = crate::agent_cx::AgentCx::for_request();
let session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
assert_eq!(session.header.provider.as_deref(), Some("anthropic"));
assert_eq!(
session.header.model_id.as_deref(),
Some("claude-sonnet-4-5")
);
});
}
#[test]
fn set_provider_model_quarantines_failed_persistence_without_runtime_mutation() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("build runtime");
runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let auth_path = dir.path().join("auth.json");
let mut auth = AuthStorage::load(auth_path).expect("load auth");
auth.set(
"anthropic",
AuthCredential::ApiKey {
key: "anthropic-key".to_string(),
},
);
auth.set(
"openai",
AuthCredential::ApiKey {
key: "openai-key".to_string(),
},
);
let mut agent_session = build_switch_test_session(&auth);
let blocked_path = dir.path().join("blocked.jsonl");
std::fs::create_dir_all(&blocked_path).expect("create blocking directory");
{
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
session.path = Some(blocked_path);
}
agent_session.save_enabled = true;
let original_compaction_window =
agent_session.compaction_settings().context_window_tokens;
let err = agent_session
.set_provider_model("openai", "gpt-4o")
.await
.expect_err("unwritable model-selection candidate must fail closed");
assert!(err.is_session_persistence(), "unexpected error: {err}");
assert_eq!(agent_session.agent.provider().name(), "anthropic");
assert_eq!(
agent_session.agent.provider().model_id(),
"claude-sonnet-4-5"
);
assert_eq!(
agent_session.agent.stream_options().api_key.as_deref(),
Some("stale-key")
);
assert_eq!(
agent_session.compaction_settings().context_window_tokens,
original_compaction_window
);
assert!(agent_session.provider_admission.reason().is_some());
let cx = crate::agent_cx::AgentCx::for_request();
let session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
assert_eq!(session.header.provider.as_deref(), Some("anthropic"));
assert_eq!(
session.header.model_id.as_deref(),
Some("claude-sonnet-4-5")
);
assert!(
session
.entries_for_current_path()
.iter()
.all(|entry| !matches!(entry, crate::session::SessionEntry::ModelChange(_)))
);
drop(session);
let blocked = agent_session
.sync_runtime_selection_from_session_header()
.await
.expect_err("quarantine must block later provider re-entry");
assert!(blocked.is_session_persistence());
});
}
#[test]
fn set_thinking_level_quarantines_failed_persistence_without_live_mutation() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("build runtime");
runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let auth_path = dir.path().join("auth.json");
let auth = AuthStorage::load(auth_path).expect("load auth");
let mut agent_session = build_switch_test_session(&auth);
let blocked_path = dir.path().join("blocked.jsonl");
std::fs::create_dir_all(&blocked_path).expect("create blocking directory");
{
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
session.path = Some(blocked_path);
}
agent_session.save_enabled = true;
let original_thinking = agent_session.agent.stream_options().thinking_level;
let err = agent_session
.set_thinking_level(crate::model::ThinkingLevel::High)
.await
.expect_err("unwritable thinking candidate must fail closed");
assert!(err.is_session_persistence(), "unexpected error: {err}");
assert_eq!(
agent_session.agent.stream_options().thinking_level,
original_thinking
);
assert!(agent_session.provider_admission.reason().is_some());
let cx = crate::agent_cx::AgentCx::for_request();
let session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
assert!(session.header.thinking_level.is_none());
assert!(
session
.entries_for_current_path()
.iter()
.all(|entry| !matches!(
entry,
crate::session::SessionEntry::ThinkingLevelChange(_)
))
);
});
}
#[test]
fn set_provider_model_clamps_thinking_for_non_reasoning_targets() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("build runtime");
runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let auth_path = dir.path().join("auth.json");
let auth = AuthStorage::load(auth_path).expect("load auth");
let mut registry = ModelRegistry::load(&auth, None);
registry.merge_entries(vec![ModelEntry {
model: Model {
id: "plain-model".to_string(),
name: "Plain Model".to_string(),
api: "openai-completions".to_string(),
provider: "acme".to_string(),
base_url: "https://example.invalid/v1".to_string(),
reasoning: false,
input: vec![InputType::Text],
cost: ModelCost {
input: 0.0,
output: 0.0,
cache_read: 0.0,
cache_write: 0.0,
},
context_window: 128_000,
max_tokens: 8_192,
headers: HashMap::new(),
},
api_key: None,
headers: HashMap::new(),
auth_header: false,
compat: None,
oauth_config: None,
}]);
let mut agent_session = build_switch_test_session(&auth);
agent_session.set_model_registry(registry);
agent_session.agent.stream_options_mut().thinking_level =
Some(crate::model::ThinkingLevel::High);
{
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
session.header.thinking_level = Some("high".to_string());
}
agent_session
.set_provider_model("acme", "plain-model")
.await
.expect("switch should clamp unsupported thinking");
assert_eq!(agent_session.agent.provider().name(), "acme");
assert_eq!(agent_session.agent.provider().model_id(), "plain-model");
assert_eq!(
agent_session.agent.stream_options().thinking_level,
Some(crate::model::ThinkingLevel::Off)
);
assert_eq!(
agent_session.agent.keyword_max_thinking_level,
crate::model::ThinkingLevel::Off,
"ultrathink must use the target model's clamped maximum"
);
assert!(
!agent_session.agent.model_accepts_images(),
"runtime model switches must install the target image policy"
);
assert_eq!(
agent_session.compaction_settings().context_window_tokens,
128_000,
"runtime model switches must install the target compaction window"
);
let cx = crate::agent_cx::AgentCx::for_request();
let session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
assert_eq!(session.header.provider.as_deref(), Some("acme"));
assert_eq!(session.header.model_id.as_deref(), Some("plain-model"));
assert_eq!(session.header.thinking_level.as_deref(), Some("off"));
});
}
#[test]
fn bare_provider_replacement_resets_catalog_controls_fail_closed() {
let tools = ToolRegistry::new(&[], Path::new("."), None);
let mut agent = Agent::new(Arc::new(SilentProvider), tools, AgentConfig::default());
agent.set_keyword_max_thinking_level(crate::model::ThinkingLevel::High);
agent.set_tool_call_dialect(crate::dialects::Dialect::Xmlish);
agent.set_provider(Arc::new(SilentProvider));
assert_eq!(
agent.keyword_max_thinking_level,
crate::model::ThinkingLevel::Off,
"provider replacement without registry metadata must not inherit the prior cap"
);
assert_eq!(
agent.tool_call_dialect,
crate::dialects::Dialect::Native,
"provider replacement without registry metadata must not inherit repair opt-in"
);
}
#[test]
fn set_provider_model_records_model_change_once() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("build runtime");
runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let auth_path = dir.path().join("auth.json");
let mut auth = AuthStorage::load(auth_path).expect("load auth");
auth.set(
"anthropic",
AuthCredential::ApiKey {
key: "anthropic-key".to_string(),
},
);
auth.set(
"openai",
AuthCredential::ApiKey {
key: "openai-key".to_string(),
},
);
let mut agent_session = build_switch_test_session(&auth);
agent_session
.set_provider_model("openai", "gpt-4o")
.await
.expect("switch model");
agent_session
.set_provider_model("openai", "gpt-4o")
.await
.expect("repeat same model");
let cx = crate::agent_cx::AgentCx::for_request();
let session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
let model_changes = session
.entries_for_current_path()
.iter()
.filter(|entry| matches!(entry, crate::session::SessionEntry::ModelChange(_)))
.count();
assert_eq!(model_changes, 1);
});
}
#[test]
fn set_provider_model_persists_canonical_registry_identity() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("build runtime");
runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let auth_path = dir.path().join("auth.json");
let mut auth = AuthStorage::load(auth_path).expect("load auth");
auth.set(
"openai",
AuthCredential::ApiKey {
key: "openai-key".to_string(),
},
);
let mut agent_session = build_switch_test_session(&auth);
agent_session
.set_provider_model(" OpenAI ", "GPT-4O")
.await
.expect("mixed-case alias should resolve");
assert_eq!(agent_session.agent.provider().name(), "openai");
assert_eq!(agent_session.agent.provider().model_id(), "gpt-4o");
let cx = crate::agent_cx::AgentCx::for_request();
let session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
assert_eq!(session.header.provider.as_deref(), Some("openai"));
assert_eq!(session.header.model_id.as_deref(), Some("gpt-4o"));
assert_eq!(
session.effective_model_for_current_path(),
Some(("openai".to_string(), "gpt-4o".to_string()))
);
});
}
#[test]
fn model_and_thinking_transitions_reopen_with_complete_state() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("build runtime");
runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let auth_path = dir.path().join("auth.json");
let mut auth = AuthStorage::load(auth_path).expect("load auth");
auth.set(
"openai",
AuthCredential::ApiKey {
key: "openai-key".to_string(),
},
);
let mut agent_session = build_switch_test_session(&auth);
{
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
let mut persistent = Session::create_with_dir(Some(dir.path().join("sessions")));
persistent.header.provider = Some("anthropic".to_string());
persistent.header.model_id = Some("claude-sonnet-4-5".to_string());
*session = persistent;
}
agent_session.save_enabled = true;
agent_session
.set_provider_model("openai", "gpt-5.5")
.await
.expect("persist model transition");
agent_session
.set_thinking_level(crate::model::ThinkingLevel::High)
.await
.expect("persist thinking transition");
let cx = crate::agent_cx::AgentCx::for_request();
let session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
let path = session.path.clone().expect("persisted session path");
drop(session);
let reopened = Session::open(path.to_string_lossy().as_ref())
.await
.expect("reopen persisted transitions");
assert_eq!(reopened.header.provider.as_deref(), Some("openai"));
assert_eq!(reopened.header.model_id.as_deref(), Some("gpt-5.5"));
assert_eq!(reopened.header.thinking_level.as_deref(), Some("high"));
assert_eq!(
reopened.effective_model_for_current_path(),
Some(("openai".to_string(), "gpt-5.5".to_string()))
);
assert_eq!(
reopened
.effective_thinking_level_for_current_path()
.as_deref(),
Some("high")
);
});
}
#[test]
fn sync_runtime_selection_from_session_header_clamps_and_normalizes_thinking() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("build runtime");
runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let auth_path = dir.path().join("auth.json");
let auth = AuthStorage::load(auth_path).expect("load auth");
let mut registry = ModelRegistry::load(&auth, None);
registry.merge_entries(vec![ModelEntry {
model: Model {
id: "plain-model".to_string(),
name: "Plain Model".to_string(),
api: "openai-completions".to_string(),
provider: "acme".to_string(),
base_url: "https://example.invalid/v1".to_string(),
reasoning: false,
input: vec![InputType::Text],
cost: ModelCost {
input: 0.0,
output: 0.0,
cache_read: 0.0,
cache_write: 0.0,
},
context_window: 128_000,
max_tokens: 8_192,
headers: HashMap::new(),
},
api_key: None,
headers: HashMap::new(),
auth_header: false,
compat: None,
oauth_config: None,
}]);
let mut agent_session = build_switch_test_session(&auth);
agent_session.set_model_registry(registry);
agent_session.agent.stream_options_mut().thinking_level =
Some(crate::model::ThinkingLevel::High);
{
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
session.header.provider = Some("acme".to_string());
session.header.model_id = Some("plain-model".to_string());
session.header.thinking_level = Some("high".to_string());
}
agent_session
.sync_runtime_selection_from_session_header()
.await
.expect("sync runtime selection");
assert_eq!(agent_session.agent.provider().name(), "acme");
assert_eq!(agent_session.agent.provider().model_id(), "plain-model");
assert_eq!(
agent_session.agent.stream_options().thinking_level,
Some(crate::model::ThinkingLevel::Off)
);
let cx = crate::agent_cx::AgentCx::for_request();
let session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
assert_eq!(session.header.thinking_level.as_deref(), Some("off"));
let thinking_changes = session
.entries_for_current_path()
.iter()
.filter(|entry| {
matches!(entry, crate::session::SessionEntry::ThinkingLevelChange(_))
})
.count();
assert_eq!(thinking_changes, 1);
});
}
#[test]
fn sync_runtime_selection_from_session_header_clamps_current_thinking_when_header_omits_it() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("build runtime");
runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let auth_path = dir.path().join("auth.json");
let auth = AuthStorage::load(auth_path).expect("load auth");
let mut registry = ModelRegistry::load(&auth, None);
registry.merge_entries(vec![ModelEntry {
model: Model {
id: "plain-model".to_string(),
name: "Plain Model".to_string(),
api: "openai-completions".to_string(),
provider: "acme".to_string(),
base_url: "https://example.invalid/v1".to_string(),
reasoning: false,
input: vec![InputType::Text],
cost: ModelCost {
input: 0.0,
output: 0.0,
cache_read: 0.0,
cache_write: 0.0,
},
context_window: 128_000,
max_tokens: 8_192,
headers: HashMap::new(),
},
api_key: None,
headers: HashMap::new(),
auth_header: false,
compat: None,
oauth_config: None,
}]);
let mut agent_session = build_switch_test_session(&auth);
agent_session.set_model_registry(registry);
agent_session.agent.stream_options_mut().thinking_level =
Some(crate::model::ThinkingLevel::High);
{
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
session.header.provider = Some("acme".to_string());
session.header.model_id = Some("plain-model".to_string());
session.header.thinking_level = None;
}
agent_session
.sync_runtime_selection_from_session_header()
.await
.expect("sync runtime selection");
assert_eq!(agent_session.agent.provider().name(), "acme");
assert_eq!(agent_session.agent.provider().model_id(), "plain-model");
assert_eq!(
agent_session.agent.stream_options().thinking_level,
Some(crate::model::ThinkingLevel::Off)
);
let cx = crate::agent_cx::AgentCx::for_request();
let session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
assert_eq!(session.header.thinking_level.as_deref(), Some("off"));
let thinking_changes = session
.entries_for_current_path()
.iter()
.filter(|entry| {
matches!(entry, crate::session::SessionEntry::ThinkingLevelChange(_))
})
.count();
assert_eq!(thinking_changes, 1);
});
}
#[test]
fn sync_runtime_selection_from_session_header_rejects_missing_credentials() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("build runtime");
runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let auth_path = dir.path().join("auth.json");
let auth = AuthStorage::load(auth_path).expect("load auth");
let mut agent_session = build_switch_test_session(&auth);
{
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
session.header.provider = Some("openai".to_string());
session.header.model_id = Some("gpt-4o".to_string());
}
let err = agent_session
.sync_runtime_selection_from_session_header()
.await
.expect_err("sync should reject switching to a credentialed target without a key");
assert!(
err.to_string()
.contains("Missing credentials for openai/gpt-4o"),
"unexpected error: {err}"
);
assert_eq!(agent_session.agent.provider().name(), "anthropic");
assert_eq!(
agent_session.agent.provider().model_id(),
"claude-sonnet-4-5"
);
let cx = crate::agent_cx::AgentCx::for_request();
let session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
assert_eq!(session.header.provider.as_deref(), Some("openai"));
assert_eq!(session.header.model_id.as_deref(), Some("gpt-4o"));
});
}
#[test]
fn sync_runtime_selection_quarantines_failed_normalization_without_live_mutation() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("build runtime");
runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let auth_path = dir.path().join("auth.json");
let mut auth = AuthStorage::load(auth_path).expect("load auth");
auth.set(
"openai",
AuthCredential::ApiKey {
key: "openai-key".to_string(),
},
);
let mut agent_session = build_switch_test_session(&auth);
let blocked_path = dir.path().join("blocked.jsonl");
std::fs::create_dir_all(&blocked_path).expect("create blocking directory");
{
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
session.path = Some(blocked_path);
session.header.provider = Some("openai".to_string());
session.header.model_id = Some("GPT-4O".to_string());
session.header.thinking_level = Some("high".to_string());
}
agent_session.save_enabled = true;
let original_provider = agent_session.agent.provider();
let original_options = agent_session.agent.stream_options().clone();
let err = agent_session
.sync_runtime_selection_from_session_header()
.await
.expect_err("unwritable normalization must fail closed");
assert!(err.is_session_persistence(), "unexpected error: {err}");
assert_eq!(
agent_session.agent.provider().name(),
original_provider.name()
);
assert_eq!(
agent_session.agent.provider().model_id(),
original_provider.model_id()
);
assert_eq!(
agent_session.agent.stream_options().api_key,
original_options.api_key
);
assert_eq!(
agent_session.agent.stream_options().thinking_level,
original_options.thinking_level
);
assert!(agent_session.provider_admission.reason().is_some());
let cx = crate::agent_cx::AgentCx::for_request();
let session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
assert_eq!(session.header.provider.as_deref(), Some("openai"));
assert_eq!(session.header.model_id.as_deref(), Some("GPT-4O"));
assert_eq!(session.header.thinking_level.as_deref(), Some("high"));
drop(session);
let compact_err = agent_session
.compact_now(|_| {})
.await
.expect_err("compaction must honor transition quarantine");
assert!(compact_err.is_session_persistence());
let extension_err = agent_session
.execute_extension_command("unused", "", 1, |_| {})
.await
.expect_err("extension execution must honor transition quarantine");
assert!(extension_err.is_session_persistence());
});
}
#[test]
fn set_provider_model_allows_current_model_without_registry() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("build runtime");
runtime.block_on(async {
let dir = tempfile::tempdir().expect("tempdir");
let auth_path = dir.path().join("auth.json");
let auth = AuthStorage::load(auth_path).expect("load auth");
let mut agent_session = build_switch_test_session(&auth);
agent_session.model_registry = None;
agent_session.agent.stream_options_mut().thinking_level =
Some(crate::model::ThinkingLevel::High);
agent_session
.set_provider_model("anthropic", "claude-sonnet-4-5")
.await
.expect("re-persisting the current model should succeed without a registry");
assert_eq!(agent_session.agent.provider().name(), "anthropic");
assert_eq!(
agent_session.agent.provider().model_id(),
"claude-sonnet-4-5"
);
assert_eq!(
agent_session.agent.stream_options().thinking_level,
Some(crate::model::ThinkingLevel::High)
);
let cx = crate::agent_cx::AgentCx::for_request();
let session = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
assert_eq!(session.header.provider.as_deref(), Some("anthropic"));
assert_eq!(
session.header.model_id.as_deref(),
Some("claude-sonnet-4-5")
);
assert_eq!(session.header.thinking_level.as_deref(), Some("high"));
});
}
#[test]
fn auto_compaction_start_serializes_to_pi_mono_format() {
let event = AgentEvent::AutoCompactionStart {
reason: "threshold".to_string(),
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "auto_compaction_start");
assert_eq!(json["reason"], "threshold");
}
#[test]
fn auto_compaction_end_serializes_to_pi_mono_format() {
let event = AgentEvent::AutoCompactionEnd {
result: Some(serde_json::json!({
"summary": "Compacted",
"firstKeptEntryId": "abc123",
"tokensBefore": 50000,
"details": { "readFiles": [], "modifiedFiles": [] }
})),
aborted: false,
will_retry: true,
error_message: None,
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "auto_compaction_end");
assert!(json["result"].is_object());
assert_eq!(json["aborted"], false);
assert_eq!(json["willRetry"], true);
assert!(json.get("errorMessage").is_none());
}
#[test]
fn auto_compaction_end_with_error_serializes_error_message() {
let event = AgentEvent::AutoCompactionEnd {
result: None,
aborted: false,
will_retry: false,
error_message: Some("compaction failed".to_string()),
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "auto_compaction_end");
assert!(json.get("result").is_none());
assert_eq!(json["errorMessage"], "compaction failed");
}
#[test]
fn apply_compaction_result_emits_structured_result_payload() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = Arc::new(SilentProvider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
let events: Arc<std::sync::Mutex<Vec<AgentEvent>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = Arc::clone(&events);
let on_event: AgentEventHandler = Arc::new(move |event| {
sink.lock().expect("lock compaction events").push(event);
});
let result = compaction::CompactionResult {
summary: "Compacted 10 messages into 2".to_string(),
first_kept_entry_id: "entry-5".to_string(),
tokens_before: 12_000,
details: compaction::CompactionDetails {
read_files: vec!["src/main.rs".to_string()],
modified_files: vec!["src/agent.rs".to_string()],
mode: None,
},
snap_payload: None,
};
let provider_admission = agent_session
.provider_admission
.acquire(&asupersync::Cx::for_testing())
.await
.expect("provider admission");
agent_session
.apply_compaction_result(result, on_event, provider_admission)
.await
.expect("apply compaction result");
let payload = {
let guard = events.lock().expect("lock captured events");
guard
.iter()
.find_map(|event| match event {
AgentEvent::AutoCompactionEnd {
result: Some(result),
..
} => Some(result.clone()),
_ => None,
})
.expect("auto compaction end payload")
};
assert_eq!(payload["summary"], "Compacted 10 messages into 2");
assert_eq!(payload["firstKeptEntryId"], "entry-5");
assert_eq!(payload["tokensBefore"], 12_000);
let tokens_after = payload["tokensAfter"]
.as_u64()
.expect("tokensAfter present and integer");
assert!(tokens_after > 0, "tokensAfter must be positive");
assert!(
tokens_after < 12_000,
"tokensAfter ({tokens_after}) must be < tokensBefore"
);
assert_eq!(payload["details"]["readFiles"], json!(["src/main.rs"]));
assert_eq!(payload["details"]["modifiedFiles"], json!(["src/agent.rs"]));
});
}
#[test]
#[allow(clippy::significant_drop_tightening)]
fn session_compact_hook_can_reenter_provider_after_transition_install() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entry_path = temp_dir.path().join("compaction-reentry.mjs");
std::fs::write(
&entry_path,
r#"
import { complete } from "@mariozechner/pi-ai";
export default function init(pi) {
pi.on("session_compact", async () => {
await complete(
{ id: "capture-model" },
[{ role: "user", content: "after compaction" }],
{ maxTokens: 16 }
);
});
}
"#,
)
.expect("write compaction extension");
let calls = Arc::new(StdMutex::new(Vec::new()));
let provider = Arc::new(PiAiCaptureProvider {
calls: Arc::clone(&calls),
});
let tools = ToolRegistry::new(&[], temp_dir.path(), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
agent_session
.enable_extensions(&[], temp_dir.path(), None, &[entry_path])
.await
.expect("enable compaction extension");
let result = compaction::CompactionResult {
summary: "Compacted before provider re-entry".to_string(),
first_kept_entry_id: "entry-5".to_string(),
tokens_before: 12_000,
details: compaction::CompactionDetails {
read_files: Vec::new(),
modified_files: Vec::new(),
mode: None,
},
snap_payload: None,
};
let provider_admission = agent_session
.provider_admission
.acquire(&asupersync::Cx::for_testing())
.await
.expect("provider admission");
let on_event: AgentEventHandler = Arc::new(|_| {});
let outcome = asupersync::time::timeout(
asupersync::time::wall_now(),
Duration::from_secs(5),
agent_session.apply_compaction_result(result, on_event, provider_admission),
)
.await
.expect("session_compact hook must not deadlock on provider admission");
outcome.expect("apply compaction result");
let captured = calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(
captured.len(),
1,
"post-install session_compact hook must reach the provider exactly once"
);
assert!(matches!(
captured[0].messages.as_slice(),
[Message::User(UserMessage {
content: UserContent::Text(text),
..
})] if text == "after compaction"
));
});
}
#[test]
#[allow(clippy::significant_drop_tightening)]
fn compaction_persistence_failure_preserves_live_session_and_quarantines_provider() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let blocked_root = tempfile::tempdir().expect("tempdir");
let blocked_session_dir = blocked_root.path().join("not-a-directory");
std::fs::write(&blocked_session_dir, b"blocked").expect("write path blocker");
let provider = Arc::new(SilentProvider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::create_with_dir(Some(
blocked_session_dir,
))));
let agent_session = AgentSession::new(
agent,
Arc::clone(&session),
true,
ResolvedCompactionSettings::default(),
);
let metrics_before = {
let cx = asupersync::Cx::for_testing();
session
.lock(&cx)
.await
.expect("session lock")
.autosave_metrics()
};
let events: Arc<std::sync::Mutex<Vec<AgentEvent>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = Arc::clone(&events);
let on_event: AgentEventHandler = Arc::new(move |event| {
sink.lock().expect("lock compaction events").push(event);
});
let result = compaction::CompactionResult {
summary: "private candidate must not leak".to_string(),
first_kept_entry_id: "entry-5".to_string(),
tokens_before: 12_000,
details: compaction::CompactionDetails {
read_files: Vec::new(),
modified_files: Vec::new(),
mode: None,
},
snap_payload: None,
};
let provider_admission = agent_session
.provider_admission
.acquire(&asupersync::Cx::for_testing())
.await
.expect("provider admission");
let err = agent_session
.apply_compaction_result(result, on_event, provider_admission)
.await
.expect_err("blocked persistence must fail");
assert!(
err.to_string()
.contains("compaction persistence remained indeterminate"),
"unexpected persistence error: {err}"
);
assert!(
agent_session.provider_admission.reason().is_some_and(
|reason| reason.contains("compaction persistence remained indeterminate")
),
"indeterminate persistence must quarantine provider re-entry"
);
let cx = asupersync::Cx::for_testing();
let session = session.lock(&cx).await.expect("session lock after failure");
assert!(
session
.entries_for_current_path()
.iter()
.all(|entry| !matches!(
entry,
crate::session::SessionEntry::Compaction(compaction)
if compaction.summary == "private candidate must not leak"
)),
"failed private candidate must not be installed into the live session"
);
let metrics_after = session.autosave_metrics();
assert_eq!(
metrics_after.pending_mutations, metrics_before.pending_mutations,
"failed private candidate must not alter the live autosave queue"
);
assert_eq!(
metrics_after.flush_started, metrics_before.flush_started,
"candidate flush attempts must remain private"
);
let events = events.lock().expect("lock compaction events");
assert_eq!(events.len(), 1, "failure must emit one terminal event");
assert!(matches!(
&events[0],
AgentEvent::AutoCompactionEnd {
result: None,
aborted: false,
will_retry: false,
error_message: Some(message),
} if message.contains("compaction persistence remained indeterminate")
));
});
}
#[test]
fn maybe_compact_forces_local_compaction_when_quota_blocked_and_oversized() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = Arc::new(SilentProvider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let settings = ResolvedCompactionSettings {
enabled: true,
context_window_tokens: 100,
reserve_tokens: 10,
keep_recent_tokens: 30,
mode: compaction::AutoCompactionMode::default(),
render_mode: compaction::CompactionRenderMode::default(),
};
{
let cx = crate::agent_cx::AgentCx::for_request();
let mut guard = session.lock(cx.cx()).await.expect("session lock");
for i in 0..6 {
guard.append_message(crate::session::SessionMessage::User {
content: UserContent::Text(format!(
"oversized turn {i}: {}",
"x".repeat(300)
)),
timestamp: Some(0),
});
}
}
let mut agent_session = AgentSession::new(agent, session, false, settings);
agent_session
.compaction_worker
.set_attempt_count_for_test(u32::MAX);
assert!(!agent_session.compaction_worker.can_start());
let events: Arc<std::sync::Mutex<Vec<AgentEvent>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = Arc::clone(&events);
let on_event: AgentEventHandler = Arc::new(move |event| {
sink.lock().expect("lock compaction events").push(event);
});
agent_session
.maybe_compact(on_event)
.await
.expect("maybe_compact");
let (start_reason, end_payload) = {
let captured = events.lock().expect("lock captured events");
let start_reason = captured.iter().find_map(|event| match event {
AgentEvent::AutoCompactionStart { reason } => Some(reason.clone()),
_ => None,
});
let end_payload = captured.iter().find_map(|event| match event {
AgentEvent::AutoCompactionEnd {
result: Some(result),
..
} => Some(result.clone()),
_ => None,
});
drop(captured);
(start_reason, end_payload)
};
let start_reason = start_reason.expect("forced local compaction should start");
assert!(
start_reason.starts_with("forced_local"),
"unexpected start reason: {start_reason}"
);
let end_payload = end_payload.expect("forced local compaction should complete");
assert!(
end_payload["summary"]
.as_str()
.expect("summary string")
.contains("deterministic fallback")
);
let cx = crate::agent_cx::AgentCx::for_request();
let guard = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
assert!(
guard
.entries_for_current_path()
.iter()
.any(|entry| matches!(entry, crate::session::SessionEntry::Compaction(_))),
"forced local compaction should append a compaction entry"
);
});
}
#[test]
fn maybe_compact_quota_blocked_is_noop_below_forced_threshold() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let provider = Arc::new(SilentProvider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let agent = Agent::new(provider, tools, AgentConfig::default());
let session = Arc::new(Mutex::new(Session::in_memory()));
let settings = ResolvedCompactionSettings {
enabled: true,
context_window_tokens: 150,
reserve_tokens: 10,
keep_recent_tokens: 30,
mode: compaction::AutoCompactionMode::default(),
render_mode: compaction::CompactionRenderMode::default(),
};
{
let cx = crate::agent_cx::AgentCx::for_request();
let mut guard = session.lock(cx.cx()).await.expect("session lock");
for i in 0..2 {
guard.append_message(crate::session::SessionMessage::User {
content: UserContent::Text(format!("turn {i}: {}", "x".repeat(300))),
timestamp: Some(0),
});
}
}
let mut agent_session = AgentSession::new(agent, session, false, settings);
agent_session
.compaction_worker
.set_attempt_count_for_test(u32::MAX);
let events: Arc<std::sync::Mutex<Vec<AgentEvent>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = Arc::clone(&events);
let on_event: AgentEventHandler = Arc::new(move |event| {
sink.lock().expect("lock compaction events").push(event);
});
agent_session
.maybe_compact(on_event)
.await
.expect("maybe_compact");
assert!(
events.lock().expect("lock captured events").is_empty(),
"quota-blocked compaction below the forced threshold must stay a no-op"
);
let cx = crate::agent_cx::AgentCx::for_request();
let guard = agent_session
.session
.lock(cx.cx())
.await
.expect("session lock");
assert!(
!guard
.entries_for_current_path()
.iter()
.any(|entry| matches!(entry, crate::session::SessionEntry::Compaction(_))),
);
});
}
#[test]
fn auto_retry_start_serializes_to_pi_mono_format() {
let event = AgentEvent::AutoRetryStart {
attempt: 2,
max_attempts: 3,
delay_ms: 4000,
error_message: "rate limited".to_string(),
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "auto_retry_start");
assert_eq!(json["attempt"], 2);
assert_eq!(json["maxAttempts"], 3);
assert_eq!(json["delayMs"], 4000);
assert_eq!(json["errorMessage"], "rate limited");
}
#[test]
fn auto_retry_end_success_serializes_to_pi_mono_format() {
let event = AgentEvent::AutoRetryEnd {
success: true,
attempt: 2,
final_error: None,
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "auto_retry_end");
assert_eq!(json["success"], true);
assert_eq!(json["attempt"], 2);
assert!(json.get("finalError").is_none());
}
#[test]
fn auto_retry_end_failure_serializes_final_error() {
let event = AgentEvent::AutoRetryEnd {
success: false,
attempt: 3,
final_error: Some("max retries exceeded".to_string()),
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "auto_retry_end");
assert_eq!(json["success"], false);
assert_eq!(json["attempt"], 3);
assert_eq!(json["finalError"], "max retries exceeded");
}
fn xdev_test_agent(enabled: &[&str], cwd: &Path) -> Agent {
let provider = Arc::new(SilentProvider);
let tools = ToolRegistry::new(enabled, cwd, None);
Agent::new(provider, tools, AgentConfig::default())
}
fn xdev_call(action: &str, name: Option<&str>, args: Option<Value>) -> ToolCall {
let mut arguments = json!({ "action": action });
if let Some(name) = name {
arguments["name"] = json!(name);
}
if let Some(inner) = args {
arguments["args"] = inner;
}
ToolCall {
id: "call-xdev-1".to_string(),
name: "xdev".to_string(),
arguments,
thought_signature: None,
}
}
#[test]
fn xdev_list_and_describe_via_tool_execute() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp = tempfile::tempdir().expect("tempdir");
let agent = xdev_test_agent(&["read", "ast_grep"], temp.path());
let registry = agent.tools.snapshot();
let xdev = registry.get("xdev").expect("xdev registered");
let list = xdev
.execute("c1", json!({"action": "list"}), None)
.await
.expect("list");
let list_text = match &list.content[0] {
ContentBlock::Text(t) => t.text.clone(),
other => panic!("expected text, got {other:?}"),
};
assert!(
list_text.contains("ast_grep"),
"list shows discoverable ast_grep"
);
assert!(!list.is_error);
let describe = xdev
.execute(
"c2",
json!({"action": "describe", "name": "ast_grep"}),
None,
)
.await
.expect("describe");
let describe_text = match &describe.content[0] {
ContentBlock::Text(t) => t.text.clone(),
other => panic!("expected text, got {other:?}"),
};
let parsed: Value = serde_json::from_str(&describe_text).expect("describe JSON");
assert_eq!(parsed["name"], "ast_grep");
assert!(parsed["parameters"]["properties"]["pattern"].is_object());
});
}
#[test]
fn xdev_run_executes_discoverable_tool_through_agent() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp = tempfile::tempdir().expect("tempdir");
std::fs::write(
temp.path().join("main.rs"),
"fn main() { let _ = compute().unwrap(); }\nfn compute() -> i32 { 1 }\n",
)
.expect("write fixture");
let agent = xdev_test_agent(&["read", "ast_grep"], temp.path());
let call = xdev_call(
"run",
Some("ast_grep"),
Some(json!({"pattern": "$EXPR.unwrap()", "path": "."})),
);
let (output, is_error) = agent
.execute_tool_without_hooks(&call, Arc::new(|_| {}))
.await;
assert!(!is_error, "run must succeed: {:?}", output.content);
let text = match &output.content[0] {
ContentBlock::Text(t) => t.text.clone(),
other => panic!("expected text, got {other:?}"),
};
assert!(
text.contains("main.rs"),
"run output names the fixture file: {text}"
);
assert_eq!(
output
.details
.as_ref()
.and_then(|d| d.get("dispatchedVia"))
.and_then(Value::as_str),
Some("xdev")
);
});
}
#[test]
fn xdev_promote_moves_tool_into_live_schema() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp = tempfile::tempdir().expect("tempdir");
let mut agent = xdev_test_agent(&["read", "ast_grep"], temp.path());
let defs_before = agent
.build_context()
.tools
.iter()
.map(|def| def.name.clone())
.collect::<Vec<_>>();
assert!(defs_before.contains(&"read".to_string()));
assert!(defs_before.contains(&"xdev".to_string()));
assert!(
!defs_before.contains(&"ast_grep".to_string()),
"discoverable tool must be hidden pre-promotion"
);
let call = xdev_call("promote", Some("ast_grep"), None);
let (output, is_error) = agent
.execute_tool_without_hooks(&call, Arc::new(|_| {}))
.await;
assert!(!is_error);
let has_promoted_tool = agent
.build_context()
.tools
.iter()
.any(|def| def.name == "ast_grep");
assert!(
has_promoted_tool,
"promoted tool enters the schema mid-session"
);
});
}
#[test]
fn xdev_run_unknown_tool_named_error() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp = tempfile::tempdir().expect("tempdir");
let agent = xdev_test_agent(&["read", "ast_grep"], temp.path());
let call = xdev_call("run", Some("nosuch_tool"), Some(json!({})));
let (output, is_error) = agent
.execute_tool_without_hooks(&call, Arc::new(|_| {}))
.await;
assert!(is_error, "unknown discoverable tool must error");
let text = match &output.content[0] {
ContentBlock::Text(t) => t.text.clone(),
other => panic!("expected text, got {other:?}"),
};
assert!(text.contains("nosuch_tool"), "error names the tool: {text}");
});
}
#[test]
fn xdev_schema_token_accounting() {
let temp = tempfile::tempdir().expect("tempdir");
let full_registry = ToolRegistry::new(
&[
"read",
"bash",
"edit",
"write",
"grep",
"find",
"ls",
"hashline_edit",
"ast_grep",
"ast_edit",
],
temp.path(),
None,
);
let all_defs: usize = full_registry
.tools()
.iter()
.map(|t| serde_json::to_string(&json!({"name": t.name(), "description": t.description(), "parameters": t.parameters()})).unwrap().len())
.sum();
let essential_defs: usize = full_registry
.tools()
.iter()
.filter(|t| !full_registry.is_discoverable(t.name()))
.map(|t| serde_json::to_string(&json!({"name": t.name(), "description": t.description(), "parameters": t.parameters()})).unwrap().len())
.sum();
println!(
"xdev token accounting: all={all_defs} bytes, essential={essential_defs} bytes, saved={}",
all_defs - essential_defs
);
assert!(
essential_defs < all_defs,
"essential schema must be strictly smaller ({essential_defs} < {all_defs})"
);
assert!(full_registry.is_discoverable("ast_grep"));
assert!(!full_registry.is_discoverable("read"));
assert!(full_registry.get("xdev").is_some(), "xdev auto-registered");
}
#[test]
fn plan_gate_blocks_mutation_and_allows_reads() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp = tempfile::tempdir().expect("tempdir");
let target = temp.path().join("scratch.txt");
std::fs::write(&target, "original").expect("write fixture");
let provider = Arc::new(SilentProvider);
let tools = ToolRegistry::new(
&["read", "write", "bash", "grep", "ls", "ast_grep"],
temp.path(),
None,
);
let agent = Agent::new(provider, tools, AgentConfig::default());
agent.plan_state().enter_planning();
let write_call = ToolCall {
id: "w1".to_string(),
name: "write".to_string(),
arguments: json!({"path": "scratch.txt", "content": "changed"}),
thought_signature: None,
};
let (output, is_error) = agent
.execute_tool_without_hooks(&write_call, Arc::new(|_| {}))
.await;
assert!(is_error, "write must be blocked while planning");
let text = match &output.content[0] {
ContentBlock::Text(t) => t.text.clone(),
other => panic!("expected text, got {other:?}"),
};
assert!(text.contains("PLAN_MODE_BLOCKED"), "gate error: {text}");
assert_eq!(
std::fs::read_to_string(&target).expect("read back"),
"original"
);
let read_call = ToolCall {
id: "r1".to_string(),
name: "read".to_string(),
arguments: json!({"path": "scratch.txt"}),
thought_signature: None,
};
let (output, is_error) = agent
.execute_tool_without_hooks(&read_call, Arc::new(|_| {}))
.await;
assert!(!is_error, "read must pass the gate");
let text = match &output.content[0] {
ContentBlock::Text(t) => t.text.clone(),
other => panic!("expected text, got {other:?}"),
};
assert!(text.contains("original"), "read returns content: {text}");
let bash_call = ToolCall {
id: "b1".to_string(),
name: "bash".to_string(),
arguments: json!({"command": "echo hi"}),
thought_signature: None,
};
let (_output, is_error) = agent
.execute_tool_without_hooks(&bash_call, Arc::new(|_| {}))
.await;
assert!(is_error, "bash is blocked while planning");
agent
.plan_state()
.submit_plan("goal: test; steps: 1) write".to_string());
assert!(agent.plan_state().approve().is_some());
let write_call = ToolCall {
id: "w2".to_string(),
name: "write".to_string(),
arguments: json!({"path": "scratch.txt", "content": "changed"}),
thought_signature: None,
};
let (_output, is_error) = agent
.execute_tool_without_hooks(&write_call, Arc::new(|_| {}))
.await;
assert!(!is_error, "write allowed after approval");
assert_eq!(
std::fs::read_to_string(&target).expect("read back"),
"changed"
);
});
}
#[test]
fn plan_gate_xdev_run_uses_inner_tool_effects() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp = tempfile::tempdir().expect("tempdir");
std::fs::write(temp.path().join("main.rs"), "fn main() {}\n").expect("fixture");
let provider = Arc::new(SilentProvider);
let tools = ToolRegistry::new(
&["read", "write", "ast_grep", "ast_edit"],
temp.path(),
None,
);
let agent = Agent::new(provider, tools, AgentConfig::default());
agent.plan_state().enter_planning();
let run_read = ToolCall {
id: "x1".to_string(),
name: "xdev".to_string(),
arguments: json!({
"action": "run",
"name": "ast_grep",
"args": {"pattern": "fn $NAME($$$)", "path": "."}
}),
thought_signature: None,
};
let (_output, is_error) = agent
.execute_tool_without_hooks(&run_read, Arc::new(|_| {}))
.await;
assert!(
!is_error,
"xdev run on a read-only tool passes while planning"
);
let run_write = ToolCall {
id: "x2".to_string(),
name: "xdev".to_string(),
arguments: json!({
"action": "run",
"name": "ast_edit",
"args": {"ops": [{"pat": "fn main() {}", "out": ""}], "path": "."}
}),
thought_signature: None,
};
let (output, is_error) = agent
.execute_tool_without_hooks(&run_write, Arc::new(|_| {}))
.await;
assert!(
is_error,
"xdev run on a mutating tool is blocked while planning"
);
let text = match &output.content[0] {
ContentBlock::Text(t) => t.text.clone(),
other => panic!("expected text, got {other:?}"),
};
assert!(text.contains("PLAN_MODE_BLOCKED"), "gate error: {text}");
});
}
#[test]
fn plan_mode_session_entries_round_trip() {
let mut session = Session::in_memory();
session.append_custom_entry("plan_mode".to_string(), Some(json!({"mode": "planning"})));
session.append_custom_entry("plan_mode".to_string(), Some(json!({"mode": "approved"})));
let json = serde_json::to_string(session.entries_for_current_path()[1]).expect("ser");
let parsed: crate::session::SessionEntry = serde_json::from_str(&json).expect("reparse");
let crate::session::SessionEntry::Custom(custom) = &parsed else {
panic!("expected custom entry");
};
assert_eq!(custom.custom_type, "plan_mode");
assert_eq!(
custom
.data
.as_ref()
.and_then(|d| d.get("mode"))
.and_then(Value::as_str),
Some("approved")
);
}
#[test]
fn session_state_reset_drops_secret_placeholders_and_plan_gate() {
let provider = Arc::new(SilentProvider);
let tools = ToolRegistry::new(&[], Path::new("."), None);
let mut agent = Agent::new(provider, tools, AgentConfig::default());
let raw_secret = "sk-abcdefghijklmnopqrstuvwxyz123456";
let placeholder = agent
.secrets_transform_outbound_text(raw_secret)
.expect("obfuscate secret");
assert_ne!(placeholder, raw_secret);
agent.plan_state().enter_planning();
let before_reset = agent.restore_secrets_inbound(ToolCall {
id: "before".to_string(),
name: "read".to_string(),
arguments: json!({"token": placeholder}),
thought_signature: None,
});
assert_eq!(before_reset.arguments["token"], raw_secret);
agent.reset_session_scoped_state(crate::plan::PlanMode::Off);
assert_eq!(agent.plan_state().mode(), crate::plan::PlanMode::Off);
let after_reset = agent.restore_secrets_inbound(ToolCall {
id: "after".to_string(),
name: "read".to_string(),
arguments: json!({"token": placeholder}),
thought_signature: None,
});
assert_eq!(after_reset.arguments["token"], placeholder);
assert_eq!(agent.mask_secrets_text(raw_secret), raw_secret);
}
struct TextCallProvider {
calls: std::sync::atomic::AtomicUsize,
model_id: &'static str,
fail_after_tool_once: bool,
expected_retry_thinking: Option<crate::model::ThinkingLevel>,
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for TextCallProvider {
fn name(&self) -> &str {
"test-provider"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
self.model_id
}
async fn stream(
&self,
context: &Context<'_>,
options: &StreamOptions,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent>> + Send>>> {
let call_number = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if call_number >= 2
&& let Some(expected) = self.expected_retry_thinking
{
assert_eq!(
options.thinking_level,
Some(expected),
"retry must preserve the original turn's magic-keyword effort"
);
}
let make = |content: Vec<ContentBlock>, reason: StopReason| AssistantMessage {
content,
api: "test-api".to_string(),
provider: "test-provider".to_string(),
model: "qwen3-mock".to_string(),
usage: Usage::default(),
stop_reason: reason,
stop_details: None,
error_message: None,
timestamp: chrono::Utc::now().timestamp_millis(),
};
let events: Vec<Result<StreamEvent>> = if call_number == 0 {
vec![
Ok(StreamEvent::TextDelta {
content_index: 0,
delta: "Checking the file. <tool_call>{\"name\": \"read\", \"arguments\": {\"path\": \"fixture.txt\"}}</tool_call>".to_string(),
}),
Ok(StreamEvent::Done {
reason: StopReason::Stop,
message: make(
vec![ContentBlock::Text(TextContent::new(
"Checking the file. <tool_call>{\"name\": \"read\", \"arguments\": {\"path\": \"fixture.txt\"}}</tool_call>",
))],
StopReason::Stop,
),
}),
]
} else if self.fail_after_tool_once && call_number == 1 {
vec![
Ok(StreamEvent::Start {
partial: make(Vec::new(), StopReason::Stop),
}),
Err(Error::api(
"SSE error: connection reset by peer (transient connection drop)",
)),
]
} else {
assert!(
!context.messages.iter().any(|message| {
matches!(
message,
Message::Assistant(assistant)
if matches!(
assistant.stop_reason,
StopReason::Error | StopReason::Aborted
)
)
}),
"retry context must not retain the incomplete assistant"
);
vec![
Ok(StreamEvent::TextDelta {
content_index: 0,
delta: "The file says hello-fixture.".to_string(),
}),
Ok(StreamEvent::Done {
reason: StopReason::Stop,
message: make(
vec![ContentBlock::Text(TextContent::new(
"The file says hello-fixture.",
))],
StopReason::Stop,
),
}),
]
};
Ok(Box::pin(futures::stream::iter(events)))
}
}
#[test]
fn dialect_repair_preserves_other_blocks_and_uses_unique_ids() {
let provider = Arc::new(TextCallProvider {
calls: std::sync::atomic::AtomicUsize::new(0),
model_id: "qwen3-mock",
fail_after_tool_once: false,
expected_retry_thinking: None,
});
let tools = ToolRegistry::new(&["read"], std::path::Path::new("."), None);
let mut agent = Agent::new(provider, tools, AgentConfig::default());
agent.set_tool_call_dialect(crate::dialects::Dialect::Xmlish);
let original = AssistantMessage {
content: vec![
ContentBlock::Thinking(ThinkingContent {
thinking: "reasoning".to_string(),
thinking_signature: Some("thinking-sig".to_string()),
}),
ContentBlock::Text(TextContent {
text: "preface".to_string(),
text_signature: Some("preface-sig".to_string()),
}),
ContentBlock::Image(ImageContent {
data: "aGVsbG8=".to_string(),
mime_type: "image/png".to_string(),
}),
ContentBlock::Text(TextContent {
text: r#"<tool_call>{"name":"read","arguments":{"path":"fixture.txt"}}</tool_call>"#
.to_string(),
text_signature: Some("candidate-sig".to_string()),
}),
ContentBlock::Text(TextContent {
text: "tail".to_string(),
text_signature: Some("tail-sig".to_string()),
}),
],
api: "test-api".to_string(),
provider: "test-provider".to_string(),
model: "qwen3-mock".to_string(),
usage: Usage::default(),
stop_reason: StopReason::Stop,
stop_details: None,
error_message: None,
timestamp: chrono::Utc::now().timestamp_millis(),
};
let first = agent.maybe_repair_dialect_tool_calls(original.clone());
let second = agent.maybe_repair_dialect_tool_calls(original);
assert!(matches!(
&first.content[0],
ContentBlock::Thinking(thinking)
if thinking.thinking_signature.as_deref() == Some("thinking-sig")
));
assert!(matches!(
&first.content[1],
ContentBlock::Text(text)
if text.text == "preface"
&& text.text_signature.as_deref() == Some("preface-sig")
));
assert!(matches!(&first.content[2], ContentBlock::Image(_)));
assert!(matches!(
&first.content[4],
ContentBlock::Text(text)
if text.text == "tail" && text.text_signature.as_deref() == Some("tail-sig")
));
let first_id = extract_tool_calls(&first.content)[0].id.clone();
let second_id = extract_tool_calls(&second.content)[0].id.clone();
assert_ne!(
first_id, second_id,
"repaired call IDs must be session-unique"
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn dialect_repair_continues_turn_and_executes_tool() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
for model_id in ["gpt-4o", "gpt-5.5"] {
let control = Arc::new(TextCallProvider {
calls: std::sync::atomic::AtomicUsize::new(0),
model_id,
fail_after_tool_once: false,
expected_retry_thinking: None,
});
let control_tools = ToolRegistry::new(&["read"], std::path::Path::new("."), None);
let mut control_agent = Agent::new(
Arc::clone(&control) as Arc<dyn Provider>,
control_tools,
AgentConfig::default(),
);
control_agent.config.max_tool_iterations = 5;
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut control_session = AgentSession::new(
control_agent,
Arc::clone(&session),
false,
ResolvedCompactionSettings::default(),
);
let tool_starts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let tool_starts_for_events = Arc::clone(&tool_starts);
let control_result = control_session
.run_text_with_abort("hello".to_string(), None, move |event| {
if matches!(event, AgentEvent::ToolExecutionStart { .. }) {
tool_starts_for_events.fetch_add(1, Ordering::SeqCst);
}
})
.await
.expect("non-repairable control completes");
assert_eq!(control_result.stop_reason, StopReason::Stop);
assert_eq!(control.calls.load(Ordering::SeqCst), 1, "{model_id}");
assert_eq!(tool_starts.load(Ordering::SeqCst), 0, "{model_id}");
let cx = asupersync::Cx::for_request();
let inner = session.lock(&cx).await.expect("control session lock");
assert!(
inner
.entries_for_current_path()
.iter()
.all(|entry| !matches!(
entry,
crate::session::SessionEntry::Custom(custom)
if custom.custom_type == "dialect_repair"
)),
"{model_id} must not persist repair telemetry"
);
}
});
runtime.block_on(async {
let temp = tempfile::tempdir().expect("tempdir");
std::fs::write(temp.path().join("fixture.txt"), "hello-fixture")
.expect("write fixture");
let provider = Arc::new(TextCallProvider {
calls: std::sync::atomic::AtomicUsize::new(0),
model_id: "qwen3-mock",
fail_after_tool_once: false,
expected_retry_thinking: None,
});
let tools = ToolRegistry::new(&["read"], temp.path(), None);
let mut agent = Agent::new(provider, tools, AgentConfig::default());
agent.set_tool_call_dialect(crate::dialects::Dialect::Xmlish);
let session = Arc::new(Mutex::new(Session::create_with_dir(Some(
temp.path().join("sessions"),
))));
let mut agent_session =
AgentSession::new(agent, session, true, ResolvedCompactionSettings::default());
let final_message = agent_session
.run_text_with_abort("check the fixture".to_string(), None, |_| {})
.await
.expect("run completes");
let texts: Vec<String> = agent_session
.agent
.messages()
.iter()
.flat_map(|m| match m {
crate::model::Message::Assistant(msg) => msg.content.clone(),
crate::model::Message::User(u) => match &u.content {
crate::model::UserContent::Text(t) => {
vec![ContentBlock::Text(TextContent::new(t))]
}
crate::model::UserContent::Blocks(blocks) => blocks.clone(),
},
crate::model::Message::ToolResult(r) => r.content.clone(),
crate::model::Message::Custom(_) => Vec::new(),
})
.filter_map(|b| match b {
ContentBlock::Text(t) => Some(t.text),
_ => None,
})
.collect();
assert!(
texts.iter().any(|t| t.contains("hello-fixture")),
"tool result with fixture content present: {texts:?}"
);
assert_eq!(
final_message.stop_reason,
StopReason::Stop,
"turn ends cleanly after the repair"
);
let entries = {
let cx = asupersync::Cx::for_request();
let inner = agent_session.session.lock(&cx).await.expect("session lock");
let found: Vec<_> = inner
.entries_for_current_path()
.iter()
.filter_map(|e| match e {
crate::session::SessionEntry::Custom(c)
if c.custom_type == "dialect_repair" =>
{
Some(c.data.clone())
}
_ => None,
})
.collect();
found
};
assert_eq!(entries.len(), 1, "one repair entry in the session");
assert_eq!(
entries[0]
.as_ref()
.and_then(|d| d.get("tool"))
.and_then(Value::as_str),
Some("read")
);
let persisted_path = {
let cx = asupersync::Cx::for_request();
let inner = agent_session.session.lock(&cx).await.expect("session lock");
inner.path.clone().expect("autosave created session file")
};
let reopened = Session::open(persisted_path.to_string_lossy().as_ref())
.await
.expect("reopen autosaved session");
let persisted_repairs = reopened
.entries_for_current_path()
.iter()
.filter(|entry| {
matches!(
entry,
crate::session::SessionEntry::Custom(custom)
if custom.custom_type == "dialect_repair"
)
})
.count();
assert_eq!(persisted_repairs, 1, "repair audit entry survives reopen");
});
}
#[test]
fn dialect_repair_on_resume_persists_before_exit() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp = tempfile::tempdir().expect("tempdir");
std::fs::write(temp.path().join("fixture.txt"), "hello-fixture")
.expect("write fixture");
let provider = Arc::new(TextCallProvider {
calls: std::sync::atomic::AtomicUsize::new(0),
model_id: "qwen3-mock",
fail_after_tool_once: false,
expected_retry_thinking: None,
});
let tools = ToolRegistry::new(&["read"], temp.path(), None);
let mut agent = Agent::new(provider, tools, AgentConfig::default());
agent.set_tool_call_dialect(crate::dialects::Dialect::Xmlish);
let mut durable_session =
Session::create_with_dir(Some(temp.path().join("resume-sessions")));
durable_session.append_model_message(Message::User(UserMessage {
content: UserContent::Text("check the fixture".to_string()),
timestamp: Utc::now().timestamp_millis(),
}));
let session = Arc::new(Mutex::new(durable_session));
let mut agent_session = AgentSession::new(
agent,
Arc::clone(&session),
true,
ResolvedCompactionSettings::default(),
);
let final_message = agent_session
.run_continue_with_abort(None, |_| {})
.await
.expect("resume completes");
assert_eq!(final_message.stop_reason, StopReason::Stop);
let persisted_path = {
let cx = asupersync::Cx::for_request();
let inner = session.lock(&cx).await.expect("session lock");
inner.path.clone().expect("autosave created session file")
};
let reopened = Session::open(persisted_path.to_string_lossy().as_ref())
.await
.expect("reopen autosaved resume session");
let persisted_repairs = reopened
.entries_for_current_path()
.iter()
.filter(|entry| {
matches!(
entry,
crate::session::SessionEntry::Custom(custom)
if custom.custom_type == "dialect_repair"
&& custom.data.as_ref().is_some_and(|data| {
data["tool"] == json!("read")
})
)
})
.count();
assert_eq!(
persisted_repairs, 1,
"resume repair audit entry survives an immediate reopen"
);
});
}
#[test]
#[allow(clippy::too_many_lines)]
fn failed_keyword_repair_turn_keeps_audits_and_reverts_incomplete_tail() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp = tempfile::tempdir().expect("tempdir");
std::fs::write(temp.path().join("fixture.txt"), "hello-fixture")
.expect("write fixture");
let provider = Arc::new(TextCallProvider {
calls: std::sync::atomic::AtomicUsize::new(0),
model_id: "qwen3-mock",
fail_after_tool_once: true,
expected_retry_thinking: Some(crate::model::ThinkingLevel::High),
});
let tools = ToolRegistry::new(&["read"], temp.path(), None);
let mut agent = Agent::new(
Arc::clone(&provider) as Arc<dyn Provider>,
tools,
AgentConfig::default(),
);
agent.set_tool_call_dialect(crate::dialects::Dialect::Xmlish);
agent.set_keyword_max_thinking_level(crate::model::ThinkingLevel::High);
let session = Arc::new(Mutex::new(Session::create_with_dir(Some(
temp.path().join("retry-sessions"),
))));
let mut agent_session = AgentSession::new(
agent,
Arc::clone(&session),
true,
ResolvedCompactionSettings::default(),
);
let tool_starts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let tool_starts_for_events = Arc::clone(&tool_starts);
let failed = agent_session
.run_text_with_abort(
"ultrathink check the fixture".to_string(),
None,
move |event| {
if matches!(event, AgentEvent::ToolExecutionStart { .. }) {
tool_starts_for_events.fetch_add(1, Ordering::SeqCst);
}
},
)
.await
.expect("mid-stream failure is represented by an assistant message");
assert_eq!(failed.stop_reason, StopReason::Error);
{
let cx = asupersync::Cx::for_request();
let inner = session.lock(&cx).await.expect("failed session lock");
let entries = inner.entries_for_current_path();
assert!(matches!(
entries.last(),
Some(crate::session::SessionEntry::Message(message))
if matches!(
&message.message,
crate::session::SessionMessage::Assistant { message }
if message.stop_reason == StopReason::Error
)
));
assert_eq!(
entries
.iter()
.filter(|entry| matches!(
entry,
crate::session::SessionEntry::Custom(custom)
if custom.custom_type == "dialect_repair"
))
.count(),
1
);
assert_eq!(
entries
.iter()
.filter(|entry| matches!(
entry,
crate::session::SessionEntry::Custom(custom)
if custom.custom_type == "magic_keyword"
))
.count(),
1
);
}
assert!(
agent_session
.revert_incomplete_response()
.await
.expect("revert succeeds"),
"the failed assistant must remain the removable session tail"
);
let resumed = agent_session
.run_continue_with_abort(None, |_| {})
.await
.expect("retry resumes after the completed tool cycle");
assert_eq!(resumed.stop_reason, StopReason::Stop);
assert_eq!(provider.calls.load(Ordering::SeqCst), 3);
assert_eq!(tool_starts.load(Ordering::SeqCst), 1);
let persisted_path = {
let cx = asupersync::Cx::for_request();
let inner = session.lock(&cx).await.expect("resumed session lock");
inner.path.clone().expect("autosave created session file")
};
let reopened = Session::open(persisted_path.to_string_lossy().as_ref())
.await
.expect("reopen autosaved retry session");
let entries = reopened.entries_for_current_path();
assert!(entries.iter().all(|entry| !matches!(
entry,
crate::session::SessionEntry::Message(message)
if matches!(
&message.message,
crate::session::SessionMessage::Assistant { message }
if matches!(message.stop_reason, StopReason::Error | StopReason::Aborted)
)
)));
let repair_data = entries.iter().find_map(|entry| match entry {
crate::session::SessionEntry::Custom(custom)
if custom.custom_type == "dialect_repair" =>
{
custom.data.as_ref()
}
_ => None,
});
assert_eq!(
repair_data
.and_then(|data| data.get("tool"))
.and_then(Value::as_str),
Some("read")
);
assert!(
repair_data
.and_then(|data| data.get("strippedBytes"))
.and_then(Value::as_u64)
.is_some_and(|bytes| bytes > 0),
"repair telemetry must prove that text was actually stripped"
);
assert_eq!(
entries
.iter()
.filter(|entry| matches!(
entry,
crate::session::SessionEntry::Custom(custom)
if custom.custom_type == "dialect_repair"
))
.count(),
1
);
assert_eq!(
entries
.iter()
.filter(|entry| matches!(
entry,
crate::session::SessionEntry::Custom(custom)
if custom.custom_type == "magic_keyword"
))
.count(),
1
);
});
}
struct ScriptedDoerProvider {
calls: std::sync::atomic::AtomicUsize,
}
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for ScriptedDoerProvider {
fn name(&self) -> &str {
"doer"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"doer-model"
}
async fn stream(
&self,
_context: &Context<'_>,
_options: &StreamOptions,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent>> + Send>>> {
let call_number = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let make = |content: Vec<ContentBlock>| AssistantMessage {
content,
api: "test-api".to_string(),
provider: "doer".to_string(),
model: "doer-model".to_string(),
usage: Usage::default(),
stop_reason: StopReason::Stop,
stop_details: None,
error_message: None,
timestamp: chrono::Utc::now().timestamp_millis(),
};
let events: Vec<Result<StreamEvent>> = if call_number == 0 {
vec![Ok(StreamEvent::Done {
reason: StopReason::Stop,
message: make(vec![ContentBlock::ToolCall(ToolCall {
id: "call-1".to_string(),
name: "read".to_string(),
arguments: json!({"path": "fixture.txt"}),
thought_signature: None,
})]),
})]
} else {
vec![Ok(StreamEvent::Done {
reason: StopReason::Stop,
message: make(vec![ContentBlock::Text(TextContent::new("all fixed"))]),
})]
};
Ok(Box::pin(futures::stream::iter(events)))
}
}
struct ScriptedAdvisorProvider;
#[async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Provider for ScriptedAdvisorProvider {
fn name(&self) -> &str {
"advisor"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"advisor-model"
}
async fn stream(
&self,
_context: &Context<'_>,
_options: &StreamOptions,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent>> + Send>>> {
let events: Vec<Result<StreamEvent>> = vec![
Ok(StreamEvent::TextDelta {
content_index: 0,
delta: "CONCERN: the read covers the whole tree".to_string(),
}),
Ok(StreamEvent::Done {
reason: StopReason::Stop,
message: AssistantMessage {
content: vec![ContentBlock::Text(TextContent::new(
"CONCERN: the read covers the whole tree",
))],
api: "test-api".to_string(),
provider: "advisor".to_string(),
model: "advisor-model".to_string(),
usage: Usage::default(),
stop_reason: StopReason::Stop,
stop_details: None,
error_message: None,
timestamp: chrono::Utc::now().timestamp_millis(),
},
}),
];
Ok(Box::pin(futures::stream::iter(events)))
}
}
#[test]
fn advisor_review_injects_concern_into_steering_queue() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp = tempfile::tempdir().expect("tempdir");
std::fs::write(temp.path().join("fixture.txt"), "x").expect("fixture");
let tools = ToolRegistry::new(&["read"], temp.path(), None);
let agent = Agent::new(
Arc::new(ScriptedDoerProvider {
calls: std::sync::atomic::AtomicUsize::new(0),
}),
tools,
AgentConfig::default(),
);
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
agent_session.advisor = Some(crate::advisor::AdvisorRuntime::new(
Arc::new(ScriptedAdvisorProvider),
"advisor/advisor-model".to_string(),
));
let _ = agent_session
.run_text_with_abort("check the fixture".to_string(), None, |_| {})
.await
.expect("turn 1 completes");
let _ = agent_session
.run_text_with_abort("continue".to_string(), None, |_| {})
.await
.expect("turn 2 completes");
let steered = agent_session
.agent
.messages()
.iter()
.filter_map(|m| match m {
crate::model::Message::User(u) => match &u.content {
crate::model::UserContent::Text(t) => Some(t.clone()),
crate::model::UserContent::Blocks(_) => None,
},
_ => None,
})
.any(|text| text.contains("ADVISOR:CONCERN"));
assert!(
steered,
"advisor concern must be delivered into the next turn"
);
let has_entry = {
let cx = asupersync::Cx::for_request();
let inner = agent_session.session.lock(&cx).await.expect("session lock");
inner.entries_for_current_path().iter().any(|e| {
matches!(
e,
crate::session::SessionEntry::Custom(c) if c.custom_type == "advisor_note"
)
})
};
assert!(has_entry, "advisor_note session entry recorded");
});
}
#[test]
fn advisor_absent_means_zero_overhead() {
let runtime = RuntimeBuilder::current_thread()
.build()
.expect("runtime build");
runtime.block_on(async {
let temp = tempfile::tempdir().expect("tempdir");
let tools = ToolRegistry::new(&["read"], temp.path(), None);
let agent = Agent::new(
Arc::new(ScriptedDoerProvider {
calls: std::sync::atomic::AtomicUsize::new(0),
}),
tools,
AgentConfig::default(),
);
let session = Arc::new(Mutex::new(Session::in_memory()));
let mut agent_session =
AgentSession::new(agent, session, false, ResolvedCompactionSettings::default());
assert!(agent_session.advisor.is_none());
let result = agent_session
.run_text_with_abort("work".to_string(), None, |_| {})
.await;
assert!(result.is_ok(), "no advisor → turn unaffected");
});
}
}