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