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 {
1557            content_index,
1558            partial,
1559        } => {
1560            // #129: recover the tool-call id/name from the partial so the
1561            // reconstructed stream stays correlatable from the start.
1562            let (id, name) = match partial.content.get(*content_index) {
1563                Some(ContentBlock::ToolCall(tc)) => (tc.id.clone(), tc.name.clone()),
1564                _ => (String::new(), String::new()),
1565            };
1566            Some(StreamEvent::ToolCallStart {
1567                content_index: *content_index,
1568                id,
1569                name,
1570            })
1571        }
1572        AME::ToolCallDelta {
1573            content_index,
1574            delta,
1575            ..
1576        } => Some(StreamEvent::ToolCallDelta {
1577            content_index: *content_index,
1578            delta: delta.clone(),
1579        }),
1580        AME::ToolCallEnd {
1581            content_index,
1582            tool_call,
1583            ..
1584        } => Some(StreamEvent::ToolCallEnd {
1585            content_index: *content_index,
1586            tool_call: tool_call.clone(),
1587        }),
1588        AME::Done { reason, message } => Some(StreamEvent::Done {
1589            reason: *reason,
1590            message: (**message).clone(),
1591        }),
1592        AME::Error { reason, error } => Some(StreamEvent::Error {
1593            reason: *reason,
1594            error: (**error).clone(),
1595        }),
1596        AME::Start { .. } => None,
1597    }
1598}
1599
1600fn resolve_path_for_cwd(path: &Path, cwd: &Path) -> PathBuf {
1601    if path.is_absolute() {
1602        path.to_path_buf()
1603    } else {
1604        cwd.join(path)
1605    }
1606}
1607
1608fn build_stream_options_with_optional_key(
1609    config: &Config,
1610    api_key: Option<String>,
1611    selection: &app::ModelSelection,
1612    session: &Session,
1613) -> StreamOptions {
1614    let mut options = StreamOptions {
1615        api_key,
1616        headers: selection.model_entry.headers.clone(),
1617        session_id: Some(session.header.id.clone()),
1618        thinking_level: Some(selection.thinking_level),
1619        // Seed the per-request output cap from the model registry's `maxTokens`
1620        // so embedders inherit the configured limit by default; they can still
1621        // override it via `set_max_tokens`.
1622        max_tokens: Some(selection.model_entry.model.max_tokens),
1623        ..Default::default()
1624    };
1625
1626    if let Some(budgets) = &config.thinking_budgets {
1627        let defaults = ThinkingBudgets::default();
1628        options.thinking_budgets = Some(ThinkingBudgets {
1629            minimal: budgets.minimal.unwrap_or(defaults.minimal),
1630            low: budgets.low.unwrap_or(defaults.low),
1631            medium: budgets.medium.unwrap_or(defaults.medium),
1632            high: budgets.high.unwrap_or(defaults.high),
1633            xhigh: budgets.xhigh.unwrap_or(defaults.xhigh),
1634            max: budgets.max.unwrap_or(defaults.max),
1635        });
1636    }
1637
1638    options
1639}
1640
1641/// Create a fully configured embeddable agent session.
1642///
1643/// This is the programmatic entrypoint for non-CLI consumers that want to run
1644/// Pi sessions in-process.
1645#[allow(clippy::too_many_lines)]
1646pub async fn create_agent_session(options: SessionOptions) -> Result<AgentSessionHandle> {
1647    let process_cwd =
1648        std::env::current_dir().map_err(|e| Error::config(format!("cwd lookup failed: {e}")))?;
1649    let cwd = options.working_directory.as_deref().map_or_else(
1650        || process_cwd.clone(),
1651        |path| resolve_path_for_cwd(path, &process_cwd),
1652    );
1653    let resolved_session_path = options
1654        .session_path
1655        .as_deref()
1656        .map(|path| resolve_path_for_cwd(path, &cwd));
1657    let resolved_session_dir = options
1658        .session_dir
1659        .as_deref()
1660        .map(|path| resolve_path_for_cwd(path, &cwd));
1661
1662    let mut cli = Cli::try_parse_from(["pi"])
1663        .map_err(|e| Error::validation(format!("CLI init failed: {e}")))?;
1664    cli.no_session = options.no_session;
1665    cli.provider = options.provider.clone();
1666    cli.model = options.model.clone();
1667    cli.api_key = options.api_key.clone();
1668    cli.system_prompt = options.system_prompt.clone();
1669    cli.append_system_prompt = options.append_system_prompt.clone();
1670    cli.hide_cwd_in_prompt = !options.include_cwd_in_prompt;
1671    cli.thinking = options.thinking.map(|t| t.to_string());
1672    cli.session = resolved_session_path
1673        .as_ref()
1674        .map(|p| p.to_string_lossy().to_string());
1675    cli.session_dir = resolved_session_dir
1676        .as_ref()
1677        .map(|p| p.to_string_lossy().to_string());
1678    if let Some(enabled_tools) = &options.enabled_tools {
1679        if enabled_tools.is_empty() {
1680            cli.no_tools = true;
1681        } else {
1682            cli.no_tools = false;
1683            cli.tools = enabled_tools.join(",");
1684        }
1685    }
1686
1687    let config = Config::load()?;
1688
1689    let mut auth = AuthStorage::load_async(Config::auth_path()).await?;
1690    auth.refresh_expired_oauth_tokens().await?;
1691
1692    let global_dir = Config::global_dir();
1693    let package_dir = Config::package_dir();
1694    let models_path = default_models_path(&global_dir);
1695    let model_registry = ModelRegistry::load(&auth, Some(models_path));
1696
1697    let mut session = Session::new(&cli, &config).await?;
1698    if resolved_session_path.is_none() {
1699        session.header.cwd = cwd.display().to_string();
1700    }
1701    let scoped_patterns = if let Some(models_arg) = &cli.models {
1702        app::parse_models_arg(models_arg)
1703    } else {
1704        config.enabled_models.clone().unwrap_or_default()
1705    };
1706    let scoped_models = if scoped_patterns.is_empty() {
1707        Vec::new()
1708    } else {
1709        app::resolve_model_scope(&scoped_patterns, &model_registry, cli.api_key.is_some())
1710    };
1711
1712    let selection = app::select_model_and_thinking(
1713        &cli,
1714        &config,
1715        &session,
1716        &model_registry,
1717        &scoped_models,
1718        &global_dir,
1719    )
1720    .map_err(|err| Error::validation(err.to_string()))?;
1721    app::update_session_for_selection(&mut session, &selection);
1722
1723    let enabled_tools_owned = cli
1724        .enabled_tools()
1725        .into_iter()
1726        .map(str::to_string)
1727        .collect::<Vec<_>>();
1728    let enabled_tools = enabled_tools_owned
1729        .iter()
1730        .map(String::as_str)
1731        .collect::<Vec<_>>();
1732
1733    let system_prompt = app::build_system_prompt(
1734        &cli,
1735        &cwd,
1736        &enabled_tools,
1737        None,
1738        &global_dir,
1739        &package_dir,
1740        std::env::var_os("PI_TEST_MODE").is_some(),
1741        options.include_cwd_in_prompt,
1742    )
1743    .map_err(|err| Error::validation(err.to_string()))?;
1744
1745    let provider = providers::create_provider(&selection.model_entry, None)
1746        .map_err(|e| Error::provider("sdk", e.to_string()))?;
1747
1748    let api_key = app::resolve_api_key(&auth, &cli, &selection.model_entry)
1749        .map_err(|err| Error::validation(err.to_string()))?;
1750
1751    let stream_options =
1752        build_stream_options_with_optional_key(&config, api_key, &selection, &session);
1753
1754    let agent_config = AgentConfig {
1755        system_prompt: Some(system_prompt),
1756        max_tool_iterations: options.max_tool_iterations,
1757        stream_options,
1758        block_images: config.image_block_images(),
1759        fail_closed_hooks: config.fail_closed_hooks(),
1760        tool_approval: None,
1761    };
1762
1763    let tools = options.tool_factory.as_ref().map_or_else(
1764        || ToolRegistry::new(&enabled_tools, &cwd, Some(&config)),
1765        |factory| factory.create_tool_registry(&enabled_tools, &cwd, &config),
1766    );
1767    let session_arc = Arc::new(asupersync::sync::Mutex::new(session));
1768
1769    let context_window_tokens = if selection.model_entry.model.context_window == 0 {
1770        ResolvedCompactionSettings::default().context_window_tokens
1771    } else {
1772        selection.model_entry.model.context_window
1773    };
1774    let compaction_settings = ResolvedCompactionSettings {
1775        enabled: config.compaction_enabled(),
1776        reserve_tokens: config.compaction_reserve_tokens(),
1777        keep_recent_tokens: config.compaction_keep_recent_tokens(),
1778        context_window_tokens,
1779    };
1780
1781    let mut agent_session = AgentSession::new(
1782        Agent::new(provider, tools, agent_config),
1783        Arc::clone(&session_arc),
1784        !cli.no_session,
1785        compaction_settings,
1786    );
1787    agent_session.set_api_key_override(options.api_key.clone());
1788
1789    if !options.extension_paths.is_empty() {
1790        let extension_paths = options
1791            .extension_paths
1792            .iter()
1793            .map(|path| resolve_path_for_cwd(path, &cwd))
1794            .collect::<Vec<_>>();
1795        let resolved_ext_policy =
1796            config.resolve_extension_policy_with_metadata(options.extension_policy.as_deref());
1797        let resolved_repair_policy =
1798            config.resolve_repair_policy_with_metadata(options.repair_policy.as_deref());
1799
1800        agent_session
1801            .enable_extensions_with_policy(
1802                &enabled_tools,
1803                &cwd,
1804                Some(&config),
1805                &extension_paths,
1806                Some(resolved_ext_policy.policy),
1807                Some(resolved_repair_policy.effective_mode),
1808                None,
1809            )
1810            .await?;
1811    }
1812
1813    agent_session.set_model_registry(model_registry.clone());
1814    agent_session.set_auth_storage(auth);
1815
1816    let history = {
1817        let cx = crate::agent_cx::AgentCx::for_request();
1818        let guard = session_arc
1819            .lock(cx.cx())
1820            .await
1821            .map_err(|e| Error::session(e.to_string()))?;
1822        guard.to_messages_for_current_path()
1823    };
1824    if !history.is_empty() {
1825        agent_session.agent.replace_messages(history);
1826    }
1827
1828    let mut listeners = EventListeners::new();
1829    if let Some(on_event) = options.on_event {
1830        listeners.subscribe(on_event);
1831    }
1832    listeners.on_tool_start = options.on_tool_start;
1833    listeners.on_tool_end = options.on_tool_end;
1834    listeners.on_stream_event = options.on_stream_event;
1835
1836    Ok(AgentSessionHandle {
1837        session: agent_session,
1838        listeners,
1839    })
1840}
1841
1842#[cfg(test)]
1843mod tests {
1844    use super::*;
1845    use asupersync::runtime::RuntimeBuilder;
1846    use asupersync::runtime::reactor::create_reactor;
1847    use asupersync::sync::Mutex as AsyncMutex;
1848    use std::env;
1849    use std::sync::{Arc, Mutex, OnceLock};
1850    use tempfile::tempdir;
1851
1852    fn run_async<F>(future: F) -> F::Output
1853    where
1854        F: std::future::Future,
1855    {
1856        let reactor = create_reactor().expect("create reactor");
1857        let runtime = RuntimeBuilder::current_thread()
1858            .with_reactor(reactor)
1859            .build()
1860            .expect("build runtime");
1861        runtime.block_on(future)
1862    }
1863
1864    fn current_dir_lock() -> std::sync::MutexGuard<'static, ()> {
1865        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1866        LOCK.get_or_init(|| Mutex::new(()))
1867            .lock()
1868            .unwrap_or_else(std::sync::PoisonError::into_inner)
1869    }
1870
1871    struct CurrentDirGuard {
1872        previous: PathBuf,
1873    }
1874
1875    impl CurrentDirGuard {
1876        fn new(path: &Path) -> Self {
1877            let previous = env::current_dir().expect("current dir");
1878            env::set_current_dir(path).expect("set current dir");
1879            Self { previous }
1880        }
1881    }
1882
1883    impl Drop for CurrentDirGuard {
1884        fn drop(&mut self) {
1885            let _ = env::set_current_dir(&self.previous);
1886        }
1887    }
1888
1889    fn hermetic_session_options(working_directory: &Path) -> SessionOptions {
1890        SessionOptions {
1891            provider: Some("openai".to_string()),
1892            model: Some("gpt-4o".to_string()),
1893            api_key: Some("dummy-key".to_string()),
1894            working_directory: Some(working_directory.to_path_buf()),
1895            no_session: true,
1896            ..SessionOptions::default()
1897        }
1898    }
1899
1900    #[test]
1901    fn create_agent_session_with_explicit_test_provider_succeeds() {
1902        let tmp = tempdir().expect("tempdir");
1903        let options = hermetic_session_options(tmp.path());
1904
1905        let handle = run_async(create_agent_session(options)).expect("create session");
1906        let provider = handle.session().agent.provider();
1907        assert!(!provider.name().is_empty());
1908        assert!(!provider.model_id().is_empty());
1909        assert_eq!(handle.model().0, provider.name());
1910        assert_eq!(handle.model().1, provider.model_id());
1911    }
1912
1913    #[test]
1914    fn create_agent_session_respects_provider_model_and_clamps_thinking() {
1915        let tmp = tempdir().expect("tempdir");
1916        let options = SessionOptions {
1917            provider: Some("openai".to_string()),
1918            model: Some("gpt-4o".to_string()),
1919            api_key: Some("dummy-key".to_string()),
1920            thinking: Some(crate::model::ThinkingLevel::Low),
1921            working_directory: Some(tmp.path().to_path_buf()),
1922            no_session: true,
1923            ..SessionOptions::default()
1924        };
1925
1926        let handle = run_async(create_agent_session(options)).expect("create session");
1927        let provider = handle.session().agent.provider();
1928        assert_eq!(provider.name(), "openai");
1929        assert_eq!(provider.model_id(), "gpt-4o");
1930        assert_eq!(
1931            handle.session().agent.stream_options().thinking_level,
1932            Some(crate::model::ThinkingLevel::Off)
1933        );
1934    }
1935
1936    #[test]
1937    fn create_agent_session_no_session_keeps_ephemeral_state() {
1938        let tmp = tempdir().expect("tempdir");
1939        let options = hermetic_session_options(tmp.path());
1940
1941        let handle = run_async(create_agent_session(options)).expect("create session");
1942        assert!(!handle.session().save_enabled());
1943
1944        let path_is_none = run_async(async {
1945            let cx = crate::agent_cx::AgentCx::for_request();
1946            let guard = handle
1947                .session()
1948                .session
1949                .lock(cx.cx())
1950                .await
1951                .expect("lock session");
1952            guard.path.is_none()
1953        });
1954        assert!(path_is_none);
1955    }
1956
1957    #[test]
1958    fn create_agent_session_uses_working_directory_for_new_session_header_and_path() {
1959        let _lock = current_dir_lock();
1960        let process_cwd = tempdir().expect("process cwd");
1961        let sdk_cwd = tempdir().expect("sdk cwd");
1962        let session_root = tempdir().expect("session root");
1963        let _guard = CurrentDirGuard::new(process_cwd.path());
1964
1965        let handle = run_async(create_agent_session(SessionOptions {
1966            provider: Some("openai".to_string()),
1967            model: Some("gpt-4o".to_string()),
1968            api_key: Some("dummy-key".to_string()),
1969            working_directory: Some(sdk_cwd.path().to_path_buf()),
1970            no_session: false,
1971            session_dir: Some(session_root.path().to_path_buf()),
1972            ..SessionOptions::default()
1973        }))
1974        .expect("create session");
1975
1976        let (header_cwd, path) = run_async(async {
1977            let cx = crate::agent_cx::AgentCx::for_request();
1978            let mut guard = handle
1979                .session()
1980                .session
1981                .lock(cx.cx())
1982                .await
1983                .expect("lock session");
1984            guard.save().await.expect("save sdk session");
1985            (
1986                guard.header.cwd.clone(),
1987                guard.path.clone().expect("saved session path"),
1988            )
1989        });
1990
1991        let expected_dir = session_root
1992            .path()
1993            .join(crate::session::encode_cwd(sdk_cwd.path()));
1994        let process_dir = session_root
1995            .path()
1996            .join(crate::session::encode_cwd(process_cwd.path()));
1997
1998        assert_eq!(header_cwd, sdk_cwd.path().display().to_string());
1999        assert_eq!(path.parent(), Some(expected_dir.as_path()));
2000        assert_ne!(path.parent(), Some(process_dir.as_path()));
2001    }
2002
2003    #[test]
2004    fn create_agent_session_resolves_relative_session_dir_against_working_directory() {
2005        let _lock = current_dir_lock();
2006        let process_cwd = tempdir().expect("process cwd");
2007        let sdk_cwd = tempdir().expect("sdk cwd");
2008        let _guard = CurrentDirGuard::new(process_cwd.path());
2009
2010        let handle = run_async(create_agent_session(SessionOptions {
2011            provider: Some("openai".to_string()),
2012            model: Some("gpt-4o".to_string()),
2013            api_key: Some("dummy-key".to_string()),
2014            working_directory: Some(sdk_cwd.path().to_path_buf()),
2015            no_session: false,
2016            session_dir: Some(PathBuf::from("sessions")),
2017            ..SessionOptions::default()
2018        }))
2019        .expect("create session");
2020
2021        let path = run_async(async {
2022            let cx = crate::agent_cx::AgentCx::for_request();
2023            let mut guard = handle
2024                .session()
2025                .session
2026                .lock(cx.cx())
2027                .await
2028                .expect("lock session");
2029            guard.save().await.expect("save sdk session");
2030            guard.path.clone().expect("saved session path")
2031        });
2032
2033        let expected_dir = sdk_cwd
2034            .path()
2035            .join("sessions")
2036            .join(crate::session::encode_cwd(sdk_cwd.path()));
2037        let process_dir = process_cwd
2038            .path()
2039            .join("sessions")
2040            .join(crate::session::encode_cwd(sdk_cwd.path()));
2041
2042        assert_eq!(path.parent(), Some(expected_dir.as_path()));
2043        assert_ne!(path.parent(), Some(process_dir.as_path()));
2044    }
2045
2046    #[test]
2047    fn create_agent_session_resolves_relative_session_path_against_working_directory() {
2048        let _lock = current_dir_lock();
2049        let process_cwd = tempdir().expect("process cwd");
2050        let sdk_cwd = tempdir().expect("sdk cwd");
2051        let _guard = CurrentDirGuard::new(process_cwd.path());
2052
2053        let session_path = sdk_cwd.path().join("relative").join("existing.jsonl");
2054        std::fs::create_dir_all(session_path.parent().expect("session parent"))
2055            .expect("create session parent");
2056        let mut header = crate::session::SessionHeader::new();
2057        header.cwd = sdk_cwd.path().display().to_string();
2058        let header_json = serde_json::to_string(&header).expect("serialize session header");
2059        std::fs::write(&session_path, format!("{header_json}\n")).expect("write session");
2060
2061        let handle = run_async(create_agent_session(SessionOptions {
2062            provider: Some("openai".to_string()),
2063            model: Some("gpt-4o".to_string()),
2064            api_key: Some("dummy-key".to_string()),
2065            working_directory: Some(sdk_cwd.path().to_path_buf()),
2066            no_session: false,
2067            session_path: Some(PathBuf::from("relative/existing.jsonl")),
2068            ..SessionOptions::default()
2069        }))
2070        .expect("create session");
2071
2072        let opened_path = run_async(async {
2073            let cx = crate::agent_cx::AgentCx::for_request();
2074            let guard = handle
2075                .session()
2076                .session
2077                .lock(cx.cx())
2078                .await
2079                .expect("lock session");
2080            guard.path.clone().expect("opened session path")
2081        });
2082
2083        assert_eq!(opened_path, session_path);
2084    }
2085
2086    #[test]
2087    fn from_session_with_listeners_set_model_switches_provider_model() {
2088        let dir = tempdir().expect("tempdir");
2089        let auth_path = dir.path().join("auth.json");
2090        let mut auth = AuthStorage::load(auth_path).expect("load auth");
2091        auth.set(
2092            "anthropic",
2093            crate::auth::AuthCredential::ApiKey {
2094                key: "anthropic-key".to_string(),
2095            },
2096        );
2097        auth.set(
2098            "openai",
2099            crate::auth::AuthCredential::ApiKey {
2100                key: "openai-key".to_string(),
2101            },
2102        );
2103
2104        let registry = ModelRegistry::load(&auth, None);
2105        let entry = registry
2106            .find("anthropic", "claude-sonnet-4-5")
2107            .expect("anthropic model in registry");
2108        let provider = providers::create_provider(&entry, None).expect("create anthropic provider");
2109        let tools = crate::tools::ToolRegistry::new(&[], std::path::Path::new("."), None);
2110        let agent = Agent::new(
2111            provider,
2112            tools,
2113            AgentConfig {
2114                system_prompt: None,
2115                max_tool_iterations: 50,
2116                stream_options: StreamOptions::default(),
2117                block_images: false,
2118                fail_closed_hooks: false,
2119                tool_approval: None,
2120            },
2121        );
2122
2123        let mut session = Session::in_memory();
2124        session.header.provider = Some("anthropic".to_string());
2125        session.header.model_id = Some("claude-sonnet-4-5".to_string());
2126
2127        let mut agent_session = AgentSession::new(
2128            agent,
2129            Arc::new(AsyncMutex::new(session)),
2130            false,
2131            ResolvedCompactionSettings::default(),
2132        );
2133        agent_session.set_model_registry(registry);
2134        agent_session.set_auth_storage(auth);
2135
2136        let mut handle =
2137            AgentSessionHandle::from_session_with_listeners(agent_session, EventListeners::new());
2138        run_async(handle.set_model("openai", "gpt-4o")).expect("set model");
2139        let provider = handle.session().agent.provider();
2140        assert_eq!(provider.name(), "openai");
2141        assert_eq!(provider.model_id(), "gpt-4o");
2142    }
2143
2144    #[test]
2145    fn create_agent_session_set_thinking_level_clamps_and_dedupes_history() {
2146        let tmp = tempdir().expect("tempdir");
2147        let options = SessionOptions {
2148            provider: Some("openai".to_string()),
2149            model: Some("gpt-4o".to_string()),
2150            api_key: Some("dummy-key".to_string()),
2151            working_directory: Some(tmp.path().to_path_buf()),
2152            no_session: true,
2153            ..SessionOptions::default()
2154        };
2155
2156        let mut handle = run_async(create_agent_session(options)).expect("create session");
2157        run_async(handle.set_thinking_level(crate::model::ThinkingLevel::High))
2158            .expect("set thinking");
2159        run_async(handle.set_thinking_level(crate::model::ThinkingLevel::High))
2160            .expect("reapply thinking");
2161
2162        assert_eq!(
2163            handle.session().agent.stream_options().thinking_level,
2164            Some(crate::model::ThinkingLevel::Off)
2165        );
2166
2167        let thinking_changes = run_async(async {
2168            let cx = crate::agent_cx::AgentCx::for_request();
2169            let guard = handle
2170                .session()
2171                .session
2172                .lock(cx.cx())
2173                .await
2174                .expect("lock session");
2175            assert_eq!(guard.header.thinking_level.as_deref(), Some("off"));
2176            guard
2177                .entries_for_current_path()
2178                .iter()
2179                .filter(|entry| {
2180                    matches!(entry, crate::session::SessionEntry::ThinkingLevelChange(_))
2181                })
2182                .count()
2183        });
2184        assert_eq!(thinking_changes, 1);
2185    }
2186
2187    #[test]
2188    fn from_session_with_listeners_set_thinking_level_uses_session_header_target() {
2189        let dir = tempdir().expect("tempdir");
2190        let auth_path = dir.path().join("auth.json");
2191        let auth = crate::auth::AuthStorage::load(auth_path).expect("load auth");
2192        let mut registry = ModelRegistry::load(&auth, None);
2193        registry.merge_entries(vec![ModelEntry {
2194            model: Model {
2195                id: "plain-model".to_string(),
2196                name: "Plain Model".to_string(),
2197                api: "openai-completions".to_string(),
2198                provider: "acme".to_string(),
2199                base_url: "https://example.invalid/v1".to_string(),
2200                reasoning: false,
2201                input: vec![InputType::Text],
2202                cost: ModelCost {
2203                    input: 0.0,
2204                    output: 0.0,
2205                    cache_read: 0.0,
2206                    cache_write: 0.0,
2207                },
2208                context_window: 128_000,
2209                max_tokens: 8_192,
2210                headers: HashMap::new(),
2211            },
2212            api_key: None,
2213            headers: HashMap::new(),
2214            auth_header: false,
2215            compat: None,
2216            oauth_config: None,
2217        }]);
2218        let entry = registry
2219            .find("anthropic", "claude-sonnet-4-5")
2220            .expect("anthropic model in registry");
2221        let provider = providers::create_provider(&entry, None).expect("create anthropic provider");
2222        let tools = crate::tools::ToolRegistry::new(&[], std::path::Path::new("."), None);
2223        let agent = Agent::new(
2224            provider,
2225            tools,
2226            AgentConfig {
2227                system_prompt: None,
2228                max_tool_iterations: 50,
2229                stream_options: StreamOptions::default(),
2230                block_images: false,
2231                fail_closed_hooks: false,
2232                tool_approval: None,
2233            },
2234        );
2235
2236        let mut session = Session::in_memory();
2237        session.header.provider = Some("acme".to_string());
2238        session.header.model_id = Some("plain-model".to_string());
2239
2240        let mut agent_session = AgentSession::new(
2241            agent,
2242            Arc::new(AsyncMutex::new(session)),
2243            false,
2244            ResolvedCompactionSettings::default(),
2245        );
2246        agent_session.set_model_registry(registry);
2247
2248        let mut handle =
2249            AgentSessionHandle::from_session_with_listeners(agent_session, EventListeners::new());
2250        run_async(handle.set_thinking_level(crate::model::ThinkingLevel::High))
2251            .expect("set thinking");
2252
2253        assert_eq!(
2254            handle.session().agent.stream_options().thinking_level,
2255            Some(crate::model::ThinkingLevel::Off)
2256        );
2257        assert_eq!(handle.model().0, "anthropic");
2258        assert_eq!(handle.model().1, "claude-sonnet-4-5");
2259    }
2260
2261    #[test]
2262    fn compact_without_history_is_noop() {
2263        let tmp = tempdir().expect("tempdir");
2264        let options = hermetic_session_options(tmp.path());
2265
2266        let mut handle = run_async(create_agent_session(options)).expect("create session");
2267        let events = Arc::new(Mutex::new(Vec::new()));
2268        let events_for_callback = Arc::clone(&events);
2269        run_async(handle.compact(move |event| {
2270            events_for_callback
2271                .lock()
2272                .expect("compact callback lock")
2273                .push(event);
2274        }))
2275        .expect("compact");
2276
2277        assert!(
2278            events
2279                .lock()
2280                .unwrap_or_else(std::sync::PoisonError::into_inner)
2281                .is_empty(),
2282            "expected no compaction lifecycle events for empty session"
2283        );
2284    }
2285
2286    #[test]
2287    fn resolve_path_for_cwd_uses_cwd_for_relative_paths() {
2288        let cwd = Path::new("/tmp/pi-sdk-cwd");
2289        assert_eq!(
2290            resolve_path_for_cwd(Path::new("relative/file.txt"), cwd),
2291            PathBuf::from("/tmp/pi-sdk-cwd/relative/file.txt")
2292        );
2293        assert_eq!(
2294            resolve_path_for_cwd(Path::new("/etc/hosts"), cwd),
2295            PathBuf::from("/etc/hosts")
2296        );
2297    }
2298
2299    // =====================================================================
2300    // EventListeners tests
2301    // =====================================================================
2302
2303    #[test]
2304    fn event_listeners_subscribe_and_notify() {
2305        let listeners = EventListeners::new();
2306        let received = Arc::new(Mutex::new(Vec::new()));
2307
2308        let recv_clone = Arc::clone(&received);
2309        let id = listeners.subscribe(Arc::new(move |event| {
2310            recv_clone
2311                .lock()
2312                .unwrap_or_else(std::sync::PoisonError::into_inner)
2313                .push(event);
2314        }));
2315
2316        let event = AgentEvent::AgentStart {
2317            session_id: "test-123".into(),
2318        };
2319        listeners.notify(&event);
2320
2321        let events = received
2322            .lock()
2323            .unwrap_or_else(std::sync::PoisonError::into_inner);
2324        assert_eq!(events.len(), 1);
2325
2326        // Verify unsubscribe
2327        drop(events);
2328        assert!(listeners.unsubscribe(id));
2329        listeners.notify(&AgentEvent::AgentStart {
2330            session_id: "test-456".into(),
2331        });
2332        assert_eq!(
2333            received
2334                .lock()
2335                .unwrap_or_else(std::sync::PoisonError::into_inner)
2336                .len(),
2337            1
2338        );
2339    }
2340
2341    #[test]
2342    fn event_listeners_unsubscribe_nonexistent_returns_false() {
2343        let listeners = EventListeners::new();
2344        assert!(!listeners.unsubscribe(SubscriptionId(999)));
2345    }
2346
2347    #[test]
2348    fn event_listeners_multiple_subscribers() {
2349        let listeners = EventListeners::new();
2350        let count_a = Arc::new(Mutex::new(0u32));
2351        let count_b = Arc::new(Mutex::new(0u32));
2352
2353        let ca = Arc::clone(&count_a);
2354        listeners.subscribe(Arc::new(move |_| {
2355            *ca.lock().unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
2356        }));
2357
2358        let cb = Arc::clone(&count_b);
2359        listeners.subscribe(Arc::new(move |_| {
2360            *cb.lock().unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
2361        }));
2362
2363        listeners.notify(&AgentEvent::AgentStart {
2364            session_id: "s".into(),
2365        });
2366
2367        assert_eq!(
2368            *count_a
2369                .lock()
2370                .unwrap_or_else(std::sync::PoisonError::into_inner),
2371            1
2372        );
2373        assert_eq!(
2374            *count_b
2375                .lock()
2376                .unwrap_or_else(std::sync::PoisonError::into_inner),
2377            1
2378        );
2379    }
2380
2381    #[test]
2382    fn event_listeners_tool_hooks_fire() {
2383        let listeners = EventListeners::new();
2384        let starts = Arc::new(Mutex::new(Vec::new()));
2385        let ends = Arc::new(Mutex::new(Vec::new()));
2386
2387        let s = Arc::clone(&starts);
2388        let mut listeners = listeners;
2389        listeners.on_tool_start = Some(Arc::new(move |name, args| {
2390            s.lock()
2391                .expect("lock")
2392                .push((name.to_string(), args.clone()));
2393        }));
2394
2395        let e = Arc::clone(&ends);
2396        listeners.on_tool_end = Some(Arc::new(move |name, _output, is_error| {
2397            e.lock()
2398                .unwrap_or_else(std::sync::PoisonError::into_inner)
2399                .push((name.to_string(), is_error));
2400        }));
2401
2402        let args = serde_json::json!({"path": "/foo"});
2403        listeners.notify_tool_start("bash", &args);
2404        let output = ToolOutput {
2405            content: vec![ContentBlock::Text(TextContent::new("ok"))],
2406            details: None,
2407            is_error: false,
2408        };
2409        listeners.notify_tool_end("bash", &output, false);
2410
2411        {
2412            let s = starts
2413                .lock()
2414                .unwrap_or_else(std::sync::PoisonError::into_inner);
2415            assert_eq!(s.len(), 1);
2416            assert_eq!(s[0].0, "bash");
2417            drop(s);
2418        }
2419
2420        {
2421            let e = ends
2422                .lock()
2423                .unwrap_or_else(std::sync::PoisonError::into_inner);
2424            assert_eq!(e.len(), 1);
2425            assert_eq!(e[0].0, "bash");
2426            assert!(!e[0].1);
2427            drop(e);
2428        }
2429    }
2430
2431    #[test]
2432    fn event_listeners_stream_event_hook_fires() {
2433        let mut listeners = EventListeners::new();
2434        let received = Arc::new(Mutex::new(Vec::new()));
2435
2436        let r = Arc::clone(&received);
2437        listeners.on_stream_event = Some(Arc::new(move |ev| {
2438            r.lock()
2439                .unwrap_or_else(std::sync::PoisonError::into_inner)
2440                .push(format!("{ev:?}"));
2441        }));
2442
2443        let event = StreamEvent::TextDelta {
2444            content_index: 0,
2445            delta: "hello".to_string(),
2446        };
2447        listeners.notify_stream_event(&event);
2448
2449        assert_eq!(
2450            received
2451                .lock()
2452                .unwrap_or_else(std::sync::PoisonError::into_inner)
2453                .len(),
2454            1
2455        );
2456    }
2457
2458    #[test]
2459    fn session_options_on_event_wired_into_listeners() {
2460        let received = Arc::new(Mutex::new(Vec::new()));
2461        let r = Arc::clone(&received);
2462        let tmp = tempdir().expect("tempdir");
2463
2464        let options = SessionOptions {
2465            on_event: Some(Arc::new(move |event| {
2466                r.lock()
2467                    .unwrap_or_else(std::sync::PoisonError::into_inner)
2468                    .push(format!("{event:?}"));
2469            })),
2470            ..hermetic_session_options(tmp.path())
2471        };
2472
2473        let handle = run_async(create_agent_session(options)).expect("create session");
2474        // Verify the listener was registered
2475        let count = handle
2476            .listeners()
2477            .subscribers
2478            .lock()
2479            .unwrap_or_else(std::sync::PoisonError::into_inner)
2480            .len();
2481        assert_eq!(
2482            count, 1,
2483            "on_event from SessionOptions should register one subscriber"
2484        );
2485    }
2486
2487    #[test]
2488    fn subscribe_unsubscribe_on_handle() {
2489        let tmp = tempdir().expect("tempdir");
2490        let options = hermetic_session_options(tmp.path());
2491
2492        let handle = run_async(create_agent_session(options)).expect("create session");
2493        let id = handle.subscribe(|_event| {});
2494        assert_eq!(
2495            handle
2496                .listeners()
2497                .subscribers
2498                .lock()
2499                .unwrap_or_else(std::sync::PoisonError::into_inner)
2500                .len(),
2501            1
2502        );
2503
2504        assert!(handle.unsubscribe(id));
2505        assert_eq!(
2506            handle
2507                .listeners()
2508                .subscribers
2509                .lock()
2510                .unwrap_or_else(std::sync::PoisonError::into_inner)
2511                .len(),
2512            0
2513        );
2514
2515        // Double unsubscribe returns false
2516        assert!(!handle.unsubscribe(id));
2517    }
2518
2519    #[test]
2520    fn stream_event_from_assistant_message_event_converts_text_delta() {
2521        use crate::model::AssistantMessageEvent as AME;
2522
2523        let partial = Arc::new(AssistantMessage {
2524            content: Vec::new(),
2525            api: String::new(),
2526            provider: String::new(),
2527            model: String::new(),
2528            usage: Usage::default(),
2529            stop_reason: StopReason::Stop,
2530            error_message: None,
2531            timestamp: 0,
2532        });
2533        let ame = AME::TextDelta {
2534            content_index: 2,
2535            delta: "chunk".to_string(),
2536            partial,
2537        };
2538        let result = stream_event_from_assistant_message_event(&ame);
2539        assert!(result.is_some());
2540        match result.unwrap() {
2541            StreamEvent::TextDelta {
2542                content_index,
2543                delta,
2544            } => {
2545                assert_eq!(content_index, 2);
2546                assert_eq!(delta, "chunk");
2547            }
2548            other => unreachable!("expected TextDelta, got {other:?}"),
2549        }
2550    }
2551
2552    #[test]
2553    fn stream_event_from_assistant_message_event_start_returns_none() {
2554        use crate::model::AssistantMessageEvent as AME;
2555
2556        let partial = Arc::new(AssistantMessage {
2557            content: Vec::new(),
2558            api: String::new(),
2559            provider: String::new(),
2560            model: String::new(),
2561            usage: Usage::default(),
2562            stop_reason: StopReason::Stop,
2563            error_message: None,
2564            timestamp: 0,
2565        });
2566        let ame = AME::Start { partial };
2567        assert!(stream_event_from_assistant_message_event(&ame).is_none());
2568    }
2569
2570    #[test]
2571    fn event_listeners_debug_impl() {
2572        let listeners = EventListeners::new();
2573        let debug = format!("{listeners:?}");
2574        assert!(debug.contains("subscriber_count"));
2575        assert!(debug.contains("has_on_tool_start"));
2576    }
2577
2578    // =====================================================================
2579    // Extension convenience method tests
2580    // =====================================================================
2581
2582    #[test]
2583    fn has_extensions_false_by_default() {
2584        let tmp = tempdir().expect("tempdir");
2585        let options = hermetic_session_options(tmp.path());
2586
2587        let handle = run_async(create_agent_session(options)).expect("create session");
2588        assert!(
2589            !handle.has_extensions(),
2590            "session without extension_paths should have no extensions"
2591        );
2592        assert!(handle.extension_manager().is_none());
2593        assert!(handle.extension_region().is_none());
2594    }
2595
2596    // =====================================================================
2597    // Tool factory function tests
2598    // =====================================================================
2599
2600    #[test]
2601    fn create_read_tool_has_correct_name() {
2602        let tmp = tempdir().expect("tempdir");
2603        let tool = super::create_read_tool(tmp.path());
2604        assert_eq!(tool.name(), "read");
2605        assert!(!tool.description().is_empty());
2606        let params = tool.parameters();
2607        assert!(params.is_object(), "parameters should be a JSON object");
2608    }
2609
2610    #[test]
2611    fn create_bash_tool_has_correct_name() {
2612        let tmp = tempdir().expect("tempdir");
2613        let tool = super::create_bash_tool(tmp.path());
2614        assert_eq!(tool.name(), "bash");
2615        assert!(!tool.description().is_empty());
2616    }
2617
2618    #[test]
2619    fn create_edit_tool_has_correct_name() {
2620        let tmp = tempdir().expect("tempdir");
2621        let tool = super::create_edit_tool(tmp.path());
2622        assert_eq!(tool.name(), "edit");
2623    }
2624
2625    #[test]
2626    fn create_write_tool_has_correct_name() {
2627        let tmp = tempdir().expect("tempdir");
2628        let tool = super::create_write_tool(tmp.path());
2629        assert_eq!(tool.name(), "write");
2630    }
2631
2632    #[test]
2633    fn create_grep_tool_has_correct_name() {
2634        let tmp = tempdir().expect("tempdir");
2635        let tool = super::create_grep_tool(tmp.path());
2636        assert_eq!(tool.name(), "grep");
2637    }
2638
2639    #[test]
2640    fn create_find_tool_has_correct_name() {
2641        let tmp = tempdir().expect("tempdir");
2642        let tool = super::create_find_tool(tmp.path());
2643        assert_eq!(tool.name(), "find");
2644    }
2645
2646    #[test]
2647    fn create_ls_tool_has_correct_name() {
2648        let tmp = tempdir().expect("tempdir");
2649        let tool = super::create_ls_tool(tmp.path());
2650        assert_eq!(tool.name(), "ls");
2651    }
2652
2653    #[test]
2654    fn create_all_tools_returns_eight() {
2655        let tmp = tempdir().expect("tempdir");
2656        let tools = super::create_all_tools(tmp.path());
2657        assert_eq!(tools.len(), 8, "should create all 8 built-in tools");
2658
2659        let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
2660        for expected in BUILTIN_TOOL_NAMES {
2661            assert!(names.contains(expected), "missing tool: {expected}");
2662        }
2663    }
2664
2665    #[test]
2666    fn tool_to_definition_preserves_schema() {
2667        let tmp = tempdir().expect("tempdir");
2668        let tool = super::create_read_tool(tmp.path());
2669        let def = super::tool_to_definition(tool.as_ref());
2670        assert_eq!(def.name, "read");
2671        assert!(!def.description.is_empty());
2672        assert!(def.parameters.is_object());
2673        assert!(
2674            def.parameters.get("properties").is_some(),
2675            "schema should have properties"
2676        );
2677    }
2678
2679    #[test]
2680    fn all_tool_definitions_returns_eight_schemas() {
2681        let tmp = tempdir().expect("tempdir");
2682        let defs = super::all_tool_definitions(tmp.path());
2683        assert_eq!(defs.len(), 8);
2684
2685        for def in &defs {
2686            assert!(!def.name.is_empty());
2687            assert!(!def.description.is_empty());
2688            assert!(def.parameters.is_object());
2689        }
2690    }
2691
2692    #[test]
2693    fn builtin_tool_names_matches_create_all() {
2694        let tmp = tempdir().expect("tempdir");
2695        let tools = super::create_all_tools(tmp.path());
2696        let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
2697        assert_eq!(
2698            names.as_slice(),
2699            BUILTIN_TOOL_NAMES,
2700            "create_all_tools order should match BUILTIN_TOOL_NAMES"
2701        );
2702    }
2703
2704    #[test]
2705    fn tool_registry_from_factory_tools() {
2706        let tmp = tempdir().expect("tempdir");
2707        let tools = super::create_all_tools(tmp.path());
2708        let registry = ToolRegistry::from_tools(tools);
2709        assert!(registry.get("read").is_some());
2710        assert!(registry.get("bash").is_some());
2711        assert!(registry.get("nonexistent").is_none());
2712    }
2713
2714    #[test]
2715    fn set_session_name_records_session_info_entry() {
2716        let tmp = tempdir().expect("tempdir");
2717        let options = hermetic_session_options(tmp.path());
2718
2719        let mut handle = run_async(create_agent_session(options)).expect("create session");
2720        run_async(handle.set_session_name("renamed-by-sdk")).expect("set session name");
2721
2722        let info_entries = run_async(async {
2723            let cx = crate::agent_cx::AgentCx::for_request();
2724            let guard = handle
2725                .session()
2726                .session
2727                .lock(cx.cx())
2728                .await
2729                .expect("lock session");
2730            guard
2731                .entries_for_current_path()
2732                .iter()
2733                .filter_map(|entry| match entry {
2734                    crate::session::SessionEntry::SessionInfo(info) => info.name.clone(),
2735                    _ => None,
2736                })
2737                .collect::<Vec<_>>()
2738        });
2739        assert_eq!(info_entries, vec!["renamed-by-sdk".to_string()]);
2740    }
2741
2742    #[test]
2743    fn max_tokens_defaults_to_registry_value_and_set_overrides() {
2744        let tmp = tempdir().expect("tempdir");
2745        let options = hermetic_session_options(tmp.path());
2746
2747        let mut handle = run_async(create_agent_session(options)).expect("create session");
2748        // The session now seeds `max_tokens` from the model registry's
2749        // configured `maxTokens` so the value users set in `models.json`
2750        // actually takes effect (instead of falling back to the provider's
2751        // hardcoded per-request default).
2752        let seeded = handle.max_tokens();
2753        assert!(
2754            seeded.is_some_and(|n| n > 0),
2755            "expected max_tokens to be seeded from the registry, got {seeded:?}"
2756        );
2757
2758        handle.set_max_tokens(Some(32_000));
2759        assert_eq!(handle.max_tokens(), Some(32_000));
2760        assert_eq!(
2761            handle.session().agent.stream_options().max_tokens,
2762            Some(32_000)
2763        );
2764
2765        // Embedders can still fall back to the provider default explicitly.
2766        handle.set_max_tokens(None);
2767        assert_eq!(handle.max_tokens(), None);
2768    }
2769}