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