1use 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
59pub type ToolDefinition = ToolDef;
61
62use crate::tools::{
67 BashTool, EditTool, FindTool, GrepTool, HashlineEditTool, LsTool, ReadTool, WriteTool,
68};
69
70pub const BUILTIN_TOOL_NAMES: &[&str] = &[
72 "read",
73 "bash",
74 "edit",
75 "write",
76 "grep",
77 "find",
78 "ls",
79 "hashline_edit",
80];
81
82pub fn create_read_tool(cwd: &Path) -> Box<dyn Tool> {
84 Box::new(ReadTool::new(cwd))
85}
86
87pub fn create_bash_tool(cwd: &Path) -> Box<dyn Tool> {
89 Box::new(BashTool::new(cwd))
90}
91
92pub fn create_edit_tool(cwd: &Path) -> Box<dyn Tool> {
94 Box::new(EditTool::new(cwd))
95}
96
97pub fn create_write_tool(cwd: &Path) -> Box<dyn Tool> {
99 Box::new(WriteTool::new(cwd))
100}
101
102pub fn create_grep_tool(cwd: &Path) -> Box<dyn Tool> {
104 Box::new(GrepTool::new(cwd))
105}
106
107pub fn create_find_tool(cwd: &Path) -> Box<dyn Tool> {
109 Box::new(FindTool::new(cwd))
110}
111
112pub fn create_ls_tool(cwd: &Path) -> Box<dyn Tool> {
114 Box::new(LsTool::new(cwd))
115}
116
117pub fn create_hashline_edit_tool(cwd: &Path) -> Box<dyn Tool> {
119 Box::new(HashlineEditTool::new(cwd))
120}
121
122pub 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
136pub 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
145pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
162pub struct SubscriptionId(u64);
163
164pub type OnToolStart = Arc<dyn Fn(&str, &Value) + Send + Sync>;
168
169pub type OnToolEnd = Arc<dyn Fn(&str, &ToolOutput, bool) + Send + Sync>;
173
174pub 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#[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 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 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 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 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 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 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#[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 pub tool_factory: Option<Arc<dyn ToolFactory>>,
318
319 pub on_event: Option<Arc<dyn Fn(AgentEvent) + Send + Sync>>,
324
325 pub on_tool_start: Option<OnToolStart>,
327
328 pub on_tool_end: Option<OnToolEnd>,
330
331 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
363pub trait ToolFactory: Send + Sync {
376 fn create_tool_registry(&self, enabled: &[&str], cwd: &Path, config: &Config) -> ToolRegistry;
385}
386
387pub fn default_tool_registry(enabled: &[&str], cwd: &Path, config: &Config) -> ToolRegistry {
395 ToolRegistry::new(enabled, cwd, Some(config))
396}
397
398pub struct AgentSessionHandle {
407 session: AgentSession,
408 listeners: EventListeners,
409}
410
411#[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#[derive(Debug, Clone)]
424pub enum SessionPromptResult {
425 InProcess(AssistantMessage),
426 RpcEvents(Vec<Value>),
427}
428
429#[derive(Debug, Clone)]
431pub enum SessionTransportEvent {
432 InProcess(AgentEvent),
433 Rpc(Value),
434}
435
436#[derive(Debug, Clone, PartialEq)]
438pub enum SessionTransportState {
439 InProcess(AgentSessionState),
440 Rpc(Box<RpcSessionState>),
441}
442
443#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
529pub struct RpcCancelledResult {
530 pub cancelled: bool,
531}
532
533#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
544pub struct RpcThinkingLevelResult {
545 pub level: crate::model::ThinkingLevel,
546}
547
548#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
572pub struct RpcForkResult {
573 pub text: String,
574 pub cancelled: bool,
575}
576
577#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
600pub struct RpcExportHtmlResult {
601 pub path: String,
602}
603
604#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
606pub struct RpcLastAssistantText {
607 pub text: Option<String>,
608}
609
610#[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#[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
637pub struct RpcTransportClient {
639 child: Child,
640 stdin: BufWriter<ChildStdin>,
641 stdout: BufReader<ChildStdout>,
642 next_request_id: u64,
643}
644
645pub 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 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 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 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 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 pub const fn from_session_with_listeners(
1197 session: AgentSession,
1198 listeners: EventListeners,
1199 ) -> Self {
1200 Self { session, listeners }
1201 }
1202
1203 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 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 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 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 pub fn new_abort_handle() -> (AbortHandle, AbortSignal) {
1267 AbortHandle::new()
1268 }
1269
1270 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 pub fn unsubscribe(&self, id: SubscriptionId) -> bool {
1287 self.listeners.unsubscribe(id)
1288 }
1289
1290 pub const fn listeners(&self) -> &EventListeners {
1292 &self.listeners
1293 }
1294
1295 pub const fn listeners_mut(&mut self) -> &mut EventListeners {
1300 &mut self.listeners
1301 }
1302
1303 pub const fn has_extensions(&self) -> bool {
1309 self.session.extensions.is_some()
1310 }
1311
1312 pub fn extension_manager(&self) -> Option<&ExtensionManager> {
1314 self.session
1315 .extensions
1316 .as_ref()
1317 .map(ExtensionRegion::manager)
1318 }
1319
1320 pub const fn extension_region(&self) -> Option<&ExtensionRegion> {
1324 self.session.extensions.as_ref()
1325 }
1326
1327 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 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 pub const fn thinking_level(&self) -> Option<crate::model::ThinkingLevel> {
1344 self.session.agent.stream_options().thinking_level
1345 }
1346
1347 pub const fn thinking(&self) -> Option<crate::model::ThinkingLevel> {
1349 self.thinking_level()
1350 }
1351
1352 pub async fn set_thinking_level(&mut self, level: crate::model::ThinkingLevel) -> Result<()> {
1359 self.session.set_thinking_level(level).await
1360 }
1361
1362 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 pub const fn max_tokens(&self) -> Option<u32> {
1390 self.session.agent.stream_options().max_tokens
1391 }
1392
1393 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 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 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 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 pub const fn session(&self) -> &AgentSession {
1449 &self.session
1450 }
1451
1452 pub const fn session_mut(&mut self) -> &mut AgentSession {
1454 &mut self.session
1455 }
1456
1457 pub fn into_inner(self) -> AgentSession {
1459 self.session
1460 }
1461
1462 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 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 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 listeners.notify(&event);
1502
1503 per_prompt(event);
1505 }
1506 }
1507}
1508
1509fn 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 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 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#[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 #[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 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 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 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 #[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 #[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 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 handle.set_max_tokens(None);
2767 assert_eq!(handle.max_tokens(), None);
2768 }
2769}