1use std::collections::HashSet;
34use std::path::{Path, PathBuf};
35use std::sync::atomic::Ordering;
36use std::sync::{Arc, Mutex, OnceLock};
37
38use rpi_ai::Provider;
39use rpi_harness::agent_harness::AgentHarness;
40use rpi_harness::context_files::{format_project_context, load_project_context_files};
41use rpi_harness::session::memory::{InMemorySessionStorage, SystemClock};
42use rpi_harness::session::session::DefaultIdGenerator;
43use rpi_harness::session::types::SessionMetadata;
44use rpi_harness::session::Session;
45use rpi_harness::system_prompt::compose_system_prompt;
46use rpi_harness::types::{
47 AgentHarnessOptions, AgentHarnessResources, AgentHarnessStreamOptions, DrivingMode,
48 HarnessTool, HarnessToolExecution, ToolReplay,
49};
50#[cfg(windows)]
51use rpi_tools::create_powershell_tool;
52use rpi_tools::{
53 create_bash_tool, create_edit_tool, create_read_tool, create_write_tool, ExecutionToolContext,
54 MutationQueueRegistry, OsExecutionEnv,
55};
56
57use crate::args::Args;
58use crate::docs_tool::create_docs_tool;
59use crate::provider::ResolvedModel;
60use crate::resource_dirs::{
61 discover_append_system_prompt_file, discover_system_prompt_file, extension_dirs,
62 global_extension_dirs, global_prompt_template_dirs, global_skill_dirs,
63 load_prompt_templates_with_precedence, load_skills_with_precedence, project_extension_dirs,
64 project_prompt_template_dirs, project_skill_dirs, prompt_template_dirs, skill_dirs,
65};
66use rpi_extensions::{
67 emit_resources_discover, ExtensionEmitter, ExtensionSession, NullDiagnostics,
68 PluginDiagnostics, PluginToolAdapter, TeeEmitter,
69};
70
71pub const BUILTIN_TOOL_NAMES: &[&str] = &["read", "bash", "edit", "write", "docs"];
73
74pub fn default_system_prompt(cwd: &str, prior_reasoning_replayed: bool) -> String {
79 let tools = BUILTIN_TOOL_NAMES
80 .iter()
81 .map(|name| (*name).to_owned())
82 .collect::<Vec<_>>();
83 default_system_prompt_with_tools(cwd, prior_reasoning_replayed, &tools)
84}
85
86pub fn default_system_prompt_with_tools(
94 cwd: &str,
95 prior_reasoning_replayed: bool,
96 tools: &[String],
97) -> String {
98 let has = |name: &str| tools.iter().any(|tool| tool == name);
99 let todo_hint = if has("todo") {
100 "\n Keep the steps of multi-step work in the todo tool and mark them done as you go."
101 } else {
102 ""
103 };
104 let working_state_rule = if prior_reasoning_replayed {
112 format!(
113 "- Your reasoning is carried back to you on the next turn: do not re-derive it or restate
114 the plan. Continue from the last unfinished step, and when a step lands, say which one.{todo_hint}
115 Re-stating the same plan without acting on it is a bug, not progress."
116 )
117 } else {
118 format!(
119 "- Your reasoning is not carried into your next turn; only your visible replies and tool
120 output come back. Before each batch of tool calls, write one short line naming the step you
121 are on and what remains, and say which step is done when it lands.{todo_hint}
122 Re-stating the same plan without acting on it is a bug, not progress."
123 )
124 };
125
126 let tool_lines = if tools.is_empty() {
127 "(none)".to_string()
128 } else {
129 tools
130 .iter()
131 .map(|name| match builtin_tool_snippet(name) {
132 Some(snippet) => format!("- {name:<5} — {snippet}"),
133 None => format!("- {name}"),
134 })
135 .collect::<Vec<_>>()
136 .join("\n")
137 };
138
139 let guidelines = [
142 "- Reply in the same language as the user's latest message (the conversation's language when
143 that message is too short to tell), unless the user asks for another.",
144 "- Be concise in your responses",
145 "- Show file paths clearly when working with files",
146 "- Prefer the smallest change that solves the problem",
147 &working_state_rule,
148 "- Read only what you need. If a file or search result was already shown earlier in this
149 conversation, use it instead of fetching it again.",
150 ];
151 let guidelines = guidelines.join("\n");
152
153 format!(
154 "You are an expert coding assistant operating inside rpi, a coding agent harness. \
155You help users by reading files, executing commands, editing code, and writing new files.
156
157Available tools:
158{tool_lines}
159
160Guidelines:
161{guidelines}
162
163Current working directory: {cwd}"
164 )
165}
166
167fn builtin_tool_snippet(name: &str) -> Option<&'static str> {
169 Some(match name {
170 "read" => "Read file contents",
171 "bash" => "Execute Bash commands (Bash/POSIX syntax, also on Windows)",
172 "edit" => "Find/replace edits to existing files",
173 "write" => "Create or overwrite files",
174 "docs" => "Look up rpi commands, extensions, Pi package compatibility and .rpi configuration instead of guessing",
175 "grep" => "Search file contents for a pattern",
176 "find" => "Find files by glob pattern",
177 "ls" => "List directory contents",
178 "powershell" => "Execute PowerShell commands on Windows",
179 _ => return None,
180 })
181}
182
183#[derive(Debug, Clone)]
190pub enum SessionSelection {
191 Ephemeral,
193 New { dir: PathBuf, name: Option<String> },
195 Latest { dir: PathBuf },
197 ById { id: String, dir: PathBuf },
200 ByExactId { id: String, dir: PathBuf },
202 Fork { source: String, dir: PathBuf },
205}
206
207pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
209 select_session_in_dir(args, configured_session_dir(args, cwd))
210}
211
212pub(crate) fn configured_session_dir(args: &Args, cwd: &Path) -> PathBuf {
214 let dir = args
215 .session_dir
216 .clone()
217 .or_else(|| {
218 crate::settings::load_settings()
219 .ok()
220 .and_then(|settings| settings.session_dir)
221 })
222 .unwrap_or_else(|| default_session_dir(cwd));
223 if dir.is_relative() && !dir.has_root() {
226 cwd.join(dir)
227 } else {
228 dir
229 }
230}
231
232fn select_session_in_dir(args: &Args, dir: PathBuf) -> SessionSelection {
233 if args.no_session {
234 return SessionSelection::Ephemeral;
235 }
236 if args.continue_session || args.resume {
240 return SessionSelection::Latest { dir };
241 }
242 if let Some(s) = &args.fork {
243 return SessionSelection::Fork {
244 source: s.clone(),
245 dir,
246 };
247 }
248 if let Some(s) = &args.session_id {
249 return SessionSelection::ByExactId { id: s.clone(), dir };
250 }
251 if let Some(s) = &args.session {
252 return SessionSelection::ById { id: s.clone(), dir };
253 }
254 SessionSelection::New {
255 dir,
256 name: args.name.clone(),
257 }
258}
259
260pub fn default_session_dir(cwd: &Path) -> PathBuf {
263 std::env::var("RPI_CODING_AGENT_SESSION_DIR")
264 .ok()
265 .map(PathBuf::from)
266 .unwrap_or_else(|| cwd.join(".rpi").join("sessions"))
267}
268
269pub struct PreparedExtensions {
272 pub extension_session: ExtensionSession,
273 session_dir: PathBuf,
274 runtime: tokio::runtime::Handle,
275 tool_context: rpi_extensions::ToolCallContext,
276 action_host: Arc<crate::extensions_actions::HarnessActionHost>,
277 harness_cell: Arc<std::sync::OnceLock<rpi_harness::agent_harness::WeakAgentHarness>>,
278 reload_mailbox: rpi_extensions::ReloadMailbox,
279 ui_dialog_mailbox: rpi_extensions::UiDialogMailbox,
280 ext_status_mailbox: rpi_extensions::ExtensionStatusMailbox,
281 editor_text_mailbox: rpi_extensions::EditorTextMailbox,
282 action_bridge: Arc<rpi_extensions::ActionBridge>,
283}
284
285pub fn prepare_extensions(
286 args: &Args,
287 cwd: &Path,
288 project_trusted: bool,
289) -> Result<PreparedExtensions, BuildError> {
290 let session_dir = configured_session_dir(args, cwd);
291 let cwd_str = cwd.to_string_lossy().to_string();
292 let tool_context = rpi_extensions::ToolCallContext::new(cwd_str.clone());
293 if !project_trusted && args.verbose {
294 eprintln!(
295 "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)"
296 );
297 }
298 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
317 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
318 })?;
319 let catalog = Vec::new();
320 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
321 catalog.clone(),
322 cwd.to_path_buf(),
323 runtime.clone(),
324 args.unknown_flags.clone(),
325 tool_context.clone(),
326 );
327 let action_host = Arc::new(action_host.with_session_dir(session_dir.clone()));
328 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = action_host.clone();
329 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
339 let ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
345 let ext_status_mailbox = rpi_extensions::ExtensionStatusMailbox::new();
349 let editor_text_mailbox = rpi_extensions::EditorTextMailbox::new();
354 let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
355 runtime.clone(),
356 host_arc,
357 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
358 ui_dialog_mailbox.clone(),
359 ext_status_mailbox.clone(),
360 editor_text_mailbox.clone(),
361 );
362
363 let extension_session = if args.no_extensions {
364 ExtensionSession::none()
365 } else {
366 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
367 };
368 if args.verbose {
369 if let Some(s) = extension_session.summary() {
370 eprintln!("extensions: {s}");
371 }
372 report_deferred_renderers(&extension_session);
373 }
374 Ok(PreparedExtensions {
375 session_dir,
376 runtime,
377 tool_context,
378 action_host,
379 harness_cell,
380 reload_mailbox,
381 ui_dialog_mailbox,
382 ext_status_mailbox,
383 editor_text_mailbox,
384 action_bridge,
385 extension_session,
386 })
387}
388
389pub async fn build(
407 resolved: &ResolvedModel,
408 args: &Args,
409 cwd: &Path,
410 project_trusted: bool,
411) -> Result<
412 (
413 AgentHarness,
414 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
415 ReloadContext,
416 ),
417 BuildError,
418> {
419 let prepared = prepare_extensions(args, cwd, project_trusted)?;
420 build_prepared(resolved, args, cwd, project_trusted, prepared).await
421}
422
423pub async fn build_prepared(
424 resolved: &ResolvedModel,
425 args: &Args,
426 cwd: &Path,
427 project_trusted: bool,
428 prepared: PreparedExtensions,
429) -> Result<
430 (
431 AgentHarness,
432 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
433 ReloadContext,
434 ),
435 BuildError,
436> {
437 let cwd_str = cwd.to_string_lossy().to_string();
438 let PreparedExtensions {
443 session_dir,
444 runtime,
445 tool_context,
446 action_host,
447 harness_cell,
448 reload_mailbox,
449 ui_dialog_mailbox,
450 ext_status_mailbox,
451 editor_text_mailbox,
452 action_bridge,
453 extension_session,
454 } = prepared;
455 let catalog = crate::provider::authenticated_catalog(resolved);
456 action_host.set_catalog(catalog.clone());
457
458 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
460 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
461 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
462 let _registry = Arc::new(MutationQueueRegistry::new());
463 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
466
467 let mut tools = build_tools(&ctx, args);
468
469 merge_extension_tools(&mut tools, &extension_session, args, &tool_context);
477 let active = filter_active_tool_names(active_tool_names(&tools, args), args);
478
479 let selection = select_session_in_dir(args, session_dir.clone());
481 let session = build_session(&selection, &cwd_str).await?;
482 if let Ok(metadata) = session.get_metadata().await {
486 publish_session_identity(&tool_context, &metadata.id);
487 }
488 if let Some(requested) = args.name.as_deref() {
494 let name = requested.trim();
495 let name = if name.is_empty() { None } else { Some(name) };
496 if let Err(error) = session.set_name(name).await {
497 if args.verbose {
498 eprintln!("warning: could not set session name: {error}");
499 }
500 }
501 }
502 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
512 let prompt_tools = active.clone();
513 let mut context_paths = Vec::new();
514 let base_prompt = match args.system_prompt.as_deref() {
515 Some(explicit) => explicit.to_string(),
516 None if project_trusted => match discover_system_prompt_file(cwd) {
517 Some(path) => read_prompt_source(&path, &mut context_paths).unwrap_or_else(|| {
518 default_system_prompt_with_tools(&cwd_str, prior_reasoning_replayed, &prompt_tools)
519 }),
520 None => {
521 default_system_prompt_with_tools(&cwd_str, prior_reasoning_replayed, &prompt_tools)
522 }
523 },
524 None => default_system_prompt_with_tools(&cwd_str, prior_reasoning_replayed, &prompt_tools),
525 };
526
527 let mut append_texts: Vec<String> = Vec::new();
534 for extra in &args.append_system_prompt {
535 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
536 append_texts.push(text);
537 }
538 if args.append_system_prompt.is_empty() {
539 if let Some(path) = project_trusted
540 .then(|| discover_append_system_prompt_file(cwd))
541 .flatten()
542 {
543 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
544 append_texts.push(text);
545 }
546 }
547 }
548 let append_join = if append_texts.is_empty() {
549 None
550 } else {
551 Some(append_texts.join("\n\n"))
552 };
553
554 let agent_dir = crate::config::agent_dir().ok();
566
567 let discovered = extension_session
583 .snapshot_arc()
584 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
585 .unwrap_or_default();
586
587 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
588 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
589 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
590 if !args.no_skills {
591 let mut dirs = if args.dev_local_only || args.project_only {
592 project_skill_dirs(cwd)
593 } else if project_trusted {
594 skill_dirs(cwd)
595 } else {
596 global_skill_dirs()
597 };
598 dirs.extend(args.skill.iter().cloned());
599 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
600 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
601 skills = result.skills;
602 skill_diags = result.diagnostics;
603 resource_diags.extend(result.resource_diagnostics);
604 }
605
606 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
607 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
608 if !args.no_prompt_templates {
609 let mut dirs = if args.dev_local_only || args.project_only {
610 project_prompt_template_dirs(cwd)
611 } else if project_trusted {
612 prompt_template_dirs(cwd)
613 } else {
614 global_prompt_template_dirs()
615 };
616 dirs.extend(args.prompt_template.iter().cloned());
617 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
618 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
619 prompt_templates = result.prompt_templates;
620 prompt_diags = result.diagnostics;
621 resource_diags.extend(result.resource_diagnostics);
622 }
623
624 let context_block = if args.no_context_files || !project_trusted {
625 String::new()
626 } else {
627 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
632 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
633 context_paths.extend(
634 files
635 .iter()
636 .map(|file| file.path.to_string_lossy().into_owned()),
637 );
638 format_project_context(&files)
639 };
640
641 if args.verbose {
643 for d in &skill_diags {
644 eprintln!(
645 "warning: skill {} ({}): {}",
646 d.path,
647 d.code.as_str(),
648 d.message
649 );
650 }
651 for d in &prompt_diags {
652 eprintln!(
653 "warning: prompt template {} ({}): {}",
654 d.path,
655 d.code.as_str(),
656 d.message
657 );
658 }
659 for d in &resource_diags {
661 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
662 eprintln!(
663 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
664 c.resource_type.as_str(),
665 c.name,
666 c.winner_path,
667 c.loser_path
668 );
669 }
670 }
671 }
672
673 let system_prompt = compose_system_prompt(
682 Some(&base_prompt),
683 &[], if context_block.is_empty() {
685 None
686 } else {
687 Some(&context_block)
688 },
689 append_join.as_deref(),
690 );
691
692 if args.debug_system_prompt {
701 eprintln!("=== --debug-system-prompt ===");
702 let base_src = if args.system_prompt.is_some() {
703 "--system-prompt"
704 } else if discover_system_prompt_file(cwd).is_some() {
705 "SYSTEM.md"
706 } else {
707 "default"
708 };
709 eprintln!("[base source: {base_src}]");
710 eprintln!("--- base ---\n{base_prompt}");
711 if let Some(append) = append_join.as_deref() {
712 eprintln!("--- append ---\n{append}");
713 } else {
714 eprintln!("--- append: (none) ---");
715 }
716 if context_block.is_empty() {
717 eprintln!("--- context: (none) ---");
718 } else {
719 eprintln!("--- context ---{context_block}");
720 }
721 let visible_skills = skills
722 .iter()
723 .filter(|s| s.disable_model_invocation != Some(true))
724 .count();
725 eprintln!(
726 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
727 skills.len(),
728 visible_skills,
729 skills.len() - visible_skills
730 );
731 for s in &skills {
732 let hidden = if s.disable_model_invocation == Some(true) {
733 " [hidden]"
734 } else {
735 ""
736 };
737 eprintln!(" {}{hidden} — {}", s.name, s.description);
738 }
739 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
740 for t in &prompt_templates {
741 eprintln!(" /{}", t.name);
742 }
743 eprintln!(
747 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
748 discovered.skill_paths.len(),
749 discovered.prompt_paths.len(),
750 discovered.theme_paths.len(),
751 );
752 for p in &discovered.skill_paths {
753 eprintln!(" skill: {p}");
754 }
755 for p in &discovered.prompt_paths {
756 eprintln!(" prompt: {p}");
757 }
758 eprintln!(
759 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
760 );
761 eprintln!("=== end --debug-system-prompt ===");
762 }
763
764 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
769 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
770 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
774
775 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
784 Some(snapshot) => {
785 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
786 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
787 }
788 None => broadcast_emitter,
789 };
790
791 let saved_settings = crate::settings::load_settings().unwrap_or_default();
795
796 if let Some(proxy) = &saved_settings.http_proxy {
801 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
802 if std::env::var_os(key).is_none() {
803 std::env::set_var(key, proxy);
804 }
805 }
806 }
807
808 let extension_session_cell = Arc::new(Mutex::new(extension_session.clone()));
813 let before_tool_call = extension_session
818 .snapshot()
819 .filter(|snapshot| {
820 !snapshot
821 .handlers_for(rpi_plugin_sdk::EventTag::ToolCall)
822 .is_empty()
823 })
824 .map(|_| {
825 extension_tool_call_hook(
826 Arc::clone(&extension_session_cell),
827 ui_dialog_mailbox.clone(),
828 )
829 });
830
831 let options = AgentHarnessOptions {
832 model: resolved.model.clone(),
833 thinking_level: resolved.thinking_level,
834 active_tool_names: active,
835 tools,
836 system_prompt: Some(system_prompt),
837 resources: AgentHarnessResources {
838 context_files: context_paths,
839 skills: if skills.is_empty() {
840 None
841 } else {
842 Some(skills)
843 },
844 prompt_templates: if prompt_templates.is_empty() {
845 None
846 } else {
847 Some(prompt_templates)
848 },
849 },
850 allow_existing_session: matches!(
853 selection,
854 SessionSelection::Latest { .. }
855 | SessionSelection::ById { .. }
856 | SessionSelection::ByExactId { .. }
857 | SessionSelection::Fork { .. }
858 ),
859 stream_options: AgentHarnessStreamOptions {
860 timeout: args.timeout,
861 stream_idle_timeout: args.stream_idle_timeout.or_else(|| {
862 saved_settings
863 .stream_idle_timeout
864 .map(std::time::Duration::from_secs)
865 }),
866 ..Default::default()
867 },
868 retry: saved_settings.retry_policy(),
873 compaction: saved_settings.compaction_settings(),
874 steering_mode: saved_settings.steering_mode(),
875 follow_up_mode: saved_settings.follow_up_mode(),
876 tool_execution: HarnessToolExecution::default(),
877 drive: DrivingMode::default(),
878 session,
879 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
887 to_provider_messages: None,
888 entry_projectors: Default::default(),
889 agent_emitter: Some(emitter),
890 before_tool_call,
895 after_tool_call: None,
896 transform_context: None,
897 entry_transforms: Vec::new(),
898 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
904 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
905 };
906
907 let harness = match AgentHarness::create(options).await {
908 Ok(h) => {
909 crate::extensions_actions::HarnessActionHost::set_harness(
912 &harness_cell,
913 Arc::new(h.clone()),
914 );
915 h
916 }
917 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
918 };
919
920 {
928 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
929 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
930 Ok(reports) => {
931 for (lane, report) in reports {
932 if report.is_clean() {
933 continue;
934 }
935 if report.is_corrupt() {
936 eprintln!(
937 "warning: session lane '{lane}' is corrupt ({} open operations); \
938 refusing to auto-repair — inspect the session file",
939 report.open_operations.len()
940 );
941 continue;
942 }
943 for decision in runtime.reconcile(&report) {
944 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
945 if args.verbose {
946 eprintln!(
947 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
948 );
949 }
950 }
951 }
952 runtime.emit_recovery_events(harness.events(), &report);
953 }
954 }
955 Err(e) => {
956 if args.verbose {
957 eprintln!("warning: session recovery sweep failed: {e}");
958 }
959 }
960 }
961 }
962
963 let mut reload_args = args.clone();
972 reload_args.session_dir = Some(session_dir);
973 let reload_context = ReloadContext {
974 extension_session: extension_session_cell,
975 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
976 tool_context,
977 catalog: Arc::new(std::sync::RwLock::new(catalog)),
978 providers: Arc::new(std::sync::RwLock::new(resolved.providers.clone())),
979 runtime: runtime.clone(),
980 cwd: cwd.to_path_buf(),
981 project_trusted,
982 args: reload_args,
983 resolved_model: resolved.model.clone(),
984 broadcast: broadcast_for_context,
985 mailbox: reload_mailbox,
986 ui_dialog: ui_dialog_mailbox,
987 ext_status: ext_status_mailbox,
988 editor_text: editor_text_mailbox,
989 dev_extension: None,
990 };
991
992 Ok((harness, event_rx, reload_context))
993}
994
995fn extension_tool_call_hook(
998 sessions: ExtensionSessionCell,
999 ui_dialog: rpi_extensions::UiDialogMailbox,
1000) -> rpi_agent::BeforeToolCall {
1001 Arc::new(move |ctx: rpi_agent::BeforeToolCallContext<'_>, signal| {
1002 let session = sessions
1003 .lock()
1004 .map(|session| session.clone())
1005 .map_err(|_| ());
1006 let ui_dialog = ui_dialog.clone();
1007 let tool_call_id = ctx.tool_call.id.clone();
1008 let tool_name = ctx.tool_call.name.clone();
1009 let params = ctx.args.clone();
1010 Box::pin(async move {
1011 let reason = match session {
1012 Ok(session) => {
1013 rpi_extensions::dispatch_tool_call_event(
1014 &session,
1015 &tool_call_id,
1016 &tool_name,
1017 ¶ms,
1018 &signal,
1019 &ui_dialog,
1020 )
1021 .await
1022 }
1023 Err(_) => Some("extension approval session is unavailable".to_string()),
1024 };
1025 reason.map(|reason| rpi_agent::BeforeToolCallResult {
1026 block: true,
1027 reason: Some(reason),
1028 terminate: false,
1029 args: None,
1030 })
1031 })
1032 })
1033}
1034
1035pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
1080
1081pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
1086
1087#[derive(Clone)]
1095pub struct ReloadContext {
1096 pub extension_session: ExtensionSessionCell,
1098 pub tool_context: rpi_extensions::ToolCallContext,
1103 pub action_bridge: ActionBridgeCell,
1105 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
1108 pub editor_text: rpi_extensions::EditorTextMailbox,
1115 pub catalog: Arc<std::sync::RwLock<Vec<rpi_ai::Model>>>,
1118 pub providers: Arc<std::sync::RwLock<Vec<Arc<dyn Provider>>>>,
1121 pub runtime: tokio::runtime::Handle,
1125 pub cwd: PathBuf,
1127 pub project_trusted: bool,
1131 pub args: Args,
1138 pub resolved_model: rpi_ai::Model,
1141 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
1147 pub mailbox: rpi_extensions::ReloadMailbox,
1154 pub ui_dialog: rpi_extensions::UiDialogMailbox,
1160 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
1164}
1165
1166impl ReloadContext {
1167 pub(crate) fn session_dir(&self) -> PathBuf {
1168 self.args
1170 .session_dir
1171 .clone()
1172 .unwrap_or_else(|| configured_session_dir(&self.args, &self.cwd))
1173 }
1174}
1175
1176pub async fn dispatch_session_event_async(
1191 reload_context: &ReloadContext,
1192 tag: rpi_plugin_sdk::EventTag,
1193) -> Option<String> {
1194 let (snapshot, keepalive) = {
1200 let session = reload_context.extension_session.lock().unwrap();
1201 (session.snapshot_arc(), session.keepalive())
1202 };
1203 match snapshot {
1204 Some(snapshot) => rpi_extensions::dispatch_lifecycle_event(&snapshot, tag, keepalive).await,
1205 None => None,
1206 }
1207}
1208
1209async fn shutdown_extensions_for_reload(
1212 snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
1213 keepalive: Arc<rpi_extensions::PluginKeepalive>,
1214) -> Result<(), String> {
1215 if let Some(snapshot) = snapshot {
1216 tokio::task::spawn_blocking(move || {
1217 let _keepalive = keepalive;
1218 rpi_extensions::dispatch_empty_event(
1219 &snapshot,
1220 rpi_plugin_sdk::EventTag::SessionShutdown,
1221 );
1222 })
1223 .await
1224 .map_err(|error| error.to_string())?;
1225 }
1226 Ok(())
1227}
1228
1229pub struct ReloadOutcome {
1232 pub summary: String,
1235 pub had_warnings: bool,
1237 pub details: Vec<String>,
1242 pub had_errors: bool,
1246}
1247
1248struct PreparedReloadInputs {
1249 extension_dirs: Vec<PathBuf>,
1250 skill_base_dirs: Vec<PathBuf>,
1251 prompt_base_dirs: Vec<PathBuf>,
1252}
1253
1254fn append_reload_resource_paths(
1259 mut paths: Vec<PathBuf>,
1260 explicit: &[PathBuf],
1261 discovered: &[String],
1262 js_paths: &[PathBuf],
1263 package_paths: &[PathBuf],
1264) -> Vec<PathBuf> {
1265 paths.extend(explicit.iter().cloned());
1266 paths.extend(discovered.iter().map(PathBuf::from));
1267 paths.extend(js_paths.iter().cloned());
1268 paths.extend(package_paths.iter().cloned());
1269 paths
1270}
1271
1272pub async fn reload_extension_resources(
1284 harness: &AgentHarness,
1285 ctx: &ReloadContext,
1286) -> ReloadOutcome {
1287 reload_extension_resources_inner(harness, ctx, || {}).await
1288}
1289
1290async fn reload_extension_resources_inner<F>(
1291 harness: &AgentHarness,
1292 ctx: &ReloadContext,
1293 after_prepare: F,
1294) -> ReloadOutcome
1295where
1296 F: FnOnce() + Send,
1297{
1298 let mut effective_args = ctx.args.clone();
1303 if let Some(dev) = &ctx.dev_extension {
1304 if let Err(error) = dev
1305 .rebuild()
1306 .and_then(|_| dev.apply_to_args(&mut effective_args))
1307 {
1308 return ReloadOutcome {
1309 summary: format!(
1310 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1311 dev.package_name()
1312 ),
1313 had_warnings: true,
1314 details: Vec::new(),
1315 had_errors: true,
1316 };
1317 }
1318 }
1319 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1320 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1321 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1322 Ok(prepared) => prepared,
1323 Err(error) => {
1324 return ReloadOutcome {
1325 summary: format!(
1326 "Settings reload failed: {error}. Keeping the currently loaded resources."
1327 ),
1328 had_warnings: true,
1329 details: Vec::new(),
1330 had_errors: true,
1331 };
1332 }
1333 };
1334 after_prepare();
1335 let PreparedReloadInputs {
1336 extension_dirs,
1337 skill_base_dirs,
1338 prompt_base_dirs,
1339 } = prepared;
1340 let mut warnings = false;
1341
1342 let (shutdown_snapshot, shutdown_keepalive) = {
1346 let session = ctx.extension_session.lock().unwrap();
1347 (session.snapshot_arc(), session.keepalive())
1348 };
1349 if let Err(reason) = shutdown_extensions_for_reload(shutdown_snapshot, shutdown_keepalive).await
1350 {
1351 return ReloadOutcome {
1352 summary: "Reload failed during extension shutdown".into(),
1353 had_warnings: true,
1354 details: vec![reason],
1355 had_errors: true,
1356 };
1357 }
1358
1359 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1366 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1367 Some(b) => b.clone_host(),
1368 None => {
1369 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1372 ctx.catalog
1373 .read()
1374 .unwrap_or_else(|error| error.into_inner())
1375 .clone(),
1376 ctx.cwd.clone(),
1377 ctx.runtime.clone(),
1378 ctx.args.unknown_flags.clone(),
1379 ctx.tool_context.clone(),
1380 );
1381 crate::extensions_actions::HarnessActionHost::set_harness(
1382 &_cell,
1383 Arc::new(harness.clone()),
1384 );
1385 Arc::new(action_host.with_session_dir(ctx.session_dir()))
1386 }
1387 };
1388
1389 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1390 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1391 ctx.runtime.clone(),
1392 host,
1393 reload_cb,
1394 ctx.ui_dialog.clone(),
1395 ctx.ext_status.clone(),
1396 ctx.editor_text.clone(),
1397 );
1398
1399 let extension_session = if effective_args.no_extensions {
1400 rpi_extensions::ExtensionSession::none()
1401 } else {
1402 load_extensions_from_dirs(
1403 &effective_args,
1404 &extension_dirs,
1405 Some(Arc::clone(&fresh_bridge)),
1406 )
1407 };
1408 if extension_session.is_empty() && !effective_args.no_extensions {
1409 }
1412 if effective_args.verbose {
1413 if let Some(s) = extension_session.summary() {
1414 eprintln!("reload: {s}");
1415 }
1416 report_deferred_renderers(&extension_session);
1417 }
1418
1419 let has_package_providers = |session: &ExtensionSession| {
1420 session.snapshot().is_some_and(|snapshot| {
1421 snapshot
1422 .providers()
1423 .iter()
1424 .any(|provider| snapshot.provider_manifest(&provider.provider_id).is_some())
1425 })
1426 };
1427 let packaged = has_package_providers(&extension_session)
1428 || has_package_providers(&ctx.extension_session.lock().unwrap());
1429 let refreshed = if packaged {
1430 let mut args = effective_args.clone();
1431 if let Ok(current) = harness.lane("main").get_model().await {
1432 let was_package = ctx
1433 .extension_session
1434 .lock()
1435 .unwrap()
1436 .snapshot()
1437 .is_some_and(|snapshot| snapshot.provider_manifest(¤t.provider).is_some());
1438 let has_replacement = extension_session.snapshot().is_some_and(|snapshot| {
1439 snapshot
1440 .providers()
1441 .iter()
1442 .any(|provider| provider.provider_id == current.provider)
1443 });
1444 if was_package && !has_replacement {
1445 return ReloadOutcome {
1446 summary: "Reload failed while resolving provider packages".into(),
1447 had_warnings: true,
1448 details: vec![format!(
1449 "active provider package '{}' is no longer loaded",
1450 current.provider
1451 )],
1452 had_errors: true,
1453 };
1454 }
1455 args.provider = Some(current.provider.clone());
1456 args.model = Some(current.id);
1457 if current.provider != ctx.resolved_model.provider {
1458 args.api_key = None;
1459 args.base_url = None;
1460 }
1461 }
1462 match crate::provider::resolve_with_extensions(
1463 &args,
1464 &ctx.cwd,
1465 ctx.project_trusted,
1466 &extension_session,
1467 ) {
1468 Ok(resolved) => Some(resolved),
1469 Err(error) => {
1470 return ReloadOutcome {
1471 summary: "Reload failed while resolving provider packages".into(),
1472 had_warnings: true,
1473 details: vec![error.to_string()],
1474 had_errors: true,
1475 }
1476 }
1477 }
1478 } else {
1479 None
1480 };
1481
1482 let old_session = {
1494 let mut session_guard = ctx.extension_session.lock().unwrap();
1495 let old_session = std::mem::replace(&mut *session_guard, extension_session.clone());
1496 if let Some(old_snap) = old_session.snapshot_arc() {
1497 old_snap.active_flag().store(false, Ordering::SeqCst);
1501 }
1502 old_session
1503 };
1504 if let Some(old_b) = old_bridge {
1505 old_b.invalidate();
1506 }
1507 old_session.begin_shutdown();
1509
1510 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1512
1513 let discovered = extension_session
1515 .snapshot_arc()
1516 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1517 .unwrap_or_default();
1518
1519 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1521 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1522
1523 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1524 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1525 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1526 if !effective_args.no_skills {
1527 let js_paths: &[PathBuf] = &[];
1531 let package_paths: &[PathBuf] = &[];
1532 let dirs = append_reload_resource_paths(
1533 skill_base_dirs,
1534 &effective_args.skill,
1535 &discovered.skill_paths,
1536 js_paths,
1537 package_paths,
1538 );
1539 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1540 skills = result.skills;
1541 skill_diags = result.diagnostics;
1542 resource_diags.extend(result.resource_diagnostics);
1543 }
1544
1545 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1546 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1547 if !effective_args.no_prompt_templates {
1548 let js_paths: &[PathBuf] = &[];
1549 let package_paths: &[PathBuf] = &[];
1550 let dirs = append_reload_resource_paths(
1551 prompt_base_dirs,
1552 &effective_args.prompt_template,
1553 &discovered.prompt_paths,
1554 js_paths,
1555 package_paths,
1556 );
1557 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1558 prompt_templates = result.prompt_templates;
1559 prompt_diags = result.diagnostics;
1560 resource_diags.extend(result.resource_diagnostics);
1561 }
1562
1563 let context_files = if effective_args.no_context_files || !project_trusted {
1564 Vec::new()
1565 } else {
1566 let agent_dir = crate::config::agent_dir().ok();
1567 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1568 load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await
1569 };
1570 let context_block = format_project_context(&context_files);
1571
1572 let mut details: Vec<String> = Vec::new();
1573 if let Some(resolved) = &refreshed {
1574 warnings |= !resolved.compat_warnings.is_empty();
1575 details.extend(
1576 resolved
1577 .compat_warnings
1578 .iter()
1579 .map(|warning| format!("warning: models.json: {warning}")),
1580 );
1581 }
1582 if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
1583 warnings = true;
1584 for d in &skill_diags {
1589 details.push(format!(
1590 "warning: skill {} ({}): {}",
1591 d.path,
1592 d.code.as_str(),
1593 d.message
1594 ));
1595 }
1596 for d in &prompt_diags {
1597 details.push(format!(
1598 "warning: prompt template {} ({}): {}",
1599 d.path,
1600 d.code.as_str(),
1601 d.message
1602 ));
1603 }
1604 for d in &resource_diags {
1606 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1607 details.push(format!(
1608 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1609 c.resource_type.as_str(),
1610 c.name,
1611 c.winner_path,
1612 c.loser_path
1613 ));
1614 }
1615 }
1616 }
1617
1618 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1627 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1628 let mut tools = build_tools(&tool_ctx, &effective_args);
1629 merge_extension_tools(
1630 &mut tools,
1631 &extension_session,
1632 &effective_args,
1633 &ctx.tool_context,
1634 );
1635 let mut active = active_tool_names(&tools, &effective_args);
1636 active = filter_active_tool_names(active, &effective_args);
1637 let prompt_tools = active.clone();
1638 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1639 let mut context_paths = Vec::new();
1640 let base_prompt = match effective_args.system_prompt.as_deref() {
1641 Some(explicit) => explicit.to_string(),
1642 None if project_trusted => match discover_system_prompt_file(&ctx.cwd) {
1643 Some(path) => read_prompt_source(&path, &mut context_paths).unwrap_or_else(|| {
1644 default_system_prompt_with_tools(&cwd_str, prior_reasoning_replayed, &prompt_tools)
1645 }),
1646 None => {
1647 default_system_prompt_with_tools(&cwd_str, prior_reasoning_replayed, &prompt_tools)
1648 }
1649 },
1650 None => default_system_prompt_with_tools(&cwd_str, prior_reasoning_replayed, &prompt_tools),
1651 };
1652 let mut append_texts: Vec<String> = Vec::new();
1653 for extra in &effective_args.append_system_prompt {
1654 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
1655 append_texts.push(text);
1656 }
1657 if effective_args.append_system_prompt.is_empty() {
1658 if let Some(path) = project_trusted
1659 .then(|| discover_append_system_prompt_file(&ctx.cwd))
1660 .flatten()
1661 {
1662 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
1663 append_texts.push(text);
1664 }
1665 }
1666 }
1667 context_paths.extend(
1668 context_files
1669 .iter()
1670 .map(|file| file.path.to_string_lossy().into_owned()),
1671 );
1672 let append_join = if append_texts.is_empty() {
1673 None
1674 } else {
1675 Some(append_texts.join("\n\n"))
1676 };
1677 let system_prompt = compose_system_prompt(
1678 Some(&base_prompt),
1679 &[],
1680 if context_block.is_empty() {
1681 None
1682 } else {
1683 Some(&context_block)
1684 },
1685 append_join.as_deref(),
1686 );
1687
1688 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1690 Some(snapshot) => {
1691 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1692 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1693 }
1694 None => ctx.broadcast.clone(),
1695 };
1696
1697 let resources = AgentHarnessResources {
1699 context_files: context_paths,
1700 skills: if skills.is_empty() {
1701 None
1702 } else {
1703 Some(skills.clone())
1704 },
1705 prompt_templates: if prompt_templates.is_empty() {
1706 None
1707 } else {
1708 Some(prompt_templates.clone())
1709 },
1710 };
1711 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1712 let _ = harness.set_resources(resources).await;
1713 let _ = harness.set_agent_emitter(Some(emitter)).await;
1714 let _ = harness
1715 .set_models(build_models_with_extensions_for_reload(
1716 &refreshed
1717 .as_ref()
1718 .map(|resolved| resolved.providers.clone())
1719 .unwrap_or_else(|| {
1720 ctx.providers
1721 .read()
1722 .unwrap_or_else(|error| error.into_inner())
1723 .clone()
1724 }),
1725 &extension_session,
1726 ctx.runtime.clone(),
1727 ))
1728 .await;
1729 let _ = harness
1730 .set_provider_hooks(
1731 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1732 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1733 )
1734 .await;
1735
1736 if let Some(resolved) = &refreshed {
1737 *ctx.providers
1738 .write()
1739 .unwrap_or_else(|error| error.into_inner()) = resolved.providers.clone();
1740 let catalog = crate::provider::authenticated_catalog(resolved);
1741 fresh_bridge
1742 .clone_host()
1743 .replace_model_catalog(catalog.clone());
1744 *ctx.catalog
1745 .write()
1746 .unwrap_or_else(|error| error.into_inner()) = catalog;
1747 let _ = harness.lane("main").set_model(resolved.model.clone()).await;
1750 }
1751
1752 let _ = harness.set_tools(tools, Some(active)).await;
1753
1754 let summary = format!(
1755 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1756 extension_session.loaded_paths().len(),
1757 skills.len(),
1758 prompt_templates.len(),
1759 );
1760 ReloadOutcome {
1761 summary,
1762 had_warnings: warnings,
1763 details,
1764 had_errors: false,
1767 }
1768}
1769
1770#[derive(Clone, Debug, PartialEq, Eq)]
1771struct ReloadSettingsFields {
1772 skill_dirs: Option<Vec<String>>,
1773 prompt_dirs: Option<Vec<String>>,
1774 extension_dirs: Option<Vec<String>>,
1775}
1776
1777impl From<crate::settings::Settings> for ReloadSettingsFields {
1778 fn from(settings: crate::settings::Settings) -> Self {
1779 Self {
1780 skill_dirs: settings.skill_dirs,
1781 prompt_dirs: settings.prompt_dirs,
1782 extension_dirs: settings.extension_dirs,
1783 }
1784 }
1785}
1786
1787#[derive(Clone, Debug, PartialEq, Eq)]
1788struct ReloadSettingsSnapshot {
1789 global: ReloadSettingsFields,
1790 project: Option<ReloadSettingsFields>,
1791}
1792
1793fn reload_reads_settings(args: &Args) -> bool {
1794 !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
1795}
1796
1797fn load_reload_settings_snapshot(
1801 args: &Args,
1802 cwd: &Path,
1803 project_trusted: bool,
1804) -> Result<Option<ReloadSettingsSnapshot>, String> {
1805 if !reload_reads_settings(args) {
1806 return Ok(None);
1807 }
1808 let global = crate::settings::load_settings()
1809 .map_err(|error| format!("could not load global settings: {error}"))?
1810 .into();
1811 let project = if project_trusted {
1812 crate::settings::load_active_project_settings(cwd)
1813 .map_err(|error| format!("could not load project settings: {error}"))?
1814 .map(|(_, settings)| settings.into())
1815 } else {
1816 None
1817 };
1818 Ok(Some(ReloadSettingsSnapshot { global, project }))
1819}
1820
1821#[cfg(test)]
1824fn validate_settings_for_reload(
1825 args: &Args,
1826 cwd: &Path,
1827 project_trusted: bool,
1828) -> Result<(), String> {
1829 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1830}
1831
1832fn prepare_reload_inputs(
1833 args: &Args,
1834 cwd: &Path,
1835 project_trusted: bool,
1836) -> Result<PreparedReloadInputs, String> {
1837 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1838}
1839
1840fn prepare_reload_inputs_inner<F>(
1841 args: &Args,
1842 cwd: &Path,
1843 project_trusted: bool,
1844 before_verify: F,
1845) -> Result<PreparedReloadInputs, String>
1846where
1847 F: FnOnce(),
1848{
1849 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1850
1851 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1852 Vec::new()
1853 } else if args.project_only {
1854 project_extension_dirs(cwd)
1855 } else if project_trusted {
1856 extension_dirs(cwd)
1857 } else {
1858 global_extension_dirs()
1859 };
1860 if !args.no_extensions {
1861 extension_dirs.extend(args.extensions_dir.iter().cloned());
1862 }
1863
1864 let skill_base_dirs = if args.no_skills {
1865 Vec::new()
1866 } else if args.dev_local_only || args.project_only {
1867 project_skill_dirs(cwd)
1868 } else if project_trusted {
1869 skill_dirs(cwd)
1870 } else {
1871 global_skill_dirs()
1872 };
1873
1874 let prompt_base_dirs = if args.no_prompt_templates {
1875 Vec::new()
1876 } else if args.dev_local_only || args.project_only {
1877 project_prompt_template_dirs(cwd)
1878 } else if project_trusted {
1879 prompt_template_dirs(cwd)
1880 } else {
1881 global_prompt_template_dirs()
1882 };
1883
1884 before_verify();
1885 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1886 if settings_before != settings_after {
1887 return Err(
1888 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1889 );
1890 }
1891
1892 Ok(PreparedReloadInputs {
1893 extension_dirs,
1894 skill_base_dirs,
1895 prompt_base_dirs,
1896 })
1897}
1898
1899fn build_models_with_extensions_for_reload(
1904 providers: &[Arc<dyn Provider>],
1905 extension_session: &ExtensionSession,
1906 runtime: tokio::runtime::Handle,
1907) -> Vec<Arc<dyn Provider>> {
1908 let mut models: Vec<Arc<dyn Provider>> = providers.to_vec();
1909 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1910 for provider in pluggable {
1911 if extension_session
1915 .snapshot()
1916 .is_some_and(|snapshot| snapshot.provider_manifest(provider.id()).is_some())
1917 {
1918 continue;
1919 }
1920 if !models.iter().any(|known| known.id() == provider.id()) {
1921 models.push(provider);
1922 }
1923 }
1924 models
1925}
1926
1927fn report_deferred_renderers(session: &ExtensionSession) {
1931 let Some(snap) = session.snapshot_arc() else {
1932 return;
1933 };
1934 let all = snap.renderers();
1935 let markdown = all
1936 .iter()
1937 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1938 .count();
1939 let message = all
1940 .iter()
1941 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1942 .count();
1943 let entry = all
1944 .iter()
1945 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1946 .count();
1947 if markdown + message + entry == 0 {
1948 return;
1949 }
1950 eprintln!(
1951 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1952 markdown, message, entry
1953 );
1954}
1955
1956#[derive(Debug, thiserror::Error)]
1958pub enum BuildError {
1959 #[error("Could not create the session directory: {0}")]
1960 SessionDir(String),
1961 #[error("Could not open the saved session: {0}")]
1962 SessionOpen(String),
1963 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1964 SessionNotFound { requested: String, dir: String },
1965 #[error("Could not build the harness: {0}")]
1966 HarnessCreate(String),
1967}
1968
1969fn build_models_with_extensions(
1972 resolved: &ResolvedModel,
1973 extension_session: &ExtensionSession,
1974 runtime: tokio::runtime::Handle,
1975) -> Vec<Arc<dyn Provider>> {
1976 build_models_with_extensions_for_reload(&resolved.providers, extension_session, runtime)
1977}
1978
1979pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1982 if let Some(override_value) = args.trust_override {
1983 return override_value;
1984 }
1985 crate::config::project_trust_decision(cwd)
1986 .ok()
1987 .flatten()
1988 .unwrap_or(true)
1989}
1990
1991pub(crate) fn load_extensions(
1999 args: &Args,
2000 cwd: &Path,
2001 project_trusted: bool,
2002 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
2003) -> ExtensionSession {
2004 if args.no_extensions {
2005 return ExtensionSession::none();
2006 }
2007 let mut dirs = if args.dev_local_only {
2008 Vec::new()
2009 } else if args.project_only {
2010 project_extension_dirs(cwd)
2011 } else if project_trusted {
2012 extension_dirs(cwd)
2013 } else {
2014 global_extension_dirs()
2015 };
2016 dirs.extend(args.extensions_dir.iter().cloned());
2017 load_extensions_from_dirs(args, &dirs, action_bridge)
2018}
2019
2020fn load_extensions_from_dirs(
2021 args: &Args,
2022 dirs: &[PathBuf],
2023 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
2024) -> ExtensionSession {
2025 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
2026 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
2033}
2034
2035fn merge_extension_tools(
2041 tools: &mut Vec<HarnessTool>,
2042 session: &ExtensionSession,
2043 args: &Args,
2044 tool_context: &rpi_extensions::ToolCallContext,
2045) {
2046 let Some(snapshot) = session.snapshot() else {
2047 return;
2048 };
2049 for et in snapshot.tools() {
2050 let name = &et.tool.name;
2051 if !tool_name_allowed(name, args) {
2052 continue;
2053 }
2054 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
2055 .with_context(tool_context.clone());
2056 let harness_tool = HarnessTool::new(Arc::new(adapter));
2057 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
2058 Some(slot) => *slot = harness_tool,
2059 None => tools.push(harness_tool),
2060 }
2061 }
2062}
2063
2064pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
2071 use rpi_tools::tools::bash::BashToolOptions;
2072 let default = std::env::var("RPI_BASH_TIMEOUT")
2073 .ok()
2074 .and_then(|v| v.parse::<f64>().ok())
2075 .unwrap_or(120.0);
2076 let settings = crate::settings::load_settings().unwrap_or_default();
2082 BashToolOptions {
2083 command_prefix: settings.shell_command_prefix.clone(),
2084 default_timeout: Some(default),
2085 prepare: None,
2086 }
2087}
2088
2089fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
2090 if args.no_tools {
2091 return Vec::new();
2092 }
2093 let mut all: Vec<(&'static str, HarnessTool)> = vec![
2102 (
2103 "read",
2104 HarnessTool::new(create_read_tool(
2105 ctx,
2106 Some(rpi_tools::tools::read::ReadToolOptions {
2107 image_processor: Some(Arc::new(
2108 rpi_tools::image_processing::InlineImageProcessor,
2109 )),
2110 ..Default::default()
2111 }),
2112 ))
2113 .with_replay(ToolReplay::Safe),
2114 ),
2115 (
2116 "bash",
2117 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
2118 ),
2119 ("edit", HarnessTool::new(create_edit_tool(ctx))),
2120 ("write", HarnessTool::new(create_write_tool(ctx))),
2121 (
2122 "docs",
2123 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
2124 ),
2125 ];
2126
2127 #[cfg(target_os = "windows")]
2129 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
2130
2131 if args.tools.is_none() {
2133 if let Ok(settings) = crate::settings::load_settings() {
2134 if let Some(default_tools) = &settings.default_tools {
2135 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
2136 }
2137 }
2138 }
2139
2140 if args.no_builtin_tools {
2144 all.clear();
2145 }
2146
2147 if let Some(allow) = &args.tools {
2149 all.retain(|(name, _)| allow.iter().any(|a| a == name));
2150 }
2151 if let Some(deny) = &args.exclude_tools {
2153 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
2154 }
2155
2156 all.into_iter().map(|(_, t)| t).collect()
2157}
2158
2159fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
2163 filter_active_tool_names(
2164 tools.iter().map(|tool| tool.tool.schema().name.clone()),
2165 args,
2166 )
2167}
2168
2169pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
2172 if args.no_tools {
2173 return false;
2174 }
2175 if args
2176 .tools
2177 .as_ref()
2178 .is_some_and(|allow| !allow.iter().any(|value| value == name))
2179 {
2180 return false;
2181 }
2182 if args
2183 .exclude_tools
2184 .as_ref()
2185 .is_some_and(|deny| deny.iter().any(|value| value == name))
2186 {
2187 return false;
2188 }
2189 true
2190}
2191
2192pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
2193where
2194 I: IntoIterator<Item = String>,
2195{
2196 names
2197 .into_iter()
2198 .filter(|name| tool_name_allowed(name, args))
2199 .collect()
2200}
2201
2202async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2204 match selection {
2205 SessionSelection::Ephemeral => Ok(ephemeral_session()),
2206 SessionSelection::New { dir, .. } => {
2207 std::fs::create_dir_all(dir)
2210 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2211 let session = create_jsonl_session(dir, cwd)
2212 .await
2213 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2214 Ok(session)
2215 }
2216 SessionSelection::Latest { .. }
2217 | SessionSelection::ById { .. }
2218 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2219 SessionSelection::Fork { source, dir } => fork_session_at_launch(source, dir, cwd).await,
2220 }
2221}
2222
2223async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2229 match selection {
2233 SessionSelection::Latest { dir } => {
2234 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2235 let Some(meta) = first_resumable_session(&metas) else {
2236 return Err(BuildError::SessionNotFound {
2237 requested: "the most recent session".to_string(),
2238 dir: dir.display().to_string(),
2239 });
2240 };
2241 open_session(meta, cwd).await
2242 }
2243 SessionSelection::ById { id, dir } => open_session_by_id_in_dir(id, cwd, dir)
2244 .await
2245 .map_err(|e| match e {
2246 OpenError::NotFound { requested } => BuildError::SessionNotFound {
2247 requested,
2248 dir: dir.display().to_string(),
2249 },
2250 OpenError::Other(msg) => BuildError::SessionOpen(msg),
2251 }),
2252 SessionSelection::ByExactId { id, dir } => {
2253 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2256 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
2257 return open_session(meta, cwd).await;
2258 }
2259 std::fs::create_dir_all(dir)
2260 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2261 create_jsonl_session_with_id(dir, cwd, Some(id.clone()))
2262 .await
2263 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2264 }
2265 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
2266 }
2267}
2268
2269async fn fork_session_at_launch(
2272 source: &str,
2273 dir: &Path,
2274 cwd: &str,
2275) -> Result<Session, BuildError> {
2276 use rpi_harness::session::jsonl::{
2277 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2278 };
2279 use rpi_harness::session::types::{ForkOptions, SessionStorage};
2280 use rpi_tools::FileSystem;
2281
2282 let dir = dir.to_path_buf();
2283 std::fs::create_dir_all(&dir)
2284 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2285 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2286 let fs: Arc<dyn FileSystem> = env.clone();
2287 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2288 fs: fs.clone(),
2289 sessions_root: dir.to_string_lossy().into_owned(),
2290 clock: Arc::new(SystemClock),
2291 ids: Arc::new(DefaultIdGenerator::new()),
2292 });
2293 let source_meta = match session_metadata_at(Path::new(source)) {
2297 Some(meta) => meta,
2298 None => {
2299 let metas = repo
2300 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2301 .await
2302 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2303 metas
2304 .into_iter()
2305 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2306 .ok_or_else(|| BuildError::SessionNotFound {
2307 requested: format!("--fork {source}"),
2308 dir: dir.display().to_string(),
2309 })?
2310 }
2311 };
2312 let fork_storage = repo
2313 .fork_typed(
2314 &source_meta,
2315 &JsonlSessionCreateOptions {
2316 id: None,
2317 parent_session_id: Some(source_meta.id.clone()),
2318 cwd: cwd.to_string(),
2319 metadata: None,
2320 },
2321 &ForkOptions::default(),
2322 )
2323 .await
2324 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2325 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
2326 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2327 Ok(Session::new(storage_arc, None))
2328}
2329
2330fn session_metadata_at(path: &Path) -> Option<rpi_harness::session::jsonl::JsonlSessionMetadata> {
2333 use rpi_harness::session::jsonl::codec::{metadata_from_header, parse_header};
2334 use std::io::BufRead;
2335
2336 let file = std::fs::File::open(path).ok()?;
2337 let modified_at = file
2338 .metadata()
2339 .ok()?
2340 .modified()
2341 .ok()?
2342 .duration_since(std::time::UNIX_EPOCH)
2343 .ok()?
2344 .as_millis() as i64;
2345 let mut first_line = String::new();
2346 std::io::BufReader::new(file)
2347 .read_line(&mut first_line)
2348 .ok()?;
2349 let header = parse_header(first_line.trim_end()).ok()?;
2350 let path = std::fs::canonicalize(path).ok()?;
2351 Some(metadata_from_header(
2352 &header,
2353 &path.to_string_lossy(),
2354 modified_at,
2355 ))
2356}
2357
2358pub enum OpenError {
2361 NotFound { requested: String },
2363 Other(String),
2365}
2366
2367impl std::fmt::Display for OpenError {
2368 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2369 match self {
2370 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2371 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
2376 }
2377 }
2378}
2379
2380pub async fn list_session_metadata(
2383 cwd: &str,
2384) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2385 let dir = default_session_dir(Path::new(cwd));
2386 list_session_metadata_in_dir(cwd, &dir).await
2387}
2388
2389pub(crate) async fn list_session_metadata_in_dir(
2390 cwd: &str,
2391 dir: &Path,
2392) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2393 use rpi_harness::session::jsonl::{
2394 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2395 };
2396 use rpi_tools::FileSystem;
2397
2398 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2399 let fs: Arc<dyn FileSystem> = env.clone();
2400 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2401 fs: fs.clone(),
2402 sessions_root: dir.to_string_lossy().into_owned(),
2403 clock: Arc::new(SystemClock),
2404 ids: Arc::new(DefaultIdGenerator::new()),
2405 });
2406 repo.list_typed(&JsonlSessionListOptions::default())
2407 .await
2408 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2409}
2410
2411#[derive(Debug, Clone, Default, PartialEq, Eq)]
2427pub struct SessionSummary {
2428 pub name: Option<String>,
2430 pub preview: Option<String>,
2432 pub empty: bool,
2434 pub bytes: u64,
2436}
2437
2438const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
2440const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
2442const SUMMARY_PREVIEW_CHARS: usize = 80;
2444
2445pub fn summarize_session_file(path: &Path) -> SessionSummary {
2449 use std::io::{Read, Seek, SeekFrom};
2450
2451 let Ok(mut file) = std::fs::File::open(path) else {
2452 return SessionSummary::default();
2453 };
2454 let Ok(size) = file.metadata().map(|m| m.len()) else {
2455 return SessionSummary::default();
2456 };
2457 if size == 0 {
2458 return SessionSummary {
2459 name: None,
2460 preview: None,
2461 empty: true,
2462 bytes: 0,
2463 };
2464 }
2465
2466 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2469 let head_len = file.read(&mut head).unwrap_or(0);
2470 head.truncate(head_len);
2471 let head = String::from_utf8_lossy(&head);
2472 let mut lines: Vec<&str> = head.lines().collect();
2473 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2474 let empty = (size as usize) <= header_len + 1;
2476 if (head_len as u64) < size && !lines.is_empty() {
2479 lines.pop();
2480 }
2481
2482 let mut summary = SessionSummary {
2483 name: None,
2484 preview: None,
2485 empty,
2486 bytes: size,
2487 };
2488 for line in lines {
2489 apply_summary_line(line, &mut summary);
2490 }
2491
2492 if size > SUMMARY_HEAD_BYTES
2496 && file
2497 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2498 .is_ok()
2499 {
2500 let mut tail = Vec::new();
2501 if file.read_to_end(&mut tail).is_ok() {
2502 let tail = String::from_utf8_lossy(&tail);
2503 let mut tail_lines = tail.lines();
2504 tail_lines.next();
2506 for line in tail_lines {
2507 if let Some(name) = name_from_line(line) {
2508 summary.name = name;
2509 }
2510 }
2511 }
2512 }
2513 summary
2514}
2515
2516fn name_from_line(line: &str) -> Option<Option<String>> {
2519 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2520 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2521 return None;
2522 }
2523 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2524 return None;
2525 }
2526 Some(match value.get("name") {
2527 Some(serde_json::Value::String(name)) => Some(name.clone()),
2528 _ => None,
2529 })
2530}
2531
2532fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2535 if let Some(name) = name_from_line(line) {
2536 summary.name = name;
2537 return;
2538 }
2539 if summary.preview.is_some() {
2540 return;
2541 }
2542 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2543 return;
2544 };
2545 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2546 return;
2547 }
2548 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2549 return;
2550 }
2551 let Some(message) = value.get("message") else {
2552 return;
2553 };
2554 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2555 return;
2556 }
2557 let text = match message.get("content") {
2558 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2559 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2560 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2561 .then(|| block.get("text").and_then(|v| v.as_str()))
2562 .flatten()
2563 }),
2564 _ => None,
2565 };
2566 let Some(text) = text else { return };
2567 let collapsed = text
2568 .lines()
2569 .map(str::trim)
2570 .filter(|line| !line.is_empty())
2571 .collect::<Vec<_>>()
2572 .join(" ");
2573 if collapsed.is_empty() {
2574 return;
2575 }
2576 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2577}
2578
2579pub fn session_display_label(summary: &SessionSummary) -> String {
2583 if let Some(name) = summary.name.as_deref().map(str::trim) {
2584 if !name.is_empty() {
2585 return name.to_string();
2586 }
2587 }
2588 match summary.preview.as_deref().map(str::trim) {
2589 Some(preview) if !preview.is_empty() => preview.to_string(),
2590 _ => "(no messages)".to_string(),
2591 }
2592}
2593
2594pub fn format_session_bytes(bytes: u64) -> String {
2597 if bytes >= 1_048_576 {
2598 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2599 } else if bytes >= 1024 {
2600 format!("{}KB", bytes / 1024)
2601 } else {
2602 format!("{bytes}B")
2603 }
2604}
2605
2606pub fn short_session_id(id: &str) -> &str {
2609 &id[..id.len().min(8)]
2610}
2611
2612pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2621 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2622 let mut visit = |path: PathBuf| {
2623 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2624 return;
2625 }
2626 let modified = std::fs::metadata(&path)
2627 .and_then(|m| m.modified())
2628 .unwrap_or(std::time::UNIX_EPOCH);
2629 files.push((path, modified));
2630 };
2631 if let Ok(entries) = std::fs::read_dir(dir) {
2632 for entry in entries.flatten() {
2633 let path = entry.path();
2634 if path.is_dir() {
2635 if let Ok(inner) = std::fs::read_dir(&path) {
2636 for child in inner.flatten() {
2637 visit(child.path());
2638 }
2639 }
2640 } else {
2641 visit(path);
2642 }
2643 }
2644 }
2645 files.sort_by(|a, b| b.1.cmp(&a.1));
2646 files.into_iter().map(|(path, _)| path).collect()
2647}
2648
2649fn first_resumable_session(
2654 metas: &[rpi_harness::session::jsonl::JsonlSessionMetadata],
2655) -> Option<&rpi_harness::session::jsonl::JsonlSessionMetadata> {
2656 metas
2657 .iter()
2658 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2659 .or_else(|| metas.first())
2660}
2661
2662pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2666 let dir = default_session_dir(Path::new(cwd));
2667 open_session_by_id_in_dir(id, cwd, &dir).await
2668}
2669
2670pub(crate) async fn open_session_by_id_in_dir(
2671 id: &str,
2672 cwd: &str,
2673 dir: &Path,
2674) -> Result<Session, OpenError> {
2675 let metas = list_session_metadata_in_dir(cwd, dir)
2676 .await
2677 .map_err(|e| OpenError::Other(e.to_string()))?;
2678 let Some(meta) = metas
2679 .iter()
2680 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2681 else {
2682 return Err(OpenError::NotFound {
2683 requested: format!("session {id}"),
2684 });
2685 };
2686 open_session(meta, cwd).await.map_err(|e| {
2687 OpenError::Other(match e {
2688 BuildError::SessionOpen(reason) => reason,
2692 other => other.to_string(),
2693 })
2694 })
2695}
2696
2697fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
2722 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
2723 tool_context.set_session_id(id);
2724}
2725
2726pub(crate) async fn set_active_session(
2737 harness: &AgentHarness,
2738 session: Session,
2739 tool_context: Option<&rpi_extensions::ToolCallContext>,
2740) -> Result<(), String> {
2741 let metadata = session.get_metadata().await.ok();
2742 harness
2743 .set_session(session)
2744 .await
2745 .map_err(|e| e.to_string())?;
2746 if let Some(metadata) = metadata {
2747 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2748 if let Some(context) = tool_context {
2749 context.set_session_id(metadata.id);
2750 }
2751 }
2752 Ok(())
2753}
2754
2755pub(crate) async fn fork_session_storage(
2762 harness: &AgentHarness,
2763 cwd: &str,
2764 dir: &Path,
2765) -> Result<Session, String> {
2766 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2767 use rpi_tools::FileSystem;
2768
2769 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2770 let fs: Arc<dyn FileSystem> = env.clone();
2771 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2772 fs,
2773 sessions_root: dir.to_string_lossy().into_owned(),
2774 clock: Arc::new(SystemClock),
2775 ids: Arc::new(DefaultIdGenerator::new()),
2776 });
2777 let id = harness.session().storage().metadata().id.clone();
2780 let metas = list_session_metadata_in_dir(cwd, dir)
2781 .await
2782 .map_err(|e| e.to_string())?;
2783 let Some(source) = metas.iter().find(|m| m.id == id) else {
2784 return Err(format!("current session {id} not found on disk"));
2785 };
2786 let fork_storage = repo
2787 .fork_typed(
2788 source,
2789 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2790 id: None,
2791 parent_session_id: Some(source.id.clone()),
2792 cwd: cwd.to_string(),
2793 metadata: None,
2794 },
2795 &rpi_harness::session::types::ForkOptions::default(),
2796 )
2797 .await
2798 .map_err(|e| e.to_string())?;
2799 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2800 Ok(Session::new(Arc::new(fork_storage), None))
2801}
2802
2803pub(crate) async fn import_session_file(
2807 source: &Path,
2808 cwd: &str,
2809 dir: &Path,
2810) -> Result<Session, String> {
2811 use rpi_harness::session::jsonl::{
2812 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions, JsonlSessionStorage,
2813 };
2814 use rpi_tools::FileSystem;
2815
2816 let dir = if dir.is_absolute() {
2817 dir.to_path_buf()
2818 } else {
2819 Path::new(cwd).join(dir)
2820 };
2821 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
2822 let staged = tempfile::NamedTempFile::new_in(&dir)
2823 .map_err(|e| e.to_string())?
2824 .into_temp_path();
2825 std::fs::copy(source, &staged).map_err(|e| format!("copy import: {e}"))?;
2826 let fs: Arc<dyn FileSystem> = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2827 let validated = JsonlSessionStorage::load(
2828 fs.clone(),
2829 &staged.to_string_lossy(),
2830 Arc::new(SystemClock),
2831 Arc::new(DefaultIdGenerator::new()),
2832 )
2833 .await
2834 .map_err(|e| format!("invalid session import: {e}"))?;
2835 let mut metadata = validated.jsonl_metadata().clone();
2836 drop(validated);
2837 if list_session_metadata_in_dir(cwd, &dir)
2838 .await
2839 .map_err(|e| e.to_string())?
2840 .iter()
2841 .any(|saved| saved.id == metadata.id)
2842 {
2843 return Err(format!(
2844 "session {} already exists in {}",
2845 metadata.id,
2846 dir.display()
2847 ));
2848 }
2849 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2850 fs,
2851 sessions_root: dir.to_string_lossy().into_owned(),
2852 clock: Arc::new(SystemClock),
2853 ids: Arc::new(DefaultIdGenerator::new()),
2854 });
2855 let destination = repo
2857 .create_typed(&JsonlSessionCreateOptions {
2858 id: Some(metadata.id.clone()),
2859 parent_session_id: metadata.parent_session_id.clone(),
2860 cwd: cwd.to_string(),
2861 metadata: metadata.metadata.clone(),
2862 })
2863 .await
2864 .map_err(|e| e.to_string())?;
2865 let path = destination.jsonl_metadata().path.clone();
2866 drop(destination);
2867 if let Err(error) = claim_session_lock(&path) {
2868 return Err(error);
2870 }
2871 if let Err(error) = staged.persist(&path) {
2872 let _ = std::fs::remove_file(&path);
2873 return Err(format!("publish import: {error}"));
2874 }
2875 metadata.path = path;
2876 let storage = repo
2877 .open_by_jsonl_metadata(&metadata)
2878 .await
2879 .map_err(|e| e.to_string())?;
2880 Ok(Session::new(Arc::new(storage), None))
2881}
2882
2883static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2885static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2888
2889fn claim_session_lock(session_path: &str) -> Result<(), String> {
2898 let lock_path = PathBuf::from(format!("{session_path}.lock"));
2899 if SESSION_LOCK_PATHS
2900 .get_or_init(|| Mutex::new(HashSet::new()))
2901 .lock()
2902 .unwrap()
2903 .contains(&lock_path)
2904 {
2905 return Ok(());
2906 }
2907 let file = std::fs::OpenOptions::new()
2908 .create(true)
2909 .write(true)
2910 .open(&lock_path)
2911 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2912 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2913 return Err(format!(
2914 "session {session_path} is already open in another rpi process"
2915 ));
2916 }
2917 SESSION_LOCKS
2918 .get_or_init(|| Mutex::new(Vec::new()))
2919 .lock()
2920 .unwrap()
2921 .push(file);
2922 SESSION_LOCK_PATHS
2923 .get_or_init(|| Mutex::new(HashSet::new()))
2924 .lock()
2925 .unwrap()
2926 .insert(lock_path);
2927 Ok(())
2928}
2929
2930async fn open_session(
2933 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2934 cwd: &str,
2935) -> Result<Session, BuildError> {
2936 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2937 use rpi_harness::session::types::SessionStorage;
2938 use rpi_tools::FileSystem;
2939
2940 let dir = default_session_dir(Path::new(cwd));
2941 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2942 let fs: Arc<dyn FileSystem> = env.clone();
2943 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2944 fs: fs.clone(),
2945 sessions_root: dir.to_string_lossy().into_owned(),
2946 clock: Arc::new(SystemClock),
2947 ids: Arc::new(DefaultIdGenerator::new()),
2948 });
2949 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2950 let storage = repo
2951 .open_by_jsonl_metadata(meta)
2952 .await
2953 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2954 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2958 Some(Path::new(&meta.path)),
2959 Path::new(&meta.cwd),
2960 Path::new(cwd),
2961 ) {
2962 eprintln!(
2963 "warning: {}",
2964 crate::session_cwd::format_missing_session_cwd_error(&issue)
2965 );
2966 }
2967 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2968 Ok(Session::new(storage_arc, None))
2969}
2970
2971pub(crate) fn ephemeral_session() -> Session {
2975 let storage = Arc::new(InMemorySessionStorage::new(
2976 SessionMetadata {
2977 id: "ephemeral".into(),
2978 created_at: 0,
2979 parent_session_id: None,
2980 },
2981 Arc::new(SystemClock),
2982 Arc::new(DefaultIdGenerator::new()),
2983 ));
2984 Session::new(storage, None).with_volatile_progress()
2985}
2986
2987pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2998 create_jsonl_session_with_id(dir, cwd, None).await
2999}
3000
3001pub(crate) async fn create_jsonl_session_with_id(
3005 dir: &Path,
3006 cwd: &str,
3007 id: Option<String>,
3008) -> Result<Session, String> {
3009 use rpi_harness::session::jsonl::{
3010 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
3011 };
3012 use rpi_tools::FileSystem;
3013
3014 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3017 let fs: Arc<dyn FileSystem> = env.clone();
3018
3019 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3020 fs: fs.clone(),
3021 sessions_root: dir.to_string_lossy().into_owned(),
3022 clock: Arc::new(SystemClock),
3023 ids: Arc::new(DefaultIdGenerator::new()),
3024 });
3025
3026 let opts = JsonlSessionCreateOptions {
3027 id, parent_session_id: None,
3029 cwd: cwd.to_string(),
3030 metadata: None,
3031 };
3032 let storage = repo
3033 .create_typed(&opts)
3034 .await
3035 .map_err(|e| format!("create session: {e}"))?;
3036 claim_session_lock(&storage.jsonl_metadata().path)
3037 .map_err(|e| format!("create session: {e}"))?;
3038 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
3040 Ok(Session::new(storage_arc, None))
3041}
3042
3043fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
3046 let path = Path::new(target);
3047 if path.is_file() {
3048 read_prompt_source(path, sources)
3049 } else {
3050 None
3051 }
3052}
3053
3054fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
3057 let text = std::fs::read_to_string(path).ok()?;
3058 let absolute = if path.is_absolute() {
3059 path.to_path_buf()
3060 } else {
3061 std::env::current_dir()
3062 .map(|cwd| cwd.join(path))
3063 .unwrap_or_else(|_| path.to_path_buf())
3064 };
3065 let source = absolute.to_string_lossy().into_owned();
3066 if !sources.contains(&source) {
3067 sources.push(source);
3068 }
3069 Some(text)
3070}
3071
3072#[cfg(test)]
3073mod runtime_session_tests;
3074
3075#[cfg(test)]
3076mod tests {
3077 use super::*;
3078
3079 #[tokio::test]
3080 async fn unauthenticated_packages_are_not_reintroduced_as_legacy_adapters() {
3081 extern "C" fn request(
3082 _: rpi_plugin_sdk::StbStringRef,
3083 _: *mut rpi_plugin_sdk::StbString,
3084 _: *mut std::ffi::c_void,
3085 ) -> i32 {
3086 1
3087 }
3088 let mut registry = rpi_extensions::ExtensionRegistry::new();
3089 for id in ["package", "legacy"] {
3090 registry.register_provider(rpi_extensions::RegisteredProvider {
3091 provider_id: id.into(),
3092 base_url: "http://localhost".into(),
3093 api_style: "openai-completions".into(),
3094 request_fn: request,
3095 stream_fn: None,
3096 plugin_free_string: rpi_plugin_sdk::callbacks::free_string,
3097 user_data: std::ptr::null_mut(),
3098 });
3099 }
3100 assert!(registry.apply_declaration_json(&serde_json::json!({"providers":[{
3101 "id":"package", "requiresAuth":true,
3102 "models":[rpi_ai::Model::new("model", "Model", rpi_ai::Api::OpenaiCompletions, "package", "http://localhost")]
3103 }]}).to_string()));
3104 let session = ExtensionSession::from_parts(
3105 Arc::new(registry.snapshot()),
3106 rpi_extensions::PluginKeepalive::empty(),
3107 vec![],
3108 None,
3109 );
3110 let providers = build_models_with_extensions_for_reload(
3111 &[],
3112 &session,
3113 tokio::runtime::Handle::current(),
3114 );
3115 assert_eq!(
3116 providers
3117 .iter()
3118 .map(|provider| provider.id())
3119 .collect::<Vec<_>>(),
3120 ["legacy"]
3121 );
3122 }
3123 use crate::args::Args;
3124
3125 #[test]
3126 fn loaded_context_paths_track_precedence_and_reload() {
3127 const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
3128 if std::env::var_os(CHILD).is_none() {
3129 let dir = tempfile::tempdir().unwrap();
3130 let output = std::process::Command::new(std::env::current_exe().unwrap())
3131 .args([
3132 "--exact",
3133 "session::tests::loaded_context_paths_track_precedence_and_reload",
3134 "--nocapture",
3135 ])
3136 .env(CHILD, "1")
3137 .env(crate::config::CONFIG_DIR_ENV, dir.path())
3138 .output()
3139 .unwrap();
3140 assert!(
3141 output.status.success(),
3142 "{}\n{}",
3143 String::from_utf8_lossy(&output.stdout),
3144 String::from_utf8_lossy(&output.stderr)
3145 );
3146 return;
3147 }
3148 tokio::runtime::Builder::new_current_thread()
3149 .enable_all()
3150 .build()
3151 .unwrap()
3152 .block_on(async {
3153 let agent = crate::config::agent_dir().unwrap();
3154 let cwd = agent.join("project");
3155 let project_config = cwd.join(".rpi");
3156 std::fs::create_dir_all(&project_config).unwrap();
3157 for (path, content) in [
3158 (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
3159 (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
3160 (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
3161 (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
3162 (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
3163 (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
3164 (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
3165 ] {
3166 std::fs::write(path, content).unwrap();
3167 }
3168 let resolved = crate::provider::resolve(
3169 Some("anthropic"),
3170 Some(crate::provider::DEFAULT_MODEL_ID),
3171 None,
3172 Some("test-key"),
3173 None,
3174 )
3175 .unwrap();
3176 let args = Args {
3177 no_session: true,
3178 no_extensions: true,
3179 no_skills: true,
3180 no_prompt_templates: true,
3181 trust_override: Some(true),
3182 ..Default::default()
3183 };
3184 let (harness, _events, mut context) =
3185 build(&resolved, &args, &cwd, true).await.unwrap();
3186 let resources = harness.get_resources().await.unwrap();
3187 let path = |p: PathBuf| p.to_string_lossy().into_owned();
3188 assert_eq!(
3189 &resources.context_files[..2],
3190 &[
3191 path(project_config.join("SYSTEM.md")),
3192 path(project_config.join("APPEND_SYSTEM.md")),
3193 ]
3194 );
3195 assert!(resources
3196 .context_files
3197 .contains(&path(agent.join("AGENTS.md"))));
3198 assert!(resources
3199 .context_files
3200 .contains(&path(cwd.join("AGENTS.md"))));
3201 assert!(!resources
3202 .context_files
3203 .contains(&path(cwd.join("CLAUDE.md"))));
3204 assert!(!resources
3205 .context_files
3206 .contains(&path(agent.join("SYSTEM.md"))));
3207 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3208 assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
3209 assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
3210
3211 std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
3214 std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
3215 std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
3216 assert!(
3217 !reload_extension_resources(&harness, &context)
3218 .await
3219 .had_errors
3220 );
3221 let resources = harness.get_resources().await.unwrap();
3222 assert_eq!(
3223 &resources.context_files[..2],
3224 &[
3225 path(agent.join("SYSTEM.md")),
3226 path(agent.join("APPEND_SYSTEM.md")),
3227 ]
3228 );
3229 assert!(resources
3230 .context_files
3231 .contains(&path(cwd.join("CLAUDE.md"))));
3232 assert!(!resources
3233 .context_files
3234 .contains(&path(project_config.join("SYSTEM.md"))));
3235
3236 let explicit = agent.join("custom-append.md");
3239 std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
3240 context.args.system_prompt = Some("EXPLICIT_BASE".into());
3241 context.args.append_system_prompt =
3242 vec![path(explicit.clone()), "literal append".into()];
3243 context.args.no_context_files = true;
3244 assert!(
3245 !reload_extension_resources(&harness, &context)
3246 .await
3247 .had_errors
3248 );
3249 assert_eq!(
3250 harness.get_resources().await.unwrap().context_files,
3251 vec![path(explicit)]
3252 );
3253
3254 context.args.system_prompt = None;
3256 context.args.append_system_prompt.clear();
3257 context.args.no_context_files = false;
3258 context.args.trust_override = Some(false);
3259 assert!(
3260 !reload_extension_resources(&harness, &context)
3261 .await
3262 .had_errors
3263 );
3264 assert!(harness
3265 .get_resources()
3266 .await
3267 .unwrap()
3268 .context_files
3269 .is_empty());
3270 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3271 assert!(
3272 !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
3273 );
3274 });
3275 }
3276
3277 #[test]
3278 fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
3279 let dir = tempfile::tempdir().unwrap();
3280 let file = dir.path().join("SYSTEM.md");
3281 let mut sources = Vec::new();
3282 assert!(read_prompt_source(&file, &mut sources).is_none());
3283 std::fs::write(&file, [0xff, 0xfe]).unwrap();
3284 assert!(read_prompt_source(&file, &mut sources).is_none());
3285 assert!(read_append_target("literal instructions", &mut sources).is_none());
3286 assert!(sources.is_empty());
3287 std::fs::write(&file, "loaded instructions").unwrap();
3288 assert_eq!(
3289 read_prompt_source(&file, &mut sources).as_deref(),
3290 Some("loaded instructions")
3291 );
3292 assert!(read_prompt_source(&file, &mut sources).is_some());
3293 assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
3294 }
3295
3296 extern "C" fn deny_test_tool_call(
3297 _event: rpi_plugin_sdk::StablePluginEvent,
3298 _data: *mut std::ffi::c_void,
3299 ) -> i32 {
3300 rpi_plugin_sdk::EVENT_HANDLER_ABORT
3301 }
3302
3303 async fn invoke_test_tool_hook(
3304 hook: &rpi_agent::BeforeToolCall,
3305 ) -> Option<rpi_agent::BeforeToolCallResult> {
3306 let message = rpi_ai::providers::faux::faux_assistant_message(
3307 "approval",
3308 rpi_ai::types::StopReason::ToolUse,
3309 );
3310 let rpi_ai::types::Content::ToolCall(call) =
3311 rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
3312 else {
3313 unreachable!()
3314 };
3315 hook(
3316 rpi_agent::BeforeToolCallContext {
3317 assistant_message: &message,
3318 tool_call: &call,
3319 args: &call.arguments,
3320 context: &rpi_agent::AgentContext::default(),
3321 },
3322 tokio_util::sync::CancellationToken::new(),
3323 )
3324 .await
3325 }
3326
3327 #[tokio::test]
3328 async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
3329 let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
3330 let hook =
3331 extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
3332 assert!(invoke_test_tool_hook(&hook).await.is_none());
3333 for name in ["first-gate", "reloaded-gate"] {
3334 let mut registry = rpi_extensions::ExtensionRegistry::new();
3335 registry.register_event_handler(
3336 name.into(),
3337 rpi_plugin_sdk::EventTag::ToolCall,
3338 deny_test_tool_call,
3339 std::ptr::null_mut(),
3340 );
3341 let fresh = ExtensionSession::from_parts(
3342 Arc::new(registry.snapshot()),
3343 rpi_extensions::PluginKeepalive::empty(),
3344 Vec::new(),
3345 None,
3346 );
3347 {
3348 let mut current = sessions.lock().unwrap();
3349 if let Some(snapshot) = current.snapshot() {
3350 snapshot.active_flag().store(false, Ordering::SeqCst);
3351 }
3352 *current = fresh;
3353 }
3354 let result = invoke_test_tool_hook(&hook).await.unwrap();
3355 assert!(result.block);
3356 assert!(result.reason.unwrap().contains(name));
3357 }
3358 *sessions.lock().unwrap() = ExtensionSession::none();
3359 assert!(invoke_test_tool_hook(&hook).await.is_none());
3360 }
3361
3362 fn scratch(name: &str) -> PathBuf {
3364 let nanos = std::time::SystemTime::now()
3365 .duration_since(std::time::UNIX_EPOCH)
3366 .unwrap()
3367 .as_nanos();
3368 let path = std::env::temp_dir().join(format!(
3369 "rpi-summary-{}-{nanos}-{name}.jsonl",
3370 std::process::id()
3371 ));
3372 path
3373 }
3374
3375 #[test]
3376 fn summary_marks_a_header_only_session_empty() {
3377 let path = scratch("empty");
3378 std::fs::write(
3379 &path,
3380 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3381 )
3382 .unwrap();
3383 let summary = summarize_session_file(&path);
3384 assert!(summary.empty, "a header-only file has nothing to resume");
3385 assert!(summary.preview.is_none());
3386 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
3387 std::fs::remove_file(&path).ok();
3388 }
3389
3390 #[test]
3391 fn summary_reads_the_first_user_prompt_and_name() {
3392 let path = scratch("content");
3393 let body = concat!(
3394 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3395 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
3396 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
3397 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
3398 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
3399 );
3400 std::fs::write(&path, body).unwrap();
3401 let summary = summarize_session_file(&path);
3402 assert!(!summary.empty);
3403 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
3404 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
3405 std::fs::remove_file(&path).ok();
3406 }
3407
3408 #[test]
3409 fn summary_extracts_a_block_style_user_prompt() {
3410 let path = scratch("blocks");
3411 let body = concat!(
3412 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3413 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
3414 );
3415 std::fs::write(&path, body).unwrap();
3416 let summary = summarize_session_file(&path);
3417 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
3418 assert_eq!(session_display_label(&summary), "line one line two");
3419 std::fs::remove_file(&path).ok();
3420 }
3421
3422 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
3423 rpi_harness::session::jsonl::JsonlSessionMetadata {
3424 id: id.to_string(),
3425 created_at: 0,
3426 cwd: ".".to_string(),
3427 path: path.to_string_lossy().into_owned(),
3428 modified_at: 0,
3429 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
3430 parent_session_id: None,
3431 legacy_parent_session_path: None,
3432 metadata: None,
3433 }
3434 }
3435
3436 #[test]
3437 fn continue_skips_newer_empty_sessions() {
3438 let empty = scratch("newest-empty");
3439 std::fs::write(
3440 &empty,
3441 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3442 )
3443 .unwrap();
3444 let content = scratch("older-content");
3445 std::fs::write(
3446 &content,
3447 concat!(
3448 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3449 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
3450 ),
3451 )
3452 .unwrap();
3453
3454 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
3455 let picked = first_resumable_session(&metas).expect("a resumable session");
3456 assert_eq!(picked.id, "old");
3457
3458 let all_empty = vec![meta_for(&empty, "empty")];
3461 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
3462
3463 std::fs::remove_file(&empty).ok();
3464 std::fs::remove_file(&content).ok();
3465 }
3466
3467 #[test]
3468 fn list_session_files_sync_walks_the_cwd_subdirectory() {
3469 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
3470 let nested = root.join("--proj--");
3471 std::fs::create_dir_all(&nested).unwrap();
3472 let file = nested.join("2026-01-01_a.jsonl");
3473 std::fs::write(&file, "{}\n").unwrap();
3474 let found = list_session_files_sync(&root);
3475 assert!(found.iter().any(|p| p == &file));
3476 std::fs::remove_dir_all(&root).ok();
3477 }
3478
3479 fn default_prompt_tools() -> Vec<String> {
3481 BUILTIN_TOOL_NAMES
3482 .iter()
3483 .copied()
3484 .chain(["todo"])
3485 .map(str::to_owned)
3486 .collect()
3487 }
3488
3489 #[test]
3490 fn default_prompt_lists_only_the_active_tools() {
3491 let tools = ["read", "grep", "find", "ls"].map(str::to_owned);
3492 let p = default_system_prompt_with_tools("/tmp/proj", true, &tools);
3493 assert!(p.contains("- read — Read file contents"));
3494 assert!(p.contains("- grep — Search file contents"));
3495 for absent in ["- edit", "- write", "- bash", "- docs", "todo", "Windows"] {
3496 assert!(
3497 !p.contains(absent),
3498 "{absent} must not appear for a read-only run: {p}"
3499 );
3500 }
3501 assert!(p.contains("Re-stating the same plan without acting on it is a bug"));
3502 let none = default_system_prompt_with_tools("/tmp/proj", false, &[]);
3503 assert!(none.contains("Available tools:\n(none)"));
3504 }
3505
3506 #[test]
3507 fn default_prompt_mentions_cwd_and_tools() {
3508 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
3509 assert!(p.contains("/tmp/proj"));
3510 assert!(p.contains("read"));
3511 assert!(p.contains("bash"));
3512 assert!(p.contains("edit"));
3513 assert!(p.contains("write"));
3514 assert!(p.contains("docs"));
3515 assert!(!p.contains("- grep"));
3516 assert!(!p.contains("- find"));
3517 assert!(!p.contains("- ls"));
3518 assert!(!p.contains("- powershell"));
3519 assert!(p.contains("Bash/POSIX syntax, also on Windows"));
3520 }
3521
3522 #[test]
3523 fn default_prompt_instructs_language_matching() {
3524 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
3527 assert!(
3528 p.contains("same language as the user's latest message"),
3529 "the prompt must tell the model to mirror the user's language: {p}"
3530 );
3531 }
3532
3533 #[test]
3534 fn default_prompt_keeps_working_state_in_the_visible_channel() {
3535 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
3543 let lower = p.to_lowercase();
3544 assert!(
3546 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
3547 "the prompt must explain that reasoning does not survive the turn: {p}"
3548 );
3549 assert!(
3551 lower.contains("visible") && lower.contains("todo tool"),
3552 "the prompt must point at visible text and the todo tool: {p}"
3553 );
3554 assert!(
3556 lower.contains("re-stating the same plan"),
3557 "the prompt must call out plan re-statement as a bug: {p}"
3558 );
3559 assert!(
3561 lower.contains("already shown earlier"),
3562 "the prompt must discourage re-fetching known content: {p}"
3563 );
3564 }
3565
3566 #[test]
3567 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
3568 let p = default_system_prompt_with_tools("/tmp/proj", true, &default_prompt_tools());
3575 let lower = p.to_lowercase();
3576 assert!(
3577 lower.contains("carried back"),
3578 "a replaying model must be told its reasoning survives: {p}"
3579 );
3580 assert!(
3581 !lower.contains("reasoning is not"),
3582 "the replayed variant must not claim reasoning is dropped: {p}"
3583 );
3584 assert!(
3585 lower.contains("do not re-derive"),
3586 "the replayed variant must forbid re-deriving the plan: {p}"
3587 );
3588 assert!(
3589 lower.contains("do not re-derive") && lower.contains("re-stating the same plan"),
3590 "the anti-repetition rule must survive the flip: {p}"
3591 );
3592 assert!(
3593 lower.contains("already shown earlier"),
3594 "the re-read rule must survive the flip: {p}"
3595 );
3596 }
3597
3598 #[test]
3599 fn default_prompt_states_each_rule_once() {
3600 for replayed in [false, true] {
3603 let p =
3604 default_system_prompt_with_tools("/tmp/proj", replayed, &default_prompt_tools());
3605 for phrase in ["the same plan", "todo tool", "Windows", "guessing"] {
3606 assert_eq!(p.matches(phrase).count(), 1, "{phrase:?} repeated: {p}");
3607 }
3608 }
3609 }
3610
3611 #[test]
3619 fn only_read_only_tools_are_replayable() {
3620 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3621 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3622 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3623 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3624 let tools = build_tools(&context, &Args::default());
3625
3626 let replay_of = |name: &str| {
3627 tools
3628 .iter()
3629 .find(|tool| tool.tool.schema().name == name)
3630 .map(|tool| tool.replay)
3631 };
3632
3633 for read_only in ["read", "docs"] {
3634 assert_eq!(
3635 replay_of(read_only),
3636 Some(ToolReplay::Safe),
3637 "{read_only} is side-effect free, so recovery may re-run it"
3638 );
3639 }
3640 for mutating in ["bash", "edit", "write", "powershell"] {
3641 if let Some(replay) = replay_of(mutating) {
3642 assert_eq!(
3643 replay,
3644 ToolReplay::Never,
3645 "{mutating} must never be replayed: re-running it would duplicate its effect"
3646 );
3647 }
3648 }
3649 }
3650
3651 #[test]
3652 fn default_tools_include_docs_lookup() {
3653 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3654 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3655 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3656 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3657 let names: Vec<String> = build_tools(&context, &Args::default())
3658 .iter()
3659 .map(|tool| tool.tool.schema().name.clone())
3660 .collect();
3661
3662 assert!(names.contains(&"docs".to_string()));
3667 for legacy in ["grep", "find", "ls"] {
3668 assert!(
3669 !names.contains(&legacy.to_string()),
3670 "legacy tool {legacy} should not be registered by default"
3671 );
3672 }
3673 }
3674
3675 #[test]
3676 fn reload_settings_preflight_respects_trust() {
3677 struct RestoreConfigDir(Option<std::ffi::OsString>);
3678
3679 impl Drop for RestoreConfigDir {
3680 fn drop(&mut self) {
3681 match self.0.take() {
3682 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3683 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3684 }
3685 }
3686 }
3687
3688 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3689 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3690 let tmp = tempfile::tempdir().unwrap();
3691 let agent = tmp.path().join("agent");
3692 let cwd = tmp.path().join("project");
3693 std::fs::create_dir_all(&agent).unwrap();
3694 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3695 std::fs::write(agent.join("settings.json"), "{}").unwrap();
3696 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
3697 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3698 let args = Args::default();
3699
3700 let trusted = validate_settings_for_reload(&args, &cwd, true);
3701 let untrusted = validate_settings_for_reload(&args, &cwd, false);
3702 assert!(trusted
3703 .unwrap_err()
3704 .contains("could not load project settings"));
3705 assert!(untrusted.is_ok());
3706
3707 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3708 assert!(validate_settings_for_reload(&args, &cwd, false)
3709 .unwrap_err()
3710 .contains("could not load global settings"));
3711
3712 let local_only = Args {
3713 dev_local_only: true,
3714 ..Args::default()
3715 };
3716 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
3717 }
3718
3719 #[tokio::test(flavor = "current_thread")]
3720 async fn reload_preserves_live_resources_when_settings_break() {
3721 struct RestoreConfigDir(Option<std::ffi::OsString>);
3722
3723 impl Drop for RestoreConfigDir {
3724 fn drop(&mut self) {
3725 match self.0.take() {
3726 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3727 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3728 }
3729 }
3730 }
3731
3732 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3733 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3734 let tmp = tempfile::tempdir().unwrap();
3735 let agent = tmp.path().join("agent");
3736 let cwd = tmp.path().join("project");
3737 let skill_dir = tmp.path().join("configured-skills");
3738 std::fs::create_dir_all(&agent).unwrap();
3739 std::fs::create_dir_all(&cwd).unwrap();
3740 std::fs::create_dir_all(&skill_dir).unwrap();
3741 std::fs::write(
3742 skill_dir.join("SKILL.md"),
3743 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
3744 )
3745 .unwrap();
3746 std::fs::write(
3747 agent.join("settings.json"),
3748 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3749 )
3750 .unwrap();
3751 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3752
3753 let resolved = crate::provider::resolve(
3754 Some("anthropic"),
3755 Some(crate::provider::DEFAULT_MODEL_ID),
3756 None,
3757 Some("test-key"),
3758 None,
3759 )
3760 .unwrap();
3761 let args = Args {
3762 trust_override: Some(false),
3763 no_session: true,
3764 no_extensions: true,
3765 no_prompt_templates: true,
3766 no_context_files: true,
3767 system_prompt: Some("stable system prompt".into()),
3768 ..Args::default()
3769 };
3770 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3771 let before_resources = harness.get_resources().await.unwrap();
3772 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
3773 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
3774 let before_prompt = harness.get_system_prompt().await.unwrap();
3775 let before_tools: Vec<String> = harness
3776 .get_tools()
3777 .await
3778 .unwrap()
3779 .iter()
3780 .map(|tool| tool.tool.schema().name.clone())
3781 .collect();
3782 let before_bridge = context
3783 .action_bridge
3784 .lock()
3785 .unwrap()
3786 .as_ref()
3787 .unwrap()
3788 .clone();
3789
3790 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3791 let outcome = reload_extension_resources(&harness, &context).await;
3792
3793 assert!(outcome.had_warnings);
3794 assert!(outcome.summary.contains("Settings reload failed"));
3795 assert!(outcome.summary.contains("could not load global settings"));
3796 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
3797 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
3798 let after_tools: Vec<String> = harness
3799 .get_tools()
3800 .await
3801 .unwrap()
3802 .iter()
3803 .map(|tool| tool.tool.schema().name.clone())
3804 .collect();
3805 assert_eq!(after_tools, before_tools);
3806 let after_bridge = context
3807 .action_bridge
3808 .lock()
3809 .unwrap()
3810 .as_ref()
3811 .unwrap()
3812 .clone();
3813 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
3814 }
3815
3816 #[test]
3817 fn reload_preparation_rejects_settings_changed_during_derivation() {
3818 struct RestoreConfigDir(Option<std::ffi::OsString>);
3819
3820 impl Drop for RestoreConfigDir {
3821 fn drop(&mut self) {
3822 match self.0.take() {
3823 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3824 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3825 }
3826 }
3827 }
3828
3829 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3830 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3831 let tmp = tempfile::tempdir().unwrap();
3832 let agent = tmp.path().join("agent");
3833 let cwd = tmp.path().join("project");
3834 let first_skill_dir = tmp.path().join("first-skills");
3835 let second_skill_dir = tmp.path().join("second-skills");
3836 std::fs::create_dir_all(&agent).unwrap();
3837 std::fs::create_dir_all(&cwd).unwrap();
3838 std::fs::write(
3839 agent.join("settings.json"),
3840 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3841 )
3842 .unwrap();
3843 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3844 let args = Args {
3845 trust_override: Some(false),
3846 no_extensions: true,
3847 no_prompt_templates: true,
3848 ..Args::default()
3849 };
3850
3851 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3852 std::fs::write(
3853 agent.join("settings.json"),
3854 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3855 )
3856 .unwrap();
3857 });
3858
3859 let error = result
3860 .err()
3861 .expect("settings mutation must fail preparation");
3862 assert!(error.contains("settings changed while reload inputs were being prepared"));
3863 }
3864
3865 #[tokio::test(flavor = "current_thread")]
3866 async fn reload_uses_frozen_settings_inputs_after_preparation() {
3867 struct RestoreConfigDir(Option<std::ffi::OsString>);
3868
3869 impl Drop for RestoreConfigDir {
3870 fn drop(&mut self) {
3871 match self.0.take() {
3872 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3873 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3874 }
3875 }
3876 }
3877
3878 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3879 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3880 let tmp = tempfile::tempdir().unwrap();
3881 let agent = tmp.path().join("agent");
3882 let cwd = tmp.path().join("project");
3883 let skill_dir = tmp.path().join("configured-skills");
3884 std::fs::create_dir_all(&agent).unwrap();
3885 std::fs::create_dir_all(&cwd).unwrap();
3886 std::fs::create_dir_all(&skill_dir).unwrap();
3887 std::fs::write(
3888 skill_dir.join("SKILL.md"),
3889 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3890 )
3891 .unwrap();
3892 std::fs::write(
3893 agent.join("settings.json"),
3894 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3895 )
3896 .unwrap();
3897 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3898
3899 let resolved = crate::provider::resolve(
3900 Some("anthropic"),
3901 Some(crate::provider::DEFAULT_MODEL_ID),
3902 None,
3903 Some("test-key"),
3904 None,
3905 )
3906 .unwrap();
3907 let args = Args {
3908 trust_override: Some(false),
3909 no_session: true,
3910 no_extensions: true,
3911 no_prompt_templates: true,
3912 no_context_files: true,
3913 system_prompt: Some("stable system prompt".into()),
3914 ..Args::default()
3915 };
3916 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3917
3918 let outcome = reload_extension_resources_inner(&harness, &context, || {
3919 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3920 })
3921 .await;
3922
3923 assert!(!outcome.summary.contains("Settings reload failed"));
3924 assert_eq!(
3925 outcome.summary,
3926 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3927 );
3928 let resources = harness.get_resources().await.unwrap();
3929 let skills = resources.skills.as_ref().unwrap();
3930 assert_eq!(skills.len(), 1);
3931 assert_eq!(skills[0].name, "frozen-skill");
3932 }
3933
3934 #[tokio::test]
3935 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3936 let tmp = tempfile::tempdir().unwrap();
3937 let skill_path = tmp.path().join("explicit-skill.md");
3938 std::fs::write(
3939 &skill_path,
3940 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3941 )
3942 .unwrap();
3943 let prompt_path = tmp.path().join("explicit-prompt.md");
3944 std::fs::write(
3945 &prompt_path,
3946 "---\ndescription: Explicit prompt\n---\nPrompt body",
3947 )
3948 .unwrap();
3949
3950 let args = Args {
3951 skill: vec![skill_path.clone()],
3952 prompt_template: vec![prompt_path.clone()],
3953 ..Args::default()
3954 };
3955 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3956 let prompt_paths =
3957 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3958 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3959 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3960
3961 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3962 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3963 assert_eq!(skills.skills[0].name, "explicit-skill");
3964
3965 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3966 assert_eq!(
3967 prompts.prompt_templates.len(),
3968 1,
3969 "{:?}",
3970 prompts.diagnostics
3971 );
3972 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3973 }
3974
3975 #[test]
3976 fn tool_policy_applies_to_rust_and_js_active_names() {
3977 let names = vec![
3978 "read".to_string(),
3979 "ask_user_question".to_string(),
3980 "write".to_string(),
3981 ];
3982
3983 let args = Args {
3984 tools: Some(vec!["read".into(), "ask_user_question".into()]),
3985 ..Args::default()
3986 };
3987 assert_eq!(
3988 filter_active_tool_names(names.clone(), &args),
3989 vec!["read", "ask_user_question"]
3990 );
3991
3992 let args = Args {
3993 exclude_tools: Some(vec!["ask_user_question".into()]),
3994 ..Args::default()
3995 };
3996 assert_eq!(
3997 filter_active_tool_names(names.clone(), &args),
3998 vec!["read", "write"]
3999 );
4000
4001 let args = Args {
4002 no_tools: true,
4003 ..Args::default()
4004 };
4005 assert!(filter_active_tool_names(names, &args).is_empty());
4006 }
4007
4008 #[test]
4009 fn select_ephemeral_when_no_session() {
4010 let args = Args {
4011 no_session: true,
4012 ..Args::default()
4013 };
4014 let cwd = Path::new("/tmp");
4015 assert!(matches!(
4016 select_session(&args, cwd),
4017 SessionSelection::Ephemeral
4018 ));
4019 }
4020
4021 #[test]
4022 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
4023 let defaults = Args::default();
4024 assert!(resolve_project_trust(
4025 &defaults,
4026 Path::new("C:/definitely-not-a-project")
4027 ));
4028
4029 let denied = Args {
4030 trust_override: Some(false),
4031 ..Args::default()
4032 };
4033 assert!(!resolve_project_trust(
4034 &denied,
4035 Path::new("C:/definitely-not-a-project")
4036 ));
4037 }
4038
4039 #[tokio::test(flavor = "current_thread")]
4040 async fn build_preserves_the_supplied_startup_project_snapshot() {
4041 struct RestoreConfigDir(Option<std::ffi::OsString>);
4042
4043 impl Drop for RestoreConfigDir {
4044 fn drop(&mut self) {
4045 match self.0.take() {
4046 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4047 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4048 }
4049 }
4050 }
4051
4052 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4053 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4054 let tmp = tempfile::tempdir().unwrap();
4055 let agent = tmp.path().join("agent");
4056 let cwd = tmp.path().join("project");
4057 std::fs::create_dir_all(&agent).unwrap();
4058 std::fs::create_dir_all(&cwd).unwrap();
4059 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4060
4061 let resolved = crate::provider::resolve(
4062 Some("anthropic"),
4063 Some(crate::provider::DEFAULT_MODEL_ID),
4064 None,
4065 Some("test-key"),
4066 None,
4067 )
4068 .unwrap();
4069 let args = Args {
4070 trust_override: Some(false),
4071 no_session: true,
4072 no_tools: true,
4073 no_extensions: true,
4074 no_skills: true,
4075 no_prompt_templates: true,
4076 no_context_files: true,
4077 system_prompt: Some("test prompt".into()),
4078 ..Args::default()
4079 };
4080
4081 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
4082
4083 assert_eq!(context.cwd, cwd);
4084 assert!(context.project_trusted);
4085 }
4086
4087 #[tokio::test(flavor = "current_thread")]
4088 async fn build_applies_the_name_flag_to_the_session() {
4089 struct RestoreConfigDir(Option<std::ffi::OsString>);
4090
4091 impl Drop for RestoreConfigDir {
4092 fn drop(&mut self) {
4093 match self.0.take() {
4094 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4095 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4096 }
4097 }
4098 }
4099
4100 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4101 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4102 let tmp = tempfile::tempdir().unwrap();
4103 let agent = tmp.path().join("agent");
4104 let cwd = tmp.path().join("project");
4105 std::fs::create_dir_all(&agent).unwrap();
4106 std::fs::create_dir_all(&cwd).unwrap();
4107 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4108
4109 let resolved = crate::provider::resolve(
4110 Some("anthropic"),
4111 Some(crate::provider::DEFAULT_MODEL_ID),
4112 None,
4113 Some("test-key"),
4114 None,
4115 )
4116 .unwrap();
4117 let args = Args {
4118 trust_override: Some(false),
4119 no_tools: true,
4120 no_extensions: true,
4121 no_skills: true,
4122 no_prompt_templates: true,
4123 no_context_files: true,
4124 system_prompt: Some("test prompt".into()),
4125 name: Some("smoke name".into()),
4126 session_dir: Some(cwd.join("sessions")),
4127 ..Args::default()
4128 };
4129
4130 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
4131 assert_eq!(
4132 harness.get_name().await.unwrap().as_deref(),
4133 Some("smoke name")
4134 );
4135 }
4136
4137 #[test]
4138 fn select_latest_for_continue_and_resume() {
4139 let _env = crate::config::test_support::SessionDirEnv::new(None);
4140 let args = Args {
4141 continue_session: true,
4142 ..Args::default()
4143 };
4144 let cwd = Path::new("/tmp");
4145 assert!(matches!(
4146 select_session(&args, cwd),
4147 SessionSelection::Latest { .. }
4148 ));
4149
4150 let args = Args {
4151 resume: true,
4152 ..Args::default()
4153 };
4154 assert!(matches!(
4155 select_session(&args, cwd),
4156 SessionSelection::Latest { .. }
4157 ));
4158 }
4159
4160 #[test]
4161 fn select_by_id_for_session_flag() {
4162 let _env = crate::config::test_support::SessionDirEnv::new(None);
4163 let args = Args {
4164 session: Some("01a02ece".into()),
4165 ..Args::default()
4166 };
4167 let cwd = Path::new("/tmp");
4168 assert!(matches!(
4169 select_session(&args, cwd),
4170 SessionSelection::ById { id, .. } if id == "01a02ece"
4171 ));
4172 }
4173
4174 #[test]
4175 fn select_new_with_custom_dir() {
4176 let _env = crate::config::test_support::SessionDirEnv::new(None);
4177 let args = Args {
4178 session_dir: Some(PathBuf::from("/tmp/sess")),
4179 ..Args::default()
4180 };
4181 let cwd = Path::new("/tmp");
4182 match select_session(&args, cwd) {
4183 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
4184 other => panic!("expected New, got {other:?}"),
4185 }
4186 }
4187
4188 #[test]
4189 fn select_new_default_dir() {
4190 let _env = crate::config::test_support::SessionDirEnv::new(None);
4191 let args = Args::default();
4192 let cwd = Path::new("/proj");
4193 match select_session(&args, cwd) {
4194 SessionSelection::New { dir, .. } => {
4195 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
4196 }
4197 other => panic!("expected New, got {other:?}"),
4198 }
4199 }
4200
4201 #[test]
4202 fn default_session_dir_only_uses_rpi() {
4203 let _env = crate::config::test_support::SessionDirEnv::new(None);
4204 let tmp = tempfile::tempdir().unwrap();
4205 let cwd = tmp.path();
4206 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
4207 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
4208 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
4209 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
4210 }
4211
4212 #[test]
4213 fn default_session_dir_uses_env_var() {
4214 let tmp = tempfile::tempdir().unwrap();
4215 let cwd = tmp.path();
4216 let custom = tmp.path().join("custom_sessions");
4217 let _env = crate::config::test_support::SessionDirEnv::new(Some(&custom));
4218 assert_eq!(default_session_dir(cwd), custom);
4219 }
4220
4221 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4222 async fn ephemeral_session_builds_roundtrips() {
4223 let s = ephemeral_session();
4227 let leaf = s.get_leaf_id().await;
4228 assert!(leaf.is_ok());
4229 }
4230
4231 #[test]
4249 fn host_private_env_matches_the_declared_contract() {
4250 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
4252 assert_eq!(
4253 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
4254 [
4255 "LANGFUSE_PI_PARENT_TRACE_ID",
4256 "LANGFUSE_PI_PARENT_SPAN_ID",
4257 "LANGFUSE_PI_PARENT_SESSION_ID",
4258 "LANGFUSE_PI_PARENT_DEPTH",
4259 ],
4260 "these strings are what rpi-langfuse (a different repo) matches; a \
4261 change here is a breaking cross-repo change, not a refactor"
4262 );
4263
4264 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
4267 .iter()
4268 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
4269 {
4270 assert!(
4271 rpi_tools::is_host_private_env(name),
4272 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
4273 rpi_tools::HOST_PRIVATE_ENV
4274 );
4275 }
4276
4277 for visible in [
4280 crate::args::RPI_OFFLINE_ENV,
4281 crate::config::CONFIG_DIR_ENV,
4282 "PATH",
4283 ] {
4284 assert!(
4285 !rpi_tools::is_host_private_env(visible),
4286 "{visible} describes the user's configuration and must still reach the shell"
4287 );
4288 }
4289 }
4290 #[tokio::test]
4291 async fn reload_shutdown_joins_all_handlers_even_after_veto() {
4292 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
4293 use std::sync::atomic::AtomicUsize;
4294 static CONTEXTS: rpi_plugin_sdk::handles::ToolHandles<Arc<AtomicUsize>> =
4295 rpi_plugin_sdk::handles::ToolHandles::new();
4296 struct ContextOwner(usize);
4297 impl Drop for ContextOwner {
4298 fn drop(&mut self) {
4299 drop(CONTEXTS.remove(self.0 as rpi_plugin_sdk::StepHandle));
4300 }
4301 }
4302 extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
4303 let context = CONTEXTS.get(data).expect("live test context");
4304 let calls = context.lock().clone();
4305 std::thread::sleep(std::time::Duration::from_millis(20));
4306 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
4307 rpi_plugin_sdk::EVENT_HANDLER_ABORT
4308 } else {
4309 0
4310 }
4311 }
4312 let calls = Arc::new(AtomicUsize::new(0));
4313 let context = CONTEXTS.insert(calls.clone());
4314 let _owner = ContextOwner(context as usize);
4315 let mut registry = rpi_extensions::ExtensionRegistry::new();
4316 for name in ["first", "second"] {
4317 registry.register_event_handler(name.into(), EventTag::SessionShutdown, stop, context);
4318 }
4319 super::shutdown_extensions_for_reload(
4320 Some(Arc::new(registry.snapshot())),
4321 rpi_extensions::PluginKeepalive::empty(),
4322 )
4323 .await
4324 .unwrap();
4325 assert_eq!(calls.load(Ordering::SeqCst), 2);
4326 }
4327}