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 source_meta = match session_metadata_at(Path::new(source)) {
2327 Some(meta) => meta,
2328 None => {
2329 let metas = repo
2330 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2331 .await
2332 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2333 metas
2334 .into_iter()
2335 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2336 .ok_or_else(|| BuildError::SessionNotFound {
2337 requested: format!("--fork {source}"),
2338 dir: dir.display().to_string(),
2339 })?
2340 }
2341 };
2342 let fork_storage = repo
2343 .fork_typed(
2344 &source_meta,
2345 &JsonlSessionCreateOptions {
2346 id: None,
2347 parent_session_id: Some(source_meta.id.clone()),
2348 cwd: cwd.to_string(),
2349 metadata: None,
2350 },
2351 &ForkOptions::default(),
2352 )
2353 .await
2354 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2355 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
2356 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2357 Ok(Session::new(storage_arc, None))
2358}
2359
2360fn session_metadata_at(path: &Path) -> Option<rpi_harness::session::jsonl::JsonlSessionMetadata> {
2363 use rpi_harness::session::jsonl::codec::{metadata_from_header, parse_header};
2364 use std::io::BufRead;
2365
2366 let file = std::fs::File::open(path).ok()?;
2367 let modified_at = file
2368 .metadata()
2369 .ok()?
2370 .modified()
2371 .ok()?
2372 .duration_since(std::time::UNIX_EPOCH)
2373 .ok()?
2374 .as_millis() as i64;
2375 let mut first_line = String::new();
2376 std::io::BufReader::new(file)
2377 .read_line(&mut first_line)
2378 .ok()?;
2379 let header = parse_header(first_line.trim_end()).ok()?;
2380 let path = std::fs::canonicalize(path).ok()?;
2381 Some(metadata_from_header(
2382 &header,
2383 &path.to_string_lossy(),
2384 modified_at,
2385 ))
2386}
2387
2388pub enum OpenError {
2391 NotFound { requested: String },
2393 Other(String),
2395}
2396
2397impl std::fmt::Display for OpenError {
2398 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2399 match self {
2400 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2401 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
2406 }
2407 }
2408}
2409
2410pub async fn list_session_metadata(
2413 cwd: &str,
2414) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2415 let dir = default_session_dir(Path::new(cwd));
2416 list_session_metadata_in_dir(cwd, &dir).await
2417}
2418
2419pub(crate) async fn list_session_metadata_in_dir(
2420 cwd: &str,
2421 dir: &Path,
2422) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2423 use rpi_harness::session::jsonl::{
2424 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2425 };
2426 use rpi_tools::FileSystem;
2427
2428 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2429 let fs: Arc<dyn FileSystem> = env.clone();
2430 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2431 fs: fs.clone(),
2432 sessions_root: dir.to_string_lossy().into_owned(),
2433 clock: Arc::new(SystemClock),
2434 ids: Arc::new(DefaultIdGenerator::new()),
2435 });
2436 repo.list_typed(&JsonlSessionListOptions::default())
2437 .await
2438 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2439}
2440
2441#[derive(Debug, Clone, Default, PartialEq, Eq)]
2457pub struct SessionSummary {
2458 pub name: Option<String>,
2460 pub preview: Option<String>,
2462 pub empty: bool,
2464 pub bytes: u64,
2466}
2467
2468const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
2470const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
2472const SUMMARY_PREVIEW_CHARS: usize = 80;
2474
2475pub fn summarize_session_file(path: &Path) -> SessionSummary {
2479 use std::io::{Read, Seek, SeekFrom};
2480
2481 let Ok(mut file) = std::fs::File::open(path) else {
2482 return SessionSummary::default();
2483 };
2484 let Ok(size) = file.metadata().map(|m| m.len()) else {
2485 return SessionSummary::default();
2486 };
2487 if size == 0 {
2488 return SessionSummary {
2489 name: None,
2490 preview: None,
2491 empty: true,
2492 bytes: 0,
2493 };
2494 }
2495
2496 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2499 let head_len = file.read(&mut head).unwrap_or(0);
2500 head.truncate(head_len);
2501 let head = String::from_utf8_lossy(&head);
2502 let mut lines: Vec<&str> = head.lines().collect();
2503 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2504 let empty = (size as usize) <= header_len + 1;
2506 if (head_len as u64) < size && !lines.is_empty() {
2509 lines.pop();
2510 }
2511
2512 let mut summary = SessionSummary {
2513 name: None,
2514 preview: None,
2515 empty,
2516 bytes: size,
2517 };
2518 for line in lines {
2519 apply_summary_line(line, &mut summary);
2520 }
2521
2522 if size > SUMMARY_HEAD_BYTES
2526 && file
2527 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2528 .is_ok()
2529 {
2530 let mut tail = Vec::new();
2531 if file.read_to_end(&mut tail).is_ok() {
2532 let tail = String::from_utf8_lossy(&tail);
2533 let mut tail_lines = tail.lines();
2534 tail_lines.next();
2536 for line in tail_lines {
2537 if let Some(name) = name_from_line(line) {
2538 summary.name = name;
2539 }
2540 }
2541 }
2542 }
2543 summary
2544}
2545
2546fn name_from_line(line: &str) -> Option<Option<String>> {
2549 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2550 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2551 return None;
2552 }
2553 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2554 return None;
2555 }
2556 Some(match value.get("name") {
2557 Some(serde_json::Value::String(name)) => Some(name.clone()),
2558 _ => None,
2559 })
2560}
2561
2562fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2565 if let Some(name) = name_from_line(line) {
2566 summary.name = name;
2567 return;
2568 }
2569 if summary.preview.is_some() {
2570 return;
2571 }
2572 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2573 return;
2574 };
2575 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2576 return;
2577 }
2578 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2579 return;
2580 }
2581 let Some(message) = value.get("message") else {
2582 return;
2583 };
2584 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2585 return;
2586 }
2587 let text = match message.get("content") {
2588 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2589 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2590 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2591 .then(|| block.get("text").and_then(|v| v.as_str()))
2592 .flatten()
2593 }),
2594 _ => None,
2595 };
2596 let Some(text) = text else { return };
2597 let collapsed = text
2598 .lines()
2599 .map(str::trim)
2600 .filter(|line| !line.is_empty())
2601 .collect::<Vec<_>>()
2602 .join(" ");
2603 if collapsed.is_empty() {
2604 return;
2605 }
2606 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2607}
2608
2609pub fn session_display_label(summary: &SessionSummary) -> String {
2613 if let Some(name) = summary.name.as_deref().map(str::trim) {
2614 if !name.is_empty() {
2615 return name.to_string();
2616 }
2617 }
2618 match summary.preview.as_deref().map(str::trim) {
2619 Some(preview) if !preview.is_empty() => preview.to_string(),
2620 _ => "(no messages)".to_string(),
2621 }
2622}
2623
2624pub fn format_session_bytes(bytes: u64) -> String {
2627 if bytes >= 1_048_576 {
2628 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2629 } else if bytes >= 1024 {
2630 format!("{}KB", bytes / 1024)
2631 } else {
2632 format!("{bytes}B")
2633 }
2634}
2635
2636pub fn short_session_id(id: &str) -> &str {
2639 &id[..id.len().min(8)]
2640}
2641
2642pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2651 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2652 let mut visit = |path: PathBuf| {
2653 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2654 return;
2655 }
2656 let modified = std::fs::metadata(&path)
2657 .and_then(|m| m.modified())
2658 .unwrap_or(std::time::UNIX_EPOCH);
2659 files.push((path, modified));
2660 };
2661 if let Ok(entries) = std::fs::read_dir(dir) {
2662 for entry in entries.flatten() {
2663 let path = entry.path();
2664 if path.is_dir() {
2665 if let Ok(inner) = std::fs::read_dir(&path) {
2666 for child in inner.flatten() {
2667 visit(child.path());
2668 }
2669 }
2670 } else {
2671 visit(path);
2672 }
2673 }
2674 }
2675 files.sort_by(|a, b| b.1.cmp(&a.1));
2676 files.into_iter().map(|(path, _)| path).collect()
2677}
2678
2679fn first_resumable_session(
2684 metas: &[rpi_harness::session::jsonl::JsonlSessionMetadata],
2685) -> Option<&rpi_harness::session::jsonl::JsonlSessionMetadata> {
2686 metas
2687 .iter()
2688 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2689 .or_else(|| metas.first())
2690}
2691
2692pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2696 let dir = default_session_dir(Path::new(cwd));
2697 open_session_by_id_in_dir(id, cwd, &dir).await
2698}
2699
2700pub(crate) async fn open_session_by_id_in_dir(
2701 id: &str,
2702 cwd: &str,
2703 dir: &Path,
2704) -> Result<Session, OpenError> {
2705 let metas = list_session_metadata_in_dir(cwd, dir)
2706 .await
2707 .map_err(|e| OpenError::Other(e.to_string()))?;
2708 let Some(meta) = metas
2709 .iter()
2710 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2711 else {
2712 return Err(OpenError::NotFound {
2713 requested: format!("session {id}"),
2714 });
2715 };
2716 open_session(meta, cwd).await.map_err(|e| {
2717 OpenError::Other(match e {
2718 BuildError::SessionOpen(reason) => reason,
2722 other => other.to_string(),
2723 })
2724 })
2725}
2726
2727fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
2752 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
2753 tool_context.set_session_id(id);
2754}
2755
2756pub(crate) async fn set_active_session(
2767 harness: &AgentHarness,
2768 session: Session,
2769 tool_context: Option<&rpi_extensions::ToolCallContext>,
2770) -> Result<(), String> {
2771 let metadata = session.get_metadata().await.ok();
2772 harness
2773 .set_session(session)
2774 .await
2775 .map_err(|e| e.to_string())?;
2776 if let Some(metadata) = metadata {
2777 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2778 if let Some(context) = tool_context {
2779 context.set_session_id(metadata.id);
2780 }
2781 }
2782 Ok(())
2783}
2784
2785pub(crate) async fn fork_session_storage(
2792 harness: &AgentHarness,
2793 cwd: &str,
2794 dir: &Path,
2795) -> Result<Session, String> {
2796 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2797 use rpi_tools::FileSystem;
2798
2799 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2800 let fs: Arc<dyn FileSystem> = env.clone();
2801 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2802 fs,
2803 sessions_root: dir.to_string_lossy().into_owned(),
2804 clock: Arc::new(SystemClock),
2805 ids: Arc::new(DefaultIdGenerator::new()),
2806 });
2807 let id = harness.session().storage().metadata().id.clone();
2810 let metas = list_session_metadata_in_dir(cwd, dir)
2811 .await
2812 .map_err(|e| e.to_string())?;
2813 let Some(source) = metas.iter().find(|m| m.id == id) else {
2814 return Err(format!("current session {id} not found on disk"));
2815 };
2816 let fork_storage = repo
2817 .fork_typed(
2818 source,
2819 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2820 id: None,
2821 parent_session_id: Some(source.id.clone()),
2822 cwd: cwd.to_string(),
2823 metadata: None,
2824 },
2825 &rpi_harness::session::types::ForkOptions::default(),
2826 )
2827 .await
2828 .map_err(|e| e.to_string())?;
2829 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2830 Ok(Session::new(Arc::new(fork_storage), None))
2831}
2832
2833pub(crate) async fn import_session_file(
2837 source: &Path,
2838 cwd: &str,
2839 dir: &Path,
2840) -> Result<Session, String> {
2841 use rpi_harness::session::jsonl::{
2842 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions, JsonlSessionStorage,
2843 };
2844 use rpi_tools::FileSystem;
2845
2846 let dir = if dir.is_absolute() {
2847 dir.to_path_buf()
2848 } else {
2849 Path::new(cwd).join(dir)
2850 };
2851 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
2852 let staged = tempfile::NamedTempFile::new_in(&dir)
2853 .map_err(|e| e.to_string())?
2854 .into_temp_path();
2855 std::fs::copy(source, &staged).map_err(|e| format!("copy import: {e}"))?;
2856 let fs: Arc<dyn FileSystem> = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2857 let validated = JsonlSessionStorage::load(
2858 fs.clone(),
2859 &staged.to_string_lossy(),
2860 Arc::new(SystemClock),
2861 Arc::new(DefaultIdGenerator::new()),
2862 )
2863 .await
2864 .map_err(|e| format!("invalid session import: {e}"))?;
2865 let mut metadata = validated.jsonl_metadata().clone();
2866 drop(validated);
2867 if list_session_metadata_in_dir(cwd, &dir)
2868 .await
2869 .map_err(|e| e.to_string())?
2870 .iter()
2871 .any(|saved| saved.id == metadata.id)
2872 {
2873 return Err(format!(
2874 "session {} already exists in {}",
2875 metadata.id,
2876 dir.display()
2877 ));
2878 }
2879 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2880 fs,
2881 sessions_root: dir.to_string_lossy().into_owned(),
2882 clock: Arc::new(SystemClock),
2883 ids: Arc::new(DefaultIdGenerator::new()),
2884 });
2885 let destination = repo
2887 .create_typed(&JsonlSessionCreateOptions {
2888 id: Some(metadata.id.clone()),
2889 parent_session_id: metadata.parent_session_id.clone(),
2890 cwd: cwd.to_string(),
2891 metadata: metadata.metadata.clone(),
2892 })
2893 .await
2894 .map_err(|e| e.to_string())?;
2895 let path = destination.jsonl_metadata().path.clone();
2896 drop(destination);
2897 if let Err(error) = claim_session_lock(&path) {
2898 return Err(error);
2900 }
2901 if let Err(error) = staged.persist(&path) {
2902 let _ = std::fs::remove_file(&path);
2903 return Err(format!("publish import: {error}"));
2904 }
2905 metadata.path = path;
2906 let storage = repo
2907 .open_by_jsonl_metadata(&metadata)
2908 .await
2909 .map_err(|e| e.to_string())?;
2910 Ok(Session::new(Arc::new(storage), None))
2911}
2912
2913static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2915static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2918
2919fn claim_session_lock(session_path: &str) -> Result<(), String> {
2928 let lock_path = PathBuf::from(format!("{session_path}.lock"));
2929 if SESSION_LOCK_PATHS
2930 .get_or_init(|| Mutex::new(HashSet::new()))
2931 .lock()
2932 .unwrap()
2933 .contains(&lock_path)
2934 {
2935 return Ok(());
2936 }
2937 let file = std::fs::OpenOptions::new()
2938 .create(true)
2939 .write(true)
2940 .open(&lock_path)
2941 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2942 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2943 return Err(format!(
2944 "session {session_path} is already open in another rpi process"
2945 ));
2946 }
2947 SESSION_LOCKS
2948 .get_or_init(|| Mutex::new(Vec::new()))
2949 .lock()
2950 .unwrap()
2951 .push(file);
2952 SESSION_LOCK_PATHS
2953 .get_or_init(|| Mutex::new(HashSet::new()))
2954 .lock()
2955 .unwrap()
2956 .insert(lock_path);
2957 Ok(())
2958}
2959
2960async fn open_session(
2963 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2964 cwd: &str,
2965) -> Result<Session, BuildError> {
2966 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2967 use rpi_harness::session::types::SessionStorage;
2968 use rpi_tools::FileSystem;
2969
2970 let dir = default_session_dir(Path::new(cwd));
2971 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2972 let fs: Arc<dyn FileSystem> = env.clone();
2973 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2974 fs: fs.clone(),
2975 sessions_root: dir.to_string_lossy().into_owned(),
2976 clock: Arc::new(SystemClock),
2977 ids: Arc::new(DefaultIdGenerator::new()),
2978 });
2979 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2980 let storage = repo
2981 .open_by_jsonl_metadata(meta)
2982 .await
2983 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2984 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2988 Some(Path::new(&meta.path)),
2989 Path::new(&meta.cwd),
2990 Path::new(cwd),
2991 ) {
2992 eprintln!(
2993 "warning: {}",
2994 crate::session_cwd::format_missing_session_cwd_error(&issue)
2995 );
2996 }
2997 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2998 Ok(Session::new(storage_arc, None))
2999}
3000
3001pub(crate) fn ephemeral_session() -> Session {
3005 let storage = Arc::new(InMemorySessionStorage::new(
3006 SessionMetadata {
3007 id: "ephemeral".into(),
3008 created_at: 0,
3009 parent_session_id: None,
3010 },
3011 Arc::new(SystemClock),
3012 Arc::new(DefaultIdGenerator::new()),
3013 ));
3014 Session::new(storage, None).with_volatile_progress()
3015}
3016
3017pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
3028 create_jsonl_session_with_id(dir, cwd, None).await
3029}
3030
3031pub(crate) async fn create_jsonl_session_with_id(
3035 dir: &Path,
3036 cwd: &str,
3037 id: Option<String>,
3038) -> Result<Session, String> {
3039 use rpi_harness::session::jsonl::{
3040 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
3041 };
3042 use rpi_tools::FileSystem;
3043
3044 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3047 let fs: Arc<dyn FileSystem> = env.clone();
3048
3049 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3050 fs: fs.clone(),
3051 sessions_root: dir.to_string_lossy().into_owned(),
3052 clock: Arc::new(SystemClock),
3053 ids: Arc::new(DefaultIdGenerator::new()),
3054 });
3055
3056 let opts = JsonlSessionCreateOptions {
3057 id, parent_session_id: None,
3059 cwd: cwd.to_string(),
3060 metadata: None,
3061 };
3062 let storage = repo
3063 .create_typed(&opts)
3064 .await
3065 .map_err(|e| format!("create session: {e}"))?;
3066 claim_session_lock(&storage.jsonl_metadata().path)
3067 .map_err(|e| format!("create session: {e}"))?;
3068 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
3070 Ok(Session::new(storage_arc, None))
3071}
3072
3073fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
3076 let path = Path::new(target);
3077 if path.is_file() {
3078 read_prompt_source(path, sources)
3079 } else {
3080 None
3081 }
3082}
3083
3084fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
3087 let text = std::fs::read_to_string(path).ok()?;
3088 let absolute = if path.is_absolute() {
3089 path.to_path_buf()
3090 } else {
3091 std::env::current_dir()
3092 .map(|cwd| cwd.join(path))
3093 .unwrap_or_else(|_| path.to_path_buf())
3094 };
3095 let source = absolute.to_string_lossy().into_owned();
3096 if !sources.contains(&source) {
3097 sources.push(source);
3098 }
3099 Some(text)
3100}
3101
3102#[cfg(test)]
3103mod runtime_session_tests;
3104
3105#[cfg(test)]
3106mod tests {
3107 use super::*;
3108
3109 #[tokio::test]
3110 async fn unauthenticated_packages_are_not_reintroduced_as_legacy_adapters() {
3111 extern "C" fn request(
3112 _: rpi_plugin_sdk::StbStringRef,
3113 _: *mut rpi_plugin_sdk::StbString,
3114 _: *mut std::ffi::c_void,
3115 ) -> i32 {
3116 1
3117 }
3118 let mut registry = rpi_extensions::ExtensionRegistry::new();
3119 for id in ["package", "legacy"] {
3120 registry.register_provider(rpi_extensions::RegisteredProvider {
3121 provider_id: id.into(),
3122 base_url: "http://localhost".into(),
3123 api_style: "openai-completions".into(),
3124 request_fn: request,
3125 stream_fn: None,
3126 plugin_free_string: rpi_plugin_sdk::callbacks::free_string,
3127 user_data: std::ptr::null_mut(),
3128 });
3129 }
3130 assert!(registry.apply_declaration_json(&serde_json::json!({"providers":[{
3131 "id":"package", "requiresAuth":true,
3132 "models":[rpi_ai::Model::new("model", "Model", rpi_ai::Api::OpenaiCompletions, "package", "http://localhost")]
3133 }]}).to_string()));
3134 let session = ExtensionSession::from_parts(
3135 Arc::new(registry.snapshot()),
3136 rpi_extensions::PluginKeepalive::empty(),
3137 vec![],
3138 None,
3139 );
3140 let providers = build_models_with_extensions_for_reload(
3141 &[],
3142 &session,
3143 tokio::runtime::Handle::current(),
3144 );
3145 assert_eq!(
3146 providers
3147 .iter()
3148 .map(|provider| provider.id())
3149 .collect::<Vec<_>>(),
3150 ["legacy"]
3151 );
3152 }
3153 use crate::args::Args;
3154
3155 #[test]
3156 fn loaded_context_paths_track_precedence_and_reload() {
3157 const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
3158 if std::env::var_os(CHILD).is_none() {
3159 let dir = tempfile::tempdir().unwrap();
3160 let output = std::process::Command::new(std::env::current_exe().unwrap())
3161 .args([
3162 "--exact",
3163 "session::tests::loaded_context_paths_track_precedence_and_reload",
3164 "--nocapture",
3165 ])
3166 .env(CHILD, "1")
3167 .env(crate::config::CONFIG_DIR_ENV, dir.path())
3168 .output()
3169 .unwrap();
3170 assert!(
3171 output.status.success(),
3172 "{}\n{}",
3173 String::from_utf8_lossy(&output.stdout),
3174 String::from_utf8_lossy(&output.stderr)
3175 );
3176 return;
3177 }
3178 tokio::runtime::Builder::new_current_thread()
3179 .enable_all()
3180 .build()
3181 .unwrap()
3182 .block_on(async {
3183 let agent = crate::config::agent_dir().unwrap();
3184 let cwd = agent.join("project");
3185 let project_config = cwd.join(".rpi");
3186 std::fs::create_dir_all(&project_config).unwrap();
3187 for (path, content) in [
3188 (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
3189 (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
3190 (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
3191 (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
3192 (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
3193 (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
3194 (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
3195 ] {
3196 std::fs::write(path, content).unwrap();
3197 }
3198 let resolved = crate::provider::resolve(
3199 Some("anthropic"),
3200 Some(crate::provider::DEFAULT_MODEL_ID),
3201 None,
3202 Some("test-key"),
3203 None,
3204 )
3205 .unwrap();
3206 let args = Args {
3207 no_session: true,
3208 no_extensions: true,
3209 no_skills: true,
3210 no_prompt_templates: true,
3211 trust_override: Some(true),
3212 ..Default::default()
3213 };
3214 let (harness, _events, mut context) =
3215 build(&resolved, &args, &cwd, true).await.unwrap();
3216 let resources = harness.get_resources().await.unwrap();
3217 let path = |p: PathBuf| p.to_string_lossy().into_owned();
3218 assert_eq!(
3219 &resources.context_files[..2],
3220 &[
3221 path(project_config.join("SYSTEM.md")),
3222 path(project_config.join("APPEND_SYSTEM.md")),
3223 ]
3224 );
3225 assert!(resources
3226 .context_files
3227 .contains(&path(agent.join("AGENTS.md"))));
3228 assert!(resources
3229 .context_files
3230 .contains(&path(cwd.join("AGENTS.md"))));
3231 assert!(!resources
3232 .context_files
3233 .contains(&path(cwd.join("CLAUDE.md"))));
3234 assert!(!resources
3235 .context_files
3236 .contains(&path(agent.join("SYSTEM.md"))));
3237 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3238 assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
3239 assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
3240
3241 std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
3244 std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
3245 std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
3246 assert!(
3247 !reload_extension_resources(&harness, &context)
3248 .await
3249 .had_errors
3250 );
3251 let resources = harness.get_resources().await.unwrap();
3252 assert_eq!(
3253 &resources.context_files[..2],
3254 &[
3255 path(agent.join("SYSTEM.md")),
3256 path(agent.join("APPEND_SYSTEM.md")),
3257 ]
3258 );
3259 assert!(resources
3260 .context_files
3261 .contains(&path(cwd.join("CLAUDE.md"))));
3262 assert!(!resources
3263 .context_files
3264 .contains(&path(project_config.join("SYSTEM.md"))));
3265
3266 let explicit = agent.join("custom-append.md");
3269 std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
3270 context.args.system_prompt = Some("EXPLICIT_BASE".into());
3271 context.args.append_system_prompt =
3272 vec![path(explicit.clone()), "literal append".into()];
3273 context.args.no_context_files = true;
3274 assert!(
3275 !reload_extension_resources(&harness, &context)
3276 .await
3277 .had_errors
3278 );
3279 assert_eq!(
3280 harness.get_resources().await.unwrap().context_files,
3281 vec![path(explicit)]
3282 );
3283
3284 context.args.system_prompt = None;
3286 context.args.append_system_prompt.clear();
3287 context.args.no_context_files = false;
3288 context.args.trust_override = Some(false);
3289 assert!(
3290 !reload_extension_resources(&harness, &context)
3291 .await
3292 .had_errors
3293 );
3294 assert!(harness
3295 .get_resources()
3296 .await
3297 .unwrap()
3298 .context_files
3299 .is_empty());
3300 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3301 assert!(
3302 !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
3303 );
3304 });
3305 }
3306
3307 #[test]
3308 fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
3309 let dir = tempfile::tempdir().unwrap();
3310 let file = dir.path().join("SYSTEM.md");
3311 let mut sources = Vec::new();
3312 assert!(read_prompt_source(&file, &mut sources).is_none());
3313 std::fs::write(&file, [0xff, 0xfe]).unwrap();
3314 assert!(read_prompt_source(&file, &mut sources).is_none());
3315 assert!(read_append_target("literal instructions", &mut sources).is_none());
3316 assert!(sources.is_empty());
3317 std::fs::write(&file, "loaded instructions").unwrap();
3318 assert_eq!(
3319 read_prompt_source(&file, &mut sources).as_deref(),
3320 Some("loaded instructions")
3321 );
3322 assert!(read_prompt_source(&file, &mut sources).is_some());
3323 assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
3324 }
3325
3326 extern "C" fn deny_test_tool_call(
3327 _event: rpi_plugin_sdk::StablePluginEvent,
3328 _data: *mut std::ffi::c_void,
3329 ) -> i32 {
3330 rpi_plugin_sdk::EVENT_HANDLER_ABORT
3331 }
3332
3333 async fn invoke_test_tool_hook(
3334 hook: &rpi_agent::BeforeToolCall,
3335 ) -> Option<rpi_agent::BeforeToolCallResult> {
3336 let message = rpi_ai::providers::faux::faux_assistant_message(
3337 "approval",
3338 rpi_ai::types::StopReason::ToolUse,
3339 );
3340 let rpi_ai::types::Content::ToolCall(call) =
3341 rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
3342 else {
3343 unreachable!()
3344 };
3345 hook(
3346 rpi_agent::BeforeToolCallContext {
3347 assistant_message: &message,
3348 tool_call: &call,
3349 args: &call.arguments,
3350 context: &rpi_agent::AgentContext::default(),
3351 },
3352 tokio_util::sync::CancellationToken::new(),
3353 )
3354 .await
3355 }
3356
3357 #[tokio::test]
3358 async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
3359 let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
3360 let hook =
3361 extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
3362 assert!(invoke_test_tool_hook(&hook).await.is_none());
3363 for name in ["first-gate", "reloaded-gate"] {
3364 let mut registry = rpi_extensions::ExtensionRegistry::new();
3365 registry.register_event_handler(
3366 name.into(),
3367 rpi_plugin_sdk::EventTag::ToolCall,
3368 deny_test_tool_call,
3369 std::ptr::null_mut(),
3370 );
3371 let fresh = ExtensionSession::from_parts(
3372 Arc::new(registry.snapshot()),
3373 rpi_extensions::PluginKeepalive::empty(),
3374 Vec::new(),
3375 None,
3376 );
3377 {
3378 let mut current = sessions.lock().unwrap();
3379 if let Some(snapshot) = current.snapshot() {
3380 snapshot.active_flag().store(false, Ordering::SeqCst);
3381 }
3382 *current = fresh;
3383 }
3384 let result = invoke_test_tool_hook(&hook).await.unwrap();
3385 assert!(result.block);
3386 assert!(result.reason.unwrap().contains(name));
3387 }
3388 *sessions.lock().unwrap() = ExtensionSession::none();
3389 assert!(invoke_test_tool_hook(&hook).await.is_none());
3390 }
3391
3392 fn scratch(name: &str) -> PathBuf {
3394 let nanos = std::time::SystemTime::now()
3395 .duration_since(std::time::UNIX_EPOCH)
3396 .unwrap()
3397 .as_nanos();
3398 let path = std::env::temp_dir().join(format!(
3399 "rpi-summary-{}-{nanos}-{name}.jsonl",
3400 std::process::id()
3401 ));
3402 path
3403 }
3404
3405 #[test]
3406 fn summary_marks_a_header_only_session_empty() {
3407 let path = scratch("empty");
3408 std::fs::write(
3409 &path,
3410 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3411 )
3412 .unwrap();
3413 let summary = summarize_session_file(&path);
3414 assert!(summary.empty, "a header-only file has nothing to resume");
3415 assert!(summary.preview.is_none());
3416 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
3417 std::fs::remove_file(&path).ok();
3418 }
3419
3420 #[test]
3421 fn summary_reads_the_first_user_prompt_and_name() {
3422 let path = scratch("content");
3423 let body = concat!(
3424 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3425 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
3426 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
3427 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
3428 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
3429 );
3430 std::fs::write(&path, body).unwrap();
3431 let summary = summarize_session_file(&path);
3432 assert!(!summary.empty);
3433 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
3434 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
3435 std::fs::remove_file(&path).ok();
3436 }
3437
3438 #[test]
3439 fn summary_extracts_a_block_style_user_prompt() {
3440 let path = scratch("blocks");
3441 let body = concat!(
3442 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3443 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
3444 );
3445 std::fs::write(&path, body).unwrap();
3446 let summary = summarize_session_file(&path);
3447 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
3448 assert_eq!(session_display_label(&summary), "line one line two");
3449 std::fs::remove_file(&path).ok();
3450 }
3451
3452 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
3453 rpi_harness::session::jsonl::JsonlSessionMetadata {
3454 id: id.to_string(),
3455 created_at: 0,
3456 cwd: ".".to_string(),
3457 path: path.to_string_lossy().into_owned(),
3458 modified_at: 0,
3459 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
3460 parent_session_id: None,
3461 legacy_parent_session_path: None,
3462 metadata: None,
3463 }
3464 }
3465
3466 #[test]
3467 fn continue_skips_newer_empty_sessions() {
3468 let empty = scratch("newest-empty");
3469 std::fs::write(
3470 &empty,
3471 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3472 )
3473 .unwrap();
3474 let content = scratch("older-content");
3475 std::fs::write(
3476 &content,
3477 concat!(
3478 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3479 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
3480 ),
3481 )
3482 .unwrap();
3483
3484 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
3485 let picked = first_resumable_session(&metas).expect("a resumable session");
3486 assert_eq!(picked.id, "old");
3487
3488 let all_empty = vec![meta_for(&empty, "empty")];
3491 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
3492
3493 std::fs::remove_file(&empty).ok();
3494 std::fs::remove_file(&content).ok();
3495 }
3496
3497 #[test]
3498 fn list_session_files_sync_walks_the_cwd_subdirectory() {
3499 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
3500 let nested = root.join("--proj--");
3501 std::fs::create_dir_all(&nested).unwrap();
3502 let file = nested.join("2026-01-01_a.jsonl");
3503 std::fs::write(&file, "{}\n").unwrap();
3504 let found = list_session_files_sync(&root);
3505 assert!(found.iter().any(|p| p == &file));
3506 std::fs::remove_dir_all(&root).ok();
3507 }
3508
3509 fn default_prompt_tools() -> Vec<String> {
3511 BUILTIN_TOOL_NAMES
3512 .iter()
3513 .copied()
3514 .chain(["todo"])
3515 .map(str::to_owned)
3516 .collect()
3517 }
3518
3519 #[test]
3520 fn default_prompt_lists_only_the_active_tools() {
3521 let tools = ["read", "grep", "find", "ls"].map(str::to_owned);
3522 let p = default_system_prompt_with_tools("/tmp/proj", true, &tools);
3523 assert!(p.contains("- read — Read file contents"));
3524 assert!(p.contains("- grep — Search file contents"));
3525 for absent in [
3526 "- edit",
3527 "- write",
3528 "- bash",
3529 "- docs",
3530 "todo",
3531 "PowerShell cmdlets",
3532 "project documentation",
3533 ] {
3534 assert!(
3535 !p.contains(absent),
3536 "{absent} must not appear for a read-only run: {p}"
3537 );
3538 }
3539 assert!(p.contains("Re-stating the same plan without acting on it is a bug"));
3540 let none = default_system_prompt_with_tools("/tmp/proj", false, &[]);
3541 assert!(none.contains("Available tools:\n(none)"));
3542 }
3543
3544 #[test]
3545 fn default_prompt_mentions_cwd_and_tools() {
3546 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
3547 assert!(p.contains("/tmp/proj"));
3548 assert!(p.contains("read"));
3549 assert!(p.contains("bash"));
3550 assert!(p.contains("edit"));
3551 assert!(p.contains("write"));
3552 assert!(p.contains("docs"));
3553 assert!(!p.contains("- grep"));
3554 assert!(!p.contains("- find"));
3555 assert!(!p.contains("- ls"));
3556 assert!(!p.contains("- powershell"));
3557 assert!(p.contains("Bash/POSIX syntax, including on Windows"));
3558 assert!(p.contains("do not pass PowerShell cmdlets"));
3559 }
3560
3561 #[test]
3562 fn default_prompt_instructs_language_matching() {
3563 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
3567 let lower = p.to_lowercase();
3568 assert!(
3569 lower.contains("same language") && lower.contains("detect"),
3570 "the prompt must tell the model to detect and mirror the user's language: {p}"
3571 );
3572 }
3573
3574 #[test]
3575 fn default_prompt_keeps_working_state_in_the_visible_channel() {
3576 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
3584 let lower = p.to_lowercase();
3585 assert!(
3587 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
3588 "the prompt must explain that reasoning does not survive the turn: {p}"
3589 );
3590 assert!(
3592 lower.contains("visible") && lower.contains("todo tool"),
3593 "the prompt must point at visible text and the todo tool: {p}"
3594 );
3595 assert!(
3597 lower.contains("re-stating the same plan"),
3598 "the prompt must call out plan re-statement as a bug: {p}"
3599 );
3600 assert!(
3602 lower.contains("already shown earlier"),
3603 "the prompt must discourage re-fetching known content: {p}"
3604 );
3605 }
3606
3607 #[test]
3608 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
3609 let p = default_system_prompt_with_tools("/tmp/proj", true, &default_prompt_tools());
3616 let lower = p.to_lowercase();
3617 assert!(
3618 lower.contains("carried back"),
3619 "a replaying model must be told its reasoning survives: {p}"
3620 );
3621 assert!(
3622 !lower.contains("reasoning is not"),
3623 "the replayed variant must not claim reasoning is dropped: {p}"
3624 );
3625 assert!(
3626 lower.contains("do not re-derive"),
3627 "the replayed variant must forbid re-deriving the plan: {p}"
3628 );
3629 assert!(
3630 lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
3631 "the anti-repetition rule must survive the flip: {p}"
3632 );
3633 assert!(
3634 lower.contains("already shown earlier"),
3635 "the re-read rule must survive the flip: {p}"
3636 );
3637 }
3638
3639 #[test]
3647 fn only_read_only_tools_are_replayable() {
3648 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3649 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3650 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3651 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3652 let tools = build_tools(&context, &Args::default());
3653
3654 let replay_of = |name: &str| {
3655 tools
3656 .iter()
3657 .find(|tool| tool.tool.schema().name == name)
3658 .map(|tool| tool.replay)
3659 };
3660
3661 for read_only in ["read", "docs"] {
3662 assert_eq!(
3663 replay_of(read_only),
3664 Some(ToolReplay::Safe),
3665 "{read_only} is side-effect free, so recovery may re-run it"
3666 );
3667 }
3668 for mutating in ["bash", "edit", "write", "powershell"] {
3669 if let Some(replay) = replay_of(mutating) {
3670 assert_eq!(
3671 replay,
3672 ToolReplay::Never,
3673 "{mutating} must never be replayed: re-running it would duplicate its effect"
3674 );
3675 }
3676 }
3677 }
3678
3679 #[test]
3680 fn default_tools_include_docs_lookup() {
3681 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3682 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3683 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3684 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3685 let names: Vec<String> = build_tools(&context, &Args::default())
3686 .iter()
3687 .map(|tool| tool.tool.schema().name.clone())
3688 .collect();
3689
3690 assert!(names.contains(&"docs".to_string()));
3695 for legacy in ["grep", "find", "ls"] {
3696 assert!(
3697 !names.contains(&legacy.to_string()),
3698 "legacy tool {legacy} should not be registered by default"
3699 );
3700 }
3701 }
3702
3703 #[test]
3704 fn reload_settings_preflight_respects_trust() {
3705 struct RestoreConfigDir(Option<std::ffi::OsString>);
3706
3707 impl Drop for RestoreConfigDir {
3708 fn drop(&mut self) {
3709 match self.0.take() {
3710 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3711 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3712 }
3713 }
3714 }
3715
3716 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3717 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3718 let tmp = tempfile::tempdir().unwrap();
3719 let agent = tmp.path().join("agent");
3720 let cwd = tmp.path().join("project");
3721 std::fs::create_dir_all(&agent).unwrap();
3722 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3723 std::fs::write(agent.join("settings.json"), "{}").unwrap();
3724 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
3725 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3726 let args = Args::default();
3727
3728 let trusted = validate_settings_for_reload(&args, &cwd, true);
3729 let untrusted = validate_settings_for_reload(&args, &cwd, false);
3730 assert!(trusted
3731 .unwrap_err()
3732 .contains("could not load project settings"));
3733 assert!(untrusted.is_ok());
3734
3735 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3736 assert!(validate_settings_for_reload(&args, &cwd, false)
3737 .unwrap_err()
3738 .contains("could not load global settings"));
3739
3740 let local_only = Args {
3741 dev_local_only: true,
3742 ..Args::default()
3743 };
3744 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
3745 }
3746
3747 #[tokio::test(flavor = "current_thread")]
3748 async fn reload_preserves_live_resources_when_settings_break() {
3749 struct RestoreConfigDir(Option<std::ffi::OsString>);
3750
3751 impl Drop for RestoreConfigDir {
3752 fn drop(&mut self) {
3753 match self.0.take() {
3754 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3755 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3756 }
3757 }
3758 }
3759
3760 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3761 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3762 let tmp = tempfile::tempdir().unwrap();
3763 let agent = tmp.path().join("agent");
3764 let cwd = tmp.path().join("project");
3765 let skill_dir = tmp.path().join("configured-skills");
3766 std::fs::create_dir_all(&agent).unwrap();
3767 std::fs::create_dir_all(&cwd).unwrap();
3768 std::fs::create_dir_all(&skill_dir).unwrap();
3769 std::fs::write(
3770 skill_dir.join("SKILL.md"),
3771 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
3772 )
3773 .unwrap();
3774 std::fs::write(
3775 agent.join("settings.json"),
3776 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3777 )
3778 .unwrap();
3779 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3780
3781 let resolved = crate::provider::resolve(
3782 Some("anthropic"),
3783 Some(crate::provider::DEFAULT_MODEL_ID),
3784 None,
3785 Some("test-key"),
3786 None,
3787 )
3788 .unwrap();
3789 let args = Args {
3790 trust_override: Some(false),
3791 no_session: true,
3792 no_extensions: true,
3793 no_prompt_templates: true,
3794 no_context_files: true,
3795 system_prompt: Some("stable system prompt".into()),
3796 ..Args::default()
3797 };
3798 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3799 let before_resources = harness.get_resources().await.unwrap();
3800 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
3801 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
3802 let before_prompt = harness.get_system_prompt().await.unwrap();
3803 let before_tools: Vec<String> = harness
3804 .get_tools()
3805 .await
3806 .unwrap()
3807 .iter()
3808 .map(|tool| tool.tool.schema().name.clone())
3809 .collect();
3810 let before_bridge = context
3811 .action_bridge
3812 .lock()
3813 .unwrap()
3814 .as_ref()
3815 .unwrap()
3816 .clone();
3817
3818 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3819 let outcome = reload_extension_resources(&harness, &context).await;
3820
3821 assert!(outcome.had_warnings);
3822 assert!(outcome.summary.contains("Settings reload failed"));
3823 assert!(outcome.summary.contains("could not load global settings"));
3824 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
3825 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
3826 let after_tools: Vec<String> = harness
3827 .get_tools()
3828 .await
3829 .unwrap()
3830 .iter()
3831 .map(|tool| tool.tool.schema().name.clone())
3832 .collect();
3833 assert_eq!(after_tools, before_tools);
3834 let after_bridge = context
3835 .action_bridge
3836 .lock()
3837 .unwrap()
3838 .as_ref()
3839 .unwrap()
3840 .clone();
3841 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
3842 }
3843
3844 #[test]
3845 fn reload_preparation_rejects_settings_changed_during_derivation() {
3846 struct RestoreConfigDir(Option<std::ffi::OsString>);
3847
3848 impl Drop for RestoreConfigDir {
3849 fn drop(&mut self) {
3850 match self.0.take() {
3851 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3852 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3853 }
3854 }
3855 }
3856
3857 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3858 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3859 let tmp = tempfile::tempdir().unwrap();
3860 let agent = tmp.path().join("agent");
3861 let cwd = tmp.path().join("project");
3862 let first_skill_dir = tmp.path().join("first-skills");
3863 let second_skill_dir = tmp.path().join("second-skills");
3864 std::fs::create_dir_all(&agent).unwrap();
3865 std::fs::create_dir_all(&cwd).unwrap();
3866 std::fs::write(
3867 agent.join("settings.json"),
3868 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3869 )
3870 .unwrap();
3871 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3872 let args = Args {
3873 trust_override: Some(false),
3874 no_extensions: true,
3875 no_prompt_templates: true,
3876 ..Args::default()
3877 };
3878
3879 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3880 std::fs::write(
3881 agent.join("settings.json"),
3882 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3883 )
3884 .unwrap();
3885 });
3886
3887 let error = result
3888 .err()
3889 .expect("settings mutation must fail preparation");
3890 assert!(error.contains("settings changed while reload inputs were being prepared"));
3891 }
3892
3893 #[tokio::test(flavor = "current_thread")]
3894 async fn reload_uses_frozen_settings_inputs_after_preparation() {
3895 struct RestoreConfigDir(Option<std::ffi::OsString>);
3896
3897 impl Drop for RestoreConfigDir {
3898 fn drop(&mut self) {
3899 match self.0.take() {
3900 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3901 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3902 }
3903 }
3904 }
3905
3906 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3907 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3908 let tmp = tempfile::tempdir().unwrap();
3909 let agent = tmp.path().join("agent");
3910 let cwd = tmp.path().join("project");
3911 let skill_dir = tmp.path().join("configured-skills");
3912 std::fs::create_dir_all(&agent).unwrap();
3913 std::fs::create_dir_all(&cwd).unwrap();
3914 std::fs::create_dir_all(&skill_dir).unwrap();
3915 std::fs::write(
3916 skill_dir.join("SKILL.md"),
3917 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3918 )
3919 .unwrap();
3920 std::fs::write(
3921 agent.join("settings.json"),
3922 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3923 )
3924 .unwrap();
3925 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3926
3927 let resolved = crate::provider::resolve(
3928 Some("anthropic"),
3929 Some(crate::provider::DEFAULT_MODEL_ID),
3930 None,
3931 Some("test-key"),
3932 None,
3933 )
3934 .unwrap();
3935 let args = Args {
3936 trust_override: Some(false),
3937 no_session: true,
3938 no_extensions: true,
3939 no_prompt_templates: true,
3940 no_context_files: true,
3941 system_prompt: Some("stable system prompt".into()),
3942 ..Args::default()
3943 };
3944 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3945
3946 let outcome = reload_extension_resources_inner(&harness, &context, || {
3947 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3948 })
3949 .await;
3950
3951 assert!(!outcome.summary.contains("Settings reload failed"));
3952 assert_eq!(
3953 outcome.summary,
3954 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3955 );
3956 let resources = harness.get_resources().await.unwrap();
3957 let skills = resources.skills.as_ref().unwrap();
3958 assert_eq!(skills.len(), 1);
3959 assert_eq!(skills[0].name, "frozen-skill");
3960 }
3961
3962 #[tokio::test]
3963 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3964 let tmp = tempfile::tempdir().unwrap();
3965 let skill_path = tmp.path().join("explicit-skill.md");
3966 std::fs::write(
3967 &skill_path,
3968 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3969 )
3970 .unwrap();
3971 let prompt_path = tmp.path().join("explicit-prompt.md");
3972 std::fs::write(
3973 &prompt_path,
3974 "---\ndescription: Explicit prompt\n---\nPrompt body",
3975 )
3976 .unwrap();
3977
3978 let args = Args {
3979 skill: vec![skill_path.clone()],
3980 prompt_template: vec![prompt_path.clone()],
3981 ..Args::default()
3982 };
3983 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3984 let prompt_paths =
3985 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3986 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3987 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3988
3989 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3990 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3991 assert_eq!(skills.skills[0].name, "explicit-skill");
3992
3993 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3994 assert_eq!(
3995 prompts.prompt_templates.len(),
3996 1,
3997 "{:?}",
3998 prompts.diagnostics
3999 );
4000 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
4001 }
4002
4003 #[test]
4004 fn tool_policy_applies_to_rust_and_js_active_names() {
4005 let names = vec![
4006 "read".to_string(),
4007 "ask_user_question".to_string(),
4008 "write".to_string(),
4009 ];
4010
4011 let args = Args {
4012 tools: Some(vec!["read".into(), "ask_user_question".into()]),
4013 ..Args::default()
4014 };
4015 assert_eq!(
4016 filter_active_tool_names(names.clone(), &args),
4017 vec!["read", "ask_user_question"]
4018 );
4019
4020 let args = Args {
4021 exclude_tools: Some(vec!["ask_user_question".into()]),
4022 ..Args::default()
4023 };
4024 assert_eq!(
4025 filter_active_tool_names(names.clone(), &args),
4026 vec!["read", "write"]
4027 );
4028
4029 let args = Args {
4030 no_tools: true,
4031 ..Args::default()
4032 };
4033 assert!(filter_active_tool_names(names, &args).is_empty());
4034 }
4035
4036 #[test]
4037 fn select_ephemeral_when_no_session() {
4038 let args = Args {
4039 no_session: true,
4040 ..Args::default()
4041 };
4042 let cwd = Path::new("/tmp");
4043 assert!(matches!(
4044 select_session(&args, cwd),
4045 SessionSelection::Ephemeral
4046 ));
4047 }
4048
4049 #[test]
4050 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
4051 let defaults = Args::default();
4052 assert!(resolve_project_trust(
4053 &defaults,
4054 Path::new("C:/definitely-not-a-project")
4055 ));
4056
4057 let denied = Args {
4058 trust_override: Some(false),
4059 ..Args::default()
4060 };
4061 assert!(!resolve_project_trust(
4062 &denied,
4063 Path::new("C:/definitely-not-a-project")
4064 ));
4065 }
4066
4067 #[tokio::test(flavor = "current_thread")]
4068 async fn build_preserves_the_supplied_startup_project_snapshot() {
4069 struct RestoreConfigDir(Option<std::ffi::OsString>);
4070
4071 impl Drop for RestoreConfigDir {
4072 fn drop(&mut self) {
4073 match self.0.take() {
4074 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4075 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4076 }
4077 }
4078 }
4079
4080 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4081 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4082 let tmp = tempfile::tempdir().unwrap();
4083 let agent = tmp.path().join("agent");
4084 let cwd = tmp.path().join("project");
4085 std::fs::create_dir_all(&agent).unwrap();
4086 std::fs::create_dir_all(&cwd).unwrap();
4087 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4088
4089 let resolved = crate::provider::resolve(
4090 Some("anthropic"),
4091 Some(crate::provider::DEFAULT_MODEL_ID),
4092 None,
4093 Some("test-key"),
4094 None,
4095 )
4096 .unwrap();
4097 let args = Args {
4098 trust_override: Some(false),
4099 no_session: true,
4100 no_tools: true,
4101 no_extensions: true,
4102 no_skills: true,
4103 no_prompt_templates: true,
4104 no_context_files: true,
4105 system_prompt: Some("test prompt".into()),
4106 ..Args::default()
4107 };
4108
4109 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
4110
4111 assert_eq!(context.cwd, cwd);
4112 assert!(context.project_trusted);
4113 }
4114
4115 #[tokio::test(flavor = "current_thread")]
4116 async fn build_applies_the_name_flag_to_the_session() {
4117 struct RestoreConfigDir(Option<std::ffi::OsString>);
4118
4119 impl Drop for RestoreConfigDir {
4120 fn drop(&mut self) {
4121 match self.0.take() {
4122 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4123 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4124 }
4125 }
4126 }
4127
4128 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4129 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4130 let tmp = tempfile::tempdir().unwrap();
4131 let agent = tmp.path().join("agent");
4132 let cwd = tmp.path().join("project");
4133 std::fs::create_dir_all(&agent).unwrap();
4134 std::fs::create_dir_all(&cwd).unwrap();
4135 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4136
4137 let resolved = crate::provider::resolve(
4138 Some("anthropic"),
4139 Some(crate::provider::DEFAULT_MODEL_ID),
4140 None,
4141 Some("test-key"),
4142 None,
4143 )
4144 .unwrap();
4145 let args = Args {
4146 trust_override: Some(false),
4147 no_tools: true,
4148 no_extensions: true,
4149 no_skills: true,
4150 no_prompt_templates: true,
4151 no_context_files: true,
4152 system_prompt: Some("test prompt".into()),
4153 name: Some("smoke name".into()),
4154 session_dir: Some(cwd.join("sessions")),
4155 ..Args::default()
4156 };
4157
4158 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
4159 assert_eq!(
4160 harness.get_name().await.unwrap().as_deref(),
4161 Some("smoke name")
4162 );
4163 }
4164
4165 #[test]
4166 fn select_latest_for_continue_and_resume() {
4167 let _env = crate::config::test_support::SessionDirEnv::new(None);
4168 let args = Args {
4169 continue_session: true,
4170 ..Args::default()
4171 };
4172 let cwd = Path::new("/tmp");
4173 assert!(matches!(
4174 select_session(&args, cwd),
4175 SessionSelection::Latest { .. }
4176 ));
4177
4178 let args = Args {
4179 resume: true,
4180 ..Args::default()
4181 };
4182 assert!(matches!(
4183 select_session(&args, cwd),
4184 SessionSelection::Latest { .. }
4185 ));
4186 }
4187
4188 #[test]
4189 fn select_by_id_for_session_flag() {
4190 let _env = crate::config::test_support::SessionDirEnv::new(None);
4191 let args = Args {
4192 session: Some("01a02ece".into()),
4193 ..Args::default()
4194 };
4195 let cwd = Path::new("/tmp");
4196 assert!(matches!(
4197 select_session(&args, cwd),
4198 SessionSelection::ById { id, .. } if id == "01a02ece"
4199 ));
4200 }
4201
4202 #[test]
4203 fn select_new_with_custom_dir() {
4204 let _env = crate::config::test_support::SessionDirEnv::new(None);
4205 let args = Args {
4206 session_dir: Some(PathBuf::from("/tmp/sess")),
4207 ..Args::default()
4208 };
4209 let cwd = Path::new("/tmp");
4210 match select_session(&args, cwd) {
4211 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
4212 other => panic!("expected New, got {other:?}"),
4213 }
4214 }
4215
4216 #[test]
4217 fn select_new_default_dir() {
4218 let _env = crate::config::test_support::SessionDirEnv::new(None);
4219 let args = Args::default();
4220 let cwd = Path::new("/proj");
4221 match select_session(&args, cwd) {
4222 SessionSelection::New { dir, .. } => {
4223 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
4224 }
4225 other => panic!("expected New, got {other:?}"),
4226 }
4227 }
4228
4229 #[test]
4230 fn default_session_dir_only_uses_rpi() {
4231 let _env = crate::config::test_support::SessionDirEnv::new(None);
4232 let tmp = tempfile::tempdir().unwrap();
4233 let cwd = tmp.path();
4234 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
4235 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
4236 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
4237 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
4238 }
4239
4240 #[test]
4241 fn default_session_dir_uses_env_var() {
4242 let tmp = tempfile::tempdir().unwrap();
4243 let cwd = tmp.path();
4244 let custom = tmp.path().join("custom_sessions");
4245 let _env = crate::config::test_support::SessionDirEnv::new(Some(&custom));
4246 assert_eq!(default_session_dir(cwd), custom);
4247 }
4248
4249 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4250 async fn ephemeral_session_builds_roundtrips() {
4251 let s = ephemeral_session();
4255 let leaf = s.get_leaf_id().await;
4256 assert!(leaf.is_ok());
4257 }
4258
4259 #[test]
4277 fn host_private_env_matches_the_declared_contract() {
4278 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
4280 assert_eq!(
4281 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
4282 [
4283 "LANGFUSE_PI_PARENT_TRACE_ID",
4284 "LANGFUSE_PI_PARENT_SPAN_ID",
4285 "LANGFUSE_PI_PARENT_SESSION_ID",
4286 "LANGFUSE_PI_PARENT_DEPTH",
4287 ],
4288 "these strings are what rpi-langfuse (a different repo) matches; a \
4289 change here is a breaking cross-repo change, not a refactor"
4290 );
4291
4292 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
4295 .iter()
4296 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
4297 {
4298 assert!(
4299 rpi_tools::is_host_private_env(name),
4300 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
4301 rpi_tools::HOST_PRIVATE_ENV
4302 );
4303 }
4304
4305 for visible in [
4308 crate::args::RPI_OFFLINE_ENV,
4309 crate::config::CONFIG_DIR_ENV,
4310 "PATH",
4311 ] {
4312 assert!(
4313 !rpi_tools::is_host_private_env(visible),
4314 "{visible} describes the user's configuration and must still reach the shell"
4315 );
4316 }
4317 }
4318 #[tokio::test]
4319 async fn reload_shutdown_joins_all_handlers_even_after_veto() {
4320 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
4321 use std::sync::atomic::AtomicUsize;
4322 static CONTEXTS: rpi_plugin_sdk::handles::ToolHandles<Arc<AtomicUsize>> =
4323 rpi_plugin_sdk::handles::ToolHandles::new();
4324 struct ContextOwner(usize);
4325 impl Drop for ContextOwner {
4326 fn drop(&mut self) {
4327 drop(CONTEXTS.remove(self.0 as rpi_plugin_sdk::StepHandle));
4328 }
4329 }
4330 extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
4331 let context = CONTEXTS.get(data).expect("live test context");
4332 let calls = context.lock().clone();
4333 std::thread::sleep(std::time::Duration::from_millis(20));
4334 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
4335 rpi_plugin_sdk::EVENT_HANDLER_ABORT
4336 } else {
4337 0
4338 }
4339 }
4340 let calls = Arc::new(AtomicUsize::new(0));
4341 let context = CONTEXTS.insert(calls.clone());
4342 let _owner = ContextOwner(context as usize);
4343 let mut registry = rpi_extensions::ExtensionRegistry::new();
4344 for name in ["first", "second"] {
4345 registry.register_event_handler(name.into(), EventTag::SessionShutdown, stop, context);
4346 }
4347 super::shutdown_extensions_for_reload(
4348 Some(Arc::new(registry.snapshot())),
4349 rpi_extensions::PluginKeepalive::empty(),
4350 )
4351 .await
4352 .unwrap();
4353 assert_eq!(calls.load(Ordering::SeqCst), 2);
4354 }
4355}