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 ", or the todo tool"
101 } else {
102 ""
103 };
104 let working_state_rule = if prior_reasoning_replayed {
110 format!(
111 "- Keep a SHORT working plan visible (a few bullets{todo_hint}) and update it as you go.
112 Your own reasoning is carried back to you on the next turn, so do not re-derive it or restate
113 the whole plan: continue from the last unfinished step, and when a step lands, say which one.
114 Re-listing the same plan without acting on it is a bug, not progress."
115 )
116 } else {
117 "- Keep your working state in your VISIBLE replies, not only in reasoning. Reasoning is not
118 carried into your next turn: only the text you write and the tool output you produce come
119 back. Before each batch of tool calls, write one short line naming the task you are on and
120 what remains. When you finish a step, say which one is done. If you keep the plan only in
121 your head you will re-derive it from scratch every turn."
122 .to_string()
123 };
124
125 let tool_lines = if tools.is_empty() {
126 "(none)".to_string()
127 } else {
128 tools
129 .iter()
130 .map(|name| match builtin_tool_snippet(name) {
131 Some(snippet) => format!("- {name:<5} — {snippet}"),
132 None => format!("- {name}"),
133 })
134 .collect::<Vec<_>>()
135 .join("\n")
136 };
137
138 let mut guidelines = vec![
139 "- Reply in the SAME language the user is writing in. Detect the language of the user's
140 latest message (and, when it is too short to be sure, the working language of the
141 conversation so far) and use it for your prose replies. Mirror it unless the user
142 explicitly asks for a different language; only fall back to English when the
143 user's language cannot be determined."
144 .to_string(),
145 "- Be concise in your responses".to_string(),
146 "- Show file paths clearly when working with files".to_string(),
147 "- Prefer the smallest change that solves the problem".to_string(),
148 ];
149 if has("bash") {
150 guidelines.push(
151 "- The bash tool runs Bash, even on Windows. Use ls, find, rg, and POSIX pipelines;
152 do not pass PowerShell cmdlets such as Get-ChildItem, Select-Object, or Select-String.
153 If a powershell tool is available, use it for PowerShell commands."
154 .to_string(),
155 );
156 }
157 guidelines.push(working_state_rule);
158 if has("todo") {
159 guidelines.push(
160 "- Track multi-step work with the todo tool instead of re-listing the plan in prose: add the
161 steps once, then mark them done as you go. Re-stating the same plan without acting on it is
162 a bug, not progress."
163 .to_string(),
164 );
165 } else {
166 guidelines.push(
167 "- Re-stating the same plan without acting on it is a bug, not progress.".to_string(),
168 );
169 }
170 guidelines.push(
171 "- Read only what you need. If a file or search result was already shown earlier in this
172 conversation, use it instead of fetching it again."
173 .to_string(),
174 );
175 if has("docs") {
176 guidelines.push(
177 "- When unsure about rpi commands, extensions, Pi package compatibility, or .rpi configuration, consult the project documentation before guessing"
178 .to_string(),
179 );
180 }
181 let guidelines = guidelines.join("\n");
182
183 format!(
184 "You are an expert coding assistant operating inside rpi, a coding agent harness. \
185You help users by reading files, executing commands, editing code, and writing new files.
186
187Available tools:
188{tool_lines}
189
190Guidelines:
191{guidelines}
192
193Current working directory: {cwd}"
194 )
195}
196
197fn builtin_tool_snippet(name: &str) -> Option<&'static str> {
199 Some(match name {
200 "read" => "Read file contents",
201 "bash" => "Execute Bash commands using Bash/POSIX syntax, including on Windows",
202 "edit" => "Find/replace edits to existing files",
203 "write" => "Create or overwrite files",
204 "docs" => "Look up rpi usage, extension, package, and compatibility documentation",
205 "grep" => "Search file contents for a pattern",
206 "find" => "Find files by glob pattern",
207 "ls" => "List directory contents",
208 "powershell" => "Execute PowerShell commands on Windows",
209 _ => return None,
210 })
211}
212
213#[derive(Debug, Clone)]
220pub enum SessionSelection {
221 Ephemeral,
223 New { dir: PathBuf, name: Option<String> },
225 Latest { dir: PathBuf },
227 ById { id: String, dir: PathBuf },
230 ByExactId { id: String, dir: PathBuf },
232 Fork { source: String, dir: PathBuf },
235}
236
237pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
239 select_session_in_dir(args, configured_session_dir(args, cwd))
240}
241
242pub(crate) fn configured_session_dir(args: &Args, cwd: &Path) -> PathBuf {
244 let dir = args
245 .session_dir
246 .clone()
247 .or_else(|| {
248 crate::settings::load_settings()
249 .ok()
250 .and_then(|settings| settings.session_dir)
251 })
252 .unwrap_or_else(|| default_session_dir(cwd));
253 if dir.is_relative() && !dir.has_root() {
256 cwd.join(dir)
257 } else {
258 dir
259 }
260}
261
262fn select_session_in_dir(args: &Args, dir: PathBuf) -> SessionSelection {
263 if args.no_session {
264 return SessionSelection::Ephemeral;
265 }
266 if args.continue_session || args.resume {
270 return SessionSelection::Latest { dir };
271 }
272 if let Some(s) = &args.fork {
273 return SessionSelection::Fork {
274 source: s.clone(),
275 dir,
276 };
277 }
278 if let Some(s) = &args.session_id {
279 return SessionSelection::ByExactId { id: s.clone(), dir };
280 }
281 if let Some(s) = &args.session {
282 return SessionSelection::ById { id: s.clone(), dir };
283 }
284 SessionSelection::New {
285 dir,
286 name: args.name.clone(),
287 }
288}
289
290pub fn default_session_dir(cwd: &Path) -> PathBuf {
293 std::env::var("RPI_CODING_AGENT_SESSION_DIR")
294 .ok()
295 .map(PathBuf::from)
296 .unwrap_or_else(|| cwd.join(".rpi").join("sessions"))
297}
298
299pub struct PreparedExtensions {
302 pub extension_session: ExtensionSession,
303 session_dir: PathBuf,
304 runtime: tokio::runtime::Handle,
305 tool_context: rpi_extensions::ToolCallContext,
306 action_host: Arc<crate::extensions_actions::HarnessActionHost>,
307 harness_cell: Arc<std::sync::OnceLock<rpi_harness::agent_harness::WeakAgentHarness>>,
308 reload_mailbox: rpi_extensions::ReloadMailbox,
309 ui_dialog_mailbox: rpi_extensions::UiDialogMailbox,
310 ext_status_mailbox: rpi_extensions::ExtensionStatusMailbox,
311 editor_text_mailbox: rpi_extensions::EditorTextMailbox,
312 action_bridge: Arc<rpi_extensions::ActionBridge>,
313}
314
315pub fn prepare_extensions(
316 args: &Args,
317 cwd: &Path,
318 project_trusted: bool,
319) -> Result<PreparedExtensions, BuildError> {
320 let session_dir = configured_session_dir(args, cwd);
321 let cwd_str = cwd.to_string_lossy().to_string();
322 let tool_context = rpi_extensions::ToolCallContext::new(cwd_str.clone());
323 if !project_trusted && args.verbose {
324 eprintln!(
325 "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)"
326 );
327 }
328 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
347 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
348 })?;
349 let catalog = Vec::new();
350 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
351 catalog.clone(),
352 cwd.to_path_buf(),
353 runtime.clone(),
354 args.unknown_flags.clone(),
355 tool_context.clone(),
356 );
357 let action_host = Arc::new(action_host.with_session_dir(session_dir.clone()));
358 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = action_host.clone();
359 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
369 let ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
375 let ext_status_mailbox = rpi_extensions::ExtensionStatusMailbox::new();
379 let editor_text_mailbox = rpi_extensions::EditorTextMailbox::new();
384 let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
385 runtime.clone(),
386 host_arc,
387 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
388 ui_dialog_mailbox.clone(),
389 ext_status_mailbox.clone(),
390 editor_text_mailbox.clone(),
391 );
392
393 let extension_session = if args.no_extensions {
394 ExtensionSession::none()
395 } else {
396 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
397 };
398 if args.verbose {
399 if let Some(s) = extension_session.summary() {
400 eprintln!("extensions: {s}");
401 }
402 report_deferred_renderers(&extension_session);
403 }
404 Ok(PreparedExtensions {
405 session_dir,
406 runtime,
407 tool_context,
408 action_host,
409 harness_cell,
410 reload_mailbox,
411 ui_dialog_mailbox,
412 ext_status_mailbox,
413 editor_text_mailbox,
414 action_bridge,
415 extension_session,
416 })
417}
418
419pub async fn build(
437 resolved: &ResolvedModel,
438 args: &Args,
439 cwd: &Path,
440 project_trusted: bool,
441) -> Result<
442 (
443 AgentHarness,
444 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
445 ReloadContext,
446 ),
447 BuildError,
448> {
449 let prepared = prepare_extensions(args, cwd, project_trusted)?;
450 build_prepared(resolved, args, cwd, project_trusted, prepared).await
451}
452
453pub async fn build_prepared(
454 resolved: &ResolvedModel,
455 args: &Args,
456 cwd: &Path,
457 project_trusted: bool,
458 prepared: PreparedExtensions,
459) -> Result<
460 (
461 AgentHarness,
462 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
463 ReloadContext,
464 ),
465 BuildError,
466> {
467 let cwd_str = cwd.to_string_lossy().to_string();
468 let PreparedExtensions {
473 session_dir,
474 runtime,
475 tool_context,
476 action_host,
477 harness_cell,
478 reload_mailbox,
479 ui_dialog_mailbox,
480 ext_status_mailbox,
481 editor_text_mailbox,
482 action_bridge,
483 extension_session,
484 } = prepared;
485 let catalog = crate::provider::authenticated_catalog(resolved);
486 action_host.set_catalog(catalog.clone());
487
488 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
490 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
491 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
492 let _registry = Arc::new(MutationQueueRegistry::new());
493 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
496
497 let mut tools = build_tools(&ctx, args);
498
499 merge_extension_tools(&mut tools, &extension_session, args, &tool_context);
507 let active = filter_active_tool_names(active_tool_names(&tools, args), args);
508
509 let selection = select_session_in_dir(args, session_dir.clone());
511 let session = build_session(&selection, &cwd_str).await?;
512 if let Ok(metadata) = session.get_metadata().await {
516 publish_session_identity(&tool_context, &metadata.id);
517 }
518 if let Some(requested) = args.name.as_deref() {
524 let name = requested.trim();
525 let name = if name.is_empty() { None } else { Some(name) };
526 if let Err(error) = session.set_name(name).await {
527 if args.verbose {
528 eprintln!("warning: could not set session name: {error}");
529 }
530 }
531 }
532 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
542 let prompt_tools = active.clone();
543 let mut context_paths = Vec::new();
544 let base_prompt = match args.system_prompt.as_deref() {
545 Some(explicit) => explicit.to_string(),
546 None if project_trusted => match discover_system_prompt_file(cwd) {
547 Some(path) => read_prompt_source(&path, &mut context_paths).unwrap_or_else(|| {
548 default_system_prompt_with_tools(&cwd_str, prior_reasoning_replayed, &prompt_tools)
549 }),
550 None => {
551 default_system_prompt_with_tools(&cwd_str, prior_reasoning_replayed, &prompt_tools)
552 }
553 },
554 None => default_system_prompt_with_tools(&cwd_str, prior_reasoning_replayed, &prompt_tools),
555 };
556
557 let mut append_texts: Vec<String> = Vec::new();
564 for extra in &args.append_system_prompt {
565 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
566 append_texts.push(text);
567 }
568 if args.append_system_prompt.is_empty() {
569 if let Some(path) = project_trusted
570 .then(|| discover_append_system_prompt_file(cwd))
571 .flatten()
572 {
573 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
574 append_texts.push(text);
575 }
576 }
577 }
578 let append_join = if append_texts.is_empty() {
579 None
580 } else {
581 Some(append_texts.join("\n\n"))
582 };
583
584 let agent_dir = crate::config::agent_dir().ok();
596
597 let discovered = extension_session
613 .snapshot_arc()
614 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
615 .unwrap_or_default();
616
617 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
618 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
619 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
620 if !args.no_skills {
621 let mut dirs = if args.dev_local_only || args.project_only {
622 project_skill_dirs(cwd)
623 } else if project_trusted {
624 skill_dirs(cwd)
625 } else {
626 global_skill_dirs()
627 };
628 dirs.extend(args.skill.iter().cloned());
629 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
630 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
631 skills = result.skills;
632 skill_diags = result.diagnostics;
633 resource_diags.extend(result.resource_diagnostics);
634 }
635
636 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
637 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
638 if !args.no_prompt_templates {
639 let mut dirs = if args.dev_local_only || args.project_only {
640 project_prompt_template_dirs(cwd)
641 } else if project_trusted {
642 prompt_template_dirs(cwd)
643 } else {
644 global_prompt_template_dirs()
645 };
646 dirs.extend(args.prompt_template.iter().cloned());
647 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
648 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
649 prompt_templates = result.prompt_templates;
650 prompt_diags = result.diagnostics;
651 resource_diags.extend(result.resource_diagnostics);
652 }
653
654 let context_block = if args.no_context_files || !project_trusted {
655 String::new()
656 } else {
657 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
662 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
663 context_paths.extend(
664 files
665 .iter()
666 .map(|file| file.path.to_string_lossy().into_owned()),
667 );
668 format_project_context(&files)
669 };
670
671 if args.verbose {
673 for d in &skill_diags {
674 eprintln!(
675 "warning: skill {} ({}): {}",
676 d.path,
677 d.code.as_str(),
678 d.message
679 );
680 }
681 for d in &prompt_diags {
682 eprintln!(
683 "warning: prompt template {} ({}): {}",
684 d.path,
685 d.code.as_str(),
686 d.message
687 );
688 }
689 for d in &resource_diags {
691 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
692 eprintln!(
693 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
694 c.resource_type.as_str(),
695 c.name,
696 c.winner_path,
697 c.loser_path
698 );
699 }
700 }
701 }
702
703 let system_prompt = compose_system_prompt(
712 Some(&base_prompt),
713 &[], if context_block.is_empty() {
715 None
716 } else {
717 Some(&context_block)
718 },
719 append_join.as_deref(),
720 );
721
722 if args.debug_system_prompt {
731 eprintln!("=== --debug-system-prompt ===");
732 let base_src = if args.system_prompt.is_some() {
733 "--system-prompt"
734 } else if discover_system_prompt_file(cwd).is_some() {
735 "SYSTEM.md"
736 } else {
737 "default"
738 };
739 eprintln!("[base source: {base_src}]");
740 eprintln!("--- base ---\n{base_prompt}");
741 if let Some(append) = append_join.as_deref() {
742 eprintln!("--- append ---\n{append}");
743 } else {
744 eprintln!("--- append: (none) ---");
745 }
746 if context_block.is_empty() {
747 eprintln!("--- context: (none) ---");
748 } else {
749 eprintln!("--- context ---{context_block}");
750 }
751 let visible_skills = skills
752 .iter()
753 .filter(|s| s.disable_model_invocation != Some(true))
754 .count();
755 eprintln!(
756 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
757 skills.len(),
758 visible_skills,
759 skills.len() - visible_skills
760 );
761 for s in &skills {
762 let hidden = if s.disable_model_invocation == Some(true) {
763 " [hidden]"
764 } else {
765 ""
766 };
767 eprintln!(" {}{hidden} — {}", s.name, s.description);
768 }
769 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
770 for t in &prompt_templates {
771 eprintln!(" /{}", t.name);
772 }
773 eprintln!(
777 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
778 discovered.skill_paths.len(),
779 discovered.prompt_paths.len(),
780 discovered.theme_paths.len(),
781 );
782 for p in &discovered.skill_paths {
783 eprintln!(" skill: {p}");
784 }
785 for p in &discovered.prompt_paths {
786 eprintln!(" prompt: {p}");
787 }
788 eprintln!(
789 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
790 );
791 eprintln!("=== end --debug-system-prompt ===");
792 }
793
794 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
799 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
800 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
804
805 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
814 Some(snapshot) => {
815 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
816 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
817 }
818 None => broadcast_emitter,
819 };
820
821 let saved_settings = crate::settings::load_settings().unwrap_or_default();
825
826 if let Some(proxy) = &saved_settings.http_proxy {
831 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
832 if std::env::var_os(key).is_none() {
833 std::env::set_var(key, proxy);
834 }
835 }
836 }
837
838 let extension_session_cell = Arc::new(Mutex::new(extension_session.clone()));
843 let before_tool_call = extension_session
848 .snapshot()
849 .filter(|snapshot| {
850 !snapshot
851 .handlers_for(rpi_plugin_sdk::EventTag::ToolCall)
852 .is_empty()
853 })
854 .map(|_| {
855 extension_tool_call_hook(
856 Arc::clone(&extension_session_cell),
857 ui_dialog_mailbox.clone(),
858 )
859 });
860
861 let options = AgentHarnessOptions {
862 model: resolved.model.clone(),
863 thinking_level: resolved.thinking_level,
864 active_tool_names: active,
865 tools,
866 system_prompt: Some(system_prompt),
867 resources: AgentHarnessResources {
868 context_files: context_paths,
869 skills: if skills.is_empty() {
870 None
871 } else {
872 Some(skills)
873 },
874 prompt_templates: if prompt_templates.is_empty() {
875 None
876 } else {
877 Some(prompt_templates)
878 },
879 },
880 allow_existing_session: matches!(
883 selection,
884 SessionSelection::Latest { .. }
885 | SessionSelection::ById { .. }
886 | SessionSelection::ByExactId { .. }
887 | SessionSelection::Fork { .. }
888 ),
889 stream_options: AgentHarnessStreamOptions {
890 timeout: args.timeout,
891 stream_idle_timeout: args.stream_idle_timeout.or_else(|| {
892 saved_settings
893 .stream_idle_timeout
894 .map(std::time::Duration::from_secs)
895 }),
896 ..Default::default()
897 },
898 retry: saved_settings.retry_policy(),
903 compaction: saved_settings.compaction_settings(),
904 steering_mode: saved_settings.steering_mode(),
905 follow_up_mode: saved_settings.follow_up_mode(),
906 tool_execution: HarnessToolExecution::default(),
907 drive: DrivingMode::default(),
908 session,
909 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
917 to_provider_messages: None,
918 entry_projectors: Default::default(),
919 agent_emitter: Some(emitter),
920 before_tool_call,
925 after_tool_call: None,
926 transform_context: None,
927 entry_transforms: Vec::new(),
928 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
934 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
935 };
936
937 let harness = match AgentHarness::create(options).await {
938 Ok(h) => {
939 crate::extensions_actions::HarnessActionHost::set_harness(
942 &harness_cell,
943 Arc::new(h.clone()),
944 );
945 h
946 }
947 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
948 };
949
950 {
958 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
959 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
960 Ok(reports) => {
961 for (lane, report) in reports {
962 if report.is_clean() {
963 continue;
964 }
965 if report.is_corrupt() {
966 eprintln!(
967 "warning: session lane '{lane}' is corrupt ({} open operations); \
968 refusing to auto-repair — inspect the session file",
969 report.open_operations.len()
970 );
971 continue;
972 }
973 for decision in runtime.reconcile(&report) {
974 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
975 if args.verbose {
976 eprintln!(
977 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
978 );
979 }
980 }
981 }
982 runtime.emit_recovery_events(harness.events(), &report);
983 }
984 }
985 Err(e) => {
986 if args.verbose {
987 eprintln!("warning: session recovery sweep failed: {e}");
988 }
989 }
990 }
991 }
992
993 let mut reload_args = args.clone();
1002 reload_args.session_dir = Some(session_dir);
1003 let reload_context = ReloadContext {
1004 extension_session: extension_session_cell,
1005 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
1006 tool_context,
1007 catalog: Arc::new(std::sync::RwLock::new(catalog)),
1008 providers: Arc::new(std::sync::RwLock::new(resolved.providers.clone())),
1009 runtime: runtime.clone(),
1010 cwd: cwd.to_path_buf(),
1011 project_trusted,
1012 args: reload_args,
1013 resolved_model: resolved.model.clone(),
1014 broadcast: broadcast_for_context,
1015 mailbox: reload_mailbox,
1016 ui_dialog: ui_dialog_mailbox,
1017 ext_status: ext_status_mailbox,
1018 editor_text: editor_text_mailbox,
1019 dev_extension: None,
1020 };
1021
1022 Ok((harness, event_rx, reload_context))
1023}
1024
1025fn extension_tool_call_hook(
1028 sessions: ExtensionSessionCell,
1029 ui_dialog: rpi_extensions::UiDialogMailbox,
1030) -> rpi_agent::BeforeToolCall {
1031 Arc::new(move |ctx: rpi_agent::BeforeToolCallContext<'_>, signal| {
1032 let session = sessions
1033 .lock()
1034 .map(|session| session.clone())
1035 .map_err(|_| ());
1036 let ui_dialog = ui_dialog.clone();
1037 let tool_call_id = ctx.tool_call.id.clone();
1038 let tool_name = ctx.tool_call.name.clone();
1039 let params = ctx.args.clone();
1040 Box::pin(async move {
1041 let reason = match session {
1042 Ok(session) => {
1043 rpi_extensions::dispatch_tool_call_event(
1044 &session,
1045 &tool_call_id,
1046 &tool_name,
1047 ¶ms,
1048 &signal,
1049 &ui_dialog,
1050 )
1051 .await
1052 }
1053 Err(_) => Some("extension approval session is unavailable".to_string()),
1054 };
1055 reason.map(|reason| rpi_agent::BeforeToolCallResult {
1056 block: true,
1057 reason: Some(reason),
1058 terminate: false,
1059 args: None,
1060 })
1061 })
1062 })
1063}
1064
1065pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
1110
1111pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
1116
1117#[derive(Clone)]
1125pub struct ReloadContext {
1126 pub extension_session: ExtensionSessionCell,
1128 pub tool_context: rpi_extensions::ToolCallContext,
1133 pub action_bridge: ActionBridgeCell,
1135 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
1138 pub editor_text: rpi_extensions::EditorTextMailbox,
1145 pub catalog: Arc<std::sync::RwLock<Vec<rpi_ai::Model>>>,
1148 pub providers: Arc<std::sync::RwLock<Vec<Arc<dyn Provider>>>>,
1151 pub runtime: tokio::runtime::Handle,
1155 pub cwd: PathBuf,
1157 pub project_trusted: bool,
1161 pub args: Args,
1168 pub resolved_model: rpi_ai::Model,
1171 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
1177 pub mailbox: rpi_extensions::ReloadMailbox,
1184 pub ui_dialog: rpi_extensions::UiDialogMailbox,
1190 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
1194}
1195
1196impl ReloadContext {
1197 pub(crate) fn session_dir(&self) -> PathBuf {
1198 self.args
1200 .session_dir
1201 .clone()
1202 .unwrap_or_else(|| configured_session_dir(&self.args, &self.cwd))
1203 }
1204}
1205
1206pub async fn dispatch_session_event_async(
1221 reload_context: &ReloadContext,
1222 tag: rpi_plugin_sdk::EventTag,
1223) -> Option<String> {
1224 let (snapshot, keepalive) = {
1230 let session = reload_context.extension_session.lock().unwrap();
1231 (session.snapshot_arc(), session.keepalive())
1232 };
1233 match snapshot {
1234 Some(snapshot) => rpi_extensions::dispatch_lifecycle_event(&snapshot, tag, keepalive).await,
1235 None => None,
1236 }
1237}
1238
1239async fn shutdown_extensions_for_reload(
1242 snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
1243 keepalive: Arc<rpi_extensions::PluginKeepalive>,
1244) -> Result<(), String> {
1245 if let Some(snapshot) = snapshot {
1246 tokio::task::spawn_blocking(move || {
1247 let _keepalive = keepalive;
1248 rpi_extensions::dispatch_empty_event(
1249 &snapshot,
1250 rpi_plugin_sdk::EventTag::SessionShutdown,
1251 );
1252 })
1253 .await
1254 .map_err(|error| error.to_string())?;
1255 }
1256 Ok(())
1257}
1258
1259pub struct ReloadOutcome {
1262 pub summary: String,
1265 pub had_warnings: bool,
1267 pub details: Vec<String>,
1272 pub had_errors: bool,
1276}
1277
1278struct PreparedReloadInputs {
1279 extension_dirs: Vec<PathBuf>,
1280 skill_base_dirs: Vec<PathBuf>,
1281 prompt_base_dirs: Vec<PathBuf>,
1282}
1283
1284fn append_reload_resource_paths(
1289 mut paths: Vec<PathBuf>,
1290 explicit: &[PathBuf],
1291 discovered: &[String],
1292 js_paths: &[PathBuf],
1293 package_paths: &[PathBuf],
1294) -> Vec<PathBuf> {
1295 paths.extend(explicit.iter().cloned());
1296 paths.extend(discovered.iter().map(PathBuf::from));
1297 paths.extend(js_paths.iter().cloned());
1298 paths.extend(package_paths.iter().cloned());
1299 paths
1300}
1301
1302pub async fn reload_extension_resources(
1314 harness: &AgentHarness,
1315 ctx: &ReloadContext,
1316) -> ReloadOutcome {
1317 reload_extension_resources_inner(harness, ctx, || {}).await
1318}
1319
1320async fn reload_extension_resources_inner<F>(
1321 harness: &AgentHarness,
1322 ctx: &ReloadContext,
1323 after_prepare: F,
1324) -> ReloadOutcome
1325where
1326 F: FnOnce() + Send,
1327{
1328 let mut effective_args = ctx.args.clone();
1333 if let Some(dev) = &ctx.dev_extension {
1334 if let Err(error) = dev
1335 .rebuild()
1336 .and_then(|_| dev.apply_to_args(&mut effective_args))
1337 {
1338 return ReloadOutcome {
1339 summary: format!(
1340 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1341 dev.package_name()
1342 ),
1343 had_warnings: true,
1344 details: Vec::new(),
1345 had_errors: true,
1346 };
1347 }
1348 }
1349 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1350 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1351 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1352 Ok(prepared) => prepared,
1353 Err(error) => {
1354 return ReloadOutcome {
1355 summary: format!(
1356 "Settings reload failed: {error}. Keeping the currently loaded resources."
1357 ),
1358 had_warnings: true,
1359 details: Vec::new(),
1360 had_errors: true,
1361 };
1362 }
1363 };
1364 after_prepare();
1365 let PreparedReloadInputs {
1366 extension_dirs,
1367 skill_base_dirs,
1368 prompt_base_dirs,
1369 } = prepared;
1370 let mut warnings = false;
1371
1372 let (shutdown_snapshot, shutdown_keepalive) = {
1376 let session = ctx.extension_session.lock().unwrap();
1377 (session.snapshot_arc(), session.keepalive())
1378 };
1379 if let Err(reason) = shutdown_extensions_for_reload(shutdown_snapshot, shutdown_keepalive).await
1380 {
1381 return ReloadOutcome {
1382 summary: "Reload failed during extension shutdown".into(),
1383 had_warnings: true,
1384 details: vec![reason],
1385 had_errors: true,
1386 };
1387 }
1388
1389 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1396 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1397 Some(b) => b.clone_host(),
1398 None => {
1399 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1402 ctx.catalog
1403 .read()
1404 .unwrap_or_else(|error| error.into_inner())
1405 .clone(),
1406 ctx.cwd.clone(),
1407 ctx.runtime.clone(),
1408 ctx.args.unknown_flags.clone(),
1409 ctx.tool_context.clone(),
1410 );
1411 crate::extensions_actions::HarnessActionHost::set_harness(
1412 &_cell,
1413 Arc::new(harness.clone()),
1414 );
1415 Arc::new(action_host.with_session_dir(ctx.session_dir()))
1416 }
1417 };
1418
1419 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1420 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1421 ctx.runtime.clone(),
1422 host,
1423 reload_cb,
1424 ctx.ui_dialog.clone(),
1425 ctx.ext_status.clone(),
1426 ctx.editor_text.clone(),
1427 );
1428
1429 let extension_session = if effective_args.no_extensions {
1430 rpi_extensions::ExtensionSession::none()
1431 } else {
1432 load_extensions_from_dirs(
1433 &effective_args,
1434 &extension_dirs,
1435 Some(Arc::clone(&fresh_bridge)),
1436 )
1437 };
1438 if extension_session.is_empty() && !effective_args.no_extensions {
1439 }
1442 if effective_args.verbose {
1443 if let Some(s) = extension_session.summary() {
1444 eprintln!("reload: {s}");
1445 }
1446 report_deferred_renderers(&extension_session);
1447 }
1448
1449 let has_package_providers = |session: &ExtensionSession| {
1450 session.snapshot().is_some_and(|snapshot| {
1451 snapshot
1452 .providers()
1453 .iter()
1454 .any(|provider| snapshot.provider_manifest(&provider.provider_id).is_some())
1455 })
1456 };
1457 let packaged = has_package_providers(&extension_session)
1458 || has_package_providers(&ctx.extension_session.lock().unwrap());
1459 let refreshed = if packaged {
1460 let mut args = effective_args.clone();
1461 if let Ok(current) = harness.lane("main").get_model().await {
1462 let was_package = ctx
1463 .extension_session
1464 .lock()
1465 .unwrap()
1466 .snapshot()
1467 .is_some_and(|snapshot| snapshot.provider_manifest(¤t.provider).is_some());
1468 let has_replacement = extension_session.snapshot().is_some_and(|snapshot| {
1469 snapshot
1470 .providers()
1471 .iter()
1472 .any(|provider| provider.provider_id == current.provider)
1473 });
1474 if was_package && !has_replacement {
1475 return ReloadOutcome {
1476 summary: "Reload failed while resolving provider packages".into(),
1477 had_warnings: true,
1478 details: vec![format!(
1479 "active provider package '{}' is no longer loaded",
1480 current.provider
1481 )],
1482 had_errors: true,
1483 };
1484 }
1485 args.provider = Some(current.provider.clone());
1486 args.model = Some(current.id);
1487 if current.provider != ctx.resolved_model.provider {
1488 args.api_key = None;
1489 args.base_url = None;
1490 }
1491 }
1492 match crate::provider::resolve_with_extensions(
1493 &args,
1494 &ctx.cwd,
1495 ctx.project_trusted,
1496 &extension_session,
1497 ) {
1498 Ok(resolved) => Some(resolved),
1499 Err(error) => {
1500 return ReloadOutcome {
1501 summary: "Reload failed while resolving provider packages".into(),
1502 had_warnings: true,
1503 details: vec![error.to_string()],
1504 had_errors: true,
1505 }
1506 }
1507 }
1508 } else {
1509 None
1510 };
1511
1512 let old_session = {
1524 let mut session_guard = ctx.extension_session.lock().unwrap();
1525 let old_session = std::mem::replace(&mut *session_guard, extension_session.clone());
1526 if let Some(old_snap) = old_session.snapshot_arc() {
1527 old_snap.active_flag().store(false, Ordering::SeqCst);
1531 }
1532 old_session
1533 };
1534 if let Some(old_b) = old_bridge {
1535 old_b.invalidate();
1536 }
1537 old_session.begin_shutdown();
1539
1540 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1542
1543 let discovered = extension_session
1545 .snapshot_arc()
1546 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1547 .unwrap_or_default();
1548
1549 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1551 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1552
1553 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1554 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1555 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1556 if !effective_args.no_skills {
1557 let js_paths: &[PathBuf] = &[];
1561 let package_paths: &[PathBuf] = &[];
1562 let dirs = append_reload_resource_paths(
1563 skill_base_dirs,
1564 &effective_args.skill,
1565 &discovered.skill_paths,
1566 js_paths,
1567 package_paths,
1568 );
1569 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1570 skills = result.skills;
1571 skill_diags = result.diagnostics;
1572 resource_diags.extend(result.resource_diagnostics);
1573 }
1574
1575 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1576 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1577 if !effective_args.no_prompt_templates {
1578 let js_paths: &[PathBuf] = &[];
1579 let package_paths: &[PathBuf] = &[];
1580 let dirs = append_reload_resource_paths(
1581 prompt_base_dirs,
1582 &effective_args.prompt_template,
1583 &discovered.prompt_paths,
1584 js_paths,
1585 package_paths,
1586 );
1587 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1588 prompt_templates = result.prompt_templates;
1589 prompt_diags = result.diagnostics;
1590 resource_diags.extend(result.resource_diagnostics);
1591 }
1592
1593 let context_files = if effective_args.no_context_files || !project_trusted {
1594 Vec::new()
1595 } else {
1596 let agent_dir = crate::config::agent_dir().ok();
1597 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1598 load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await
1599 };
1600 let context_block = format_project_context(&context_files);
1601
1602 let mut details: Vec<String> = Vec::new();
1603 if let Some(resolved) = &refreshed {
1604 warnings |= !resolved.compat_warnings.is_empty();
1605 details.extend(
1606 resolved
1607 .compat_warnings
1608 .iter()
1609 .map(|warning| format!("warning: models.json: {warning}")),
1610 );
1611 }
1612 if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
1613 warnings = true;
1614 for d in &skill_diags {
1619 details.push(format!(
1620 "warning: skill {} ({}): {}",
1621 d.path,
1622 d.code.as_str(),
1623 d.message
1624 ));
1625 }
1626 for d in &prompt_diags {
1627 details.push(format!(
1628 "warning: prompt template {} ({}): {}",
1629 d.path,
1630 d.code.as_str(),
1631 d.message
1632 ));
1633 }
1634 for d in &resource_diags {
1636 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1637 details.push(format!(
1638 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1639 c.resource_type.as_str(),
1640 c.name,
1641 c.winner_path,
1642 c.loser_path
1643 ));
1644 }
1645 }
1646 }
1647
1648 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1657 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1658 let mut tools = build_tools(&tool_ctx, &effective_args);
1659 merge_extension_tools(
1660 &mut tools,
1661 &extension_session,
1662 &effective_args,
1663 &ctx.tool_context,
1664 );
1665 let mut active = active_tool_names(&tools, &effective_args);
1666 active = filter_active_tool_names(active, &effective_args);
1667 let prompt_tools = active.clone();
1668 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1669 let mut context_paths = Vec::new();
1670 let base_prompt = match effective_args.system_prompt.as_deref() {
1671 Some(explicit) => explicit.to_string(),
1672 None if project_trusted => match discover_system_prompt_file(&ctx.cwd) {
1673 Some(path) => read_prompt_source(&path, &mut context_paths).unwrap_or_else(|| {
1674 default_system_prompt_with_tools(&cwd_str, prior_reasoning_replayed, &prompt_tools)
1675 }),
1676 None => {
1677 default_system_prompt_with_tools(&cwd_str, prior_reasoning_replayed, &prompt_tools)
1678 }
1679 },
1680 None => default_system_prompt_with_tools(&cwd_str, prior_reasoning_replayed, &prompt_tools),
1681 };
1682 let mut append_texts: Vec<String> = Vec::new();
1683 for extra in &effective_args.append_system_prompt {
1684 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
1685 append_texts.push(text);
1686 }
1687 if effective_args.append_system_prompt.is_empty() {
1688 if let Some(path) = project_trusted
1689 .then(|| discover_append_system_prompt_file(&ctx.cwd))
1690 .flatten()
1691 {
1692 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
1693 append_texts.push(text);
1694 }
1695 }
1696 }
1697 context_paths.extend(
1698 context_files
1699 .iter()
1700 .map(|file| file.path.to_string_lossy().into_owned()),
1701 );
1702 let append_join = if append_texts.is_empty() {
1703 None
1704 } else {
1705 Some(append_texts.join("\n\n"))
1706 };
1707 let system_prompt = compose_system_prompt(
1708 Some(&base_prompt),
1709 &[],
1710 if context_block.is_empty() {
1711 None
1712 } else {
1713 Some(&context_block)
1714 },
1715 append_join.as_deref(),
1716 );
1717
1718 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1720 Some(snapshot) => {
1721 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1722 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1723 }
1724 None => ctx.broadcast.clone(),
1725 };
1726
1727 let resources = AgentHarnessResources {
1729 context_files: context_paths,
1730 skills: if skills.is_empty() {
1731 None
1732 } else {
1733 Some(skills.clone())
1734 },
1735 prompt_templates: if prompt_templates.is_empty() {
1736 None
1737 } else {
1738 Some(prompt_templates.clone())
1739 },
1740 };
1741 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1742 let _ = harness.set_resources(resources).await;
1743 let _ = harness.set_agent_emitter(Some(emitter)).await;
1744 let _ = harness
1745 .set_models(build_models_with_extensions_for_reload(
1746 &refreshed
1747 .as_ref()
1748 .map(|resolved| resolved.providers.clone())
1749 .unwrap_or_else(|| {
1750 ctx.providers
1751 .read()
1752 .unwrap_or_else(|error| error.into_inner())
1753 .clone()
1754 }),
1755 &extension_session,
1756 ctx.runtime.clone(),
1757 ))
1758 .await;
1759 let _ = harness
1760 .set_provider_hooks(
1761 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1762 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1763 )
1764 .await;
1765
1766 if let Some(resolved) = &refreshed {
1767 *ctx.providers
1768 .write()
1769 .unwrap_or_else(|error| error.into_inner()) = resolved.providers.clone();
1770 let catalog = crate::provider::authenticated_catalog(resolved);
1771 fresh_bridge
1772 .clone_host()
1773 .replace_model_catalog(catalog.clone());
1774 *ctx.catalog
1775 .write()
1776 .unwrap_or_else(|error| error.into_inner()) = catalog;
1777 let _ = harness.lane("main").set_model(resolved.model.clone()).await;
1780 }
1781
1782 let _ = harness.set_tools(tools, Some(active)).await;
1783
1784 let summary = format!(
1785 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1786 extension_session.loaded_paths().len(),
1787 skills.len(),
1788 prompt_templates.len(),
1789 );
1790 ReloadOutcome {
1791 summary,
1792 had_warnings: warnings,
1793 details,
1794 had_errors: false,
1797 }
1798}
1799
1800#[derive(Clone, Debug, PartialEq, Eq)]
1801struct ReloadSettingsFields {
1802 skill_dirs: Option<Vec<String>>,
1803 prompt_dirs: Option<Vec<String>>,
1804 extension_dirs: Option<Vec<String>>,
1805}
1806
1807impl From<crate::settings::Settings> for ReloadSettingsFields {
1808 fn from(settings: crate::settings::Settings) -> Self {
1809 Self {
1810 skill_dirs: settings.skill_dirs,
1811 prompt_dirs: settings.prompt_dirs,
1812 extension_dirs: settings.extension_dirs,
1813 }
1814 }
1815}
1816
1817#[derive(Clone, Debug, PartialEq, Eq)]
1818struct ReloadSettingsSnapshot {
1819 global: ReloadSettingsFields,
1820 project: Option<ReloadSettingsFields>,
1821}
1822
1823fn reload_reads_settings(args: &Args) -> bool {
1824 !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
1825}
1826
1827fn load_reload_settings_snapshot(
1831 args: &Args,
1832 cwd: &Path,
1833 project_trusted: bool,
1834) -> Result<Option<ReloadSettingsSnapshot>, String> {
1835 if !reload_reads_settings(args) {
1836 return Ok(None);
1837 }
1838 let global = crate::settings::load_settings()
1839 .map_err(|error| format!("could not load global settings: {error}"))?
1840 .into();
1841 let project = if project_trusted {
1842 crate::settings::load_active_project_settings(cwd)
1843 .map_err(|error| format!("could not load project settings: {error}"))?
1844 .map(|(_, settings)| settings.into())
1845 } else {
1846 None
1847 };
1848 Ok(Some(ReloadSettingsSnapshot { global, project }))
1849}
1850
1851#[cfg(test)]
1854fn validate_settings_for_reload(
1855 args: &Args,
1856 cwd: &Path,
1857 project_trusted: bool,
1858) -> Result<(), String> {
1859 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1860}
1861
1862fn prepare_reload_inputs(
1863 args: &Args,
1864 cwd: &Path,
1865 project_trusted: bool,
1866) -> Result<PreparedReloadInputs, String> {
1867 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1868}
1869
1870fn prepare_reload_inputs_inner<F>(
1871 args: &Args,
1872 cwd: &Path,
1873 project_trusted: bool,
1874 before_verify: F,
1875) -> Result<PreparedReloadInputs, String>
1876where
1877 F: FnOnce(),
1878{
1879 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1880
1881 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1882 Vec::new()
1883 } else if args.project_only {
1884 project_extension_dirs(cwd)
1885 } else if project_trusted {
1886 extension_dirs(cwd)
1887 } else {
1888 global_extension_dirs()
1889 };
1890 if !args.no_extensions {
1891 extension_dirs.extend(args.extensions_dir.iter().cloned());
1892 }
1893
1894 let skill_base_dirs = if args.no_skills {
1895 Vec::new()
1896 } else if args.dev_local_only || args.project_only {
1897 project_skill_dirs(cwd)
1898 } else if project_trusted {
1899 skill_dirs(cwd)
1900 } else {
1901 global_skill_dirs()
1902 };
1903
1904 let prompt_base_dirs = if args.no_prompt_templates {
1905 Vec::new()
1906 } else if args.dev_local_only || args.project_only {
1907 project_prompt_template_dirs(cwd)
1908 } else if project_trusted {
1909 prompt_template_dirs(cwd)
1910 } else {
1911 global_prompt_template_dirs()
1912 };
1913
1914 before_verify();
1915 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1916 if settings_before != settings_after {
1917 return Err(
1918 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1919 );
1920 }
1921
1922 Ok(PreparedReloadInputs {
1923 extension_dirs,
1924 skill_base_dirs,
1925 prompt_base_dirs,
1926 })
1927}
1928
1929fn build_models_with_extensions_for_reload(
1934 providers: &[Arc<dyn Provider>],
1935 extension_session: &ExtensionSession,
1936 runtime: tokio::runtime::Handle,
1937) -> Vec<Arc<dyn Provider>> {
1938 let mut models: Vec<Arc<dyn Provider>> = providers.to_vec();
1939 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1940 for provider in pluggable {
1941 if extension_session
1945 .snapshot()
1946 .is_some_and(|snapshot| snapshot.provider_manifest(provider.id()).is_some())
1947 {
1948 continue;
1949 }
1950 if !models.iter().any(|known| known.id() == provider.id()) {
1951 models.push(provider);
1952 }
1953 }
1954 models
1955}
1956
1957fn report_deferred_renderers(session: &ExtensionSession) {
1961 let Some(snap) = session.snapshot_arc() else {
1962 return;
1963 };
1964 let all = snap.renderers();
1965 let markdown = all
1966 .iter()
1967 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1968 .count();
1969 let message = all
1970 .iter()
1971 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1972 .count();
1973 let entry = all
1974 .iter()
1975 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1976 .count();
1977 if markdown + message + entry == 0 {
1978 return;
1979 }
1980 eprintln!(
1981 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1982 markdown, message, entry
1983 );
1984}
1985
1986#[derive(Debug, thiserror::Error)]
1988pub enum BuildError {
1989 #[error("Could not create the session directory: {0}")]
1990 SessionDir(String),
1991 #[error("Could not open the saved session: {0}")]
1992 SessionOpen(String),
1993 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1994 SessionNotFound { requested: String, dir: String },
1995 #[error("Could not build the harness: {0}")]
1996 HarnessCreate(String),
1997}
1998
1999fn build_models_with_extensions(
2002 resolved: &ResolvedModel,
2003 extension_session: &ExtensionSession,
2004 runtime: tokio::runtime::Handle,
2005) -> Vec<Arc<dyn Provider>> {
2006 build_models_with_extensions_for_reload(&resolved.providers, extension_session, runtime)
2007}
2008
2009pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
2012 if let Some(override_value) = args.trust_override {
2013 return override_value;
2014 }
2015 crate::config::project_trust_decision(cwd)
2016 .ok()
2017 .flatten()
2018 .unwrap_or(true)
2019}
2020
2021pub(crate) fn load_extensions(
2029 args: &Args,
2030 cwd: &Path,
2031 project_trusted: bool,
2032 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
2033) -> ExtensionSession {
2034 if args.no_extensions {
2035 return ExtensionSession::none();
2036 }
2037 let mut dirs = if args.dev_local_only {
2038 Vec::new()
2039 } else if args.project_only {
2040 project_extension_dirs(cwd)
2041 } else if project_trusted {
2042 extension_dirs(cwd)
2043 } else {
2044 global_extension_dirs()
2045 };
2046 dirs.extend(args.extensions_dir.iter().cloned());
2047 load_extensions_from_dirs(args, &dirs, action_bridge)
2048}
2049
2050fn load_extensions_from_dirs(
2051 args: &Args,
2052 dirs: &[PathBuf],
2053 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
2054) -> ExtensionSession {
2055 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
2056 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
2063}
2064
2065fn merge_extension_tools(
2071 tools: &mut Vec<HarnessTool>,
2072 session: &ExtensionSession,
2073 args: &Args,
2074 tool_context: &rpi_extensions::ToolCallContext,
2075) {
2076 let Some(snapshot) = session.snapshot() else {
2077 return;
2078 };
2079 for et in snapshot.tools() {
2080 let name = &et.tool.name;
2081 if !tool_name_allowed(name, args) {
2082 continue;
2083 }
2084 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
2085 .with_context(tool_context.clone());
2086 let harness_tool = HarnessTool::new(Arc::new(adapter));
2087 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
2088 Some(slot) => *slot = harness_tool,
2089 None => tools.push(harness_tool),
2090 }
2091 }
2092}
2093
2094pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
2101 use rpi_tools::tools::bash::BashToolOptions;
2102 let default = std::env::var("RPI_BASH_TIMEOUT")
2103 .ok()
2104 .and_then(|v| v.parse::<f64>().ok())
2105 .unwrap_or(120.0);
2106 let settings = crate::settings::load_settings().unwrap_or_default();
2112 BashToolOptions {
2113 command_prefix: settings.shell_command_prefix.clone(),
2114 default_timeout: Some(default),
2115 prepare: None,
2116 }
2117}
2118
2119fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
2120 if args.no_tools {
2121 return Vec::new();
2122 }
2123 let mut all: Vec<(&'static str, HarnessTool)> = vec![
2132 (
2133 "read",
2134 HarnessTool::new(create_read_tool(
2135 ctx,
2136 Some(rpi_tools::tools::read::ReadToolOptions {
2137 image_processor: Some(Arc::new(
2138 rpi_tools::image_processing::InlineImageProcessor,
2139 )),
2140 ..Default::default()
2141 }),
2142 ))
2143 .with_replay(ToolReplay::Safe),
2144 ),
2145 (
2146 "bash",
2147 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
2148 ),
2149 ("edit", HarnessTool::new(create_edit_tool(ctx))),
2150 ("write", HarnessTool::new(create_write_tool(ctx))),
2151 (
2152 "docs",
2153 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
2154 ),
2155 ];
2156
2157 #[cfg(target_os = "windows")]
2159 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
2160
2161 if args.tools.is_none() {
2163 if let Ok(settings) = crate::settings::load_settings() {
2164 if let Some(default_tools) = &settings.default_tools {
2165 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
2166 }
2167 }
2168 }
2169
2170 if args.no_builtin_tools {
2174 all.clear();
2175 }
2176
2177 if let Some(allow) = &args.tools {
2179 all.retain(|(name, _)| allow.iter().any(|a| a == name));
2180 }
2181 if let Some(deny) = &args.exclude_tools {
2183 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
2184 }
2185
2186 all.into_iter().map(|(_, t)| t).collect()
2187}
2188
2189fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
2193 filter_active_tool_names(
2194 tools.iter().map(|tool| tool.tool.schema().name.clone()),
2195 args,
2196 )
2197}
2198
2199pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
2202 if args.no_tools {
2203 return false;
2204 }
2205 if args
2206 .tools
2207 .as_ref()
2208 .is_some_and(|allow| !allow.iter().any(|value| value == name))
2209 {
2210 return false;
2211 }
2212 if args
2213 .exclude_tools
2214 .as_ref()
2215 .is_some_and(|deny| deny.iter().any(|value| value == name))
2216 {
2217 return false;
2218 }
2219 true
2220}
2221
2222pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
2223where
2224 I: IntoIterator<Item = String>,
2225{
2226 names
2227 .into_iter()
2228 .filter(|name| tool_name_allowed(name, args))
2229 .collect()
2230}
2231
2232async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2234 match selection {
2235 SessionSelection::Ephemeral => Ok(ephemeral_session()),
2236 SessionSelection::New { dir, .. } => {
2237 std::fs::create_dir_all(dir)
2240 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2241 let session = create_jsonl_session(dir, cwd)
2242 .await
2243 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2244 Ok(session)
2245 }
2246 SessionSelection::Latest { .. }
2247 | SessionSelection::ById { .. }
2248 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2249 SessionSelection::Fork { source, dir } => fork_session_at_launch(source, dir, cwd).await,
2250 }
2251}
2252
2253async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2259 match selection {
2263 SessionSelection::Latest { dir } => {
2264 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2265 let Some(meta) = first_resumable_session(&metas) else {
2266 return Err(BuildError::SessionNotFound {
2267 requested: "the most recent session".to_string(),
2268 dir: dir.display().to_string(),
2269 });
2270 };
2271 open_session(meta, cwd).await
2272 }
2273 SessionSelection::ById { id, dir } => open_session_by_id_in_dir(id, cwd, dir)
2274 .await
2275 .map_err(|e| match e {
2276 OpenError::NotFound { requested } => BuildError::SessionNotFound {
2277 requested,
2278 dir: dir.display().to_string(),
2279 },
2280 OpenError::Other(msg) => BuildError::SessionOpen(msg),
2281 }),
2282 SessionSelection::ByExactId { id, dir } => {
2283 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2286 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
2287 return open_session(meta, cwd).await;
2288 }
2289 std::fs::create_dir_all(dir)
2290 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2291 create_jsonl_session_with_id(dir, cwd, Some(id.clone()))
2292 .await
2293 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2294 }
2295 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
2296 }
2297}
2298
2299async fn fork_session_at_launch(
2302 source: &str,
2303 dir: &Path,
2304 cwd: &str,
2305) -> Result<Session, BuildError> {
2306 use rpi_harness::session::jsonl::{
2307 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2308 };
2309 use rpi_harness::session::types::{ForkOptions, SessionStorage};
2310 use rpi_tools::FileSystem;
2311
2312 let dir = dir.to_path_buf();
2313 std::fs::create_dir_all(&dir)
2314 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2315 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2316 let fs: Arc<dyn FileSystem> = env.clone();
2317 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2318 fs: fs.clone(),
2319 sessions_root: dir.to_string_lossy().into_owned(),
2320 clock: Arc::new(SystemClock),
2321 ids: Arc::new(DefaultIdGenerator::new()),
2322 });
2323 let metas = repo
2324 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2325 .await
2326 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2327 let source_meta = metas
2328 .iter()
2329 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2330 .ok_or_else(|| BuildError::SessionNotFound {
2331 requested: format!("--fork {source}"),
2332 dir: dir.display().to_string(),
2333 })?;
2334 let fork_storage = repo
2335 .fork_typed(
2336 source_meta,
2337 &JsonlSessionCreateOptions {
2338 id: None,
2339 parent_session_id: Some(source_meta.id.clone()),
2340 cwd: cwd.to_string(),
2341 metadata: None,
2342 },
2343 &ForkOptions::default(),
2344 )
2345 .await
2346 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2347 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
2348 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2349 Ok(Session::new(storage_arc, None))
2350}
2351
2352pub enum OpenError {
2355 NotFound { requested: String },
2357 Other(String),
2359}
2360
2361impl std::fmt::Display for OpenError {
2362 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2363 match self {
2364 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2365 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
2370 }
2371 }
2372}
2373
2374pub async fn list_session_metadata(
2377 cwd: &str,
2378) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2379 let dir = default_session_dir(Path::new(cwd));
2380 list_session_metadata_in_dir(cwd, &dir).await
2381}
2382
2383pub(crate) async fn list_session_metadata_in_dir(
2384 cwd: &str,
2385 dir: &Path,
2386) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2387 use rpi_harness::session::jsonl::{
2388 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2389 };
2390 use rpi_tools::FileSystem;
2391
2392 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2393 let fs: Arc<dyn FileSystem> = env.clone();
2394 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2395 fs: fs.clone(),
2396 sessions_root: dir.to_string_lossy().into_owned(),
2397 clock: Arc::new(SystemClock),
2398 ids: Arc::new(DefaultIdGenerator::new()),
2399 });
2400 repo.list_typed(&JsonlSessionListOptions::default())
2401 .await
2402 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2403}
2404
2405#[derive(Debug, Clone, Default, PartialEq, Eq)]
2421pub struct SessionSummary {
2422 pub name: Option<String>,
2424 pub preview: Option<String>,
2426 pub empty: bool,
2428 pub bytes: u64,
2430}
2431
2432const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
2434const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
2436const SUMMARY_PREVIEW_CHARS: usize = 80;
2438
2439pub fn summarize_session_file(path: &Path) -> SessionSummary {
2443 use std::io::{Read, Seek, SeekFrom};
2444
2445 let Ok(mut file) = std::fs::File::open(path) else {
2446 return SessionSummary::default();
2447 };
2448 let Ok(size) = file.metadata().map(|m| m.len()) else {
2449 return SessionSummary::default();
2450 };
2451 if size == 0 {
2452 return SessionSummary {
2453 name: None,
2454 preview: None,
2455 empty: true,
2456 bytes: 0,
2457 };
2458 }
2459
2460 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2463 let head_len = file.read(&mut head).unwrap_or(0);
2464 head.truncate(head_len);
2465 let head = String::from_utf8_lossy(&head);
2466 let mut lines: Vec<&str> = head.lines().collect();
2467 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2468 let empty = (size as usize) <= header_len + 1;
2470 if (head_len as u64) < size && !lines.is_empty() {
2473 lines.pop();
2474 }
2475
2476 let mut summary = SessionSummary {
2477 name: None,
2478 preview: None,
2479 empty,
2480 bytes: size,
2481 };
2482 for line in lines {
2483 apply_summary_line(line, &mut summary);
2484 }
2485
2486 if size > SUMMARY_HEAD_BYTES
2490 && file
2491 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2492 .is_ok()
2493 {
2494 let mut tail = Vec::new();
2495 if file.read_to_end(&mut tail).is_ok() {
2496 let tail = String::from_utf8_lossy(&tail);
2497 let mut tail_lines = tail.lines();
2498 tail_lines.next();
2500 for line in tail_lines {
2501 if let Some(name) = name_from_line(line) {
2502 summary.name = name;
2503 }
2504 }
2505 }
2506 }
2507 summary
2508}
2509
2510fn name_from_line(line: &str) -> Option<Option<String>> {
2513 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2514 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2515 return None;
2516 }
2517 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2518 return None;
2519 }
2520 Some(match value.get("name") {
2521 Some(serde_json::Value::String(name)) => Some(name.clone()),
2522 _ => None,
2523 })
2524}
2525
2526fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2529 if let Some(name) = name_from_line(line) {
2530 summary.name = name;
2531 return;
2532 }
2533 if summary.preview.is_some() {
2534 return;
2535 }
2536 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2537 return;
2538 };
2539 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2540 return;
2541 }
2542 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2543 return;
2544 }
2545 let Some(message) = value.get("message") else {
2546 return;
2547 };
2548 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2549 return;
2550 }
2551 let text = match message.get("content") {
2552 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2553 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2554 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2555 .then(|| block.get("text").and_then(|v| v.as_str()))
2556 .flatten()
2557 }),
2558 _ => None,
2559 };
2560 let Some(text) = text else { return };
2561 let collapsed = text
2562 .lines()
2563 .map(str::trim)
2564 .filter(|line| !line.is_empty())
2565 .collect::<Vec<_>>()
2566 .join(" ");
2567 if collapsed.is_empty() {
2568 return;
2569 }
2570 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2571}
2572
2573pub fn session_display_label(summary: &SessionSummary) -> String {
2577 if let Some(name) = summary.name.as_deref().map(str::trim) {
2578 if !name.is_empty() {
2579 return name.to_string();
2580 }
2581 }
2582 match summary.preview.as_deref().map(str::trim) {
2583 Some(preview) if !preview.is_empty() => preview.to_string(),
2584 _ => "(no messages)".to_string(),
2585 }
2586}
2587
2588pub fn format_session_bytes(bytes: u64) -> String {
2591 if bytes >= 1_048_576 {
2592 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2593 } else if bytes >= 1024 {
2594 format!("{}KB", bytes / 1024)
2595 } else {
2596 format!("{bytes}B")
2597 }
2598}
2599
2600pub fn short_session_id(id: &str) -> &str {
2603 &id[..id.len().min(8)]
2604}
2605
2606pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2615 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2616 let mut visit = |path: PathBuf| {
2617 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2618 return;
2619 }
2620 let modified = std::fs::metadata(&path)
2621 .and_then(|m| m.modified())
2622 .unwrap_or(std::time::UNIX_EPOCH);
2623 files.push((path, modified));
2624 };
2625 if let Ok(entries) = std::fs::read_dir(dir) {
2626 for entry in entries.flatten() {
2627 let path = entry.path();
2628 if path.is_dir() {
2629 if let Ok(inner) = std::fs::read_dir(&path) {
2630 for child in inner.flatten() {
2631 visit(child.path());
2632 }
2633 }
2634 } else {
2635 visit(path);
2636 }
2637 }
2638 }
2639 files.sort_by(|a, b| b.1.cmp(&a.1));
2640 files.into_iter().map(|(path, _)| path).collect()
2641}
2642
2643fn first_resumable_session(
2648 metas: &[rpi_harness::session::jsonl::JsonlSessionMetadata],
2649) -> Option<&rpi_harness::session::jsonl::JsonlSessionMetadata> {
2650 metas
2651 .iter()
2652 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2653 .or_else(|| metas.first())
2654}
2655
2656pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2660 let dir = default_session_dir(Path::new(cwd));
2661 open_session_by_id_in_dir(id, cwd, &dir).await
2662}
2663
2664pub(crate) async fn open_session_by_id_in_dir(
2665 id: &str,
2666 cwd: &str,
2667 dir: &Path,
2668) -> Result<Session, OpenError> {
2669 let metas = list_session_metadata_in_dir(cwd, dir)
2670 .await
2671 .map_err(|e| OpenError::Other(e.to_string()))?;
2672 let Some(meta) = metas
2673 .iter()
2674 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2675 else {
2676 return Err(OpenError::NotFound {
2677 requested: format!("session {id}"),
2678 });
2679 };
2680 open_session(meta, cwd).await.map_err(|e| {
2681 OpenError::Other(match e {
2682 BuildError::SessionOpen(reason) => reason,
2686 other => other.to_string(),
2687 })
2688 })
2689}
2690
2691fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
2716 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
2717 tool_context.set_session_id(id);
2718}
2719
2720pub(crate) async fn set_active_session(
2731 harness: &AgentHarness,
2732 session: Session,
2733 tool_context: Option<&rpi_extensions::ToolCallContext>,
2734) -> Result<(), String> {
2735 let metadata = session.get_metadata().await.ok();
2736 harness
2737 .set_session(session)
2738 .await
2739 .map_err(|e| e.to_string())?;
2740 if let Some(metadata) = metadata {
2741 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2742 if let Some(context) = tool_context {
2743 context.set_session_id(metadata.id);
2744 }
2745 }
2746 Ok(())
2747}
2748
2749pub(crate) async fn fork_session_storage(
2756 harness: &AgentHarness,
2757 cwd: &str,
2758 dir: &Path,
2759) -> Result<Session, String> {
2760 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2761 use rpi_tools::FileSystem;
2762
2763 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2764 let fs: Arc<dyn FileSystem> = env.clone();
2765 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2766 fs,
2767 sessions_root: dir.to_string_lossy().into_owned(),
2768 clock: Arc::new(SystemClock),
2769 ids: Arc::new(DefaultIdGenerator::new()),
2770 });
2771 let id = harness.session().storage().metadata().id.clone();
2774 let metas = list_session_metadata_in_dir(cwd, dir)
2775 .await
2776 .map_err(|e| e.to_string())?;
2777 let Some(source) = metas.iter().find(|m| m.id == id) else {
2778 return Err(format!("current session {id} not found on disk"));
2779 };
2780 let fork_storage = repo
2781 .fork_typed(
2782 source,
2783 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2784 id: None,
2785 parent_session_id: Some(source.id.clone()),
2786 cwd: cwd.to_string(),
2787 metadata: None,
2788 },
2789 &rpi_harness::session::types::ForkOptions::default(),
2790 )
2791 .await
2792 .map_err(|e| e.to_string())?;
2793 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2794 Ok(Session::new(Arc::new(fork_storage), None))
2795}
2796
2797pub(crate) async fn import_session_file(
2801 source: &Path,
2802 cwd: &str,
2803 dir: &Path,
2804) -> Result<Session, String> {
2805 use rpi_harness::session::jsonl::{
2806 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions, JsonlSessionStorage,
2807 };
2808 use rpi_tools::FileSystem;
2809
2810 let dir = if dir.is_absolute() {
2811 dir.to_path_buf()
2812 } else {
2813 Path::new(cwd).join(dir)
2814 };
2815 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
2816 let staged = tempfile::NamedTempFile::new_in(&dir)
2817 .map_err(|e| e.to_string())?
2818 .into_temp_path();
2819 std::fs::copy(source, &staged).map_err(|e| format!("copy import: {e}"))?;
2820 let fs: Arc<dyn FileSystem> = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2821 let validated = JsonlSessionStorage::load(
2822 fs.clone(),
2823 &staged.to_string_lossy(),
2824 Arc::new(SystemClock),
2825 Arc::new(DefaultIdGenerator::new()),
2826 )
2827 .await
2828 .map_err(|e| format!("invalid session import: {e}"))?;
2829 let mut metadata = validated.jsonl_metadata().clone();
2830 drop(validated);
2831 if list_session_metadata_in_dir(cwd, &dir)
2832 .await
2833 .map_err(|e| e.to_string())?
2834 .iter()
2835 .any(|saved| saved.id == metadata.id)
2836 {
2837 return Err(format!(
2838 "session {} already exists in {}",
2839 metadata.id,
2840 dir.display()
2841 ));
2842 }
2843 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2844 fs,
2845 sessions_root: dir.to_string_lossy().into_owned(),
2846 clock: Arc::new(SystemClock),
2847 ids: Arc::new(DefaultIdGenerator::new()),
2848 });
2849 let destination = repo
2851 .create_typed(&JsonlSessionCreateOptions {
2852 id: Some(metadata.id.clone()),
2853 parent_session_id: metadata.parent_session_id.clone(),
2854 cwd: cwd.to_string(),
2855 metadata: metadata.metadata.clone(),
2856 })
2857 .await
2858 .map_err(|e| e.to_string())?;
2859 let path = destination.jsonl_metadata().path.clone();
2860 drop(destination);
2861 if let Err(error) = claim_session_lock(&path) {
2862 return Err(error);
2864 }
2865 if let Err(error) = staged.persist(&path) {
2866 let _ = std::fs::remove_file(&path);
2867 return Err(format!("publish import: {error}"));
2868 }
2869 metadata.path = path;
2870 let storage = repo
2871 .open_by_jsonl_metadata(&metadata)
2872 .await
2873 .map_err(|e| e.to_string())?;
2874 Ok(Session::new(Arc::new(storage), None))
2875}
2876
2877static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2879static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2882
2883fn claim_session_lock(session_path: &str) -> Result<(), String> {
2892 let lock_path = PathBuf::from(format!("{session_path}.lock"));
2893 if SESSION_LOCK_PATHS
2894 .get_or_init(|| Mutex::new(HashSet::new()))
2895 .lock()
2896 .unwrap()
2897 .contains(&lock_path)
2898 {
2899 return Ok(());
2900 }
2901 let file = std::fs::OpenOptions::new()
2902 .create(true)
2903 .write(true)
2904 .open(&lock_path)
2905 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2906 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2907 return Err(format!(
2908 "session {session_path} is already open in another rpi process"
2909 ));
2910 }
2911 SESSION_LOCKS
2912 .get_or_init(|| Mutex::new(Vec::new()))
2913 .lock()
2914 .unwrap()
2915 .push(file);
2916 SESSION_LOCK_PATHS
2917 .get_or_init(|| Mutex::new(HashSet::new()))
2918 .lock()
2919 .unwrap()
2920 .insert(lock_path);
2921 Ok(())
2922}
2923
2924async fn open_session(
2927 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2928 cwd: &str,
2929) -> Result<Session, BuildError> {
2930 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2931 use rpi_harness::session::types::SessionStorage;
2932 use rpi_tools::FileSystem;
2933
2934 let dir = default_session_dir(Path::new(cwd));
2935 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2936 let fs: Arc<dyn FileSystem> = env.clone();
2937 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2938 fs: fs.clone(),
2939 sessions_root: dir.to_string_lossy().into_owned(),
2940 clock: Arc::new(SystemClock),
2941 ids: Arc::new(DefaultIdGenerator::new()),
2942 });
2943 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2944 let storage = repo
2945 .open_by_jsonl_metadata(meta)
2946 .await
2947 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2948 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2952 Some(Path::new(&meta.path)),
2953 Path::new(&meta.cwd),
2954 Path::new(cwd),
2955 ) {
2956 eprintln!(
2957 "warning: {}",
2958 crate::session_cwd::format_missing_session_cwd_error(&issue)
2959 );
2960 }
2961 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2962 Ok(Session::new(storage_arc, None))
2963}
2964
2965pub(crate) fn ephemeral_session() -> Session {
2969 let storage = Arc::new(InMemorySessionStorage::new(
2970 SessionMetadata {
2971 id: "ephemeral".into(),
2972 created_at: 0,
2973 parent_session_id: None,
2974 },
2975 Arc::new(SystemClock),
2976 Arc::new(DefaultIdGenerator::new()),
2977 ));
2978 Session::new(storage, None).with_volatile_progress()
2979}
2980
2981pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2992 create_jsonl_session_with_id(dir, cwd, None).await
2993}
2994
2995pub(crate) async fn create_jsonl_session_with_id(
2999 dir: &Path,
3000 cwd: &str,
3001 id: Option<String>,
3002) -> Result<Session, String> {
3003 use rpi_harness::session::jsonl::{
3004 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
3005 };
3006 use rpi_tools::FileSystem;
3007
3008 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3011 let fs: Arc<dyn FileSystem> = env.clone();
3012
3013 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3014 fs: fs.clone(),
3015 sessions_root: dir.to_string_lossy().into_owned(),
3016 clock: Arc::new(SystemClock),
3017 ids: Arc::new(DefaultIdGenerator::new()),
3018 });
3019
3020 let opts = JsonlSessionCreateOptions {
3021 id, parent_session_id: None,
3023 cwd: cwd.to_string(),
3024 metadata: None,
3025 };
3026 let storage = repo
3027 .create_typed(&opts)
3028 .await
3029 .map_err(|e| format!("create session: {e}"))?;
3030 claim_session_lock(&storage.jsonl_metadata().path)
3031 .map_err(|e| format!("create session: {e}"))?;
3032 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
3034 Ok(Session::new(storage_arc, None))
3035}
3036
3037fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
3040 let path = Path::new(target);
3041 if path.is_file() {
3042 read_prompt_source(path, sources)
3043 } else {
3044 None
3045 }
3046}
3047
3048fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
3051 let text = std::fs::read_to_string(path).ok()?;
3052 let absolute = if path.is_absolute() {
3053 path.to_path_buf()
3054 } else {
3055 std::env::current_dir()
3056 .map(|cwd| cwd.join(path))
3057 .unwrap_or_else(|_| path.to_path_buf())
3058 };
3059 let source = absolute.to_string_lossy().into_owned();
3060 if !sources.contains(&source) {
3061 sources.push(source);
3062 }
3063 Some(text)
3064}
3065
3066#[cfg(test)]
3067mod runtime_session_tests;
3068
3069#[cfg(test)]
3070mod tests {
3071 use super::*;
3072
3073 #[tokio::test]
3074 async fn unauthenticated_packages_are_not_reintroduced_as_legacy_adapters() {
3075 extern "C" fn request(
3076 _: rpi_plugin_sdk::StbStringRef,
3077 _: *mut rpi_plugin_sdk::StbString,
3078 _: *mut std::ffi::c_void,
3079 ) -> i32 {
3080 1
3081 }
3082 let mut registry = rpi_extensions::ExtensionRegistry::new();
3083 for id in ["package", "legacy"] {
3084 registry.register_provider(rpi_extensions::RegisteredProvider {
3085 provider_id: id.into(),
3086 base_url: "http://localhost".into(),
3087 api_style: "openai-completions".into(),
3088 request_fn: request,
3089 stream_fn: None,
3090 plugin_free_string: rpi_plugin_sdk::callbacks::free_string,
3091 user_data: std::ptr::null_mut(),
3092 });
3093 }
3094 assert!(registry.apply_declaration_json(&serde_json::json!({"providers":[{
3095 "id":"package", "requiresAuth":true,
3096 "models":[rpi_ai::Model::new("model", "Model", rpi_ai::Api::OpenaiCompletions, "package", "http://localhost")]
3097 }]}).to_string()));
3098 let session = ExtensionSession::from_parts(
3099 Arc::new(registry.snapshot()),
3100 rpi_extensions::PluginKeepalive::empty(),
3101 vec![],
3102 None,
3103 );
3104 let providers = build_models_with_extensions_for_reload(
3105 &[],
3106 &session,
3107 tokio::runtime::Handle::current(),
3108 );
3109 assert_eq!(
3110 providers
3111 .iter()
3112 .map(|provider| provider.id())
3113 .collect::<Vec<_>>(),
3114 ["legacy"]
3115 );
3116 }
3117 use crate::args::Args;
3118
3119 #[test]
3120 fn loaded_context_paths_track_precedence_and_reload() {
3121 const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
3122 if std::env::var_os(CHILD).is_none() {
3123 let dir = tempfile::tempdir().unwrap();
3124 let output = std::process::Command::new(std::env::current_exe().unwrap())
3125 .args([
3126 "--exact",
3127 "session::tests::loaded_context_paths_track_precedence_and_reload",
3128 "--nocapture",
3129 ])
3130 .env(CHILD, "1")
3131 .env(crate::config::CONFIG_DIR_ENV, dir.path())
3132 .output()
3133 .unwrap();
3134 assert!(
3135 output.status.success(),
3136 "{}\n{}",
3137 String::from_utf8_lossy(&output.stdout),
3138 String::from_utf8_lossy(&output.stderr)
3139 );
3140 return;
3141 }
3142 tokio::runtime::Builder::new_current_thread()
3143 .enable_all()
3144 .build()
3145 .unwrap()
3146 .block_on(async {
3147 let agent = crate::config::agent_dir().unwrap();
3148 let cwd = agent.join("project");
3149 let project_config = cwd.join(".rpi");
3150 std::fs::create_dir_all(&project_config).unwrap();
3151 for (path, content) in [
3152 (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
3153 (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
3154 (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
3155 (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
3156 (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
3157 (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
3158 (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
3159 ] {
3160 std::fs::write(path, content).unwrap();
3161 }
3162 let resolved = crate::provider::resolve(
3163 Some("anthropic"),
3164 Some(crate::provider::DEFAULT_MODEL_ID),
3165 None,
3166 Some("test-key"),
3167 None,
3168 )
3169 .unwrap();
3170 let args = Args {
3171 no_session: true,
3172 no_extensions: true,
3173 no_skills: true,
3174 no_prompt_templates: true,
3175 trust_override: Some(true),
3176 ..Default::default()
3177 };
3178 let (harness, _events, mut context) =
3179 build(&resolved, &args, &cwd, true).await.unwrap();
3180 let resources = harness.get_resources().await.unwrap();
3181 let path = |p: PathBuf| p.to_string_lossy().into_owned();
3182 assert_eq!(
3183 &resources.context_files[..2],
3184 &[
3185 path(project_config.join("SYSTEM.md")),
3186 path(project_config.join("APPEND_SYSTEM.md")),
3187 ]
3188 );
3189 assert!(resources
3190 .context_files
3191 .contains(&path(agent.join("AGENTS.md"))));
3192 assert!(resources
3193 .context_files
3194 .contains(&path(cwd.join("AGENTS.md"))));
3195 assert!(!resources
3196 .context_files
3197 .contains(&path(cwd.join("CLAUDE.md"))));
3198 assert!(!resources
3199 .context_files
3200 .contains(&path(agent.join("SYSTEM.md"))));
3201 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3202 assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
3203 assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
3204
3205 std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
3208 std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
3209 std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
3210 assert!(
3211 !reload_extension_resources(&harness, &context)
3212 .await
3213 .had_errors
3214 );
3215 let resources = harness.get_resources().await.unwrap();
3216 assert_eq!(
3217 &resources.context_files[..2],
3218 &[
3219 path(agent.join("SYSTEM.md")),
3220 path(agent.join("APPEND_SYSTEM.md")),
3221 ]
3222 );
3223 assert!(resources
3224 .context_files
3225 .contains(&path(cwd.join("CLAUDE.md"))));
3226 assert!(!resources
3227 .context_files
3228 .contains(&path(project_config.join("SYSTEM.md"))));
3229
3230 let explicit = agent.join("custom-append.md");
3233 std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
3234 context.args.system_prompt = Some("EXPLICIT_BASE".into());
3235 context.args.append_system_prompt =
3236 vec![path(explicit.clone()), "literal append".into()];
3237 context.args.no_context_files = true;
3238 assert!(
3239 !reload_extension_resources(&harness, &context)
3240 .await
3241 .had_errors
3242 );
3243 assert_eq!(
3244 harness.get_resources().await.unwrap().context_files,
3245 vec![path(explicit)]
3246 );
3247
3248 context.args.system_prompt = None;
3250 context.args.append_system_prompt.clear();
3251 context.args.no_context_files = false;
3252 context.args.trust_override = Some(false);
3253 assert!(
3254 !reload_extension_resources(&harness, &context)
3255 .await
3256 .had_errors
3257 );
3258 assert!(harness
3259 .get_resources()
3260 .await
3261 .unwrap()
3262 .context_files
3263 .is_empty());
3264 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3265 assert!(
3266 !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
3267 );
3268 });
3269 }
3270
3271 #[test]
3272 fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
3273 let dir = tempfile::tempdir().unwrap();
3274 let file = dir.path().join("SYSTEM.md");
3275 let mut sources = Vec::new();
3276 assert!(read_prompt_source(&file, &mut sources).is_none());
3277 std::fs::write(&file, [0xff, 0xfe]).unwrap();
3278 assert!(read_prompt_source(&file, &mut sources).is_none());
3279 assert!(read_append_target("literal instructions", &mut sources).is_none());
3280 assert!(sources.is_empty());
3281 std::fs::write(&file, "loaded instructions").unwrap();
3282 assert_eq!(
3283 read_prompt_source(&file, &mut sources).as_deref(),
3284 Some("loaded instructions")
3285 );
3286 assert!(read_prompt_source(&file, &mut sources).is_some());
3287 assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
3288 }
3289
3290 extern "C" fn deny_test_tool_call(
3291 _event: rpi_plugin_sdk::StablePluginEvent,
3292 _data: *mut std::ffi::c_void,
3293 ) -> i32 {
3294 rpi_plugin_sdk::EVENT_HANDLER_ABORT
3295 }
3296
3297 async fn invoke_test_tool_hook(
3298 hook: &rpi_agent::BeforeToolCall,
3299 ) -> Option<rpi_agent::BeforeToolCallResult> {
3300 let message = rpi_ai::providers::faux::faux_assistant_message(
3301 "approval",
3302 rpi_ai::types::StopReason::ToolUse,
3303 );
3304 let rpi_ai::types::Content::ToolCall(call) =
3305 rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
3306 else {
3307 unreachable!()
3308 };
3309 hook(
3310 rpi_agent::BeforeToolCallContext {
3311 assistant_message: &message,
3312 tool_call: &call,
3313 args: &call.arguments,
3314 context: &rpi_agent::AgentContext::default(),
3315 },
3316 tokio_util::sync::CancellationToken::new(),
3317 )
3318 .await
3319 }
3320
3321 #[tokio::test]
3322 async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
3323 let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
3324 let hook =
3325 extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
3326 assert!(invoke_test_tool_hook(&hook).await.is_none());
3327 for name in ["first-gate", "reloaded-gate"] {
3328 let mut registry = rpi_extensions::ExtensionRegistry::new();
3329 registry.register_event_handler(
3330 name.into(),
3331 rpi_plugin_sdk::EventTag::ToolCall,
3332 deny_test_tool_call,
3333 std::ptr::null_mut(),
3334 );
3335 let fresh = ExtensionSession::from_parts(
3336 Arc::new(registry.snapshot()),
3337 rpi_extensions::PluginKeepalive::empty(),
3338 Vec::new(),
3339 None,
3340 );
3341 {
3342 let mut current = sessions.lock().unwrap();
3343 if let Some(snapshot) = current.snapshot() {
3344 snapshot.active_flag().store(false, Ordering::SeqCst);
3345 }
3346 *current = fresh;
3347 }
3348 let result = invoke_test_tool_hook(&hook).await.unwrap();
3349 assert!(result.block);
3350 assert!(result.reason.unwrap().contains(name));
3351 }
3352 *sessions.lock().unwrap() = ExtensionSession::none();
3353 assert!(invoke_test_tool_hook(&hook).await.is_none());
3354 }
3355
3356 fn scratch(name: &str) -> PathBuf {
3358 let nanos = std::time::SystemTime::now()
3359 .duration_since(std::time::UNIX_EPOCH)
3360 .unwrap()
3361 .as_nanos();
3362 let path = std::env::temp_dir().join(format!(
3363 "rpi-summary-{}-{nanos}-{name}.jsonl",
3364 std::process::id()
3365 ));
3366 path
3367 }
3368
3369 #[test]
3370 fn summary_marks_a_header_only_session_empty() {
3371 let path = scratch("empty");
3372 std::fs::write(
3373 &path,
3374 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3375 )
3376 .unwrap();
3377 let summary = summarize_session_file(&path);
3378 assert!(summary.empty, "a header-only file has nothing to resume");
3379 assert!(summary.preview.is_none());
3380 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
3381 std::fs::remove_file(&path).ok();
3382 }
3383
3384 #[test]
3385 fn summary_reads_the_first_user_prompt_and_name() {
3386 let path = scratch("content");
3387 let body = concat!(
3388 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3389 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
3390 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
3391 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
3392 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
3393 );
3394 std::fs::write(&path, body).unwrap();
3395 let summary = summarize_session_file(&path);
3396 assert!(!summary.empty);
3397 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
3398 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
3399 std::fs::remove_file(&path).ok();
3400 }
3401
3402 #[test]
3403 fn summary_extracts_a_block_style_user_prompt() {
3404 let path = scratch("blocks");
3405 let body = concat!(
3406 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3407 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
3408 );
3409 std::fs::write(&path, body).unwrap();
3410 let summary = summarize_session_file(&path);
3411 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
3412 assert_eq!(session_display_label(&summary), "line one line two");
3413 std::fs::remove_file(&path).ok();
3414 }
3415
3416 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
3417 rpi_harness::session::jsonl::JsonlSessionMetadata {
3418 id: id.to_string(),
3419 created_at: 0,
3420 cwd: ".".to_string(),
3421 path: path.to_string_lossy().into_owned(),
3422 modified_at: 0,
3423 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
3424 parent_session_id: None,
3425 legacy_parent_session_path: None,
3426 metadata: None,
3427 }
3428 }
3429
3430 #[test]
3431 fn continue_skips_newer_empty_sessions() {
3432 let empty = scratch("newest-empty");
3433 std::fs::write(
3434 &empty,
3435 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3436 )
3437 .unwrap();
3438 let content = scratch("older-content");
3439 std::fs::write(
3440 &content,
3441 concat!(
3442 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3443 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
3444 ),
3445 )
3446 .unwrap();
3447
3448 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
3449 let picked = first_resumable_session(&metas).expect("a resumable session");
3450 assert_eq!(picked.id, "old");
3451
3452 let all_empty = vec![meta_for(&empty, "empty")];
3455 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
3456
3457 std::fs::remove_file(&empty).ok();
3458 std::fs::remove_file(&content).ok();
3459 }
3460
3461 #[test]
3462 fn list_session_files_sync_walks_the_cwd_subdirectory() {
3463 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
3464 let nested = root.join("--proj--");
3465 std::fs::create_dir_all(&nested).unwrap();
3466 let file = nested.join("2026-01-01_a.jsonl");
3467 std::fs::write(&file, "{}\n").unwrap();
3468 let found = list_session_files_sync(&root);
3469 assert!(found.iter().any(|p| p == &file));
3470 std::fs::remove_dir_all(&root).ok();
3471 }
3472
3473 fn default_prompt_tools() -> Vec<String> {
3475 BUILTIN_TOOL_NAMES
3476 .iter()
3477 .copied()
3478 .chain(["todo"])
3479 .map(str::to_owned)
3480 .collect()
3481 }
3482
3483 #[test]
3484 fn default_prompt_lists_only_the_active_tools() {
3485 let tools = ["read", "grep", "find", "ls"].map(str::to_owned);
3486 let p = default_system_prompt_with_tools("/tmp/proj", true, &tools);
3487 assert!(p.contains("- read — Read file contents"));
3488 assert!(p.contains("- grep — Search file contents"));
3489 for absent in [
3490 "- edit",
3491 "- write",
3492 "- bash",
3493 "- docs",
3494 "todo",
3495 "PowerShell cmdlets",
3496 "project documentation",
3497 ] {
3498 assert!(
3499 !p.contains(absent),
3500 "{absent} must not appear for a read-only run: {p}"
3501 );
3502 }
3503 assert!(p.contains("Re-stating the same plan without acting on it is a bug"));
3504 let none = default_system_prompt_with_tools("/tmp/proj", false, &[]);
3505 assert!(none.contains("Available tools:\n(none)"));
3506 }
3507
3508 #[test]
3509 fn default_prompt_mentions_cwd_and_tools() {
3510 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
3511 assert!(p.contains("/tmp/proj"));
3512 assert!(p.contains("read"));
3513 assert!(p.contains("bash"));
3514 assert!(p.contains("edit"));
3515 assert!(p.contains("write"));
3516 assert!(p.contains("docs"));
3517 assert!(!p.contains("- grep"));
3518 assert!(!p.contains("- find"));
3519 assert!(!p.contains("- ls"));
3520 assert!(!p.contains("- powershell"));
3521 assert!(p.contains("Bash/POSIX syntax, including on Windows"));
3522 assert!(p.contains("do not pass PowerShell cmdlets"));
3523 }
3524
3525 #[test]
3526 fn default_prompt_instructs_language_matching() {
3527 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
3531 let lower = p.to_lowercase();
3532 assert!(
3533 lower.contains("same language") && lower.contains("detect"),
3534 "the prompt must tell the model to detect and mirror the user's language: {p}"
3535 );
3536 }
3537
3538 #[test]
3539 fn default_prompt_keeps_working_state_in_the_visible_channel() {
3540 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
3548 let lower = p.to_lowercase();
3549 assert!(
3551 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
3552 "the prompt must explain that reasoning does not survive the turn: {p}"
3553 );
3554 assert!(
3556 lower.contains("visible") && lower.contains("todo tool"),
3557 "the prompt must point at visible text and the todo tool: {p}"
3558 );
3559 assert!(
3561 lower.contains("re-stating the same plan"),
3562 "the prompt must call out plan re-statement as a bug: {p}"
3563 );
3564 assert!(
3566 lower.contains("already shown earlier"),
3567 "the prompt must discourage re-fetching known content: {p}"
3568 );
3569 }
3570
3571 #[test]
3572 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
3573 let p = default_system_prompt_with_tools("/tmp/proj", true, &default_prompt_tools());
3580 let lower = p.to_lowercase();
3581 assert!(
3582 lower.contains("carried back"),
3583 "a replaying model must be told its reasoning survives: {p}"
3584 );
3585 assert!(
3586 !lower.contains("reasoning is not"),
3587 "the replayed variant must not claim reasoning is dropped: {p}"
3588 );
3589 assert!(
3590 lower.contains("do not re-derive"),
3591 "the replayed variant must forbid re-deriving the plan: {p}"
3592 );
3593 assert!(
3594 lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
3595 "the anti-repetition rule must survive the flip: {p}"
3596 );
3597 assert!(
3598 lower.contains("already shown earlier"),
3599 "the re-read rule must survive the flip: {p}"
3600 );
3601 }
3602
3603 #[test]
3611 fn only_read_only_tools_are_replayable() {
3612 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3613 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3614 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3615 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3616 let tools = build_tools(&context, &Args::default());
3617
3618 let replay_of = |name: &str| {
3619 tools
3620 .iter()
3621 .find(|tool| tool.tool.schema().name == name)
3622 .map(|tool| tool.replay)
3623 };
3624
3625 for read_only in ["read", "docs"] {
3626 assert_eq!(
3627 replay_of(read_only),
3628 Some(ToolReplay::Safe),
3629 "{read_only} is side-effect free, so recovery may re-run it"
3630 );
3631 }
3632 for mutating in ["bash", "edit", "write", "powershell"] {
3633 if let Some(replay) = replay_of(mutating) {
3634 assert_eq!(
3635 replay,
3636 ToolReplay::Never,
3637 "{mutating} must never be replayed: re-running it would duplicate its effect"
3638 );
3639 }
3640 }
3641 }
3642
3643 #[test]
3644 fn default_tools_include_docs_lookup() {
3645 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3646 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3647 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3648 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3649 let names: Vec<String> = build_tools(&context, &Args::default())
3650 .iter()
3651 .map(|tool| tool.tool.schema().name.clone())
3652 .collect();
3653
3654 assert!(names.contains(&"docs".to_string()));
3659 for legacy in ["grep", "find", "ls"] {
3660 assert!(
3661 !names.contains(&legacy.to_string()),
3662 "legacy tool {legacy} should not be registered by default"
3663 );
3664 }
3665 }
3666
3667 #[test]
3668 fn reload_settings_preflight_respects_trust() {
3669 struct RestoreConfigDir(Option<std::ffi::OsString>);
3670
3671 impl Drop for RestoreConfigDir {
3672 fn drop(&mut self) {
3673 match self.0.take() {
3674 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3675 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3676 }
3677 }
3678 }
3679
3680 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3681 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3682 let tmp = tempfile::tempdir().unwrap();
3683 let agent = tmp.path().join("agent");
3684 let cwd = tmp.path().join("project");
3685 std::fs::create_dir_all(&agent).unwrap();
3686 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3687 std::fs::write(agent.join("settings.json"), "{}").unwrap();
3688 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
3689 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3690 let args = Args::default();
3691
3692 let trusted = validate_settings_for_reload(&args, &cwd, true);
3693 let untrusted = validate_settings_for_reload(&args, &cwd, false);
3694 assert!(trusted
3695 .unwrap_err()
3696 .contains("could not load project settings"));
3697 assert!(untrusted.is_ok());
3698
3699 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3700 assert!(validate_settings_for_reload(&args, &cwd, false)
3701 .unwrap_err()
3702 .contains("could not load global settings"));
3703
3704 let local_only = Args {
3705 dev_local_only: true,
3706 ..Args::default()
3707 };
3708 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
3709 }
3710
3711 #[tokio::test(flavor = "current_thread")]
3712 async fn reload_preserves_live_resources_when_settings_break() {
3713 struct RestoreConfigDir(Option<std::ffi::OsString>);
3714
3715 impl Drop for RestoreConfigDir {
3716 fn drop(&mut self) {
3717 match self.0.take() {
3718 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3719 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3720 }
3721 }
3722 }
3723
3724 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3725 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3726 let tmp = tempfile::tempdir().unwrap();
3727 let agent = tmp.path().join("agent");
3728 let cwd = tmp.path().join("project");
3729 let skill_dir = tmp.path().join("configured-skills");
3730 std::fs::create_dir_all(&agent).unwrap();
3731 std::fs::create_dir_all(&cwd).unwrap();
3732 std::fs::create_dir_all(&skill_dir).unwrap();
3733 std::fs::write(
3734 skill_dir.join("SKILL.md"),
3735 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
3736 )
3737 .unwrap();
3738 std::fs::write(
3739 agent.join("settings.json"),
3740 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3741 )
3742 .unwrap();
3743 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3744
3745 let resolved = crate::provider::resolve(
3746 Some("anthropic"),
3747 Some(crate::provider::DEFAULT_MODEL_ID),
3748 None,
3749 Some("test-key"),
3750 None,
3751 )
3752 .unwrap();
3753 let args = Args {
3754 trust_override: Some(false),
3755 no_session: true,
3756 no_extensions: true,
3757 no_prompt_templates: true,
3758 no_context_files: true,
3759 system_prompt: Some("stable system prompt".into()),
3760 ..Args::default()
3761 };
3762 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3763 let before_resources = harness.get_resources().await.unwrap();
3764 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
3765 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
3766 let before_prompt = harness.get_system_prompt().await.unwrap();
3767 let before_tools: Vec<String> = harness
3768 .get_tools()
3769 .await
3770 .unwrap()
3771 .iter()
3772 .map(|tool| tool.tool.schema().name.clone())
3773 .collect();
3774 let before_bridge = context
3775 .action_bridge
3776 .lock()
3777 .unwrap()
3778 .as_ref()
3779 .unwrap()
3780 .clone();
3781
3782 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3783 let outcome = reload_extension_resources(&harness, &context).await;
3784
3785 assert!(outcome.had_warnings);
3786 assert!(outcome.summary.contains("Settings reload failed"));
3787 assert!(outcome.summary.contains("could not load global settings"));
3788 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
3789 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
3790 let after_tools: Vec<String> = harness
3791 .get_tools()
3792 .await
3793 .unwrap()
3794 .iter()
3795 .map(|tool| tool.tool.schema().name.clone())
3796 .collect();
3797 assert_eq!(after_tools, before_tools);
3798 let after_bridge = context
3799 .action_bridge
3800 .lock()
3801 .unwrap()
3802 .as_ref()
3803 .unwrap()
3804 .clone();
3805 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
3806 }
3807
3808 #[test]
3809 fn reload_preparation_rejects_settings_changed_during_derivation() {
3810 struct RestoreConfigDir(Option<std::ffi::OsString>);
3811
3812 impl Drop for RestoreConfigDir {
3813 fn drop(&mut self) {
3814 match self.0.take() {
3815 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3816 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3817 }
3818 }
3819 }
3820
3821 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3822 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3823 let tmp = tempfile::tempdir().unwrap();
3824 let agent = tmp.path().join("agent");
3825 let cwd = tmp.path().join("project");
3826 let first_skill_dir = tmp.path().join("first-skills");
3827 let second_skill_dir = tmp.path().join("second-skills");
3828 std::fs::create_dir_all(&agent).unwrap();
3829 std::fs::create_dir_all(&cwd).unwrap();
3830 std::fs::write(
3831 agent.join("settings.json"),
3832 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3833 )
3834 .unwrap();
3835 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3836 let args = Args {
3837 trust_override: Some(false),
3838 no_extensions: true,
3839 no_prompt_templates: true,
3840 ..Args::default()
3841 };
3842
3843 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3844 std::fs::write(
3845 agent.join("settings.json"),
3846 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3847 )
3848 .unwrap();
3849 });
3850
3851 let error = result
3852 .err()
3853 .expect("settings mutation must fail preparation");
3854 assert!(error.contains("settings changed while reload inputs were being prepared"));
3855 }
3856
3857 #[tokio::test(flavor = "current_thread")]
3858 async fn reload_uses_frozen_settings_inputs_after_preparation() {
3859 struct RestoreConfigDir(Option<std::ffi::OsString>);
3860
3861 impl Drop for RestoreConfigDir {
3862 fn drop(&mut self) {
3863 match self.0.take() {
3864 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3865 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3866 }
3867 }
3868 }
3869
3870 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3871 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3872 let tmp = tempfile::tempdir().unwrap();
3873 let agent = tmp.path().join("agent");
3874 let cwd = tmp.path().join("project");
3875 let skill_dir = tmp.path().join("configured-skills");
3876 std::fs::create_dir_all(&agent).unwrap();
3877 std::fs::create_dir_all(&cwd).unwrap();
3878 std::fs::create_dir_all(&skill_dir).unwrap();
3879 std::fs::write(
3880 skill_dir.join("SKILL.md"),
3881 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3882 )
3883 .unwrap();
3884 std::fs::write(
3885 agent.join("settings.json"),
3886 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3887 )
3888 .unwrap();
3889 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3890
3891 let resolved = crate::provider::resolve(
3892 Some("anthropic"),
3893 Some(crate::provider::DEFAULT_MODEL_ID),
3894 None,
3895 Some("test-key"),
3896 None,
3897 )
3898 .unwrap();
3899 let args = Args {
3900 trust_override: Some(false),
3901 no_session: true,
3902 no_extensions: true,
3903 no_prompt_templates: true,
3904 no_context_files: true,
3905 system_prompt: Some("stable system prompt".into()),
3906 ..Args::default()
3907 };
3908 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3909
3910 let outcome = reload_extension_resources_inner(&harness, &context, || {
3911 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3912 })
3913 .await;
3914
3915 assert!(!outcome.summary.contains("Settings reload failed"));
3916 assert_eq!(
3917 outcome.summary,
3918 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3919 );
3920 let resources = harness.get_resources().await.unwrap();
3921 let skills = resources.skills.as_ref().unwrap();
3922 assert_eq!(skills.len(), 1);
3923 assert_eq!(skills[0].name, "frozen-skill");
3924 }
3925
3926 #[tokio::test]
3927 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3928 let tmp = tempfile::tempdir().unwrap();
3929 let skill_path = tmp.path().join("explicit-skill.md");
3930 std::fs::write(
3931 &skill_path,
3932 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3933 )
3934 .unwrap();
3935 let prompt_path = tmp.path().join("explicit-prompt.md");
3936 std::fs::write(
3937 &prompt_path,
3938 "---\ndescription: Explicit prompt\n---\nPrompt body",
3939 )
3940 .unwrap();
3941
3942 let args = Args {
3943 skill: vec![skill_path.clone()],
3944 prompt_template: vec![prompt_path.clone()],
3945 ..Args::default()
3946 };
3947 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3948 let prompt_paths =
3949 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3950 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3951 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3952
3953 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3954 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3955 assert_eq!(skills.skills[0].name, "explicit-skill");
3956
3957 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3958 assert_eq!(
3959 prompts.prompt_templates.len(),
3960 1,
3961 "{:?}",
3962 prompts.diagnostics
3963 );
3964 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3965 }
3966
3967 #[test]
3968 fn tool_policy_applies_to_rust_and_js_active_names() {
3969 let names = vec![
3970 "read".to_string(),
3971 "ask_user_question".to_string(),
3972 "write".to_string(),
3973 ];
3974
3975 let args = Args {
3976 tools: Some(vec!["read".into(), "ask_user_question".into()]),
3977 ..Args::default()
3978 };
3979 assert_eq!(
3980 filter_active_tool_names(names.clone(), &args),
3981 vec!["read", "ask_user_question"]
3982 );
3983
3984 let args = Args {
3985 exclude_tools: Some(vec!["ask_user_question".into()]),
3986 ..Args::default()
3987 };
3988 assert_eq!(
3989 filter_active_tool_names(names.clone(), &args),
3990 vec!["read", "write"]
3991 );
3992
3993 let args = Args {
3994 no_tools: true,
3995 ..Args::default()
3996 };
3997 assert!(filter_active_tool_names(names, &args).is_empty());
3998 }
3999
4000 #[test]
4001 fn select_ephemeral_when_no_session() {
4002 let args = Args {
4003 no_session: true,
4004 ..Args::default()
4005 };
4006 let cwd = Path::new("/tmp");
4007 assert!(matches!(
4008 select_session(&args, cwd),
4009 SessionSelection::Ephemeral
4010 ));
4011 }
4012
4013 #[test]
4014 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
4015 let defaults = Args::default();
4016 assert!(resolve_project_trust(
4017 &defaults,
4018 Path::new("C:/definitely-not-a-project")
4019 ));
4020
4021 let denied = Args {
4022 trust_override: Some(false),
4023 ..Args::default()
4024 };
4025 assert!(!resolve_project_trust(
4026 &denied,
4027 Path::new("C:/definitely-not-a-project")
4028 ));
4029 }
4030
4031 #[tokio::test(flavor = "current_thread")]
4032 async fn build_preserves_the_supplied_startup_project_snapshot() {
4033 struct RestoreConfigDir(Option<std::ffi::OsString>);
4034
4035 impl Drop for RestoreConfigDir {
4036 fn drop(&mut self) {
4037 match self.0.take() {
4038 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4039 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4040 }
4041 }
4042 }
4043
4044 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4045 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4046 let tmp = tempfile::tempdir().unwrap();
4047 let agent = tmp.path().join("agent");
4048 let cwd = tmp.path().join("project");
4049 std::fs::create_dir_all(&agent).unwrap();
4050 std::fs::create_dir_all(&cwd).unwrap();
4051 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4052
4053 let resolved = crate::provider::resolve(
4054 Some("anthropic"),
4055 Some(crate::provider::DEFAULT_MODEL_ID),
4056 None,
4057 Some("test-key"),
4058 None,
4059 )
4060 .unwrap();
4061 let args = Args {
4062 trust_override: Some(false),
4063 no_session: true,
4064 no_tools: true,
4065 no_extensions: true,
4066 no_skills: true,
4067 no_prompt_templates: true,
4068 no_context_files: true,
4069 system_prompt: Some("test prompt".into()),
4070 ..Args::default()
4071 };
4072
4073 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
4074
4075 assert_eq!(context.cwd, cwd);
4076 assert!(context.project_trusted);
4077 }
4078
4079 #[tokio::test(flavor = "current_thread")]
4080 async fn build_applies_the_name_flag_to_the_session() {
4081 struct RestoreConfigDir(Option<std::ffi::OsString>);
4082
4083 impl Drop for RestoreConfigDir {
4084 fn drop(&mut self) {
4085 match self.0.take() {
4086 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4087 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4088 }
4089 }
4090 }
4091
4092 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4093 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4094 let tmp = tempfile::tempdir().unwrap();
4095 let agent = tmp.path().join("agent");
4096 let cwd = tmp.path().join("project");
4097 std::fs::create_dir_all(&agent).unwrap();
4098 std::fs::create_dir_all(&cwd).unwrap();
4099 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4100
4101 let resolved = crate::provider::resolve(
4102 Some("anthropic"),
4103 Some(crate::provider::DEFAULT_MODEL_ID),
4104 None,
4105 Some("test-key"),
4106 None,
4107 )
4108 .unwrap();
4109 let args = Args {
4110 trust_override: Some(false),
4111 no_tools: true,
4112 no_extensions: true,
4113 no_skills: true,
4114 no_prompt_templates: true,
4115 no_context_files: true,
4116 system_prompt: Some("test prompt".into()),
4117 name: Some("smoke name".into()),
4118 session_dir: Some(cwd.join("sessions")),
4119 ..Args::default()
4120 };
4121
4122 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
4123 assert_eq!(
4124 harness.get_name().await.unwrap().as_deref(),
4125 Some("smoke name")
4126 );
4127 }
4128
4129 #[test]
4130 fn select_latest_for_continue_and_resume() {
4131 let _env = crate::config::test_support::SessionDirEnv::new(None);
4132 let args = Args {
4133 continue_session: true,
4134 ..Args::default()
4135 };
4136 let cwd = Path::new("/tmp");
4137 assert!(matches!(
4138 select_session(&args, cwd),
4139 SessionSelection::Latest { .. }
4140 ));
4141
4142 let args = Args {
4143 resume: true,
4144 ..Args::default()
4145 };
4146 assert!(matches!(
4147 select_session(&args, cwd),
4148 SessionSelection::Latest { .. }
4149 ));
4150 }
4151
4152 #[test]
4153 fn select_by_id_for_session_flag() {
4154 let _env = crate::config::test_support::SessionDirEnv::new(None);
4155 let args = Args {
4156 session: Some("01a02ece".into()),
4157 ..Args::default()
4158 };
4159 let cwd = Path::new("/tmp");
4160 assert!(matches!(
4161 select_session(&args, cwd),
4162 SessionSelection::ById { id, .. } if id == "01a02ece"
4163 ));
4164 }
4165
4166 #[test]
4167 fn select_new_with_custom_dir() {
4168 let _env = crate::config::test_support::SessionDirEnv::new(None);
4169 let args = Args {
4170 session_dir: Some(PathBuf::from("/tmp/sess")),
4171 ..Args::default()
4172 };
4173 let cwd = Path::new("/tmp");
4174 match select_session(&args, cwd) {
4175 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
4176 other => panic!("expected New, got {other:?}"),
4177 }
4178 }
4179
4180 #[test]
4181 fn select_new_default_dir() {
4182 let _env = crate::config::test_support::SessionDirEnv::new(None);
4183 let args = Args::default();
4184 let cwd = Path::new("/proj");
4185 match select_session(&args, cwd) {
4186 SessionSelection::New { dir, .. } => {
4187 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
4188 }
4189 other => panic!("expected New, got {other:?}"),
4190 }
4191 }
4192
4193 #[test]
4194 fn default_session_dir_only_uses_rpi() {
4195 let _env = crate::config::test_support::SessionDirEnv::new(None);
4196 let tmp = tempfile::tempdir().unwrap();
4197 let cwd = tmp.path();
4198 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
4199 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
4200 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
4201 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
4202 }
4203
4204 #[test]
4205 fn default_session_dir_uses_env_var() {
4206 let tmp = tempfile::tempdir().unwrap();
4207 let cwd = tmp.path();
4208 let custom = tmp.path().join("custom_sessions");
4209 let _env = crate::config::test_support::SessionDirEnv::new(Some(&custom));
4210 assert_eq!(default_session_dir(cwd), custom);
4211 }
4212
4213 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4214 async fn ephemeral_session_builds_roundtrips() {
4215 let s = ephemeral_session();
4219 let leaf = s.get_leaf_id().await;
4220 assert!(leaf.is_ok());
4221 }
4222
4223 #[test]
4241 fn host_private_env_matches_the_declared_contract() {
4242 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
4244 assert_eq!(
4245 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
4246 [
4247 "LANGFUSE_PI_PARENT_TRACE_ID",
4248 "LANGFUSE_PI_PARENT_SPAN_ID",
4249 "LANGFUSE_PI_PARENT_SESSION_ID",
4250 "LANGFUSE_PI_PARENT_DEPTH",
4251 ],
4252 "these strings are what rpi-langfuse (a different repo) matches; a \
4253 change here is a breaking cross-repo change, not a refactor"
4254 );
4255
4256 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
4259 .iter()
4260 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
4261 {
4262 assert!(
4263 rpi_tools::is_host_private_env(name),
4264 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
4265 rpi_tools::HOST_PRIVATE_ENV
4266 );
4267 }
4268
4269 for visible in [
4272 crate::args::RPI_OFFLINE_ENV,
4273 crate::config::CONFIG_DIR_ENV,
4274 "PATH",
4275 ] {
4276 assert!(
4277 !rpi_tools::is_host_private_env(visible),
4278 "{visible} describes the user's configuration and must still reach the shell"
4279 );
4280 }
4281 }
4282 #[tokio::test]
4283 async fn reload_shutdown_joins_all_handlers_even_after_veto() {
4284 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
4285 use std::sync::atomic::AtomicUsize;
4286 static CONTEXTS: rpi_plugin_sdk::handles::ToolHandles<Arc<AtomicUsize>> =
4287 rpi_plugin_sdk::handles::ToolHandles::new();
4288 struct ContextOwner(usize);
4289 impl Drop for ContextOwner {
4290 fn drop(&mut self) {
4291 drop(CONTEXTS.remove(self.0 as rpi_plugin_sdk::StepHandle));
4292 }
4293 }
4294 extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
4295 let context = CONTEXTS.get(data).expect("live test context");
4296 let calls = context.lock().clone();
4297 std::thread::sleep(std::time::Duration::from_millis(20));
4298 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
4299 rpi_plugin_sdk::EVENT_HANDLER_ABORT
4300 } else {
4301 0
4302 }
4303 }
4304 let calls = Arc::new(AtomicUsize::new(0));
4305 let context = CONTEXTS.insert(calls.clone());
4306 let _owner = ContextOwner(context as usize);
4307 let mut registry = rpi_extensions::ExtensionRegistry::new();
4308 for name in ["first", "second"] {
4309 registry.register_event_handler(name.into(), EventTag::SessionShutdown, stop, context);
4310 }
4311 super::shutdown_extensions_for_reload(
4312 Some(Arc::new(registry.snapshot())),
4313 rpi_extensions::PluginKeepalive::empty(),
4314 )
4315 .await
4316 .unwrap();
4317 assert_eq!(calls.load(Ordering::SeqCst), 2);
4318 }
4319}