mod extension_bootstrap;
mod recovery;
use crate::app;
use crate::auth::AuthStorage;
use crate::cli::Cli;
use crate::models::default_models_path;
use crate::provider::ThinkingBudgets;
use crate::providers;
use clap::Parser;
use futures::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{ChildStdin, ChildStdout, Command, Stdio};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub use crate::agent::{
AbortHandle, AbortSignal, Agent, AgentConfig, AgentEvent, AgentSession, QueueMode,
};
pub use crate::compaction::ResolvedCompactionSettings;
pub use crate::config::Config;
pub use crate::error::{Error, Result};
pub use crate::extension_dispatcher::ExtensionUiHandler;
pub use crate::extensions::{
ExtensionManager, ExtensionPolicy, ExtensionRegion, ExtensionUiRequest, ExtensionUiResponse,
};
pub use crate::model::ThinkingLevel;
pub use crate::model::{
AssistantMessage, ContentBlock, Cost, CustomMessage, ImageContent, MediaContent, Message,
StopDetails, StopReason, StreamEvent, TextContent, ThinkingContent, ToolCall,
ToolResultMessage, Usage, UserContent, UserMessage,
};
pub use crate::models::{ModelEntry, ModelRegistry};
pub use crate::provider::{
Context as ProviderContext, InputType, Model, ModelCost, Provider, StreamOptions,
ThinkingBudgets as ProviderThinkingBudgets, ToolDef,
};
pub use crate::session::Session;
pub use crate::tools::{Tool, ToolOutput, ToolRegistry, ToolUpdate};
pub type ToolDefinition = ToolDef;
use crate::tools::{
BashTool, EditTool, FindTool, GrepTool, HashlineEditTool, LsTool, ReadTool, WriteTool,
};
pub const BUILTIN_TOOL_NAMES: &[&str] = &[
"read",
"bash",
"edit",
"write",
"grep",
"find",
"ls",
"hashline_edit",
];
pub fn create_read_tool(cwd: &Path) -> Box<dyn Tool> {
Box::new(ReadTool::new(cwd))
}
pub fn create_bash_tool(cwd: &Path) -> Box<dyn Tool> {
Box::new(BashTool::new(cwd))
}
pub fn create_edit_tool(cwd: &Path) -> Box<dyn Tool> {
Box::new(EditTool::new(cwd))
}
pub fn create_write_tool(cwd: &Path) -> Box<dyn Tool> {
Box::new(WriteTool::new(cwd))
}
pub fn create_grep_tool(cwd: &Path) -> Box<dyn Tool> {
Box::new(GrepTool::new(cwd))
}
pub fn create_find_tool(cwd: &Path) -> Box<dyn Tool> {
Box::new(FindTool::new(cwd))
}
pub fn create_ls_tool(cwd: &Path) -> Box<dyn Tool> {
Box::new(LsTool::new(cwd))
}
pub fn create_hashline_edit_tool(cwd: &Path) -> Box<dyn Tool> {
Box::new(HashlineEditTool::new(cwd))
}
pub fn create_all_tools(cwd: &Path) -> Vec<Box<dyn Tool>> {
vec![
create_read_tool(cwd),
create_bash_tool(cwd),
create_edit_tool(cwd),
create_write_tool(cwd),
create_grep_tool(cwd),
create_find_tool(cwd),
create_ls_tool(cwd),
create_hashline_edit_tool(cwd),
]
}
pub fn tool_to_definition(tool: &dyn Tool) -> ToolDefinition {
ToolDefinition {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters(),
}
}
pub fn all_tool_definitions(cwd: &Path) -> Vec<ToolDefinition> {
create_all_tools(cwd)
.iter()
.map(|t| tool_to_definition(t.as_ref()))
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SubscriptionId(u64);
pub type OnToolStart = Arc<dyn Fn(&str, &Value) + Send + Sync>;
pub type OnToolEnd = Arc<dyn Fn(&str, &ToolOutput, bool) + Send + Sync>;
pub type OnStreamEvent = Arc<dyn Fn(&StreamEvent) + Send + Sync>;
pub type EventSubscriber = Arc<dyn Fn(AgentEvent) + Send + Sync>;
type EventSubscribers = HashMap<SubscriptionId, EventSubscriber>;
#[derive(Clone, Default)]
pub struct EventListeners {
next_id: Arc<AtomicU64>,
subscribers: Arc<std::sync::Mutex<EventSubscribers>>,
pub on_tool_start: Option<OnToolStart>,
pub on_tool_end: Option<OnToolEnd>,
pub on_stream_event: Option<OnStreamEvent>,
}
impl EventListeners {
fn new() -> Self {
Self {
next_id: Arc::new(AtomicU64::new(1)),
subscribers: Arc::new(std::sync::Mutex::new(HashMap::new())),
on_tool_start: None,
on_tool_end: None,
on_stream_event: None,
}
}
pub fn subscribe(&self, listener: EventSubscriber) -> SubscriptionId {
let id = SubscriptionId(self.next_id.fetch_add(1, Ordering::Relaxed));
let mut subs = self
.subscribers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
subs.insert(id, listener);
id
}
pub fn unsubscribe(&self, id: SubscriptionId) -> bool {
let mut subs = self
.subscribers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
subs.remove(&id).is_some()
}
pub fn notify(&self, event: &AgentEvent) {
let listeners: Vec<_> = {
let subs = self
.subscribers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
subs.values().cloned().collect()
};
for listener in listeners {
listener(event.clone());
}
}
pub fn notify_tool_start(&self, tool_name: &str, args: &Value) {
if let Some(cb) = &self.on_tool_start {
cb(tool_name, args);
}
}
pub fn notify_tool_end(&self, tool_name: &str, output: &ToolOutput, is_error: bool) {
if let Some(cb) = &self.on_tool_end {
cb(tool_name, output, is_error);
}
}
pub fn notify_stream_event(&self, event: &StreamEvent) {
if let Some(cb) = &self.on_stream_event {
cb(event);
}
}
}
impl std::fmt::Debug for EventListeners {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let count = self.subscribers.lock().map_or(0, |s| s.len());
let next_id = self.next_id.load(Ordering::Relaxed);
f.debug_struct("EventListeners")
.field("subscriber_count", &count)
.field("next_id", &next_id)
.field("has_on_tool_start", &self.on_tool_start.is_some())
.field("has_on_tool_end", &self.on_tool_end.is_some())
.field("has_on_stream_event", &self.on_stream_event.is_some())
.finish()
}
}
#[derive(Debug, Clone, Default)]
pub struct McpSessionOptions {
pub config_paths: Vec<PathBuf>,
pub global_dir: Option<PathBuf>,
}
#[derive(Clone)]
pub struct FailoverOptions {
pub chains: std::collections::HashMap<String, Vec<String>>,
pub available_models: Vec<crate::models::ModelEntry>,
pub auth: crate::auth::AuthStorage,
pub cli_api_key: Option<String>,
pub cooldown_secs: u64,
}
impl FailoverOptions {
#[must_use]
pub fn from_config(
config: &Config,
available_models: Vec<crate::models::ModelEntry>,
auth: crate::auth::AuthStorage,
cli_api_key: Option<String>,
) -> Option<Self> {
let chains = config
.retry
.as_ref()
.and_then(|retry| retry.fallback_chains.as_ref())?
.clone();
Some(Self {
chains,
available_models,
auth,
cli_api_key,
cooldown_secs: config.failover_cooldown_secs(),
})
}
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Clone)]
pub struct SessionOptions {
pub provider: Option<String>,
pub model: Option<String>,
pub model_scope: Option<Vec<String>>,
pub api_key: Option<String>,
pub thinking: Option<crate::model::ThinkingLevel>,
pub system_prompt: Option<String>,
pub append_system_prompt: Option<String>,
pub enabled_tools: Option<Vec<String>>,
pub working_directory: Option<PathBuf>,
pub workspace_trusted: bool,
pub no_session: bool,
pub session_path: Option<PathBuf>,
pub session_dir: Option<PathBuf>,
pub package_dir: Option<PathBuf>,
pub extension_paths: Vec<PathBuf>,
pub extension_policy: Option<String>,
pub repair_policy: Option<String>,
pub extension_flags: Vec<crate::cli::ExtensionCliFlag>,
pub include_cwd_in_prompt: bool,
pub skills_prompt: Option<String>,
pub no_context_files: bool,
pub max_tool_iterations: usize,
pub max_time: Option<Duration>,
pub retry: Option<crate::failover::RetryPolicy>,
pub failover: Option<FailoverOptions>,
pub mcp: Option<McpSessionOptions>,
pub advisor: Option<crate::advisor::AdvisorOptions>,
pub runtime_handle: Option<asupersync::runtime::RuntimeHandle>,
pub tool_factory: Option<Arc<dyn ToolFactory>>,
pub workspace: Option<crate::workspace::WorkspaceHandle>,
pub on_event: Option<Arc<dyn Fn(AgentEvent) + Send + Sync>>,
pub on_tool_start: Option<OnToolStart>,
pub on_tool_end: Option<OnToolEnd>,
pub on_stream_event: Option<OnStreamEvent>,
pub extension_ui_handler: Option<Arc<dyn ExtensionUiHandler>>,
pub persist_extension_permissions: bool,
pub compaction_settings: Option<ResolvedCompactionSettings>,
pub approval_state: Option<crate::approval::ApprovalState>,
pub tool_approval: Option<crate::agent::ToolApprovalHandler>,
}
impl Default for SessionOptions {
fn default() -> Self {
Self {
provider: None,
model: None,
model_scope: None,
api_key: None,
thinking: None,
system_prompt: None,
append_system_prompt: None,
enabled_tools: None,
working_directory: None,
workspace_trusted: false,
package_dir: None,
no_session: true,
session_path: None,
session_dir: None,
extension_paths: Vec::new(),
extension_policy: None,
extension_flags: Vec::new(),
tool_factory: None,
workspace: None,
repair_policy: None,
include_cwd_in_prompt: true,
skills_prompt: None,
no_context_files: false,
max_tool_iterations: crate::agent::resolved_max_tool_iterations_default(),
max_time: None,
retry: None,
failover: None,
mcp: None,
advisor: None,
runtime_handle: None,
on_event: None,
on_tool_start: None,
on_tool_end: None,
on_stream_event: None,
extension_ui_handler: None,
persist_extension_permissions: true,
compaction_settings: None,
approval_state: None,
tool_approval: None,
}
}
}
pub trait ToolFactory: Send + Sync {
fn create_tool_registry(&self, enabled: &[&str], cwd: &Path, config: &Config) -> ToolRegistry;
}
pub fn default_tool_registry(enabled: &[&str], cwd: &Path, config: &Config) -> ToolRegistry {
if enabled.is_empty() {
ToolRegistry::without_builtins(Some(config))
} else {
ToolRegistry::new(enabled, cwd, Some(config))
}
}
pub struct AgentSessionHandle {
session: AgentSession,
listeners: EventListeners,
ask_tool: Option<crate::ask::AskTool>,
workspace: Option<crate::workspace::WorkspaceHandle>,
mcp_manager: Option<Arc<crate::mcp::McpManager>>,
retry: Option<crate::failover::RetryPolicy>,
failover: Option<Arc<FailoverOptions>>,
failover_state: crate::failover::FailoverState,
event_runtime: Option<asupersync::runtime::Runtime>,
}
#[derive(PartialEq, Eq)]
struct SessionEditSnapshot {
path: Option<PathBuf>,
session_dir: Option<PathBuf>,
leaf_id: Option<String>,
content: Vec<u8>,
}
impl SessionEditSnapshot {
fn capture(session: &Session) -> Result<Self> {
Ok(Self {
path: session.path.clone(),
session_dir: session.session_dir.clone(),
leaf_id: session.leaf_id().map(str::to_string),
content: serde_json::to_vec(&(&session.header, &session.entries))?,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentSessionState {
pub session_id: Option<String>,
pub provider: String,
pub model_id: String,
pub thinking_level: Option<crate::model::ThinkingLevel>,
pub save_enabled: bool,
pub message_count: usize,
}
#[derive(Debug, Clone)]
pub enum SessionPromptResult {
InProcess(Box<AssistantMessage>),
RpcEvents(Vec<Value>),
}
#[derive(Debug, Clone)]
pub enum SessionTransportEvent {
InProcess(Box<AgentEvent>),
Rpc(Value),
}
#[derive(Debug, Clone, PartialEq)]
pub enum SessionTransportState {
InProcess(AgentSessionState),
Rpc(Box<RpcSessionState>),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RpcModelInfo {
pub id: String,
pub name: String,
pub api: String,
pub provider: String,
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub reasoning: bool,
#[serde(default)]
pub input: Vec<InputType>,
#[serde(default)]
pub context_window: u32,
#[serde(default)]
pub max_tokens: u32,
#[serde(default)]
pub cost: Option<ModelCost>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
#[allow(clippy::struct_excessive_bools)]
pub struct RpcSessionState {
#[serde(default)]
pub model: Option<RpcModelInfo>,
#[serde(default)]
pub thinking_level: String,
#[serde(default)]
pub is_streaming: bool,
#[serde(default)]
pub is_compacting: bool,
#[serde(default)]
pub steering_mode: String,
#[serde(default)]
pub follow_up_mode: String,
#[serde(default)]
pub session_file: Option<String>,
#[serde(default)]
pub session_id: String,
#[serde(default)]
pub session_name: Option<String>,
#[serde(default)]
pub auto_compaction_enabled: bool,
#[serde(default)]
pub auto_retry_enabled: bool,
#[serde(default)]
pub message_count: usize,
#[serde(default)]
pub pending_message_count: usize,
#[serde(default)]
pub durability_mode: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RpcTokenStats {
pub input: u64,
pub output: u64,
pub cache_read: u64,
pub cache_write: u64,
pub total: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RpcSessionStats {
#[serde(default)]
pub session_file: Option<String>,
pub session_id: String,
pub user_messages: u64,
pub assistant_messages: u64,
pub tool_calls: u64,
pub tool_results: u64,
pub total_messages: u64,
pub tokens: RpcTokenStats,
pub cost: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RpcCancelledResult {
pub cancelled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RpcCycleModelResult {
pub model: RpcModelInfo,
pub thinking_level: crate::model::ThinkingLevel,
pub is_scoped: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RpcThinkingLevelResult {
pub level: crate::model::ThinkingLevel,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RpcBashResult {
pub output: String,
pub exit_code: i32,
pub cancelled: bool,
pub truncated: bool,
pub full_output_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RpcCompactionResult {
pub summary: String,
pub first_kept_entry_id: String,
pub tokens_before: u64,
#[serde(default)]
pub tokens_after: u64,
#[serde(default)]
pub details: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RpcForkResult {
pub text: String,
pub cancelled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RpcForkMessage {
pub entry_id: String,
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RpcCommandInfo {
pub name: String,
#[serde(default)]
pub description: Option<String>,
pub source: String,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RpcExportHtmlResult {
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RpcLastAssistantText {
pub text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RpcExtensionUiResponse {
Value { value: Value },
Confirmed { confirmed: bool },
Cancelled,
}
#[derive(Debug, Clone)]
pub struct RpcTransportOptions {
pub binary_path: PathBuf,
pub args: Vec<String>,
pub cwd: Option<PathBuf>,
}
impl Default for RpcTransportOptions {
fn default() -> Self {
Self {
binary_path: PathBuf::from("pi"),
args: vec!["--mode".to_string(), "rpc".to_string()],
cwd: None,
}
}
}
const RPC_MAX_LINE_BYTES: usize = 128 * 1024 * 1024;
const RPC_MAX_PRE_ACK_EVENTS: usize = 256;
const RPC_MAX_PRE_ACK_BYTES: usize = 256 * 1024 * 1024;
const RPC_PIPE_POLL_INTERVAL: Duration = Duration::from_millis(5);
pub struct RpcTransportClient {
owner: Arc<RpcSubprocessOwner>,
stdin: Arc<Mutex<ChildStdin>>,
stdout: futures::channel::mpsc::Receiver<Result<Value>>,
reader_worker: Option<std::thread::JoinHandle<()>>,
writer_worker: Option<std::thread::JoinHandle<()>>,
next_request_id: Arc<AtomicU64>,
}
struct RpcWriteRequest {
encoded: Vec<u8>,
completed: futures::channel::oneshot::Sender<Result<()>>,
}
struct RpcSubprocessOwner {
process: Mutex<RpcProcessCleanup>,
closed: Arc<AtomicBool>,
writer: Mutex<Option<std::sync::mpsc::SyncSender<RpcWriteRequest>>>,
reader_abort: futures::future::AbortHandle,
}
struct RpcProcessCleanup {
child: Option<crate::tools::ProcessGuard>,
reaper: Option<RpcProcessReaper>,
}
struct RpcProcessReaper {
worker: std::thread::JoinHandle<std::io::Result<std::process::ExitStatus>>,
child: Arc<Mutex<Option<crate::tools::ProcessGuard>>>,
}
impl RpcSubprocessOwner {
fn ensure_open(&self) -> Result<()> {
if self.closed.load(Ordering::Acquire) {
Err(Error::api("RPC subprocess transport is closed"))
} else {
Ok(())
}
}
fn close(&self, wait: bool) -> Result<()> {
self.closed.store(true, Ordering::Release);
self.reader_abort.abort();
drop(
self.writer
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take(),
);
let mut process = self
.process
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(child) = process.child.take() {
let child = Arc::new(Mutex::new(Some(child)));
let reaper_child = Arc::clone(&child);
if let Ok(worker) = std::thread::Builder::new()
.name("pi-sdk-rpc-reaper".to_string())
.spawn(move || {
let mut child = reaper_child
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
child
.as_mut()
.expect("RPC reaper owns its child")
.terminate_and_wait()
})
{
process.reaper = Some(RpcProcessReaper { worker, child });
} else {
let mut child = child
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
.expect("failed RPC reaper spawn retains its child");
if let Err(error) = child.terminate_and_wait() {
process.child = Some(child);
return Err(Error::Io(Box::new(error)));
}
}
}
let reaper = if wait { process.reaper.take() } else { None };
drop(process);
if let Some(reaper) = reaper {
let result = reaper
.worker
.join()
.map_err(|_| Error::api("RPC subprocess reaper panicked"))
.and_then(|result| result.map_err(|error| Error::Io(Box::new(error))));
if result.is_err() {
self.process
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.child = reaper
.child
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
}
result?;
}
Ok(())
}
}
impl Drop for RpcSubprocessOwner {
fn drop(&mut self) {
let _ = self.close(true);
}
}
struct RpcOperation {
owner: Arc<RpcSubprocessOwner>,
finished: bool,
}
impl RpcOperation {
fn new(owner: &Arc<RpcSubprocessOwner>) -> Result<Self> {
owner.ensure_open()?;
Ok(Self {
owner: Arc::clone(owner),
finished: false,
})
}
const fn finish(&mut self) {
self.finished = true;
}
}
impl Drop for RpcOperation {
fn drop(&mut self) {
if !self.finished {
let _ = self.owner.close(false);
}
}
}
#[derive(Clone)]
pub struct RpcControlHandle {
owner: Arc<RpcSubprocessOwner>,
stdin: Arc<Mutex<ChildStdin>>,
next_request_id: Arc<AtomicU64>,
}
impl std::fmt::Debug for RpcControlHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RpcControlHandle").finish_non_exhaustive()
}
}
impl RpcControlHandle {
fn send(&self, command: &str, payload: Map<String, Value>) -> Result<String> {
self.owner.ensure_open()?;
let request_id = next_rpc_request_id(&self.next_request_id)?;
let mut frame = Map::new();
frame.insert("type".to_string(), Value::String(command.to_string()));
frame.insert("id".to_string(), Value::String(request_id.clone()));
frame.extend(payload);
let encoded = encode_rpc_json_line(&Value::Object(frame))?;
if let Err(error) = write_rpc_bytes(&self.stdin, &self.owner.closed, &encoded) {
let _ = self.owner.close(false);
return Err(error);
}
Ok(request_id)
}
pub fn steer(&self, message: impl Into<String>) -> Result<String> {
let mut payload = Map::new();
payload.insert("message".to_string(), Value::String(message.into()));
self.send("steer", payload)
}
pub fn follow_up(&self, message: impl Into<String>) -> Result<String> {
let mut payload = Map::new();
payload.insert("message".to_string(), Value::String(message.into()));
self.send("follow_up", payload)
}
pub fn abort(&self) -> Result<String> {
self.send("abort", Map::new())
}
}
pub enum SessionTransport {
InProcess(Box<AgentSessionHandle>),
RpcSubprocess(RpcTransportClient),
}
impl SessionTransport {
pub async fn in_process(options: SessionOptions) -> Result<Self> {
create_agent_session(options)
.await
.map(Box::new)
.map(Self::InProcess)
}
pub fn rpc_subprocess(options: RpcTransportOptions) -> Result<Self> {
RpcTransportClient::connect(options).map(Self::RpcSubprocess)
}
#[allow(clippy::missing_const_for_fn)]
pub fn as_in_process_mut(&mut self) -> Option<&mut AgentSessionHandle> {
match self {
Self::InProcess(handle) => Some(handle.as_mut()),
Self::RpcSubprocess(_) => None,
}
}
#[allow(clippy::missing_const_for_fn)]
pub fn as_rpc_mut(&mut self) -> Option<&mut RpcTransportClient> {
match self {
Self::InProcess(_) => None,
Self::RpcSubprocess(client) => Some(client),
}
}
pub async fn prompt(
&mut self,
input: impl Into<String>,
on_event: impl Fn(SessionTransportEvent) + Send + Sync + 'static,
) -> Result<SessionPromptResult> {
let input = input.into();
let on_event = Arc::new(on_event);
match self {
Self::InProcess(handle) => {
let on_event = Arc::clone(&on_event);
let assistant = handle
.prompt(input, move |event| {
(on_event)(SessionTransportEvent::InProcess(Box::new(event)));
})
.await?;
Ok(SessionPromptResult::InProcess(Box::new(assistant)))
}
Self::RpcSubprocess(client) => {
let callback = Arc::clone(&on_event);
let events = client
.prompt_with_options_streaming(input, None, None, move |event| {
(callback)(SessionTransportEvent::Rpc(event));
})
.await?;
Ok(SessionPromptResult::RpcEvents(events))
}
}
}
pub async fn state(&mut self) -> Result<SessionTransportState> {
match self {
Self::InProcess(handle) => handle.state().await.map(SessionTransportState::InProcess),
Self::RpcSubprocess(client) => client
.get_state()
.await
.map(Box::new)
.map(SessionTransportState::Rpc),
}
}
pub async fn set_model(&mut self, provider: &str, model_id: &str) -> Result<()> {
match self {
Self::InProcess(handle) => handle.set_model(provider, model_id).await,
Self::RpcSubprocess(client) => {
let _ = client.set_model(provider, model_id).await?;
Ok(())
}
}
}
pub fn shutdown(&mut self) -> Result<()> {
match self {
Self::InProcess(_) => Ok(()),
Self::RpcSubprocess(client) => client.shutdown(),
}
}
}
impl RpcTransportClient {
#[allow(clippy::too_many_lines)]
pub fn connect(options: RpcTransportOptions) -> Result<Self> {
let mut command = Command::new(&options.binary_path);
command
.args(&options.args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
if let Some(cwd) = options.cwd {
command.current_dir(cwd);
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
command.process_group(0);
}
let child =
crate::tools::spawn_command_with_job_discipline(&mut command).map_err(|err| {
Error::config(format!(
"Failed to spawn RPC subprocess {}: {err}",
options.binary_path.display()
))
})?;
let mut child = crate::tools::ProcessGuard::new(
child,
crate::tools::ProcessCleanupMode::ProcessGroupTree,
);
let pipes = (|| {
let stdin = child
.take_stdin()
.ok_or_else(|| Error::config("RPC subprocess stdin is not piped"))?;
let stdout = child
.take_stdout()
.ok_or_else(|| Error::config("RPC subprocess stdout is not piped"))?;
#[cfg(unix)]
{
prepare_rpc_pipe(&stdin)?;
prepare_rpc_pipe(&stdout)?;
}
Ok::<_, Error>((stdin, stdout))
})();
let (stdin, stdout) = match pipes {
Ok(pipes) => pipes,
Err(error) => {
let _ = child.kill();
return Err(error);
}
};
let stdin = Arc::new(Mutex::new(stdin));
let closed = Arc::new(AtomicBool::new(false));
let (writer_tx, writer_rx) = std::sync::mpsc::sync_channel::<RpcWriteRequest>(1);
let (mut reader_tx, reader_rx) = futures::channel::mpsc::channel(0);
let (reader_abort, reader_registration) = futures::future::AbortHandle::new_pair();
let owner = Arc::new(RpcSubprocessOwner {
process: Mutex::new(RpcProcessCleanup {
child: Some(child),
reaper: None,
}),
closed: Arc::clone(&closed),
writer: Mutex::new(Some(writer_tx)),
reader_abort,
});
let read_closed = Arc::clone(&closed);
let reader_worker = std::thread::Builder::new()
.name("pi-sdk-rpc-reader".to_string())
.spawn(move || {
let mut stdout = BufReader::new(stdout);
let read = async move {
while !read_closed.load(Ordering::Acquire) {
let frame = read_rpc_json_line(&mut stdout, &read_closed);
let terminal = frame.is_err();
if reader_tx.send(frame).await.is_err() || terminal {
break;
}
}
};
let _ = futures::executor::block_on(futures::future::Abortable::new(
read,
reader_registration,
));
});
let reader_worker = match reader_worker {
Ok(worker) => worker,
Err(error) => {
let _ = owner.close(true);
return Err(Error::Io(Box::new(error)));
}
};
let write_stdin = Arc::clone(&stdin);
let write_closed = Arc::clone(&closed);
let writer_worker = std::thread::Builder::new()
.name("pi-sdk-rpc-writer".to_string())
.spawn(move || {
while let Ok(request) = writer_rx.recv() {
if write_closed.load(Ordering::Acquire) {
break;
}
let result = write_rpc_bytes(&write_stdin, &write_closed, &request.encoded);
let terminal = result.is_err();
let _ = request.completed.send(result);
if terminal {
break;
}
}
});
let writer_worker = match writer_worker {
Ok(worker) => worker,
Err(error) => {
if let Err(cleanup_error) = owner.close(true) {
return Err(Error::api(format!(
"Failed to start RPC writer worker: {error}; subprocess cleanup failed: {cleanup_error}"
)));
}
let _ = reader_worker.join();
return Err(Error::Io(Box::new(error)));
}
};
Ok(Self {
owner,
stdin,
stdout: reader_rx,
reader_worker: Some(reader_worker),
writer_worker: Some(writer_worker),
next_request_id: Arc::new(AtomicU64::new(1)),
})
}
#[must_use]
pub fn control_handle(&self) -> RpcControlHandle {
RpcControlHandle {
owner: Arc::clone(&self.owner),
stdin: Arc::clone(&self.stdin),
next_request_id: Arc::clone(&self.next_request_id),
}
}
pub async fn request(&mut self, command: &str, payload: Map<String, Value>) -> Result<Value> {
if payload.contains_key("type") || payload.contains_key("id") {
return Err(Error::validation(
"RPC request payload cannot override reserved type/id fields",
));
}
let request_id = self.next_request_id()?;
let mut command_payload = Map::new();
command_payload.insert("type".to_string(), Value::String(command.to_string()));
command_payload.insert("id".to_string(), Value::String(request_id.clone()));
command_payload.extend(payload);
let mut operation = RpcOperation::new(&self.owner)?;
self.write_json_line(&Value::Object(command_payload))
.await?;
let response = self.wait_for_response(&request_id, command).await?;
let success = response
.get("success")
.and_then(Value::as_bool)
.ok_or_else(|| Error::api("RPC response did not include a boolean success flag"))?;
operation.finish();
if success {
Ok(response.get("data").cloned().unwrap_or(Value::Null))
} else {
Err(rpc_error_from_response(&response, command))
}
}
fn parse_response_data<T: DeserializeOwned>(data: Value, command: &str) -> Result<T> {
serde_json::from_value(data).map_err(|err| {
Error::api(format!(
"Failed to decode RPC `{command}` response payload: {err}"
))
})
}
async fn request_typed<T: DeserializeOwned>(
&mut self,
command: &str,
payload: Map<String, Value>,
) -> Result<T> {
let data = self.request(command, payload).await?;
Self::parse_response_data(data, command)
}
async fn request_no_data(&mut self, command: &str, payload: Map<String, Value>) -> Result<()> {
let _ = self.request(command, payload).await?;
Ok(())
}
pub async fn steer(&mut self, message: impl Into<String>) -> Result<()> {
let mut payload = Map::new();
payload.insert("message".to_string(), Value::String(message.into()));
self.request_no_data("steer", payload).await
}
pub async fn follow_up(&mut self, message: impl Into<String>) -> Result<()> {
let mut payload = Map::new();
payload.insert("message".to_string(), Value::String(message.into()));
self.request_no_data("follow_up", payload).await
}
pub async fn abort(&mut self) -> Result<()> {
self.request_no_data("abort", Map::new()).await
}
pub async fn new_session(
&mut self,
parent_session: Option<&Path>,
) -> Result<RpcCancelledResult> {
let mut payload = Map::new();
if let Some(parent_session) = parent_session {
payload.insert(
"parentSession".to_string(),
Value::String(parent_session.display().to_string()),
);
}
self.request_typed("new_session", payload).await
}
pub async fn get_state(&mut self) -> Result<RpcSessionState> {
self.request_typed("get_state", Map::new()).await
}
pub async fn get_session_stats(&mut self) -> Result<RpcSessionStats> {
self.request_typed("get_session_stats", Map::new()).await
}
pub async fn get_messages(&mut self) -> Result<Vec<Value>> {
#[derive(Deserialize)]
struct MessagesPayload {
messages: Vec<Value>,
}
let payload: MessagesPayload = self.request_typed("get_messages", Map::new()).await?;
Ok(payload.messages)
}
pub async fn get_available_models(&mut self) -> Result<Vec<RpcModelInfo>> {
#[derive(Deserialize)]
struct ModelsPayload {
models: Vec<RpcModelInfo>,
}
let payload: ModelsPayload = self
.request_typed("get_available_models", Map::new())
.await?;
Ok(payload.models)
}
pub async fn set_model(&mut self, provider: &str, model_id: &str) -> Result<RpcModelInfo> {
let mut payload = Map::new();
payload.insert("provider".to_string(), Value::String(provider.to_string()));
payload.insert("modelId".to_string(), Value::String(model_id.to_string()));
self.request_typed("set_model", payload).await
}
pub async fn cycle_model(&mut self) -> Result<Option<RpcCycleModelResult>> {
self.request_typed("cycle_model", Map::new()).await
}
pub async fn set_thinking_level(&mut self, level: crate::model::ThinkingLevel) -> Result<()> {
let mut payload = Map::new();
payload.insert("level".to_string(), Value::String(level.to_string()));
self.request_no_data("set_thinking_level", payload).await
}
pub async fn cycle_thinking_level(&mut self) -> Result<Option<RpcThinkingLevelResult>> {
self.request_typed("cycle_thinking_level", Map::new()).await
}
pub async fn set_steering_mode(&mut self, mode: &str) -> Result<()> {
let mut payload = Map::new();
payload.insert("mode".to_string(), Value::String(mode.to_string()));
self.request_no_data("set_steering_mode", payload).await
}
pub async fn set_follow_up_mode(&mut self, mode: &str) -> Result<()> {
let mut payload = Map::new();
payload.insert("mode".to_string(), Value::String(mode.to_string()));
self.request_no_data("set_follow_up_mode", payload).await
}
pub async fn set_auto_compaction(&mut self, enabled: bool) -> Result<()> {
let mut payload = Map::new();
payload.insert("enabled".to_string(), Value::Bool(enabled));
self.request_no_data("set_auto_compaction", payload).await
}
pub async fn set_auto_retry(&mut self, enabled: bool) -> Result<()> {
let mut payload = Map::new();
payload.insert("enabled".to_string(), Value::Bool(enabled));
self.request_no_data("set_auto_retry", payload).await
}
pub async fn abort_retry(&mut self) -> Result<()> {
self.request_no_data("abort_retry", Map::new()).await
}
pub async fn set_session_name(&mut self, name: impl Into<String>) -> Result<()> {
let mut payload = Map::new();
payload.insert("name".to_string(), Value::String(name.into()));
self.request_no_data("set_session_name", payload).await
}
pub async fn get_last_assistant_text(&mut self) -> Result<Option<String>> {
let payload: RpcLastAssistantText = self
.request_typed("get_last_assistant_text", Map::new())
.await?;
Ok(payload.text)
}
pub async fn export_html(&mut self, output_path: Option<&Path>) -> Result<RpcExportHtmlResult> {
let mut payload = Map::new();
if let Some(path) = output_path {
payload.insert(
"outputPath".to_string(),
Value::String(path.display().to_string()),
);
}
self.request_typed("export_html", payload).await
}
pub async fn bash(&mut self, command: impl Into<String>) -> Result<RpcBashResult> {
let mut payload = Map::new();
payload.insert("command".to_string(), Value::String(command.into()));
self.request_typed("bash", payload).await
}
pub async fn abort_bash(&mut self) -> Result<()> {
self.request_no_data("abort_bash", Map::new()).await
}
pub async fn compact(&mut self) -> Result<RpcCompactionResult> {
self.compact_with_instructions(None).await
}
pub async fn compact_with_instructions(
&mut self,
custom_instructions: Option<&str>,
) -> Result<RpcCompactionResult> {
let mut payload = Map::new();
if let Some(custom_instructions) = custom_instructions {
payload.insert(
"customInstructions".to_string(),
Value::String(custom_instructions.to_string()),
);
}
self.request_typed("compact", payload).await
}
pub async fn switch_session(&mut self, session_path: &Path) -> Result<RpcCancelledResult> {
let mut payload = Map::new();
payload.insert(
"sessionPath".to_string(),
Value::String(session_path.display().to_string()),
);
self.request_typed("switch_session", payload).await
}
pub async fn fork(&mut self, entry_id: impl Into<String>) -> Result<RpcForkResult> {
let mut payload = Map::new();
payload.insert("entryId".to_string(), Value::String(entry_id.into()));
self.request_typed("fork", payload).await
}
pub async fn get_fork_messages(&mut self) -> Result<Vec<RpcForkMessage>> {
#[derive(Deserialize)]
struct ForkMessagesPayload {
messages: Vec<RpcForkMessage>,
}
let payload: ForkMessagesPayload =
self.request_typed("get_fork_messages", Map::new()).await?;
Ok(payload.messages)
}
pub async fn get_commands(&mut self) -> Result<Vec<RpcCommandInfo>> {
#[derive(Deserialize)]
struct CommandsPayload {
commands: Vec<RpcCommandInfo>,
}
let payload: CommandsPayload = self.request_typed("get_commands", Map::new()).await?;
Ok(payload.commands)
}
pub async fn extension_ui_response(
&mut self,
request_id: &str,
request_generation: u64,
response: RpcExtensionUiResponse,
) -> Result<bool> {
#[derive(Deserialize)]
struct ExtensionUiResolvedPayload {
resolved: bool,
}
let mut payload = Map::new();
payload.insert(
"requestId".to_string(),
Value::String(request_id.to_string()),
);
payload.insert(
"requestGeneration".to_string(),
Value::from(request_generation),
);
match response {
RpcExtensionUiResponse::Value { value } => {
payload.insert("value".to_string(), value);
}
RpcExtensionUiResponse::Confirmed { confirmed } => {
payload.insert("confirmed".to_string(), Value::Bool(confirmed));
}
RpcExtensionUiResponse::Cancelled => {
payload.insert("cancelled".to_string(), Value::Bool(true));
}
}
let response: Option<ExtensionUiResolvedPayload> =
self.request_typed("extension_ui_response", payload).await?;
Ok(response.is_none_or(|payload| payload.resolved))
}
pub async fn prompt(&mut self, message: impl Into<String>) -> Result<Vec<Value>> {
self.prompt_with_options(message, None, None).await
}
pub async fn prompt_with_options(
&mut self,
message: impl Into<String>,
images: Option<Vec<ImageContent>>,
streaming_behavior: Option<&str>,
) -> Result<Vec<Value>> {
self.prompt_with_options_streaming(message, images, streaming_behavior, |_| {})
.await
}
pub async fn prompt_with_content(&mut self, content: Vec<ContentBlock>) -> Result<Vec<Value>> {
self.prompt_with_content_streaming(content, None, |_| {})
.await
}
pub async fn prompt_with_content_streaming(
&mut self,
content: Vec<ContentBlock>,
streaming_behavior: Option<&str>,
on_event: impl FnMut(Value),
) -> Result<Vec<Value>> {
AgentSession::validate_user_content_blocks(&content)?;
let content = serde_json::to_value(content).map_err(|err| Error::Json(Box::new(err)))?;
let content = crate::rpc::parse_native_prompt_content(&content, 64 * 1024 * 1024)?;
let mut payload = Map::new();
payload.insert(
"content".to_string(),
serde_json::to_value(content).map_err(|err| Error::Json(Box::new(err)))?,
);
self.prompt_payload_streaming(payload, streaming_behavior, on_event)
.await
}
pub async fn prompt_with_options_streaming(
&mut self,
message: impl Into<String>,
images: Option<Vec<ImageContent>>,
streaming_behavior: Option<&str>,
on_event: impl FnMut(Value),
) -> Result<Vec<Value>> {
let mut payload = Map::new();
payload.insert("message".to_string(), Value::String(message.into()));
if let Some(images) = images {
payload.insert(
"images".to_string(),
serde_json::to_value(images).map_err(|err| Error::Json(Box::new(err)))?,
);
}
self.prompt_payload_streaming(payload, streaming_behavior, on_event)
.await
}
async fn prompt_payload_streaming(
&mut self,
mut payload: Map<String, Value>,
streaming_behavior: Option<&str>,
mut on_event: impl FnMut(Value),
) -> Result<Vec<Value>> {
let request_id = self.next_request_id()?;
payload.insert("type".to_string(), Value::String("prompt".to_string()));
payload.insert("id".to_string(), Value::String(request_id.clone()));
if let Some(streaming_behavior) = streaming_behavior {
payload.insert(
"streamingBehavior".to_string(),
Value::String(streaming_behavior.to_string()),
);
}
let mut operation = RpcOperation::new(&self.owner)?;
self.write_json_line(&Value::Object(payload)).await?;
let mut saw_ack = false;
let mut events = Vec::new();
let mut pre_ack: Vec<Value> = Vec::new();
let mut pre_ack_bytes = 0usize;
loop {
let item = self.read_json_line().await?;
let item_type = item.get("type").and_then(Value::as_str);
if item_type == Some("response") {
if item.get("id").and_then(Value::as_str) != Some(request_id.as_str()) {
continue;
}
if item.get("command").and_then(Value::as_str) != Some("prompt") {
return Err(Error::api(
"RPC prompt acknowledgement used the matching id with the wrong command",
));
}
if saw_ack {
return Err(Error::api("RPC prompt sent a duplicate acknowledgement"));
}
let success = item
.get("success")
.and_then(Value::as_bool)
.ok_or_else(|| {
Error::api(
"RPC prompt acknowledgement did not include a boolean success flag",
)
})?;
if !success {
operation.finish();
return Err(rpc_error_from_response(&item, "prompt"));
}
saw_ack = true;
for event in std::mem::take(&mut pre_ack) {
let reached_end =
event.get("type").and_then(Value::as_str) == Some("agent_end");
on_event(event.clone());
events.push(event);
if reached_end {
operation.finish();
return Ok(events);
}
}
continue;
}
if !saw_ack {
let encoded_len = serde_json::to_vec(&item)
.map_err(|err| Error::Json(Box::new(err)))?
.len();
if pre_ack.len() >= RPC_MAX_PRE_ACK_EVENTS
|| encoded_len > RPC_MAX_PRE_ACK_BYTES.saturating_sub(pre_ack_bytes)
{
return Err(Error::api(
"RPC prompt emitted too many events before its acknowledgement",
));
}
pre_ack_bytes += encoded_len;
pre_ack.push(item);
continue;
}
let reached_end = item_type == Some("agent_end");
on_event(item.clone());
events.push(item);
if reached_end {
operation.finish();
return Ok(events);
}
}
}
pub fn shutdown(&mut self) -> Result<()> {
let result = self.owner.close(true);
self.stdout.close();
result?;
let reader_panicked = self
.reader_worker
.take()
.is_some_and(|worker| worker.join().is_err());
let writer_panicked = self
.writer_worker
.take()
.is_some_and(|worker| worker.join().is_err());
if reader_panicked || writer_panicked {
Err(Error::api("RPC subprocess pipe worker panicked"))
} else {
Ok(())
}
}
fn next_request_id(&self) -> Result<String> {
next_rpc_request_id(&self.next_request_id)
}
#[allow(clippy::significant_drop_tightening)]
async fn write_json_line(&self, payload: &Value) -> Result<()> {
self.owner.ensure_open()?;
let encoded = encode_rpc_json_line(payload)?;
let (completed, completion) = futures::channel::oneshot::channel();
{
let writer = self
.owner
.writer
.lock()
.map_err(|_| Error::api("RPC subprocess writer queue lock poisoned"))?;
let writer = writer
.as_ref()
.ok_or_else(|| Error::api("RPC subprocess transport is closed"))?;
writer
.try_send(RpcWriteRequest { encoded, completed })
.map_err(|_| Error::api("RPC subprocess writer is unavailable"))?;
}
completion
.await
.map_err(|_| Error::api("RPC subprocess writer stopped before completing the frame"))?
}
async fn read_json_line(&mut self) -> Result<Value> {
self.owner.ensure_open()?;
self.stdout
.next()
.await
.ok_or_else(|| Error::api("RPC subprocess reader stopped before sending a response"))?
}
async fn wait_for_response(&mut self, request_id: &str, command: &str) -> Result<Value> {
loop {
let item = self.read_json_line().await?;
let Some(item_type) = item.get("type").and_then(Value::as_str) else {
continue;
};
if item_type != "response" {
continue;
}
if item.get("id").and_then(Value::as_str) != Some(request_id) {
continue;
}
if item.get("command").and_then(Value::as_str) != Some(command) {
return Err(Error::api(
"RPC response used the matching id with the wrong command",
));
}
return Ok(item);
}
}
}
fn next_rpc_request_id(counter: &AtomicU64) -> Result<String> {
let id = counter
.try_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
current.checked_add(1)
})
.map_err(|_| Error::api("RPC request id space exhausted"))?;
Ok(format!("rpc-{id}"))
}
#[cfg(unix)]
fn prepare_rpc_pipe(pipe: &impl std::os::fd::AsFd) -> Result<()> {
let flags = rustix::fs::fcntl_getfl(pipe)
.map_err(|error| Error::Io(Box::new(std::io::Error::from(error))))?;
rustix::fs::fcntl_setfl(pipe, flags | rustix::fs::OFlags::NONBLOCK)
.map_err(|error| Error::Io(Box::new(std::io::Error::from(error))))
}
fn encode_rpc_json_line(payload: &Value) -> Result<Vec<u8>> {
let mut encoded = serde_json::to_vec(payload).map_err(|err| Error::Json(Box::new(err)))?;
encoded.push(b'\n');
Ok(encoded)
}
fn write_rpc_bytes(stdin: &Mutex<ChildStdin>, closed: &AtomicBool, encoded: &[u8]) -> Result<()> {
if closed.load(Ordering::Acquire) {
return Err(Error::api("RPC subprocess transport is closed"));
}
let mut stdin = stdin
.lock()
.map_err(|_| Error::api("RPC subprocess stdin writer lock poisoned"))?;
let mut written = 0;
while written < encoded.len() {
if closed.load(Ordering::Acquire) {
return Err(Error::api("RPC subprocess transport is closed"));
}
match stdin.write(&encoded[written..]) {
Ok(0) => return Err(Error::api("RPC subprocess stdin closed during a frame")),
Ok(count) => written += count,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(RPC_PIPE_POLL_INTERVAL);
}
Err(error) => return Err(Error::Io(Box::new(error))),
}
}
Ok(())
}
fn read_rpc_json_line(stdout: &mut BufReader<ChildStdout>, closed: &AtomicBool) -> Result<Value> {
let mut line = Vec::new();
loop {
if closed.load(Ordering::Acquire) {
return Err(Error::api("RPC subprocess transport is closed"));
}
let available = match stdout.fill_buf() {
Ok(available) => available,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(RPC_PIPE_POLL_INTERVAL);
continue;
}
Err(error) => return Err(Error::Io(Box::new(error))),
};
if available.is_empty() {
return Err(Error::api(if line.is_empty() {
"RPC subprocess exited before sending a response"
} else {
"RPC subprocess ended in the middle of a JSON line"
}));
}
if let Some(newline) = available.iter().position(|byte| *byte == b'\n') {
if newline > RPC_MAX_LINE_BYTES.saturating_sub(line.len()) {
return Err(Error::api("RPC subprocess JSON line exceeded 128 MiB"));
}
line.extend_from_slice(&available[..newline]);
stdout.consume(newline + 1);
break;
}
if available.len() > RPC_MAX_LINE_BYTES.saturating_sub(line.len()) {
return Err(Error::api("RPC subprocess JSON line exceeded 128 MiB"));
}
let available_len = available.len();
line.extend_from_slice(available);
stdout.consume(available_len);
}
if line.last() == Some(&b'\r') {
line.pop();
}
serde_json::from_slice(&line).map_err(|err| Error::Json(Box::new(err)))
}
impl Drop for RpcTransportClient {
fn drop(&mut self) {
let _ = self.shutdown();
}
}
fn rpc_error_from_response(response: &Value, command: &str) -> Error {
let error = response
.get("error")
.and_then(Value::as_str)
.unwrap_or("RPC command failed");
Error::api(format!("RPC {command} failed: {error}"))
}
pub(crate) struct PreparedSessionShutdown {
owner_session_id: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum McpShutdownOutcome {
#[default]
NotOwned,
Released,
Indeterminate,
}
#[derive(Debug, Default)]
pub struct SessionResourceShutdown {
failures: Vec<String>,
mcp: McpShutdownOutcome,
}
const SESSION_MCP_SHUTDOWN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
impl SessionResourceShutdown {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.failures.is_empty()
}
#[must_use]
pub const fn completed_cleanly(&self) -> bool {
self.failures.is_empty()
}
#[must_use]
pub(crate) const fn permits_replacement_mcp_activation(&self) -> bool {
matches!(
self.mcp,
McpShutdownOutcome::NotOwned | McpShutdownOutcome::Released
)
}
pub fn failures(&self) -> impl Iterator<Item = &str> {
self.failures.iter().map(String::as_str)
}
pub fn messages(&self) -> impl Iterator<Item = &str> {
self.failures.iter().map(String::as_str)
}
pub(crate) fn fail(&mut self, message: String) {
self.failures.push(message);
}
}
impl AgentSessionHandle {
pub const fn from_session_with_listeners(
session: AgentSession,
listeners: EventListeners,
) -> Self {
Self {
session,
listeners,
ask_tool: None,
workspace: None,
mcp_manager: None,
retry: None,
failover: None,
failover_state: crate::failover::FailoverState::new_empty(),
event_runtime: None,
}
}
#[must_use]
pub fn ask_tool(&self) -> Option<crate::ask::AskTool> {
self.ask_tool.clone()
}
#[must_use]
pub fn workspace(&self) -> Option<crate::workspace::WorkspaceHandle> {
self.workspace.clone()
}
#[must_use]
pub fn mcp_manager(&self) -> Option<Arc<crate::mcp::McpManager>> {
self.mcp_manager.clone()
}
pub(crate) fn disable_mcp(&mut self) {
self.mcp_manager = None;
}
#[allow(clippy::needless_pass_by_ref_mut)]
pub(crate) async fn preflight_replacement(
&mut self,
) -> std::result::Result<PreparedSessionShutdown, SessionResourceShutdown> {
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = match asupersync::sync::OwnedMutexGuard::lock(
Arc::clone(&self.session.session),
cx.cx(),
)
.await
{
Ok(session) => session,
Err(err) => {
let warning = format!("failed to lock session for resource shutdown: {err}");
tracing::warn!(
event = "sdk.session.shutdown.jobs_owner_failed",
"{warning}"
);
let mut report = SessionResourceShutdown::default();
report.fail(warning);
return Err(report);
}
};
let owner_session_id = session.header.id.clone();
if self.session.save_enabled()
&& let Err(err) = session.flush_autosave_on_shutdown().await
{
let warning = format!("failed to flush session autosave: {err}");
tracing::warn!(event = "sdk.session.shutdown.autosave_failed", "{warning}");
let mut report = SessionResourceShutdown::default();
report.fail(warning);
return Err(report);
}
Ok(PreparedSessionShutdown { owner_session_id })
}
async fn shutdown_runtime_resources(
self,
owner_session_id: Option<&str>,
mut report: SessionResourceShutdown,
) -> SessionResourceShutdown {
if let Some(owner_session_id) = owner_session_id
&& let Err(err) = crate::jobs::kill_session(owner_session_id).await
{
let warning = format!("failed to stop session-owned background jobs: {err}");
tracing::warn!(event = "sdk.session.shutdown.jobs_failed", "{warning}");
report.fail(warning);
}
if let Some(region) = self.session.extensions.as_ref()
&& !region.shutdown().await
{
let warning = "extension runtime did not stop within its shutdown budget".to_string();
tracing::warn!(
event = "sdk.session.shutdown.extension_timeout",
"{warning}"
);
report.fail(warning);
}
if let Some(manager) = self.mcp_manager.clone() {
report.mcp = McpShutdownOutcome::Indeterminate;
if asupersync::time::timeout(
asupersync::time::wall_now(),
SESSION_MCP_SHUTDOWN_TIMEOUT,
manager.shutdown_all(),
)
.await
.is_ok()
{
report.mcp = McpShutdownOutcome::Released;
} else {
let warning = format!(
"MCP manager did not stop within {} seconds",
SESSION_MCP_SHUTDOWN_TIMEOUT.as_secs()
);
tracing::warn!(event = "sdk.session.shutdown.mcp_timeout", "{warning}");
report.fail(warning);
}
}
report
}
pub(crate) async fn commit_resource_shutdown(
self,
prepared: PreparedSessionShutdown,
) -> SessionResourceShutdown {
self.shutdown_runtime_resources(
Some(prepared.owner_session_id.as_str()),
SessionResourceShutdown::default(),
)
.await
}
pub(crate) async fn discard_uncommitted_resources(self) -> SessionResourceShutdown {
let cx = crate::agent_cx::AgentCx::for_request();
let mut report = SessionResourceShutdown::default();
let owner_session_id = match asupersync::sync::OwnedMutexGuard::lock(
Arc::clone(&self.session.session),
cx.cx(),
)
.await
{
Ok(session) => Some(session.header.id.clone()),
Err(err) => {
let warning = format!("failed to resolve discarded session ownership: {err}");
tracing::warn!(event = "sdk.session.discard.owner_failed", "{warning}");
report.fail(warning);
None
}
};
self.shutdown_runtime_resources(owner_session_id.as_deref(), report)
.await
}
pub async fn shutdown_owned_resources(self) -> SessionResourceShutdown {
let mut report = SessionResourceShutdown::default();
let cx = crate::agent_cx::AgentCx::for_request();
let owner_session_id = match asupersync::sync::OwnedMutexGuard::lock(
Arc::clone(&self.session.session),
cx.cx(),
)
.await
{
Ok(mut session) => {
let owner_session_id = session.header.id.clone();
if self.session.save_enabled()
&& let Err(err) = session.flush_autosave_on_shutdown().await
{
let warning = format!("failed to flush session autosave: {err}");
tracing::warn!(event = "sdk.session.shutdown.autosave_failed", "{warning}");
report.fail(warning);
}
Some(owner_session_id)
}
Err(err) => {
let warning = format!("failed to lock session for resource shutdown: {err}");
tracing::warn!(
event = "sdk.session.shutdown.jobs_owner_failed",
"{warning}"
);
report.fail(warning);
None
}
};
self.shutdown_runtime_resources(owner_session_id.as_deref(), report)
.await
}
#[must_use]
pub fn refresh_mcp_server_tools(&mut self, server_name: &str) -> usize {
let Some(manager) = self.mcp_manager.clone() else {
return 0;
};
if !manager
.list()
.iter()
.any(|server| server.name == server_name)
{
return 0;
}
crate::mcp::reconcile_tools(&manager, &self.session.agent.shared_tools())
}
#[allow(clippy::needless_pass_by_ref_mut)]
pub(crate) async fn activate_mcp(&mut self) {
let Some(manager) = self.mcp_manager.clone() else {
return;
};
manager.connect_trusted().await;
crate::mcp::reconcile_tools(&manager, &self.session.agent.shared_tools());
}
pub async fn sync_extension_mcp_registrations(&mut self) -> usize {
let Some(manager) = self
.mcp_manager
.clone()
.or_else(|| self.session.mcp_manager())
else {
return 0;
};
let Some(extensions) = self.extension_manager().cloned() else {
return 0;
};
crate::mcp::sync_extension_registrations(&manager, &extensions, &mut self.session.agent)
.await
}
#[must_use]
pub fn session_store(&self) -> Arc<asupersync::sync::Mutex<crate::session::Session>> {
Arc::clone(&self.session.session)
}
#[must_use]
pub fn has_tool(&self, name: &str) -> bool {
self.session.agent.has_tool(name)
}
pub fn new_abort_handle() -> (AbortHandle, AbortSignal) {
AbortHandle::new()
}
pub fn subscribe(
&self,
listener: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> SubscriptionId {
self.listeners.subscribe(Arc::new(listener))
}
pub fn unsubscribe(&self, id: SubscriptionId) -> bool {
self.listeners.unsubscribe(id)
}
pub const fn listeners(&self) -> &EventListeners {
&self.listeners
}
pub const fn listeners_mut(&mut self) -> &mut EventListeners {
&mut self.listeners
}
pub const fn has_extensions(&self) -> bool {
self.session.extensions.is_some()
}
pub fn extension_manager(&self) -> Option<&ExtensionManager> {
self.session
.extensions
.as_ref()
.map(ExtensionRegion::manager)
}
pub const fn extension_region(&self) -> Option<&ExtensionRegion> {
self.session.extensions.as_ref()
}
pub const fn compaction_settings(&self) -> &ResolvedCompactionSettings {
self.session.compaction_settings()
}
pub fn model(&self) -> (String, String) {
let provider = self.session.agent.provider();
(provider.name().to_string(), provider.model_id().to_string())
}
pub async fn set_model(&mut self, provider: &str, model_id: &str) -> Result<()> {
self.session.set_provider_model(provider, model_id).await?;
self.failover_state.clear();
Ok(())
}
pub const fn thinking_level(&self) -> Option<crate::model::ThinkingLevel> {
self.session.agent.stream_options().thinking_level
}
pub const fn thinking(&self) -> Option<crate::model::ThinkingLevel> {
self.thinking_level()
}
pub async fn set_thinking_level(&mut self, level: crate::model::ThinkingLevel) -> Result<()> {
self.session.set_thinking_level(level).await
}
pub async fn cycle_thinking_level(&mut self) -> Result<Option<crate::model::ThinkingLevel>> {
let Some(entry) = self.session.current_model_entry() else {
return Ok(None);
};
let levels = entry.available_thinking_levels();
if levels.len() <= 1 {
return Ok(None);
}
let current = self.thinking_level().unwrap_or_default();
let index = levels
.iter()
.position(|level| *level == current)
.unwrap_or(0);
let Some(next) = levels.iter().copied().cycle().nth(index + 1) else {
return Ok(None);
};
self.set_thinking_level(next).await?;
Ok(Some(next))
}
pub async fn set_session_name(&mut self, name: impl Into<String>) -> Result<()> {
let name = name.into();
let cx = crate::agent_cx::AgentCx::for_request();
{
let mut guard = self
.session
.session
.lock(cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
guard.append_session_info(Some(name));
}
self.session.persist_session().await
}
pub async fn fresh_stream_state(&mut self) -> Result<String> {
let cx = crate::agent_cx::AgentCx::for_request();
let new_id = {
let mut guard = self
.session
.session
.lock(cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
crate::checkpoint::fresh_stream_state(&mut self.session.agent, &mut guard)
};
self.session.persist_session().await?;
Ok(new_id)
}
pub async fn record_approval_mode(
&mut self,
mode: crate::approval::ApprovalMode,
) -> Result<()> {
let cx = crate::agent_cx::AgentCx::for_request();
{
let mut guard = self
.session
.session
.lock(cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
guard.append_custom_entry(
"approval_mode".to_string(),
Some(serde_json::json!({"mode": mode.as_str()})),
);
}
self.session.persist_session().await
}
pub async fn mark_checkpoint(
&mut self,
name: &str,
note: Option<&str>,
) -> Result<crate::checkpoint::Checkpoint> {
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let admission = self.session.provider_admission_gate();
let _provider_authority = admission.acquire_transition_authority(cx.cx()).await?;
let session_actions = self.session.session_action_admission_gate();
let _session_action_permit = session_actions.acquire(cx.cx()).await?;
let store = self.session_store();
let mut guard = asupersync::sync::OwnedMutexGuard::lock(store, cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
let mut candidate = guard.clone();
candidate.ensure_full_v2_hydration_before_save()?;
let messages = candidate.to_messages_for_current_path();
let checkpoint = crate::checkpoint::mark_checkpoint(&mut candidate, name, note, &messages);
self.install_context_candidate(&mut guard, candidate, false, &admission, "checkpoint")
.await?;
Ok(checkpoint)
}
pub async fn rewind_to_checkpoint(
&mut self,
name: Option<&str>,
) -> Result<crate::checkpoint::RewindOutcome> {
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let admission = self.session.provider_admission_gate();
let _provider_authority = admission.acquire_transition_authority(cx.cx()).await?;
let session_actions = self.session.session_action_admission_gate();
let store = self.session_store();
let (snapshot, mut candidate, checkpoint, span) = {
let _session_action_permit = session_actions.acquire(cx.cx()).await?;
let guard = asupersync::sync::OwnedMutexGuard::lock(Arc::clone(&store), cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
let snapshot = SessionEditSnapshot::capture(&guard)?;
let mut candidate = guard.clone();
candidate.ensure_full_v2_hydration_before_save()?;
let checkpoint =
crate::checkpoint::find_checkpoint(&candidate, name).ok_or_else(|| {
Error::session(name.map_or_else(
|| String::from("No checkpoints yet — mark one with /checkpoint"),
|name| format!("No checkpoint named '{name}'"),
))
})?;
let (messages, boundary) = checkpoint
.entry_id
.as_deref()
.and_then(|id| candidate.context_for_checkpoint(id))
.ok_or_else(|| {
Error::session(format!(
"Checkpoint '{}' is no longer in the active context after compaction or rewind; restore its branch or mark a new checkpoint",
checkpoint.name
))
})?;
let span = messages[boundary..].to_vec();
(snapshot, candidate, checkpoint, span)
};
let agent = &self.session.agent;
if span.is_empty() {
return Err(Error::session(format!(
"Nothing to rewind — the active context is already at '{}'.",
checkpoint.name
)));
}
let privacy = agent.compaction_privacy();
let settings = crate::compaction::ResolvedCompactionSettings {
enabled: true,
..Default::default()
};
let summary = crate::checkpoint::summarize_span(
&span,
agent.provider(),
agent.stream_options(),
&settings,
&privacy,
)
.await?;
let outcome = crate::checkpoint::RewindOutcome {
schema: crate::checkpoint::CHECKPOINT_SCHEMA.to_string(),
checkpoint: checkpoint.name,
checkpoint_entry_id: checkpoint.entry_id,
collapsed_messages: span.len(),
summary_tokens_estimate: (summary.len() / 4) as u64,
summary,
tree_preserved: true,
};
candidate.append_custom_entry("rewind".to_string(), Some(serde_json::to_value(&outcome)?));
let _session_action_permit = session_actions.acquire(cx.cx()).await?;
let mut guard = asupersync::sync::OwnedMutexGuard::lock(store, cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
admission.ensure_allowed()?;
if SessionEditSnapshot::capture(&guard)? != snapshot {
return Err(Error::session(
"Session changed while the rewind summary was running; the result was not applied",
));
}
self.install_context_candidate(
&mut guard,
candidate,
true,
&admission,
"checkpoint rewind",
)
.await?;
Ok(outcome)
}
async fn install_context_candidate(
&mut self,
live: &mut Session,
mut candidate: Session,
replace_context: bool,
admission: &crate::agent::ProviderAdmissionGate,
operation: &str,
) -> Result<()> {
admission.ensure_allowed()?;
let save_enabled = self.session.save_enabled();
if save_enabled {
admission.block(format!(
"SDK {operation} persistence was interrupted before live installation completed"
));
if let Err(first_error) = candidate.save().await
&& let Err(retry_error) = candidate.save().await
{
let reason = format!(
"SDK {operation} persistence remained indeterminate after an idempotent retry: first failure: {first_error}; retry failure: {retry_error}"
);
admission.block(reason.clone());
return Err(Error::session_persistence(reason));
}
}
let messages = replace_context.then(|| candidate.to_messages_for_current_path());
*live = candidate;
if let Some(messages) = messages {
self.session.invalidate_background_compaction();
self.session.agent.replace_messages(messages);
}
if save_enabled {
admission.clear();
}
Ok(())
}
pub async fn prepare_retry(&mut self) -> Result<String> {
match self.prepare_retry_input(true).await? {
UserContent::Text(text) => Ok(text),
UserContent::Blocks(_) => Err(Error::session(
"Structured retry input requires prepare_retry_content",
)),
}
}
pub async fn prepare_retry_content(&mut self) -> Result<UserContent> {
self.prepare_retry_input(false).await
}
async fn prepare_retry_input(&mut self, require_text: bool) -> Result<UserContent> {
let admission = self.session.provider_admission_gate();
admission.ensure_allowed()?;
let cx = crate::agent_cx::AgentCx::for_request();
let _provider_authority = admission.acquire_transition_authority(cx.cx()).await?;
let session_actions = self.session.session_action_admission_gate();
let _session_action_permit = session_actions.acquire(cx.cx()).await?;
let store = Arc::clone(&self.session.session);
let mut guard = asupersync::sync::OwnedMutexGuard::lock(store, cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
let plan = crate::checkpoint::plan_retry_content(&guard)
.ok_or_else(|| Error::session("No user turn to retry"))?;
if let UserContent::Blocks(blocks) = &plan.content {
if require_text {
return Err(Error::session(
"The last user turn contains structured content; use prepare_retry_content to preserve its attachments",
));
}
AgentSession::validate_user_content_blocks(blocks)?;
}
let mut candidate = guard.clone();
crate::checkpoint::apply_retry_content_plan(&mut candidate, &plan)
.map_err(|error| Error::session(error.to_string()))?;
self.install_context_candidate(&mut guard, candidate, true, &admission, "retry rewind")
.await?;
Ok(plan.content)
}
pub async fn rewind_to_user_message(
&mut self,
entry_id: &str,
) -> Result<crate::checkpoint::RewindPreparation> {
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let admission = self.session.provider_admission_gate();
let _provider_authority = admission.acquire_transition_authority(cx.cx()).await?;
let session_actions = self.session.session_action_admission_gate();
let _session_action_permit = session_actions.acquire(cx.cx()).await?;
let store = self.session_store();
let mut guard = asupersync::sync::OwnedMutexGuard::lock(store, cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
let mut candidate = guard.clone();
candidate.ensure_full_v2_hydration_before_save()?;
let prepared = crate::checkpoint::rewind_to_user_entry(&mut candidate, entry_id)
.ok_or_else(|| Error::session(format!("No user message {entry_id} on this branch")))?;
self.install_context_candidate(&mut guard, candidate, true, &admission, "branch rewind")
.await?;
Ok(prepared)
}
pub const fn max_tokens(&self) -> Option<u32> {
self.session.agent.stream_options().max_tokens
}
pub const fn set_max_tokens(&mut self, max_tokens: Option<u32>) {
self.session.agent.stream_options_mut().max_tokens = max_tokens;
}
pub async fn add_workspace_root(&mut self, dir: impl AsRef<Path>) -> Result<String> {
let dir = dir.as_ref();
if dir.as_os_str().is_empty() {
return Err(Error::validation(
"cannot add an empty path as a workspace root",
));
}
let mut workspace = self
.workspace
.clone()
.ok_or_else(|| Error::validation("no workspace handle in this session"))?;
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let admission = self.session.provider_admission_gate();
let _provider_authority = admission.acquire_transition_authority(cx.cx()).await?;
let session_actions = self.session.session_action_admission_gate();
let _session_action_permit = session_actions.acquire(cx.cx()).await?;
let mut guard = asupersync::sync::OwnedMutexGuard::lock(self.session_store(), cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
let mut candidate = guard.clone();
candidate.ensure_full_v2_hydration_before_save()?;
let cwd = crate::session_workdir::require_session_workdir(&candidate)?;
let snapshot = workspace.snapshot_or(&cwd);
crate::session_workdir::check_session_workdir(&candidate, snapshot.primary())?;
let canonical = crate::workspace::validate_new_root(&resolve_path_for_cwd(dir, &cwd))?;
let previous_roots = snapshot.additional();
let mut roots = previous_roots.to_vec();
let already = snapshot.contains_canonical(&canonical);
if !already {
roots.push(canonical.clone());
}
candidate.set_additional_roots(&roots);
self.install_context_candidate(
&mut guard,
candidate,
false,
&admission,
"workspace root grant",
)
.await?;
if !workspace.replace_additional_roots_if_unchanged(previous_roots, roots) {
guard.set_additional_roots(&workspace.additional_roots());
let reason = "Workspace roots changed while a root grant was being saved; \
live access was not overwritten";
admission.block(reason.to_string());
return Err(Error::session_persistence(reason));
}
let display = canonical.display().to_string();
Ok(if already {
format!("Already a workspace root: {display}")
} else {
format!("Workspace root added: {display}")
})
}
pub async fn remove_workspace_root(&mut self, dir: impl AsRef<Path>) -> Result<String> {
let dir = dir.as_ref();
if dir.as_os_str().is_empty() {
return Err(Error::validation(
"cannot remove an empty workspace root path",
));
}
let mut workspace = self
.workspace
.clone()
.ok_or_else(|| Error::validation("no workspace handle in this session"))?;
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let admission = self.session.provider_admission_gate();
let _provider_authority = admission.acquire_transition_authority(cx.cx()).await?;
let session_actions = self.session.session_action_admission_gate();
let _session_action_permit = session_actions.acquire(cx.cx()).await?;
let mut guard = asupersync::sync::OwnedMutexGuard::lock(self.session_store(), cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
let mut candidate = guard.clone();
candidate.ensure_full_v2_hydration_before_save()?;
let cwd = crate::session_workdir::require_session_workdir(&candidate)?;
let snapshot = workspace.snapshot_or(&cwd);
crate::session_workdir::check_session_workdir(&candidate, snapshot.primary())?;
let canonical = crate::extensions::safe_canonicalize(&resolve_path_for_cwd(dir, &cwd));
let previous_roots = snapshot.additional();
let mut roots = previous_roots.to_vec();
roots.retain(|root| root != &canonical);
if roots.len() == previous_roots.len() {
return Ok(format!("Not a workspace root: {}", canonical.display()));
}
if !workspace.replace_additional_roots_if_unchanged(previous_roots, roots.clone()) {
return Err(Error::session(
"Workspace roots changed while preparing a root removal; retry the removal",
));
}
guard.set_additional_roots(&roots);
candidate.set_additional_roots(&roots);
self.install_context_candidate(
&mut guard,
candidate,
false,
&admission,
"workspace root removal",
)
.await?;
if !workspace.replace_additional_roots_if_unchanged(&roots, roots.clone()) {
guard.set_additional_roots(&workspace.additional_roots());
let reason = "Workspace roots changed while a root removal was being saved; \
live access was not overwritten";
admission.block(reason.to_string());
return Err(Error::session_persistence(reason));
}
Ok(format!("Workspace root removed: {}", canonical.display()))
}
pub async fn messages(&self) -> Result<Vec<Message>> {
let cx = crate::agent_cx::AgentCx::for_request();
let guard = self
.session
.session
.lock(cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
Ok(guard.to_messages_for_current_path())
}
pub async fn state(&self) -> Result<AgentSessionState> {
let (provider, model_id) = self.model();
let thinking_level = self.thinking_level();
let save_enabled = self.session.save_enabled();
let cx = crate::agent_cx::AgentCx::for_request();
let guard = self
.session
.session
.lock(cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
let session_id = Some(guard.header.id.clone());
let message_count = guard.to_messages_for_current_path().len();
Ok(AgentSessionState {
session_id,
provider,
model_id,
thinking_level,
save_enabled,
message_count,
})
}
pub async fn with_session<R>(
&self,
f: impl FnOnce(&crate::session::Session) -> R,
) -> Result<R> {
let cx = crate::agent_cx::AgentCx::for_request();
let guard = self
.session
.session
.lock(cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
Ok(f(&guard))
}
pub async fn compact(
&mut self,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<()> {
self.session.compact_now(on_event).await
}
pub async fn shake(
&mut self,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<()> {
self.session.shake_now(on_event).await
}
pub const fn session(&self) -> &AgentSession {
&self.session
}
pub const fn session_mut(&mut self) -> &mut AgentSession {
&mut self.session
}
pub const fn retry_policy(&self) -> Option<crate::failover::RetryPolicy> {
self.retry
}
pub const fn set_retry_policy(&mut self, policy: Option<crate::failover::RetryPolicy>) {
self.retry = policy;
}
pub(crate) fn adopt_auth_storage(&mut self, mut auth: crate::auth::AuthStorage) {
self.session.scope_auth_storage(&mut auth);
self.session.adopt_auth_storage(auth.clone());
if let Some(options) = &mut self.failover {
Arc::make_mut(options).auth = auth;
}
}
pub fn into_inner(self) -> AgentSession {
self.session
}
fn make_combined_callback(
&self,
per_prompt: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> impl Fn(AgentEvent) + Send + Sync + 'static {
let listeners = self.listeners.clone();
let coalescer = self
.extension_manager()
.map(|manager| crate::extensions::EventCoalescer::new(manager.clone()));
let event_runtime = self.session.runtime_handle().cloned();
if coalescer.is_some() && event_runtime.is_none() {
tracing::debug!(
target: "pi::sdk",
"extensions are loaded but this session has no runtime handle; \
message and tool-execution events will not reach them"
);
}
move |event: AgentEvent| {
match &event {
AgentEvent::ToolExecutionStart {
tool_name, args, ..
} => {
listeners.notify_tool_start(tool_name, args);
}
AgentEvent::ToolExecutionEnd {
tool_name,
result,
is_error,
..
} => {
listeners.notify_tool_end(tool_name, result, *is_error);
}
AgentEvent::MessageUpdate {
assistant_message_event,
..
} => {
if let Some(stream_ev) =
stream_event_from_assistant_message_event(assistant_message_event)
{
listeners.notify_stream_event(&stream_ev);
}
}
_ => {}
}
listeners.notify(&event);
if let (Some(coal), Some(runtime)) = (coalescer.as_ref(), event_runtime.as_ref()) {
coal.dispatch_agent_event_lazy(&event, runtime);
}
per_prompt(event);
}
}
}
fn stream_event_from_assistant_message_event(
event: &crate::model::AssistantMessageEvent,
) -> Option<StreamEvent> {
use crate::model::AssistantMessageEvent as AME;
match event {
AME::TextStart { content_index, .. } => Some(StreamEvent::TextStart {
content_index: *content_index,
}),
AME::TextDelta {
content_index,
delta,
..
} => Some(StreamEvent::TextDelta {
content_index: *content_index,
delta: delta.clone(),
}),
AME::TextEnd {
content_index,
content,
..
} => Some(StreamEvent::TextEnd {
content_index: *content_index,
content: content.clone(),
}),
AME::ThinkingStart { content_index, .. } => Some(StreamEvent::ThinkingStart {
content_index: *content_index,
}),
AME::ThinkingDelta {
content_index,
delta,
..
} => Some(StreamEvent::ThinkingDelta {
content_index: *content_index,
delta: delta.clone(),
}),
AME::ThinkingEnd {
content_index,
content,
..
} => Some(StreamEvent::ThinkingEnd {
content_index: *content_index,
content: content.clone(),
}),
AME::ToolCallStart {
content_index,
partial,
} => {
let (id, name) = match partial.content.get(*content_index) {
Some(ContentBlock::ToolCall(tc)) => (tc.id.clone(), tc.name.clone()),
_ => (String::new(), String::new()),
};
Some(StreamEvent::ToolCallStart {
content_index: *content_index,
id,
name,
})
}
AME::ToolCallDelta {
content_index,
delta,
..
} => Some(StreamEvent::ToolCallDelta {
content_index: *content_index,
delta: delta.clone(),
}),
AME::ToolCallEnd {
content_index,
tool_call,
..
} => Some(StreamEvent::ToolCallEnd {
content_index: *content_index,
tool_call: tool_call.clone(),
}),
AME::Done { reason, message } => Some(StreamEvent::Done {
reason: *reason,
message: (**message).clone(),
}),
AME::Error { reason, error } => Some(StreamEvent::Error {
reason: *reason,
error: (**error).clone(),
}),
AME::Start { .. } => None,
}
}
fn resolve_path_for_cwd(path: &Path, cwd: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
}
}
fn load_session_config(
cwd: &Path,
global_dir: &Path,
config_override: Option<&Path>,
workspace_trusted: bool,
) -> Result<Config> {
Config::load_with_roots_and_project_trust(config_override, global_dir, cwd, workspace_trusted)
}
fn build_stream_options_with_optional_key(
config: &Config,
api_key: Option<String>,
selection: &app::ModelSelection,
session: &Session,
) -> StreamOptions {
let cache_retention =
app::cache_retention_from_env(std::env::var("PI_CACHE_RETENTION").ok().as_deref());
let mut options = StreamOptions {
api_key,
headers: selection.model_entry.headers.clone(),
session_id: Some(session.header.id.clone()),
thinking_level: Some(selection.thinking_level),
cache_retention,
prompt_cache_key: app::resolve_prompt_cache_key(
std::env::var("PI_PROMPT_CACHE_KEY").ok().as_deref(),
cache_retention,
Some(session.header.id.as_str()),
),
max_tokens: Some(selection.model_entry.model.max_tokens),
..Default::default()
};
if let Some(budgets) = &config.thinking_budgets {
let defaults = ThinkingBudgets::default();
options.thinking_budgets = Some(ThinkingBudgets {
minimal: budgets.minimal.unwrap_or(defaults.minimal),
low: budgets.low.unwrap_or(defaults.low),
medium: budgets.medium.unwrap_or(defaults.medium),
high: budgets.high.unwrap_or(defaults.high),
xhigh: budgets.xhigh.unwrap_or(defaults.xhigh),
max: budgets.max.unwrap_or(defaults.max),
});
}
options
}
pub async fn create_agent_session(options: SessionOptions) -> Result<AgentSessionHandle> {
let mut handle = create_agent_session_deferred_mcp(options).await?;
handle.activate_mcp().await;
Ok(handle)
}
#[allow(clippy::too_many_lines)]
pub(crate) async fn create_agent_session_deferred_mcp(
options: SessionOptions,
) -> Result<AgentSessionHandle> {
let process_cwd =
std::env::current_dir().map_err(|e| Error::config(format!("cwd lookup failed: {e}")))?;
let requested_cwd = options.working_directory.as_deref().map_or_else(
|| process_cwd.clone(),
|path| resolve_path_for_cwd(path, &process_cwd),
);
let mut resolved_session_path = options
.session_path
.as_deref()
.map(|path| resolve_path_for_cwd(path, &requested_cwd));
let resolved_session_dir = options
.session_dir
.as_deref()
.map(|path| resolve_path_for_cwd(path, &requested_cwd));
let saved_workdir = if !options.no_session
&& let Some(path) = resolved_session_path.as_deref()
&& crate::session::session_path_entry_exists(path)?
{
Some(crate::session_workdir::inspect_saved_session_workdir(path).await?)
} else {
None
};
let cwd = if let Some(saved) = &saved_workdir {
let cwd = saved.resolve_runtime_cwd(
options
.working_directory
.as_ref()
.map(|_| requested_cwd.as_path()),
)?;
if let Some(workspace) = &options.workspace {
saved.check_runtime_cwd(workspace.snapshot_or(&cwd).primary())?;
}
resolved_session_path = Some(saved.source_path.clone());
cwd
} else {
requested_cwd
};
let mut cli = Cli::try_parse_from(["pi"])
.map_err(|e| Error::validation(format!("CLI init failed: {e}")))?;
cli.no_session = options.no_session;
cli.provider = options.provider.clone();
cli.model = options.model.clone();
cli.models = options
.model_scope
.as_ref()
.map(|patterns| patterns.join(","));
cli.api_key = options.api_key.clone();
cli.system_prompt = options.system_prompt.clone();
cli.append_system_prompt = options.append_system_prompt.clone();
cli.hide_cwd_in_prompt = !options.include_cwd_in_prompt;
cli.no_context_files = options.no_context_files;
cli.thinking = options.thinking.map(|t| t.to_string());
cli.session = resolved_session_path
.as_ref()
.map(|p| p.to_string_lossy().to_string());
cli.session_dir = resolved_session_dir
.as_ref()
.map(|p| p.to_string_lossy().to_string());
if let Some(enabled_tools) = &options.enabled_tools {
if enabled_tools.is_empty() {
cli.no_tools = true;
} else {
cli.no_tools = false;
cli.tools = enabled_tools.join(",");
}
}
let global_dir = Config::global_dir();
let config_override = Config::config_path_override_from_env(&cwd);
let config = load_session_config(
&cwd,
&global_dir,
config_override.as_deref(),
options.workspace_trusted,
)?;
let mut session = Session::new(&cli, &config).await?;
if let Some(saved) = &saved_workdir {
saved.check_loaded_session(&session)?;
saved.check_runtime_cwd(&cwd)?;
} else {
if !options.no_session
&& let Some(path) = resolved_session_path.as_deref()
&& crate::session::session_path_entry_exists(path)?
{
return Err(Error::session(
"PI_SESSION_WORKDIR_SOURCE_CHANGED: session appeared during startup; \
retry discovery before resuming. No workspace was substituted.",
));
}
session.header.cwd = cwd.display().to_string();
}
let workspace = options.workspace.clone().or_else(|| {
let roots = session.additional_roots();
if roots.is_empty() {
return None;
}
let mut restored = crate::workspace::WorkspaceHandle::single(&cwd);
for root in roots {
if !root.is_absolute() {
tracing::warn!(path = %root.display(), "Skipping non-absolute saved workspace root");
continue;
}
match crate::workspace::validate_new_root(&root) {
Ok(canonical) => restored.add_root(&canonical),
Err(error) => tracing::warn!(%error, "Skipping unavailable saved workspace root"),
}
}
Some(restored)
});
if options.workspace.is_some()
&& let Some(workspace) = &workspace
{
session.set_additional_roots(&workspace.additional_roots());
}
let auth_path = Config::auth_path();
let auth_result = AuthStorage::load_async(auth_path.clone()).await;
let mut auth = match auth_result {
Ok(auth) => auth,
Err(err)
if options
.api_key
.as_deref()
.is_some_and(|k| !k.trim().is_empty()) =>
{
tracing::warn!(
"stored credentials are unavailable ({err}); continuing with explicit api_key only"
);
AuthStorage::empty_at(auth_path)
}
Err(err) => return Err(err),
};
auth.set_command_working_directory(&cwd)?;
let oauth_refresh = auth.refresh_expired_oauth_tokens_report().await;
if !oauth_refresh.failed.is_empty() {
tracing::warn!(
providers = ?oauth_refresh.failed_provider_ids(),
"stored OAuth credentials could not be refreshed; ignored unless the session selects one of them"
);
}
let raw_package_dir = options
.package_dir
.clone()
.unwrap_or_else(crate::config::Config::package_dir);
let package_dir = crate::app::stable_package_dir(&raw_package_dir, Some(&cwd));
let models_path = default_models_path(&global_dir);
let mut model_registry = ModelRegistry::load(&auth, Some(models_path));
let scope_override = config
.model_scope_overrides
.as_deref()
.and_then(|overrides| crate::failover::best_scope_override(overrides, &cwd));
let scoped_patterns = if let Some(models_arg) = &cli.models {
app::parse_models_arg(models_arg)
} else if let Some(patterns) = scope_override.and_then(|scope| scope.enabled_models.clone()) {
patterns
} else {
config.enabled_models.clone().unwrap_or_default()
};
let has_extensions = !options.extension_paths.is_empty();
let scoped_models = if has_extensions {
Vec::new()
} else {
app::resolve_startup_model_scope(
&cli,
&session,
&scoped_patterns,
&model_registry,
&config,
&cwd,
)
.map_err(|error| Error::validation(error.to_string()))?
};
let selection = if has_extensions {
extension_bootstrap::provisional_selection(&model_registry)?
} else {
app::select_model_and_thinking(
&cli,
&config,
&session,
&model_registry,
&scoped_models,
&global_dir,
)
.map_err(|err| Error::validation(err.to_string()))?
};
if !has_extensions {
app::update_session_for_selection(&mut session, &selection);
}
let enabled_tools_owned = cli
.enabled_tools()
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>();
let enabled_tools = enabled_tools_owned
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
let sdk_test_mode = std::env::var_os("PI_TEST_MODE").is_some();
let foreign_rules = if config.foreign_rules_enabled() && !sdk_test_mode && !cli.no_context_files
{
crate::context_files::discover_foreign_rules(&cwd)
} else {
crate::context_files::ForeignRules::default()
};
let system_prompt = app::build_system_prompt(
&cli,
&cwd,
&enabled_tools,
options
.skills_prompt
.as_deref()
.filter(|block| !block.is_empty()),
&global_dir,
&package_dir,
sdk_test_mode,
options.include_cwd_in_prompt,
Some(&foreign_rules),
&config,
)
.map_err(|err| Error::validation(err.to_string()))?;
let provider = providers::create_provider_with_auth(&selection.model_entry, None, Some(&auth))
.map_err(|e| Error::provider("sdk", e.to_string()))?;
let api_key = if has_extensions {
None
} else {
app::resolve_api_key(&auth, &cli, &selection.model_entry)
.map_err(|err| Error::validation(err.to_string()))?
};
if !has_extensions
&& cli.api_key.is_none()
&& let Some(failure) = oauth_refresh.failure_for(&selection.model_entry.model.provider)
{
return Err(Error::auth(format!(
"OAuth token refresh failed for: {} ({})",
failure.provider, failure.error
)));
}
let stream_options =
build_stream_options_with_optional_key(&config, api_key, &selection, &session);
let agent_config = AgentConfig {
system_prompt: Some(system_prompt),
max_tool_iterations: options.max_tool_iterations,
stream_options,
block_images: config.image_block_images(),
model_accepts_images: selection
.model_entry
.model
.input
.contains(&crate::provider::InputType::Image),
fail_closed_hooks: config.fail_closed_hooks(),
tool_approval: options.tool_approval.clone(),
keyword_settings: config.keywords.clone(),
max_time: options.max_time,
turn_recovery: config.turn_recovery_mode(),
approval_state: options.approval_state.clone(),
bash_settings: config.bash.clone(),
secrets: config.secrets.clone(),
};
let tools = options.tool_factory.as_ref().map_or_else(
|| {
if cli.no_tools {
return ToolRegistry::without_builtins(Some(&config));
}
ToolRegistry::with_mutation_recorder(
&enabled_tools,
&cwd,
Some(&config),
Some(std::sync::Arc::new(
crate::undo::FileMutationRecorder::default(),
)),
workspace.as_ref(),
)
},
|factory| factory.create_tool_registry(&enabled_tools, &cwd, &config),
);
let host_ask = tools.host_ask_tool();
let session_arc = Arc::new(asupersync::sync::Mutex::new(session));
let compaction_settings = options.compaction_settings.clone().unwrap_or_else(|| {
let context_window_tokens = if selection.model_entry.model.context_window == 0 {
ResolvedCompactionSettings::default().context_window_tokens
} else {
selection.model_entry.model.context_window
};
ResolvedCompactionSettings {
enabled: config.compaction_enabled(),
reserve_tokens: config.compaction_reserve_tokens(),
keep_recent_tokens: config.compaction_keep_recent_tokens(),
context_window_tokens,
mode: config.compaction_mode(),
render_mode: config.compaction_render_mode(),
}
});
let mut agent_session = AgentSession::new(
Agent::new(provider, tools, agent_config),
Arc::clone(&session_arc),
!cli.no_session,
compaction_settings,
);
if let Some(handle) = options.runtime_handle.clone() {
agent_session = agent_session.with_runtime_handle(handle);
}
agent_session.set_queue_modes(config.steering_queue_mode(), config.follow_up_queue_mode());
agent_session.set_api_key_override(options.api_key.clone());
agent_session.advisor = options.advisor.as_ref().map(|options| {
options
.runtime()
.with_secrets_settings(config.secrets.as_ref())
});
if foreign_rules.scoped_rules().next().is_some() {
agent_session
.agent
.set_foreign_scoped_rules(foreign_rules.rules.clone(), cwd.clone());
}
if enabled_tools.contains(&"todo") {
let todo_session = Arc::clone(&agent_session.session);
agent_session.agent.extend_tools(vec![
Box::new(crate::todo::TodoTool::new(todo_session)) as Box<dyn crate::tools::Tool>
]);
}
let ask_tool_handle = Some(host_ask.clone());
if enabled_tools.contains(&"ask") {
agent_session.agent.extend_tools(vec![
Box::new(host_ask.clone()) as Box<dyn crate::tools::Tool>
]);
}
if let Some(approval_state) = &options.approval_state
&& options.tool_approval.is_none()
{
agent_session
.agent
.set_tool_approval(Some(crate::ask::approval_handler_via_ask(
host_ask,
approval_state.clone(),
)));
}
if has_extensions {
let extension_paths = options
.extension_paths
.iter()
.map(|path| resolve_path_for_cwd(path, &cwd))
.collect::<Vec<_>>();
let resolved_ext_policy =
config.resolve_extension_policy_with_metadata(options.extension_policy.as_deref());
let resolved_repair_policy =
config.resolve_repair_policy_with_metadata(options.repair_policy.as_deref());
agent_session
.enable_extensions_with_policy(
&enabled_tools,
&cwd,
Some(&config),
&extension_paths,
Some(resolved_ext_policy.policy),
Some(resolved_repair_policy.effective_mode),
None,
crate::agent::ExtensionHostConfiguration {
ui_handler: options.extension_ui_handler.clone(),
persist_permission_decisions: options.persist_extension_permissions,
cli_flags: options.extension_flags.clone(),
},
)
.await?;
extension_bootstrap::finish_selection(
&mut agent_session,
&mut model_registry,
&mut auth,
extension_bootstrap::SelectionInputs {
cli: &cli,
config: &config,
cwd: &cwd,
scoped_patterns: &scoped_patterns,
global_dir: &global_dir,
oauth_refresh: &oauth_refresh,
preserve_compaction_window: options.compaction_settings.is_some(),
},
)
.await?;
}
let event_runtime = if options.runtime_handle.is_none() && agent_session.extensions.is_some() {
match asupersync::runtime::RuntimeBuilder::new().build() {
Ok(runtime) => {
agent_session = agent_session.with_runtime_handle(runtime.handle());
tracing::debug!(
target: "pi::sdk",
"extensions are loaded and no runtime handle was supplied; built one so \
message and tool-execution events reach them"
);
Some(runtime)
}
Err(err) => {
tracing::warn!(
target: "pi::sdk",
error = %err,
"extensions are loaded but no runtime could be built for them; message and \
tool-execution events will not reach extensions on this session"
);
None
}
}
} else {
None
};
let mcp_manager = if let Some(mcp) = &options.mcp {
let global_dir = mcp.global_dir.clone().unwrap_or_else(Config::global_dir);
let manager = Arc::new(crate::mcp::McpManager::bootstrap(
&cwd,
&global_dir,
&mcp.config_paths,
options.workspace_trusted,
)?);
if let Some(extension_manager) = agent_session
.extensions
.as_ref()
.map(ExtensionRegion::manager)
{
for spec in extension_manager.extension_mcp_servers() {
let name = spec.get("name").and_then(Value::as_str).unwrap_or_default();
if !name.is_empty() {
manager.register_extension_server(name, &spec);
}
}
}
Some(manager)
} else {
None
};
agent_session.set_model_registry(model_registry.clone());
agent_session.set_auth_storage(auth);
let history = {
let cx = crate::agent_cx::AgentCx::for_request();
let guard = session_arc
.lock(cx.cx())
.await
.map_err(|e| Error::session(e.to_string()))?;
guard.to_messages_for_current_path()
};
if !history.is_empty() {
agent_session.agent.replace_messages(history);
}
let mut listeners = EventListeners::new();
if let Some(on_event) = options.on_event {
listeners.subscribe(on_event);
}
listeners.on_tool_start = options.on_tool_start;
listeners.on_tool_end = options.on_tool_end;
listeners.on_stream_event = options.on_stream_event;
let failover_state = match options.failover.as_ref() {
None => crate::failover::FailoverState::new_empty(),
Some(failover) => {
let cx = crate::agent_cx::AgentCx::for_request();
let inner = agent_session.session.lock(cx.cx()).await.ok();
inner.as_deref().map_or_else(
|| crate::failover::FailoverState::with_cooldown_secs(failover.cooldown_secs),
|s| {
crate::failover::FailoverState::reconstruct_from_session(
s,
failover.cooldown_secs,
chrono::Utc::now(),
)
},
)
}
};
let failover = options.failover.map(|mut options| {
agent_session.scope_auth_storage(&mut options.auth);
Arc::new(options)
});
Ok(AgentSessionHandle {
session: agent_session,
listeners,
ask_tool: ask_tool_handle,
workspace,
mcp_manager,
retry: options.retry,
failover_state,
failover,
event_runtime,
})
}
#[cfg(test)]
mod tests {
mod recovery;
use super::*;
use asupersync::runtime::RuntimeBuilder;
use asupersync::runtime::reactor::create_reactor;
use asupersync::sync::Mutex as AsyncMutex;
use std::env;
use std::sync::{Arc, Mutex};
use tempfile::tempdir;
fn run_async<F>(future: F) -> F::Output
where
F: std::future::Future,
{
let reactor = create_reactor().expect("create reactor");
let runtime = RuntimeBuilder::current_thread()
.with_reactor(reactor)
.build()
.expect("build runtime");
runtime.block_on(Box::pin(future))
}
fn run_async_with_blocking_pool<F>(future: F) -> F::Output
where
F: std::future::Future,
{
let reactor = create_reactor().expect("create reactor");
let runtime = RuntimeBuilder::current_thread()
.with_reactor(reactor)
.blocking_threads(1, 2)
.build()
.expect("build runtime");
runtime.block_on(Box::pin(future))
}
fn current_dir_lock() -> std::sync::MutexGuard<'static, ()> {
crate::test_current_dir_lock()
}
struct RewindPrivacyProvider {
requests: Arc<Mutex<Vec<String>>>,
}
#[async_trait::async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl crate::provider::Provider for RewindPrivacyProvider {
fn name(&self) -> &str {
"rewind-privacy"
}
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
"test-model"
}
async fn stream(
&self,
context: &crate::provider::Context<'_>,
_options: &crate::provider::StreamOptions,
) -> crate::error::Result<
std::pin::Pin<
Box<
dyn futures::Stream<Item = crate::error::Result<crate::model::StreamEvent>>
+ Send,
>,
>,
> {
let request = serde_json::to_string(context.messages.as_ref())?;
self.requests
.lock()
.map_err(|_| Error::session("fixture request lock poisoned"))?
.push(request);
let message = crate::model::AssistantMessage {
content: vec![crate::model::ContentBlock::Text(
crate::model::TextContent::new("privacy-aware rewind report"),
)],
stop_reason: crate::model::StopReason::Stop,
..Default::default()
};
Ok(Box::pin(futures::stream::iter(vec![Ok(
crate::model::StreamEvent::Done {
reason: crate::model::StopReason::Stop,
message,
},
)])))
}
}
#[derive(Clone, Copy)]
enum RewindCallbackAction {
Append,
Header,
Pending,
}
struct RewindCallbackProvider {
delegate: RewindPrivacyProvider,
store: Arc<AsyncMutex<Session>>,
actions: crate::agent::SessionActionAdmissionGate,
action: RewindCallbackAction,
after_callback: Arc<Mutex<Option<SessionEditSnapshot>>>,
calls: Arc<std::sync::atomic::AtomicUsize>,
}
#[async_trait::async_trait]
impl Provider for RewindCallbackProvider {
fn name(&self) -> &str {
self.delegate.name()
}
fn api(&self) -> &str {
self.delegate.api()
}
fn model_id(&self) -> &str {
self.delegate.model_id()
}
async fn stream(
&self,
context: &ProviderContext<'_>,
options: &StreamOptions,
) -> Result<std::pin::Pin<Box<dyn futures::Stream<Item = Result<StreamEvent>> + Send>>>
{
self.calls.fetch_add(1, Ordering::SeqCst);
{
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let mut action = Box::pin(self.actions.acquire(cx.cx()));
let std::task::Poll::Ready(Ok(_action_permit)) = futures::poll!(action.as_mut())
else {
return Err(Error::session(
"rewind held Session-action authority across its provider callback",
));
};
let mut session = self.store.try_lock().map_err(|_| {
Error::session("rewind held the Session lock across its provider callback")
})?;
match self.action {
RewindCallbackAction::Append => {
session.append_custom_entry("callback-mutation".to_string(), None);
}
RewindCallbackAction::Header => {
session.header.additional_roots = Some(vec!["/callback-root".to_string()]);
}
RewindCallbackAction::Pending => {}
}
*self.after_callback.lock().unwrap() =
Some(SessionEditSnapshot::capture(&session)?);
}
if matches!(self.action, RewindCallbackAction::Pending) {
return futures::future::pending().await;
}
self.delegate.stream(context, options).await
}
}
struct FlakyThenOkProvider {
failures: usize,
calls: Arc<std::sync::atomic::AtomicUsize>,
name: String,
model: String,
input_tokens: u64,
}
#[async_trait::async_trait]
impl crate::provider::Provider for FlakyThenOkProvider {
fn name(&self) -> &str {
&self.name
}
#[allow(clippy::unnecessary_literal_bound)]
fn api(&self) -> &str {
"test-api"
}
fn model_id(&self) -> &str {
&self.model
}
async fn stream(
&self,
_context: &crate::provider::Context<'_>,
_options: &crate::provider::StreamOptions,
) -> crate::error::Result<
std::pin::Pin<
Box<
dyn futures::Stream<Item = crate::error::Result<crate::model::StreamEvent>>
+ Send,
>,
>,
> {
let call = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let mut partial = crate::model::AssistantMessage {
content: Vec::new(),
api: self.api().to_string(),
provider: self.name().to_string(),
model: self.model_id().to_string(),
usage: crate::model::Usage {
input: self.input_tokens,
..crate::model::Usage::default()
},
stop_reason: crate::model::StopReason::Error,
stop_details: None,
error_message: Some("503 service unavailable".to_string()),
timestamp: 0,
};
if call < self.failures {
let events = vec![
Ok(crate::model::StreamEvent::Start {
partial: partial.clone(),
}),
Ok(crate::model::StreamEvent::Error {
reason: crate::model::StopReason::Error,
error: partial,
}),
];
return Ok(Box::pin(futures::stream::iter(events)));
}
partial.stop_reason = crate::model::StopReason::Stop;
partial.error_message = None;
let events = vec![
Ok(crate::model::StreamEvent::Start {
partial: partial.clone(),
}),
Ok(crate::model::StreamEvent::Done {
reason: crate::model::StopReason::Stop,
message: partial,
}),
];
Ok(Box::pin(futures::stream::iter(events)))
}
}
fn flaky_handle(failures: usize) -> (AgentSessionHandle, Arc<std::sync::atomic::AtomicUsize>) {
flaky_handle_as(failures, "test-provider", "test-model")
}
fn saving_handle(dir: &Path) -> AgentSessionHandle {
saving_handle_with_store(dir, crate::session::SessionStoreKind::Jsonl)
}
fn saving_handle_with_store(
dir: &Path,
store_kind: crate::session::SessionStoreKind,
) -> AgentSessionHandle {
let provider = Arc::new(FlakyThenOkProvider {
failures: 0,
calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
name: "test-provider".to_string(),
model: "test-model".to_string(),
input_tokens: 0,
});
let agent = crate::agent::Agent::new(
provider,
crate::tools::ToolRegistry::new(&[], Path::new("."), None),
crate::agent::AgentConfig::default(),
);
let session = AgentSession::new(
agent,
Arc::new(AsyncMutex::new(
crate::session::Session::create_with_dir_and_store(
Some(dir.to_path_buf()),
store_kind,
),
)),
true,
crate::compaction::ResolvedCompactionSettings::default(),
);
AgentSessionHandle::from_session_with_listeners(session, EventListeners::default())
}
fn workspace_handle_with_store(
root: &Path,
store_kind: crate::session::SessionStoreKind,
) -> AgentSessionHandle {
let cwd = root.join("primary");
std::fs::create_dir(&cwd).expect("primary workspace");
let workspace = crate::workspace::WorkspaceHandle::single(&cwd);
let provider = Arc::new(FlakyThenOkProvider {
failures: 0,
calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
name: "test-provider".to_string(),
model: "test-model".to_string(),
input_tokens: 0,
});
let tools = ToolRegistry::with_mutation_recorder(
&["read", "write"],
&cwd,
None,
None,
Some(&workspace),
);
let mut store = Session::create_with_dir_and_store(Some(root.join("sessions")), store_kind);
store.header.cwd = cwd.display().to_string();
let session = AgentSession::new(
Agent::new(provider, tools, AgentConfig::default()),
Arc::new(AsyncMutex::new(store)),
true,
ResolvedCompactionSettings::default(),
);
let mut handle =
AgentSessionHandle::from_session_with_listeners(session, EventListeners::default());
handle.workspace = Some(workspace);
handle
}
#[test]
fn workspace_root_changes_reopen_durably_and_revoke_existing_tool_handles() {
for store_kind in [
crate::session::SessionStoreKind::Jsonl,
#[cfg(feature = "sqlite-sessions")]
crate::session::SessionStoreKind::Sqlite,
] {
let root = tempdir().unwrap();
let mut handle = workspace_handle_with_store(root.path(), store_kind);
let extra = root.path().join("additional");
std::fs::create_dir(&extra).unwrap();
let canonical = extra.canonicalize().unwrap();
let probe = extra.join("probe.txt");
std::fs::write(&probe, "before grant").unwrap();
let tools = handle.session.agent.shared_tools().snapshot();
let write = tools.get("write").unwrap();
run_async(async {
assert!(
write
.execute(
"before-grant",
serde_json::json!({"path": probe, "content": "denied"}),
None,
)
.await
.is_err()
);
handle.add_workspace_root(&extra).await.unwrap();
let path = handle
.session_store()
.try_lock()
.unwrap()
.path
.clone()
.unwrap();
let reopened = Session::open(&path.display().to_string()).await.unwrap();
assert_eq!(reopened.additional_roots(), vec![canonical.clone()]);
let output = write
.execute(
"after-grant",
serde_json::json!({"path": probe, "content": "allowed"}),
None,
)
.await
.unwrap();
assert!(!output.is_error);
let nested = extra.join("nested");
std::fs::create_dir(&nested).unwrap();
assert!(
handle
.add_workspace_root(&nested)
.await
.unwrap()
.starts_with("Already")
);
assert_eq!(
Session::open(&path.display().to_string())
.await
.unwrap()
.additional_roots(),
vec![canonical]
);
handle.remove_workspace_root(&extra).await.unwrap();
assert!(
Session::open(&path.display().to_string())
.await
.unwrap()
.additional_roots()
.is_empty()
);
assert!(handle.workspace().unwrap().additional_roots().is_empty());
assert!(
write
.execute(
"after-revocation",
serde_json::json!({"path": probe, "content": "must stay denied"}),
None,
)
.await
.is_err()
);
assert_eq!(std::fs::read_to_string(&probe).unwrap(), "allowed");
});
}
}
#[test]
fn workspace_root_save_failures_do_not_grant_access_or_undo_revocation() {
for store_kind in [
crate::session::SessionStoreKind::Jsonl,
#[cfg(feature = "sqlite-sessions")]
crate::session::SessionStoreKind::Sqlite,
] {
for remove in [false, true] {
let root = tempdir().unwrap();
let mut handle = workspace_handle_with_store(root.path(), store_kind);
let extra = root.path().join("additional");
std::fs::create_dir(&extra).unwrap();
let probe = extra.join("probe.txt");
std::fs::write(&probe, "must remain untouched").unwrap();
let tools = handle.session.agent.shared_tools().snapshot();
run_async(async {
handle
.mark_checkpoint("before root change", None)
.await
.unwrap();
if remove {
handle.add_workspace_root(&extra).await.unwrap();
}
let store = handle.session_store();
let original_path = store.try_lock().unwrap().path.clone().unwrap();
let original_bytes = std::fs::read(&original_path).unwrap();
let blocked_path = root
.path()
.join("directory-not-session")
.with_extension(original_path.extension().unwrap());
std::fs::create_dir(&blocked_path).unwrap();
store.try_lock().unwrap().path = Some(blocked_path);
let result = if remove {
handle.remove_workspace_root(&extra).await
} else {
handle.add_workspace_root(&extra).await
};
assert!(result.unwrap_err().is_session_persistence());
assert!(handle.workspace().unwrap().additional_roots().is_empty());
assert!(store.try_lock().unwrap().additional_roots().is_empty());
assert_eq!(std::fs::read(&original_path).unwrap(), original_bytes);
assert!(
tools
.get("write")
.unwrap()
.execute(
"failed-root-change",
serde_json::json!({"path": probe, "content": "denied"}),
None,
)
.await
.is_err()
);
assert_eq!(
std::fs::read_to_string(&probe).unwrap(),
"must remain untouched"
);
store.try_lock().unwrap().path = Some(original_path.clone());
assert!(
handle
.prompt("must remain fenced", |_| {})
.await
.unwrap_err()
.is_session_persistence()
);
assert!(
handle
.add_workspace_root(&extra)
.await
.unwrap_err()
.is_session_persistence()
);
handle.session.persist_session().await.unwrap();
assert!(
Session::open(&original_path.display().to_string())
.await
.unwrap()
.additional_roots()
.is_empty()
);
assert!(
handle
.session
.ensure_provider_reentry_allowed()
.unwrap_err()
.is_session_persistence()
);
});
}
}
}
#[test]
fn workspace_root_paths_use_a_resumed_workdir_attachment() {
let _lock = current_dir_lock();
let root = tempdir().unwrap();
let process_cwd = root.path().join("process/workspace");
let attached = root.path().join("attached/workspace");
let extra = root.path().join("attached/additional");
let decoy = root.path().join("process/additional");
for dir in [&process_cwd, &attached, &extra, &decoy] {
std::fs::create_dir_all(dir).unwrap();
}
let _cwd = CurrentDirGuard::new(&process_cwd);
let path = root.path().join("resumed.jsonl");
let mut source = Session::in_memory();
source.header.cwd = root
.path()
.join("original-moved-away")
.display()
.to_string();
source.path = Some(path.clone());
source.session_dir = Some(root.path().join("sessions"));
crate::session_workdir::attach_session_workdir(&mut source, &attached).unwrap();
run_async(async {
source.save().await.unwrap();
let mut handle = create_agent_session(SessionOptions {
provider: Some("openai".to_string()),
model: Some("gpt-4o".to_string()),
api_key: Some("dummy-key".to_string()),
session_path: Some(path.clone()),
session_dir: Some(root.path().join("sessions")),
no_session: false,
no_context_files: true,
enabled_tools: Some(vec!["read".to_string(), "write".to_string()]),
workspace: Some(crate::workspace::WorkspaceHandle::single(&attached)),
..SessionOptions::default()
})
.await
.unwrap();
handle.add_workspace_root("../additional").await.unwrap();
let expected = vec![extra.canonicalize().unwrap()];
assert_eq!(handle.workspace().unwrap().additional_roots(), expected);
assert_eq!(
Session::open(&path.display().to_string())
.await
.unwrap()
.additional_roots(),
expected
);
assert!(!handle.workspace().unwrap().is_within(&decoy));
handle.remove_workspace_root("../additional").await.unwrap();
let reopened = Session::open(&path.display().to_string()).await.unwrap();
assert!(reopened.additional_roots().is_empty());
assert_eq!(
crate::session_workdir::require_session_workdir(&reopened).unwrap(),
attached.canonicalize().unwrap()
);
assert_eq!(reopened.header.cwd, source.header.cwd);
});
}
#[test]
fn waiting_workspace_root_changes_cancel_without_mutation_or_quarantine() {
for remove in [false, true] {
let root = tempdir().unwrap();
let mut handle =
workspace_handle_with_store(root.path(), crate::session::SessionStoreKind::Jsonl);
let extra = root.path().join("additional");
std::fs::create_dir(&extra).unwrap();
run_async(async {
handle
.mark_checkpoint("before root change", None)
.await
.unwrap();
if remove {
handle.add_workspace_root(&extra).await.unwrap();
}
let store = handle.session_store();
let workspace = handle.workspace().unwrap();
let before_roots = workspace.additional_roots();
let before_session =
SessionEditSnapshot::capture(&store.try_lock().unwrap()).unwrap();
let admission = handle.session.provider_admission_gate();
let actions = handle.session.session_action_admission_gate();
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let provider_permit = admission.acquire(cx.cx()).await.unwrap();
let mut change = Box::pin(async {
if remove {
handle.remove_workspace_root(&extra).await
} else {
handle.add_workspace_root(&extra).await
}
});
assert!(futures::poll!(change.as_mut()).is_pending());
let mut callback_action = Box::pin(actions.acquire(cx.cx()));
let std::task::Poll::Ready(Ok(action_permit)) =
futures::poll!(callback_action.as_mut())
else {
panic!("a waiting root change must not hold Session-action authority");
};
assert_eq!(workspace.additional_roots(), before_roots);
assert!(
SessionEditSnapshot::capture(&store.try_lock().unwrap()).unwrap()
== before_session
);
drop(change);
drop(action_permit);
drop(provider_permit);
admission.ensure_allowed().unwrap();
if remove {
handle.remove_workspace_root(&extra).await.unwrap();
} else {
handle.add_workspace_root(&extra).await.unwrap();
}
admission.ensure_allowed().unwrap();
});
}
}
#[test]
fn workspace_root_grant_stays_private_and_preserves_host_edits_during_save() {
let root = tempdir().unwrap();
let mut handle =
workspace_handle_with_store(root.path(), crate::session::SessionStoreKind::Jsonl);
let previous = root.path().join("previous");
let proposed = root.path().join("proposed");
std::fs::create_dir(&previous).unwrap();
std::fs::create_dir(&proposed).unwrap();
run_async_with_blocking_pool(async {
handle.add_workspace_root(&previous).await.unwrap();
let store = handle.session_store();
let path = store.try_lock().unwrap().path.clone().unwrap();
let admission = handle.session.provider_admission_gate();
let mut host_workspace = handle.workspace().unwrap();
let disk_lock = crate::session::lock_session_persistence(&path).unwrap();
let mut change = Box::pin(handle.add_workspace_root(&proposed));
assert!(futures::poll!(change.as_mut()).is_pending());
assert!(
admission
.ensure_allowed()
.unwrap_err()
.is_session_persistence()
);
assert_eq!(
host_workspace.additional_roots(),
vec![previous.canonicalize().unwrap()]
);
assert!(!host_workspace.is_within(&proposed));
assert!(host_workspace.remove_root(&previous));
drop(disk_lock);
let result = asupersync::time::timeout(
asupersync::time::wall_now(),
Duration::from_secs(10),
change,
)
.await
.expect("root save must finish after releasing the persistence lock");
assert!(result.unwrap_err().is_session_persistence());
assert!(host_workspace.additional_roots().is_empty());
assert!(store.try_lock().unwrap().additional_roots().is_empty());
assert!(
admission
.ensure_allowed()
.unwrap_err()
.is_session_persistence()
);
handle.session.persist_session().await.unwrap();
assert!(
Session::open(&path.display().to_string())
.await
.unwrap()
.additional_roots()
.is_empty()
);
});
}
#[test]
fn workspace_root_removal_revokes_existing_tools_before_save_completes() {
let root = tempdir().unwrap();
let mut handle =
workspace_handle_with_store(root.path(), crate::session::SessionStoreKind::Jsonl);
let extra = root.path().join("additional");
std::fs::create_dir(&extra).unwrap();
let probe = extra.join("probe.txt");
std::fs::write(&probe, "must remain untouched").unwrap();
let tools = handle.session.agent.shared_tools().snapshot();
run_async_with_blocking_pool(async {
handle.add_workspace_root(&extra).await.unwrap();
let store = handle.session_store();
let path = store.try_lock().unwrap().path.clone().unwrap();
let workspace = handle.workspace().unwrap();
let admission = handle.session.provider_admission_gate();
let disk_lock = crate::session::lock_session_persistence(&path).unwrap();
let mut change = Box::pin(handle.remove_workspace_root(&extra));
assert!(futures::poll!(change.as_mut()).is_pending());
assert!(workspace.additional_roots().is_empty());
assert!(
admission
.ensure_allowed()
.unwrap_err()
.is_session_persistence()
);
assert!(
tools
.get("write")
.unwrap()
.execute(
"revoked-before-save",
serde_json::json!({"path": probe, "content": "denied"}),
None,
)
.await
.is_err()
);
drop(disk_lock);
asupersync::time::timeout(
asupersync::time::wall_now(),
Duration::from_secs(10),
change,
)
.await
.expect("root removal must finish after releasing the persistence lock")
.unwrap();
admission.ensure_allowed().unwrap();
assert!(
Session::open(&path.display().to_string())
.await
.unwrap()
.additional_roots()
.is_empty()
);
assert_eq!(
std::fs::read_to_string(&probe).unwrap(),
"must remain untouched"
);
});
}
fn flaky_handle_as(
failures: usize,
name: &str,
model: &str,
) -> (AgentSessionHandle, Arc<std::sync::atomic::AtomicUsize>) {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let provider = Arc::new(FlakyThenOkProvider {
failures,
calls: Arc::clone(&calls),
name: name.to_string(),
model: model.to_string(),
input_tokens: 0,
});
let agent = crate::agent::Agent::new(
provider,
crate::tools::ToolRegistry::new(&[], Path::new("."), None),
crate::agent::AgentConfig::default(),
);
let session = AgentSession::new(
agent,
Arc::new(AsyncMutex::new(crate::session::Session::in_memory())),
false,
crate::compaction::ResolvedCompactionSettings::default(),
);
(
AgentSessionHandle::from_session_with_listeners(session, EventListeners::default()),
calls,
)
}
fn with_chain(handle: AgentSessionHandle, spec: &str) -> AgentSessionHandle {
with_chain_cooldown(handle, spec, 300)
}
#[cfg(unix)]
#[test]
fn auth_reload_and_failover_keep_the_active_workspace() {
let root = tempdir().expect("fixture root");
let workspace = root.path().join("workspace");
let other = root.path().join("other");
std::fs::create_dir(&workspace).expect("runtime workspace");
std::fs::create_dir(&other).expect("other workspace");
std::fs::write(workspace.join("credential.txt"), "runtime-key\n")
.expect("runtime credential");
std::fs::write(other.join("credential.txt"), "other-key\n").expect("other credential");
let auth_path = root.path().join("auth.json");
let fresh_auth = || {
let mut auth = AuthStorage::empty_at(auth_path.clone());
auth.set(
"test-provider",
crate::auth::AuthCredential::ApiKey {
key: "$CMD:cat credential.txt".to_string(),
},
);
auth
};
let (mut handle, _) = flaky_handle(0);
let mut initial = AuthStorage::empty_at(auth_path.clone());
initial
.set_command_working_directory(&workspace)
.expect("scope active runtime");
handle.session_mut().set_auth_storage(initial);
let mut incoming = fresh_auth();
incoming
.set_command_working_directory(&other)
.expect("incoming context must not replace runtime workspace");
handle = handle.with_failover(Some(FailoverOptions {
chains: std::collections::HashMap::new(),
available_models: Vec::new(),
auth: incoming.clone(),
cli_api_key: None,
cooldown_secs: 0,
}));
assert_eq!(
handle
.failover
.as_ref()
.expect("installed failover")
.auth
.api_key("test-provider")
.as_deref(),
Some("runtime-key"),
"caller-supplied fallback credentials use the active runtime cwd"
);
handle.adopt_auth_storage(incoming);
assert_eq!(
handle.session().agent.stream_options().api_key.as_deref(),
Some("runtime-key"),
"login adoption resolves the running model in its original workspace"
);
assert_eq!(
handle
.failover
.as_ref()
.expect("retained failover")
.auth
.api_key("test-provider")
.as_deref(),
Some("runtime-key"),
"future fallback candidates keep the same scoped credentials"
);
handle
.session_mut()
.adopt_auth_storage(AuthStorage::empty_at(auth_path.clone()));
assert!(handle.session().agent.stream_options().api_key.is_none());
handle.session_mut().adopt_auth_storage(fresh_auth());
assert_eq!(
handle.session().agent.stream_options().api_key.as_deref(),
Some("runtime-key"),
"direct session adoption retains cwd through logout and another login"
);
}
fn spawn_unauthorized_stub() -> String {
use std::io::{Read as _, Write as _};
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind stub");
let address = listener.local_addr().expect("stub address");
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let mut request = [0_u8; 8192];
let _ = stream.read(&mut request);
let body = r#"{"error":{"message":"Incorrect API key provided","type":"invalid_request_error","code":"invalid_api_key"}}"#;
let _ = write!(
stream,
"HTTP/1.1 401 Unauthorized\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
}
});
format!("http://{address}/v1")
}
fn with_chain_cooldown(
handle: AgentSessionHandle,
spec: &str,
cooldown_secs: u64,
) -> AgentSessionHandle {
let auth_path = tempdir().expect("tempdir").path().join("auth.json");
let auth = crate::auth::AuthStorage::load(auth_path).expect("auth load");
let (provider, model_id) =
crate::provider_metadata::split_provider_model_spec(spec).expect("chain spec");
let mut fallback =
crate::models::ad_hoc_model_entry(provider, model_id).expect("fallback entry");
fallback.model.base_url = spawn_unauthorized_stub();
handle.with_failover(Some(FailoverOptions {
chains: std::collections::HashMap::from([(
"default".to_string(),
vec![spec.to_string()],
)]),
available_models: vec![fallback],
auth,
cli_api_key: Some("test-key".to_string()),
cooldown_secs,
}))
}
fn handle_after_one_failover(cooldown_secs: u64) -> AgentSessionHandle {
let (handle, _calls) = flaky_handle_as(usize::MAX, "anthropic", "claude-3-5-haiku-latest");
let mut handle = with_chain_cooldown(
handle.with_retry(Some(crate::failover::RetryPolicy {
max_retries: 1,
max_failovers_per_turn: 1,
base_delay_ms: 1,
max_delay_ms: 1,
})),
"openai/gpt-4o-mini",
cooldown_secs,
);
let (_abort_handle, abort_signal) = AgentSessionHandle::new_abort_handle();
let _ = run_async(handle.prompt_with_abort("hello", abort_signal, |_| {}));
assert_eq!(
handle.session.agent.provider().model_id(),
"gpt-4o-mini",
"the setup turn must have installed the fallback"
);
handle
}
type RestoreLog = Arc<Mutex<Vec<(bool, String)>>>;
fn restore_recorder() -> (RestoreLog, Arc<dyn Fn(AgentEvent) + Send + Sync>) {
let seen: RestoreLog = Arc::new(Mutex::new(Vec::new()));
let recorder = Arc::clone(&seen);
let shared: Arc<dyn Fn(AgentEvent) + Send + Sync> = Arc::new(move |event| {
if let AgentEvent::FailoverEnd {
restored_primary,
model,
..
} = event
{
recorder
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push((restored_primary, model));
}
});
(seen, shared)
}
fn fast_retry_policy(max_retries: u32) -> crate::failover::RetryPolicy {
crate::failover::RetryPolicy {
max_retries,
max_failovers_per_turn: 0,
base_delay_ms: 1,
max_delay_ms: 1,
}
}
#[test]
fn changing_retry_policy_controls_subsequent_turns_without_rebuilding_the_session() {
let (mut handle, calls) = flaky_handle(2);
let store = handle.session_store();
let policy = fast_retry_policy(3);
handle.set_retry_policy(None);
let first = run_async(handle.prompt("first", |_| {})).expect("first turn");
assert_eq!(first.stop_reason, crate::model::StopReason::Error);
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
handle.set_retry_policy(Some(policy));
let second = run_async(handle.prompt("second", |_| {})).expect("recovered turn");
assert_eq!(second.stop_reason, crate::model::StopReason::Stop);
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 3);
assert!(Arc::ptr_eq(&store, &handle.session_store()));
}
#[test]
fn toggling_retry_preserves_the_active_fallback_and_primary_cooldown() {
let mut handle = handle_after_one_failover(3600);
let policy = handle.retry_policy().expect("recovery policy");
let fallback = Arc::clone(handle.failover.as_ref().expect("fallback configuration"));
let before = format!("{:?}", handle.failover_state);
let model = handle.model();
handle.set_retry_policy(None);
assert_eq!(handle.model(), model);
assert_eq!(format!("{:?}", handle.failover_state), before);
handle.set_retry_policy(Some(policy));
assert!(Arc::ptr_eq(
handle.failover.as_ref().expect("same fallback"),
&fallback
));
assert_eq!(format!("{:?}", handle.failover_state), before);
}
#[test]
fn a_configured_retry_policy_resumes_a_transient_failure() {
let (handle, calls) = flaky_handle(1);
let mut handle = handle.with_retry(Some(fast_retry_policy(3)));
let seen = Arc::new(Mutex::new(Vec::<String>::new()));
let recorder = Arc::clone(&seen);
let (_abort_handle, abort_signal) = AgentSessionHandle::new_abort_handle();
let message = run_async(
handle.prompt_with_abort("hello", abort_signal, move |event| {
let name = match &event {
AgentEvent::AutoRetryStart { .. } => Some("auto_retry_start"),
AgentEvent::AutoRetryEnd { .. } => Some("auto_retry_end"),
_ => None,
};
if let Some(name) = name {
recorder
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(name.to_string());
}
}),
)
.expect("the retried turn must complete");
assert_eq!(
message.stop_reason,
crate::model::StopReason::Stop,
"the turn must finish on the retry, not surface the 503"
);
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
2,
"one failed attempt plus one retry"
);
let events = seen
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
let start = events.iter().position(|name| name == "auto_retry_start");
let end = events.iter().position(|name| name == "auto_retry_end");
assert!(
start.is_some() && end.is_some() && start < end,
"the host must see the retry, in order: {events:?}"
);
}
#[test]
fn without_a_policy_a_transient_failure_is_returned_unchanged() {
let (mut handle, calls) = flaky_handle(1);
let (_abort_handle, abort_signal) = AgentSessionHandle::new_abort_handle();
let message = run_async(handle.prompt_with_abort("hello", abort_signal, |_| {}))
.expect("the turn returns its errored message rather than failing the call");
assert_eq!(
message.stop_reason,
crate::model::StopReason::Error,
"no policy means no retry"
);
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
1,
"exactly one provider call"
);
}
#[test]
fn a_configured_chain_is_walked_once_the_retry_budget_is_spent() {
let (handle, calls) = flaky_handle(usize::MAX);
let mut handle = with_chain(
handle.with_retry(Some(crate::failover::RetryPolicy {
max_retries: 1,
max_failovers_per_turn: 1,
base_delay_ms: 1,
max_delay_ms: 1,
})),
"openai/gpt-4o-mini",
);
let seen = Arc::new(Mutex::new(Vec::<String>::new()));
let recorder = Arc::clone(&seen);
let (_abort_handle, abort_signal) = AgentSessionHandle::new_abort_handle();
let _ = run_async(
handle.prompt_with_abort("hello", abort_signal, move |event| {
let name = match &event {
AgentEvent::AutoRetryStart { attempt, .. } => {
Some(format!("auto_retry_start:{attempt}"))
}
AgentEvent::AutoRetryEnd { attempt, .. } => {
Some(format!("auto_retry_end:{attempt}"))
}
AgentEvent::FailoverStart { to_provider, .. } => {
Some(format!("failover_start:{to_provider}"))
}
AgentEvent::FailoverEnd { .. } => Some("failover_end".to_string()),
_ => None,
};
if let Some(name) = name {
recorder
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(name);
}
}),
);
let events = seen
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
assert_eq!(
events,
vec![
"auto_retry_start:1".to_string(),
"auto_retry_end:1".to_string(),
"failover_start:openai".to_string(),
"failover_end".to_string()
],
"every Start must be closed before the next opens, in this exact \
order: {events:?}"
);
assert!(
calls.load(std::sync::atomic::Ordering::SeqCst) >= 2,
"the failing provider was called at least once before the swap"
);
}
#[test]
fn a_continuation_persists_its_turn_to_the_reopened_session() {
let dir = tempdir().expect("tempdir");
let mut handle = saving_handle(dir.path());
let message = run_async(handle.continue_turn(|_| {})).expect("continuation completes");
assert_eq!(
message.stop_reason,
crate::model::StopReason::Stop,
"the provider double completes on the first attempt"
);
let path = run_async(async {
let store = handle.session_store();
let cx = crate::agent_cx::AgentCx::for_request();
let inner = store.lock(cx.cx()).await.expect("session lock");
inner.path.clone()
})
.expect("a persisted continuation gives the session a path on disk");
let reopened = run_async(crate::session::Session::open(&path.display().to_string()))
.expect("reopen the session file");
assert!(
!reopened.to_messages_for_current_path().is_empty(),
"a continuation must write its turn to the session file; the raw \
Agent loop persists nothing (bd-9o9i2)"
);
}
#[test]
fn fresh_stream_state_rebinds_the_stream_and_records_the_reset_on_disk() {
let dir = tempdir().expect("tempdir");
let mut handle = saving_handle(dir.path());
let before = handle.session.agent.stream_options().session_id.clone();
let first = run_async(handle.fresh_stream_state()).expect("fresh");
let second = run_async(handle.fresh_stream_state()).expect("fresh again");
assert_ne!(first, second, "each /fresh gets its own id");
assert_ne!(before.as_deref(), Some(second.as_str()));
assert_eq!(
handle.session.agent.stream_options().session_id.as_deref(),
Some(second.as_str()),
"the live stream options carry the newest id"
);
assert!(
handle.session.agent.messages().is_empty(),
"no transcript change"
);
let path = run_async(async {
let store = handle.session_store();
let cx = crate::agent_cx::AgentCx::for_request();
let inner = store.lock(cx.cx()).await.expect("session lock");
inner.path.clone()
})
.expect("the reset is persisted");
let reopened = run_async(crate::session::Session::open(&path.display().to_string()))
.expect("reopen the session file");
let recorded: Vec<String> = reopened
.entries
.iter()
.filter_map(|entry| match entry {
crate::session::SessionEntry::Custom(custom) if custom.custom_type == "fresh" => {
custom
.data
.as_ref()
.and_then(|data| data["newSessionId"].as_str())
.map(str::to_string)
}
_ => None,
})
.collect();
assert_eq!(recorded, vec![first, second]);
}
#[test]
fn shake_compacts_without_a_provider_request() {
let dir = tempdir().expect("tempdir");
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let provider = Arc::new(FlakyThenOkProvider {
failures: 0,
calls: Arc::clone(&calls),
name: "test-provider".to_string(),
model: "test-model".to_string(),
input_tokens: 0,
});
let agent = crate::agent::Agent::new(
provider,
crate::tools::ToolRegistry::new(&[], Path::new("."), None),
crate::agent::AgentConfig::default(),
);
let settings = crate::compaction::ResolvedCompactionSettings {
enabled: true,
context_window_tokens: 2_000,
reserve_tokens: 1_000,
keep_recent_tokens: 1,
..Default::default()
};
let session = AgentSession::new(
agent,
Arc::new(AsyncMutex::new(crate::session::Session::create_with_dir(
Some(dir.path().to_path_buf()),
))),
true,
settings,
);
let mut handle =
AgentSessionHandle::from_session_with_listeners(session, EventListeners::default());
let bulky = "history ".repeat(200);
run_async(async {
let store = handle.session_store();
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = store.lock(cx.cx()).await.expect("session lock");
for i in 0..4 {
session.append_message(crate::session::SessionMessage::User {
content: crate::model::UserContent::Text(format!("user-{i} {bulky}")),
timestamp: Some(i),
});
session.append_message(crate::session::SessionMessage::Assistant {
message: crate::model::AssistantMessage {
content: vec![crate::model::ContentBlock::Text(
crate::model::TextContent::new(format!("assistant-{i} {bulky}")),
)],
..Default::default()
},
});
}
});
run_async(handle.shake(|_| {})).expect("shake");
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
0,
"shake must not reach the provider"
);
let mode = run_async(handle.with_session(|session| {
session.entries.iter().find_map(|entry| match entry {
crate::session::SessionEntry::Compaction(compaction) => compaction
.details
.as_ref()
.and_then(|details| details["mode"].as_str().map(str::to_string)),
_ => None,
})
}))
.expect("session");
assert_eq!(mode.as_deref(), Some("shake"));
}
#[test]
fn rewind_collapses_the_span_after_a_checkpoint() {
let dir = tempdir().expect("tempdir");
let mut handle = saving_handle(dir.path());
let no_checkpoint = run_async(handle.rewind_to_checkpoint(None)).unwrap_err();
assert!(no_checkpoint.to_string().contains("No checkpoints yet"));
let checkpoint = run_async(handle.mark_checkpoint("start", None)).expect("mark");
assert_eq!(
(checkpoint.name.as_str(), checkpoint.message_count),
("start", 0)
);
let nothing = run_async(handle.rewind_to_checkpoint(None)).unwrap_err();
assert!(
nothing.to_string().contains("Nothing to rewind"),
"{nothing}"
);
let (_abort, signal) = AgentSessionHandle::new_abort_handle();
run_async(handle.prompt_with_abort("explore something", signal, |_| {})).expect("turn");
let turn_len = handle.session.agent.messages().len();
assert!(turn_len >= 2, "user + assistant");
let wrong = run_async(handle.rewind_to_checkpoint(Some("nope"))).unwrap_err();
assert!(wrong.to_string().contains("No checkpoint named 'nope'"));
handle
.session
.agent
.set_provider(Arc::new(RewindPrivacyProvider {
requests: Arc::new(Mutex::new(Vec::new())),
}));
let outcome = run_async(handle.rewind_to_checkpoint(Some("start"))).expect("rewind");
assert_eq!(outcome.collapsed_messages, turn_len);
assert!(
handle.session.agent.messages().len() <= 1,
"at most the rewind report remains in context"
);
let path = run_async(async {
let store = handle.session_store();
let cx = crate::agent_cx::AgentCx::for_request();
let inner = store.lock(cx.cx()).await.expect("session lock");
inner.path.clone()
})
.expect("persisted");
let reopened = run_async(crate::session::Session::open(&path.display().to_string()))
.expect("reopen the session file");
assert!(reopened.entries.iter().any(|entry| matches!(
entry,
crate::session::SessionEntry::Custom(custom) if custom.custom_type == "rewind"
)));
}
#[test]
#[allow(clippy::too_many_lines)] fn checkpoint_save_failures_preserve_live_state_and_fence_every_rewind() {
for operation in ["mark", "checkpoint", "branch"] {
let dir = tempdir().expect("tempdir");
let mut handle = saving_handle(dir.path());
run_async(async {
handle
.prompt("keep this turn", |_| {})
.await
.expect("first turn");
handle
.mark_checkpoint("kept", None)
.await
.expect("checkpoint");
handle
.prompt("rewind this turn", |_| {})
.await
.expect("second turn");
});
let requests = Arc::new(Mutex::new(Vec::new()));
handle
.session
.agent
.set_provider(Arc::new(RewindPrivacyProvider {
requests: Arc::clone(&requests),
}));
let store = handle.session_store();
let (original_path, target_id) = {
let guard = store.try_lock().expect("session");
let target_id = guard
.entries
.iter()
.rev()
.find_map(|entry| match entry {
crate::session::SessionEntry::Message(entry)
if matches!(
entry.message,
crate::session::SessionMessage::User { .. }
) =>
{
entry.base.id.clone()
}
_ => None,
})
.expect("latest user entry");
(guard.path.clone().expect("saved path"), target_id)
};
let original_bytes = std::fs::read(&original_path).expect("session bytes");
let before_agent = serde_json::to_value(handle.session.agent.messages()).unwrap();
let blocked_path = dir.path().join("directory-not-session.jsonl");
std::fs::create_dir(&blocked_path).expect("block candidate save");
let before_session = {
let mut guard = store.try_lock().expect("session");
guard.path = Some(blocked_path);
SessionEditSnapshot::capture(&guard).expect("snapshot")
};
let result = run_async(async {
match operation {
"mark" => handle
.mark_checkpoint("must-not-appear", None)
.await
.map(|_| ()),
"checkpoint" => handle.rewind_to_checkpoint(Some("kept")).await.map(|_| ()),
"branch" => handle.rewind_to_user_message(&target_id).await.map(|_| ()),
_ => unreachable!(),
}
});
assert!(
result.as_ref().is_err_and(Error::is_session_persistence),
"{operation}"
);
assert_eq!(
serde_json::to_value(handle.session.agent.messages()).unwrap(),
before_agent
);
{
let mut guard = store.try_lock().expect("live session");
assert!(SessionEditSnapshot::capture(&guard).unwrap() == before_session);
guard.path = Some(original_path.clone());
}
let calls_before = requests.lock().unwrap().len();
run_async(async {
assert!(
handle
.mark_checkpoint("refused", None)
.await
.as_ref()
.is_err_and(Error::is_session_persistence)
);
assert!(
handle
.rewind_to_checkpoint(Some("kept"))
.await
.as_ref()
.is_err_and(Error::is_session_persistence)
);
assert!(
handle
.rewind_to_user_message(&target_id)
.await
.as_ref()
.is_err_and(Error::is_session_persistence)
);
assert!(
handle
.prompt("refused", |_| {})
.await
.as_ref()
.is_err_and(Error::is_session_persistence)
);
});
assert_eq!(
requests.lock().unwrap().len(),
calls_before,
"quarantine must prevent summary requests too"
);
assert_eq!(std::fs::read(&original_path).unwrap(), original_bytes);
}
}
#[test]
#[allow(clippy::too_many_lines)] fn first_turn_rewind_and_retry_keep_root_selected_after_save_and_reopen() {
for store_kind in [
crate::session::SessionStoreKind::Jsonl,
#[cfg(feature = "sqlite-sessions")]
crate::session::SessionStoreKind::Sqlite,
] {
for operation in ["branch", "retry", "native-retry"] {
let dir = tempdir().expect("tempdir");
let mut handle = saving_handle_with_store(dir.path(), store_kind);
let store = handle.session_store();
let (first_user, path) = run_async(async {
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = store.lock(cx.cx()).await.expect("session");
let content = if operation == "native-retry" {
UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(
"original first turn",
))])
} else {
UserContent::Text("original first turn".to_string())
};
let first_user = session.append_message(crate::session::SessionMessage::User {
content,
timestamp: Some(0),
});
session.append_message(crate::session::SessionMessage::Assistant {
message: AssistantMessage {
content: vec![ContentBlock::Text(TextContent::new("original answer"))],
stop_reason: StopReason::Stop,
..Default::default()
},
});
session.save().await.expect("original session");
handle
.session
.agent
.replace_messages(session.to_messages_for_current_path());
(first_user, session.path.clone().expect("saved path"))
});
assert_eq!(handle.session.agent.messages().len(), 2);
run_async(async {
match operation {
"branch" => {
let prepared = handle
.rewind_to_user_message(&first_user)
.await
.expect("first-user rewind");
assert_eq!(prepared.text, "original first turn");
}
"retry" => {
assert_eq!(
handle.prepare_retry().await.expect("only-turn retry"),
"original first turn"
);
}
"native-retry" => {
let prepared =
handle.prepare_retry_content().await.expect("native retry");
assert!(matches!(prepared, UserContent::Blocks(_)));
}
_ => unreachable!(),
}
});
assert!(
handle.session.agent.messages().is_empty(),
"{operation}: live context"
);
{
let session = store.try_lock().expect("selected root");
assert!(session.leaf_id().is_none());
assert_eq!(session.header.current_leaf.as_deref(), Some(""));
assert!(session.to_messages_for_current_path().is_empty());
assert_eq!(session.entries.len(), 2, "history remains in the tree");
}
let reopened =
run_async(Session::open(&path.display().to_string())).expect("root reopen");
assert!(reopened.leaf_id().is_none(), "{operation}: reopened root");
assert!(reopened.to_messages_for_current_path().is_empty());
assert_eq!(reopened.entries.len(), 2);
run_async(handle.prompt("replacement first turn", |_| {})).expect("sibling turn");
let live_context = checkpoint_context_value(handle.session.agent.messages());
assert!(!live_context.to_string().contains("original first turn"));
let reopened =
run_async(Session::open(&path.display().to_string())).expect("sibling reopen");
assert_eq!(
checkpoint_context_value(&reopened.to_messages_for_current_path()),
live_context
);
assert!(
reopened.get_entry(&first_user).is_some(),
"abandoned branch survives"
);
}
}
}
#[test]
fn failed_checkpoint_summary_keeps_the_span_available_for_retry() {
let dir = tempdir().expect("tempdir");
let mut handle = saving_handle(dir.path());
run_async(async {
handle
.mark_checkpoint("start", None)
.await
.expect("checkpoint");
handle
.prompt("valuable work must remain visible", |_| {})
.await
.expect("turn");
});
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
handle
.session
.agent
.set_provider(Arc::new(FlakyThenOkProvider {
failures: usize::MAX,
calls: Arc::clone(&calls),
name: "test-provider".to_string(),
model: "test-model".to_string(),
input_tokens: 0,
}));
let store = handle.session_store();
let before_session = SessionEditSnapshot::capture(&store.try_lock().unwrap()).unwrap();
let before_agent = serde_json::to_value(handle.session.agent.messages()).unwrap();
let path = store.try_lock().unwrap().path.clone().unwrap();
let before_file = std::fs::read(&path).unwrap();
let error =
run_async(handle.rewind_to_checkpoint(Some("start"))).expect_err("failed summary");
assert!(error.to_string().contains("503"));
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert_eq!(
serde_json::to_value(handle.session.agent.messages()).unwrap(),
before_agent
);
assert!(
SessionEditSnapshot::capture(&store.try_lock().unwrap()).unwrap() == before_session
);
assert_eq!(std::fs::read(&path).unwrap(), before_file);
assert!(handle.session.ensure_provider_reentry_allowed().is_ok());
handle
.session
.agent
.set_provider(Arc::new(RewindPrivacyProvider {
requests: Arc::new(Mutex::new(Vec::new())),
}));
let outcome = run_async(handle.rewind_to_checkpoint(Some("start"))).expect("retry summary");
assert_eq!(outcome.collapsed_messages, 2);
assert_eq!(handle.session.agent.messages().len(), 1);
}
fn checkpoint_context_value(messages: &[Message]) -> Value {
let mut value = serde_json::to_value(messages).expect("messages");
for message in value.as_array_mut().expect("message array") {
message
.as_object_mut()
.expect("message object")
.remove("timestamp");
}
value
}
#[test]
#[allow(clippy::significant_drop_tightening)]
fn checkpoint_rewind_uses_compacted_boundary_and_reopens_identically() {
let dir = tempdir().expect("tempdir");
let mut handle = saving_handle(dir.path());
run_async(async {
for index in 0..4 {
handle
.prompt(format!("retained history {index}"), |_| {})
.await
.expect("history turn");
}
let checkpoint = handle
.mark_checkpoint("before-experiment", None)
.await
.expect("checkpoint");
assert_eq!(checkpoint.message_count, 8);
handle
.prompt("experiment to summarize", |_| {})
.await
.expect("experiment turn");
let store = handle.session_store();
let cx = crate::agent_cx::AgentCx::for_request();
let mut guard = store.lock(cx.cx()).await.expect("session");
let retained_user = guard
.entries
.iter()
.filter_map(|entry| match entry {
crate::session::SessionEntry::Message(entry)
if matches!(entry.message, crate::session::SessionMessage::User { .. }) =>
{
entry.base.id.clone()
}
_ => None,
})
.nth(3)
.expect("last history turn");
guard.append_compaction(
"earlier history summary".to_string(),
retained_user,
10_000,
None,
None,
);
guard.save().await.expect("persist compaction");
handle
.session
.agent
.replace_messages(guard.to_messages_for_current_path());
});
assert_eq!(
handle.session.agent.messages().len(),
5,
"compaction renumbers the checkpoint boundary"
);
let requests = Arc::new(Mutex::new(Vec::new()));
handle
.session
.agent
.set_provider(Arc::new(RewindPrivacyProvider {
requests: Arc::clone(&requests),
}));
let store = handle.session_store();
let entries_before = store.try_lock().unwrap().entries.len();
let outcome = run_async(handle.rewind_to_checkpoint(Some("before-experiment")))
.expect("rewind after compaction");
assert_eq!(outcome.collapsed_messages, 2);
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert!(requests[0].contains("experiment to summarize"));
assert!(
!requests[0].contains("retained history 3"),
"kept context must not enter the collapsed span"
);
let live_context = checkpoint_context_value(handle.session.agent.messages());
let (path, entries_after) = {
let guard = store.try_lock().unwrap();
assert_eq!(
checkpoint_context_value(&guard.to_messages_for_current_path()),
live_context
);
(guard.path.clone().unwrap(), guard.entries.len())
};
assert_eq!(
entries_after,
entries_before + 1,
"append the report without removing history"
);
let reopened =
run_async(Session::open(&path.display().to_string())).expect("reopen rewind");
assert_eq!(
checkpoint_context_value(&reopened.to_messages_for_current_path()),
live_context
);
assert_eq!(reopened.entries.len(), entries_after);
assert_eq!(
live_context.as_array().unwrap().len(),
4,
"summary, retained turn and rewind report"
);
}
#[test]
fn checkpoint_hidden_by_compaction_or_rewind_is_refused_before_summary() {
for compacted in [false, true] {
let dir = tempdir().expect("tempdir");
let mut handle = saving_handle(dir.path());
run_async(async {
handle
.mark_checkpoint("outer", None)
.await
.expect("outer checkpoint");
handle
.prompt("first span", |_| {})
.await
.expect("first turn");
handle
.mark_checkpoint("inner", None)
.await
.expect("inner checkpoint");
handle
.prompt("second span", |_| {})
.await
.expect("second turn");
});
let requests = Arc::new(Mutex::new(Vec::new()));
handle
.session
.agent
.set_provider(Arc::new(RewindPrivacyProvider {
requests: Arc::clone(&requests),
}));
let store = handle.session_store();
run_async(async {
if compacted {
let cx = crate::agent_cx::AgentCx::for_request();
let mut guard = store.lock(cx.cx()).await.expect("session");
let retained_user = guard
.entries
.iter()
.rev()
.find_map(|entry| match entry {
crate::session::SessionEntry::Message(entry)
if matches!(
entry.message,
crate::session::SessionMessage::User { .. }
) =>
{
entry.base.id.clone()
}
_ => None,
})
.expect("last user entry");
guard.append_compaction(
"old context".to_string(),
retained_user,
10_000,
None,
None,
);
guard.save().await.expect("save compaction");
handle
.session
.agent
.replace_messages(guard.to_messages_for_current_path());
} else {
handle
.rewind_to_checkpoint(Some("outer"))
.await
.expect("outer rewind");
}
});
let before_agent = checkpoint_context_value(handle.session.agent.messages());
let before_session = SessionEditSnapshot::capture(&store.try_lock().unwrap()).unwrap();
let calls_before = requests.lock().unwrap().len();
let error = run_async(handle.rewind_to_checkpoint(Some("inner")))
.expect_err("hidden checkpoint");
assert!(
error
.to_string()
.contains("no longer in the active context"),
"{error}"
);
assert_eq!(requests.lock().unwrap().len(), calls_before);
assert_eq!(
checkpoint_context_value(handle.session.agent.messages()),
before_agent
);
assert!(
SessionEditSnapshot::capture(&store.try_lock().unwrap()).unwrap() == before_session
);
assert!(handle.session.ensure_provider_reentry_allowed().is_ok());
}
}
#[test]
fn checkpoint_rewind_does_not_overwrite_provider_callback_changes() {
for action in [RewindCallbackAction::Append, RewindCallbackAction::Header] {
let dir = tempdir().expect("tempdir");
let mut handle = saving_handle(dir.path());
run_async(async {
handle
.mark_checkpoint("start", None)
.await
.expect("checkpoint");
handle.prompt("source context", |_| {}).await.expect("turn");
});
let store = handle.session_store();
let before_agent = serde_json::to_value(handle.session.agent.messages()).unwrap();
let path = store.try_lock().unwrap().path.clone().unwrap();
let before_file = std::fs::read(&path).unwrap();
let after_callback = Arc::new(Mutex::new(None));
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let actions = handle.session.session_action_admission_gate();
handle
.session
.agent
.set_provider(Arc::new(RewindCallbackProvider {
delegate: RewindPrivacyProvider {
requests: Arc::new(Mutex::new(Vec::new())),
},
store: Arc::clone(&store),
actions,
action,
after_callback: Arc::clone(&after_callback),
calls: Arc::clone(&calls),
}));
let error =
run_async(handle.rewind_to_checkpoint(Some("start"))).expect_err("stale summary");
assert!(
error.to_string().contains("Session changed while"),
"{error}"
);
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert_eq!(
serde_json::to_value(handle.session.agent.messages()).unwrap(),
before_agent
);
let live = SessionEditSnapshot::capture(&store.try_lock().unwrap()).unwrap();
assert!(
after_callback.lock().unwrap().as_ref() == Some(&live),
"callback changes must survive the refused rewind"
);
assert_eq!(std::fs::read(&path).unwrap(), before_file);
assert!(handle.session.ensure_provider_reentry_allowed().is_ok());
}
}
#[test]
fn dropping_checkpoint_summary_preserves_state_without_quarantining() {
let dir = tempdir().expect("tempdir");
let mut handle = saving_handle(dir.path());
run_async(async {
handle
.mark_checkpoint("start", None)
.await
.expect("checkpoint");
handle
.prompt("context before cancelled summary", |_| {})
.await
.expect("turn");
});
let store = handle.session_store();
let before_session = SessionEditSnapshot::capture(&store.try_lock().unwrap()).unwrap();
let before_agent = serde_json::to_value(handle.session.agent.messages()).unwrap();
let path = store.try_lock().unwrap().path.clone().unwrap();
let before_file = std::fs::read(&path).unwrap();
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let actions = handle.session.session_action_admission_gate();
handle
.session
.agent
.set_provider(Arc::new(RewindCallbackProvider {
delegate: RewindPrivacyProvider {
requests: Arc::new(Mutex::new(Vec::new())),
},
store: Arc::clone(&store),
actions,
action: RewindCallbackAction::Pending,
after_callback: Arc::new(Mutex::new(None)),
calls: Arc::clone(&calls),
}));
run_async(async {
let mut rewind = Box::pin(handle.rewind_to_checkpoint(Some("start")));
assert!(futures::poll!(rewind.as_mut()).is_pending());
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"drop during the provider request"
);
drop(rewind);
});
assert!(handle.session.ensure_provider_reentry_allowed().is_ok());
assert_eq!(
serde_json::to_value(handle.session.agent.messages()).unwrap(),
before_agent
);
assert!(
SessionEditSnapshot::capture(&store.try_lock().unwrap()).unwrap() == before_session
);
assert_eq!(std::fs::read(&path).unwrap(), before_file);
handle
.session
.agent
.set_provider(Arc::new(RewindPrivacyProvider {
requests: Arc::new(Mutex::new(Vec::new())),
}));
let result =
run_async(handle.rewind_to_checkpoint(Some("start"))).expect("retry after drop");
assert_eq!(result.collapsed_messages, 2);
}
#[allow(clippy::significant_drop_tightening)]
fn assert_rewind_privacy(mode: &str) {
let requests = Arc::new(Mutex::new(Vec::new()));
let provider = Arc::new(RewindPrivacyProvider {
requests: Arc::clone(&requests),
});
let mut agent = crate::agent::Agent::new(
provider,
crate::tools::ToolRegistry::without_builtins(None),
crate::agent::AgentConfig {
secrets: Some(crate::secrets::SecretsSettings {
mode: Some(mode.to_string()),
extra_patterns: Some(vec!["ACME-[0-9]{6}".to_string()]),
}),
..Default::default()
},
);
let learned = "historicalValue482610";
if mode == "obfuscate" {
let masked =
agent .secrets_transform_outbound_text(&format!("API_KEY={learned}"))
.expect("learn earlier credential");
assert!(!masked.contains(learned)); }
let session = AgentSession::new(
agent,
Arc::new(AsyncMutex::new(crate::session::Session::in_memory())),
false,
crate::compaction::ResolvedCompactionSettings::default(),
);
let mut handle =
AgentSessionHandle::from_session_with_listeners(session, EventListeners::default());
let raw = format!("Explore customer ACME-123456 and prior reference {learned}.");
let message = crate::model::Message::User(crate::model::UserMessage {
content: crate::model::UserContent::Text(raw.clone()),
timestamp: 0,
});
let outcome = run_async(async {
handle .mark_checkpoint("start", None)
.await
.expect("mark checkpoint");
handle.session.agent.add_message(message.clone());
let store = handle.session_store();
let cx = crate::agent_cx::AgentCx::for_request();
{
let mut stored = store.lock(cx.cx()).await.expect("session lock"); stored.append_message(crate::session::SessionMessage::from(message));
}
let before_agent = serde_json::to_value(handle.session.agent.messages()).unwrap();
let before_session = {
let stored = store.lock(cx.cx()).await.expect("session snapshot");
SessionEditSnapshot::capture(&stored).expect("snapshot")
};
let outcome = handle.rewind_to_checkpoint(Some("start")).await;
let stored = store.lock(cx.cx()).await.expect("session lock"); assert!(stored.entries.iter().any(
|entry| matches!( entry,
crate::session::SessionEntry::Message(entry)
if matches!(&entry.message,
crate::session::SessionMessage::User {
content: crate::model::UserContent::Text(text), ..
} if text == &raw)
)
));
if mode == "block" {
assert_eq!(
serde_json::to_value(handle.session.agent.messages()).unwrap(),
before_agent,
"privacy refusal must retain the active span"
);
assert!(
SessionEditSnapshot::capture(&stored).unwrap() == before_session,
"privacy refusal must not append a replacement report"
);
}
outcome
});
let requests = requests.lock().expect("recorded provider requests"); if mode == "block" {
assert!(requests.is_empty()); assert!(outcome.unwrap_err().to_string().contains("PI_SECRET_BLOCK")); } else {
assert_eq!(requests.len(), 1); let request = requests.first().expect("summarization request"); assert!(request.contains("Explore customer")); assert!(!request.contains("ACME-123456")); assert!(!request.contains(learned)); assert!(!request.contains("<pi-secret:000")); assert!(
outcome
.expect("rewind")
.summary
.contains("privacy-aware rewind report")
); }
}
#[test]
fn rewind_screens_configured_patterns_and_live_vault_secrets() {
assert_rewind_privacy("obfuscate");
}
#[test]
fn rewind_block_mode_refuses_provider_dispatch_and_preserves_history() {
assert_rewind_privacy("block");
}
#[test]
fn prepare_retry_branches_the_last_turn_and_the_file_keeps_both() {
let dir = tempdir().expect("tempdir");
let mut handle = saving_handle(dir.path());
assert!(
run_async(handle.prepare_retry()).is_err(),
"nothing to retry yet"
);
let (_abort, signal) = AgentSessionHandle::new_abort_handle();
run_async(handle.prompt_with_abort("hello", signal, |_| {})).expect("first turn");
let text = run_async(handle.prepare_retry()).expect("retry plan");
assert_eq!(text, "hello");
assert!(
handle.session.agent.messages().is_empty(),
"the agent's context no longer holds the abandoned turn"
);
let (_abort, signal) = AgentSessionHandle::new_abort_handle();
run_async(handle.prompt_with_abort(text, signal, |_| {})).expect("retried turn");
let path = run_async(async {
let store = handle.session_store();
let cx = crate::agent_cx::AgentCx::for_request();
let inner = store.lock(cx.cx()).await.expect("session lock");
inner.path.clone()
})
.expect("persisted");
let reopened = run_async(crate::session::Session::open(&path.display().to_string()))
.expect("reopen the session file");
let users_in_file = reopened
.entries
.iter()
.filter(|entry| {
matches!(
entry,
crate::session::SessionEntry::Message(message)
if matches!(message.message, crate::session::SessionMessage::User { .. })
)
})
.count();
assert_eq!(users_in_file, 2, "the abandoned turn stays in the tree");
let users_on_path = reopened
.to_messages_for_current_path()
.iter()
.filter(|message| matches!(message, crate::model::Message::User(_)))
.count();
assert_eq!(users_on_path, 1, "the active path holds only the retry");
}
#[test]
fn prepare_retry_save_failure_preserves_live_context_and_fences_reentry() {
for native in [false, true] {
let dir = tempdir().expect("tempdir");
let mut handle = saving_handle(dir.path());
let content = vec![ContentBlock::Media(MediaContent {
data: "YQ==".to_string(),
mime_type: "audio/wav".to_string(),
name: Some("voice.wav".to_string()),
})];
run_async(async {
if native {
handle.prompt_with_content(content.clone(), |_| {}).await
} else {
handle.prompt("original turn", |_| {}).await
}
})
.expect("original turn");
let before_agent = serde_json::to_value(handle.session.agent.messages()).unwrap();
let store = handle.session_store();
let (original_path, original_leaf, original_entries) = {
let guard = store.try_lock().expect("original session");
(
guard.path.clone().expect("saved path"),
guard.leaf_id().map(str::to_string),
serde_json::to_value(&guard.entries).unwrap(),
)
};
let original_file = std::fs::read(&original_path).expect("saved session bytes");
let blocked = dir.path().join("directory-not-session.jsonl");
std::fs::create_dir(&blocked).expect("block persistence");
store.try_lock().unwrap().path = Some(blocked);
let result = run_async(async {
if native {
handle.prepare_retry_content().await
} else {
handle.prepare_retry().await.map(UserContent::Text)
}
});
assert!(result.as_ref().is_err_and(Error::is_session_persistence));
assert_eq!(
serde_json::to_value(handle.session.agent.messages()).unwrap(),
before_agent,
"failed retry persistence must not rewind the live agent"
);
{
let mut guard = store.try_lock().expect("unchanged live session");
assert_eq!(guard.leaf_id(), original_leaf.as_deref());
assert_eq!(
serde_json::to_value(&guard.entries).unwrap(),
original_entries
);
guard.path = Some(original_path.clone());
}
let blocked_prompt = run_async(handle.prompt_with_content(content, |_| {
panic!("quarantined retry must not begin another provider turn");
}));
assert!(
blocked_prompt
.as_ref()
.is_err_and(Error::is_session_persistence),
"repairing the path alone must not clear uncertain transition state"
);
assert_eq!(
std::fs::read(&original_path).unwrap(),
original_file,
"failed preparation and refused reentry must leave the durable branch intact"
);
}
}
#[test]
fn prepare_retry_content_refuses_invalid_latest_input_without_rewinding() {
let (mut handle, calls) = flaky_handle(0);
let store = handle.session_store();
let leaf = {
let mut guard = store.try_lock().expect("session");
guard.append_message(crate::session::SessionMessage::User {
content: UserContent::Text("older valid input".to_string()),
timestamp: Some(0),
});
let leaf = guard.append_message(crate::session::SessionMessage::User {
content: UserContent::Blocks(Vec::new()),
timestamp: Some(0),
});
handle
.session
.agent
.replace_messages(guard.to_messages_for_current_path());
leaf
};
let before = serde_json::to_value(handle.session.agent.messages()).unwrap();
let error = run_async(handle.prepare_retry_content()).expect_err("invalid native input");
assert!(error.to_string().contains("PI_INPUT_CONTENT"));
assert_eq!(store.try_lock().unwrap().leaf_id(), Some(leaf.as_str()));
assert_eq!(
serde_json::to_value(handle.session.agent.messages()).unwrap(),
before
);
assert!(
handle
.session
.provider_admission_gate()
.ensure_allowed()
.is_ok()
);
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
}
#[test]
fn prepare_retry_waits_for_provider_before_session_locks_and_cancels_cleanly() {
let (mut handle, calls) = flaky_handle(0);
let store = handle.session_store();
let original =
store
.try_lock()
.unwrap()
.append_message(crate::session::SessionMessage::User {
content: UserContent::Text("original input".to_string()),
timestamp: Some(0),
});
let provider_gate = handle.session.provider_admission_gate();
let action_gate = handle.session.session_action_admission_gate();
run_async(async {
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let provider_call = provider_gate.acquire(cx.cx()).await.unwrap();
let mut retry = Box::pin(handle.prepare_retry_content());
assert!(futures::poll!(retry.as_mut()).is_pending());
let mut callback_action = Box::pin(action_gate.acquire(cx.cx()));
let std::task::Poll::Ready(Ok(action_permit)) =
futures::poll!(callback_action.as_mut())
else {
panic!("provider callbacks must retain access to Session actions");
};
let callback_entry = store
.try_lock()
.expect("waiting retry must not hold the Session store")
.append_message(crate::session::SessionMessage::User {
content: UserContent::Text("input from provider callback".to_string()),
timestamp: Some(0),
});
drop(action_permit);
drop(retry);
provider_gate.ensure_allowed().unwrap();
assert_eq!(
store.try_lock().unwrap().leaf_id(),
Some(callback_entry.as_str())
);
drop(provider_call);
let action_permit = action_gate.acquire(cx.cx()).await.unwrap();
let mut retry = Box::pin(handle.prepare_retry_content());
assert!(futures::poll!(retry.as_mut()).is_pending());
assert!(store.try_lock().is_ok());
provider_gate.ensure_allowed().unwrap();
drop(retry);
drop(action_permit);
let prepared = asupersync::time::timeout(
asupersync::time::wall_now(),
std::time::Duration::from_secs(5),
handle.prepare_retry_content(),
)
.await
.expect("cancelled preparation must release every admission permit")
.expect("retry after the callback finishes");
assert!(matches!(
prepared,
UserContent::Text(text) if text == "input from provider callback"
));
assert_eq!(store.try_lock().unwrap().leaf_id(), Some(original.as_str()));
provider_gate.ensure_allowed().unwrap();
});
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
}
#[test]
fn a_continuation_cannot_issue_against_a_quarantined_provider() {
let (handle, calls) = flaky_handle(0);
let mut handle = handle;
handle
.session
.provider_admission_gate()
.block("quarantined for this test".to_string());
let result = run_async(handle.continue_turn(|_| {}));
assert!(
result.is_err(),
"a quarantined provider must refuse the continuation"
);
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
0,
"the provider must never be reached at all"
);
}
#[test]
fn the_captured_primary_is_restored_once_its_cooldown_expires() {
let mut handle = handle_after_one_failover(0);
let captured = handle
.failover_state
.primary()
.cloned()
.expect("a swap records the primary it left");
assert_eq!(captured.model_id, "claude-3-5-haiku-latest");
let (seen, shared) = restore_recorder();
run_async(handle.maybe_restore_primary(&shared)).expect("restore primary");
assert_eq!(
handle.session.agent.provider().model_id(),
"claude-3-5-haiku-latest",
"the cooldown expired, so the primary must be live again"
);
assert!(
handle.failover_state.primary().is_none(),
"a restored primary clears the chain state, so a later failure walks the chain from \
the top instead of resuming past entries it never used"
);
let events = seen
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
assert_eq!(
events,
vec![(true, "claude-3-5-haiku-latest".to_string())],
"the host must be told the primary is back, or the model indicator lies: {events:?}"
);
}
#[test]
fn a_live_cooldown_keeps_the_fallback_installed() {
let mut handle = handle_after_one_failover(300);
let (seen, shared) = restore_recorder();
run_async(handle.maybe_restore_primary(&shared)).expect("cooldown check");
assert_eq!(
handle.session.agent.provider().model_id(),
"gpt-4o-mini",
"the cooldown is live, so the fallback must stay installed"
);
assert!(
handle.failover_state.primary().is_some(),
"the primary must stay captured for a later restoration"
);
assert!(
seen.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_empty(),
"nothing was restored, so no restoration must be reported"
);
}
#[test]
fn without_failover_options_no_chain_is_consulted() {
let (handle, _calls) = flaky_handle(usize::MAX);
let mut handle = handle.with_retry(Some(fast_retry_policy(1)));
let seen = Arc::new(Mutex::new(Vec::<String>::new()));
let recorder = Arc::clone(&seen);
let (_abort_handle, abort_signal) = AgentSessionHandle::new_abort_handle();
let _ = run_async(
handle.prompt_with_abort("hello", abort_signal, move |event| {
if matches!(event, AgentEvent::FailoverStart { .. }) {
recorder
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push("failover_start".to_string());
}
}),
);
assert!(
seen.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_empty(),
"no chain configured means no failover"
);
}
#[test]
fn the_retry_budget_bounds_a_provider_that_never_recovers() {
let (handle, calls) = flaky_handle(usize::MAX);
let mut handle = handle.with_retry(Some(fast_retry_policy(2)));
let (_abort_handle, abort_signal) = AgentSessionHandle::new_abort_handle();
let message = run_async(handle.prompt_with_abort("hello", abort_signal, |_| {}))
.expect("the exhausted turn still returns its message");
assert_eq!(message.stop_reason, crate::model::StopReason::Error);
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
3,
"the first attempt plus max_retries, and no more"
);
}
struct CurrentDirGuard {
previous: PathBuf,
}
impl CurrentDirGuard {
fn new(path: &Path) -> Self {
let previous = env::current_dir().expect("current dir");
env::set_current_dir(path).expect("set current dir");
Self { previous }
}
}
impl Drop for CurrentDirGuard {
fn drop(&mut self) {
let _ = env::set_current_dir(&self.previous);
}
}
fn hermetic_session_options(working_directory: &Path) -> SessionOptions {
SessionOptions {
provider: Some("openai".to_string()),
model: Some("gpt-4o".to_string()),
api_key: Some("dummy-key".to_string()),
working_directory: Some(working_directory.to_path_buf()),
no_session: true,
..SessionOptions::default()
}
}
#[test]
fn sdk_config_loader_uses_session_root_and_workspace_trust() {
let tmp = tempdir().expect("tempdir");
let cwd = tmp.path().join("workspace");
let global_dir = tmp.path().join("global");
std::fs::create_dir_all(cwd.join(".pi")).expect("create project config dir");
std::fs::create_dir_all(&global_dir).expect("create global config dir");
std::fs::write(
global_dir.join("settings.json"),
r#"{"defaultThinkingLevel":"low"}"#,
)
.expect("write global settings");
std::fs::write(
cwd.join(".pi/settings.json"),
r#"{"defaultThinkingLevel":"high"}"#,
)
.expect("write project settings");
let untrusted =
load_session_config(&cwd, &global_dir, None, false).expect("load untrusted config");
assert_eq!(untrusted.default_thinking_level.as_deref(), Some("low"));
let trusted =
load_session_config(&cwd, &global_dir, None, true).expect("load trusted config");
assert_eq!(trusted.default_thinking_level.as_deref(), Some("high"));
}
#[test]
fn sessions_apply_the_configured_secrets_settings() {
let session_with = |settings: &str| {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join(".pi")).expect("create project config dir");
std::fs::write(tmp.path().join(".pi/settings.json"), settings)
.expect("write project settings");
let mut options = hermetic_session_options(tmp.path());
options.workspace_trusted = true;
(
tmp,
run_async(create_agent_session(options)).expect("create session"),
)
};
let (_tmp, mut handle) =
session_with(r#"{"secrets":{"extra_patterns":["ACME-[0-9]{6}"]}}"#);
let out = handle
.session_mut()
.agent
.secrets_transform_outbound_text("token ACME-123456 here")
.expect("obfuscate mode");
assert!(!out.contains("ACME-123456"), "user pattern applied: {out}");
let (_tmp, mut handle) =
session_with(r#"{"secrets":{"mode":"block","extra_patterns":["ACME-[0-9]{6}"]}}"#);
assert!(
handle
.session_mut()
.agent
.secrets_transform_outbound_text("token ACME-123456 here")
.is_err(),
"block mode refuses"
);
}
#[test]
fn no_context_files_keeps_agents_md_out_of_the_prompt() {
let prompt_with = |no_context_files: bool| {
let tmp = tempdir().expect("tempdir");
std::fs::write(
tmp.path().join("AGENTS.md"),
"gh216-context-marker: follow the house style",
)
.expect("write AGENTS.md");
let mut options = hermetic_session_options(tmp.path());
options.workspace_trusted = true;
options.no_context_files = no_context_files;
let handle = run_async(create_agent_session(options)).expect("create session");
handle
.session()
.agent
.system_prompt()
.unwrap_or_default()
.to_string()
};
if std::env::var_os("PI_TEST_MODE").is_none() {
assert!(prompt_with(false).contains("gh216-context-marker"));
}
assert!(!prompt_with(true).contains("gh216-context-marker"));
}
#[test]
fn sessions_list_the_host_provided_skills() {
let block =
"\n\n<available_skills>\n <skill><name>demo-skill</name></skill>\n</available_skills>";
let prompt_with = |skills_prompt: Option<String>| {
let tmp = tempdir().expect("tempdir");
let mut options = hermetic_session_options(tmp.path());
options.skills_prompt = skills_prompt;
let handle = run_async(create_agent_session(options)).expect("create session");
handle
.session()
.agent
.system_prompt()
.unwrap_or_default()
.to_string()
};
assert!(prompt_with(Some(block.to_string())).contains("demo-skill"));
assert!(!prompt_with(None).contains("available_skills"));
}
#[test]
fn sessions_use_the_configured_queue_modes() {
let modes_for = |mode: &str| {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join(".pi")).expect("create project config dir");
std::fs::write(
tmp.path().join(".pi/settings.json"),
format!(r#"{{"steeringMode":"{mode}","followUpMode":"{mode}"}}"#),
)
.expect("write project settings");
let mut options = hermetic_session_options(tmp.path());
options.workspace_trusted = true;
let handle = run_async(create_agent_session(options)).expect("create session");
handle.session().agent.queue_modes()
};
assert_eq!(modes_for("all"), (QueueMode::All, QueueMode::All));
assert_eq!(
modes_for("one-at-a-time"),
(QueueMode::OneAtATime, QueueMode::OneAtATime)
);
}
fn observing_extension(dir: &Path) -> PathBuf {
let path = dir.join("observe.mjs");
std::fs::write(
&path,
"export default function (pi) {\n pi.on(\"message_update\", async () => {});\n}\n",
)
.expect("write fixture extension");
path
}
#[test]
fn extensions_without_a_supplied_runtime_get_one_built_for_them() {
let tmp = tempdir().expect("tempdir");
let mut options = hermetic_session_options(tmp.path());
options.extension_paths = vec![observing_extension(tmp.path())];
let handle = run_async(create_agent_session(options)).expect("create session");
assert!(
handle.session.extensions.is_some(),
"the fixture extension must load, or this test is asserting nothing"
);
assert!(
handle.event_runtime.is_some(),
"a session that loads extensions and was given no runtime must build one and own it"
);
assert!(
handle.session.runtime_handle().is_some(),
"the built runtime must be installed on the session, not merely held: the coalescer \
reads it from there"
);
}
#[test]
fn a_supplied_runtime_is_not_replaced_by_a_built_one() {
let tmp = tempdir().expect("tempdir");
let runtime = asupersync::runtime::RuntimeBuilder::new()
.build()
.expect("runtime for handle");
let mut options = hermetic_session_options(tmp.path());
options.extension_paths = vec![observing_extension(tmp.path())];
options.runtime_handle = Some(runtime.handle());
let handle = run_async(create_agent_session(options)).expect("create session");
assert!(
handle.event_runtime.is_none(),
"an embedder that supplied a runtime must not have a second one started behind its \
back: four worker threads appearing unasked is worse than the problem it solves"
);
assert!(
handle.session.runtime_handle().is_some(),
"the supplied handle must still be installed"
);
}
#[test]
fn a_session_without_extensions_builds_no_runtime() {
let tmp = tempdir().expect("tempdir");
let handle = run_async(create_agent_session(hermetic_session_options(tmp.path())))
.expect("create session");
assert!(
handle.session.extensions.is_none(),
"the hermetic options must not load extensions, or this test asserts nothing"
);
assert!(
handle.event_runtime.is_none(),
"a session with nothing to observe must not start worker threads for observation"
);
}
#[test]
fn create_agent_session_with_explicit_test_provider_succeeds() {
let tmp = tempdir().expect("tempdir");
let options = hermetic_session_options(tmp.path());
let handle = run_async(create_agent_session(options)).expect("create session");
let provider = handle.session().agent.provider();
assert!(!provider.name().is_empty());
assert!(!provider.model_id().is_empty());
assert_eq!(handle.model().0, provider.name());
assert_eq!(handle.model().1, provider.model_id());
}
#[test]
fn create_agent_session_resolves_relative_package_dir_against_working_directory() {
let _lock = current_dir_lock();
let process_cwd = tempdir().expect("process cwd");
let sdk_cwd = tempdir().expect("sdk cwd");
let _guard = CurrentDirGuard::new(process_cwd.path());
let pkg_root = sdk_cwd.path().join("relpkgs");
std::fs::create_dir_all(pkg_root.join("docs")).expect("mkdir relpkgs/docs");
std::fs::write(pkg_root.join("README.md"), "# pi\n").expect("write readme");
std::fs::write(pkg_root.join("docs").join("extensions.md"), "#\n")
.expect("write extensions.md");
let handle = run_async(create_agent_session(SessionOptions {
provider: Some("openai".to_string()),
model: Some("gpt-4o".to_string()),
api_key: Some("dummy-key".to_string()),
working_directory: Some(sdk_cwd.path().to_path_buf()),
no_session: true,
package_dir: Some(PathBuf::from("relpkgs")),
..SessionOptions::default()
}))
.expect("create session");
let system_prompt = handle
.session()
.agent
.system_prompt()
.expect("default system prompt present")
.to_string();
assert!(system_prompt.contains("Pi documentation"));
let advertised_readme = pkg_root.join("README.md");
assert!(
system_prompt.contains(&advertised_readme.display().to_string()),
"prompt must advertise the session-root-resolved README: {system_prompt}"
);
assert!(advertised_readme.is_file());
assert!(pkg_root.join("docs").join("extensions.md").is_file());
assert!(system_prompt.contains("extensions (docs/extensions.md)"));
}
#[test]
fn create_agent_session_respects_provider_model_and_clamps_thinking() {
let tmp = tempdir().expect("tempdir");
let options = SessionOptions {
provider: Some("openai".to_string()),
model: Some("gpt-4o".to_string()),
api_key: Some("dummy-key".to_string()),
thinking: Some(crate::model::ThinkingLevel::Low),
working_directory: Some(tmp.path().to_path_buf()),
no_session: true,
..SessionOptions::default()
};
let handle = run_async(create_agent_session(options)).expect("create session");
let provider = handle.session().agent.provider();
assert_eq!(provider.name(), "openai");
assert_eq!(provider.model_id(), "gpt-4o");
assert_eq!(
handle.session().agent.stream_options().thinking_level,
Some(crate::model::ThinkingLevel::Off)
);
}
#[test]
fn create_agent_session_no_session_keeps_ephemeral_state() {
let tmp = tempdir().expect("tempdir");
let options = hermetic_session_options(tmp.path());
let handle = run_async(create_agent_session(options)).expect("create session");
assert!(!handle.session().save_enabled());
let path_is_none = run_async(async {
let cx = crate::agent_cx::AgentCx::for_request();
let guard = handle
.session()
.session
.lock(cx.cx())
.await
.expect("lock session");
guard.path.is_none()
});
assert!(path_is_none);
}
#[test]
fn create_agent_session_uses_working_directory_for_new_session_header_and_path() {
let _lock = current_dir_lock();
let process_cwd = tempdir().expect("process cwd");
let sdk_cwd = tempdir().expect("sdk cwd");
let session_root = tempdir().expect("session root");
let _guard = CurrentDirGuard::new(process_cwd.path());
let handle = run_async(create_agent_session(SessionOptions {
provider: Some("openai".to_string()),
model: Some("gpt-4o".to_string()),
api_key: Some("dummy-key".to_string()),
working_directory: Some(sdk_cwd.path().to_path_buf()),
no_session: false,
session_dir: Some(session_root.path().to_path_buf()),
..SessionOptions::default()
}))
.expect("create session");
let (header_cwd, path) = run_async(async {
let cx = crate::agent_cx::AgentCx::for_request();
let mut guard = handle
.session()
.session
.lock(cx.cx())
.await
.expect("lock session");
guard.save().await.expect("save sdk session");
(
guard.header.cwd.clone(),
guard.path.clone().expect("saved session path"),
)
});
let expected_dir = session_root
.path()
.join(crate::session::encode_cwd(sdk_cwd.path()));
let process_dir = session_root
.path()
.join(crate::session::encode_cwd(process_cwd.path()));
assert_eq!(header_cwd, sdk_cwd.path().display().to_string());
assert_eq!(path.parent(), Some(expected_dir.as_path()));
assert_ne!(path.parent(), Some(process_dir.as_path()));
}
#[test]
fn create_agent_session_resolves_relative_session_dir_against_working_directory() {
let _lock = current_dir_lock();
let process_cwd = tempdir().expect("process cwd");
let sdk_cwd = tempdir().expect("sdk cwd");
let _guard = CurrentDirGuard::new(process_cwd.path());
let handle = run_async(create_agent_session(SessionOptions {
provider: Some("openai".to_string()),
model: Some("gpt-4o".to_string()),
api_key: Some("dummy-key".to_string()),
working_directory: Some(sdk_cwd.path().to_path_buf()),
no_session: false,
session_dir: Some(PathBuf::from("sessions")),
..SessionOptions::default()
}))
.expect("create session");
let path = run_async(async {
let cx = crate::agent_cx::AgentCx::for_request();
let mut guard = handle
.session()
.session
.lock(cx.cx())
.await
.expect("lock session");
guard.save().await.expect("save sdk session");
guard.path.clone().expect("saved session path")
});
let expected_dir = sdk_cwd
.path()
.join("sessions")
.join(crate::session::encode_cwd(sdk_cwd.path()));
let process_dir = process_cwd
.path()
.join("sessions")
.join(crate::session::encode_cwd(sdk_cwd.path()));
assert_eq!(path.parent(), Some(expected_dir.as_path()));
assert_ne!(path.parent(), Some(process_dir.as_path()));
}
#[test]
fn create_agent_session_resolves_relative_session_path_against_working_directory() {
let _lock = current_dir_lock();
let process_cwd = tempdir().expect("process cwd");
let sdk_cwd = tempdir().expect("sdk cwd");
let _guard = CurrentDirGuard::new(process_cwd.path());
let session_path = sdk_cwd.path().join("relative").join("existing.jsonl");
std::fs::create_dir_all(session_path.parent().expect("session parent"))
.expect("create session parent");
let mut header = crate::session::SessionHeader::new();
header.cwd = sdk_cwd.path().display().to_string();
let header_json = serde_json::to_string(&header).expect("serialize session header");
std::fs::write(&session_path, format!("{header_json}\n")).expect("write session");
let handle = run_async(create_agent_session(SessionOptions {
provider: Some("openai".to_string()),
model: Some("gpt-4o".to_string()),
api_key: Some("dummy-key".to_string()),
working_directory: Some(sdk_cwd.path().to_path_buf()),
no_session: false,
session_path: Some(PathBuf::from("relative/existing.jsonl")),
..SessionOptions::default()
}))
.expect("create session");
let opened_path = run_async(async {
let cx = crate::agent_cx::AgentCx::for_request();
let guard = handle
.session()
.session
.lock(cx.cx())
.await
.expect("lock session");
guard.path.clone().expect("opened session path")
});
assert_eq!(opened_path, session_path);
}
#[test]
fn from_session_with_listeners_set_model_switches_provider_model() {
let dir = tempdir().expect("tempdir");
let auth_path = dir.path().join("auth.json");
let mut auth = AuthStorage::load(auth_path).expect("load auth");
auth.set(
"anthropic",
crate::auth::AuthCredential::ApiKey {
key: "anthropic-key".to_string(),
},
);
auth.set(
"openai",
crate::auth::AuthCredential::ApiKey {
key: "openai-key".to_string(),
},
);
let registry = ModelRegistry::load(&auth, None);
let entry = registry
.find("anthropic", "claude-sonnet-4-5")
.expect("anthropic model in registry");
let provider = providers::create_provider(&entry, None).expect("create anthropic provider");
let tools = crate::tools::ToolRegistry::new(&[], std::path::Path::new("."), None);
let agent = Agent::new(
provider,
tools,
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,
},
);
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(AsyncMutex::new(session)),
false,
ResolvedCompactionSettings::default(),
);
agent_session.set_model_registry(registry);
agent_session.set_auth_storage(auth);
let mut handle =
AgentSessionHandle::from_session_with_listeners(agent_session, EventListeners::new());
run_async(handle.set_model("openai", "gpt-4o")).expect("set model");
let provider = handle.session().agent.provider();
assert_eq!(provider.name(), "openai");
assert_eq!(provider.model_id(), "gpt-4o");
}
#[test]
fn create_agent_session_set_thinking_level_clamps_and_dedupes_history() {
let tmp = tempdir().expect("tempdir");
let options = SessionOptions {
provider: Some("openai".to_string()),
model: Some("gpt-4o".to_string()),
api_key: Some("dummy-key".to_string()),
working_directory: Some(tmp.path().to_path_buf()),
no_session: true,
..SessionOptions::default()
};
let mut handle = run_async(create_agent_session(options)).expect("create session");
run_async(handle.set_thinking_level(crate::model::ThinkingLevel::High))
.expect("set thinking");
run_async(handle.set_thinking_level(crate::model::ThinkingLevel::High))
.expect("reapply thinking");
assert_eq!(
handle.session().agent.stream_options().thinking_level,
Some(crate::model::ThinkingLevel::Off)
);
let thinking_changes = run_async(async {
let cx = crate::agent_cx::AgentCx::for_request();
let guard = handle
.session()
.session
.lock(cx.cx())
.await
.expect("lock session");
assert_eq!(guard.header.thinking_level.as_deref(), Some("off"));
guard
.entries_for_current_path()
.iter()
.filter(|entry| {
matches!(entry, crate::session::SessionEntry::ThinkingLevelChange(_))
})
.count()
});
assert_eq!(thinking_changes, 1);
}
#[test]
fn cycle_thinking_level_walks_the_models_own_levels_and_wraps() {
let tmp = tempdir().expect("tempdir");
let options = SessionOptions {
provider: Some("anthropic".to_string()),
model: Some("claude-sonnet-4-6".to_string()),
api_key: Some("dummy-key".to_string()),
working_directory: Some(tmp.path().to_path_buf()),
no_session: true,
..SessionOptions::default()
};
let mut handle = run_async(create_agent_session(options)).expect("create session");
let levels = handle
.session()
.current_model_entry()
.expect("registry resolves the fixture model")
.available_thinking_levels();
assert!(
levels.len() > 1,
"fixture must be a reasoning model, got {levels:?}"
);
let start = handle.thinking_level().unwrap_or_default();
let start_index = levels
.iter()
.position(|level| *level == start)
.expect("starting level is one the model offers");
let lap = levels
.iter()
.copied()
.cycle()
.skip(start_index + 1)
.take(levels.len())
.collect::<Vec<_>>();
assert_eq!(lap.len(), levels.len());
for (step, expected) in lap.into_iter().enumerate() {
let reported = run_async(handle.cycle_thinking_level()).expect("cycle");
assert_eq!(reported, Some(expected), "step {step}");
assert_eq!(handle.thinking_level(), Some(expected), "step {step}");
}
assert_eq!(
handle.thinking_level(),
Some(start),
"a full lap must return to where it started"
);
}
#[test]
fn cycle_thinking_level_reports_nothing_to_cycle_rather_than_moving() {
let tmp = tempdir().expect("tempdir");
let options = SessionOptions {
provider: Some("openai".to_string()),
model: Some("gpt-4o".to_string()),
api_key: Some("dummy-key".to_string()),
working_directory: Some(tmp.path().to_path_buf()),
no_session: true,
..SessionOptions::default()
};
let mut handle = run_async(create_agent_session(options)).expect("create session");
assert_eq!(
run_async(handle.cycle_thinking_level()).expect("cycle"),
None
);
assert_eq!(
handle.session().agent.stream_options().thinking_level,
Some(crate::model::ThinkingLevel::Off),
"a model with nothing to cycle must be left where it was"
);
}
#[test]
fn sdk_stream_options_enable_prompt_caching_by_default() {
let config = crate::config::Config::default();
let session = crate::session::Session::in_memory();
let selection = app::ModelSelection {
model_entry: ModelEntry {
model: Model {
id: "plain-model".to_string(),
name: "Plain Model".to_string(),
api: "anthropic-messages".to_string(),
provider: "anthropic".to_string(),
base_url: "https://api.anthropic.com/v1/messages".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,
},
thinking_level: crate::model::ThinkingLevel::Off,
scoped_models: Vec::new(),
fallback_message: None,
};
let options = build_stream_options_with_optional_key(&config, None, &selection, &session);
assert_eq!(
options.cache_retention,
app::cache_retention_from_env(std::env::var("PI_CACHE_RETENTION").ok().as_deref())
);
if std::env::var("PI_CACHE_RETENTION").is_err() {
assert_eq!(
options.cache_retention,
crate::provider::CacheRetention::Short
);
}
}
#[test]
fn from_session_with_listeners_set_thinking_level_uses_session_header_target() {
let dir = tempdir().expect("tempdir");
let auth_path = dir.path().join("auth.json");
let auth = crate::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 entry = registry
.find("anthropic", "claude-sonnet-4-5")
.expect("anthropic model in registry");
let provider = providers::create_provider(&entry, None).expect("create anthropic provider");
let tools = crate::tools::ToolRegistry::new(&[], std::path::Path::new("."), None);
let agent = Agent::new(
provider,
tools,
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,
},
);
let mut session = Session::in_memory();
session.header.provider = Some("acme".to_string());
session.header.model_id = Some("plain-model".to_string());
let mut agent_session = AgentSession::new(
agent,
Arc::new(AsyncMutex::new(session)),
false,
ResolvedCompactionSettings::default(),
);
agent_session.set_model_registry(registry);
let mut handle =
AgentSessionHandle::from_session_with_listeners(agent_session, EventListeners::new());
run_async(handle.set_thinking_level(crate::model::ThinkingLevel::High))
.expect("set thinking");
assert_eq!(
handle.session().agent.stream_options().thinking_level,
Some(crate::model::ThinkingLevel::Off)
);
assert_eq!(handle.model().0, "anthropic");
assert_eq!(handle.model().1, "claude-sonnet-4-5");
}
#[test]
fn compact_without_history_is_noop() {
let tmp = tempdir().expect("tempdir");
let options = hermetic_session_options(tmp.path());
let mut handle = run_async(create_agent_session(options)).expect("create session");
let events = Arc::new(Mutex::new(Vec::new()));
let events_for_callback = Arc::clone(&events);
run_async(handle.compact(move |event| {
events_for_callback
.lock()
.expect("compact callback lock")
.push(event);
}))
.expect("compact");
assert!(
events
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_empty(),
"expected no compaction lifecycle events for empty session"
);
}
#[test]
fn resolve_path_for_cwd_uses_cwd_for_relative_paths() {
let cwd = Path::new("/tmp/pi-sdk-cwd");
assert_eq!(
resolve_path_for_cwd(Path::new("relative/file.txt"), cwd),
PathBuf::from("/tmp/pi-sdk-cwd/relative/file.txt")
);
assert_eq!(
resolve_path_for_cwd(Path::new("/etc/hosts"), cwd),
PathBuf::from("/etc/hosts")
);
}
#[test]
fn event_listeners_subscribe_and_notify() {
let listeners = EventListeners::new();
let received = Arc::new(Mutex::new(Vec::new()));
let recv_clone = Arc::clone(&received);
let id = listeners.subscribe(Arc::new(move |event| {
recv_clone
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(event);
}));
let event = AgentEvent::AgentStart {
session_id: "test-123".into(),
};
listeners.notify(&event);
let events = received
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(events.len(), 1);
drop(events);
assert!(listeners.unsubscribe(id));
listeners.notify(&AgentEvent::AgentStart {
session_id: "test-456".into(),
});
assert_eq!(
received
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len(),
1
);
}
#[test]
fn event_listeners_unsubscribe_nonexistent_returns_false() {
let listeners = EventListeners::new();
assert!(!listeners.unsubscribe(SubscriptionId(999)));
}
#[test]
fn event_listeners_multiple_subscribers() {
let listeners = EventListeners::new();
let count_a = Arc::new(Mutex::new(0u32));
let count_b = Arc::new(Mutex::new(0u32));
let ca = Arc::clone(&count_a);
listeners.subscribe(Arc::new(move |_| {
*ca.lock().unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
}));
let cb = Arc::clone(&count_b);
listeners.subscribe(Arc::new(move |_| {
*cb.lock().unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
}));
listeners.notify(&AgentEvent::AgentStart {
session_id: "s".into(),
});
assert_eq!(
*count_a
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
1
);
assert_eq!(
*count_b
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
1
);
}
#[test]
fn event_listeners_tool_hooks_fire() {
let listeners = EventListeners::new();
let starts = Arc::new(Mutex::new(Vec::new()));
let ends = Arc::new(Mutex::new(Vec::new()));
let s = Arc::clone(&starts);
let mut listeners = listeners;
listeners.on_tool_start = Some(Arc::new(move |name, args| {
s.lock()
.expect("lock")
.push((name.to_string(), args.clone()));
}));
let e = Arc::clone(&ends);
listeners.on_tool_end = Some(Arc::new(move |name, _output, is_error| {
e.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push((name.to_string(), is_error));
}));
let args = serde_json::json!({"path": "/foo"});
listeners.notify_tool_start("bash", &args);
let output = ToolOutput {
content: vec![ContentBlock::Text(TextContent::new("ok"))],
details: None,
is_error: false,
};
listeners.notify_tool_end("bash", &output, false);
{
let s = starts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(s.len(), 1);
assert_eq!(s[0].0, "bash");
drop(s);
}
{
let e = ends
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(e.len(), 1);
assert_eq!(e[0].0, "bash");
assert!(!e[0].1);
drop(e);
}
}
#[test]
fn event_listeners_stream_event_hook_fires() {
let mut listeners = EventListeners::new();
let received = Arc::new(Mutex::new(Vec::new()));
let r = Arc::clone(&received);
listeners.on_stream_event = Some(Arc::new(move |ev| {
r.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(format!("{ev:?}"));
}));
let event = StreamEvent::TextDelta {
content_index: 0,
delta: "hello".to_string(),
};
listeners.notify_stream_event(&event);
assert_eq!(
received
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len(),
1
);
}
#[test]
fn session_options_on_event_wired_into_listeners() {
let received = Arc::new(Mutex::new(Vec::new()));
let r = Arc::clone(&received);
let tmp = tempdir().expect("tempdir");
let options = SessionOptions {
on_event: Some(Arc::new(move |event| {
r.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(format!("{event:?}"));
})),
..hermetic_session_options(tmp.path())
};
let handle = run_async(create_agent_session(options)).expect("create session");
let count = handle
.listeners()
.subscribers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len();
assert_eq!(
count, 1,
"on_event from SessionOptions should register one subscriber"
);
}
#[test]
fn subscribe_unsubscribe_on_handle() {
let tmp = tempdir().expect("tempdir");
let options = hermetic_session_options(tmp.path());
let handle = run_async(create_agent_session(options)).expect("create session");
let id = handle.subscribe(|_event| {});
assert_eq!(
handle
.listeners()
.subscribers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len(),
1
);
assert!(handle.unsubscribe(id));
assert_eq!(
handle
.listeners()
.subscribers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len(),
0
);
assert!(!handle.unsubscribe(id));
}
#[test]
fn stream_event_from_assistant_message_event_converts_text_delta() {
use crate::model::AssistantMessageEvent as AME;
let partial = Arc::new(AssistantMessage {
content: Vec::new(),
api: String::new(),
provider: String::new(),
model: String::new(),
usage: Usage::default(),
stop_reason: StopReason::Stop,
stop_details: None,
error_message: None,
timestamp: 0,
});
let ame = AME::TextDelta {
content_index: 2,
delta: "chunk".to_string(),
partial,
};
let result = stream_event_from_assistant_message_event(&ame);
assert!(result.is_some());
match result.unwrap() {
StreamEvent::TextDelta {
content_index,
delta,
} => {
assert_eq!(content_index, 2);
assert_eq!(delta, "chunk");
}
other => unreachable!("expected TextDelta, got {other:?}"),
}
}
#[test]
fn stream_event_from_assistant_message_event_start_returns_none() {
use crate::model::AssistantMessageEvent as AME;
let partial = Arc::new(AssistantMessage {
content: Vec::new(),
api: String::new(),
provider: String::new(),
model: String::new(),
usage: Usage::default(),
stop_reason: StopReason::Stop,
stop_details: None,
error_message: None,
timestamp: 0,
});
let ame = AME::Start { partial };
assert!(stream_event_from_assistant_message_event(&ame).is_none());
}
#[test]
fn event_listeners_debug_impl() {
let listeners = EventListeners::new();
let debug = format!("{listeners:?}");
assert!(debug.contains("subscriber_count"));
assert!(debug.contains("has_on_tool_start"));
}
#[test]
fn has_extensions_false_by_default() {
let tmp = tempdir().expect("tempdir");
let options = hermetic_session_options(tmp.path());
let handle = run_async(create_agent_session(options)).expect("create session");
assert!(
!handle.has_extensions(),
"session without extension_paths should have no extensions"
);
assert!(handle.extension_manager().is_none());
assert!(handle.extension_region().is_none());
}
#[test]
fn session_options_new_fields_default_to_current_behavior() {
let options = SessionOptions::default();
assert!(options.extension_ui_handler.is_none());
assert!(options.persist_extension_permissions);
assert!(options.compaction_settings.is_none());
assert!(options.mcp.is_none());
assert!(options.extension_flags.is_empty());
}
#[test]
fn create_agent_session_uses_compaction_override_verbatim() {
let tmp = tempdir().expect("tempdir");
let custom = ResolvedCompactionSettings {
enabled: false,
context_window_tokens: 55_555,
reserve_tokens: 1_234,
keep_recent_tokens: 4_321,
mode: crate::compaction::AutoCompactionMode::default(),
render_mode: crate::compaction::CompactionRenderMode::default(),
};
let options = SessionOptions {
compaction_settings: Some(custom.clone()),
..hermetic_session_options(tmp.path())
};
let handle = run_async(create_agent_session(options)).expect("create session");
let resolved = handle.compaction_settings();
assert!(!resolved.enabled);
assert_eq!(resolved.context_window_tokens, custom.context_window_tokens);
assert_eq!(resolved.reserve_tokens, custom.reserve_tokens);
assert_eq!(resolved.keep_recent_tokens, custom.keep_recent_tokens);
}
#[test]
fn create_agent_session_derives_compaction_without_override() {
let tmp = tempdir().expect("tempdir");
let options = hermetic_session_options(tmp.path());
let handle = run_async(create_agent_session(options)).expect("create session");
let resolved = handle.compaction_settings();
assert!(
resolved.context_window_tokens > 0,
"derived settings must resolve a context window"
);
}
#[test]
fn replacement_mcp_activation_requires_proven_predecessor_release() {
let no_predecessor_manager = SessionResourceShutdown::default();
assert!(no_predecessor_manager.permits_replacement_mcp_activation());
let released = SessionResourceShutdown {
mcp: McpShutdownOutcome::Released,
..Default::default()
};
assert!(released.permits_replacement_mcp_activation());
let mut indeterminate = SessionResourceShutdown {
mcp: McpShutdownOutcome::Indeterminate,
..Default::default()
};
indeterminate.fail(String::from("MCP shutdown timed out"));
assert!(!indeterminate.permits_replacement_mcp_activation());
assert!(!indeterminate.completed_cleanly());
}
#[test]
fn shutdown_skips_autosave_for_ephemeral_session() {
let tmp = tempdir().expect("tempdir");
let warnings = run_async(async {
let handle = create_agent_session(hermetic_session_options(tmp.path()))
.await
.expect("create session");
let cx = crate::agent_cx::AgentCx::for_request();
{
let mut session = handle
.session
.session
.lock(cx.cx())
.await
.expect("lock session");
session
.set_autosave_durability_mode(crate::session::AutosaveDurabilityMode::Strict);
session.append_session_info(Some("ephemeral mutation".to_string()));
}
handle.shutdown_owned_resources().await
});
assert!(
warnings.is_empty(),
"ephemeral sessions have no persistence path and must not attempt autosave: {warnings:?}"
);
}
#[cfg(unix)]
#[test]
fn shutdown_stops_background_jobs_owned_by_the_session() {
let tmp = tempdir().expect("tempdir");
let (warnings, session_id, job_id) = run_async(async {
let handle = create_agent_session(hermetic_session_options(tmp.path()))
.await
.expect("create session");
let cx = crate::agent_cx::AgentCx::for_request();
let session_id = handle
.session
.session
.lock(cx.cx())
.await
.expect("lock session")
.header
.id
.clone();
let job = crate::jobs::spawn_background(
&session_id,
tmp.path(),
None,
None,
"sleep 300",
Some(300),
None,
)
.expect("spawn session-owned background job");
let warnings = handle.shutdown_owned_resources().await;
(warnings, session_id, job.id)
});
assert!(
warnings.is_empty(),
"clean session shutdown must not report warnings: {warnings:?}"
);
let jobs = crate::jobs::list(&session_id).expect("list session jobs");
assert!(jobs.iter().any(|job| {
job.id == job_id && job.status == crate::jobs::JobStatus::Killed.as_str()
}));
let _ = crate::jobs::take_completion_notices(&session_id);
}
struct SdkRecordingUiHandler {
prompts: Mutex<Vec<ExtensionUiRequest>>,
}
#[async_trait::async_trait]
impl ExtensionUiHandler for SdkRecordingUiHandler {
async fn request_ui(
&self,
request: ExtensionUiRequest,
) -> Result<Option<ExtensionUiResponse>> {
let id = request.id.clone();
self.prompts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(request);
Ok(Some(ExtensionUiResponse {
id,
value: Some(Value::Bool(true)),
cancelled: false,
}))
}
}
#[test]
fn create_agent_session_wires_extension_ui_handler_before_startup() {
let tmp = tempdir().expect("tempdir");
let ext_path = tmp.path().join("noop_ext.js");
std::fs::write(
&ext_path,
r#"
export default function init(pi) {
pi.on("startup", async (_event, ctx) => {
await ctx.ui.confirm("Startup prompt", "Allow startup initialization?");
});
}
"#,
)
.expect("write extension");
let handler = Arc::new(SdkRecordingUiHandler {
prompts: Mutex::new(Vec::new()),
});
let options = SessionOptions {
extension_paths: vec![ext_path],
extension_ui_handler: Some(handler.clone()),
persist_extension_permissions: false,
..hermetic_session_options(tmp.path())
};
let handle = run_async(create_agent_session(options)).expect("create session");
let manager = handle.extension_manager().expect("extensions loaded");
assert!(
!manager.policy_prompt_persistence(),
"persist_extension_permissions: false must scope decisions to the session"
);
{
let prompts = handler
.prompts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(
prompts.len(),
1,
"the UI bridge must be installed before the startup hook runs"
);
assert_eq!(prompts[0].method, "confirm");
assert_eq!(prompts[0].payload["title"], "Startup prompt");
}
let response = run_async(async {
manager
.request_ui(ExtensionUiRequest::new(
"",
"confirm",
serde_json::json!({ "title": "Allow?", "message": "capability" }),
))
.await
})
.expect("request_ui")
.expect("handler response");
assert_eq!(response.value, Some(Value::Bool(true)));
let prompts = handler
.prompts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(prompts.len(), 2, "handler must receive both UI requests");
assert_eq!(prompts[1].method, "confirm");
}
#[test]
fn create_read_tool_has_correct_name() {
let tmp = tempdir().expect("tempdir");
let tool = super::create_read_tool(tmp.path());
assert_eq!(tool.name(), "read");
assert!(!tool.description().is_empty());
let params = tool.parameters();
assert!(params.is_object(), "parameters should be a JSON object");
}
#[test]
fn create_bash_tool_has_correct_name() {
let tmp = tempdir().expect("tempdir");
let tool = super::create_bash_tool(tmp.path());
assert_eq!(tool.name(), "bash");
assert!(!tool.description().is_empty());
}
#[test]
fn create_edit_tool_has_correct_name() {
let tmp = tempdir().expect("tempdir");
let tool = super::create_edit_tool(tmp.path());
assert_eq!(tool.name(), "edit");
}
#[test]
fn create_write_tool_has_correct_name() {
let tmp = tempdir().expect("tempdir");
let tool = super::create_write_tool(tmp.path());
assert_eq!(tool.name(), "write");
}
#[test]
fn create_grep_tool_has_correct_name() {
let tmp = tempdir().expect("tempdir");
let tool = super::create_grep_tool(tmp.path());
assert_eq!(tool.name(), "grep");
}
#[test]
fn create_find_tool_has_correct_name() {
let tmp = tempdir().expect("tempdir");
let tool = super::create_find_tool(tmp.path());
assert_eq!(tool.name(), "find");
}
#[test]
fn create_ls_tool_has_correct_name() {
let tmp = tempdir().expect("tempdir");
let tool = super::create_ls_tool(tmp.path());
assert_eq!(tool.name(), "ls");
}
#[test]
fn create_all_tools_returns_eight() {
let tmp = tempdir().expect("tempdir");
let tools = super::create_all_tools(tmp.path());
assert_eq!(tools.len(), 8, "should create all 8 built-in tools");
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
for expected in BUILTIN_TOOL_NAMES {
assert!(names.contains(expected), "missing tool: {expected}");
}
}
#[test]
fn tool_to_definition_preserves_schema() {
let tmp = tempdir().expect("tempdir");
let tool = super::create_read_tool(tmp.path());
let def = super::tool_to_definition(tool.as_ref());
assert_eq!(def.name, "read");
assert!(!def.description.is_empty());
assert!(def.parameters.is_object());
assert!(
def.parameters.get("properties").is_some(),
"schema should have properties"
);
}
#[test]
fn all_tool_definitions_returns_eight_schemas() {
let tmp = tempdir().expect("tempdir");
let defs = super::all_tool_definitions(tmp.path());
assert_eq!(defs.len(), 8);
for def in &defs {
assert!(!def.name.is_empty());
assert!(!def.description.is_empty());
assert!(def.parameters.is_object());
}
}
#[test]
fn builtin_tool_names_matches_create_all() {
let tmp = tempdir().expect("tempdir");
let tools = super::create_all_tools(tmp.path());
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert_eq!(
names.as_slice(),
BUILTIN_TOOL_NAMES,
"create_all_tools order should match BUILTIN_TOOL_NAMES"
);
}
#[test]
fn tool_registry_from_factory_tools() {
let tmp = tempdir().expect("tempdir");
let tools = super::create_all_tools(tmp.path());
let registry = ToolRegistry::from_tools(tools);
assert!(registry.get("read").is_some());
assert!(registry.get("bash").is_some());
assert!(registry.get("nonexistent").is_none());
}
#[test]
fn set_session_name_records_session_info_entry() {
let tmp = tempdir().expect("tempdir");
let options = hermetic_session_options(tmp.path());
let mut handle = run_async(create_agent_session(options)).expect("create session");
run_async(handle.set_session_name("renamed-by-sdk")).expect("set session name");
let info_entries = run_async(async {
let cx = crate::agent_cx::AgentCx::for_request();
let guard = handle
.session()
.session
.lock(cx.cx())
.await
.expect("lock session");
guard
.entries_for_current_path()
.iter()
.filter_map(|entry| match entry {
crate::session::SessionEntry::SessionInfo(info) => info.name.clone(),
_ => None,
})
.collect::<Vec<_>>()
});
assert_eq!(info_entries, vec!["renamed-by-sdk".to_string()]);
}
#[test]
fn max_tokens_defaults_to_registry_value_and_set_overrides() {
let tmp = tempdir().expect("tempdir");
let options = hermetic_session_options(tmp.path());
let mut handle = run_async(create_agent_session(options)).expect("create session");
let seeded = handle.max_tokens();
assert!(
seeded.is_some_and(|n| n > 0),
"expected max_tokens to be seeded from the registry, got {seeded:?}"
);
handle.set_max_tokens(Some(32_000));
assert_eq!(handle.max_tokens(), Some(32_000));
assert_eq!(
handle.session().agent.stream_options().max_tokens,
Some(32_000)
);
handle.set_max_tokens(None);
assert_eq!(handle.max_tokens(), None);
}
#[test]
fn empty_default_tool_registry_disables_configured_builtins() {
let tmp = tempdir().expect("tempdir"); let config: Config = serde_json::from_value(
serde_json::json!({ "askPolicy": "recommended",
"memory": {"backend": "local"},
"media": {
"enableInspectImage": true,
"enableReadMedia": true,
"enableGenerateImage": true,
"enableTts": true
},
"computer": {"enableComputer": true},
"browser": {"enableBrowser": true}
}),
)
.expect("configured builtins");
let registry = default_tool_registry(&[], tmp.path(), &config);
assert!(registry.tools().is_empty());
let picker = registry.host_ask_tool();
let output = run_async(crate::tools::Tool::execute(
&picker,
"host-picker",
serde_json::json!({
"questions": [{
"question": "Choose a host action",
"recommended": 0,
"options": [{"label": "Continue"}, {"label": "Wait"}]
}]
}),
None,
))
.expect("configured host picker remains usable");
assert!(!output.is_error); assert_eq!(
output
.details
.as_ref()
.and_then(|details| details.get("autoAnswered"))
.and_then(Value::as_bool),
Some(true)
);
}
#[test]
fn sdk_no_builtins_keeps_explicit_extension_tools_executable() {
let tmp = tempdir().expect("tempdir"); let extension_path = tmp.path().join("kept_custom.js");
std::fs::write(
&extension_path,
r#"
export default function init(pi) {
pi.registerTool({
name: "sdk_kept_custom",
label: "Custom tool",
description: "Explicitly configured extension tool",
parameters: { type: "object", properties: {} },
execute: async () => ({
content: [{ type: "text", text: "kept custom tool" }],
isError: false
})
});
}
"#,
)
.expect("write custom extension");
let options = SessionOptions {
enabled_tools: Some(Vec::new()),
extension_paths: vec![extension_path],
extension_policy: Some("safe".to_string()),
persist_extension_permissions: false,
..hermetic_session_options(tmp.path())
};
run_async(async {
let handle = create_agent_session(options) .await
.expect("create session");
assert!(handle.ask_tool().is_some()); let registry = handle.session().agent.shared_tools().snapshot();
let names: Vec<_> = registry.tools().iter().map(|tool| tool.name()).collect();
assert_eq!(names, ["sdk_kept_custom"]);
let output = registry .get("sdk_kept_custom")
.expect("explicit extension remains registered")
.execute("custom-call", serde_json::json!({}), None)
.await
.expect("execute explicit extension");
assert!(!output.is_error); let text: Vec<_> = output
.content
.iter()
.filter_map(|content| match content {
crate::model::ContentBlock::Text(text) => Some(text.text.as_str()),
_ => None,
})
.collect();
assert_eq!(text, ["kept custom tool"]); let shutdown = handle.shutdown_owned_resources().await;
assert!(shutdown.completed_cleanly()); });
}
#[test]
#[allow(clippy::too_many_lines)]
fn disabled_builtins_survive_extension_mount_and_plan_checkpoint_restore() {
run_async(async {
let tmp = tempdir().expect("tempdir"); let mut options = hermetic_session_options(tmp.path());
options.no_session = false;
options.session_dir = Some(tmp.path().join("sessions"));
let mut enabled = create_agent_session(options) .await
.expect("create enabled session");
let owner = crate::agent_cx::AgentCx::for_request();
let entered = enabled .enter_plan_mode(&owner)
.await
.expect("enter enabled plan mode");
assert_eq!(entered.persistence, crate::plan::PlanPersistence::Saved); let installed = enabled.session().agent.shared_tools().snapshot();
let submit = installed.get("submit_plan").expect("mounted submit_plan"); let output = submit .execute(
"saved-proposal",
serde_json::json!({
"plan": "Goal: update code. Steps: edit the selected source, then verify it.",
"files": ["src/fixture.rs"],
}),
None,
)
.await
.expect("submit persisted proposal");
assert!(!output.is_error); drop(installed);
let review = enabled .pending_plan_review()
.expect("read pending review")
.expect("submitted review");
let approved = enabled .approve_plan_review(&owner, &review)
.await
.expect("approve fixture plan");
assert_eq!(approved.persistence, crate::plan::PlanPersistence::Saved); let path = enabled .with_session(|session| session.path.clone())
.await
.expect("read saved session path")
.expect("saved session path");
drop(enabled);
let extension_path = tmp.path().join("disabled-plan-probe.mjs");
std::fs::write(
&extension_path,
r#"export default function (pi) {
pi.registerTool({
name: "sdk_disabled_plan_probe",
label: "Plan policy probe",
description: "Explicit extension retained when built-ins are disabled",
parameters: {type: "object", properties: {}},
execute: async () => ({
content: [{type: "text", text: "extension retained"}],
isError: false
})
});
}
"#,
)
.expect("write extension fixture");
let mut options = hermetic_session_options(tmp.path());
options.no_session = false;
options.session_path = Some(path.clone());
options.enabled_tools = Some(Vec::new());
options.extension_paths = vec![extension_path];
options.extension_policy = Some("safe".to_string());
options.persist_extension_permissions = false;
let mut disabled = create_agent_session(options) .await
.expect("reopen without built-ins");
let shared = disabled.session().agent.shared_tools();
let names_before = shared
.snapshot()
.tools()
.iter()
.map(|tool| tool.name().to_string())
.collect::<Vec<_>>();
assert!(disabled.has_tool("sdk_disabled_plan_probe")); assert!(!disabled.has_tool("submit_plan")); let state = disabled.session().agent.plan_state();
assert_eq!(state.mode(), crate::plan::PlanMode::Off); assert!(state.plan().is_none()); let prompt_before = disabled.session().agent.system_prompt().map(str::to_string);
let entries_before = disabled .with_session(|session| session.entries.clone())
.await
.expect("read restored journal");
assert!(entries_before.iter().any(
|entry| matches!( entry,
crate::session::SessionEntry::Custom(custom)
if custom.custom_type == "plan_checkpoint"
)
));
let journal_before = serde_json::to_value(&entries_before) .expect("serialize journal before rejection");
let disk_before = std::fs::read(&path).expect("read saved checkpoint");
for restore in [false, true] {
let result = if restore {
disabled.restore_plan_checkpoint(&owner).await
} else {
disabled.enter_plan_mode(&owner).await
};
let error = result.expect_err("disabled planning must be rejected"); assert!(error.to_string().contains("[PLAN_TOOLS_DISABLED]")); assert_eq!(state.mode(), crate::plan::PlanMode::Off); assert!(state.plan().is_none()); assert!(!disabled.has_tool("submit_plan")); assert_eq!(
shared
.snapshot()
.tools()
.iter()
.map(|tool| tool.name().to_string())
.collect::<Vec<_>>(),
names_before,
);
assert_eq!(
disabled.session().agent.system_prompt(),
prompt_before.as_deref(),
);
let journal_after = disabled .with_session(|session| serde_json::to_value(&session.entries))
.await
.expect("read journal after rejection")
.expect("serialize journal after rejection");
assert_eq!(journal_after, journal_before); assert_eq!(
std::fs::read(&path).expect("reread saved checkpoint"), disk_before,
);
}
});
}
#[test]
fn session_without_model_ask_still_exposes_host_picker() {
let tmp = tempdir().expect("tempdir");
let mut options = hermetic_session_options(tmp.path());
options.enabled_tools = Some(vec!["read".to_string()]);
let handle = run_async(create_agent_session(options)).expect("create session");
assert!(!handle.has_tool("ask"));
assert!(handle.ask_tool().is_some());
}
}