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
232pub(crate) fn 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());
486 let session = build_session(&selection, &cwd_str).await?;
487 if let Ok(metadata) = session.get_metadata().await {
491 publish_session_identity(&tool_context, &metadata.id);
492 }
493 if let Some(requested) = args.name.as_deref() {
499 let name = requested.trim();
500 let name = if name.is_empty() { None } else { Some(name) };
501 if let Err(error) = session.set_name(name).await {
502 if args.verbose {
503 eprintln!("warning: could not set session name: {error}");
504 }
505 }
506 }
507 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
517 let prompt_tools = active.clone();
518 let mut context_paths = Vec::new();
519 let base_prompt = match args.system_prompt.as_deref() {
520 Some(explicit) => explicit.to_string(),
521 None if project_trusted => match discover_system_prompt_file(cwd) {
522 Some(path) => read_prompt_source(&path, &mut context_paths).unwrap_or_else(|| {
523 default_system_prompt_with_tools(&cwd_str, prior_reasoning_replayed, &prompt_tools)
524 }),
525 None => {
526 default_system_prompt_with_tools(&cwd_str, prior_reasoning_replayed, &prompt_tools)
527 }
528 },
529 None => default_system_prompt_with_tools(&cwd_str, prior_reasoning_replayed, &prompt_tools),
530 };
531
532 let mut append_texts: Vec<String> = Vec::new();
539 for extra in &args.append_system_prompt {
540 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
541 append_texts.push(text);
542 }
543 if args.append_system_prompt.is_empty() {
544 if let Some(path) = project_trusted
545 .then(|| discover_append_system_prompt_file(cwd))
546 .flatten()
547 {
548 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
549 append_texts.push(text);
550 }
551 }
552 }
553 let append_join = if append_texts.is_empty() {
554 None
555 } else {
556 Some(append_texts.join("\n\n"))
557 };
558
559 let agent_dir = crate::config::agent_dir().ok();
571
572 let discovered = extension_session
588 .snapshot_arc()
589 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
590 .unwrap_or_default();
591
592 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
593 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
594 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
595 if !args.no_skills {
596 let mut dirs = if args.dev_local_only || args.project_only {
597 project_skill_dirs(cwd)
598 } else if project_trusted {
599 skill_dirs(cwd)
600 } else {
601 global_skill_dirs()
602 };
603 dirs.extend(args.skill.iter().cloned());
604 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
605 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
606 skills = result.skills;
607 skill_diags = result.diagnostics;
608 resource_diags.extend(result.resource_diagnostics);
609 }
610
611 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
612 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
613 if !args.no_prompt_templates {
614 let mut dirs = if args.dev_local_only || args.project_only {
615 project_prompt_template_dirs(cwd)
616 } else if project_trusted {
617 prompt_template_dirs(cwd)
618 } else {
619 global_prompt_template_dirs()
620 };
621 dirs.extend(args.prompt_template.iter().cloned());
622 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
623 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
624 prompt_templates = result.prompt_templates;
625 prompt_diags = result.diagnostics;
626 resource_diags.extend(result.resource_diagnostics);
627 }
628
629 let context_block = if args.no_context_files || !project_trusted {
630 String::new()
631 } else {
632 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
637 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
638 context_paths.extend(
639 files
640 .iter()
641 .map(|file| file.path.to_string_lossy().into_owned()),
642 );
643 format_project_context(&files)
644 };
645
646 if args.verbose {
648 for d in &skill_diags {
649 eprintln!(
650 "warning: skill {} ({}): {}",
651 d.path,
652 d.code.as_str(),
653 d.message
654 );
655 }
656 for d in &prompt_diags {
657 eprintln!(
658 "warning: prompt template {} ({}): {}",
659 d.path,
660 d.code.as_str(),
661 d.message
662 );
663 }
664 for d in &resource_diags {
666 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
667 eprintln!(
668 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
669 c.resource_type.as_str(),
670 c.name,
671 c.winner_path,
672 c.loser_path
673 );
674 }
675 }
676 }
677
678 let system_prompt = compose_system_prompt(
687 Some(&base_prompt),
688 &[], if context_block.is_empty() {
690 None
691 } else {
692 Some(&context_block)
693 },
694 append_join.as_deref(),
695 );
696
697 if args.debug_system_prompt {
706 eprintln!("=== --debug-system-prompt ===");
707 let base_src = if args.system_prompt.is_some() {
708 "--system-prompt"
709 } else if discover_system_prompt_file(cwd).is_some() {
710 "SYSTEM.md"
711 } else {
712 "default"
713 };
714 eprintln!("[base source: {base_src}]");
715 eprintln!("--- base ---\n{base_prompt}");
716 if let Some(append) = append_join.as_deref() {
717 eprintln!("--- append ---\n{append}");
718 } else {
719 eprintln!("--- append: (none) ---");
720 }
721 if context_block.is_empty() {
722 eprintln!("--- context: (none) ---");
723 } else {
724 eprintln!("--- context ---{context_block}");
725 }
726 let visible_skills = skills
727 .iter()
728 .filter(|s| s.disable_model_invocation != Some(true))
729 .count();
730 eprintln!(
731 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
732 skills.len(),
733 visible_skills,
734 skills.len() - visible_skills
735 );
736 for s in &skills {
737 let hidden = if s.disable_model_invocation == Some(true) {
738 " [hidden]"
739 } else {
740 ""
741 };
742 eprintln!(" {}{hidden} — {}", s.name, s.description);
743 }
744 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
745 for t in &prompt_templates {
746 eprintln!(" /{}", t.name);
747 }
748 eprintln!(
752 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
753 discovered.skill_paths.len(),
754 discovered.prompt_paths.len(),
755 discovered.theme_paths.len(),
756 );
757 for p in &discovered.skill_paths {
758 eprintln!(" skill: {p}");
759 }
760 for p in &discovered.prompt_paths {
761 eprintln!(" prompt: {p}");
762 }
763 eprintln!(
764 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
765 );
766 eprintln!("=== end --debug-system-prompt ===");
767 }
768
769 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
774 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
775 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
779
780 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
789 Some(snapshot) => {
790 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
791 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
792 }
793 None => broadcast_emitter,
794 };
795
796 let saved_settings = crate::settings::load_settings().unwrap_or_default();
800
801 if let Some(proxy) = &saved_settings.http_proxy {
806 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
807 if std::env::var_os(key).is_none() {
808 std::env::set_var(key, proxy);
809 }
810 }
811 }
812
813 let extension_session_cell = Arc::new(Mutex::new(extension_session.clone()));
818 let before_tool_call = extension_session
823 .snapshot()
824 .filter(|snapshot| {
825 !snapshot
826 .handlers_for(rpi_plugin_sdk::EventTag::ToolCall)
827 .is_empty()
828 })
829 .map(|_| {
830 extension_tool_call_hook(
831 Arc::clone(&extension_session_cell),
832 ui_dialog_mailbox.clone(),
833 )
834 });
835
836 let options = AgentHarnessOptions {
837 model: resolved.model.clone(),
838 thinking_level: resolved.thinking_level,
839 active_tool_names: active,
840 tools,
841 system_prompt: Some(system_prompt),
842 resources: AgentHarnessResources {
843 context_files: context_paths,
844 skills: if skills.is_empty() {
845 None
846 } else {
847 Some(skills)
848 },
849 prompt_templates: if prompt_templates.is_empty() {
850 None
851 } else {
852 Some(prompt_templates)
853 },
854 },
855 allow_existing_session: matches!(
858 selection,
859 SessionSelection::Latest { .. }
860 | SessionSelection::ById { .. }
861 | SessionSelection::ByExactId { .. }
862 | SessionSelection::Fork { .. }
863 ),
864 stream_options: AgentHarnessStreamOptions {
865 timeout: args.timeout,
866 stream_idle_timeout: args.stream_idle_timeout.or_else(|| {
867 saved_settings
868 .stream_idle_timeout
869 .map(std::time::Duration::from_secs)
870 }),
871 ..Default::default()
872 },
873 retry: saved_settings.retry_policy(),
878 compaction: saved_settings.compaction_settings(),
879 steering_mode: saved_settings.steering_mode(),
880 follow_up_mode: saved_settings.follow_up_mode(),
881 tool_execution: HarnessToolExecution::default(),
882 drive: DrivingMode::default(),
883 session,
884 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
892 to_provider_messages: None,
893 entry_projectors: Default::default(),
894 agent_emitter: Some(emitter),
895 before_tool_call,
900 after_tool_call: None,
901 transform_context: None,
902 entry_transforms: Vec::new(),
903 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
909 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
910 };
911
912 let harness = match AgentHarness::create(options).await {
913 Ok(h) => {
914 crate::extensions_actions::HarnessActionHost::set_harness(
917 &harness_cell,
918 Arc::new(h.clone()),
919 );
920 h
921 }
922 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
923 };
924
925 {
933 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
934 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
935 Ok(reports) => {
936 for (lane, report) in reports {
937 if report.is_clean() {
938 continue;
939 }
940 if report.is_corrupt() {
941 eprintln!(
942 "warning: session lane '{lane}' is corrupt ({} open operations); \
943 refusing to auto-repair — inspect the session file",
944 report.open_operations.len()
945 );
946 continue;
947 }
948 for decision in runtime.reconcile(&report) {
949 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
950 if args.verbose {
951 eprintln!(
952 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
953 );
954 }
955 }
956 }
957 runtime.emit_recovery_events(harness.events(), &report);
958 }
959 }
960 Err(e) => {
961 if args.verbose {
962 eprintln!("warning: session recovery sweep failed: {e}");
963 }
964 }
965 }
966 }
967
968 let mut reload_args = args.clone();
977 reload_args.session_dir = Some(session_dir);
978 let reload_context = ReloadContext {
979 extension_session: extension_session_cell,
980 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
981 tool_context,
982 catalog: Arc::new(std::sync::RwLock::new(catalog)),
983 providers: Arc::new(std::sync::RwLock::new(resolved.providers.clone())),
984 runtime: runtime.clone(),
985 cwd: cwd.to_path_buf(),
986 project_trusted,
987 args: reload_args,
988 resolved_model: resolved.model.clone(),
989 broadcast: broadcast_for_context,
990 mailbox: reload_mailbox,
991 ui_dialog: ui_dialog_mailbox,
992 ext_status: ext_status_mailbox,
993 editor_text: editor_text_mailbox,
994 dev_extension: None,
995 startup_notices: Vec::new(),
996 };
997
998 Ok((harness, event_rx, reload_context))
999}
1000
1001fn extension_tool_call_hook(
1004 sessions: ExtensionSessionCell,
1005 ui_dialog: rpi_extensions::UiDialogMailbox,
1006) -> rpi_agent::BeforeToolCall {
1007 Arc::new(move |ctx: rpi_agent::BeforeToolCallContext<'_>, signal| {
1008 let session = sessions
1009 .lock()
1010 .map(|session| session.clone())
1011 .map_err(|_| ());
1012 let ui_dialog = ui_dialog.clone();
1013 let tool_call_id = ctx.tool_call.id.clone();
1014 let tool_name = ctx.tool_call.name.clone();
1015 let params = ctx.args.clone();
1016 Box::pin(async move {
1017 let reason = match session {
1018 Ok(session) => {
1019 rpi_extensions::dispatch_tool_call_event(
1020 &session,
1021 &tool_call_id,
1022 &tool_name,
1023 ¶ms,
1024 &signal,
1025 &ui_dialog,
1026 )
1027 .await
1028 }
1029 Err(_) => Some("extension approval session is unavailable".to_string()),
1030 };
1031 reason.map(|reason| rpi_agent::BeforeToolCallResult {
1032 block: true,
1033 reason: Some(reason),
1034 terminate: false,
1035 args: None,
1036 })
1037 })
1038 })
1039}
1040
1041pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
1086
1087pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
1092
1093#[derive(Clone)]
1101pub struct ReloadContext {
1102 pub extension_session: ExtensionSessionCell,
1104 pub tool_context: rpi_extensions::ToolCallContext,
1109 pub action_bridge: ActionBridgeCell,
1111 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
1114 pub editor_text: rpi_extensions::EditorTextMailbox,
1121 pub catalog: Arc<std::sync::RwLock<Vec<rpi_ai::Model>>>,
1124 pub providers: Arc<std::sync::RwLock<Vec<Arc<dyn Provider>>>>,
1127 pub runtime: tokio::runtime::Handle,
1131 pub cwd: PathBuf,
1133 pub project_trusted: bool,
1137 pub args: Args,
1144 pub resolved_model: rpi_ai::Model,
1147 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
1153 pub mailbox: rpi_extensions::ReloadMailbox,
1160 pub ui_dialog: rpi_extensions::UiDialogMailbox,
1166 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
1170 pub startup_notices: Vec<String>,
1176}
1177
1178impl ReloadContext {
1179 pub(crate) fn session_dir(&self) -> PathBuf {
1180 self.args
1182 .session_dir
1183 .clone()
1184 .unwrap_or_else(|| configured_session_dir(&self.args, &self.cwd))
1185 }
1186}
1187
1188pub async fn dispatch_session_event_async(
1203 reload_context: &ReloadContext,
1204 tag: rpi_plugin_sdk::EventTag,
1205) -> Option<String> {
1206 let (snapshot, keepalive) = {
1212 let session = reload_context.extension_session.lock().unwrap();
1213 (session.snapshot_arc(), session.keepalive())
1214 };
1215 match snapshot {
1216 Some(snapshot) => rpi_extensions::dispatch_lifecycle_event(&snapshot, tag, keepalive).await,
1217 None => None,
1218 }
1219}
1220
1221async fn shutdown_extensions_for_reload(
1224 snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
1225 keepalive: Arc<rpi_extensions::PluginKeepalive>,
1226) -> Result<(), String> {
1227 if let Some(snapshot) = snapshot {
1228 tokio::task::spawn_blocking(move || {
1229 let _keepalive = keepalive;
1230 rpi_extensions::dispatch_empty_event(
1231 &snapshot,
1232 rpi_plugin_sdk::EventTag::SessionShutdown,
1233 );
1234 })
1235 .await
1236 .map_err(|error| error.to_string())?;
1237 }
1238 Ok(())
1239}
1240
1241pub struct ReloadOutcome {
1244 pub summary: String,
1247 pub had_warnings: bool,
1249 pub details: Vec<String>,
1254 pub had_errors: bool,
1258}
1259
1260struct PreparedReloadInputs {
1261 extension_dirs: Vec<PathBuf>,
1262 skill_base_dirs: Vec<PathBuf>,
1263 prompt_base_dirs: Vec<PathBuf>,
1264}
1265
1266fn append_reload_resource_paths(
1271 mut paths: Vec<PathBuf>,
1272 explicit: &[PathBuf],
1273 discovered: &[String],
1274 js_paths: &[PathBuf],
1275 package_paths: &[PathBuf],
1276) -> Vec<PathBuf> {
1277 paths.extend(explicit.iter().cloned());
1278 paths.extend(discovered.iter().map(PathBuf::from));
1279 paths.extend(js_paths.iter().cloned());
1280 paths.extend(package_paths.iter().cloned());
1281 paths
1282}
1283
1284pub async fn reload_extension_resources(
1296 harness: &AgentHarness,
1297 ctx: &ReloadContext,
1298) -> ReloadOutcome {
1299 reload_extension_resources_inner(harness, ctx, || {}).await
1300}
1301
1302async fn reload_extension_resources_inner<F>(
1303 harness: &AgentHarness,
1304 ctx: &ReloadContext,
1305 after_prepare: F,
1306) -> ReloadOutcome
1307where
1308 F: FnOnce() + Send,
1309{
1310 let mut effective_args = ctx.args.clone();
1315 if let Some(dev) = &ctx.dev_extension {
1316 if let Err(error) = dev
1317 .rebuild()
1318 .and_then(|_| dev.apply_to_args(&mut effective_args))
1319 {
1320 return ReloadOutcome {
1321 summary: format!(
1322 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1323 dev.package_name()
1324 ),
1325 had_warnings: true,
1326 details: Vec::new(),
1327 had_errors: true,
1328 };
1329 }
1330 }
1331 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1332 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1333 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1334 Ok(prepared) => prepared,
1335 Err(error) => {
1336 return ReloadOutcome {
1337 summary: format!(
1338 "Settings reload failed: {error}. Keeping the currently loaded resources."
1339 ),
1340 had_warnings: true,
1341 details: Vec::new(),
1342 had_errors: true,
1343 };
1344 }
1345 };
1346 after_prepare();
1347 let PreparedReloadInputs {
1348 extension_dirs,
1349 skill_base_dirs,
1350 prompt_base_dirs,
1351 } = prepared;
1352 let mut warnings = false;
1353
1354 let (shutdown_snapshot, shutdown_keepalive) = {
1358 let session = ctx.extension_session.lock().unwrap();
1359 (session.snapshot_arc(), session.keepalive())
1360 };
1361 if let Err(reason) = shutdown_extensions_for_reload(shutdown_snapshot, shutdown_keepalive).await
1362 {
1363 return ReloadOutcome {
1364 summary: "Reload failed during extension shutdown".into(),
1365 had_warnings: true,
1366 details: vec![reason],
1367 had_errors: true,
1368 };
1369 }
1370
1371 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1378 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1379 Some(b) => b.clone_host(),
1380 None => {
1381 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1384 ctx.catalog
1385 .read()
1386 .unwrap_or_else(|error| error.into_inner())
1387 .clone(),
1388 ctx.cwd.clone(),
1389 ctx.runtime.clone(),
1390 ctx.args.unknown_flags.clone(),
1391 ctx.tool_context.clone(),
1392 );
1393 crate::extensions_actions::HarnessActionHost::set_harness(
1394 &_cell,
1395 Arc::new(harness.clone()),
1396 );
1397 Arc::new(action_host.with_session_dir(ctx.session_dir()))
1398 }
1399 };
1400
1401 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1402 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1403 ctx.runtime.clone(),
1404 host,
1405 reload_cb,
1406 ctx.ui_dialog.clone(),
1407 ctx.ext_status.clone(),
1408 ctx.editor_text.clone(),
1409 );
1410
1411 let extension_session = if effective_args.no_extensions {
1412 rpi_extensions::ExtensionSession::none()
1413 } else {
1414 load_extensions_from_dirs(
1415 &effective_args,
1416 &extension_dirs,
1417 Some(Arc::clone(&fresh_bridge)),
1418 )
1419 };
1420 if extension_session.is_empty() && !effective_args.no_extensions {
1421 }
1424 if effective_args.verbose {
1425 if let Some(s) = extension_session.summary() {
1426 eprintln!("reload: {s}");
1427 }
1428 report_deferred_renderers(&extension_session);
1429 }
1430
1431 let has_package_providers = |session: &ExtensionSession| {
1432 session.snapshot().is_some_and(|snapshot| {
1433 snapshot
1434 .providers()
1435 .iter()
1436 .any(|provider| snapshot.provider_manifest(&provider.provider_id).is_some())
1437 })
1438 };
1439 let packaged = has_package_providers(&extension_session)
1440 || has_package_providers(&ctx.extension_session.lock().unwrap());
1441 let refreshed = if packaged {
1442 let mut args = effective_args.clone();
1443 if let Ok(current) = harness.lane("main").get_model().await {
1444 let was_package = ctx
1445 .extension_session
1446 .lock()
1447 .unwrap()
1448 .snapshot()
1449 .is_some_and(|snapshot| snapshot.provider_manifest(¤t.provider).is_some());
1450 let has_replacement = extension_session.snapshot().is_some_and(|snapshot| {
1451 snapshot
1452 .providers()
1453 .iter()
1454 .any(|provider| provider.provider_id == current.provider)
1455 });
1456 if was_package && !has_replacement {
1457 return ReloadOutcome {
1458 summary: "Reload failed while resolving provider packages".into(),
1459 had_warnings: true,
1460 details: vec![format!(
1461 "active provider package '{}' is no longer loaded",
1462 current.provider
1463 )],
1464 had_errors: true,
1465 };
1466 }
1467 args.provider = Some(current.provider.clone());
1468 args.model = Some(current.id);
1469 if current.provider != ctx.resolved_model.provider {
1470 args.api_key = None;
1471 args.base_url = None;
1472 }
1473 }
1474 match crate::provider::resolve_with_extensions(
1475 &args,
1476 &ctx.cwd,
1477 ctx.project_trusted,
1478 &extension_session,
1479 ) {
1480 Ok(resolved) => Some(resolved),
1481 Err(error) => {
1482 return ReloadOutcome {
1483 summary: "Reload failed while resolving provider packages".into(),
1484 had_warnings: true,
1485 details: vec![error.to_string()],
1486 had_errors: true,
1487 }
1488 }
1489 }
1490 } else {
1491 None
1492 };
1493
1494 let old_session = {
1506 let mut session_guard = ctx.extension_session.lock().unwrap();
1507 let old_session = std::mem::replace(&mut *session_guard, extension_session.clone());
1508 if let Some(old_snap) = old_session.snapshot_arc() {
1509 old_snap.active_flag().store(false, Ordering::SeqCst);
1513 }
1514 old_session
1515 };
1516 if let Some(old_b) = old_bridge {
1517 old_b.invalidate();
1518 }
1519 old_session.begin_shutdown();
1521
1522 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1524
1525 let discovered = extension_session
1527 .snapshot_arc()
1528 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1529 .unwrap_or_default();
1530
1531 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1533 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1534
1535 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1536 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1537 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1538 if !effective_args.no_skills {
1539 let js_paths: &[PathBuf] = &[];
1543 let package_paths: &[PathBuf] = &[];
1544 let dirs = append_reload_resource_paths(
1545 skill_base_dirs,
1546 &effective_args.skill,
1547 &discovered.skill_paths,
1548 js_paths,
1549 package_paths,
1550 );
1551 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1552 skills = result.skills;
1553 skill_diags = result.diagnostics;
1554 resource_diags.extend(result.resource_diagnostics);
1555 }
1556
1557 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1558 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1559 if !effective_args.no_prompt_templates {
1560 let js_paths: &[PathBuf] = &[];
1561 let package_paths: &[PathBuf] = &[];
1562 let dirs = append_reload_resource_paths(
1563 prompt_base_dirs,
1564 &effective_args.prompt_template,
1565 &discovered.prompt_paths,
1566 js_paths,
1567 package_paths,
1568 );
1569 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1570 prompt_templates = result.prompt_templates;
1571 prompt_diags = result.diagnostics;
1572 resource_diags.extend(result.resource_diagnostics);
1573 }
1574
1575 let context_files = if effective_args.no_context_files || !project_trusted {
1576 Vec::new()
1577 } else {
1578 let agent_dir = crate::config::agent_dir().ok();
1579 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1580 load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await
1581 };
1582 let context_block = format_project_context(&context_files);
1583
1584 let mut details: Vec<String> = Vec::new();
1585 if let Some(resolved) = &refreshed {
1586 warnings |= !resolved.compat_warnings.is_empty();
1587 details.extend(
1588 resolved
1589 .compat_warnings
1590 .iter()
1591 .map(|warning| format!("warning: models.json: {warning}")),
1592 );
1593 }
1594 if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
1595 warnings = true;
1596 for d in &skill_diags {
1601 details.push(format!(
1602 "warning: skill {} ({}): {}",
1603 d.path,
1604 d.code.as_str(),
1605 d.message
1606 ));
1607 }
1608 for d in &prompt_diags {
1609 details.push(format!(
1610 "warning: prompt template {} ({}): {}",
1611 d.path,
1612 d.code.as_str(),
1613 d.message
1614 ));
1615 }
1616 for d in &resource_diags {
1618 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1619 details.push(format!(
1620 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1621 c.resource_type.as_str(),
1622 c.name,
1623 c.winner_path,
1624 c.loser_path
1625 ));
1626 }
1627 }
1628 }
1629
1630 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1639 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1640 let mut tools = build_tools(&tool_ctx, &effective_args);
1641 merge_extension_tools(
1642 &mut tools,
1643 &extension_session,
1644 &effective_args,
1645 &ctx.tool_context,
1646 );
1647 let mut active = active_tool_names(&tools, &effective_args);
1648 active = filter_active_tool_names(active, &effective_args);
1649 let prompt_tools = active.clone();
1650 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1651 let mut context_paths = Vec::new();
1652 let base_prompt = match effective_args.system_prompt.as_deref() {
1653 Some(explicit) => explicit.to_string(),
1654 None if project_trusted => match discover_system_prompt_file(&ctx.cwd) {
1655 Some(path) => read_prompt_source(&path, &mut context_paths).unwrap_or_else(|| {
1656 default_system_prompt_with_tools(&cwd_str, prior_reasoning_replayed, &prompt_tools)
1657 }),
1658 None => {
1659 default_system_prompt_with_tools(&cwd_str, prior_reasoning_replayed, &prompt_tools)
1660 }
1661 },
1662 None => default_system_prompt_with_tools(&cwd_str, prior_reasoning_replayed, &prompt_tools),
1663 };
1664 let mut append_texts: Vec<String> = Vec::new();
1665 for extra in &effective_args.append_system_prompt {
1666 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
1667 append_texts.push(text);
1668 }
1669 if effective_args.append_system_prompt.is_empty() {
1670 if let Some(path) = project_trusted
1671 .then(|| discover_append_system_prompt_file(&ctx.cwd))
1672 .flatten()
1673 {
1674 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
1675 append_texts.push(text);
1676 }
1677 }
1678 }
1679 context_paths.extend(
1680 context_files
1681 .iter()
1682 .map(|file| file.path.to_string_lossy().into_owned()),
1683 );
1684 let append_join = if append_texts.is_empty() {
1685 None
1686 } else {
1687 Some(append_texts.join("\n\n"))
1688 };
1689 let system_prompt = compose_system_prompt(
1690 Some(&base_prompt),
1691 &[],
1692 if context_block.is_empty() {
1693 None
1694 } else {
1695 Some(&context_block)
1696 },
1697 append_join.as_deref(),
1698 );
1699
1700 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1702 Some(snapshot) => {
1703 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1704 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1705 }
1706 None => ctx.broadcast.clone(),
1707 };
1708
1709 let resources = AgentHarnessResources {
1711 context_files: context_paths,
1712 skills: if skills.is_empty() {
1713 None
1714 } else {
1715 Some(skills.clone())
1716 },
1717 prompt_templates: if prompt_templates.is_empty() {
1718 None
1719 } else {
1720 Some(prompt_templates.clone())
1721 },
1722 };
1723 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1724 let _ = harness.set_resources(resources).await;
1725 let _ = harness.set_agent_emitter(Some(emitter)).await;
1726 let _ = harness
1727 .set_models(build_models_with_extensions_for_reload(
1728 &refreshed
1729 .as_ref()
1730 .map(|resolved| resolved.providers.clone())
1731 .unwrap_or_else(|| {
1732 ctx.providers
1733 .read()
1734 .unwrap_or_else(|error| error.into_inner())
1735 .clone()
1736 }),
1737 &extension_session,
1738 ctx.runtime.clone(),
1739 ))
1740 .await;
1741 let _ = harness
1742 .set_provider_hooks(
1743 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1744 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1745 )
1746 .await;
1747
1748 if let Some(resolved) = &refreshed {
1749 *ctx.providers
1750 .write()
1751 .unwrap_or_else(|error| error.into_inner()) = resolved.providers.clone();
1752 let catalog = crate::provider::authenticated_catalog(resolved);
1753 fresh_bridge
1754 .clone_host()
1755 .replace_model_catalog(catalog.clone());
1756 *ctx.catalog
1757 .write()
1758 .unwrap_or_else(|error| error.into_inner()) = catalog;
1759 let _ = harness.lane("main").set_model(resolved.model.clone()).await;
1762 }
1763
1764 let _ = harness.set_tools(tools, Some(active)).await;
1765
1766 let summary = format!(
1767 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1768 extension_session.loaded_paths().len(),
1769 skills.len(),
1770 prompt_templates.len(),
1771 );
1772 ReloadOutcome {
1773 summary,
1774 had_warnings: warnings,
1775 details,
1776 had_errors: false,
1779 }
1780}
1781
1782#[derive(Clone, Debug, PartialEq, Eq)]
1783struct ReloadSettingsFields {
1784 skill_dirs: Option<Vec<String>>,
1785 prompt_dirs: Option<Vec<String>>,
1786 extension_dirs: Option<Vec<String>>,
1787}
1788
1789impl From<crate::settings::Settings> for ReloadSettingsFields {
1790 fn from(settings: crate::settings::Settings) -> Self {
1791 Self {
1792 skill_dirs: settings.skill_dirs,
1793 prompt_dirs: settings.prompt_dirs,
1794 extension_dirs: settings.extension_dirs,
1795 }
1796 }
1797}
1798
1799#[derive(Clone, Debug, PartialEq, Eq)]
1800struct ReloadSettingsSnapshot {
1801 global: ReloadSettingsFields,
1802 project: Option<ReloadSettingsFields>,
1803}
1804
1805fn reload_reads_settings(args: &Args) -> bool {
1806 !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
1807}
1808
1809fn load_reload_settings_snapshot(
1813 args: &Args,
1814 cwd: &Path,
1815 project_trusted: bool,
1816) -> Result<Option<ReloadSettingsSnapshot>, String> {
1817 if !reload_reads_settings(args) {
1818 return Ok(None);
1819 }
1820 let global = crate::settings::load_settings()
1821 .map_err(|error| format!("could not load global settings: {error}"))?
1822 .into();
1823 let project = if project_trusted {
1824 crate::settings::load_active_project_settings(cwd)
1825 .map_err(|error| format!("could not load project settings: {error}"))?
1826 .map(|(_, settings)| settings.into())
1827 } else {
1828 None
1829 };
1830 Ok(Some(ReloadSettingsSnapshot { global, project }))
1831}
1832
1833#[cfg(test)]
1836fn validate_settings_for_reload(
1837 args: &Args,
1838 cwd: &Path,
1839 project_trusted: bool,
1840) -> Result<(), String> {
1841 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1842}
1843
1844fn prepare_reload_inputs(
1845 args: &Args,
1846 cwd: &Path,
1847 project_trusted: bool,
1848) -> Result<PreparedReloadInputs, String> {
1849 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1850}
1851
1852fn prepare_reload_inputs_inner<F>(
1853 args: &Args,
1854 cwd: &Path,
1855 project_trusted: bool,
1856 before_verify: F,
1857) -> Result<PreparedReloadInputs, String>
1858where
1859 F: FnOnce(),
1860{
1861 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1862
1863 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1864 Vec::new()
1865 } else if args.project_only {
1866 project_extension_dirs(cwd)
1867 } else if project_trusted {
1868 extension_dirs(cwd)
1869 } else {
1870 global_extension_dirs()
1871 };
1872 if !args.no_extensions {
1873 extension_dirs.extend(args.extensions_dir.iter().cloned());
1874 }
1875
1876 let skill_base_dirs = if args.no_skills {
1877 Vec::new()
1878 } else if args.dev_local_only || args.project_only {
1879 project_skill_dirs(cwd)
1880 } else if project_trusted {
1881 skill_dirs(cwd)
1882 } else {
1883 global_skill_dirs()
1884 };
1885
1886 let prompt_base_dirs = if args.no_prompt_templates {
1887 Vec::new()
1888 } else if args.dev_local_only || args.project_only {
1889 project_prompt_template_dirs(cwd)
1890 } else if project_trusted {
1891 prompt_template_dirs(cwd)
1892 } else {
1893 global_prompt_template_dirs()
1894 };
1895
1896 before_verify();
1897 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1898 if settings_before != settings_after {
1899 return Err(
1900 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1901 );
1902 }
1903
1904 Ok(PreparedReloadInputs {
1905 extension_dirs,
1906 skill_base_dirs,
1907 prompt_base_dirs,
1908 })
1909}
1910
1911fn build_models_with_extensions_for_reload(
1916 providers: &[Arc<dyn Provider>],
1917 extension_session: &ExtensionSession,
1918 runtime: tokio::runtime::Handle,
1919) -> Vec<Arc<dyn Provider>> {
1920 let mut models: Vec<Arc<dyn Provider>> = providers.to_vec();
1921 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1922 for provider in pluggable {
1923 if extension_session
1927 .snapshot()
1928 .is_some_and(|snapshot| snapshot.provider_manifest(provider.id()).is_some())
1929 {
1930 continue;
1931 }
1932 if !models.iter().any(|known| known.id() == provider.id()) {
1933 models.push(provider);
1934 }
1935 }
1936 models
1937}
1938
1939fn report_deferred_renderers(session: &ExtensionSession) {
1943 let Some(snap) = session.snapshot_arc() else {
1944 return;
1945 };
1946 let all = snap.renderers();
1947 let markdown = all
1948 .iter()
1949 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1950 .count();
1951 let message = all
1952 .iter()
1953 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1954 .count();
1955 let entry = all
1956 .iter()
1957 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1958 .count();
1959 if markdown + message + entry == 0 {
1960 return;
1961 }
1962 eprintln!(
1963 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1964 markdown, message, entry
1965 );
1966}
1967
1968#[derive(Debug, thiserror::Error)]
1970pub enum BuildError {
1971 #[error("Could not create the session directory: {0}")]
1972 SessionDir(String),
1973 #[error("Could not open the saved session: {0}")]
1974 SessionOpen(String),
1975 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1976 SessionNotFound { requested: String, dir: String },
1977 #[error("Could not build the harness: {0}")]
1978 HarnessCreate(String),
1979}
1980
1981fn build_models_with_extensions(
1984 resolved: &ResolvedModel,
1985 extension_session: &ExtensionSession,
1986 runtime: tokio::runtime::Handle,
1987) -> Vec<Arc<dyn Provider>> {
1988 build_models_with_extensions_for_reload(&resolved.providers, extension_session, runtime)
1989}
1990
1991pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1994 if let Some(override_value) = args.trust_override {
1995 return override_value;
1996 }
1997 crate::config::project_trust_decision(cwd)
1998 .ok()
1999 .flatten()
2000 .unwrap_or(true)
2001}
2002
2003pub(crate) fn load_extensions(
2011 args: &Args,
2012 cwd: &Path,
2013 project_trusted: bool,
2014 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
2015) -> ExtensionSession {
2016 if args.no_extensions {
2017 return ExtensionSession::none();
2018 }
2019 let mut dirs = if args.dev_local_only {
2020 Vec::new()
2021 } else if args.project_only {
2022 project_extension_dirs(cwd)
2023 } else if project_trusted {
2024 extension_dirs(cwd)
2025 } else {
2026 global_extension_dirs()
2027 };
2028 dirs.extend(args.extensions_dir.iter().cloned());
2029 load_extensions_from_dirs(args, &dirs, action_bridge)
2030}
2031
2032fn load_extensions_from_dirs(
2033 args: &Args,
2034 dirs: &[PathBuf],
2035 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
2036) -> ExtensionSession {
2037 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
2038 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
2045}
2046
2047fn merge_extension_tools(
2053 tools: &mut Vec<HarnessTool>,
2054 session: &ExtensionSession,
2055 args: &Args,
2056 tool_context: &rpi_extensions::ToolCallContext,
2057) {
2058 let Some(snapshot) = session.snapshot() else {
2059 return;
2060 };
2061 for et in snapshot.tools() {
2062 let name = &et.tool.name;
2063 if !tool_name_allowed(name, args) {
2064 continue;
2065 }
2066 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
2067 .with_context(tool_context.clone());
2068 let harness_tool = HarnessTool::new(Arc::new(adapter));
2069 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
2070 Some(slot) => *slot = harness_tool,
2071 None => tools.push(harness_tool),
2072 }
2073 }
2074}
2075
2076pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
2083 use rpi_tools::tools::bash::BashToolOptions;
2084 let default = std::env::var("RPI_BASH_TIMEOUT")
2085 .ok()
2086 .and_then(|v| v.parse::<f64>().ok())
2087 .unwrap_or(rpi_tools::tools::bash::DEFAULT_BASH_TIMEOUT_SECONDS);
2088 let settings = crate::settings::load_settings().unwrap_or_default();
2094 BashToolOptions {
2095 command_prefix: settings.shell_command_prefix.clone(),
2096 default_timeout: Some(default),
2097 prepare: None,
2098 }
2099}
2100
2101fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
2102 if args.no_tools {
2103 return Vec::new();
2104 }
2105 let mut all: Vec<(&'static str, HarnessTool)> = vec![
2114 (
2115 "read",
2116 HarnessTool::new(create_read_tool(
2117 ctx,
2118 Some(rpi_tools::tools::read::ReadToolOptions {
2119 image_processor: Some(Arc::new(
2120 rpi_tools::image_processing::InlineImageProcessor,
2121 )),
2122 ..Default::default()
2123 }),
2124 ))
2125 .with_replay(ToolReplay::Safe),
2126 ),
2127 (
2128 "bash",
2129 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
2130 ),
2131 ("edit", HarnessTool::new(create_edit_tool(ctx))),
2132 ("write", HarnessTool::new(create_write_tool(ctx))),
2133 (
2134 "docs",
2135 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
2136 ),
2137 ];
2138
2139 #[cfg(target_os = "windows")]
2141 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
2142
2143 if args.tools.is_none() {
2145 if let Ok(settings) = crate::settings::load_settings() {
2146 if let Some(default_tools) = &settings.default_tools {
2147 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
2148 }
2149 }
2150 }
2151
2152 if args.no_builtin_tools {
2156 all.clear();
2157 }
2158
2159 if let Some(allow) = &args.tools {
2161 all.retain(|(name, _)| allow.iter().any(|a| a == name));
2162 }
2163 if let Some(deny) = &args.exclude_tools {
2165 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
2166 }
2167
2168 all.into_iter().map(|(_, t)| t).collect()
2169}
2170
2171fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
2175 filter_active_tool_names(
2176 tools.iter().map(|tool| tool.tool.schema().name.clone()),
2177 args,
2178 )
2179}
2180
2181pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
2184 if args.no_tools {
2185 return false;
2186 }
2187 if args
2188 .tools
2189 .as_ref()
2190 .is_some_and(|allow| !allow.iter().any(|value| value == name))
2191 {
2192 return false;
2193 }
2194 if args
2195 .exclude_tools
2196 .as_ref()
2197 .is_some_and(|deny| deny.iter().any(|value| value == name))
2198 {
2199 return false;
2200 }
2201 true
2202}
2203
2204pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
2205where
2206 I: IntoIterator<Item = String>,
2207{
2208 names
2209 .into_iter()
2210 .filter(|name| tool_name_allowed(name, args))
2211 .collect()
2212}
2213
2214async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2216 match selection {
2217 SessionSelection::Ephemeral => Ok(ephemeral_session()),
2218 SessionSelection::New { dir, .. } => {
2219 std::fs::create_dir_all(dir)
2222 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2223 let session = create_jsonl_session(dir, cwd)
2224 .await
2225 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2226 Ok(session)
2227 }
2228 SessionSelection::Latest { .. }
2229 | SessionSelection::ById { .. }
2230 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2231 SessionSelection::Fork { source, dir } => fork_session_at_launch(source, dir, cwd).await,
2232 }
2233}
2234
2235async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2241 let Some(meta) = resolve_restore_target(selection, cwd).await? else {
2245 return match selection {
2246 SessionSelection::ByExactId { id, dir } => {
2249 std::fs::create_dir_all(dir)
2250 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2251 create_jsonl_session_with_id(dir, cwd, Some(id.clone()))
2252 .await
2253 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2254 }
2255 SessionSelection::Latest { dir } => Err(BuildError::SessionNotFound {
2256 requested: "the most recent session".to_string(),
2257 dir: dir.display().to_string(),
2258 }),
2259 SessionSelection::ById { id, dir } => Err(BuildError::SessionNotFound {
2260 requested: format!("session {id}"),
2261 dir: dir.display().to_string(),
2262 }),
2263 _ => unreachable!("resolve_restore_target only returns None for restore selections"),
2264 };
2265 };
2266 let sessions_root = selection_dir(selection);
2267 open_session(&meta, cwd, &sessions_root)
2268 .await
2269 .map(|restored| restored.session)
2270}
2271
2272fn selection_dir(selection: &SessionSelection) -> PathBuf {
2274 match selection {
2275 SessionSelection::Ephemeral => PathBuf::new(),
2276 SessionSelection::New { dir, .. }
2277 | SessionSelection::Latest { dir }
2278 | SessionSelection::ById { dir, .. }
2279 | SessionSelection::ByExactId { dir, .. }
2280 | SessionSelection::Fork { dir, .. } => dir.clone(),
2281 }
2282}
2283
2284async fn resolve_restore_target(
2291 selection: &SessionSelection,
2292 cwd: &str,
2293) -> Result<Option<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2294 match selection {
2295 SessionSelection::Latest { dir } => {
2296 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2297 Ok(first_resumable_session(&metas).cloned())
2298 }
2299 SessionSelection::ById { id, dir } => find_session_metadata(id, cwd, dir).await,
2300 SessionSelection::ByExactId { id, dir } => {
2301 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2302 Ok(metas.into_iter().find(|m| m.id == *id))
2303 }
2304 SessionSelection::Fork { .. }
2305 | SessionSelection::New { .. }
2306 | SessionSelection::Ephemeral => Ok(None),
2307 }
2308}
2309
2310async fn find_session_metadata(
2314 id: &str,
2315 cwd: &str,
2316 dir: &Path,
2317) -> Result<Option<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2318 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2319 Ok(metas
2320 .into_iter()
2321 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id)))
2322}
2323
2324pub(crate) struct RestoredSessionCwd {
2326 pub id: String,
2332 pub recorded: PathBuf,
2334 pub adopted: Option<PathBuf>,
2337}
2338
2339pub(crate) async fn peek_restored_session_cwd(
2347 selection: &SessionSelection,
2348 launch_cwd: &Path,
2349) -> Option<RestoredSessionCwd> {
2350 let cwd_str = launch_cwd.to_string_lossy().into_owned();
2351 let meta = resolve_restore_target(selection, &cwd_str).await.ok()??;
2352 let recorded = PathBuf::from(&meta.cwd);
2353 if recorded.as_os_str().is_empty() {
2354 return None;
2355 }
2356 Some(RestoredSessionCwd {
2357 id: meta.id,
2358 adopted: crate::session_cwd::resolve_session_cwd(&recorded),
2359 recorded,
2360 })
2361}
2362
2363pub(crate) fn pin_restored_selection(args: &mut Args, restored: &RestoredSessionCwd) {
2373 args.continue_session = false;
2374 args.resume = false;
2375 args.session = Some(restored.id.clone());
2376}
2377
2378async fn fork_session_at_launch(
2381 source: &str,
2382 dir: &Path,
2383 cwd: &str,
2384) -> Result<Session, BuildError> {
2385 use rpi_harness::session::jsonl::{
2386 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2387 };
2388 use rpi_harness::session::types::{ForkOptions, SessionStorage};
2389 use rpi_tools::FileSystem;
2390
2391 let dir = dir.to_path_buf();
2392 std::fs::create_dir_all(&dir)
2393 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2394 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2395 let fs: Arc<dyn FileSystem> = env.clone();
2396 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2397 fs: fs.clone(),
2398 sessions_root: dir.to_string_lossy().into_owned(),
2399 clock: Arc::new(SystemClock),
2400 ids: Arc::new(DefaultIdGenerator::new()),
2401 });
2402 let source_meta = match session_metadata_at(Path::new(source)) {
2406 Some(meta) => meta,
2407 None => {
2408 let metas = repo
2409 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2410 .await
2411 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2412 metas
2413 .into_iter()
2414 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2415 .ok_or_else(|| BuildError::SessionNotFound {
2416 requested: format!("--fork {source}"),
2417 dir: dir.display().to_string(),
2418 })?
2419 }
2420 };
2421 let fork_storage = repo
2422 .fork_typed(
2423 &source_meta,
2424 &JsonlSessionCreateOptions {
2425 id: None,
2426 parent_session_id: Some(source_meta.id.clone()),
2427 cwd: cwd.to_string(),
2428 metadata: None,
2429 },
2430 &ForkOptions::default(),
2431 )
2432 .await
2433 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2434 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
2435 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2436 Ok(Session::new(storage_arc, None))
2437}
2438
2439fn session_metadata_at(path: &Path) -> Option<rpi_harness::session::jsonl::JsonlSessionMetadata> {
2442 use rpi_harness::session::jsonl::codec::{metadata_from_header, parse_header};
2443 use std::io::BufRead;
2444
2445 let file = std::fs::File::open(path).ok()?;
2446 let modified_at = file
2447 .metadata()
2448 .ok()?
2449 .modified()
2450 .ok()?
2451 .duration_since(std::time::UNIX_EPOCH)
2452 .ok()?
2453 .as_millis() as i64;
2454 let mut first_line = String::new();
2455 std::io::BufReader::new(file)
2456 .read_line(&mut first_line)
2457 .ok()?;
2458 let header = parse_header(first_line.trim_end()).ok()?;
2459 let path = std::fs::canonicalize(path).ok()?;
2460 Some(metadata_from_header(
2461 &header,
2462 &path.to_string_lossy(),
2463 modified_at,
2464 ))
2465}
2466
2467pub enum OpenError {
2470 NotFound { requested: String },
2472 Other(String),
2474}
2475
2476impl std::fmt::Display for OpenError {
2477 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2478 match self {
2479 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2480 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
2485 }
2486 }
2487}
2488
2489pub async fn list_session_metadata(
2492 cwd: &str,
2493) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2494 let dir = default_session_dir(Path::new(cwd));
2495 list_session_metadata_in_dir(cwd, &dir).await
2496}
2497
2498pub(crate) async fn list_session_metadata_in_dir(
2499 cwd: &str,
2500 dir: &Path,
2501) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2502 use rpi_harness::session::jsonl::{
2503 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2504 };
2505 use rpi_tools::FileSystem;
2506
2507 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2508 let fs: Arc<dyn FileSystem> = env.clone();
2509 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2510 fs: fs.clone(),
2511 sessions_root: dir.to_string_lossy().into_owned(),
2512 clock: Arc::new(SystemClock),
2513 ids: Arc::new(DefaultIdGenerator::new()),
2514 });
2515 repo.list_typed(&JsonlSessionListOptions::default())
2516 .await
2517 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2518}
2519
2520#[derive(Debug, Clone, Default, PartialEq, Eq)]
2536pub struct SessionSummary {
2537 pub name: Option<String>,
2539 pub preview: Option<String>,
2541 pub empty: bool,
2543 pub bytes: u64,
2545}
2546
2547const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
2549const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
2551const SUMMARY_PREVIEW_CHARS: usize = 80;
2553
2554pub fn summarize_session_file(path: &Path) -> SessionSummary {
2558 use std::io::{Read, Seek, SeekFrom};
2559
2560 let Ok(mut file) = std::fs::File::open(path) else {
2561 return SessionSummary::default();
2562 };
2563 let Ok(size) = file.metadata().map(|m| m.len()) else {
2564 return SessionSummary::default();
2565 };
2566 if size == 0 {
2567 return SessionSummary {
2568 name: None,
2569 preview: None,
2570 empty: true,
2571 bytes: 0,
2572 };
2573 }
2574
2575 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2578 let head_len = file.read(&mut head).unwrap_or(0);
2579 head.truncate(head_len);
2580 let head = String::from_utf8_lossy(&head);
2581 let mut lines: Vec<&str> = head.lines().collect();
2582 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2583 let empty = (size as usize) <= header_len + 1;
2585 if (head_len as u64) < size && !lines.is_empty() {
2588 lines.pop();
2589 }
2590
2591 let mut summary = SessionSummary {
2592 name: None,
2593 preview: None,
2594 empty,
2595 bytes: size,
2596 };
2597 for line in lines {
2598 apply_summary_line(line, &mut summary);
2599 }
2600
2601 if size > SUMMARY_HEAD_BYTES
2605 && file
2606 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2607 .is_ok()
2608 {
2609 let mut tail = Vec::new();
2610 if file.read_to_end(&mut tail).is_ok() {
2611 let tail = String::from_utf8_lossy(&tail);
2612 let mut tail_lines = tail.lines();
2613 tail_lines.next();
2615 for line in tail_lines {
2616 if let Some(name) = name_from_line(line) {
2617 summary.name = name;
2618 }
2619 }
2620 }
2621 }
2622 summary
2623}
2624
2625fn name_from_line(line: &str) -> Option<Option<String>> {
2628 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2629 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2630 return None;
2631 }
2632 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2633 return None;
2634 }
2635 Some(match value.get("name") {
2636 Some(serde_json::Value::String(name)) => Some(name.clone()),
2637 _ => None,
2638 })
2639}
2640
2641fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2644 if let Some(name) = name_from_line(line) {
2645 summary.name = name;
2646 return;
2647 }
2648 if summary.preview.is_some() {
2649 return;
2650 }
2651 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2652 return;
2653 };
2654 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2655 return;
2656 }
2657 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2658 return;
2659 }
2660 let Some(message) = value.get("message") else {
2661 return;
2662 };
2663 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2664 return;
2665 }
2666 let text = match message.get("content") {
2667 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2668 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2669 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2670 .then(|| block.get("text").and_then(|v| v.as_str()))
2671 .flatten()
2672 }),
2673 _ => None,
2674 };
2675 let Some(text) = text else { return };
2676 let collapsed = text
2677 .lines()
2678 .map(str::trim)
2679 .filter(|line| !line.is_empty())
2680 .collect::<Vec<_>>()
2681 .join(" ");
2682 if collapsed.is_empty() {
2683 return;
2684 }
2685 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2686}
2687
2688pub fn session_display_label(summary: &SessionSummary) -> String {
2692 if let Some(name) = summary.name.as_deref().map(str::trim) {
2693 if !name.is_empty() {
2694 return name.to_string();
2695 }
2696 }
2697 match summary.preview.as_deref().map(str::trim) {
2698 Some(preview) if !preview.is_empty() => preview.to_string(),
2699 _ => "(no messages)".to_string(),
2700 }
2701}
2702
2703pub fn format_session_bytes(bytes: u64) -> String {
2706 if bytes >= 1_048_576 {
2707 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2708 } else if bytes >= 1024 {
2709 format!("{}KB", bytes / 1024)
2710 } else {
2711 format!("{bytes}B")
2712 }
2713}
2714
2715pub fn short_session_id(id: &str) -> &str {
2718 &id[..id.len().min(8)]
2719}
2720
2721pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2730 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2731 let mut visit = |path: PathBuf| {
2732 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2733 return;
2734 }
2735 let modified = std::fs::metadata(&path)
2736 .and_then(|m| m.modified())
2737 .unwrap_or(std::time::UNIX_EPOCH);
2738 files.push((path, modified));
2739 };
2740 if let Ok(entries) = std::fs::read_dir(dir) {
2741 for entry in entries.flatten() {
2742 let path = entry.path();
2743 if path.is_dir() {
2744 if let Ok(inner) = std::fs::read_dir(&path) {
2745 for child in inner.flatten() {
2746 visit(child.path());
2747 }
2748 }
2749 } else {
2750 visit(path);
2751 }
2752 }
2753 }
2754 files.sort_by(|a, b| b.1.cmp(&a.1));
2755 files.into_iter().map(|(path, _)| path).collect()
2756}
2757
2758fn first_resumable_session(
2763 metas: &[rpi_harness::session::jsonl::JsonlSessionMetadata],
2764) -> Option<&rpi_harness::session::jsonl::JsonlSessionMetadata> {
2765 metas
2766 .iter()
2767 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2768 .or_else(|| metas.first())
2769}
2770
2771pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2775 let dir = default_session_dir(Path::new(cwd));
2776 open_session_by_id_in_dir(id, cwd, &dir)
2777 .await
2778 .map(|restored| restored.session)
2779}
2780
2781pub(crate) async fn open_session_by_id_in_dir(
2782 id: &str,
2783 cwd: &str,
2784 dir: &Path,
2785) -> Result<RestoredSession, OpenError> {
2786 let meta = find_session_metadata(id, cwd, dir)
2787 .await
2788 .map_err(|e| OpenError::Other(e.to_string()))?;
2789 let Some(meta) = meta else {
2790 return Err(OpenError::NotFound {
2791 requested: format!("session {id}"),
2792 });
2793 };
2794 open_session(&meta, cwd, dir).await.map_err(|e| {
2795 OpenError::Other(match e {
2796 BuildError::SessionOpen(reason) => reason,
2800 other => other.to_string(),
2801 })
2802 })
2803}
2804
2805fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
2830 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
2831 tool_context.set_session_id(id);
2832}
2833
2834pub(crate) async fn set_active_session(
2845 harness: &AgentHarness,
2846 session: Session,
2847 tool_context: Option<&rpi_extensions::ToolCallContext>,
2848) -> Result<(), String> {
2849 let metadata = session.get_metadata().await.ok();
2850 harness
2851 .set_session(session)
2852 .await
2853 .map_err(|e| e.to_string())?;
2854 if let Some(metadata) = metadata {
2855 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2856 if let Some(context) = tool_context {
2857 context.set_session_id(metadata.id);
2858 }
2859 }
2860 Ok(())
2861}
2862
2863pub(crate) async fn fork_session_storage(
2870 harness: &AgentHarness,
2871 cwd: &str,
2872 dir: &Path,
2873) -> Result<Session, String> {
2874 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2875 use rpi_tools::FileSystem;
2876
2877 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2878 let fs: Arc<dyn FileSystem> = env.clone();
2879 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2880 fs,
2881 sessions_root: dir.to_string_lossy().into_owned(),
2882 clock: Arc::new(SystemClock),
2883 ids: Arc::new(DefaultIdGenerator::new()),
2884 });
2885 let id = harness.session().storage().metadata().id.clone();
2888 let metas = list_session_metadata_in_dir(cwd, dir)
2889 .await
2890 .map_err(|e| e.to_string())?;
2891 let Some(source) = metas.iter().find(|m| m.id == id) else {
2892 return Err(format!("current session {id} not found on disk"));
2893 };
2894 let fork_storage = repo
2895 .fork_typed(
2896 source,
2897 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2898 id: None,
2899 parent_session_id: Some(source.id.clone()),
2900 cwd: cwd.to_string(),
2901 metadata: None,
2902 },
2903 &rpi_harness::session::types::ForkOptions::default(),
2904 )
2905 .await
2906 .map_err(|e| e.to_string())?;
2907 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2908 Ok(Session::new(Arc::new(fork_storage), None))
2909}
2910
2911pub(crate) async fn import_session_file(
2915 source: &Path,
2916 cwd: &str,
2917 dir: &Path,
2918) -> Result<Session, String> {
2919 use rpi_harness::session::jsonl::{
2920 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions, JsonlSessionStorage,
2921 };
2922 use rpi_tools::FileSystem;
2923
2924 let dir = if dir.is_absolute() {
2925 dir.to_path_buf()
2926 } else {
2927 Path::new(cwd).join(dir)
2928 };
2929 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
2930 let staged = tempfile::NamedTempFile::new_in(&dir)
2931 .map_err(|e| e.to_string())?
2932 .into_temp_path();
2933 std::fs::copy(source, &staged).map_err(|e| format!("copy import: {e}"))?;
2934 let fs: Arc<dyn FileSystem> = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2935 let validated = JsonlSessionStorage::load(
2936 fs.clone(),
2937 &staged.to_string_lossy(),
2938 Arc::new(SystemClock),
2939 Arc::new(DefaultIdGenerator::new()),
2940 )
2941 .await
2942 .map_err(|e| format!("invalid session import: {e}"))?;
2943 let mut metadata = validated.jsonl_metadata().clone();
2944 drop(validated);
2945 if list_session_metadata_in_dir(cwd, &dir)
2946 .await
2947 .map_err(|e| e.to_string())?
2948 .iter()
2949 .any(|saved| saved.id == metadata.id)
2950 {
2951 return Err(format!(
2952 "session {} already exists in {}",
2953 metadata.id,
2954 dir.display()
2955 ));
2956 }
2957 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2958 fs,
2959 sessions_root: dir.to_string_lossy().into_owned(),
2960 clock: Arc::new(SystemClock),
2961 ids: Arc::new(DefaultIdGenerator::new()),
2962 });
2963 let destination = repo
2965 .create_typed(&JsonlSessionCreateOptions {
2966 id: Some(metadata.id.clone()),
2967 parent_session_id: metadata.parent_session_id.clone(),
2968 cwd: cwd.to_string(),
2969 metadata: metadata.metadata.clone(),
2970 })
2971 .await
2972 .map_err(|e| e.to_string())?;
2973 let path = destination.jsonl_metadata().path.clone();
2974 drop(destination);
2975 if let Err(error) = claim_session_lock(&path) {
2976 return Err(error);
2978 }
2979 if let Err(error) = staged.persist(&path) {
2980 let _ = std::fs::remove_file(&path);
2981 return Err(format!("publish import: {error}"));
2982 }
2983 metadata.path = path;
2984 let storage = repo
2985 .open_by_jsonl_metadata(&metadata)
2986 .await
2987 .map_err(|e| e.to_string())?;
2988 Ok(Session::new(Arc::new(storage), None))
2989}
2990
2991static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2993static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2996
2997fn claim_session_lock(session_path: &str) -> Result<(), String> {
3006 let lock_path = PathBuf::from(format!("{session_path}.lock"));
3007 if SESSION_LOCK_PATHS
3008 .get_or_init(|| Mutex::new(HashSet::new()))
3009 .lock()
3010 .unwrap()
3011 .contains(&lock_path)
3012 {
3013 return Ok(());
3014 }
3015 let file = std::fs::OpenOptions::new()
3016 .create(true)
3017 .write(true)
3018 .open(&lock_path)
3019 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
3020 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
3021 return Err(format!(
3022 "session {session_path} is already open in another rpi process"
3023 ));
3024 }
3025 SESSION_LOCKS
3026 .get_or_init(|| Mutex::new(Vec::new()))
3027 .lock()
3028 .unwrap()
3029 .push(file);
3030 SESSION_LOCK_PATHS
3031 .get_or_init(|| Mutex::new(HashSet::new()))
3032 .lock()
3033 .unwrap()
3034 .insert(lock_path);
3035 Ok(())
3036}
3037
3038pub(crate) struct RestoredSession {
3047 pub session: Session,
3048 pub recorded_cwd: Option<PathBuf>,
3051}
3052
3053async fn open_session(
3060 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
3061 cwd: &str,
3062 sessions_root: &Path,
3063) -> Result<RestoredSession, BuildError> {
3064 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3065 use rpi_harness::session::types::SessionStorage;
3066 use rpi_tools::FileSystem;
3067
3068 let dir = sessions_root;
3069 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3070 let fs: Arc<dyn FileSystem> = env.clone();
3071 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3072 fs: fs.clone(),
3073 sessions_root: dir.to_string_lossy().into_owned(),
3074 clock: Arc::new(SystemClock),
3075 ids: Arc::new(DefaultIdGenerator::new()),
3076 });
3077 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
3078 let storage = repo
3079 .open_by_jsonl_metadata(meta)
3080 .await
3081 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
3082 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
3083 Ok(RestoredSession {
3084 session: Session::new(storage_arc, None),
3085 recorded_cwd: crate::session_cwd::resolve_session_cwd(Path::new(&meta.cwd)),
3086 })
3087}
3088
3089pub(crate) fn ephemeral_session() -> Session {
3093 let storage = Arc::new(InMemorySessionStorage::new(
3094 SessionMetadata {
3095 id: "ephemeral".into(),
3096 created_at: 0,
3097 parent_session_id: None,
3098 },
3099 Arc::new(SystemClock),
3100 Arc::new(DefaultIdGenerator::new()),
3101 ));
3102 Session::new(storage, None).with_volatile_progress()
3103}
3104
3105pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
3116 create_jsonl_session_with_id(dir, cwd, None).await
3117}
3118
3119pub(crate) async fn create_jsonl_session_with_id(
3123 dir: &Path,
3124 cwd: &str,
3125 id: Option<String>,
3126) -> Result<Session, String> {
3127 use rpi_harness::session::jsonl::{
3128 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
3129 };
3130 use rpi_tools::FileSystem;
3131
3132 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3135 let fs: Arc<dyn FileSystem> = env.clone();
3136
3137 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3138 fs: fs.clone(),
3139 sessions_root: dir.to_string_lossy().into_owned(),
3140 clock: Arc::new(SystemClock),
3141 ids: Arc::new(DefaultIdGenerator::new()),
3142 });
3143
3144 let opts = JsonlSessionCreateOptions {
3145 id, parent_session_id: None,
3147 cwd: cwd.to_string(),
3148 metadata: None,
3149 };
3150 let storage = repo
3151 .create_typed(&opts)
3152 .await
3153 .map_err(|e| format!("create session: {e}"))?;
3154 claim_session_lock(&storage.jsonl_metadata().path)
3155 .map_err(|e| format!("create session: {e}"))?;
3156 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
3158 Ok(Session::new(storage_arc, None))
3159}
3160
3161fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
3164 let path = Path::new(target);
3165 if path.is_file() {
3166 read_prompt_source(path, sources)
3167 } else {
3168 None
3169 }
3170}
3171
3172fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
3175 let text = std::fs::read_to_string(path).ok()?;
3176 let absolute = if path.is_absolute() {
3177 path.to_path_buf()
3178 } else {
3179 std::env::current_dir()
3180 .map(|cwd| cwd.join(path))
3181 .unwrap_or_else(|_| path.to_path_buf())
3182 };
3183 let source = absolute.to_string_lossy().into_owned();
3184 if !sources.contains(&source) {
3185 sources.push(source);
3186 }
3187 Some(text)
3188}
3189
3190#[cfg(test)]
3191mod runtime_session_tests;
3192
3193#[cfg(test)]
3194mod tests {
3195 use super::*;
3196
3197 #[tokio::test]
3198 async fn unauthenticated_packages_are_not_reintroduced_as_legacy_adapters() {
3199 extern "C" fn request(
3200 _: rpi_plugin_sdk::StbStringRef,
3201 _: *mut rpi_plugin_sdk::StbString,
3202 _: *mut std::ffi::c_void,
3203 ) -> i32 {
3204 1
3205 }
3206 let mut registry = rpi_extensions::ExtensionRegistry::new();
3207 for id in ["package", "legacy"] {
3208 registry.register_provider(rpi_extensions::RegisteredProvider {
3209 provider_id: id.into(),
3210 base_url: "http://localhost".into(),
3211 api_style: "openai-completions".into(),
3212 request_fn: request,
3213 stream_fn: None,
3214 plugin_free_string: rpi_plugin_sdk::callbacks::free_string,
3215 user_data: std::ptr::null_mut(),
3216 });
3217 }
3218 assert!(registry.apply_declaration_json(&serde_json::json!({"providers":[{
3219 "id":"package", "requiresAuth":true,
3220 "models":[rpi_ai::Model::new("model", "Model", rpi_ai::Api::OpenaiCompletions, "package", "http://localhost")]
3221 }]}).to_string()));
3222 let session = ExtensionSession::from_parts(
3223 Arc::new(registry.snapshot()),
3224 rpi_extensions::PluginKeepalive::empty(),
3225 vec![],
3226 None,
3227 );
3228 let providers = build_models_with_extensions_for_reload(
3229 &[],
3230 &session,
3231 tokio::runtime::Handle::current(),
3232 );
3233 assert_eq!(
3234 providers
3235 .iter()
3236 .map(|provider| provider.id())
3237 .collect::<Vec<_>>(),
3238 ["legacy"]
3239 );
3240 }
3241 use crate::args::Args;
3242
3243 #[test]
3244 fn loaded_context_paths_track_precedence_and_reload() {
3245 const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
3246 if std::env::var_os(CHILD).is_none() {
3247 let dir = tempfile::tempdir().unwrap();
3248 let output = std::process::Command::new(std::env::current_exe().unwrap())
3249 .args([
3250 "--exact",
3251 "session::tests::loaded_context_paths_track_precedence_and_reload",
3252 "--nocapture",
3253 ])
3254 .env(CHILD, "1")
3255 .env(crate::config::CONFIG_DIR_ENV, dir.path())
3256 .output()
3257 .unwrap();
3258 assert!(
3259 output.status.success(),
3260 "{}\n{}",
3261 String::from_utf8_lossy(&output.stdout),
3262 String::from_utf8_lossy(&output.stderr)
3263 );
3264 return;
3265 }
3266 tokio::runtime::Builder::new_current_thread()
3267 .enable_all()
3268 .build()
3269 .unwrap()
3270 .block_on(async {
3271 let agent = crate::config::agent_dir().unwrap();
3272 let cwd = agent.join("project");
3273 let project_config = cwd.join(".rpi");
3274 std::fs::create_dir_all(&project_config).unwrap();
3275 for (path, content) in [
3276 (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
3277 (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
3278 (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
3279 (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
3280 (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
3281 (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
3282 (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
3283 ] {
3284 std::fs::write(path, content).unwrap();
3285 }
3286 let resolved = crate::provider::resolve(
3287 Some("anthropic"),
3288 Some(crate::provider::DEFAULT_MODEL_ID),
3289 None,
3290 Some("test-key"),
3291 None,
3292 )
3293 .unwrap();
3294 let args = Args {
3295 no_session: true,
3296 no_extensions: true,
3297 no_skills: true,
3298 no_prompt_templates: true,
3299 trust_override: Some(true),
3300 ..Default::default()
3301 };
3302 let (harness, _events, mut context) =
3303 build(&resolved, &args, &cwd, true).await.unwrap();
3304 let resources = harness.get_resources().await.unwrap();
3305 let path = |p: PathBuf| p.to_string_lossy().into_owned();
3306 assert_eq!(
3307 &resources.context_files[..2],
3308 &[
3309 path(project_config.join("SYSTEM.md")),
3310 path(project_config.join("APPEND_SYSTEM.md")),
3311 ]
3312 );
3313 assert!(resources
3314 .context_files
3315 .contains(&path(agent.join("AGENTS.md"))));
3316 assert!(resources
3317 .context_files
3318 .contains(&path(cwd.join("AGENTS.md"))));
3319 assert!(!resources
3320 .context_files
3321 .contains(&path(cwd.join("CLAUDE.md"))));
3322 assert!(!resources
3323 .context_files
3324 .contains(&path(agent.join("SYSTEM.md"))));
3325 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3326 assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
3327 assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
3328
3329 std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
3332 std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
3333 std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
3334 assert!(
3335 !reload_extension_resources(&harness, &context)
3336 .await
3337 .had_errors
3338 );
3339 let resources = harness.get_resources().await.unwrap();
3340 assert_eq!(
3341 &resources.context_files[..2],
3342 &[
3343 path(agent.join("SYSTEM.md")),
3344 path(agent.join("APPEND_SYSTEM.md")),
3345 ]
3346 );
3347 assert!(resources
3348 .context_files
3349 .contains(&path(cwd.join("CLAUDE.md"))));
3350 assert!(!resources
3351 .context_files
3352 .contains(&path(project_config.join("SYSTEM.md"))));
3353
3354 let explicit = agent.join("custom-append.md");
3357 std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
3358 context.args.system_prompt = Some("EXPLICIT_BASE".into());
3359 context.args.append_system_prompt =
3360 vec![path(explicit.clone()), "literal append".into()];
3361 context.args.no_context_files = true;
3362 assert!(
3363 !reload_extension_resources(&harness, &context)
3364 .await
3365 .had_errors
3366 );
3367 assert_eq!(
3368 harness.get_resources().await.unwrap().context_files,
3369 vec![path(explicit)]
3370 );
3371
3372 context.args.system_prompt = None;
3374 context.args.append_system_prompt.clear();
3375 context.args.no_context_files = false;
3376 context.args.trust_override = Some(false);
3377 assert!(
3378 !reload_extension_resources(&harness, &context)
3379 .await
3380 .had_errors
3381 );
3382 assert!(harness
3383 .get_resources()
3384 .await
3385 .unwrap()
3386 .context_files
3387 .is_empty());
3388 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3389 assert!(
3390 !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
3391 );
3392 });
3393 }
3394
3395 #[test]
3396 fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
3397 let dir = tempfile::tempdir().unwrap();
3398 let file = dir.path().join("SYSTEM.md");
3399 let mut sources = Vec::new();
3400 assert!(read_prompt_source(&file, &mut sources).is_none());
3401 std::fs::write(&file, [0xff, 0xfe]).unwrap();
3402 assert!(read_prompt_source(&file, &mut sources).is_none());
3403 assert!(read_append_target("literal instructions", &mut sources).is_none());
3404 assert!(sources.is_empty());
3405 std::fs::write(&file, "loaded instructions").unwrap();
3406 assert_eq!(
3407 read_prompt_source(&file, &mut sources).as_deref(),
3408 Some("loaded instructions")
3409 );
3410 assert!(read_prompt_source(&file, &mut sources).is_some());
3411 assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
3412 }
3413
3414 extern "C" fn deny_test_tool_call(
3415 _event: rpi_plugin_sdk::StablePluginEvent,
3416 _data: *mut std::ffi::c_void,
3417 ) -> i32 {
3418 rpi_plugin_sdk::EVENT_HANDLER_ABORT
3419 }
3420
3421 async fn invoke_test_tool_hook(
3422 hook: &rpi_agent::BeforeToolCall,
3423 ) -> Option<rpi_agent::BeforeToolCallResult> {
3424 let message = rpi_ai::providers::faux::faux_assistant_message(
3425 "approval",
3426 rpi_ai::types::StopReason::ToolUse,
3427 );
3428 let rpi_ai::types::Content::ToolCall(call) =
3429 rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
3430 else {
3431 unreachable!()
3432 };
3433 hook(
3434 rpi_agent::BeforeToolCallContext {
3435 assistant_message: &message,
3436 tool_call: &call,
3437 args: &call.arguments,
3438 context: &rpi_agent::AgentContext::default(),
3439 },
3440 tokio_util::sync::CancellationToken::new(),
3441 )
3442 .await
3443 }
3444
3445 #[tokio::test]
3446 async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
3447 let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
3448 let hook =
3449 extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
3450 assert!(invoke_test_tool_hook(&hook).await.is_none());
3451 for name in ["first-gate", "reloaded-gate"] {
3452 let mut registry = rpi_extensions::ExtensionRegistry::new();
3453 registry.register_event_handler(
3454 name.into(),
3455 rpi_plugin_sdk::EventTag::ToolCall,
3456 deny_test_tool_call,
3457 std::ptr::null_mut(),
3458 );
3459 let fresh = ExtensionSession::from_parts(
3460 Arc::new(registry.snapshot()),
3461 rpi_extensions::PluginKeepalive::empty(),
3462 Vec::new(),
3463 None,
3464 );
3465 {
3466 let mut current = sessions.lock().unwrap();
3467 if let Some(snapshot) = current.snapshot() {
3468 snapshot.active_flag().store(false, Ordering::SeqCst);
3469 }
3470 *current = fresh;
3471 }
3472 let result = invoke_test_tool_hook(&hook).await.unwrap();
3473 assert!(result.block);
3474 assert!(result.reason.unwrap().contains(name));
3475 }
3476 *sessions.lock().unwrap() = ExtensionSession::none();
3477 assert!(invoke_test_tool_hook(&hook).await.is_none());
3478 }
3479
3480 fn scratch(name: &str) -> PathBuf {
3482 let nanos = std::time::SystemTime::now()
3483 .duration_since(std::time::UNIX_EPOCH)
3484 .unwrap()
3485 .as_nanos();
3486 let path = std::env::temp_dir().join(format!(
3487 "rpi-summary-{}-{nanos}-{name}.jsonl",
3488 std::process::id()
3489 ));
3490 path
3491 }
3492
3493 #[test]
3494 fn summary_marks_a_header_only_session_empty() {
3495 let path = scratch("empty");
3496 std::fs::write(
3497 &path,
3498 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3499 )
3500 .unwrap();
3501 let summary = summarize_session_file(&path);
3502 assert!(summary.empty, "a header-only file has nothing to resume");
3503 assert!(summary.preview.is_none());
3504 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
3505 std::fs::remove_file(&path).ok();
3506 }
3507
3508 #[test]
3509 fn summary_reads_the_first_user_prompt_and_name() {
3510 let path = scratch("content");
3511 let body = concat!(
3512 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3513 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
3514 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
3515 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
3516 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
3517 );
3518 std::fs::write(&path, body).unwrap();
3519 let summary = summarize_session_file(&path);
3520 assert!(!summary.empty);
3521 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
3522 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
3523 std::fs::remove_file(&path).ok();
3524 }
3525
3526 #[test]
3527 fn summary_extracts_a_block_style_user_prompt() {
3528 let path = scratch("blocks");
3529 let body = concat!(
3530 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3531 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
3532 );
3533 std::fs::write(&path, body).unwrap();
3534 let summary = summarize_session_file(&path);
3535 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
3536 assert_eq!(session_display_label(&summary), "line one line two");
3537 std::fs::remove_file(&path).ok();
3538 }
3539
3540 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
3541 rpi_harness::session::jsonl::JsonlSessionMetadata {
3542 id: id.to_string(),
3543 created_at: 0,
3544 cwd: ".".to_string(),
3545 path: path.to_string_lossy().into_owned(),
3546 modified_at: 0,
3547 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
3548 parent_session_id: None,
3549 legacy_parent_session_path: None,
3550 metadata: None,
3551 }
3552 }
3553
3554 #[test]
3555 fn continue_skips_newer_empty_sessions() {
3556 let empty = scratch("newest-empty");
3557 std::fs::write(
3558 &empty,
3559 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3560 )
3561 .unwrap();
3562 let content = scratch("older-content");
3563 std::fs::write(
3564 &content,
3565 concat!(
3566 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3567 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
3568 ),
3569 )
3570 .unwrap();
3571
3572 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
3573 let picked = first_resumable_session(&metas).expect("a resumable session");
3574 assert_eq!(picked.id, "old");
3575
3576 let all_empty = vec![meta_for(&empty, "empty")];
3579 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
3580
3581 std::fs::remove_file(&empty).ok();
3582 std::fs::remove_file(&content).ok();
3583 }
3584
3585 #[test]
3586 fn list_session_files_sync_walks_the_cwd_subdirectory() {
3587 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
3588 let nested = root.join("--proj--");
3589 std::fs::create_dir_all(&nested).unwrap();
3590 let file = nested.join("2026-01-01_a.jsonl");
3591 std::fs::write(&file, "{}\n").unwrap();
3592 let found = list_session_files_sync(&root);
3593 assert!(found.iter().any(|p| p == &file));
3594 std::fs::remove_dir_all(&root).ok();
3595 }
3596
3597 fn default_prompt_tools() -> Vec<String> {
3599 BUILTIN_TOOL_NAMES
3600 .iter()
3601 .copied()
3602 .chain(["todo"])
3603 .map(str::to_owned)
3604 .collect()
3605 }
3606
3607 #[test]
3608 fn default_prompt_lists_only_the_active_tools() {
3609 let tools = ["read", "grep", "find", "ls"].map(str::to_owned);
3610 let p = default_system_prompt_with_tools("/tmp/proj", true, &tools);
3611 assert!(p.contains("- read — Read file contents"));
3612 assert!(p.contains("- grep — Search file contents"));
3613 for absent in ["- edit", "- write", "- bash", "- docs", "todo", "Windows"] {
3614 assert!(
3615 !p.contains(absent),
3616 "{absent} must not appear for a read-only run: {p}"
3617 );
3618 }
3619 assert!(p.contains("Re-stating the same plan without acting on it is a bug"));
3620 let none = default_system_prompt_with_tools("/tmp/proj", false, &[]);
3621 assert!(none.contains("Available tools:\n(none)"));
3622 }
3623
3624 #[test]
3625 fn default_prompt_mentions_cwd_and_tools() {
3626 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
3627 assert!(p.contains("/tmp/proj"));
3628 assert!(p.contains("read"));
3629 assert!(p.contains("bash"));
3630 assert!(p.contains("edit"));
3631 assert!(p.contains("write"));
3632 assert!(p.contains("docs"));
3633 assert!(!p.contains("- grep"));
3634 assert!(!p.contains("- find"));
3635 assert!(!p.contains("- ls"));
3636 assert!(!p.contains("- powershell"));
3637 assert!(p.contains("Bash/POSIX syntax, also on Windows"));
3638 }
3639
3640 #[test]
3641 fn default_prompt_instructs_language_matching() {
3642 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
3645 assert!(
3646 p.contains("same language as the user's latest message"),
3647 "the prompt must tell the model to mirror the user's language: {p}"
3648 );
3649 }
3650
3651 #[test]
3652 fn default_prompt_keeps_working_state_in_the_visible_channel() {
3653 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
3661 let lower = p.to_lowercase();
3662 assert!(
3664 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
3665 "the prompt must explain that reasoning does not survive the turn: {p}"
3666 );
3667 assert!(
3669 lower.contains("visible") && lower.contains("todo tool"),
3670 "the prompt must point at visible text and the todo tool: {p}"
3671 );
3672 assert!(
3674 lower.contains("re-stating the same plan"),
3675 "the prompt must call out plan re-statement as a bug: {p}"
3676 );
3677 assert!(
3679 lower.contains("already shown earlier"),
3680 "the prompt must discourage re-fetching known content: {p}"
3681 );
3682 }
3683
3684 #[test]
3685 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
3686 let p = default_system_prompt_with_tools("/tmp/proj", true, &default_prompt_tools());
3693 let lower = p.to_lowercase();
3694 assert!(
3695 lower.contains("carried back"),
3696 "a replaying model must be told its reasoning survives: {p}"
3697 );
3698 assert!(
3699 !lower.contains("reasoning is not"),
3700 "the replayed variant must not claim reasoning is dropped: {p}"
3701 );
3702 assert!(
3703 lower.contains("do not re-derive"),
3704 "the replayed variant must forbid re-deriving the plan: {p}"
3705 );
3706 assert!(
3707 lower.contains("do not re-derive") && lower.contains("re-stating the same plan"),
3708 "the anti-repetition rule must survive the flip: {p}"
3709 );
3710 assert!(
3711 lower.contains("already shown earlier"),
3712 "the re-read rule must survive the flip: {p}"
3713 );
3714 }
3715
3716 #[test]
3717 fn default_prompt_states_each_rule_once() {
3718 for replayed in [false, true] {
3721 let p =
3722 default_system_prompt_with_tools("/tmp/proj", replayed, &default_prompt_tools());
3723 for phrase in ["the same plan", "todo tool", "Windows", "guessing"] {
3724 assert_eq!(p.matches(phrase).count(), 1, "{phrase:?} repeated: {p}");
3725 }
3726 }
3727 }
3728
3729 #[test]
3737 fn only_read_only_tools_are_replayable() {
3738 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3739 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3740 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3741 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3742 let tools = build_tools(&context, &Args::default());
3743
3744 let replay_of = |name: &str| {
3745 tools
3746 .iter()
3747 .find(|tool| tool.tool.schema().name == name)
3748 .map(|tool| tool.replay)
3749 };
3750
3751 for read_only in ["read", "docs"] {
3752 assert_eq!(
3753 replay_of(read_only),
3754 Some(ToolReplay::Safe),
3755 "{read_only} is side-effect free, so recovery may re-run it"
3756 );
3757 }
3758 for mutating in ["bash", "edit", "write", "powershell"] {
3759 if let Some(replay) = replay_of(mutating) {
3760 assert_eq!(
3761 replay,
3762 ToolReplay::Never,
3763 "{mutating} must never be replayed: re-running it would duplicate its effect"
3764 );
3765 }
3766 }
3767 }
3768
3769 #[test]
3770 fn default_tools_include_docs_lookup() {
3771 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3772 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3773 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3774 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3775 let names: Vec<String> = build_tools(&context, &Args::default())
3776 .iter()
3777 .map(|tool| tool.tool.schema().name.clone())
3778 .collect();
3779
3780 assert!(names.contains(&"docs".to_string()));
3785 for legacy in ["grep", "find", "ls"] {
3786 assert!(
3787 !names.contains(&legacy.to_string()),
3788 "legacy tool {legacy} should not be registered by default"
3789 );
3790 }
3791 }
3792
3793 #[test]
3794 fn reload_settings_preflight_respects_trust() {
3795 struct RestoreConfigDir(Option<std::ffi::OsString>);
3796
3797 impl Drop for RestoreConfigDir {
3798 fn drop(&mut self) {
3799 match self.0.take() {
3800 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3801 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3802 }
3803 }
3804 }
3805
3806 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3807 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3808 let tmp = tempfile::tempdir().unwrap();
3809 let agent = tmp.path().join("agent");
3810 let cwd = tmp.path().join("project");
3811 std::fs::create_dir_all(&agent).unwrap();
3812 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3813 std::fs::write(agent.join("settings.json"), "{}").unwrap();
3814 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
3815 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3816 let args = Args::default();
3817
3818 let trusted = validate_settings_for_reload(&args, &cwd, true);
3819 let untrusted = validate_settings_for_reload(&args, &cwd, false);
3820 assert!(trusted
3821 .unwrap_err()
3822 .contains("could not load project settings"));
3823 assert!(untrusted.is_ok());
3824
3825 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3826 assert!(validate_settings_for_reload(&args, &cwd, false)
3827 .unwrap_err()
3828 .contains("could not load global settings"));
3829
3830 let local_only = Args {
3831 dev_local_only: true,
3832 ..Args::default()
3833 };
3834 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
3835 }
3836
3837 #[tokio::test(flavor = "current_thread")]
3838 async fn reload_preserves_live_resources_when_settings_break() {
3839 struct RestoreConfigDir(Option<std::ffi::OsString>);
3840
3841 impl Drop for RestoreConfigDir {
3842 fn drop(&mut self) {
3843 match self.0.take() {
3844 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3845 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3846 }
3847 }
3848 }
3849
3850 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3851 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3852 let tmp = tempfile::tempdir().unwrap();
3853 let agent = tmp.path().join("agent");
3854 let cwd = tmp.path().join("project");
3855 let skill_dir = tmp.path().join("configured-skills");
3856 std::fs::create_dir_all(&agent).unwrap();
3857 std::fs::create_dir_all(&cwd).unwrap();
3858 std::fs::create_dir_all(&skill_dir).unwrap();
3859 std::fs::write(
3860 skill_dir.join("SKILL.md"),
3861 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
3862 )
3863 .unwrap();
3864 std::fs::write(
3865 agent.join("settings.json"),
3866 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3867 )
3868 .unwrap();
3869 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3870
3871 let resolved = crate::provider::resolve(
3872 Some("anthropic"),
3873 Some(crate::provider::DEFAULT_MODEL_ID),
3874 None,
3875 Some("test-key"),
3876 None,
3877 )
3878 .unwrap();
3879 let args = Args {
3880 trust_override: Some(false),
3881 no_session: true,
3882 no_extensions: true,
3883 no_prompt_templates: true,
3884 no_context_files: true,
3885 system_prompt: Some("stable system prompt".into()),
3886 ..Args::default()
3887 };
3888 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3889 let before_resources = harness.get_resources().await.unwrap();
3890 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
3891 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
3892 let before_prompt = harness.get_system_prompt().await.unwrap();
3893 let before_tools: Vec<String> = harness
3894 .get_tools()
3895 .await
3896 .unwrap()
3897 .iter()
3898 .map(|tool| tool.tool.schema().name.clone())
3899 .collect();
3900 let before_bridge = context
3901 .action_bridge
3902 .lock()
3903 .unwrap()
3904 .as_ref()
3905 .unwrap()
3906 .clone();
3907
3908 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3909 let outcome = reload_extension_resources(&harness, &context).await;
3910
3911 assert!(outcome.had_warnings);
3912 assert!(outcome.summary.contains("Settings reload failed"));
3913 assert!(outcome.summary.contains("could not load global settings"));
3914 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
3915 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
3916 let after_tools: Vec<String> = harness
3917 .get_tools()
3918 .await
3919 .unwrap()
3920 .iter()
3921 .map(|tool| tool.tool.schema().name.clone())
3922 .collect();
3923 assert_eq!(after_tools, before_tools);
3924 let after_bridge = context
3925 .action_bridge
3926 .lock()
3927 .unwrap()
3928 .as_ref()
3929 .unwrap()
3930 .clone();
3931 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
3932 }
3933
3934 #[test]
3935 fn reload_preparation_rejects_settings_changed_during_derivation() {
3936 struct RestoreConfigDir(Option<std::ffi::OsString>);
3937
3938 impl Drop for RestoreConfigDir {
3939 fn drop(&mut self) {
3940 match self.0.take() {
3941 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3942 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3943 }
3944 }
3945 }
3946
3947 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3948 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3949 let tmp = tempfile::tempdir().unwrap();
3950 let agent = tmp.path().join("agent");
3951 let cwd = tmp.path().join("project");
3952 let first_skill_dir = tmp.path().join("first-skills");
3953 let second_skill_dir = tmp.path().join("second-skills");
3954 std::fs::create_dir_all(&agent).unwrap();
3955 std::fs::create_dir_all(&cwd).unwrap();
3956 std::fs::write(
3957 agent.join("settings.json"),
3958 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3959 )
3960 .unwrap();
3961 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3962 let args = Args {
3963 trust_override: Some(false),
3964 no_extensions: true,
3965 no_prompt_templates: true,
3966 ..Args::default()
3967 };
3968
3969 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3970 std::fs::write(
3971 agent.join("settings.json"),
3972 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3973 )
3974 .unwrap();
3975 });
3976
3977 let error = result
3978 .err()
3979 .expect("settings mutation must fail preparation");
3980 assert!(error.contains("settings changed while reload inputs were being prepared"));
3981 }
3982
3983 #[tokio::test(flavor = "current_thread")]
3984 async fn reload_uses_frozen_settings_inputs_after_preparation() {
3985 struct RestoreConfigDir(Option<std::ffi::OsString>);
3986
3987 impl Drop for RestoreConfigDir {
3988 fn drop(&mut self) {
3989 match self.0.take() {
3990 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3991 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3992 }
3993 }
3994 }
3995
3996 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3997 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3998 let tmp = tempfile::tempdir().unwrap();
3999 let agent = tmp.path().join("agent");
4000 let cwd = tmp.path().join("project");
4001 let skill_dir = tmp.path().join("configured-skills");
4002 std::fs::create_dir_all(&agent).unwrap();
4003 std::fs::create_dir_all(&cwd).unwrap();
4004 std::fs::create_dir_all(&skill_dir).unwrap();
4005 std::fs::write(
4006 skill_dir.join("SKILL.md"),
4007 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
4008 )
4009 .unwrap();
4010 std::fs::write(
4011 agent.join("settings.json"),
4012 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
4013 )
4014 .unwrap();
4015 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4016
4017 let resolved = crate::provider::resolve(
4018 Some("anthropic"),
4019 Some(crate::provider::DEFAULT_MODEL_ID),
4020 None,
4021 Some("test-key"),
4022 None,
4023 )
4024 .unwrap();
4025 let args = Args {
4026 trust_override: Some(false),
4027 no_session: true,
4028 no_extensions: true,
4029 no_prompt_templates: true,
4030 no_context_files: true,
4031 system_prompt: Some("stable system prompt".into()),
4032 ..Args::default()
4033 };
4034 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
4035
4036 let outcome = reload_extension_resources_inner(&harness, &context, || {
4037 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
4038 })
4039 .await;
4040
4041 assert!(!outcome.summary.contains("Settings reload failed"));
4042 assert_eq!(
4043 outcome.summary,
4044 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
4045 );
4046 let resources = harness.get_resources().await.unwrap();
4047 let skills = resources.skills.as_ref().unwrap();
4048 assert_eq!(skills.len(), 1);
4049 assert_eq!(skills[0].name, "frozen-skill");
4050 }
4051
4052 #[tokio::test]
4053 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
4054 let tmp = tempfile::tempdir().unwrap();
4055 let skill_path = tmp.path().join("explicit-skill.md");
4056 std::fs::write(
4057 &skill_path,
4058 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
4059 )
4060 .unwrap();
4061 let prompt_path = tmp.path().join("explicit-prompt.md");
4062 std::fs::write(
4063 &prompt_path,
4064 "---\ndescription: Explicit prompt\n---\nPrompt body",
4065 )
4066 .unwrap();
4067
4068 let args = Args {
4069 skill: vec![skill_path.clone()],
4070 prompt_template: vec![prompt_path.clone()],
4071 ..Args::default()
4072 };
4073 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
4074 let prompt_paths =
4075 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
4076 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
4077 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
4078
4079 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
4080 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
4081 assert_eq!(skills.skills[0].name, "explicit-skill");
4082
4083 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
4084 assert_eq!(
4085 prompts.prompt_templates.len(),
4086 1,
4087 "{:?}",
4088 prompts.diagnostics
4089 );
4090 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
4091 }
4092
4093 #[test]
4094 fn tool_policy_applies_to_rust_and_js_active_names() {
4095 let names = vec![
4096 "read".to_string(),
4097 "ask_user_question".to_string(),
4098 "write".to_string(),
4099 ];
4100
4101 let args = Args {
4102 tools: Some(vec!["read".into(), "ask_user_question".into()]),
4103 ..Args::default()
4104 };
4105 assert_eq!(
4106 filter_active_tool_names(names.clone(), &args),
4107 vec!["read", "ask_user_question"]
4108 );
4109
4110 let args = Args {
4111 exclude_tools: Some(vec!["ask_user_question".into()]),
4112 ..Args::default()
4113 };
4114 assert_eq!(
4115 filter_active_tool_names(names.clone(), &args),
4116 vec!["read", "write"]
4117 );
4118
4119 let args = Args {
4120 no_tools: true,
4121 ..Args::default()
4122 };
4123 assert!(filter_active_tool_names(names, &args).is_empty());
4124 }
4125
4126 #[test]
4127 fn select_ephemeral_when_no_session() {
4128 let args = Args {
4129 no_session: true,
4130 ..Args::default()
4131 };
4132 let cwd = Path::new("/tmp");
4133 assert!(matches!(
4134 select_session(&args, cwd),
4135 SessionSelection::Ephemeral
4136 ));
4137 }
4138
4139 #[test]
4140 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
4141 let defaults = Args::default();
4142 assert!(resolve_project_trust(
4143 &defaults,
4144 Path::new("C:/definitely-not-a-project")
4145 ));
4146
4147 let denied = Args {
4148 trust_override: Some(false),
4149 ..Args::default()
4150 };
4151 assert!(!resolve_project_trust(
4152 &denied,
4153 Path::new("C:/definitely-not-a-project")
4154 ));
4155 }
4156
4157 #[tokio::test(flavor = "current_thread")]
4158 async fn build_preserves_the_supplied_startup_project_snapshot() {
4159 struct RestoreConfigDir(Option<std::ffi::OsString>);
4160
4161 impl Drop for RestoreConfigDir {
4162 fn drop(&mut self) {
4163 match self.0.take() {
4164 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4165 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4166 }
4167 }
4168 }
4169
4170 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4171 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4172 let tmp = tempfile::tempdir().unwrap();
4173 let agent = tmp.path().join("agent");
4174 let cwd = tmp.path().join("project");
4175 std::fs::create_dir_all(&agent).unwrap();
4176 std::fs::create_dir_all(&cwd).unwrap();
4177 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4178
4179 let resolved = crate::provider::resolve(
4180 Some("anthropic"),
4181 Some(crate::provider::DEFAULT_MODEL_ID),
4182 None,
4183 Some("test-key"),
4184 None,
4185 )
4186 .unwrap();
4187 let args = Args {
4188 trust_override: Some(false),
4189 no_session: true,
4190 no_tools: true,
4191 no_extensions: true,
4192 no_skills: true,
4193 no_prompt_templates: true,
4194 no_context_files: true,
4195 system_prompt: Some("test prompt".into()),
4196 ..Args::default()
4197 };
4198
4199 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
4200
4201 assert_eq!(context.cwd, cwd);
4202 assert!(context.project_trusted);
4203 }
4204
4205 #[tokio::test(flavor = "current_thread")]
4206 async fn build_applies_the_name_flag_to_the_session() {
4207 struct RestoreConfigDir(Option<std::ffi::OsString>);
4208
4209 impl Drop for RestoreConfigDir {
4210 fn drop(&mut self) {
4211 match self.0.take() {
4212 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4213 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4214 }
4215 }
4216 }
4217
4218 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4219 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4220 let tmp = tempfile::tempdir().unwrap();
4221 let agent = tmp.path().join("agent");
4222 let cwd = tmp.path().join("project");
4223 std::fs::create_dir_all(&agent).unwrap();
4224 std::fs::create_dir_all(&cwd).unwrap();
4225 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4226
4227 let resolved = crate::provider::resolve(
4228 Some("anthropic"),
4229 Some(crate::provider::DEFAULT_MODEL_ID),
4230 None,
4231 Some("test-key"),
4232 None,
4233 )
4234 .unwrap();
4235 let args = Args {
4236 trust_override: Some(false),
4237 no_tools: true,
4238 no_extensions: true,
4239 no_skills: true,
4240 no_prompt_templates: true,
4241 no_context_files: true,
4242 system_prompt: Some("test prompt".into()),
4243 name: Some("smoke name".into()),
4244 session_dir: Some(cwd.join("sessions")),
4245 ..Args::default()
4246 };
4247
4248 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
4249 assert_eq!(
4250 harness.get_name().await.unwrap().as_deref(),
4251 Some("smoke name")
4252 );
4253 }
4254
4255 #[test]
4256 fn select_latest_for_continue_and_resume() {
4257 let _env = crate::config::test_support::SessionDirEnv::new(None);
4258 let args = Args {
4259 continue_session: true,
4260 ..Args::default()
4261 };
4262 let cwd = Path::new("/tmp");
4263 assert!(matches!(
4264 select_session(&args, cwd),
4265 SessionSelection::Latest { .. }
4266 ));
4267
4268 let args = Args {
4269 resume: true,
4270 ..Args::default()
4271 };
4272 assert!(matches!(
4273 select_session(&args, cwd),
4274 SessionSelection::Latest { .. }
4275 ));
4276 }
4277
4278 #[test]
4279 fn select_by_id_for_session_flag() {
4280 let _env = crate::config::test_support::SessionDirEnv::new(None);
4281 let args = Args {
4282 session: Some("01a02ece".into()),
4283 ..Args::default()
4284 };
4285 let cwd = Path::new("/tmp");
4286 assert!(matches!(
4287 select_session(&args, cwd),
4288 SessionSelection::ById { id, .. } if id == "01a02ece"
4289 ));
4290 }
4291
4292 #[test]
4293 fn select_new_with_custom_dir() {
4294 let _env = crate::config::test_support::SessionDirEnv::new(None);
4295 let args = Args {
4296 session_dir: Some(PathBuf::from("/tmp/sess")),
4297 ..Args::default()
4298 };
4299 let cwd = Path::new("/tmp");
4300 match select_session(&args, cwd) {
4301 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
4302 other => panic!("expected New, got {other:?}"),
4303 }
4304 }
4305
4306 #[test]
4307 fn select_new_default_dir() {
4308 let _env = crate::config::test_support::SessionDirEnv::new(None);
4309 let args = Args::default();
4310 let cwd = Path::new("/proj");
4311 match select_session(&args, cwd) {
4312 SessionSelection::New { dir, .. } => {
4313 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
4314 }
4315 other => panic!("expected New, got {other:?}"),
4316 }
4317 }
4318
4319 #[test]
4320 fn default_session_dir_only_uses_rpi() {
4321 let _env = crate::config::test_support::SessionDirEnv::new(None);
4322 let tmp = tempfile::tempdir().unwrap();
4323 let cwd = tmp.path();
4324 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
4325 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
4326 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
4327 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
4328 }
4329
4330 #[test]
4331 fn default_session_dir_uses_env_var() {
4332 let tmp = tempfile::tempdir().unwrap();
4333 let cwd = tmp.path();
4334 let custom = tmp.path().join("custom_sessions");
4335 let _env = crate::config::test_support::SessionDirEnv::new(Some(&custom));
4336 assert_eq!(default_session_dir(cwd), custom);
4337 }
4338
4339 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4340 async fn ephemeral_session_builds_roundtrips() {
4341 let s = ephemeral_session();
4345 let leaf = s.get_leaf_id().await;
4346 assert!(leaf.is_ok());
4347 }
4348
4349 #[test]
4367 fn host_private_env_matches_the_declared_contract() {
4368 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
4370 assert_eq!(
4371 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
4372 [
4373 "LANGFUSE_PI_PARENT_TRACE_ID",
4374 "LANGFUSE_PI_PARENT_SPAN_ID",
4375 "LANGFUSE_PI_PARENT_SESSION_ID",
4376 "LANGFUSE_PI_PARENT_DEPTH",
4377 ],
4378 "these strings are what rpi-langfuse (a different repo) matches; a \
4379 change here is a breaking cross-repo change, not a refactor"
4380 );
4381
4382 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
4385 .iter()
4386 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
4387 {
4388 assert!(
4389 rpi_tools::is_host_private_env(name),
4390 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
4391 rpi_tools::HOST_PRIVATE_ENV
4392 );
4393 }
4394
4395 for visible in [
4398 crate::args::RPI_OFFLINE_ENV,
4399 crate::config::CONFIG_DIR_ENV,
4400 "PATH",
4401 ] {
4402 assert!(
4403 !rpi_tools::is_host_private_env(visible),
4404 "{visible} describes the user's configuration and must still reach the shell"
4405 );
4406 }
4407 }
4408 #[tokio::test]
4409 async fn reload_shutdown_joins_all_handlers_even_after_veto() {
4410 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
4411 use std::sync::atomic::AtomicUsize;
4412 static CONTEXTS: rpi_plugin_sdk::handles::ToolHandles<Arc<AtomicUsize>> =
4413 rpi_plugin_sdk::handles::ToolHandles::new();
4414 struct ContextOwner(usize);
4415 impl Drop for ContextOwner {
4416 fn drop(&mut self) {
4417 drop(CONTEXTS.remove(self.0 as rpi_plugin_sdk::StepHandle));
4418 }
4419 }
4420 extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
4421 let context = CONTEXTS.get(data).expect("live test context");
4422 let calls = context.lock().clone();
4423 std::thread::sleep(std::time::Duration::from_millis(20));
4424 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
4425 rpi_plugin_sdk::EVENT_HANDLER_ABORT
4426 } else {
4427 0
4428 }
4429 }
4430 let calls = Arc::new(AtomicUsize::new(0));
4431 let context = CONTEXTS.insert(calls.clone());
4432 let _owner = ContextOwner(context as usize);
4433 let mut registry = rpi_extensions::ExtensionRegistry::new();
4434 for name in ["first", "second"] {
4435 registry.register_event_handler(name.into(), EventTag::SessionShutdown, stop, context);
4436 }
4437 super::shutdown_extensions_for_reload(
4438 Some(Arc::new(registry.snapshot())),
4439 rpi_extensions::PluginKeepalive::empty(),
4440 )
4441 .await
4442 .unwrap();
4443 assert_eq!(calls.load(Ordering::SeqCst), 2);
4444 }
4445
4446 #[tokio::test]
4455 async fn a_restored_session_adopts_its_recorded_directory() {
4456 let tmp = tempfile::tempdir().unwrap();
4457 let project = tmp.path().join("project-a");
4458 let launch = tmp.path().join("launch-dir");
4459 let sessions = tmp.path().join("sessions");
4460 std::fs::create_dir_all(&project).unwrap();
4461 std::fs::create_dir_all(&launch).unwrap();
4462 std::fs::create_dir_all(&sessions).unwrap();
4463 create_jsonl_session_with_id(
4464 &sessions,
4465 &project.to_string_lossy(),
4466 Some("cwdadopt01".into()),
4467 )
4468 .await
4469 .unwrap();
4470
4471 let selection = SessionSelection::ById {
4472 id: "cwdadopt01".into(),
4473 dir: sessions.clone(),
4474 };
4475 let peeked = peek_restored_session_cwd(&selection, &launch)
4476 .await
4477 .expect("a resumable session with a recorded cwd");
4478 assert_eq!(peeked.recorded, project);
4479 assert_eq!(peeked.adopted, Some(project));
4480 }
4481
4482 #[tokio::test]
4486 async fn a_missing_recorded_directory_is_reported_not_adopted() {
4487 let tmp = tempfile::tempdir().unwrap();
4488 let gone = tmp.path().join("project-gone");
4489 let launch = tmp.path().join("launch-dir");
4490 let sessions = tmp.path().join("sessions");
4491 std::fs::create_dir_all(&launch).unwrap();
4492 std::fs::create_dir_all(&sessions).unwrap();
4493 create_jsonl_session_with_id(
4494 &sessions,
4495 &gone.to_string_lossy(),
4496 Some("cwdgone001".into()),
4497 )
4498 .await
4499 .unwrap();
4500
4501 let selection = SessionSelection::ById {
4502 id: "cwdgone001".into(),
4503 dir: sessions.clone(),
4504 };
4505 let peeked = peek_restored_session_cwd(&selection, &launch)
4506 .await
4507 .expect("the session itself is resumable");
4508 assert_eq!(peeked.recorded, gone);
4509 assert_eq!(peeked.adopted, None);
4510 let note = crate::session_cwd::missing_session_cwd_note(&peeked.recorded, &launch);
4511 assert!(note.contains("no longer exists"), "{note}");
4512 assert!(note.contains("launch-dir"), "{note}");
4513 }
4514
4515 #[tokio::test]
4518 async fn open_session_reports_the_recorded_directory() {
4519 let tmp = tempfile::tempdir().unwrap();
4520 let project = tmp.path().join("project-open");
4521 let launch = tmp.path().join("launch-dir");
4522 let sessions = tmp.path().join("sessions");
4523 std::fs::create_dir_all(&project).unwrap();
4524 std::fs::create_dir_all(&launch).unwrap();
4525 std::fs::create_dir_all(&sessions).unwrap();
4526 create_jsonl_session_with_id(
4527 &sessions,
4528 &project.to_string_lossy(),
4529 Some("cwdopen001".into()),
4530 )
4531 .await
4532 .unwrap();
4533
4534 let restored =
4535 open_session_by_id_in_dir("cwdopen001", &launch.to_string_lossy(), &sessions)
4536 .await
4537 .map_err(|error| error.to_string())
4538 .unwrap();
4539 assert_eq!(restored.recorded_cwd, Some(project.clone()));
4540 assert_eq!(restored.session.storage().metadata().id, "cwdopen001");
4541 }
4542
4543 #[tokio::test]
4552 async fn the_peeked_session_is_the_one_the_build_opens() {
4553 let tmp = tempfile::tempdir().unwrap();
4554 let first_project = tmp.path().join("project-first");
4555 let second_project = tmp.path().join("project-second");
4556 let launch = tmp.path().join("launch-dir");
4557 let sessions = tmp.path().join("sessions");
4558 for dir in [&first_project, &second_project, &launch, &sessions] {
4559 std::fs::create_dir_all(dir).unwrap();
4560 }
4561 create_jsonl_session_with_id(
4562 &sessions,
4563 &first_project.to_string_lossy(),
4564 Some("cwdrace001".into()),
4565 )
4566 .await
4567 .unwrap();
4568
4569 let mut args = Args {
4570 continue_session: true,
4571 session_dir: Some(sessions.clone()),
4572 ..Default::default()
4573 };
4574 let selection = select_session_in_dir(&args, sessions.clone());
4575 let peeked = peek_restored_session_cwd(&selection, &launch)
4576 .await
4577 .expect("a resumable session");
4578 assert_eq!(peeked.adopted, Some(first_project.clone()));
4579
4580 create_jsonl_session_with_id(
4582 &sessions,
4583 &second_project.to_string_lossy(),
4584 Some("cwdrace002".into()),
4585 )
4586 .await
4587 .unwrap();
4588
4589 pin_restored_selection(&mut args, &peeked);
4590 let pinned = select_session_in_dir(&args, sessions.clone());
4591 let reopened = resolve_restore_target(&pinned, &launch.to_string_lossy())
4592 .await
4593 .unwrap()
4594 .expect("the pinned session still exists");
4595 assert_eq!(
4596 reopened.id, peeked.id,
4597 "the build must open the session the peek adopted the directory of"
4598 );
4599 assert_eq!(reopened.cwd, first_project.to_string_lossy());
4600 }
4601
4602 #[test]
4607 fn trust_is_resolved_per_directory() {
4608 struct RestoreConfigDir(Option<std::ffi::OsString>);
4609
4610 impl Drop for RestoreConfigDir {
4611 fn drop(&mut self) {
4612 match self.0.take() {
4613 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4614 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4615 }
4616 }
4617 }
4618
4619 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4620 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4621 let tmp = tempfile::tempdir().unwrap();
4622 let agent = tmp.path().join("agent");
4623 std::fs::create_dir_all(&agent).unwrap();
4624 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4625
4626 let untrusted = tmp.path().join("project-untrusted");
4627 let launch = tmp.path().join("project-launch");
4628 std::fs::create_dir_all(&untrusted).unwrap();
4629 std::fs::create_dir_all(&launch).unwrap();
4630 crate::config::set_project_trust(&untrusted, Some(false)).unwrap();
4631
4632 let args = Args::default();
4633 assert!(!resolve_project_trust(&args, &untrusted));
4634 assert!(resolve_project_trust(&args, &launch));
4636 }
4637}