Skip to main content

pi/
sdk.rs

1//! Stable SDK-facing API surface for embedding Pi as a library.
2//!
3//! This module is the supported entry point for external library consumers.
4//! Prefer importing from `pi::sdk` instead of deep internal modules.
5//!
6//! # Examples
7//!
8//! ```rust
9//! use pi::sdk::{AgentEvent, Message, ToolDefinition};
10//!
11//! let _events: Vec<AgentEvent> = Vec::new();
12//! let _messages: Vec<Message> = Vec::new();
13//! let _tools: Vec<ToolDefinition> = Vec::new();
14//! ```
15//!
16//! Internal implementation types are intentionally not part of this surface.
17//!
18//! ```compile_fail
19//! use pi::sdk::RpcSharedState;
20//! ```
21
22use crate::app;
23use crate::auth::AuthStorage;
24use crate::cli::Cli;
25use crate::compaction::ResolvedCompactionSettings;
26use crate::models::default_models_path;
27use crate::provider::ThinkingBudgets;
28use crate::providers;
29use clap::Parser;
30use serde::{Deserialize, Serialize, de::DeserializeOwned};
31use serde_json::{Map, Value};
32use std::collections::HashMap;
33use std::io::{BufRead, BufReader, BufWriter, Write};
34use std::path::{Path, PathBuf};
35use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
36use std::sync::Arc;
37use std::sync::atomic::{AtomicU64, Ordering};
38
39pub use crate::agent::{
40    AbortHandle, AbortSignal, Agent, AgentConfig, AgentEvent, AgentSession, QueueMode,
41};
42pub use crate::config::Config;
43pub use crate::error::{Error, Result};
44pub use crate::extensions::{ExtensionManager, ExtensionPolicy, ExtensionRegion};
45pub use crate::model::ThinkingLevel;
46pub use crate::model::{
47    AssistantMessage, ContentBlock, Cost, CustomMessage, ImageContent, Message, StopReason,
48    StreamEvent, TextContent, ThinkingContent, ToolCall, ToolResultMessage, Usage, UserContent,
49    UserMessage,
50};
51pub use crate::models::{ModelEntry, ModelRegistry};
52pub use crate::provider::{
53    Context as ProviderContext, InputType, Model, ModelCost, Provider, StreamOptions,
54    ThinkingBudgets as ProviderThinkingBudgets, ToolDef,
55};
56pub use crate::session::Session;
57pub use crate::tools::{Tool, ToolOutput, ToolRegistry, ToolUpdate};
58
59/// Stable alias for model-exposed tool schema definitions.
60pub type ToolDefinition = ToolDef;
61
62// ============================================================================
63// Tool Factory Functions
64// ============================================================================
65
66use crate::tools::{
67    BashTool, EditTool, FindTool, GrepTool, HashlineEditTool, LsTool, ReadTool, WriteTool,
68};
69
70/// All built-in tool names.
71pub const BUILTIN_TOOL_NAMES: &[&str] = &[
72    "read",
73    "bash",
74    "edit",
75    "write",
76    "grep",
77    "find",
78    "ls",
79    "hashline_edit",
80];
81
82/// Create a read tool configured for `cwd`.
83pub fn create_read_tool(cwd: &Path) -> Box<dyn Tool> {
84    Box::new(ReadTool::new(cwd))
85}
86
87/// Create a bash tool configured for `cwd`.
88pub fn create_bash_tool(cwd: &Path) -> Box<dyn Tool> {
89    Box::new(BashTool::new(cwd))
90}
91
92/// Create an edit tool configured for `cwd`.
93pub fn create_edit_tool(cwd: &Path) -> Box<dyn Tool> {
94    Box::new(EditTool::new(cwd))
95}
96
97/// Create a write tool configured for `cwd`.
98pub fn create_write_tool(cwd: &Path) -> Box<dyn Tool> {
99    Box::new(WriteTool::new(cwd))
100}
101
102/// Create a grep tool configured for `cwd`.
103pub fn create_grep_tool(cwd: &Path) -> Box<dyn Tool> {
104    Box::new(GrepTool::new(cwd))
105}
106
107/// Create a find tool configured for `cwd`.
108pub fn create_find_tool(cwd: &Path) -> Box<dyn Tool> {
109    Box::new(FindTool::new(cwd))
110}
111
112/// Create an ls tool configured for `cwd`.
113pub fn create_ls_tool(cwd: &Path) -> Box<dyn Tool> {
114    Box::new(LsTool::new(cwd))
115}
116
117/// Create a hashline edit tool configured for `cwd`.
118pub fn create_hashline_edit_tool(cwd: &Path) -> Box<dyn Tool> {
119    Box::new(HashlineEditTool::new(cwd))
120}
121
122/// Create all built-in tools configured for `cwd`.
123pub fn create_all_tools(cwd: &Path) -> Vec<Box<dyn Tool>> {
124    vec![
125        create_read_tool(cwd),
126        create_bash_tool(cwd),
127        create_edit_tool(cwd),
128        create_write_tool(cwd),
129        create_grep_tool(cwd),
130        create_find_tool(cwd),
131        create_ls_tool(cwd),
132        create_hashline_edit_tool(cwd),
133    ]
134}
135
136/// Convert a [`Tool`] into its [`ToolDefinition`] schema.
137pub fn tool_to_definition(tool: &dyn Tool) -> ToolDefinition {
138    ToolDefinition {
139        name: tool.name().to_string(),
140        description: tool.description().to_string(),
141        parameters: tool.parameters(),
142    }
143}
144
145/// Return [`ToolDefinition`] schemas for all built-in tools.
146pub fn all_tool_definitions(cwd: &Path) -> Vec<ToolDefinition> {
147    create_all_tools(cwd)
148        .iter()
149        .map(|t| tool_to_definition(t.as_ref()))
150        .collect()
151}
152
153// ============================================================================
154// Streaming Callbacks and Tool Hooks
155// ============================================================================
156
157/// Opaque identifier for an event subscription.
158///
159/// Returned by [`AgentSessionHandle::subscribe`] and used to remove the
160/// listener via [`AgentSessionHandle::unsubscribe`].
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
162pub struct SubscriptionId(u64);
163
164/// Callback invoked when a tool execution starts.
165///
166/// Arguments: `(tool_name, input_args)`.
167pub type OnToolStart = Arc<dyn Fn(&str, &Value) + Send + Sync>;
168
169/// Callback invoked when a tool execution ends.
170///
171/// Arguments: `(tool_name, output, is_error)`.
172pub type OnToolEnd = Arc<dyn Fn(&str, &ToolOutput, bool) + Send + Sync>;
173
174/// Callback invoked for every raw provider [`StreamEvent`].
175///
176/// This gives SDK consumers direct access to the low-level streaming protocol
177/// before events are wrapped into [`AgentEvent::MessageUpdate`].
178pub type OnStreamEvent = Arc<dyn Fn(&StreamEvent) + Send + Sync>;
179
180pub type EventSubscriber = Arc<dyn Fn(AgentEvent) + Send + Sync>;
181type EventSubscribers = HashMap<SubscriptionId, EventSubscriber>;
182
183/// Collection of session-level event listeners.
184///
185/// These are registered once and invoked for every prompt throughout the
186/// session lifetime, in contrast to per-prompt callbacks on
187/// [`AgentSessionHandle::prompt`].
188#[derive(Clone, Default)]
189pub struct EventListeners {
190    next_id: Arc<AtomicU64>,
191    subscribers: Arc<std::sync::Mutex<EventSubscribers>>,
192    pub on_tool_start: Option<OnToolStart>,
193    pub on_tool_end: Option<OnToolEnd>,
194    pub on_stream_event: Option<OnStreamEvent>,
195}
196
197impl EventListeners {
198    fn new() -> Self {
199        Self {
200            next_id: Arc::new(AtomicU64::new(1)),
201            subscribers: Arc::new(std::sync::Mutex::new(HashMap::new())),
202            on_tool_start: None,
203            on_tool_end: None,
204            on_stream_event: None,
205        }
206    }
207
208    /// Register a session-level event listener.
209    pub fn subscribe(&self, listener: EventSubscriber) -> SubscriptionId {
210        let id = SubscriptionId(self.next_id.fetch_add(1, Ordering::Relaxed));
211        let mut subs = self
212            .subscribers
213            .lock()
214            .unwrap_or_else(std::sync::PoisonError::into_inner);
215        subs.insert(id, listener);
216        id
217    }
218
219    /// Remove a previously registered listener.
220    pub fn unsubscribe(&self, id: SubscriptionId) -> bool {
221        let mut subs = self
222            .subscribers
223            .lock()
224            .unwrap_or_else(std::sync::PoisonError::into_inner);
225        subs.remove(&id).is_some()
226    }
227
228    /// Dispatch an [`AgentEvent`] to all registered subscribers.
229    pub fn notify(&self, event: &AgentEvent) {
230        let listeners: Vec<_> = {
231            let subs = self
232                .subscribers
233                .lock()
234                .unwrap_or_else(std::sync::PoisonError::into_inner);
235            subs.values().cloned().collect()
236        };
237        for listener in listeners {
238            listener(event.clone());
239        }
240    }
241
242    /// Dispatch tool-start to the typed hook (if set).
243    pub fn notify_tool_start(&self, tool_name: &str, args: &Value) {
244        if let Some(cb) = &self.on_tool_start {
245            cb(tool_name, args);
246        }
247    }
248
249    /// Dispatch tool-end to the typed hook (if set).
250    pub fn notify_tool_end(&self, tool_name: &str, output: &ToolOutput, is_error: bool) {
251        if let Some(cb) = &self.on_tool_end {
252            cb(tool_name, output, is_error);
253        }
254    }
255
256    /// Dispatch a raw stream event (if hook is set).
257    pub fn notify_stream_event(&self, event: &StreamEvent) {
258        if let Some(cb) = &self.on_stream_event {
259            cb(event);
260        }
261    }
262}
263
264impl std::fmt::Debug for EventListeners {
265    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266        let count = self.subscribers.lock().map_or(0, |s| s.len());
267        let next_id = self.next_id.load(Ordering::Relaxed);
268        f.debug_struct("EventListeners")
269            .field("subscriber_count", &count)
270            .field("next_id", &next_id)
271            .field("has_on_tool_start", &self.on_tool_start.is_some())
272            .field("has_on_tool_end", &self.on_tool_end.is_some())
273            .field("has_on_stream_event", &self.on_stream_event.is_some())
274            .finish()
275    }
276}
277
278/// SDK session construction options.
279///
280/// These options provide the programmatic equivalent of the core CLI startup
281/// path used in `src/main.rs`.
282#[derive(Clone)]
283pub struct SessionOptions {
284    pub provider: Option<String>,
285    pub model: Option<String>,
286    pub api_key: Option<String>,
287    pub thinking: Option<crate::model::ThinkingLevel>,
288    pub system_prompt: Option<String>,
289    pub append_system_prompt: Option<String>,
290    pub enabled_tools: Option<Vec<String>>,
291    pub working_directory: Option<PathBuf>,
292    pub no_session: bool,
293    pub session_path: Option<PathBuf>,
294    pub session_dir: Option<PathBuf>,
295    pub extension_paths: Vec<PathBuf>,
296    pub extension_policy: Option<String>,
297    pub repair_policy: Option<String>,
298    pub include_cwd_in_prompt: bool,
299    pub max_tool_iterations: usize,
300
301    /// Optional factory for the session's [`ToolRegistry`].
302    ///
303    /// When `None` (the default), [`create_agent_session`] builds the
304    /// built-in tool set from [`SessionOptions::enabled_tools`] exactly
305    /// as it always has — existing callers are unaffected.
306    ///
307    /// Setting this lets a downstream embedder layer custom tools onto
308    /// the registry: approval-gated wrappers around the built-ins, a
309    /// read-only "plan mode" subset, a `Task` tool that spawns nested
310    /// sandboxed sessions, etc. Implement [`ToolFactory`] on your own
311    /// `Send + Sync` type and call [`default_tool_registry`] from inside
312    /// it to inherit the current built-in resolution rules.
313    ///
314    /// Wrapped in `Arc` because [`SessionOptions`] is cloned through
315    /// the session-handle pipeline and the factory may need to outlive
316    /// the options struct.
317    pub tool_factory: Option<Arc<dyn ToolFactory>>,
318
319    /// Session-level event listener invoked for every [`AgentEvent`].
320    ///
321    /// Unlike the per-prompt callback passed to [`AgentSessionHandle::prompt`],
322    /// this fires for all prompts throughout the session lifetime.
323    pub on_event: Option<Arc<dyn Fn(AgentEvent) + Send + Sync>>,
324
325    /// Typed callback invoked when tool execution starts.
326    pub on_tool_start: Option<OnToolStart>,
327
328    /// Typed callback invoked when tool execution ends.
329    pub on_tool_end: Option<OnToolEnd>,
330
331    /// Callback for raw provider [`StreamEvent`]s.
332    pub on_stream_event: Option<OnStreamEvent>,
333}
334
335impl Default for SessionOptions {
336    fn default() -> Self {
337        Self {
338            provider: None,
339            model: None,
340            api_key: None,
341            thinking: None,
342            system_prompt: None,
343            append_system_prompt: None,
344            enabled_tools: None,
345            working_directory: None,
346            no_session: true,
347            session_path: None,
348            session_dir: None,
349            extension_paths: Vec::new(),
350            extension_policy: None,
351            repair_policy: None,
352            include_cwd_in_prompt: true,
353            max_tool_iterations: crate::agent::resolved_max_tool_iterations_default(),
354            tool_factory: None,
355            on_event: None,
356            on_tool_start: None,
357            on_tool_end: None,
358            on_stream_event: None,
359        }
360    }
361}
362
363/// Hook for assembling the session's [`ToolRegistry`].
364///
365/// Attach an `Arc<dyn ToolFactory>` to [`SessionOptions::tool_factory`]
366/// to inject custom tools, wrap the built-ins, or restrict the active
367/// set. The factory runs once during [`create_agent_session`], after
368/// the tool-name allowlist has been resolved from
369/// [`SessionOptions::enabled_tools`].
370///
371/// To layer on top of the existing built-ins instead of starting from
372/// scratch, call [`default_tool_registry`] from inside your impl and
373/// register additional tools (or wrap existing ones) on the returned
374/// registry.
375pub trait ToolFactory: Send + Sync {
376    /// Build the registry for a new session.
377    ///
378    /// - `enabled` is the resolved tool-name allowlist after
379    ///   [`SessionOptions::enabled_tools`] and CLI defaults are merged.
380    /// - `cwd` is the working directory the session opened in.
381    /// - `config` is pi's loaded [`Config`], passed through so custom
382    ///   tools can read settings such as `block_images` without
383    ///   duplicating the config-loading logic.
384    fn create_tool_registry(&self, enabled: &[&str], cwd: &Path, config: &Config) -> ToolRegistry;
385}
386
387/// Build the same [`ToolRegistry`] [`create_agent_session`] uses by
388/// default.
389///
390/// Useful inside a [`ToolFactory`] impl when you want to start from
391/// the standard built-in tool set and then layer custom tools on top
392/// (e.g. wrap each tool with an approval gate, or add a `Task` tool
393/// that spawns a nested session).
394pub fn default_tool_registry(enabled: &[&str], cwd: &Path, config: &Config) -> ToolRegistry {
395    ToolRegistry::new(enabled, cwd, Some(config))
396}
397
398/// Lightweight handle for programmatic embedding.
399///
400/// This wraps `AgentSession` and exposes high-level request methods while still
401/// allowing access to the underlying session when needed.
402///
403/// Session-level event listeners can be registered via [`Self::subscribe`] or
404/// by providing callbacks on [`SessionOptions`].  These fire for **every**
405/// prompt, in addition to the per-prompt `on_event` callback.
406pub struct AgentSessionHandle {
407    session: AgentSession,
408    listeners: EventListeners,
409}
410
411/// Snapshot of the current agent session state.
412#[derive(Debug, Clone, PartialEq, Eq)]
413pub struct AgentSessionState {
414    pub session_id: Option<String>,
415    pub provider: String,
416    pub model_id: String,
417    pub thinking_level: Option<crate::model::ThinkingLevel>,
418    pub save_enabled: bool,
419    pub message_count: usize,
420}
421
422/// Prompt completion payload returned by `SessionTransport`.
423#[derive(Debug, Clone)]
424pub enum SessionPromptResult {
425    InProcess(AssistantMessage),
426    RpcEvents(Vec<Value>),
427}
428
429/// Event wrapper used by the unified `SessionTransport` callback.
430#[derive(Debug, Clone)]
431pub enum SessionTransportEvent {
432    InProcess(AgentEvent),
433    Rpc(Value),
434}
435
436/// Unified session state snapshot across in-process and RPC transports.
437#[derive(Debug, Clone, PartialEq)]
438pub enum SessionTransportState {
439    InProcess(AgentSessionState),
440    Rpc(Box<RpcSessionState>),
441}
442
443/// Model metadata exposed by RPC APIs.
444#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
445#[serde(rename_all = "camelCase")]
446pub struct RpcModelInfo {
447    pub id: String,
448    pub name: String,
449    pub api: String,
450    pub provider: String,
451    #[serde(default)]
452    pub base_url: String,
453    #[serde(default)]
454    pub reasoning: bool,
455    #[serde(default)]
456    pub input: Vec<InputType>,
457    #[serde(default)]
458    pub context_window: u32,
459    #[serde(default)]
460    pub max_tokens: u32,
461    #[serde(default)]
462    pub cost: Option<ModelCost>,
463}
464
465/// Session state payload returned by RPC `get_state`.
466#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
467#[serde(rename_all = "camelCase")]
468#[allow(clippy::struct_excessive_bools)]
469pub struct RpcSessionState {
470    #[serde(default)]
471    pub model: Option<RpcModelInfo>,
472    #[serde(default)]
473    pub thinking_level: String,
474    #[serde(default)]
475    pub is_streaming: bool,
476    #[serde(default)]
477    pub is_compacting: bool,
478    #[serde(default)]
479    pub steering_mode: String,
480    #[serde(default)]
481    pub follow_up_mode: String,
482    #[serde(default)]
483    pub session_file: Option<String>,
484    #[serde(default)]
485    pub session_id: String,
486    #[serde(default)]
487    pub session_name: Option<String>,
488    #[serde(default)]
489    pub auto_compaction_enabled: bool,
490    #[serde(default)]
491    pub auto_retry_enabled: bool,
492    #[serde(default)]
493    pub message_count: usize,
494    #[serde(default)]
495    pub pending_message_count: usize,
496    #[serde(default)]
497    pub durability_mode: String,
498}
499
500/// Session-level token aggregates returned by RPC `get_session_stats`.
501#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
502#[serde(rename_all = "camelCase")]
503pub struct RpcTokenStats {
504    pub input: u64,
505    pub output: u64,
506    pub cache_read: u64,
507    pub cache_write: u64,
508    pub total: u64,
509}
510
511/// Session stats payload returned by RPC `get_session_stats`.
512#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
513#[serde(rename_all = "camelCase")]
514pub struct RpcSessionStats {
515    #[serde(default)]
516    pub session_file: Option<String>,
517    pub session_id: String,
518    pub user_messages: u64,
519    pub assistant_messages: u64,
520    pub tool_calls: u64,
521    pub tool_results: u64,
522    pub total_messages: u64,
523    pub tokens: RpcTokenStats,
524    pub cost: f64,
525}
526
527/// Result payload for `new_session` and `switch_session`.
528#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
529pub struct RpcCancelledResult {
530    pub cancelled: bool,
531}
532
533/// Result payload returned by RPC `cycle_model`.
534#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
535#[serde(rename_all = "camelCase")]
536pub struct RpcCycleModelResult {
537    pub model: RpcModelInfo,
538    pub thinking_level: crate::model::ThinkingLevel,
539    pub is_scoped: bool,
540}
541
542/// Result payload returned by RPC `cycle_thinking_level`.
543#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
544pub struct RpcThinkingLevelResult {
545    pub level: crate::model::ThinkingLevel,
546}
547
548/// Bash execution result returned by RPC `bash`.
549#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
550#[serde(rename_all = "camelCase")]
551pub struct RpcBashResult {
552    pub output: String,
553    pub exit_code: i32,
554    pub cancelled: bool,
555    pub truncated: bool,
556    pub full_output_path: Option<String>,
557}
558
559/// Compaction result returned by RPC `compact`.
560#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
561#[serde(rename_all = "camelCase")]
562pub struct RpcCompactionResult {
563    pub summary: String,
564    pub first_kept_entry_id: String,
565    pub tokens_before: u64,
566    #[serde(default)]
567    pub details: Value,
568}
569
570/// Result payload returned by RPC `fork`.
571#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
572pub struct RpcForkResult {
573    pub text: String,
574    pub cancelled: bool,
575}
576
577/// Forkable message entry returned by RPC `get_fork_messages`.
578#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
579#[serde(rename_all = "camelCase")]
580pub struct RpcForkMessage {
581    pub entry_id: String,
582    pub text: String,
583}
584
585/// Slash command metadata returned by RPC `get_commands`.
586#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
587pub struct RpcCommandInfo {
588    pub name: String,
589    #[serde(default)]
590    pub description: Option<String>,
591    pub source: String,
592    #[serde(default)]
593    pub location: Option<String>,
594    #[serde(default)]
595    pub path: Option<String>,
596}
597
598/// Export HTML response payload.
599#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
600pub struct RpcExportHtmlResult {
601    pub path: String,
602}
603
604/// Last-assistant-text response payload.
605#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
606pub struct RpcLastAssistantText {
607    pub text: Option<String>,
608}
609
610/// Extension UI response payload used by RPC `extension_ui_response`.
611#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
612#[serde(tag = "kind", rename_all = "snake_case")]
613pub enum RpcExtensionUiResponse {
614    Value { value: Value },
615    Confirmed { confirmed: bool },
616    Cancelled,
617}
618
619/// Process-boundary transport options for SDK callers that prefer RPC mode.
620#[derive(Debug, Clone)]
621pub struct RpcTransportOptions {
622    pub binary_path: PathBuf,
623    pub args: Vec<String>,
624    pub cwd: Option<PathBuf>,
625}
626
627impl Default for RpcTransportOptions {
628    fn default() -> Self {
629        Self {
630            binary_path: PathBuf::from("pi"),
631            args: vec!["--mode".to_string(), "rpc".to_string()],
632            cwd: None,
633        }
634    }
635}
636
637/// Subprocess-backed SDK transport for `pi --mode rpc`.
638pub struct RpcTransportClient {
639    child: Child,
640    stdin: BufWriter<ChildStdin>,
641    stdout: BufReader<ChildStdout>,
642    next_request_id: u64,
643}
644
645/// Unified adapter over in-process and subprocess-backed session control.
646pub enum SessionTransport {
647    InProcess(Box<AgentSessionHandle>),
648    RpcSubprocess(RpcTransportClient),
649}
650
651impl SessionTransport {
652    pub async fn in_process(options: SessionOptions) -> Result<Self> {
653        create_agent_session(options)
654            .await
655            .map(Box::new)
656            .map(Self::InProcess)
657    }
658
659    pub fn rpc_subprocess(options: RpcTransportOptions) -> Result<Self> {
660        RpcTransportClient::connect(options).map(Self::RpcSubprocess)
661    }
662
663    #[allow(clippy::missing_const_for_fn)]
664    pub fn as_in_process_mut(&mut self) -> Option<&mut AgentSessionHandle> {
665        match self {
666            Self::InProcess(handle) => Some(handle.as_mut()),
667            Self::RpcSubprocess(_) => None,
668        }
669    }
670
671    #[allow(clippy::missing_const_for_fn)]
672    pub fn as_rpc_mut(&mut self) -> Option<&mut RpcTransportClient> {
673        match self {
674            Self::InProcess(_) => None,
675            Self::RpcSubprocess(client) => Some(client),
676        }
677    }
678
679    /// Send one prompt over whichever transport is active.
680    ///
681    /// - In-process mode returns the final assistant message.
682    /// - RPC mode waits for `agent_end` and returns collected raw events.
683    pub async fn prompt(
684        &mut self,
685        input: impl Into<String>,
686        on_event: impl Fn(SessionTransportEvent) + Send + Sync + 'static,
687    ) -> Result<SessionPromptResult> {
688        let input = input.into();
689        let on_event = Arc::new(on_event);
690        match self {
691            Self::InProcess(handle) => {
692                let on_event = Arc::clone(&on_event);
693                let assistant = handle
694                    .prompt(input, move |event| {
695                        (on_event)(SessionTransportEvent::InProcess(event));
696                    })
697                    .await?;
698                Ok(SessionPromptResult::InProcess(assistant))
699            }
700            Self::RpcSubprocess(client) => {
701                let events = client.prompt(input).await?;
702                for event in events.iter().cloned() {
703                    (on_event)(SessionTransportEvent::Rpc(event));
704                }
705                Ok(SessionPromptResult::RpcEvents(events))
706            }
707        }
708    }
709
710    /// Return a state snapshot from the active transport.
711    pub async fn state(&mut self) -> Result<SessionTransportState> {
712        match self {
713            Self::InProcess(handle) => handle.state().await.map(SessionTransportState::InProcess),
714            Self::RpcSubprocess(client) => client
715                .get_state()
716                .await
717                .map(Box::new)
718                .map(SessionTransportState::Rpc),
719        }
720    }
721
722    /// Update provider/model for the active transport.
723    pub async fn set_model(&mut self, provider: &str, model_id: &str) -> Result<()> {
724        match self {
725            Self::InProcess(handle) => handle.set_model(provider, model_id).await,
726            Self::RpcSubprocess(client) => {
727                let _ = client.set_model(provider, model_id).await?;
728                Ok(())
729            }
730        }
731    }
732
733    /// Shut down transport resources (best effort for in-process, explicit for RPC).
734    pub fn shutdown(&mut self) -> Result<()> {
735        match self {
736            Self::InProcess(_) => Ok(()),
737            Self::RpcSubprocess(client) => client.shutdown(),
738        }
739    }
740}
741
742impl RpcTransportClient {
743    pub fn connect(options: RpcTransportOptions) -> Result<Self> {
744        let mut command = Command::new(&options.binary_path);
745        command
746            .args(&options.args)
747            .stdin(Stdio::piped())
748            .stdout(Stdio::piped())
749            .stderr(Stdio::inherit());
750        if let Some(cwd) = options.cwd {
751            command.current_dir(cwd);
752        }
753
754        let mut child = command.spawn().map_err(|err| {
755            Error::config(format!(
756                "Failed to spawn RPC subprocess {}: {err}",
757                options.binary_path.display()
758            ))
759        })?;
760        let stdin = child
761            .stdin
762            .take()
763            .ok_or_else(|| Error::config("RPC subprocess stdin is not piped"))?;
764        let stdout = child
765            .stdout
766            .take()
767            .ok_or_else(|| Error::config("RPC subprocess stdout is not piped"))?;
768
769        Ok(Self {
770            child,
771            stdin: BufWriter::new(stdin),
772            stdout: BufReader::new(stdout),
773            next_request_id: 1,
774        })
775    }
776
777    #[allow(
778        clippy::unused_async,
779        reason = "SDK RPC transport keeps an async public API"
780    )]
781    pub async fn request(&mut self, command: &str, payload: Map<String, Value>) -> Result<Value> {
782        let request_id = self.next_request_id();
783        let mut command_payload = Map::new();
784        command_payload.insert("type".to_string(), Value::String(command.to_string()));
785        command_payload.insert("id".to_string(), Value::String(request_id.clone()));
786        command_payload.extend(payload);
787
788        self.write_json_line(&Value::Object(command_payload))?;
789        self.wait_for_response(&request_id, command)
790    }
791
792    fn parse_response_data<T: DeserializeOwned>(data: Value, command: &str) -> Result<T> {
793        serde_json::from_value(data).map_err(|err| {
794            Error::api(format!(
795                "Failed to decode RPC `{command}` response payload: {err}"
796            ))
797        })
798    }
799
800    async fn request_typed<T: DeserializeOwned>(
801        &mut self,
802        command: &str,
803        payload: Map<String, Value>,
804    ) -> Result<T> {
805        let data = self.request(command, payload).await?;
806        Self::parse_response_data(data, command)
807    }
808
809    async fn request_no_data(&mut self, command: &str, payload: Map<String, Value>) -> Result<()> {
810        let _ = self.request(command, payload).await?;
811        Ok(())
812    }
813
814    pub async fn steer(&mut self, message: impl Into<String>) -> Result<()> {
815        let mut payload = Map::new();
816        payload.insert("message".to_string(), Value::String(message.into()));
817        self.request_no_data("steer", payload).await
818    }
819
820    pub async fn follow_up(&mut self, message: impl Into<String>) -> Result<()> {
821        let mut payload = Map::new();
822        payload.insert("message".to_string(), Value::String(message.into()));
823        self.request_no_data("follow_up", payload).await
824    }
825
826    pub async fn abort(&mut self) -> Result<()> {
827        self.request_no_data("abort", Map::new()).await
828    }
829
830    pub async fn new_session(
831        &mut self,
832        parent_session: Option<&Path>,
833    ) -> Result<RpcCancelledResult> {
834        let mut payload = Map::new();
835        if let Some(parent_session) = parent_session {
836            payload.insert(
837                "parentSession".to_string(),
838                Value::String(parent_session.display().to_string()),
839            );
840        }
841        self.request_typed("new_session", payload).await
842    }
843
844    pub async fn get_state(&mut self) -> Result<RpcSessionState> {
845        self.request_typed("get_state", Map::new()).await
846    }
847
848    pub async fn get_session_stats(&mut self) -> Result<RpcSessionStats> {
849        self.request_typed("get_session_stats", Map::new()).await
850    }
851
852    pub async fn get_messages(&mut self) -> Result<Vec<Value>> {
853        #[derive(Deserialize)]
854        struct MessagesPayload {
855            messages: Vec<Value>,
856        }
857        let payload: MessagesPayload = self.request_typed("get_messages", Map::new()).await?;
858        Ok(payload.messages)
859    }
860
861    pub async fn get_available_models(&mut self) -> Result<Vec<RpcModelInfo>> {
862        #[derive(Deserialize)]
863        struct ModelsPayload {
864            models: Vec<RpcModelInfo>,
865        }
866        let payload: ModelsPayload = self
867            .request_typed("get_available_models", Map::new())
868            .await?;
869        Ok(payload.models)
870    }
871
872    pub async fn set_model(&mut self, provider: &str, model_id: &str) -> Result<RpcModelInfo> {
873        let mut payload = Map::new();
874        payload.insert("provider".to_string(), Value::String(provider.to_string()));
875        payload.insert("modelId".to_string(), Value::String(model_id.to_string()));
876        self.request_typed("set_model", payload).await
877    }
878
879    pub async fn cycle_model(&mut self) -> Result<Option<RpcCycleModelResult>> {
880        self.request_typed("cycle_model", Map::new()).await
881    }
882
883    pub async fn set_thinking_level(&mut self, level: crate::model::ThinkingLevel) -> Result<()> {
884        let mut payload = Map::new();
885        payload.insert("level".to_string(), Value::String(level.to_string()));
886        self.request_no_data("set_thinking_level", payload).await
887    }
888
889    pub async fn cycle_thinking_level(&mut self) -> Result<Option<RpcThinkingLevelResult>> {
890        self.request_typed("cycle_thinking_level", Map::new()).await
891    }
892
893    pub async fn set_steering_mode(&mut self, mode: &str) -> Result<()> {
894        let mut payload = Map::new();
895        payload.insert("mode".to_string(), Value::String(mode.to_string()));
896        self.request_no_data("set_steering_mode", payload).await
897    }
898
899    pub async fn set_follow_up_mode(&mut self, mode: &str) -> Result<()> {
900        let mut payload = Map::new();
901        payload.insert("mode".to_string(), Value::String(mode.to_string()));
902        self.request_no_data("set_follow_up_mode", payload).await
903    }
904
905    pub async fn set_auto_compaction(&mut self, enabled: bool) -> Result<()> {
906        let mut payload = Map::new();
907        payload.insert("enabled".to_string(), Value::Bool(enabled));
908        self.request_no_data("set_auto_compaction", payload).await
909    }
910
911    pub async fn set_auto_retry(&mut self, enabled: bool) -> Result<()> {
912        let mut payload = Map::new();
913        payload.insert("enabled".to_string(), Value::Bool(enabled));
914        self.request_no_data("set_auto_retry", payload).await
915    }
916
917    pub async fn abort_retry(&mut self) -> Result<()> {
918        self.request_no_data("abort_retry", Map::new()).await
919    }
920
921    pub async fn set_session_name(&mut self, name: impl Into<String>) -> Result<()> {
922        let mut payload = Map::new();
923        payload.insert("name".to_string(), Value::String(name.into()));
924        self.request_no_data("set_session_name", payload).await
925    }
926
927    pub async fn get_last_assistant_text(&mut self) -> Result<Option<String>> {
928        let payload: RpcLastAssistantText = self
929            .request_typed("get_last_assistant_text", Map::new())
930            .await?;
931        Ok(payload.text)
932    }
933
934    pub async fn export_html(&mut self, output_path: Option<&Path>) -> Result<RpcExportHtmlResult> {
935        let mut payload = Map::new();
936        if let Some(path) = output_path {
937            payload.insert(
938                "outputPath".to_string(),
939                Value::String(path.display().to_string()),
940            );
941        }
942        self.request_typed("export_html", payload).await
943    }
944
945    pub async fn bash(&mut self, command: impl Into<String>) -> Result<RpcBashResult> {
946        let mut payload = Map::new();
947        payload.insert("command".to_string(), Value::String(command.into()));
948        self.request_typed("bash", payload).await
949    }
950
951    pub async fn abort_bash(&mut self) -> Result<()> {
952        self.request_no_data("abort_bash", Map::new()).await
953    }
954
955    pub async fn compact(&mut self) -> Result<RpcCompactionResult> {
956        self.compact_with_instructions(None).await
957    }
958
959    pub async fn compact_with_instructions(
960        &mut self,
961        custom_instructions: Option<&str>,
962    ) -> Result<RpcCompactionResult> {
963        let mut payload = Map::new();
964        if let Some(custom_instructions) = custom_instructions {
965            payload.insert(
966                "customInstructions".to_string(),
967                Value::String(custom_instructions.to_string()),
968            );
969        }
970        self.request_typed("compact", payload).await
971    }
972
973    pub async fn switch_session(&mut self, session_path: &Path) -> Result<RpcCancelledResult> {
974        let mut payload = Map::new();
975        payload.insert(
976            "sessionPath".to_string(),
977            Value::String(session_path.display().to_string()),
978        );
979        self.request_typed("switch_session", payload).await
980    }
981
982    pub async fn fork(&mut self, entry_id: impl Into<String>) -> Result<RpcForkResult> {
983        let mut payload = Map::new();
984        payload.insert("entryId".to_string(), Value::String(entry_id.into()));
985        self.request_typed("fork", payload).await
986    }
987
988    pub async fn get_fork_messages(&mut self) -> Result<Vec<RpcForkMessage>> {
989        #[derive(Deserialize)]
990        struct ForkMessagesPayload {
991            messages: Vec<RpcForkMessage>,
992        }
993        let payload: ForkMessagesPayload =
994            self.request_typed("get_fork_messages", Map::new()).await?;
995        Ok(payload.messages)
996    }
997
998    pub async fn get_commands(&mut self) -> Result<Vec<RpcCommandInfo>> {
999        #[derive(Deserialize)]
1000        struct CommandsPayload {
1001            commands: Vec<RpcCommandInfo>,
1002        }
1003        let payload: CommandsPayload = self.request_typed("get_commands", Map::new()).await?;
1004        Ok(payload.commands)
1005    }
1006
1007    pub async fn extension_ui_response(
1008        &mut self,
1009        request_id: &str,
1010        response: RpcExtensionUiResponse,
1011    ) -> Result<bool> {
1012        #[derive(Deserialize)]
1013        struct ExtensionUiResolvedPayload {
1014            resolved: bool,
1015        }
1016
1017        let mut payload = Map::new();
1018        payload.insert(
1019            "requestId".to_string(),
1020            Value::String(request_id.to_string()),
1021        );
1022
1023        match response {
1024            RpcExtensionUiResponse::Value { value } => {
1025                payload.insert("value".to_string(), value);
1026            }
1027            RpcExtensionUiResponse::Confirmed { confirmed } => {
1028                payload.insert("confirmed".to_string(), Value::Bool(confirmed));
1029            }
1030            RpcExtensionUiResponse::Cancelled => {
1031                payload.insert("cancelled".to_string(), Value::Bool(true));
1032            }
1033        }
1034
1035        let response: Option<ExtensionUiResolvedPayload> =
1036            self.request_typed("extension_ui_response", payload).await?;
1037        Ok(response.is_none_or(|payload| payload.resolved))
1038    }
1039
1040    pub async fn prompt(&mut self, message: impl Into<String>) -> Result<Vec<Value>> {
1041        self.prompt_with_options(message, None, None).await
1042    }
1043
1044    #[allow(
1045        clippy::unused_async,
1046        reason = "SDK RPC transport keeps an async public API"
1047    )]
1048    pub async fn prompt_with_options(
1049        &mut self,
1050        message: impl Into<String>,
1051        images: Option<Vec<ImageContent>>,
1052        streaming_behavior: Option<&str>,
1053    ) -> Result<Vec<Value>> {
1054        let request_id = self.next_request_id();
1055        let mut payload = Map::new();
1056        payload.insert("type".to_string(), Value::String("prompt".to_string()));
1057        payload.insert("id".to_string(), Value::String(request_id.clone()));
1058        payload.insert("message".to_string(), Value::String(message.into()));
1059        if let Some(images) = images {
1060            payload.insert(
1061                "images".to_string(),
1062                serde_json::to_value(images).map_err(|err| Error::Json(Box::new(err)))?,
1063            );
1064        }
1065        if let Some(streaming_behavior) = streaming_behavior {
1066            payload.insert(
1067                "streamingBehavior".to_string(),
1068                Value::String(streaming_behavior.to_string()),
1069            );
1070        }
1071        let payload = Value::Object(payload);
1072        self.write_json_line(&payload)?;
1073
1074        let mut saw_ack = false;
1075        let mut events = Vec::new();
1076        loop {
1077            let item = self.read_json_line()?;
1078            let item_type = item.get("type").and_then(Value::as_str);
1079            if item_type == Some("response") {
1080                if item.get("id").and_then(Value::as_str) != Some(request_id.as_str()) {
1081                    continue;
1082                }
1083                let success = item
1084                    .get("success")
1085                    .and_then(Value::as_bool)
1086                    .unwrap_or(false);
1087                if !success {
1088                    return Err(rpc_error_from_response(&item, "prompt"));
1089                }
1090                saw_ack = true;
1091                continue;
1092            }
1093
1094            if saw_ack {
1095                let reached_end = item_type == Some("agent_end");
1096                events.push(item);
1097                if reached_end {
1098                    return Ok(events);
1099                }
1100            }
1101        }
1102    }
1103
1104    pub fn shutdown(&mut self) -> Result<()> {
1105        if self
1106            .child
1107            .try_wait()
1108            .map_err(|err| Error::Io(Box::new(err)))?
1109            .is_none()
1110        {
1111            self.child.kill().map_err(|err| Error::Io(Box::new(err)))?;
1112        }
1113        let _ = self.child.wait();
1114        Ok(())
1115    }
1116
1117    fn next_request_id(&mut self) -> String {
1118        let id = format!("rpc-{}", self.next_request_id);
1119        self.next_request_id = self.next_request_id.saturating_add(1);
1120        id
1121    }
1122
1123    fn write_json_line(&mut self, payload: &Value) -> Result<()> {
1124        let encoded = serde_json::to_string(payload).map_err(|err| Error::Json(Box::new(err)))?;
1125        self.stdin
1126            .write_all(encoded.as_bytes())
1127            .map_err(|err| Error::Io(Box::new(err)))?;
1128        self.stdin
1129            .write_all(b"\n")
1130            .map_err(|err| Error::Io(Box::new(err)))?;
1131        self.stdin.flush().map_err(|err| Error::Io(Box::new(err)))?;
1132        Ok(())
1133    }
1134
1135    fn read_json_line(&mut self) -> Result<Value> {
1136        let mut line = String::new();
1137        let read = self
1138            .stdout
1139            .read_line(&mut line)
1140            .map_err(|err| Error::Io(Box::new(err)))?;
1141        if read == 0 {
1142            return Err(Error::api(
1143                "RPC subprocess exited before sending a response",
1144            ));
1145        }
1146        serde_json::from_str(line.trim_end()).map_err(|err| Error::Json(Box::new(err)))
1147    }
1148
1149    fn wait_for_response(&mut self, request_id: &str, command: &str) -> Result<Value> {
1150        loop {
1151            let item = self.read_json_line()?;
1152            let Some(item_type) = item.get("type").and_then(Value::as_str) else {
1153                continue;
1154            };
1155            if item_type != "response" {
1156                continue;
1157            }
1158            if item.get("id").and_then(Value::as_str) != Some(request_id) {
1159                continue;
1160            }
1161            if item.get("command").and_then(Value::as_str) != Some(command) {
1162                continue;
1163            }
1164
1165            let success = item
1166                .get("success")
1167                .and_then(Value::as_bool)
1168                .unwrap_or(false);
1169            if success {
1170                return Ok(item.get("data").cloned().unwrap_or(Value::Null));
1171            }
1172            return Err(rpc_error_from_response(&item, command));
1173        }
1174    }
1175}
1176
1177impl Drop for RpcTransportClient {
1178    fn drop(&mut self) {
1179        let _ = self.shutdown();
1180    }
1181}
1182
1183fn rpc_error_from_response(response: &Value, command: &str) -> Error {
1184    let error = response
1185        .get("error")
1186        .and_then(Value::as_str)
1187        .unwrap_or("RPC command failed");
1188    Error::api(format!("RPC {command} failed: {error}"))
1189}
1190
1191impl AgentSessionHandle {
1192    /// Create a handle from a pre-built `AgentSession` with custom listeners.
1193    ///
1194    /// This is useful for tests and advanced embedding scenarios where
1195    /// the full `create_agent_session()` flow is not needed.
1196    pub const fn from_session_with_listeners(
1197        session: AgentSession,
1198        listeners: EventListeners,
1199    ) -> Self {
1200        Self { session, listeners }
1201    }
1202
1203    /// Send one user prompt through the agent loop.
1204    ///
1205    /// The `on_event` callback receives events for this prompt only.
1206    /// Session-level listeners registered via [`Self::subscribe`] or
1207    /// [`SessionOptions`] callbacks also fire for every event.
1208    pub async fn prompt(
1209        &mut self,
1210        input: impl Into<String>,
1211        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
1212    ) -> Result<AssistantMessage> {
1213        let combined = self.make_combined_callback(on_event);
1214        self.session.run_text(input.into(), combined).await
1215    }
1216
1217    /// Send one user prompt through the agent loop with an explicit abort signal.
1218    pub async fn prompt_with_abort(
1219        &mut self,
1220        input: impl Into<String>,
1221        abort_signal: AbortSignal,
1222        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
1223    ) -> Result<AssistantMessage> {
1224        let combined = self.make_combined_callback(on_event);
1225        self.session
1226            .run_text_with_abort(input.into(), Some(abort_signal), combined)
1227            .await
1228    }
1229
1230    /// Continue the current agent loop without adding a new user prompt.
1231    ///
1232    /// This is useful for retry/continuation flows where session history or
1233    /// injected messages should drive the next turn without synthesizing a new
1234    /// user message through [`Self::prompt`].
1235    pub async fn continue_turn(
1236        &mut self,
1237        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
1238    ) -> Result<AssistantMessage> {
1239        let combined = self.make_combined_callback(on_event);
1240        self.session
1241            .sync_runtime_selection_from_session_header()
1242            .await?;
1243        self.session
1244            .agent
1245            .run_continue_with_abort(None, combined)
1246            .await
1247    }
1248
1249    /// Continue the current agent loop with an explicit abort signal.
1250    pub async fn continue_turn_with_abort(
1251        &mut self,
1252        abort_signal: AbortSignal,
1253        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
1254    ) -> Result<AssistantMessage> {
1255        let combined = self.make_combined_callback(on_event);
1256        self.session
1257            .sync_runtime_selection_from_session_header()
1258            .await?;
1259        self.session
1260            .agent
1261            .run_continue_with_abort(Some(abort_signal), combined)
1262            .await
1263    }
1264
1265    /// Create a new abort handle/signal pair for prompt cancellation.
1266    pub fn new_abort_handle() -> (AbortHandle, AbortSignal) {
1267        AbortHandle::new()
1268    }
1269
1270    /// Register a session-level event listener.
1271    ///
1272    /// The listener fires for every [`AgentEvent`] across all future prompts
1273    /// until removed via [`Self::unsubscribe`].
1274    ///
1275    /// Returns a [`SubscriptionId`] that can be used to remove the listener.
1276    pub fn subscribe(
1277        &self,
1278        listener: impl Fn(AgentEvent) + Send + Sync + 'static,
1279    ) -> SubscriptionId {
1280        self.listeners.subscribe(Arc::new(listener))
1281    }
1282
1283    /// Remove a previously registered event listener.
1284    ///
1285    /// Returns `true` if the listener was found and removed.
1286    pub fn unsubscribe(&self, id: SubscriptionId) -> bool {
1287        self.listeners.unsubscribe(id)
1288    }
1289
1290    /// Access the session-level event listeners.
1291    pub const fn listeners(&self) -> &EventListeners {
1292        &self.listeners
1293    }
1294
1295    /// Mutable access to session-level event listeners.
1296    ///
1297    /// Allows updating typed hooks (`on_tool_start`, `on_tool_end`,
1298    /// `on_stream_event`) after session creation.
1299    pub const fn listeners_mut(&mut self) -> &mut EventListeners {
1300        &mut self.listeners
1301    }
1302
1303    // -----------------------------------------------------------------
1304    // Extensions & Capability Policy
1305    // -----------------------------------------------------------------
1306
1307    /// Whether this session has extensions loaded.
1308    pub const fn has_extensions(&self) -> bool {
1309        self.session.extensions.is_some()
1310    }
1311
1312    /// Return a reference to the extension manager (if extensions are loaded).
1313    pub fn extension_manager(&self) -> Option<&ExtensionManager> {
1314        self.session
1315            .extensions
1316            .as_ref()
1317            .map(ExtensionRegion::manager)
1318    }
1319
1320    /// Return a reference to the extension region (if extensions are loaded).
1321    ///
1322    /// The region wraps the extension manager with lifecycle management.
1323    pub const fn extension_region(&self) -> Option<&ExtensionRegion> {
1324        self.session.extensions.as_ref()
1325    }
1326
1327    // -----------------------------------------------------------------
1328    // Provider & Model
1329    // -----------------------------------------------------------------
1330
1331    /// Return the active provider/model pair.
1332    pub fn model(&self) -> (String, String) {
1333        let provider = self.session.agent.provider();
1334        (provider.name().to_string(), provider.model_id().to_string())
1335    }
1336
1337    /// Update the active provider/model pair and persist it to session metadata.
1338    pub async fn set_model(&mut self, provider: &str, model_id: &str) -> Result<()> {
1339        self.session.set_provider_model(provider, model_id).await
1340    }
1341
1342    /// Return the currently configured thinking level.
1343    pub const fn thinking_level(&self) -> Option<crate::model::ThinkingLevel> {
1344        self.session.agent.stream_options().thinking_level
1345    }
1346
1347    /// Alias for thinking level access, matching the SDK naming style.
1348    pub const fn thinking(&self) -> Option<crate::model::ThinkingLevel> {
1349        self.thinking_level()
1350    }
1351
1352    /// Update thinking level and persist it to session metadata.
1353    ///
1354    /// Delegates to [`AgentSession::set_thinking_level`] so the runtime
1355    /// reconfiguration logic (clamping, history dedupe, persistence) lives in
1356    /// one place and stays consistent across the SDK handle and the ACP
1357    /// transport, which both need it.
1358    pub async fn set_thinking_level(&mut self, level: crate::model::ThinkingLevel) -> Result<()> {
1359        self.session.set_thinking_level(level).await
1360    }
1361
1362    /// Update the persisted session display name.
1363    ///
1364    /// Records a `SessionInfo` entry with the new name on the leaf path and
1365    /// flushes session metadata to disk, mirroring how the in-process rename
1366    /// path works for runtime CLIs that need to retitle a live session.
1367    pub async fn set_session_name(&mut self, name: impl Into<String>) -> Result<()> {
1368        let name = name.into();
1369        let cx = crate::agent_cx::AgentCx::for_request();
1370        {
1371            let mut guard = self
1372                .session
1373                .session
1374                .lock(cx.cx())
1375                .await
1376                .map_err(|e| Error::session(e.to_string()))?;
1377            guard.append_session_info(Some(name));
1378        }
1379        self.session.persist_session().await
1380    }
1381
1382    /// Read the per-prompt `max_tokens` cap currently configured on the
1383    /// session's stream options.
1384    ///
1385    /// `None` means the provider's default applies (e.g. 4096 for the
1386    /// openai-compat provider), which can truncate generations that emit
1387    /// large tool-call arguments. Embedders carrying tool-heavy traffic
1388    /// should raise this via [`Self::set_max_tokens`].
1389    pub const fn max_tokens(&self) -> Option<u32> {
1390        self.session.agent.stream_options().max_tokens
1391    }
1392
1393    /// Override the per-prompt `max_tokens` cap.
1394    ///
1395    /// Persists for the lifetime of the in-process handle only; not written
1396    /// to session metadata. Pass `None` to fall back to the provider's
1397    /// default cap.
1398    pub const fn set_max_tokens(&mut self, max_tokens: Option<u32>) {
1399        self.session.agent.stream_options_mut().max_tokens = max_tokens;
1400    }
1401
1402    /// Return all model messages for the current session path.
1403    pub async fn messages(&self) -> Result<Vec<Message>> {
1404        let cx = crate::agent_cx::AgentCx::for_request();
1405        let guard = self
1406            .session
1407            .session
1408            .lock(cx.cx())
1409            .await
1410            .map_err(|e| Error::session(e.to_string()))?;
1411        Ok(guard.to_messages_for_current_path())
1412    }
1413
1414    /// Return a lightweight state snapshot.
1415    pub async fn state(&self) -> Result<AgentSessionState> {
1416        let (provider, model_id) = self.model();
1417        let thinking_level = self.thinking_level();
1418        let save_enabled = self.session.save_enabled();
1419        let cx = crate::agent_cx::AgentCx::for_request();
1420        let guard = self
1421            .session
1422            .session
1423            .lock(cx.cx())
1424            .await
1425            .map_err(|e| Error::session(e.to_string()))?;
1426        let session_id = Some(guard.header.id.clone());
1427        let message_count = guard.to_messages_for_current_path().len();
1428
1429        Ok(AgentSessionState {
1430            session_id,
1431            provider,
1432            model_id,
1433            thinking_level,
1434            save_enabled,
1435            message_count,
1436        })
1437    }
1438
1439    /// Trigger an immediate compaction pass (if compaction is enabled).
1440    pub async fn compact(
1441        &mut self,
1442        on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
1443    ) -> Result<()> {
1444        self.session.compact_now(on_event).await
1445    }
1446
1447    /// Access the underlying `AgentSession`.
1448    pub const fn session(&self) -> &AgentSession {
1449        &self.session
1450    }
1451
1452    /// Mutable access to the underlying `AgentSession`.
1453    pub const fn session_mut(&mut self) -> &mut AgentSession {
1454        &mut self.session
1455    }
1456
1457    /// Consume the handle and return the inner `AgentSession`.
1458    pub fn into_inner(self) -> AgentSession {
1459        self.session
1460    }
1461
1462    /// Build a combined callback that fans out to the per-prompt callback,
1463    /// session-level subscribers, and typed hooks.
1464    fn make_combined_callback(
1465        &self,
1466        per_prompt: impl Fn(AgentEvent) + Send + Sync + 'static,
1467    ) -> impl Fn(AgentEvent) + Send + Sync + 'static {
1468        let listeners = self.listeners.clone();
1469        move |event: AgentEvent| {
1470            // Typed tool hooks — fire before generic listeners.
1471            match &event {
1472                AgentEvent::ToolExecutionStart {
1473                    tool_name, args, ..
1474                } => {
1475                    listeners.notify_tool_start(tool_name, args);
1476                }
1477                AgentEvent::ToolExecutionEnd {
1478                    tool_name,
1479                    result,
1480                    is_error,
1481                    ..
1482                } => {
1483                    listeners.notify_tool_end(tool_name, result, *is_error);
1484                }
1485                AgentEvent::MessageUpdate {
1486                    assistant_message_event,
1487                    ..
1488                } => {
1489                    // Forward raw stream events from the nested
1490                    // `AssistantMessageEvent` when possible.
1491                    if let Some(stream_ev) =
1492                        stream_event_from_assistant_message_event(assistant_message_event)
1493                    {
1494                        listeners.notify_stream_event(&stream_ev);
1495                    }
1496                }
1497                _ => {}
1498            }
1499
1500            // Session-level generic subscribers.
1501            listeners.notify(&event);
1502
1503            // Per-prompt callback.
1504            per_prompt(event);
1505        }
1506    }
1507}
1508
1509/// Extract a raw [`StreamEvent`] equivalent from an [`AssistantMessageEvent`].
1510///
1511/// This lets the typed `on_stream_event` hook fire with the low-level provider
1512/// protocol event rather than the wrapped agent-level event.
1513fn stream_event_from_assistant_message_event(
1514    event: &crate::model::AssistantMessageEvent,
1515) -> Option<StreamEvent> {
1516    use crate::model::AssistantMessageEvent as AME;
1517    match event {
1518        AME::TextStart { content_index, .. } => Some(StreamEvent::TextStart {
1519            content_index: *content_index,
1520        }),
1521        AME::TextDelta {
1522            content_index,
1523            delta,
1524            ..
1525        } => Some(StreamEvent::TextDelta {
1526            content_index: *content_index,
1527            delta: delta.clone(),
1528        }),
1529        AME::TextEnd {
1530            content_index,
1531            content,
1532            ..
1533        } => Some(StreamEvent::TextEnd {
1534            content_index: *content_index,
1535            content: content.clone(),
1536        }),
1537        AME::ThinkingStart { content_index, .. } => Some(StreamEvent::ThinkingStart {
1538            content_index: *content_index,
1539        }),
1540        AME::ThinkingDelta {
1541            content_index,
1542            delta,
1543            ..
1544        } => Some(StreamEvent::ThinkingDelta {
1545            content_index: *content_index,
1546            delta: delta.clone(),
1547        }),
1548        AME::ThinkingEnd {
1549            content_index,
1550            content,
1551            ..
1552        } => Some(StreamEvent::ThinkingEnd {
1553            content_index: *content_index,
1554            content: content.clone(),
1555        }),
1556        AME::ToolCallStart { content_index, .. } => Some(StreamEvent::ToolCallStart {
1557            content_index: *content_index,
1558        }),
1559        AME::ToolCallDelta {
1560            content_index,
1561            delta,
1562            ..
1563        } => Some(StreamEvent::ToolCallDelta {
1564            content_index: *content_index,
1565            delta: delta.clone(),
1566        }),
1567        AME::ToolCallEnd {
1568            content_index,
1569            tool_call,
1570            ..
1571        } => Some(StreamEvent::ToolCallEnd {
1572            content_index: *content_index,
1573            tool_call: tool_call.clone(),
1574        }),
1575        AME::Done { reason, message } => Some(StreamEvent::Done {
1576            reason: *reason,
1577            message: (**message).clone(),
1578        }),
1579        AME::Error { reason, error } => Some(StreamEvent::Error {
1580            reason: *reason,
1581            error: (**error).clone(),
1582        }),
1583        AME::Start { .. } => None,
1584    }
1585}
1586
1587fn resolve_path_for_cwd(path: &Path, cwd: &Path) -> PathBuf {
1588    if path.is_absolute() {
1589        path.to_path_buf()
1590    } else {
1591        cwd.join(path)
1592    }
1593}
1594
1595fn build_stream_options_with_optional_key(
1596    config: &Config,
1597    api_key: Option<String>,
1598    selection: &app::ModelSelection,
1599    session: &Session,
1600) -> StreamOptions {
1601    let mut options = StreamOptions {
1602        api_key,
1603        headers: selection.model_entry.headers.clone(),
1604        session_id: Some(session.header.id.clone()),
1605        thinking_level: Some(selection.thinking_level),
1606        // Seed the per-request output cap from the model registry's `maxTokens`
1607        // so embedders inherit the configured limit by default; they can still
1608        // override it via `set_max_tokens`.
1609        max_tokens: Some(selection.model_entry.model.max_tokens),
1610        ..Default::default()
1611    };
1612
1613    if let Some(budgets) = &config.thinking_budgets {
1614        let defaults = ThinkingBudgets::default();
1615        options.thinking_budgets = Some(ThinkingBudgets {
1616            minimal: budgets.minimal.unwrap_or(defaults.minimal),
1617            low: budgets.low.unwrap_or(defaults.low),
1618            medium: budgets.medium.unwrap_or(defaults.medium),
1619            high: budgets.high.unwrap_or(defaults.high),
1620            xhigh: budgets.xhigh.unwrap_or(defaults.xhigh),
1621        });
1622    }
1623
1624    options
1625}
1626
1627/// Create a fully configured embeddable agent session.
1628///
1629/// This is the programmatic entrypoint for non-CLI consumers that want to run
1630/// Pi sessions in-process.
1631#[allow(clippy::too_many_lines)]
1632pub async fn create_agent_session(options: SessionOptions) -> Result<AgentSessionHandle> {
1633    let process_cwd =
1634        std::env::current_dir().map_err(|e| Error::config(format!("cwd lookup failed: {e}")))?;
1635    let cwd = options.working_directory.as_deref().map_or_else(
1636        || process_cwd.clone(),
1637        |path| resolve_path_for_cwd(path, &process_cwd),
1638    );
1639    let resolved_session_path = options
1640        .session_path
1641        .as_deref()
1642        .map(|path| resolve_path_for_cwd(path, &cwd));
1643    let resolved_session_dir = options
1644        .session_dir
1645        .as_deref()
1646        .map(|path| resolve_path_for_cwd(path, &cwd));
1647
1648    let mut cli = Cli::try_parse_from(["pi"])
1649        .map_err(|e| Error::validation(format!("CLI init failed: {e}")))?;
1650    cli.no_session = options.no_session;
1651    cli.provider = options.provider.clone();
1652    cli.model = options.model.clone();
1653    cli.api_key = options.api_key.clone();
1654    cli.system_prompt = options.system_prompt.clone();
1655    cli.append_system_prompt = options.append_system_prompt.clone();
1656    cli.hide_cwd_in_prompt = !options.include_cwd_in_prompt;
1657    cli.thinking = options.thinking.map(|t| t.to_string());
1658    cli.session = resolved_session_path
1659        .as_ref()
1660        .map(|p| p.to_string_lossy().to_string());
1661    cli.session_dir = resolved_session_dir
1662        .as_ref()
1663        .map(|p| p.to_string_lossy().to_string());
1664    if let Some(enabled_tools) = &options.enabled_tools {
1665        if enabled_tools.is_empty() {
1666            cli.no_tools = true;
1667        } else {
1668            cli.no_tools = false;
1669            cli.tools = enabled_tools.join(",");
1670        }
1671    }
1672
1673    let config = Config::load()?;
1674
1675    let mut auth = AuthStorage::load_async(Config::auth_path()).await?;
1676    auth.refresh_expired_oauth_tokens().await?;
1677
1678    let global_dir = Config::global_dir();
1679    let package_dir = Config::package_dir();
1680    let models_path = default_models_path(&global_dir);
1681    let model_registry = ModelRegistry::load(&auth, Some(models_path));
1682
1683    let mut session = Session::new(&cli, &config).await?;
1684    if resolved_session_path.is_none() {
1685        session.header.cwd = cwd.display().to_string();
1686    }
1687    let scoped_patterns = if let Some(models_arg) = &cli.models {
1688        app::parse_models_arg(models_arg)
1689    } else {
1690        config.enabled_models.clone().unwrap_or_default()
1691    };
1692    let scoped_models = if scoped_patterns.is_empty() {
1693        Vec::new()
1694    } else {
1695        app::resolve_model_scope(&scoped_patterns, &model_registry, cli.api_key.is_some())
1696    };
1697
1698    let selection = app::select_model_and_thinking(
1699        &cli,
1700        &config,
1701        &session,
1702        &model_registry,
1703        &scoped_models,
1704        &global_dir,
1705    )
1706    .map_err(|err| Error::validation(err.to_string()))?;
1707    app::update_session_for_selection(&mut session, &selection);
1708
1709    let enabled_tools_owned = cli
1710        .enabled_tools()
1711        .into_iter()
1712        .map(str::to_string)
1713        .collect::<Vec<_>>();
1714    let enabled_tools = enabled_tools_owned
1715        .iter()
1716        .map(String::as_str)
1717        .collect::<Vec<_>>();
1718
1719    let system_prompt = app::build_system_prompt(
1720        &cli,
1721        &cwd,
1722        &enabled_tools,
1723        None,
1724        &global_dir,
1725        &package_dir,
1726        std::env::var_os("PI_TEST_MODE").is_some(),
1727        options.include_cwd_in_prompt,
1728    )
1729    .map_err(|err| Error::validation(err.to_string()))?;
1730
1731    let provider = providers::create_provider(&selection.model_entry, None)
1732        .map_err(|e| Error::provider("sdk", e.to_string()))?;
1733
1734    let api_key = app::resolve_api_key(&auth, &cli, &selection.model_entry)
1735        .map_err(|err| Error::validation(err.to_string()))?;
1736
1737    let stream_options =
1738        build_stream_options_with_optional_key(&config, api_key, &selection, &session);
1739
1740    let agent_config = AgentConfig {
1741        system_prompt: Some(system_prompt),
1742        max_tool_iterations: options.max_tool_iterations,
1743        stream_options,
1744        block_images: config.image_block_images(),
1745        fail_closed_hooks: config.fail_closed_hooks(),
1746        tool_approval: None,
1747    };
1748
1749    let tools = options.tool_factory.as_ref().map_or_else(
1750        || ToolRegistry::new(&enabled_tools, &cwd, Some(&config)),
1751        |factory| factory.create_tool_registry(&enabled_tools, &cwd, &config),
1752    );
1753    let session_arc = Arc::new(asupersync::sync::Mutex::new(session));
1754
1755    let context_window_tokens = if selection.model_entry.model.context_window == 0 {
1756        ResolvedCompactionSettings::default().context_window_tokens
1757    } else {
1758        selection.model_entry.model.context_window
1759    };
1760    let compaction_settings = ResolvedCompactionSettings {
1761        enabled: config.compaction_enabled(),
1762        reserve_tokens: config.compaction_reserve_tokens(),
1763        keep_recent_tokens: config.compaction_keep_recent_tokens(),
1764        context_window_tokens,
1765    };
1766
1767    let mut agent_session = AgentSession::new(
1768        Agent::new(provider, tools, agent_config),
1769        Arc::clone(&session_arc),
1770        !cli.no_session,
1771        compaction_settings,
1772    );
1773    agent_session.set_api_key_override(options.api_key.clone());
1774
1775    if !options.extension_paths.is_empty() {
1776        let extension_paths = options
1777            .extension_paths
1778            .iter()
1779            .map(|path| resolve_path_for_cwd(path, &cwd))
1780            .collect::<Vec<_>>();
1781        let resolved_ext_policy =
1782            config.resolve_extension_policy_with_metadata(options.extension_policy.as_deref());
1783        let resolved_repair_policy =
1784            config.resolve_repair_policy_with_metadata(options.repair_policy.as_deref());
1785
1786        agent_session
1787            .enable_extensions_with_policy(
1788                &enabled_tools,
1789                &cwd,
1790                Some(&config),
1791                &extension_paths,
1792                Some(resolved_ext_policy.policy),
1793                Some(resolved_repair_policy.effective_mode),
1794                None,
1795            )
1796            .await?;
1797    }
1798
1799    agent_session.set_model_registry(model_registry.clone());
1800    agent_session.set_auth_storage(auth);
1801
1802    let history = {
1803        let cx = crate::agent_cx::AgentCx::for_request();
1804        let guard = session_arc
1805            .lock(cx.cx())
1806            .await
1807            .map_err(|e| Error::session(e.to_string()))?;
1808        guard.to_messages_for_current_path()
1809    };
1810    if !history.is_empty() {
1811        agent_session.agent.replace_messages(history);
1812    }
1813
1814    let mut listeners = EventListeners::new();
1815    if let Some(on_event) = options.on_event {
1816        listeners.subscribe(on_event);
1817    }
1818    listeners.on_tool_start = options.on_tool_start;
1819    listeners.on_tool_end = options.on_tool_end;
1820    listeners.on_stream_event = options.on_stream_event;
1821
1822    Ok(AgentSessionHandle {
1823        session: agent_session,
1824        listeners,
1825    })
1826}
1827
1828#[cfg(test)]
1829mod tests {
1830    use super::*;
1831    use asupersync::runtime::RuntimeBuilder;
1832    use asupersync::runtime::reactor::create_reactor;
1833    use asupersync::sync::Mutex as AsyncMutex;
1834    use std::env;
1835    use std::sync::{Arc, Mutex, OnceLock};
1836    use tempfile::tempdir;
1837
1838    fn run_async<F>(future: F) -> F::Output
1839    where
1840        F: std::future::Future,
1841    {
1842        let reactor = create_reactor().expect("create reactor");
1843        let runtime = RuntimeBuilder::current_thread()
1844            .with_reactor(reactor)
1845            .build()
1846            .expect("build runtime");
1847        runtime.block_on(future)
1848    }
1849
1850    fn current_dir_lock() -> std::sync::MutexGuard<'static, ()> {
1851        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1852        LOCK.get_or_init(|| Mutex::new(()))
1853            .lock()
1854            .unwrap_or_else(std::sync::PoisonError::into_inner)
1855    }
1856
1857    struct CurrentDirGuard {
1858        previous: PathBuf,
1859    }
1860
1861    impl CurrentDirGuard {
1862        fn new(path: &Path) -> Self {
1863            let previous = env::current_dir().expect("current dir");
1864            env::set_current_dir(path).expect("set current dir");
1865            Self { previous }
1866        }
1867    }
1868
1869    impl Drop for CurrentDirGuard {
1870        fn drop(&mut self) {
1871            let _ = env::set_current_dir(&self.previous);
1872        }
1873    }
1874
1875    fn hermetic_session_options(working_directory: &Path) -> SessionOptions {
1876        SessionOptions {
1877            provider: Some("openai".to_string()),
1878            model: Some("gpt-4o".to_string()),
1879            api_key: Some("dummy-key".to_string()),
1880            working_directory: Some(working_directory.to_path_buf()),
1881            no_session: true,
1882            ..SessionOptions::default()
1883        }
1884    }
1885
1886    #[test]
1887    fn create_agent_session_with_explicit_test_provider_succeeds() {
1888        let tmp = tempdir().expect("tempdir");
1889        let options = hermetic_session_options(tmp.path());
1890
1891        let handle = run_async(create_agent_session(options)).expect("create session");
1892        let provider = handle.session().agent.provider();
1893        assert!(!provider.name().is_empty());
1894        assert!(!provider.model_id().is_empty());
1895        assert_eq!(handle.model().0, provider.name());
1896        assert_eq!(handle.model().1, provider.model_id());
1897    }
1898
1899    #[test]
1900    fn create_agent_session_respects_provider_model_and_clamps_thinking() {
1901        let tmp = tempdir().expect("tempdir");
1902        let options = SessionOptions {
1903            provider: Some("openai".to_string()),
1904            model: Some("gpt-4o".to_string()),
1905            api_key: Some("dummy-key".to_string()),
1906            thinking: Some(crate::model::ThinkingLevel::Low),
1907            working_directory: Some(tmp.path().to_path_buf()),
1908            no_session: true,
1909            ..SessionOptions::default()
1910        };
1911
1912        let handle = run_async(create_agent_session(options)).expect("create session");
1913        let provider = handle.session().agent.provider();
1914        assert_eq!(provider.name(), "openai");
1915        assert_eq!(provider.model_id(), "gpt-4o");
1916        assert_eq!(
1917            handle.session().agent.stream_options().thinking_level,
1918            Some(crate::model::ThinkingLevel::Off)
1919        );
1920    }
1921
1922    #[test]
1923    fn create_agent_session_no_session_keeps_ephemeral_state() {
1924        let tmp = tempdir().expect("tempdir");
1925        let options = hermetic_session_options(tmp.path());
1926
1927        let handle = run_async(create_agent_session(options)).expect("create session");
1928        assert!(!handle.session().save_enabled());
1929
1930        let path_is_none = run_async(async {
1931            let cx = crate::agent_cx::AgentCx::for_request();
1932            let guard = handle
1933                .session()
1934                .session
1935                .lock(cx.cx())
1936                .await
1937                .expect("lock session");
1938            guard.path.is_none()
1939        });
1940        assert!(path_is_none);
1941    }
1942
1943    #[test]
1944    fn create_agent_session_uses_working_directory_for_new_session_header_and_path() {
1945        let _lock = current_dir_lock();
1946        let process_cwd = tempdir().expect("process cwd");
1947        let sdk_cwd = tempdir().expect("sdk cwd");
1948        let session_root = tempdir().expect("session root");
1949        let _guard = CurrentDirGuard::new(process_cwd.path());
1950
1951        let handle = run_async(create_agent_session(SessionOptions {
1952            provider: Some("openai".to_string()),
1953            model: Some("gpt-4o".to_string()),
1954            api_key: Some("dummy-key".to_string()),
1955            working_directory: Some(sdk_cwd.path().to_path_buf()),
1956            no_session: false,
1957            session_dir: Some(session_root.path().to_path_buf()),
1958            ..SessionOptions::default()
1959        }))
1960        .expect("create session");
1961
1962        let (header_cwd, path) = run_async(async {
1963            let cx = crate::agent_cx::AgentCx::for_request();
1964            let mut guard = handle
1965                .session()
1966                .session
1967                .lock(cx.cx())
1968                .await
1969                .expect("lock session");
1970            guard.save().await.expect("save sdk session");
1971            (
1972                guard.header.cwd.clone(),
1973                guard.path.clone().expect("saved session path"),
1974            )
1975        });
1976
1977        let expected_dir = session_root
1978            .path()
1979            .join(crate::session::encode_cwd(sdk_cwd.path()));
1980        let process_dir = session_root
1981            .path()
1982            .join(crate::session::encode_cwd(process_cwd.path()));
1983
1984        assert_eq!(header_cwd, sdk_cwd.path().display().to_string());
1985        assert_eq!(path.parent(), Some(expected_dir.as_path()));
1986        assert_ne!(path.parent(), Some(process_dir.as_path()));
1987    }
1988
1989    #[test]
1990    fn create_agent_session_resolves_relative_session_dir_against_working_directory() {
1991        let _lock = current_dir_lock();
1992        let process_cwd = tempdir().expect("process cwd");
1993        let sdk_cwd = tempdir().expect("sdk cwd");
1994        let _guard = CurrentDirGuard::new(process_cwd.path());
1995
1996        let handle = run_async(create_agent_session(SessionOptions {
1997            provider: Some("openai".to_string()),
1998            model: Some("gpt-4o".to_string()),
1999            api_key: Some("dummy-key".to_string()),
2000            working_directory: Some(sdk_cwd.path().to_path_buf()),
2001            no_session: false,
2002            session_dir: Some(PathBuf::from("sessions")),
2003            ..SessionOptions::default()
2004        }))
2005        .expect("create session");
2006
2007        let path = run_async(async {
2008            let cx = crate::agent_cx::AgentCx::for_request();
2009            let mut guard = handle
2010                .session()
2011                .session
2012                .lock(cx.cx())
2013                .await
2014                .expect("lock session");
2015            guard.save().await.expect("save sdk session");
2016            guard.path.clone().expect("saved session path")
2017        });
2018
2019        let expected_dir = sdk_cwd
2020            .path()
2021            .join("sessions")
2022            .join(crate::session::encode_cwd(sdk_cwd.path()));
2023        let process_dir = process_cwd
2024            .path()
2025            .join("sessions")
2026            .join(crate::session::encode_cwd(sdk_cwd.path()));
2027
2028        assert_eq!(path.parent(), Some(expected_dir.as_path()));
2029        assert_ne!(path.parent(), Some(process_dir.as_path()));
2030    }
2031
2032    #[test]
2033    fn create_agent_session_resolves_relative_session_path_against_working_directory() {
2034        let _lock = current_dir_lock();
2035        let process_cwd = tempdir().expect("process cwd");
2036        let sdk_cwd = tempdir().expect("sdk cwd");
2037        let _guard = CurrentDirGuard::new(process_cwd.path());
2038
2039        let session_path = sdk_cwd.path().join("relative").join("existing.jsonl");
2040        std::fs::create_dir_all(session_path.parent().expect("session parent"))
2041            .expect("create session parent");
2042        let mut header = crate::session::SessionHeader::new();
2043        header.cwd = sdk_cwd.path().display().to_string();
2044        let header_json = serde_json::to_string(&header).expect("serialize session header");
2045        std::fs::write(&session_path, format!("{header_json}\n")).expect("write session");
2046
2047        let handle = run_async(create_agent_session(SessionOptions {
2048            provider: Some("openai".to_string()),
2049            model: Some("gpt-4o".to_string()),
2050            api_key: Some("dummy-key".to_string()),
2051            working_directory: Some(sdk_cwd.path().to_path_buf()),
2052            no_session: false,
2053            session_path: Some(PathBuf::from("relative/existing.jsonl")),
2054            ..SessionOptions::default()
2055        }))
2056        .expect("create session");
2057
2058        let opened_path = run_async(async {
2059            let cx = crate::agent_cx::AgentCx::for_request();
2060            let guard = handle
2061                .session()
2062                .session
2063                .lock(cx.cx())
2064                .await
2065                .expect("lock session");
2066            guard.path.clone().expect("opened session path")
2067        });
2068
2069        assert_eq!(opened_path, session_path);
2070    }
2071
2072    #[test]
2073    fn from_session_with_listeners_set_model_switches_provider_model() {
2074        let dir = tempdir().expect("tempdir");
2075        let auth_path = dir.path().join("auth.json");
2076        let mut auth = AuthStorage::load(auth_path).expect("load auth");
2077        auth.set(
2078            "anthropic",
2079            crate::auth::AuthCredential::ApiKey {
2080                key: "anthropic-key".to_string(),
2081            },
2082        );
2083        auth.set(
2084            "openai",
2085            crate::auth::AuthCredential::ApiKey {
2086                key: "openai-key".to_string(),
2087            },
2088        );
2089
2090        let registry = ModelRegistry::load(&auth, None);
2091        let entry = registry
2092            .find("anthropic", "claude-sonnet-4-5")
2093            .expect("anthropic model in registry");
2094        let provider = providers::create_provider(&entry, None).expect("create anthropic provider");
2095        let tools = crate::tools::ToolRegistry::new(&[], std::path::Path::new("."), None);
2096        let agent = Agent::new(
2097            provider,
2098            tools,
2099            AgentConfig {
2100                system_prompt: None,
2101                max_tool_iterations: 50,
2102                stream_options: StreamOptions::default(),
2103                block_images: false,
2104                fail_closed_hooks: false,
2105                tool_approval: None,
2106            },
2107        );
2108
2109        let mut session = Session::in_memory();
2110        session.header.provider = Some("anthropic".to_string());
2111        session.header.model_id = Some("claude-sonnet-4-5".to_string());
2112
2113        let mut agent_session = AgentSession::new(
2114            agent,
2115            Arc::new(AsyncMutex::new(session)),
2116            false,
2117            ResolvedCompactionSettings::default(),
2118        );
2119        agent_session.set_model_registry(registry);
2120        agent_session.set_auth_storage(auth);
2121
2122        let mut handle =
2123            AgentSessionHandle::from_session_with_listeners(agent_session, EventListeners::new());
2124        run_async(handle.set_model("openai", "gpt-4o")).expect("set model");
2125        let provider = handle.session().agent.provider();
2126        assert_eq!(provider.name(), "openai");
2127        assert_eq!(provider.model_id(), "gpt-4o");
2128    }
2129
2130    #[test]
2131    fn create_agent_session_set_thinking_level_clamps_and_dedupes_history() {
2132        let tmp = tempdir().expect("tempdir");
2133        let options = SessionOptions {
2134            provider: Some("openai".to_string()),
2135            model: Some("gpt-4o".to_string()),
2136            api_key: Some("dummy-key".to_string()),
2137            working_directory: Some(tmp.path().to_path_buf()),
2138            no_session: true,
2139            ..SessionOptions::default()
2140        };
2141
2142        let mut handle = run_async(create_agent_session(options)).expect("create session");
2143        run_async(handle.set_thinking_level(crate::model::ThinkingLevel::High))
2144            .expect("set thinking");
2145        run_async(handle.set_thinking_level(crate::model::ThinkingLevel::High))
2146            .expect("reapply thinking");
2147
2148        assert_eq!(
2149            handle.session().agent.stream_options().thinking_level,
2150            Some(crate::model::ThinkingLevel::Off)
2151        );
2152
2153        let thinking_changes = run_async(async {
2154            let cx = crate::agent_cx::AgentCx::for_request();
2155            let guard = handle
2156                .session()
2157                .session
2158                .lock(cx.cx())
2159                .await
2160                .expect("lock session");
2161            assert_eq!(guard.header.thinking_level.as_deref(), Some("off"));
2162            guard
2163                .entries_for_current_path()
2164                .iter()
2165                .filter(|entry| {
2166                    matches!(entry, crate::session::SessionEntry::ThinkingLevelChange(_))
2167                })
2168                .count()
2169        });
2170        assert_eq!(thinking_changes, 1);
2171    }
2172
2173    #[test]
2174    fn from_session_with_listeners_set_thinking_level_uses_session_header_target() {
2175        let dir = tempdir().expect("tempdir");
2176        let auth_path = dir.path().join("auth.json");
2177        let auth = crate::auth::AuthStorage::load(auth_path).expect("load auth");
2178        let mut registry = ModelRegistry::load(&auth, None);
2179        registry.merge_entries(vec![ModelEntry {
2180            model: Model {
2181                id: "plain-model".to_string(),
2182                name: "Plain Model".to_string(),
2183                api: "openai-completions".to_string(),
2184                provider: "acme".to_string(),
2185                base_url: "https://example.invalid/v1".to_string(),
2186                reasoning: false,
2187                input: vec![InputType::Text],
2188                cost: ModelCost {
2189                    input: 0.0,
2190                    output: 0.0,
2191                    cache_read: 0.0,
2192                    cache_write: 0.0,
2193                },
2194                context_window: 128_000,
2195                max_tokens: 8_192,
2196                headers: HashMap::new(),
2197            },
2198            api_key: None,
2199            headers: HashMap::new(),
2200            auth_header: false,
2201            compat: None,
2202            oauth_config: None,
2203        }]);
2204        let entry = registry
2205            .find("anthropic", "claude-sonnet-4-5")
2206            .expect("anthropic model in registry");
2207        let provider = providers::create_provider(&entry, None).expect("create anthropic provider");
2208        let tools = crate::tools::ToolRegistry::new(&[], std::path::Path::new("."), None);
2209        let agent = Agent::new(
2210            provider,
2211            tools,
2212            AgentConfig {
2213                system_prompt: None,
2214                max_tool_iterations: 50,
2215                stream_options: StreamOptions::default(),
2216                block_images: false,
2217                fail_closed_hooks: false,
2218                tool_approval: None,
2219            },
2220        );
2221
2222        let mut session = Session::in_memory();
2223        session.header.provider = Some("acme".to_string());
2224        session.header.model_id = Some("plain-model".to_string());
2225
2226        let mut agent_session = AgentSession::new(
2227            agent,
2228            Arc::new(AsyncMutex::new(session)),
2229            false,
2230            ResolvedCompactionSettings::default(),
2231        );
2232        agent_session.set_model_registry(registry);
2233
2234        let mut handle =
2235            AgentSessionHandle::from_session_with_listeners(agent_session, EventListeners::new());
2236        run_async(handle.set_thinking_level(crate::model::ThinkingLevel::High))
2237            .expect("set thinking");
2238
2239        assert_eq!(
2240            handle.session().agent.stream_options().thinking_level,
2241            Some(crate::model::ThinkingLevel::Off)
2242        );
2243        assert_eq!(handle.model().0, "anthropic");
2244        assert_eq!(handle.model().1, "claude-sonnet-4-5");
2245    }
2246
2247    #[test]
2248    fn compact_without_history_is_noop() {
2249        let tmp = tempdir().expect("tempdir");
2250        let options = hermetic_session_options(tmp.path());
2251
2252        let mut handle = run_async(create_agent_session(options)).expect("create session");
2253        let events = Arc::new(Mutex::new(Vec::new()));
2254        let events_for_callback = Arc::clone(&events);
2255        run_async(handle.compact(move |event| {
2256            events_for_callback
2257                .lock()
2258                .expect("compact callback lock")
2259                .push(event);
2260        }))
2261        .expect("compact");
2262
2263        assert!(
2264            events
2265                .lock()
2266                .unwrap_or_else(std::sync::PoisonError::into_inner)
2267                .is_empty(),
2268            "expected no compaction lifecycle events for empty session"
2269        );
2270    }
2271
2272    #[test]
2273    fn resolve_path_for_cwd_uses_cwd_for_relative_paths() {
2274        let cwd = Path::new("/tmp/pi-sdk-cwd");
2275        assert_eq!(
2276            resolve_path_for_cwd(Path::new("relative/file.txt"), cwd),
2277            PathBuf::from("/tmp/pi-sdk-cwd/relative/file.txt")
2278        );
2279        assert_eq!(
2280            resolve_path_for_cwd(Path::new("/etc/hosts"), cwd),
2281            PathBuf::from("/etc/hosts")
2282        );
2283    }
2284
2285    // =====================================================================
2286    // EventListeners tests
2287    // =====================================================================
2288
2289    #[test]
2290    fn event_listeners_subscribe_and_notify() {
2291        let listeners = EventListeners::new();
2292        let received = Arc::new(Mutex::new(Vec::new()));
2293
2294        let recv_clone = Arc::clone(&received);
2295        let id = listeners.subscribe(Arc::new(move |event| {
2296            recv_clone
2297                .lock()
2298                .unwrap_or_else(std::sync::PoisonError::into_inner)
2299                .push(event);
2300        }));
2301
2302        let event = AgentEvent::AgentStart {
2303            session_id: "test-123".into(),
2304        };
2305        listeners.notify(&event);
2306
2307        let events = received
2308            .lock()
2309            .unwrap_or_else(std::sync::PoisonError::into_inner);
2310        assert_eq!(events.len(), 1);
2311
2312        // Verify unsubscribe
2313        drop(events);
2314        assert!(listeners.unsubscribe(id));
2315        listeners.notify(&AgentEvent::AgentStart {
2316            session_id: "test-456".into(),
2317        });
2318        assert_eq!(
2319            received
2320                .lock()
2321                .unwrap_or_else(std::sync::PoisonError::into_inner)
2322                .len(),
2323            1
2324        );
2325    }
2326
2327    #[test]
2328    fn event_listeners_unsubscribe_nonexistent_returns_false() {
2329        let listeners = EventListeners::new();
2330        assert!(!listeners.unsubscribe(SubscriptionId(999)));
2331    }
2332
2333    #[test]
2334    fn event_listeners_multiple_subscribers() {
2335        let listeners = EventListeners::new();
2336        let count_a = Arc::new(Mutex::new(0u32));
2337        let count_b = Arc::new(Mutex::new(0u32));
2338
2339        let ca = Arc::clone(&count_a);
2340        listeners.subscribe(Arc::new(move |_| {
2341            *ca.lock().unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
2342        }));
2343
2344        let cb = Arc::clone(&count_b);
2345        listeners.subscribe(Arc::new(move |_| {
2346            *cb.lock().unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
2347        }));
2348
2349        listeners.notify(&AgentEvent::AgentStart {
2350            session_id: "s".into(),
2351        });
2352
2353        assert_eq!(
2354            *count_a
2355                .lock()
2356                .unwrap_or_else(std::sync::PoisonError::into_inner),
2357            1
2358        );
2359        assert_eq!(
2360            *count_b
2361                .lock()
2362                .unwrap_or_else(std::sync::PoisonError::into_inner),
2363            1
2364        );
2365    }
2366
2367    #[test]
2368    fn event_listeners_tool_hooks_fire() {
2369        let listeners = EventListeners::new();
2370        let starts = Arc::new(Mutex::new(Vec::new()));
2371        let ends = Arc::new(Mutex::new(Vec::new()));
2372
2373        let s = Arc::clone(&starts);
2374        let mut listeners = listeners;
2375        listeners.on_tool_start = Some(Arc::new(move |name, args| {
2376            s.lock()
2377                .expect("lock")
2378                .push((name.to_string(), args.clone()));
2379        }));
2380
2381        let e = Arc::clone(&ends);
2382        listeners.on_tool_end = Some(Arc::new(move |name, _output, is_error| {
2383            e.lock()
2384                .unwrap_or_else(std::sync::PoisonError::into_inner)
2385                .push((name.to_string(), is_error));
2386        }));
2387
2388        let args = serde_json::json!({"path": "/foo"});
2389        listeners.notify_tool_start("bash", &args);
2390        let output = ToolOutput {
2391            content: vec![ContentBlock::Text(TextContent::new("ok"))],
2392            details: None,
2393            is_error: false,
2394        };
2395        listeners.notify_tool_end("bash", &output, false);
2396
2397        {
2398            let s = starts
2399                .lock()
2400                .unwrap_or_else(std::sync::PoisonError::into_inner);
2401            assert_eq!(s.len(), 1);
2402            assert_eq!(s[0].0, "bash");
2403            drop(s);
2404        }
2405
2406        {
2407            let e = ends
2408                .lock()
2409                .unwrap_or_else(std::sync::PoisonError::into_inner);
2410            assert_eq!(e.len(), 1);
2411            assert_eq!(e[0].0, "bash");
2412            assert!(!e[0].1);
2413            drop(e);
2414        }
2415    }
2416
2417    #[test]
2418    fn event_listeners_stream_event_hook_fires() {
2419        let mut listeners = EventListeners::new();
2420        let received = Arc::new(Mutex::new(Vec::new()));
2421
2422        let r = Arc::clone(&received);
2423        listeners.on_stream_event = Some(Arc::new(move |ev| {
2424            r.lock()
2425                .unwrap_or_else(std::sync::PoisonError::into_inner)
2426                .push(format!("{ev:?}"));
2427        }));
2428
2429        let event = StreamEvent::TextDelta {
2430            content_index: 0,
2431            delta: "hello".to_string(),
2432        };
2433        listeners.notify_stream_event(&event);
2434
2435        assert_eq!(
2436            received
2437                .lock()
2438                .unwrap_or_else(std::sync::PoisonError::into_inner)
2439                .len(),
2440            1
2441        );
2442    }
2443
2444    #[test]
2445    fn session_options_on_event_wired_into_listeners() {
2446        let received = Arc::new(Mutex::new(Vec::new()));
2447        let r = Arc::clone(&received);
2448        let tmp = tempdir().expect("tempdir");
2449
2450        let options = SessionOptions {
2451            on_event: Some(Arc::new(move |event| {
2452                r.lock()
2453                    .unwrap_or_else(std::sync::PoisonError::into_inner)
2454                    .push(format!("{event:?}"));
2455            })),
2456            ..hermetic_session_options(tmp.path())
2457        };
2458
2459        let handle = run_async(create_agent_session(options)).expect("create session");
2460        // Verify the listener was registered
2461        let count = handle
2462            .listeners()
2463            .subscribers
2464            .lock()
2465            .unwrap_or_else(std::sync::PoisonError::into_inner)
2466            .len();
2467        assert_eq!(
2468            count, 1,
2469            "on_event from SessionOptions should register one subscriber"
2470        );
2471    }
2472
2473    #[test]
2474    fn subscribe_unsubscribe_on_handle() {
2475        let tmp = tempdir().expect("tempdir");
2476        let options = hermetic_session_options(tmp.path());
2477
2478        let handle = run_async(create_agent_session(options)).expect("create session");
2479        let id = handle.subscribe(|_event| {});
2480        assert_eq!(
2481            handle
2482                .listeners()
2483                .subscribers
2484                .lock()
2485                .unwrap_or_else(std::sync::PoisonError::into_inner)
2486                .len(),
2487            1
2488        );
2489
2490        assert!(handle.unsubscribe(id));
2491        assert_eq!(
2492            handle
2493                .listeners()
2494                .subscribers
2495                .lock()
2496                .unwrap_or_else(std::sync::PoisonError::into_inner)
2497                .len(),
2498            0
2499        );
2500
2501        // Double unsubscribe returns false
2502        assert!(!handle.unsubscribe(id));
2503    }
2504
2505    #[test]
2506    fn stream_event_from_assistant_message_event_converts_text_delta() {
2507        use crate::model::AssistantMessageEvent as AME;
2508
2509        let partial = Arc::new(AssistantMessage {
2510            content: Vec::new(),
2511            api: String::new(),
2512            provider: String::new(),
2513            model: String::new(),
2514            usage: Usage::default(),
2515            stop_reason: StopReason::Stop,
2516            error_message: None,
2517            timestamp: 0,
2518        });
2519        let ame = AME::TextDelta {
2520            content_index: 2,
2521            delta: "chunk".to_string(),
2522            partial,
2523        };
2524        let result = stream_event_from_assistant_message_event(&ame);
2525        assert!(result.is_some());
2526        match result.unwrap() {
2527            StreamEvent::TextDelta {
2528                content_index,
2529                delta,
2530            } => {
2531                assert_eq!(content_index, 2);
2532                assert_eq!(delta, "chunk");
2533            }
2534            other => unreachable!("expected TextDelta, got {other:?}"),
2535        }
2536    }
2537
2538    #[test]
2539    fn stream_event_from_assistant_message_event_start_returns_none() {
2540        use crate::model::AssistantMessageEvent as AME;
2541
2542        let partial = Arc::new(AssistantMessage {
2543            content: Vec::new(),
2544            api: String::new(),
2545            provider: String::new(),
2546            model: String::new(),
2547            usage: Usage::default(),
2548            stop_reason: StopReason::Stop,
2549            error_message: None,
2550            timestamp: 0,
2551        });
2552        let ame = AME::Start { partial };
2553        assert!(stream_event_from_assistant_message_event(&ame).is_none());
2554    }
2555
2556    #[test]
2557    fn event_listeners_debug_impl() {
2558        let listeners = EventListeners::new();
2559        let debug = format!("{listeners:?}");
2560        assert!(debug.contains("subscriber_count"));
2561        assert!(debug.contains("has_on_tool_start"));
2562    }
2563
2564    // =====================================================================
2565    // Extension convenience method tests
2566    // =====================================================================
2567
2568    #[test]
2569    fn has_extensions_false_by_default() {
2570        let tmp = tempdir().expect("tempdir");
2571        let options = hermetic_session_options(tmp.path());
2572
2573        let handle = run_async(create_agent_session(options)).expect("create session");
2574        assert!(
2575            !handle.has_extensions(),
2576            "session without extension_paths should have no extensions"
2577        );
2578        assert!(handle.extension_manager().is_none());
2579        assert!(handle.extension_region().is_none());
2580    }
2581
2582    // =====================================================================
2583    // Tool factory function tests
2584    // =====================================================================
2585
2586    #[test]
2587    fn create_read_tool_has_correct_name() {
2588        let tmp = tempdir().expect("tempdir");
2589        let tool = super::create_read_tool(tmp.path());
2590        assert_eq!(tool.name(), "read");
2591        assert!(!tool.description().is_empty());
2592        let params = tool.parameters();
2593        assert!(params.is_object(), "parameters should be a JSON object");
2594    }
2595
2596    #[test]
2597    fn create_bash_tool_has_correct_name() {
2598        let tmp = tempdir().expect("tempdir");
2599        let tool = super::create_bash_tool(tmp.path());
2600        assert_eq!(tool.name(), "bash");
2601        assert!(!tool.description().is_empty());
2602    }
2603
2604    #[test]
2605    fn create_edit_tool_has_correct_name() {
2606        let tmp = tempdir().expect("tempdir");
2607        let tool = super::create_edit_tool(tmp.path());
2608        assert_eq!(tool.name(), "edit");
2609    }
2610
2611    #[test]
2612    fn create_write_tool_has_correct_name() {
2613        let tmp = tempdir().expect("tempdir");
2614        let tool = super::create_write_tool(tmp.path());
2615        assert_eq!(tool.name(), "write");
2616    }
2617
2618    #[test]
2619    fn create_grep_tool_has_correct_name() {
2620        let tmp = tempdir().expect("tempdir");
2621        let tool = super::create_grep_tool(tmp.path());
2622        assert_eq!(tool.name(), "grep");
2623    }
2624
2625    #[test]
2626    fn create_find_tool_has_correct_name() {
2627        let tmp = tempdir().expect("tempdir");
2628        let tool = super::create_find_tool(tmp.path());
2629        assert_eq!(tool.name(), "find");
2630    }
2631
2632    #[test]
2633    fn create_ls_tool_has_correct_name() {
2634        let tmp = tempdir().expect("tempdir");
2635        let tool = super::create_ls_tool(tmp.path());
2636        assert_eq!(tool.name(), "ls");
2637    }
2638
2639    #[test]
2640    fn create_all_tools_returns_eight() {
2641        let tmp = tempdir().expect("tempdir");
2642        let tools = super::create_all_tools(tmp.path());
2643        assert_eq!(tools.len(), 8, "should create all 8 built-in tools");
2644
2645        let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
2646        for expected in BUILTIN_TOOL_NAMES {
2647            assert!(names.contains(expected), "missing tool: {expected}");
2648        }
2649    }
2650
2651    #[test]
2652    fn tool_to_definition_preserves_schema() {
2653        let tmp = tempdir().expect("tempdir");
2654        let tool = super::create_read_tool(tmp.path());
2655        let def = super::tool_to_definition(tool.as_ref());
2656        assert_eq!(def.name, "read");
2657        assert!(!def.description.is_empty());
2658        assert!(def.parameters.is_object());
2659        assert!(
2660            def.parameters.get("properties").is_some(),
2661            "schema should have properties"
2662        );
2663    }
2664
2665    #[test]
2666    fn all_tool_definitions_returns_eight_schemas() {
2667        let tmp = tempdir().expect("tempdir");
2668        let defs = super::all_tool_definitions(tmp.path());
2669        assert_eq!(defs.len(), 8);
2670
2671        for def in &defs {
2672            assert!(!def.name.is_empty());
2673            assert!(!def.description.is_empty());
2674            assert!(def.parameters.is_object());
2675        }
2676    }
2677
2678    #[test]
2679    fn builtin_tool_names_matches_create_all() {
2680        let tmp = tempdir().expect("tempdir");
2681        let tools = super::create_all_tools(tmp.path());
2682        let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
2683        assert_eq!(
2684            names.as_slice(),
2685            BUILTIN_TOOL_NAMES,
2686            "create_all_tools order should match BUILTIN_TOOL_NAMES"
2687        );
2688    }
2689
2690    #[test]
2691    fn tool_registry_from_factory_tools() {
2692        let tmp = tempdir().expect("tempdir");
2693        let tools = super::create_all_tools(tmp.path());
2694        let registry = ToolRegistry::from_tools(tools);
2695        assert!(registry.get("read").is_some());
2696        assert!(registry.get("bash").is_some());
2697        assert!(registry.get("nonexistent").is_none());
2698    }
2699
2700    #[test]
2701    fn set_session_name_records_session_info_entry() {
2702        let tmp = tempdir().expect("tempdir");
2703        let options = hermetic_session_options(tmp.path());
2704
2705        let mut handle = run_async(create_agent_session(options)).expect("create session");
2706        run_async(handle.set_session_name("renamed-by-sdk")).expect("set session name");
2707
2708        let info_entries = run_async(async {
2709            let cx = crate::agent_cx::AgentCx::for_request();
2710            let guard = handle
2711                .session()
2712                .session
2713                .lock(cx.cx())
2714                .await
2715                .expect("lock session");
2716            guard
2717                .entries_for_current_path()
2718                .iter()
2719                .filter_map(|entry| match entry {
2720                    crate::session::SessionEntry::SessionInfo(info) => info.name.clone(),
2721                    _ => None,
2722                })
2723                .collect::<Vec<_>>()
2724        });
2725        assert_eq!(info_entries, vec!["renamed-by-sdk".to_string()]);
2726    }
2727
2728    #[test]
2729    fn max_tokens_defaults_to_registry_value_and_set_overrides() {
2730        let tmp = tempdir().expect("tempdir");
2731        let options = hermetic_session_options(tmp.path());
2732
2733        let mut handle = run_async(create_agent_session(options)).expect("create session");
2734        // The session now seeds `max_tokens` from the model registry's
2735        // configured `maxTokens` so the value users set in `models.json`
2736        // actually takes effect (instead of falling back to the provider's
2737        // hardcoded per-request default).
2738        let seeded = handle.max_tokens();
2739        assert!(
2740            seeded.is_some_and(|n| n > 0),
2741            "expected max_tokens to be seeded from the registry, got {seeded:?}"
2742        );
2743
2744        handle.set_max_tokens(Some(32_000));
2745        assert_eq!(handle.max_tokens(), Some(32_000));
2746        assert_eq!(
2747            handle.session().agent.stream_options().max_tokens,
2748            Some(32_000)
2749        );
2750
2751        // Embedders can still fall back to the provider default explicitly.
2752        handle.set_max_tokens(None);
2753        assert_eq!(handle.max_tokens(), None);
2754    }
2755}