1use std::collections::{BTreeMap, 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, ToolPromptMetadata,
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 {
80 let tools = BUILTIN_TOOL_NAMES
81 .iter()
82 .map(|name| (*name).to_owned())
83 .collect::<Vec<_>>();
84 default_system_prompt_with_tools(cwd, prior_reasoning_replayed, &tools)
85}
86
87pub fn default_system_prompt_with_tools(
96 cwd: &str,
97 prior_reasoning_replayed: bool,
98 tools: &[String],
99) -> String {
100 default_system_prompt_with_tool_prompts(cwd, prior_reasoning_replayed, tools, &BTreeMap::new())
101}
102
103pub fn default_system_prompt_with_tool_prompts(
108 cwd: &str,
109 prior_reasoning_replayed: bool,
110 tools: &[String],
111 overrides: &BTreeMap<String, ToolPromptMetadata>,
112) -> String {
113 let metadata: BTreeMap<_, _> = tools
114 .iter()
115 .map(|name| {
116 let prompt = overrides
117 .get(name)
118 .cloned()
119 .unwrap_or_else(|| ToolPromptMetadata {
120 prompt_snippet: builtin_tool_snippet(name).map(str::to_owned),
121 prompt_guidelines: Vec::new(),
122 })
123 .normalized();
124 (name.as_str(), prompt)
125 })
126 .collect();
127 let working_state_rule = if prior_reasoning_replayed {
133 "- Your reasoning is carried back to you on the next turn: do not re-derive it or restate
134 the plan. Continue from the last unfinished step, and when a step lands, say which one.
135 Re-stating the same plan without acting on it is a bug, not progress."
136 } else {
137 "- Your reasoning is not carried into your next turn; only your visible replies and tool
138 output come back. Before each batch of tool calls, write one short line naming the step you
139 are on and what remains, and say which step is done when it lands.
140 Re-stating the same plan without acting on it is a bug, not progress."
141 };
142
143 let tool_lines = tools
146 .iter()
147 .filter_map(|name| {
148 metadata
149 .get(name.as_str())?
150 .prompt_snippet
151 .as_ref()
152 .map(|snippet| format!("- {name:<5} — {snippet}"))
153 })
154 .collect::<Vec<_>>()
155 .join("\n");
156 let tool_lines = if tool_lines.is_empty() {
157 "(none)".to_string()
158 } else {
159 tool_lines
160 };
161
162 let guidelines = [
165 "- Reply in the same language as the user's latest message (the conversation's language when
166 that message is too short to tell), unless the user asks for another.",
167 "- Be concise in your responses",
168 "- Show file paths clearly when working with files",
169 "- Prefer the smallest change that solves the problem",
170 working_state_rule,
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 ];
174 let mut seen = HashSet::new();
177 let tool_rules = tools
178 .iter()
179 .flat_map(|name| &metadata[name.as_str()].prompt_guidelines)
180 .map(String::as_str);
181 let core_rules = guidelines
182 .iter()
183 .map(|rule| rule.strip_prefix("- ").unwrap_or(rule));
184 let guidelines = tool_rules
185 .chain(core_rules)
186 .map(str::trim)
187 .filter(|rule| !rule.is_empty() && seen.insert(*rule))
188 .map(|rule| format!("- {rule}"))
189 .collect::<Vec<_>>()
190 .join("\n");
191
192 format!(
193 "You are an expert coding assistant operating inside rpi, a coding agent harness. \
194You help users by reading files, executing commands, editing code, and writing new files.
195
196Available tools:
197{tool_lines}
198
199In addition to the tools above, you may have access to other custom tools depending on the project.
200
201Guidelines:
202{guidelines}
203
204Current working directory: {cwd}"
205 )
206}
207
208fn extension_tool_prompt_metadata(
211 session: &ExtensionSession,
212) -> BTreeMap<String, ToolPromptMetadata> {
213 session
214 .snapshot()
215 .map(|snapshot| {
216 snapshot
217 .tools()
218 .iter()
219 .map(|tool| (tool.tool.name.clone(), tool.prompt.clone()))
220 .collect()
221 })
222 .unwrap_or_default()
223}
224
225fn builtin_tool_snippet(name: &str) -> Option<&'static str> {
227 Some(match name {
228 "read" => "Read file contents",
229 "bash" => "Execute Bash commands (Bash/POSIX syntax, also on Windows)",
230 "edit" => "Find/replace edits to existing files",
231 "write" => "Create or overwrite files",
232 "docs" => "Look up rpi commands, extensions, Pi package compatibility and .rpi configuration instead of guessing",
233 "grep" => "Search file contents for a pattern",
234 "find" => "Find files by glob pattern",
235 "ls" => "List directory contents",
236 "powershell" => "Execute PowerShell commands on Windows",
237 _ => return None,
238 })
239}
240
241#[derive(Debug, Clone)]
248pub enum SessionSelection {
249 Ephemeral,
251 New { dir: PathBuf, name: Option<String> },
253 Latest { dir: PathBuf },
255 ById { id: String, dir: PathBuf },
258 ByExactId { id: String, dir: PathBuf },
260 Fork { source: String, dir: PathBuf },
263}
264
265pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
267 select_session_in_dir(args, configured_session_dir(args, cwd))
268}
269
270pub(crate) fn configured_session_dir(args: &Args, cwd: &Path) -> PathBuf {
272 let dir = args
273 .session_dir
274 .clone()
275 .or_else(|| {
276 crate::settings::load_settings()
277 .ok()
278 .and_then(|settings| settings.session_dir)
279 })
280 .unwrap_or_else(|| default_session_dir(cwd));
281 if dir.is_relative() && !dir.has_root() {
284 cwd.join(dir)
285 } else {
286 dir
287 }
288}
289
290pub(crate) fn select_session_in_dir(args: &Args, dir: PathBuf) -> SessionSelection {
291 if args.no_session {
292 return SessionSelection::Ephemeral;
293 }
294 if args.continue_session || args.resume {
298 return SessionSelection::Latest { dir };
299 }
300 if let Some(s) = &args.fork {
301 return SessionSelection::Fork {
302 source: s.clone(),
303 dir,
304 };
305 }
306 if let Some(s) = &args.session_id {
307 return SessionSelection::ByExactId { id: s.clone(), dir };
308 }
309 if let Some(s) = &args.session {
310 return SessionSelection::ById { id: s.clone(), dir };
311 }
312 SessionSelection::New {
313 dir,
314 name: args.name.clone(),
315 }
316}
317
318pub fn default_session_dir(cwd: &Path) -> PathBuf {
321 std::env::var("RPI_CODING_AGENT_SESSION_DIR")
322 .ok()
323 .map(PathBuf::from)
324 .unwrap_or_else(|| cwd.join(".rpi").join("sessions"))
325}
326
327pub struct PreparedExtensions {
330 pub extension_session: ExtensionSession,
331 session_dir: PathBuf,
332 runtime: tokio::runtime::Handle,
333 tool_context: rpi_extensions::ToolCallContext,
334 action_host: Arc<crate::extensions_actions::HarnessActionHost>,
335 harness_cell: Arc<std::sync::OnceLock<rpi_harness::agent_harness::WeakAgentHarness>>,
336 reload_mailbox: rpi_extensions::ReloadMailbox,
337 ui_dialog_mailbox: rpi_extensions::UiDialogMailbox,
338 ext_status_mailbox: rpi_extensions::ExtensionStatusMailbox,
339 editor_text_mailbox: rpi_extensions::EditorTextMailbox,
340 action_bridge: Arc<rpi_extensions::ActionBridge>,
341}
342
343pub fn prepare_extensions(
344 args: &Args,
345 cwd: &Path,
346 project_trusted: bool,
347) -> Result<PreparedExtensions, BuildError> {
348 let session_dir = configured_session_dir(args, cwd);
349 let cwd_str = cwd.to_string_lossy().to_string();
350 let tool_context = rpi_extensions::ToolCallContext::new(cwd_str.clone());
351 if !project_trusted && args.verbose {
352 eprintln!(
353 "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)"
354 );
355 }
356 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
375 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
376 })?;
377 let catalog = Vec::new();
378 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
379 catalog.clone(),
380 cwd.to_path_buf(),
381 runtime.clone(),
382 args.unknown_flags.clone(),
383 tool_context.clone(),
384 );
385 let action_host = Arc::new(action_host.with_session_dir(session_dir.clone()));
386 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = action_host.clone();
387 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
397 let ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
403 let ext_status_mailbox = rpi_extensions::ExtensionStatusMailbox::new();
407 let editor_text_mailbox = rpi_extensions::EditorTextMailbox::new();
412 let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
413 runtime.clone(),
414 host_arc,
415 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
416 ui_dialog_mailbox.clone(),
417 ext_status_mailbox.clone(),
418 editor_text_mailbox.clone(),
419 );
420
421 let extension_session = if args.no_extensions {
422 ExtensionSession::none()
423 } else {
424 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
425 };
426 if args.verbose {
427 if let Some(s) = extension_session.summary() {
428 eprintln!("extensions: {s}");
429 }
430 report_deferred_renderers(&extension_session);
431 }
432 Ok(PreparedExtensions {
433 session_dir,
434 runtime,
435 tool_context,
436 action_host,
437 harness_cell,
438 reload_mailbox,
439 ui_dialog_mailbox,
440 ext_status_mailbox,
441 editor_text_mailbox,
442 action_bridge,
443 extension_session,
444 })
445}
446
447pub async fn build(
465 resolved: &ResolvedModel,
466 args: &Args,
467 cwd: &Path,
468 project_trusted: bool,
469) -> Result<
470 (
471 AgentHarness,
472 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
473 ReloadContext,
474 ),
475 BuildError,
476> {
477 let prepared = prepare_extensions(args, cwd, project_trusted)?;
478 build_prepared(resolved, args, cwd, project_trusted, prepared).await
479}
480
481pub async fn build_prepared(
482 resolved: &ResolvedModel,
483 args: &Args,
484 cwd: &Path,
485 project_trusted: bool,
486 prepared: PreparedExtensions,
487) -> Result<
488 (
489 AgentHarness,
490 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
491 ReloadContext,
492 ),
493 BuildError,
494> {
495 let cwd_str = cwd.to_string_lossy().to_string();
496 let PreparedExtensions {
501 session_dir,
502 runtime,
503 tool_context,
504 action_host,
505 harness_cell,
506 reload_mailbox,
507 ui_dialog_mailbox,
508 ext_status_mailbox,
509 editor_text_mailbox,
510 action_bridge,
511 extension_session,
512 } = prepared;
513 let catalog = crate::provider::authenticated_catalog(resolved);
514 action_host.set_catalog(catalog.clone());
515
516 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
518 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
519 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
520 let _registry = Arc::new(MutationQueueRegistry::new());
521 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
524
525 let mut tools = build_tools(&ctx, args);
526
527 merge_extension_tools(&mut tools, &extension_session, args, &tool_context);
535 let active = filter_active_tool_names(active_tool_names(&tools, args), args);
536
537 let selection = select_session_in_dir(args, session_dir.clone());
544 let session = build_session(&selection, &cwd_str).await?;
545 if let Ok(metadata) = session.get_metadata().await {
549 publish_session_identity(&tool_context, &metadata.id);
550 }
551 if let Some(requested) = args.name.as_deref() {
557 let name = requested.trim();
558 let name = if name.is_empty() { None } else { Some(name) };
559 if let Err(error) = session.set_name(name).await {
560 if args.verbose {
561 eprintln!("warning: could not set session name: {error}");
562 }
563 }
564 }
565 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
575 let prompt_tools = active.clone();
576 let prompt_metadata = extension_tool_prompt_metadata(&extension_session);
577 let default_prompt = || {
578 default_system_prompt_with_tool_prompts(
579 &cwd_str,
580 prior_reasoning_replayed,
581 &prompt_tools,
582 &prompt_metadata,
583 )
584 };
585 let mut context_paths = Vec::new();
586 let base_prompt = match args.system_prompt.as_deref() {
587 Some(explicit) => explicit.to_string(),
588 None if project_trusted => match discover_system_prompt_file(cwd) {
589 Some(path) => {
590 read_prompt_source(&path, &mut context_paths).unwrap_or_else(default_prompt)
591 }
592 None => default_prompt(),
593 },
594 None => default_prompt(),
595 };
596
597 let mut append_texts: Vec<String> = Vec::new();
604 for extra in &args.append_system_prompt {
605 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
606 append_texts.push(text);
607 }
608 if args.append_system_prompt.is_empty() {
609 if let Some(path) = project_trusted
610 .then(|| discover_append_system_prompt_file(cwd))
611 .flatten()
612 {
613 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
614 append_texts.push(text);
615 }
616 }
617 }
618 let append_join = if append_texts.is_empty() {
619 None
620 } else {
621 Some(append_texts.join("\n\n"))
622 };
623
624 let agent_dir = crate::config::agent_dir().ok();
636
637 let discovered = extension_session
653 .snapshot_arc()
654 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
655 .unwrap_or_default();
656
657 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
658 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
659 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
660 if !args.no_skills {
661 let mut dirs = if args.dev_local_only || args.project_only {
662 project_skill_dirs(cwd)
663 } else if project_trusted {
664 skill_dirs(cwd)
665 } else {
666 global_skill_dirs()
667 };
668 dirs.extend(args.skill.iter().cloned());
669 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
670 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
671 skills = result.skills;
672 skill_diags = result.diagnostics;
673 resource_diags.extend(result.resource_diagnostics);
674 }
675
676 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
677 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
678 if !args.no_prompt_templates {
679 let mut dirs = if args.dev_local_only || args.project_only {
680 project_prompt_template_dirs(cwd)
681 } else if project_trusted {
682 prompt_template_dirs(cwd)
683 } else {
684 global_prompt_template_dirs()
685 };
686 dirs.extend(args.prompt_template.iter().cloned());
687 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
688 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
689 prompt_templates = result.prompt_templates;
690 prompt_diags = result.diagnostics;
691 resource_diags.extend(result.resource_diagnostics);
692 }
693
694 let context_block = if args.no_context_files || !project_trusted {
695 String::new()
696 } else {
697 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
702 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
703 context_paths.extend(
704 files
705 .iter()
706 .map(|file| file.path.to_string_lossy().into_owned()),
707 );
708 format_project_context(&files)
709 };
710
711 if args.verbose {
713 for d in &skill_diags {
714 eprintln!(
715 "warning: skill {} ({}): {}",
716 d.path,
717 d.code.as_str(),
718 d.message
719 );
720 }
721 for d in &prompt_diags {
722 eprintln!(
723 "warning: prompt template {} ({}): {}",
724 d.path,
725 d.code.as_str(),
726 d.message
727 );
728 }
729 for d in &resource_diags {
731 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
732 eprintln!(
733 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
734 c.resource_type.as_str(),
735 c.name,
736 c.winner_path,
737 c.loser_path
738 );
739 }
740 }
741 }
742
743 let system_prompt = compose_system_prompt(
752 Some(&base_prompt),
753 &[], if context_block.is_empty() {
755 None
756 } else {
757 Some(&context_block)
758 },
759 append_join.as_deref(),
760 );
761
762 if args.debug_system_prompt {
771 eprintln!("=== --debug-system-prompt ===");
772 let base_src = if args.system_prompt.is_some() {
773 "--system-prompt"
774 } else if discover_system_prompt_file(cwd).is_some() {
775 "SYSTEM.md"
776 } else {
777 "default"
778 };
779 eprintln!("[base source: {base_src}]");
780 eprintln!("--- base ---\n{base_prompt}");
781 if let Some(append) = append_join.as_deref() {
782 eprintln!("--- append ---\n{append}");
783 } else {
784 eprintln!("--- append: (none) ---");
785 }
786 if context_block.is_empty() {
787 eprintln!("--- context: (none) ---");
788 } else {
789 eprintln!("--- context ---{context_block}");
790 }
791 let visible_skills = skills
792 .iter()
793 .filter(|s| s.disable_model_invocation != Some(true))
794 .count();
795 eprintln!(
796 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
797 skills.len(),
798 visible_skills,
799 skills.len() - visible_skills
800 );
801 for s in &skills {
802 let hidden = if s.disable_model_invocation == Some(true) {
803 " [hidden]"
804 } else {
805 ""
806 };
807 eprintln!(" {}{hidden} — {}", s.name, s.description);
808 }
809 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
810 for t in &prompt_templates {
811 eprintln!(" /{}", t.name);
812 }
813 eprintln!(
817 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
818 discovered.skill_paths.len(),
819 discovered.prompt_paths.len(),
820 discovered.theme_paths.len(),
821 );
822 for p in &discovered.skill_paths {
823 eprintln!(" skill: {p}");
824 }
825 for p in &discovered.prompt_paths {
826 eprintln!(" prompt: {p}");
827 }
828 eprintln!(
829 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
830 );
831 eprintln!("=== end --debug-system-prompt ===");
832 }
833
834 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
839 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
840 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
844
845 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
854 Some(snapshot) => {
855 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
856 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
857 }
858 None => broadcast_emitter,
859 };
860
861 let saved_settings = crate::settings::load_settings().unwrap_or_default();
865
866 if let Some(proxy) = &saved_settings.http_proxy {
871 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
872 if std::env::var_os(key).is_none() {
873 std::env::set_var(key, proxy);
874 }
875 }
876 }
877
878 let extension_session_cell = Arc::new(Mutex::new(extension_session.clone()));
883 let before_tool_call = extension_session
888 .snapshot()
889 .filter(|snapshot| {
890 !snapshot
891 .handlers_for(rpi_plugin_sdk::EventTag::ToolCall)
892 .is_empty()
893 })
894 .map(|_| {
895 extension_tool_call_hook(
896 Arc::clone(&extension_session_cell),
897 ui_dialog_mailbox.clone(),
898 )
899 });
900
901 let options = AgentHarnessOptions {
902 model: resolved.model.clone(),
903 thinking_level: resolved.thinking_level,
904 active_tool_names: active,
905 tools,
906 system_prompt: Some(system_prompt),
907 resources: AgentHarnessResources {
908 context_files: context_paths,
909 skills: if skills.is_empty() {
910 None
911 } else {
912 Some(skills)
913 },
914 prompt_templates: if prompt_templates.is_empty() {
915 None
916 } else {
917 Some(prompt_templates)
918 },
919 },
920 allow_existing_session: matches!(
923 selection,
924 SessionSelection::Latest { .. }
925 | SessionSelection::ById { .. }
926 | SessionSelection::ByExactId { .. }
927 | SessionSelection::Fork { .. }
928 ),
929 stream_options: AgentHarnessStreamOptions {
930 timeout: args.timeout,
931 stream_idle_timeout: args.stream_idle_timeout.or_else(|| {
932 saved_settings
933 .stream_idle_timeout
934 .map(std::time::Duration::from_secs)
935 }),
936 thinking_budgets: saved_settings.thinking_budgets,
937 ..Default::default()
938 },
939 retry: saved_settings.retry_policy(),
944 compaction: saved_settings.compaction_settings(),
945 steering_mode: saved_settings.steering_mode(),
946 follow_up_mode: saved_settings.follow_up_mode(),
947 tool_execution: HarnessToolExecution::default(),
948 drive: DrivingMode::default(),
949 session,
950 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
958 to_provider_messages: None,
959 entry_projectors: Default::default(),
960 agent_emitter: Some(emitter),
961 before_tool_call,
966 after_tool_call: None,
967 transform_context: None,
968 entry_transforms: Vec::new(),
969 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
975 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
976 };
977
978 let harness = match AgentHarness::create(options).await {
979 Ok(h) => {
980 crate::extensions_actions::HarnessActionHost::set_harness(
983 &harness_cell,
984 Arc::new(h.clone()),
985 );
986 h
987 }
988 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
989 };
990
991 {
999 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
1000 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
1001 Ok(reports) => {
1002 for (lane, report) in reports {
1003 if report.is_clean() {
1004 continue;
1005 }
1006 if report.is_corrupt() {
1007 eprintln!(
1008 "warning: session lane '{lane}' is corrupt ({} open operations); \
1009 refusing to auto-repair — inspect the session file",
1010 report.open_operations.len()
1011 );
1012 continue;
1013 }
1014 for decision in runtime.reconcile(&report) {
1015 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
1016 if args.verbose {
1017 eprintln!(
1018 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
1019 );
1020 }
1021 }
1022 }
1023 runtime.emit_recovery_events(harness.events(), &report);
1024 }
1025 }
1026 Err(e) => {
1027 if args.verbose {
1028 eprintln!("warning: session recovery sweep failed: {e}");
1029 }
1030 }
1031 }
1032 }
1033
1034 let mut reload_args = args.clone();
1043 reload_args.session_dir = Some(session_dir);
1044 let reload_context = ReloadContext {
1045 extension_session: extension_session_cell,
1046 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
1047 tool_context,
1048 catalog: Arc::new(std::sync::RwLock::new(catalog)),
1049 providers: Arc::new(std::sync::RwLock::new(resolved.providers.clone())),
1050 runtime: runtime.clone(),
1051 cwd: cwd.to_path_buf(),
1052 project_trusted,
1053 args: reload_args,
1054 resolved_model: resolved.model.clone(),
1055 broadcast: broadcast_for_context,
1056 mailbox: reload_mailbox,
1057 ui_dialog: ui_dialog_mailbox,
1058 ext_status: ext_status_mailbox,
1059 editor_text: editor_text_mailbox,
1060 dev_extension: None,
1061 startup_notices: Vec::new(),
1062 };
1063
1064 Ok((harness, event_rx, reload_context))
1065}
1066
1067fn extension_tool_call_hook(
1070 sessions: ExtensionSessionCell,
1071 ui_dialog: rpi_extensions::UiDialogMailbox,
1072) -> rpi_agent::BeforeToolCall {
1073 Arc::new(move |ctx: rpi_agent::BeforeToolCallContext<'_>, signal| {
1074 let session = sessions
1075 .lock()
1076 .map(|session| session.clone())
1077 .map_err(|_| ());
1078 let ui_dialog = ui_dialog.clone();
1079 let tool_call_id = ctx.tool_call.id.clone();
1080 let tool_name = ctx.tool_call.name.clone();
1081 let params = ctx.args.clone();
1082 Box::pin(async move {
1083 let reason = match session {
1084 Ok(session) => {
1085 rpi_extensions::dispatch_tool_call_event(
1086 &session,
1087 &tool_call_id,
1088 &tool_name,
1089 ¶ms,
1090 &signal,
1091 &ui_dialog,
1092 )
1093 .await
1094 }
1095 Err(_) => Some("extension approval session is unavailable".to_string()),
1096 };
1097 reason.map(|reason| rpi_agent::BeforeToolCallResult {
1098 block: true,
1099 reason: Some(reason),
1100 terminate: false,
1101 args: None,
1102 })
1103 })
1104 })
1105}
1106
1107pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
1152
1153pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
1158
1159#[derive(Clone)]
1167pub struct ReloadContext {
1168 pub extension_session: ExtensionSessionCell,
1170 pub tool_context: rpi_extensions::ToolCallContext,
1175 pub action_bridge: ActionBridgeCell,
1177 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
1180 pub editor_text: rpi_extensions::EditorTextMailbox,
1187 pub catalog: Arc<std::sync::RwLock<Vec<rpi_ai::Model>>>,
1190 pub providers: Arc<std::sync::RwLock<Vec<Arc<dyn Provider>>>>,
1193 pub runtime: tokio::runtime::Handle,
1197 pub cwd: PathBuf,
1199 pub project_trusted: bool,
1203 pub args: Args,
1210 pub resolved_model: rpi_ai::Model,
1213 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
1219 pub mailbox: rpi_extensions::ReloadMailbox,
1226 pub ui_dialog: rpi_extensions::UiDialogMailbox,
1232 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
1236 pub startup_notices: Vec<String>,
1242}
1243
1244impl ReloadContext {
1245 pub(crate) fn session_dir(&self) -> PathBuf {
1246 self.args
1248 .session_dir
1249 .clone()
1250 .unwrap_or_else(|| configured_session_dir(&self.args, &self.cwd))
1251 }
1252}
1253
1254pub async fn dispatch_session_event_async(
1269 reload_context: &ReloadContext,
1270 tag: rpi_plugin_sdk::EventTag,
1271) -> Option<String> {
1272 let (snapshot, keepalive) = {
1278 let session = reload_context.extension_session.lock().unwrap();
1279 (session.snapshot_arc(), session.keepalive())
1280 };
1281 match snapshot {
1282 Some(snapshot) => rpi_extensions::dispatch_lifecycle_event(&snapshot, tag, keepalive).await,
1283 None => None,
1284 }
1285}
1286
1287async fn shutdown_extensions_for_reload(
1290 snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
1291 keepalive: Arc<rpi_extensions::PluginKeepalive>,
1292) -> Result<(), String> {
1293 if let Some(snapshot) = snapshot {
1294 tokio::task::spawn_blocking(move || {
1295 let _keepalive = keepalive;
1296 rpi_extensions::dispatch_empty_event(
1297 &snapshot,
1298 rpi_plugin_sdk::EventTag::SessionShutdown,
1299 );
1300 })
1301 .await
1302 .map_err(|error| error.to_string())?;
1303 }
1304 Ok(())
1305}
1306
1307pub struct ReloadOutcome {
1310 pub summary: String,
1313 pub had_warnings: bool,
1315 pub details: Vec<String>,
1320 pub had_errors: bool,
1324}
1325
1326struct PreparedReloadInputs {
1327 extension_dirs: Vec<PathBuf>,
1328 skill_base_dirs: Vec<PathBuf>,
1329 prompt_base_dirs: Vec<PathBuf>,
1330}
1331
1332fn append_reload_resource_paths(
1337 mut paths: Vec<PathBuf>,
1338 explicit: &[PathBuf],
1339 discovered: &[String],
1340 js_paths: &[PathBuf],
1341 package_paths: &[PathBuf],
1342) -> Vec<PathBuf> {
1343 paths.extend(explicit.iter().cloned());
1344 paths.extend(discovered.iter().map(PathBuf::from));
1345 paths.extend(js_paths.iter().cloned());
1346 paths.extend(package_paths.iter().cloned());
1347 paths
1348}
1349
1350pub async fn reload_extension_resources(
1362 harness: &AgentHarness,
1363 ctx: &ReloadContext,
1364) -> ReloadOutcome {
1365 reload_extension_resources_inner(harness, ctx, || {}).await
1366}
1367
1368async fn reload_extension_resources_inner<F>(
1369 harness: &AgentHarness,
1370 ctx: &ReloadContext,
1371 after_prepare: F,
1372) -> ReloadOutcome
1373where
1374 F: FnOnce() + Send,
1375{
1376 let mut effective_args = ctx.args.clone();
1381 if let Some(dev) = &ctx.dev_extension {
1382 if let Err(error) = dev
1383 .rebuild()
1384 .and_then(|_| dev.apply_to_args(&mut effective_args))
1385 {
1386 return ReloadOutcome {
1387 summary: format!(
1388 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1389 dev.package_name()
1390 ),
1391 had_warnings: true,
1392 details: Vec::new(),
1393 had_errors: true,
1394 };
1395 }
1396 }
1397 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1398 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1399 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1400 Ok(prepared) => prepared,
1401 Err(error) => {
1402 return ReloadOutcome {
1403 summary: format!(
1404 "Settings reload failed: {error}. Keeping the currently loaded resources."
1405 ),
1406 had_warnings: true,
1407 details: Vec::new(),
1408 had_errors: true,
1409 };
1410 }
1411 };
1412 after_prepare();
1413 let PreparedReloadInputs {
1414 extension_dirs,
1415 skill_base_dirs,
1416 prompt_base_dirs,
1417 } = prepared;
1418 let mut warnings = false;
1419
1420 let (shutdown_snapshot, shutdown_keepalive) = {
1424 let session = ctx.extension_session.lock().unwrap();
1425 (session.snapshot_arc(), session.keepalive())
1426 };
1427 if let Err(reason) = shutdown_extensions_for_reload(shutdown_snapshot, shutdown_keepalive).await
1428 {
1429 return ReloadOutcome {
1430 summary: "Reload failed during extension shutdown".into(),
1431 had_warnings: true,
1432 details: vec![reason],
1433 had_errors: true,
1434 };
1435 }
1436
1437 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1444 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1445 Some(b) => b.clone_host(),
1446 None => {
1447 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1450 ctx.catalog
1451 .read()
1452 .unwrap_or_else(|error| error.into_inner())
1453 .clone(),
1454 ctx.cwd.clone(),
1455 ctx.runtime.clone(),
1456 ctx.args.unknown_flags.clone(),
1457 ctx.tool_context.clone(),
1458 );
1459 crate::extensions_actions::HarnessActionHost::set_harness(
1460 &_cell,
1461 Arc::new(harness.clone()),
1462 );
1463 Arc::new(action_host.with_session_dir(ctx.session_dir()))
1464 }
1465 };
1466
1467 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1468 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1469 ctx.runtime.clone(),
1470 host,
1471 reload_cb,
1472 ctx.ui_dialog.clone(),
1473 ctx.ext_status.clone(),
1474 ctx.editor_text.clone(),
1475 );
1476
1477 let extension_session = if effective_args.no_extensions {
1478 rpi_extensions::ExtensionSession::none()
1479 } else {
1480 load_extensions_from_dirs(
1481 &effective_args,
1482 &extension_dirs,
1483 Some(Arc::clone(&fresh_bridge)),
1484 )
1485 };
1486 if extension_session.is_empty() && !effective_args.no_extensions {
1487 }
1490 if effective_args.verbose {
1491 if let Some(s) = extension_session.summary() {
1492 eprintln!("reload: {s}");
1493 }
1494 report_deferred_renderers(&extension_session);
1495 }
1496
1497 let has_package_providers = |session: &ExtensionSession| {
1498 session.snapshot().is_some_and(|snapshot| {
1499 snapshot
1500 .providers()
1501 .iter()
1502 .any(|provider| snapshot.provider_manifest(&provider.provider_id).is_some())
1503 })
1504 };
1505 let packaged = has_package_providers(&extension_session)
1506 || has_package_providers(&ctx.extension_session.lock().unwrap());
1507 let refreshed = if packaged {
1508 let mut args = effective_args.clone();
1509 if let Ok(current) = harness.lane("main").get_model().await {
1510 let was_package = ctx
1511 .extension_session
1512 .lock()
1513 .unwrap()
1514 .snapshot()
1515 .is_some_and(|snapshot| snapshot.provider_manifest(¤t.provider).is_some());
1516 let has_replacement = extension_session.snapshot().is_some_and(|snapshot| {
1517 snapshot
1518 .providers()
1519 .iter()
1520 .any(|provider| provider.provider_id == current.provider)
1521 });
1522 if was_package && !has_replacement {
1523 return ReloadOutcome {
1524 summary: "Reload failed while resolving provider packages".into(),
1525 had_warnings: true,
1526 details: vec![format!(
1527 "active provider package '{}' is no longer loaded",
1528 current.provider
1529 )],
1530 had_errors: true,
1531 };
1532 }
1533 args.provider = Some(current.provider.clone());
1534 args.model = Some(current.id);
1535 if current.provider != ctx.resolved_model.provider {
1536 args.api_key = None;
1537 args.base_url = None;
1538 }
1539 }
1540 match crate::provider::resolve_with_extensions(
1541 &args,
1542 &ctx.cwd,
1543 ctx.project_trusted,
1544 &extension_session,
1545 ) {
1546 Ok(resolved) => Some(resolved),
1547 Err(error) => {
1548 return ReloadOutcome {
1549 summary: "Reload failed while resolving provider packages".into(),
1550 had_warnings: true,
1551 details: vec![error.to_string()],
1552 had_errors: true,
1553 }
1554 }
1555 }
1556 } else {
1557 None
1558 };
1559
1560 let old_session = {
1572 let mut session_guard = ctx.extension_session.lock().unwrap();
1573 let old_session = std::mem::replace(&mut *session_guard, extension_session.clone());
1574 if let Some(old_snap) = old_session.snapshot_arc() {
1575 old_snap.active_flag().store(false, Ordering::SeqCst);
1579 }
1580 old_session
1581 };
1582 if let Some(old_b) = old_bridge {
1583 old_b.invalidate();
1584 }
1585 old_session.begin_shutdown();
1587
1588 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1590
1591 let discovered = extension_session
1593 .snapshot_arc()
1594 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1595 .unwrap_or_default();
1596
1597 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1599 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1600
1601 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1602 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1603 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1604 if !effective_args.no_skills {
1605 let js_paths: &[PathBuf] = &[];
1609 let package_paths: &[PathBuf] = &[];
1610 let dirs = append_reload_resource_paths(
1611 skill_base_dirs,
1612 &effective_args.skill,
1613 &discovered.skill_paths,
1614 js_paths,
1615 package_paths,
1616 );
1617 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1618 skills = result.skills;
1619 skill_diags = result.diagnostics;
1620 resource_diags.extend(result.resource_diagnostics);
1621 }
1622
1623 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1624 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1625 if !effective_args.no_prompt_templates {
1626 let js_paths: &[PathBuf] = &[];
1627 let package_paths: &[PathBuf] = &[];
1628 let dirs = append_reload_resource_paths(
1629 prompt_base_dirs,
1630 &effective_args.prompt_template,
1631 &discovered.prompt_paths,
1632 js_paths,
1633 package_paths,
1634 );
1635 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1636 prompt_templates = result.prompt_templates;
1637 prompt_diags = result.diagnostics;
1638 resource_diags.extend(result.resource_diagnostics);
1639 }
1640
1641 let context_files = if effective_args.no_context_files || !project_trusted {
1642 Vec::new()
1643 } else {
1644 let agent_dir = crate::config::agent_dir().ok();
1645 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1646 load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await
1647 };
1648 let context_block = format_project_context(&context_files);
1649
1650 let mut details: Vec<String> = Vec::new();
1651 if let Some(resolved) = &refreshed {
1652 warnings |= !resolved.compat_warnings.is_empty();
1653 details.extend(
1654 resolved
1655 .compat_warnings
1656 .iter()
1657 .map(|warning| format!("warning: models.json: {warning}")),
1658 );
1659 }
1660 if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
1661 warnings = true;
1662 for d in &skill_diags {
1667 details.push(format!(
1668 "warning: skill {} ({}): {}",
1669 d.path,
1670 d.code.as_str(),
1671 d.message
1672 ));
1673 }
1674 for d in &prompt_diags {
1675 details.push(format!(
1676 "warning: prompt template {} ({}): {}",
1677 d.path,
1678 d.code.as_str(),
1679 d.message
1680 ));
1681 }
1682 for d in &resource_diags {
1684 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1685 details.push(format!(
1686 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1687 c.resource_type.as_str(),
1688 c.name,
1689 c.winner_path,
1690 c.loser_path
1691 ));
1692 }
1693 }
1694 }
1695
1696 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1705 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1706 let mut tools = build_tools(&tool_ctx, &effective_args);
1707 merge_extension_tools(
1708 &mut tools,
1709 &extension_session,
1710 &effective_args,
1711 &ctx.tool_context,
1712 );
1713 let mut active = active_tool_names(&tools, &effective_args);
1714 active = filter_active_tool_names(active, &effective_args);
1715 let prompt_tools = active.clone();
1716 let prompt_metadata = extension_tool_prompt_metadata(&extension_session);
1717 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1718 let default_prompt = || {
1719 default_system_prompt_with_tool_prompts(
1720 &cwd_str,
1721 prior_reasoning_replayed,
1722 &prompt_tools,
1723 &prompt_metadata,
1724 )
1725 };
1726 let mut context_paths = Vec::new();
1727 let base_prompt = match effective_args.system_prompt.as_deref() {
1728 Some(explicit) => explicit.to_string(),
1729 None if project_trusted => match discover_system_prompt_file(&ctx.cwd) {
1730 Some(path) => {
1731 read_prompt_source(&path, &mut context_paths).unwrap_or_else(default_prompt)
1732 }
1733 None => default_prompt(),
1734 },
1735 None => default_prompt(),
1736 };
1737 let mut append_texts: Vec<String> = Vec::new();
1738 for extra in &effective_args.append_system_prompt {
1739 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
1740 append_texts.push(text);
1741 }
1742 if effective_args.append_system_prompt.is_empty() {
1743 if let Some(path) = project_trusted
1744 .then(|| discover_append_system_prompt_file(&ctx.cwd))
1745 .flatten()
1746 {
1747 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
1748 append_texts.push(text);
1749 }
1750 }
1751 }
1752 context_paths.extend(
1753 context_files
1754 .iter()
1755 .map(|file| file.path.to_string_lossy().into_owned()),
1756 );
1757 let append_join = if append_texts.is_empty() {
1758 None
1759 } else {
1760 Some(append_texts.join("\n\n"))
1761 };
1762 let system_prompt = compose_system_prompt(
1763 Some(&base_prompt),
1764 &[],
1765 if context_block.is_empty() {
1766 None
1767 } else {
1768 Some(&context_block)
1769 },
1770 append_join.as_deref(),
1771 );
1772
1773 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1775 Some(snapshot) => {
1776 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1777 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1778 }
1779 None => ctx.broadcast.clone(),
1780 };
1781
1782 let resources = AgentHarnessResources {
1784 context_files: context_paths,
1785 skills: if skills.is_empty() {
1786 None
1787 } else {
1788 Some(skills.clone())
1789 },
1790 prompt_templates: if prompt_templates.is_empty() {
1791 None
1792 } else {
1793 Some(prompt_templates.clone())
1794 },
1795 };
1796 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1797 let _ = harness.set_resources(resources).await;
1798 let _ = harness.set_agent_emitter(Some(emitter)).await;
1799 let _ = harness
1800 .set_models(build_models_with_extensions_for_reload(
1801 &refreshed
1802 .as_ref()
1803 .map(|resolved| resolved.providers.clone())
1804 .unwrap_or_else(|| {
1805 ctx.providers
1806 .read()
1807 .unwrap_or_else(|error| error.into_inner())
1808 .clone()
1809 }),
1810 &extension_session,
1811 ctx.runtime.clone(),
1812 ))
1813 .await;
1814 let _ = harness
1815 .set_provider_hooks(
1816 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1817 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1818 )
1819 .await;
1820
1821 if let Some(resolved) = &refreshed {
1822 *ctx.providers
1823 .write()
1824 .unwrap_or_else(|error| error.into_inner()) = resolved.providers.clone();
1825 let catalog = crate::provider::authenticated_catalog(resolved);
1826 fresh_bridge
1827 .clone_host()
1828 .replace_model_catalog(catalog.clone());
1829 *ctx.catalog
1830 .write()
1831 .unwrap_or_else(|error| error.into_inner()) = catalog;
1832 let _ = harness.lane("main").set_model(resolved.model.clone()).await;
1835 }
1836
1837 let _ = harness.set_tools(tools, Some(active)).await;
1838
1839 let summary = format!(
1840 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1841 extension_session.loaded_paths().len(),
1842 skills.len(),
1843 prompt_templates.len(),
1844 );
1845 ReloadOutcome {
1846 summary,
1847 had_warnings: warnings,
1848 details,
1849 had_errors: false,
1852 }
1853}
1854
1855#[derive(Clone, Debug, PartialEq, Eq)]
1856struct ReloadSettingsFields {
1857 skill_dirs: Option<Vec<String>>,
1858 prompt_dirs: Option<Vec<String>>,
1859 extension_dirs: Option<Vec<String>>,
1860}
1861
1862impl From<crate::settings::Settings> for ReloadSettingsFields {
1863 fn from(settings: crate::settings::Settings) -> Self {
1864 Self {
1865 skill_dirs: settings.skill_dirs,
1866 prompt_dirs: settings.prompt_dirs,
1867 extension_dirs: settings.extension_dirs,
1868 }
1869 }
1870}
1871
1872#[derive(Clone, Debug, PartialEq, Eq)]
1873struct ReloadSettingsSnapshot {
1874 global: ReloadSettingsFields,
1875 project: Option<ReloadSettingsFields>,
1876}
1877
1878fn reload_reads_settings(args: &Args) -> bool {
1879 !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
1880}
1881
1882fn load_reload_settings_snapshot(
1886 args: &Args,
1887 cwd: &Path,
1888 project_trusted: bool,
1889) -> Result<Option<ReloadSettingsSnapshot>, String> {
1890 if !reload_reads_settings(args) {
1891 return Ok(None);
1892 }
1893 let global = crate::settings::load_settings()
1894 .map_err(|error| format!("could not load global settings: {error}"))?
1895 .into();
1896 let project = if project_trusted {
1897 crate::settings::load_active_project_settings(cwd)
1898 .map_err(|error| format!("could not load project settings: {error}"))?
1899 .map(|(_, settings)| settings.into())
1900 } else {
1901 None
1902 };
1903 Ok(Some(ReloadSettingsSnapshot { global, project }))
1904}
1905
1906#[cfg(test)]
1909fn validate_settings_for_reload(
1910 args: &Args,
1911 cwd: &Path,
1912 project_trusted: bool,
1913) -> Result<(), String> {
1914 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1915}
1916
1917fn prepare_reload_inputs(
1918 args: &Args,
1919 cwd: &Path,
1920 project_trusted: bool,
1921) -> Result<PreparedReloadInputs, String> {
1922 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1923}
1924
1925fn prepare_reload_inputs_inner<F>(
1926 args: &Args,
1927 cwd: &Path,
1928 project_trusted: bool,
1929 before_verify: F,
1930) -> Result<PreparedReloadInputs, String>
1931where
1932 F: FnOnce(),
1933{
1934 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1935
1936 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1937 Vec::new()
1938 } else if args.project_only {
1939 project_extension_dirs(cwd)
1940 } else if project_trusted {
1941 extension_dirs(cwd)
1942 } else {
1943 global_extension_dirs()
1944 };
1945 if !args.no_extensions {
1946 extension_dirs.extend(args.extensions_dir.iter().cloned());
1947 }
1948
1949 let skill_base_dirs = if args.no_skills {
1950 Vec::new()
1951 } else if args.dev_local_only || args.project_only {
1952 project_skill_dirs(cwd)
1953 } else if project_trusted {
1954 skill_dirs(cwd)
1955 } else {
1956 global_skill_dirs()
1957 };
1958
1959 let prompt_base_dirs = if args.no_prompt_templates {
1960 Vec::new()
1961 } else if args.dev_local_only || args.project_only {
1962 project_prompt_template_dirs(cwd)
1963 } else if project_trusted {
1964 prompt_template_dirs(cwd)
1965 } else {
1966 global_prompt_template_dirs()
1967 };
1968
1969 before_verify();
1970 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1971 if settings_before != settings_after {
1972 return Err(
1973 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1974 );
1975 }
1976
1977 Ok(PreparedReloadInputs {
1978 extension_dirs,
1979 skill_base_dirs,
1980 prompt_base_dirs,
1981 })
1982}
1983
1984fn build_models_with_extensions_for_reload(
1989 providers: &[Arc<dyn Provider>],
1990 extension_session: &ExtensionSession,
1991 runtime: tokio::runtime::Handle,
1992) -> Vec<Arc<dyn Provider>> {
1993 let mut models: Vec<Arc<dyn Provider>> = providers.to_vec();
1994 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1995 for provider in pluggable {
1996 if extension_session
2000 .snapshot()
2001 .is_some_and(|snapshot| snapshot.provider_manifest(provider.id()).is_some())
2002 {
2003 continue;
2004 }
2005 if !models.iter().any(|known| known.id() == provider.id()) {
2006 models.push(provider);
2007 }
2008 }
2009 models
2010}
2011
2012fn report_deferred_renderers(session: &ExtensionSession) {
2016 let Some(snap) = session.snapshot_arc() else {
2017 return;
2018 };
2019 let all = snap.renderers();
2020 let markdown = all
2021 .iter()
2022 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
2023 .count();
2024 let message = all
2025 .iter()
2026 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
2027 .count();
2028 let entry = all
2029 .iter()
2030 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
2031 .count();
2032 if markdown + message + entry == 0 {
2033 return;
2034 }
2035 eprintln!(
2036 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
2037 markdown, message, entry
2038 );
2039}
2040
2041#[derive(Debug, thiserror::Error)]
2043pub enum BuildError {
2044 #[error("Could not create the session directory: {0}")]
2045 SessionDir(String),
2046 #[error("Could not open the saved session: {0}")]
2047 SessionOpen(String),
2048 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
2049 SessionNotFound { requested: String, dir: String },
2050 #[error("Could not build the harness: {0}")]
2051 HarnessCreate(String),
2052}
2053
2054fn build_models_with_extensions(
2057 resolved: &ResolvedModel,
2058 extension_session: &ExtensionSession,
2059 runtime: tokio::runtime::Handle,
2060) -> Vec<Arc<dyn Provider>> {
2061 build_models_with_extensions_for_reload(&resolved.providers, extension_session, runtime)
2062}
2063
2064pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
2067 if let Some(override_value) = args.trust_override {
2068 return override_value;
2069 }
2070 crate::config::project_trust_decision(cwd)
2071 .ok()
2072 .flatten()
2073 .unwrap_or(true)
2074}
2075
2076pub(crate) fn load_extensions(
2084 args: &Args,
2085 cwd: &Path,
2086 project_trusted: bool,
2087 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
2088) -> ExtensionSession {
2089 if args.no_extensions {
2090 return ExtensionSession::none();
2091 }
2092 let mut dirs = if args.dev_local_only {
2093 Vec::new()
2094 } else if args.project_only {
2095 project_extension_dirs(cwd)
2096 } else if project_trusted {
2097 extension_dirs(cwd)
2098 } else {
2099 global_extension_dirs()
2100 };
2101 dirs.extend(args.extensions_dir.iter().cloned());
2102 load_extensions_from_dirs(args, &dirs, action_bridge)
2103}
2104
2105fn load_extensions_from_dirs(
2106 args: &Args,
2107 dirs: &[PathBuf],
2108 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
2109) -> ExtensionSession {
2110 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
2111 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
2118}
2119
2120fn merge_extension_tools(
2126 tools: &mut Vec<HarnessTool>,
2127 session: &ExtensionSession,
2128 args: &Args,
2129 tool_context: &rpi_extensions::ToolCallContext,
2130) {
2131 let Some(snapshot) = session.snapshot() else {
2132 return;
2133 };
2134 for et in snapshot.tools() {
2135 let name = &et.tool.name;
2136 if !tool_name_allowed(name, args) {
2137 continue;
2138 }
2139 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
2140 .with_context(tool_context.clone());
2141 let harness_tool = HarnessTool::new(Arc::new(adapter));
2142 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
2143 Some(slot) => *slot = harness_tool,
2144 None => tools.push(harness_tool),
2145 }
2146 }
2147}
2148
2149pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
2156 use rpi_tools::tools::bash::BashToolOptions;
2157 let default = std::env::var("RPI_BASH_TIMEOUT")
2158 .ok()
2159 .and_then(|v| v.parse::<f64>().ok())
2160 .unwrap_or(rpi_tools::tools::bash::DEFAULT_BASH_TIMEOUT_SECONDS);
2161 let settings = crate::settings::load_settings().unwrap_or_default();
2167 BashToolOptions {
2168 command_prefix: settings.shell_command_prefix.clone(),
2169 default_timeout: Some(default),
2170 prepare: None,
2171 }
2172}
2173
2174fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
2175 if args.no_tools {
2176 return Vec::new();
2177 }
2178 let mut all: Vec<(&'static str, HarnessTool)> = vec![
2187 (
2188 "read",
2189 HarnessTool::new(create_read_tool(
2190 ctx,
2191 Some(rpi_tools::tools::read::ReadToolOptions {
2192 image_processor: Some(Arc::new(
2193 rpi_tools::image_processing::InlineImageProcessor,
2194 )),
2195 ..Default::default()
2196 }),
2197 ))
2198 .with_replay(ToolReplay::Safe),
2199 ),
2200 (
2201 "bash",
2202 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
2203 ),
2204 ("edit", HarnessTool::new(create_edit_tool(ctx))),
2205 ("write", HarnessTool::new(create_write_tool(ctx))),
2206 (
2207 "docs",
2208 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
2209 ),
2210 ];
2211
2212 #[cfg(target_os = "windows")]
2214 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
2215
2216 if args.tools.is_none() {
2218 if let Ok(settings) = crate::settings::load_settings() {
2219 if let Some(default_tools) = &settings.default_tools {
2220 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
2221 }
2222 }
2223 }
2224
2225 if args.no_builtin_tools {
2229 all.clear();
2230 }
2231
2232 if let Some(allow) = &args.tools {
2234 all.retain(|(name, _)| allow.iter().any(|a| a == name));
2235 }
2236 if let Some(deny) = &args.exclude_tools {
2238 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
2239 }
2240
2241 all.into_iter().map(|(_, t)| t).collect()
2242}
2243
2244fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
2248 filter_active_tool_names(
2249 tools.iter().map(|tool| tool.tool.schema().name.clone()),
2250 args,
2251 )
2252}
2253
2254pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
2257 if args.no_tools {
2258 return false;
2259 }
2260 if args
2261 .tools
2262 .as_ref()
2263 .is_some_and(|allow| !allow.iter().any(|value| value == name))
2264 {
2265 return false;
2266 }
2267 if args
2268 .exclude_tools
2269 .as_ref()
2270 .is_some_and(|deny| deny.iter().any(|value| value == name))
2271 {
2272 return false;
2273 }
2274 true
2275}
2276
2277pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
2278where
2279 I: IntoIterator<Item = String>,
2280{
2281 names
2282 .into_iter()
2283 .filter(|name| tool_name_allowed(name, args))
2284 .collect()
2285}
2286
2287async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2289 match selection {
2290 SessionSelection::Ephemeral => Ok(ephemeral_session()),
2291 SessionSelection::New { dir, .. } => {
2292 std::fs::create_dir_all(dir)
2295 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2296 let session = create_jsonl_session(dir, cwd)
2297 .await
2298 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2299 Ok(session)
2300 }
2301 SessionSelection::Latest { .. }
2302 | SessionSelection::ById { .. }
2303 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2304 SessionSelection::Fork { source, dir } => fork_session_at_launch(source, dir, cwd).await,
2305 }
2306}
2307
2308async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2314 let Some(meta) = resolve_restore_target(selection, cwd).await? else {
2318 return match selection {
2319 SessionSelection::ByExactId { id, dir } => {
2322 std::fs::create_dir_all(dir)
2323 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2324 create_jsonl_session_with_id(dir, cwd, Some(id.clone()))
2325 .await
2326 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2327 }
2328 SessionSelection::Latest { dir } => Err(BuildError::SessionNotFound {
2329 requested: "the most recent session".to_string(),
2330 dir: dir.display().to_string(),
2331 }),
2332 SessionSelection::ById { id, dir } => Err(BuildError::SessionNotFound {
2333 requested: format!("session {id}"),
2334 dir: dir.display().to_string(),
2335 }),
2336 _ => unreachable!("resolve_restore_target only returns None for restore selections"),
2337 };
2338 };
2339 let sessions_root = selection_dir(selection);
2340 open_session(&meta, cwd, &sessions_root)
2341 .await
2342 .map(|restored| restored.session)
2343}
2344
2345fn selection_dir(selection: &SessionSelection) -> PathBuf {
2347 match selection {
2348 SessionSelection::Ephemeral => PathBuf::new(),
2349 SessionSelection::New { dir, .. }
2350 | SessionSelection::Latest { dir }
2351 | SessionSelection::ById { dir, .. }
2352 | SessionSelection::ByExactId { dir, .. }
2353 | SessionSelection::Fork { dir, .. } => dir.clone(),
2354 }
2355}
2356
2357async fn resolve_restore_target(
2364 selection: &SessionSelection,
2365 cwd: &str,
2366) -> Result<Option<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2367 match selection {
2368 SessionSelection::Latest { dir } => {
2369 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2370 Ok(first_resumable_session(&metas).cloned())
2371 }
2372 SessionSelection::ById { id, dir } => find_session_metadata(id, cwd, dir).await,
2373 SessionSelection::ByExactId { id, dir } => {
2374 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2375 Ok(metas.into_iter().find(|m| m.id == *id))
2376 }
2377 SessionSelection::Fork { .. }
2378 | SessionSelection::New { .. }
2379 | SessionSelection::Ephemeral => Ok(None),
2380 }
2381}
2382
2383async fn find_session_metadata(
2387 id: &str,
2388 cwd: &str,
2389 dir: &Path,
2390) -> Result<Option<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2391 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2392 Ok(metas
2393 .into_iter()
2394 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id)))
2395}
2396
2397pub(crate) struct RestoredSessionCwd {
2399 pub id: String,
2405 pub recorded: PathBuf,
2407 pub adopted: Option<PathBuf>,
2410}
2411
2412pub(crate) async fn peek_restored_session_cwd(
2420 selection: &SessionSelection,
2421 launch_cwd: &Path,
2422) -> Option<RestoredSessionCwd> {
2423 let cwd_str = launch_cwd.to_string_lossy().into_owned();
2424 let meta = resolve_restore_target(selection, &cwd_str).await.ok()??;
2425 let recorded = PathBuf::from(&meta.cwd);
2426 if recorded.as_os_str().is_empty() {
2427 return None;
2428 }
2429 Some(RestoredSessionCwd {
2430 id: meta.id,
2431 adopted: crate::session_cwd::resolve_session_cwd(&recorded),
2432 recorded,
2433 })
2434}
2435
2436pub(crate) fn pin_restored_selection(args: &mut Args, restored: &RestoredSessionCwd) {
2446 args.continue_session = false;
2447 args.resume = false;
2448 args.session = Some(restored.id.clone());
2449}
2450
2451async fn fork_session_at_launch(
2454 source: &str,
2455 dir: &Path,
2456 cwd: &str,
2457) -> Result<Session, BuildError> {
2458 use rpi_harness::session::jsonl::{
2459 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2460 };
2461 use rpi_harness::session::types::{ForkOptions, SessionStorage};
2462 use rpi_tools::FileSystem;
2463
2464 let dir = dir.to_path_buf();
2465 std::fs::create_dir_all(&dir)
2466 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2467 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2468 let fs: Arc<dyn FileSystem> = env.clone();
2469 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2470 fs: fs.clone(),
2471 sessions_root: dir.to_string_lossy().into_owned(),
2472 clock: Arc::new(SystemClock),
2473 ids: Arc::new(DefaultIdGenerator::new()),
2474 });
2475 let source_meta = match session_metadata_at(Path::new(source)) {
2479 Some(meta) => meta,
2480 None => {
2481 let metas = repo
2482 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2483 .await
2484 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2485 metas
2486 .into_iter()
2487 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2488 .ok_or_else(|| BuildError::SessionNotFound {
2489 requested: format!("--fork {source}"),
2490 dir: dir.display().to_string(),
2491 })?
2492 }
2493 };
2494 let fork_storage = repo
2495 .fork_typed(
2496 &source_meta,
2497 &JsonlSessionCreateOptions {
2498 id: None,
2499 parent_session_id: Some(source_meta.id.clone()),
2500 cwd: cwd.to_string(),
2501 metadata: None,
2502 },
2503 &ForkOptions::default(),
2504 )
2505 .await
2506 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2507 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
2508 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2509 Ok(Session::new(storage_arc, None))
2510}
2511
2512fn session_metadata_at(path: &Path) -> Option<rpi_harness::session::jsonl::JsonlSessionMetadata> {
2515 use rpi_harness::session::jsonl::codec::{metadata_from_header, parse_header};
2516 use std::io::BufRead;
2517
2518 let file = std::fs::File::open(path).ok()?;
2519 let modified_at = file
2520 .metadata()
2521 .ok()?
2522 .modified()
2523 .ok()?
2524 .duration_since(std::time::UNIX_EPOCH)
2525 .ok()?
2526 .as_millis() as i64;
2527 let mut first_line = String::new();
2528 std::io::BufReader::new(file)
2529 .read_line(&mut first_line)
2530 .ok()?;
2531 let header = parse_header(first_line.trim_end()).ok()?;
2532 let path = std::fs::canonicalize(path).ok()?;
2533 Some(metadata_from_header(
2534 &header,
2535 &path.to_string_lossy(),
2536 modified_at,
2537 ))
2538}
2539
2540pub enum OpenError {
2543 NotFound { requested: String },
2545 Other(String),
2547}
2548
2549impl std::fmt::Display for OpenError {
2550 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2551 match self {
2552 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2553 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
2558 }
2559 }
2560}
2561
2562pub async fn list_session_metadata(
2565 cwd: &str,
2566) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2567 let dir = default_session_dir(Path::new(cwd));
2568 list_session_metadata_in_dir(cwd, &dir).await
2569}
2570
2571pub(crate) async fn list_session_metadata_in_dir(
2572 cwd: &str,
2573 dir: &Path,
2574) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2575 use rpi_harness::session::jsonl::{
2576 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2577 };
2578 use rpi_tools::FileSystem;
2579
2580 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2581 let fs: Arc<dyn FileSystem> = env.clone();
2582 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2583 fs: fs.clone(),
2584 sessions_root: dir.to_string_lossy().into_owned(),
2585 clock: Arc::new(SystemClock),
2586 ids: Arc::new(DefaultIdGenerator::new()),
2587 });
2588 repo.list_typed(&JsonlSessionListOptions::default())
2589 .await
2590 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2591}
2592
2593#[derive(Debug, Clone, Default, PartialEq, Eq)]
2609pub struct SessionSummary {
2610 pub name: Option<String>,
2612 pub preview: Option<String>,
2614 pub empty: bool,
2616 pub bytes: u64,
2618}
2619
2620const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
2622const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
2624const SUMMARY_PREVIEW_CHARS: usize = 80;
2626
2627pub fn summarize_session_file(path: &Path) -> SessionSummary {
2631 use std::io::{Read, Seek, SeekFrom};
2632
2633 let Ok(mut file) = std::fs::File::open(path) else {
2634 return SessionSummary::default();
2635 };
2636 let Ok(size) = file.metadata().map(|m| m.len()) else {
2637 return SessionSummary::default();
2638 };
2639 if size == 0 {
2640 return SessionSummary {
2641 name: None,
2642 preview: None,
2643 empty: true,
2644 bytes: 0,
2645 };
2646 }
2647
2648 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2651 let head_len = file.read(&mut head).unwrap_or(0);
2652 head.truncate(head_len);
2653 let head = String::from_utf8_lossy(&head);
2654 let mut lines: Vec<&str> = head.lines().collect();
2655 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2656 let empty = (size as usize) <= header_len + 1;
2658 if (head_len as u64) < size && !lines.is_empty() {
2661 lines.pop();
2662 }
2663
2664 let mut summary = SessionSummary {
2665 name: None,
2666 preview: None,
2667 empty,
2668 bytes: size,
2669 };
2670 for line in lines {
2671 apply_summary_line(line, &mut summary);
2672 }
2673
2674 if size > SUMMARY_HEAD_BYTES
2678 && file
2679 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2680 .is_ok()
2681 {
2682 let mut tail = Vec::new();
2683 if file.read_to_end(&mut tail).is_ok() {
2684 let tail = String::from_utf8_lossy(&tail);
2685 let mut tail_lines = tail.lines();
2686 tail_lines.next();
2688 for line in tail_lines {
2689 if let Some(name) = name_from_line(line) {
2690 summary.name = name;
2691 }
2692 }
2693 }
2694 }
2695 summary
2696}
2697
2698fn name_from_line(line: &str) -> Option<Option<String>> {
2701 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2702 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2703 return None;
2704 }
2705 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2706 return None;
2707 }
2708 Some(match value.get("name") {
2709 Some(serde_json::Value::String(name)) => Some(name.clone()),
2710 _ => None,
2711 })
2712}
2713
2714fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2717 if let Some(name) = name_from_line(line) {
2718 summary.name = name;
2719 return;
2720 }
2721 if summary.preview.is_some() {
2722 return;
2723 }
2724 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2725 return;
2726 };
2727 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2728 return;
2729 }
2730 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2731 return;
2732 }
2733 let Some(message) = value.get("message") else {
2734 return;
2735 };
2736 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2737 return;
2738 }
2739 let text = match message.get("content") {
2740 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2741 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2742 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2743 .then(|| block.get("text").and_then(|v| v.as_str()))
2744 .flatten()
2745 }),
2746 _ => None,
2747 };
2748 let Some(text) = text else { return };
2749 let collapsed = text
2750 .lines()
2751 .map(str::trim)
2752 .filter(|line| !line.is_empty())
2753 .collect::<Vec<_>>()
2754 .join(" ");
2755 if collapsed.is_empty() {
2756 return;
2757 }
2758 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2759}
2760
2761pub fn session_display_label(summary: &SessionSummary) -> String {
2765 if let Some(name) = summary.name.as_deref().map(str::trim) {
2766 if !name.is_empty() {
2767 return name.to_string();
2768 }
2769 }
2770 match summary.preview.as_deref().map(str::trim) {
2771 Some(preview) if !preview.is_empty() => preview.to_string(),
2772 _ => "(no messages)".to_string(),
2773 }
2774}
2775
2776pub fn format_session_bytes(bytes: u64) -> String {
2779 if bytes >= 1_048_576 {
2780 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2781 } else if bytes >= 1024 {
2782 format!("{}KB", bytes / 1024)
2783 } else {
2784 format!("{bytes}B")
2785 }
2786}
2787
2788pub fn short_session_id(id: &str) -> &str {
2791 &id[..id.len().min(8)]
2792}
2793
2794pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2803 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2804 let mut visit = |path: PathBuf| {
2805 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2806 return;
2807 }
2808 let modified = std::fs::metadata(&path)
2809 .and_then(|m| m.modified())
2810 .unwrap_or(std::time::UNIX_EPOCH);
2811 files.push((path, modified));
2812 };
2813 if let Ok(entries) = std::fs::read_dir(dir) {
2814 for entry in entries.flatten() {
2815 let path = entry.path();
2816 if path.is_dir() {
2817 if let Ok(inner) = std::fs::read_dir(&path) {
2818 for child in inner.flatten() {
2819 visit(child.path());
2820 }
2821 }
2822 } else {
2823 visit(path);
2824 }
2825 }
2826 }
2827 files.sort_by(|a, b| b.1.cmp(&a.1));
2828 files.into_iter().map(|(path, _)| path).collect()
2829}
2830
2831fn first_resumable_session(
2836 metas: &[rpi_harness::session::jsonl::JsonlSessionMetadata],
2837) -> Option<&rpi_harness::session::jsonl::JsonlSessionMetadata> {
2838 metas
2839 .iter()
2840 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2841 .or_else(|| metas.first())
2842}
2843
2844pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2848 let dir = default_session_dir(Path::new(cwd));
2849 open_session_by_id_in_dir(id, cwd, &dir)
2850 .await
2851 .map(|restored| restored.session)
2852}
2853
2854pub(crate) async fn open_session_by_id_in_dir(
2855 id: &str,
2856 cwd: &str,
2857 dir: &Path,
2858) -> Result<RestoredSession, OpenError> {
2859 let meta = find_session_metadata(id, cwd, dir)
2860 .await
2861 .map_err(|e| OpenError::Other(e.to_string()))?;
2862 let Some(meta) = meta else {
2863 return Err(OpenError::NotFound {
2864 requested: format!("session {id}"),
2865 });
2866 };
2867 open_session(&meta, cwd, dir).await.map_err(|e| {
2868 OpenError::Other(match e {
2869 BuildError::SessionOpen(reason) => reason,
2873 other => other.to_string(),
2874 })
2875 })
2876}
2877
2878fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
2903 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
2904 tool_context.set_session_id(id);
2905}
2906
2907pub(crate) async fn set_active_session(
2918 harness: &AgentHarness,
2919 session: Session,
2920 tool_context: Option<&rpi_extensions::ToolCallContext>,
2921) -> Result<(), String> {
2922 let metadata = session.get_metadata().await.ok();
2923 harness
2924 .set_session(session)
2925 .await
2926 .map_err(|e| e.to_string())?;
2927 if let Some(metadata) = metadata {
2928 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2929 if let Some(context) = tool_context {
2930 context.set_session_id(metadata.id);
2931 }
2932 }
2933 Ok(())
2934}
2935
2936pub(crate) async fn fork_session_storage(
2943 harness: &AgentHarness,
2944 cwd: &str,
2945 dir: &Path,
2946) -> Result<Session, String> {
2947 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2948 use rpi_tools::FileSystem;
2949
2950 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2951 let fs: Arc<dyn FileSystem> = env.clone();
2952 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2953 fs,
2954 sessions_root: dir.to_string_lossy().into_owned(),
2955 clock: Arc::new(SystemClock),
2956 ids: Arc::new(DefaultIdGenerator::new()),
2957 });
2958 let id = harness.session().storage().metadata().id.clone();
2961 let metas = list_session_metadata_in_dir(cwd, dir)
2962 .await
2963 .map_err(|e| e.to_string())?;
2964 let Some(source) = metas.iter().find(|m| m.id == id) else {
2965 return Err(format!("current session {id} not found on disk"));
2966 };
2967 let fork_storage = repo
2968 .fork_typed(
2969 source,
2970 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2971 id: None,
2972 parent_session_id: Some(source.id.clone()),
2973 cwd: cwd.to_string(),
2974 metadata: None,
2975 },
2976 &rpi_harness::session::types::ForkOptions::default(),
2977 )
2978 .await
2979 .map_err(|e| e.to_string())?;
2980 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2981 Ok(Session::new(Arc::new(fork_storage), None))
2982}
2983
2984pub(crate) async fn import_session_file(
2988 source: &Path,
2989 cwd: &str,
2990 dir: &Path,
2991) -> Result<Session, String> {
2992 use rpi_harness::session::jsonl::{
2993 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions, JsonlSessionStorage,
2994 };
2995 use rpi_tools::FileSystem;
2996
2997 let dir = if dir.is_absolute() {
2998 dir.to_path_buf()
2999 } else {
3000 Path::new(cwd).join(dir)
3001 };
3002 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
3003 let staged = tempfile::NamedTempFile::new_in(&dir)
3004 .map_err(|e| e.to_string())?
3005 .into_temp_path();
3006 std::fs::copy(source, &staged).map_err(|e| format!("copy import: {e}"))?;
3007 let fs: Arc<dyn FileSystem> = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3008 let validated = JsonlSessionStorage::load(
3009 fs.clone(),
3010 &staged.to_string_lossy(),
3011 Arc::new(SystemClock),
3012 Arc::new(DefaultIdGenerator::new()),
3013 )
3014 .await
3015 .map_err(|e| format!("invalid session import: {e}"))?;
3016 let mut metadata = validated.jsonl_metadata().clone();
3017 drop(validated);
3018 if list_session_metadata_in_dir(cwd, &dir)
3019 .await
3020 .map_err(|e| e.to_string())?
3021 .iter()
3022 .any(|saved| saved.id == metadata.id)
3023 {
3024 return Err(format!(
3025 "session {} already exists in {}",
3026 metadata.id,
3027 dir.display()
3028 ));
3029 }
3030 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3031 fs,
3032 sessions_root: dir.to_string_lossy().into_owned(),
3033 clock: Arc::new(SystemClock),
3034 ids: Arc::new(DefaultIdGenerator::new()),
3035 });
3036 let destination = repo
3038 .create_typed(&JsonlSessionCreateOptions {
3039 id: Some(metadata.id.clone()),
3040 parent_session_id: metadata.parent_session_id.clone(),
3041 cwd: cwd.to_string(),
3042 metadata: metadata.metadata.clone(),
3043 })
3044 .await
3045 .map_err(|e| e.to_string())?;
3046 let path = destination.jsonl_metadata().path.clone();
3047 drop(destination);
3048 if let Err(error) = claim_session_lock(&path) {
3049 return Err(error);
3051 }
3052 if let Err(error) = staged.persist(&path) {
3053 let _ = std::fs::remove_file(&path);
3054 return Err(format!("publish import: {error}"));
3055 }
3056 metadata.path = path;
3057 let storage = repo
3058 .open_by_jsonl_metadata(&metadata)
3059 .await
3060 .map_err(|e| e.to_string())?;
3061 Ok(Session::new(Arc::new(storage), None))
3062}
3063
3064static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
3066static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
3069
3070fn claim_session_lock(session_path: &str) -> Result<(), String> {
3079 let lock_path = PathBuf::from(format!("{session_path}.lock"));
3080 if SESSION_LOCK_PATHS
3081 .get_or_init(|| Mutex::new(HashSet::new()))
3082 .lock()
3083 .unwrap()
3084 .contains(&lock_path)
3085 {
3086 return Ok(());
3087 }
3088 let file = std::fs::OpenOptions::new()
3089 .create(true)
3090 .write(true)
3091 .open(&lock_path)
3092 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
3093 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
3094 return Err(format!(
3095 "session {session_path} is already open in another rpi process"
3096 ));
3097 }
3098 SESSION_LOCKS
3099 .get_or_init(|| Mutex::new(Vec::new()))
3100 .lock()
3101 .unwrap()
3102 .push(file);
3103 SESSION_LOCK_PATHS
3104 .get_or_init(|| Mutex::new(HashSet::new()))
3105 .lock()
3106 .unwrap()
3107 .insert(lock_path);
3108 Ok(())
3109}
3110
3111pub(crate) struct RestoredSession {
3120 pub session: Session,
3121 pub recorded_cwd: Option<PathBuf>,
3124}
3125
3126async fn open_session(
3133 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
3134 cwd: &str,
3135 sessions_root: &Path,
3136) -> Result<RestoredSession, BuildError> {
3137 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3138 use rpi_harness::session::types::SessionStorage;
3139 use rpi_tools::FileSystem;
3140
3141 let dir = sessions_root;
3142 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3143 let fs: Arc<dyn FileSystem> = env.clone();
3144 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3145 fs: fs.clone(),
3146 sessions_root: dir.to_string_lossy().into_owned(),
3147 clock: Arc::new(SystemClock),
3148 ids: Arc::new(DefaultIdGenerator::new()),
3149 });
3150 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
3151 let storage = repo
3152 .open_by_jsonl_metadata(meta)
3153 .await
3154 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
3155 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
3156 Ok(RestoredSession {
3157 session: Session::new(storage_arc, None),
3158 recorded_cwd: crate::session_cwd::resolve_session_cwd(Path::new(&meta.cwd)),
3159 })
3160}
3161
3162pub(crate) fn ephemeral_session() -> Session {
3166 let storage = Arc::new(InMemorySessionStorage::new(
3167 SessionMetadata {
3168 id: "ephemeral".into(),
3169 created_at: 0,
3170 parent_session_id: None,
3171 },
3172 Arc::new(SystemClock),
3173 Arc::new(DefaultIdGenerator::new()),
3174 ));
3175 Session::new(storage, None).with_volatile_progress()
3176}
3177
3178pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
3189 create_jsonl_session_with_id(dir, cwd, None).await
3190}
3191
3192pub(crate) async fn create_jsonl_session_with_id(
3196 dir: &Path,
3197 cwd: &str,
3198 id: Option<String>,
3199) -> Result<Session, String> {
3200 use rpi_harness::session::jsonl::{
3201 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
3202 };
3203 use rpi_tools::FileSystem;
3204
3205 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3208 let fs: Arc<dyn FileSystem> = env.clone();
3209
3210 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3211 fs: fs.clone(),
3212 sessions_root: dir.to_string_lossy().into_owned(),
3213 clock: Arc::new(SystemClock),
3214 ids: Arc::new(DefaultIdGenerator::new()),
3215 });
3216
3217 let opts = JsonlSessionCreateOptions {
3218 id, parent_session_id: None,
3220 cwd: cwd.to_string(),
3221 metadata: None,
3222 };
3223 let storage = repo
3224 .create_typed(&opts)
3225 .await
3226 .map_err(|e| format!("create session: {e}"))?;
3227 claim_session_lock(&storage.jsonl_metadata().path)
3228 .map_err(|e| format!("create session: {e}"))?;
3229 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
3231 Ok(Session::new(storage_arc, None))
3232}
3233
3234fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
3237 let path = Path::new(target);
3238 if path.is_file() {
3239 read_prompt_source(path, sources)
3240 } else {
3241 None
3242 }
3243}
3244
3245fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
3248 let text = std::fs::read_to_string(path).ok()?;
3249 let absolute = if path.is_absolute() {
3250 path.to_path_buf()
3251 } else {
3252 std::env::current_dir()
3253 .map(|cwd| cwd.join(path))
3254 .unwrap_or_else(|_| path.to_path_buf())
3255 };
3256 let source = absolute.to_string_lossy().into_owned();
3257 if !sources.contains(&source) {
3258 sources.push(source);
3259 }
3260 Some(text)
3261}
3262
3263#[cfg(test)]
3264mod runtime_session_tests;
3265
3266#[cfg(test)]
3267mod tests {
3268 use super::*;
3269
3270 #[tokio::test]
3271 async fn unauthenticated_packages_are_not_reintroduced_as_legacy_adapters() {
3272 extern "C" fn request(
3273 _: rpi_plugin_sdk::StbStringRef,
3274 _: *mut rpi_plugin_sdk::StbString,
3275 _: *mut std::ffi::c_void,
3276 ) -> i32 {
3277 1
3278 }
3279 let mut registry = rpi_extensions::ExtensionRegistry::new();
3280 for id in ["package", "legacy"] {
3281 registry.register_provider(rpi_extensions::RegisteredProvider {
3282 provider_id: id.into(),
3283 base_url: "http://localhost".into(),
3284 api_style: "openai-completions".into(),
3285 request_fn: request,
3286 stream_fn: None,
3287 plugin_free_string: rpi_plugin_sdk::callbacks::free_string,
3288 user_data: std::ptr::null_mut(),
3289 });
3290 }
3291 assert!(registry.apply_declaration_json(&serde_json::json!({"providers":[{
3292 "id":"package", "requiresAuth":true,
3293 "models":[rpi_ai::Model::new("model", "Model", rpi_ai::Api::OpenaiCompletions, "package", "http://localhost")]
3294 }]}).to_string()));
3295 let session = ExtensionSession::from_parts(
3296 Arc::new(registry.snapshot()),
3297 rpi_extensions::PluginKeepalive::empty(),
3298 vec![],
3299 None,
3300 );
3301 let providers = build_models_with_extensions_for_reload(
3302 &[],
3303 &session,
3304 tokio::runtime::Handle::current(),
3305 );
3306 assert_eq!(
3307 providers
3308 .iter()
3309 .map(|provider| provider.id())
3310 .collect::<Vec<_>>(),
3311 ["legacy"]
3312 );
3313 }
3314 use crate::args::Args;
3315
3316 #[test]
3317 fn loaded_context_paths_track_precedence_and_reload() {
3318 const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
3319 if std::env::var_os(CHILD).is_none() {
3320 let dir = tempfile::tempdir().unwrap();
3321 let output = std::process::Command::new(std::env::current_exe().unwrap())
3322 .args([
3323 "--exact",
3324 "session::tests::loaded_context_paths_track_precedence_and_reload",
3325 "--nocapture",
3326 ])
3327 .env(CHILD, "1")
3328 .env(crate::config::CONFIG_DIR_ENV, dir.path())
3329 .output()
3330 .unwrap();
3331 assert!(
3332 output.status.success(),
3333 "{}\n{}",
3334 String::from_utf8_lossy(&output.stdout),
3335 String::from_utf8_lossy(&output.stderr)
3336 );
3337 return;
3338 }
3339 tokio::runtime::Builder::new_current_thread()
3340 .enable_all()
3341 .build()
3342 .unwrap()
3343 .block_on(async {
3344 let agent = crate::config::agent_dir().unwrap();
3345 let cwd = agent.join("project");
3346 let project_config = cwd.join(".rpi");
3347 std::fs::create_dir_all(&project_config).unwrap();
3348 for (path, content) in [
3349 (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
3350 (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
3351 (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
3352 (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
3353 (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
3354 (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
3355 (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
3356 ] {
3357 std::fs::write(path, content).unwrap();
3358 }
3359 let resolved = crate::provider::resolve(
3360 Some("anthropic"),
3361 Some(crate::provider::DEFAULT_MODEL_ID),
3362 None,
3363 Some("test-key"),
3364 None,
3365 )
3366 .unwrap();
3367 let args = Args {
3368 no_session: true,
3369 no_extensions: true,
3370 no_skills: true,
3371 no_prompt_templates: true,
3372 trust_override: Some(true),
3373 ..Default::default()
3374 };
3375 let (harness, _events, mut context) =
3376 build(&resolved, &args, &cwd, true).await.unwrap();
3377 let resources = harness.get_resources().await.unwrap();
3378 let path = |p: PathBuf| p.to_string_lossy().into_owned();
3379 assert_eq!(
3380 &resources.context_files[..2],
3381 &[
3382 path(project_config.join("SYSTEM.md")),
3383 path(project_config.join("APPEND_SYSTEM.md")),
3384 ]
3385 );
3386 assert!(resources
3387 .context_files
3388 .contains(&path(agent.join("AGENTS.md"))));
3389 assert!(resources
3390 .context_files
3391 .contains(&path(cwd.join("AGENTS.md"))));
3392 assert!(!resources
3393 .context_files
3394 .contains(&path(cwd.join("CLAUDE.md"))));
3395 assert!(!resources
3396 .context_files
3397 .contains(&path(agent.join("SYSTEM.md"))));
3398 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3399 assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
3400 assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
3401
3402 std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
3405 std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
3406 std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
3407 assert!(
3408 !reload_extension_resources(&harness, &context)
3409 .await
3410 .had_errors
3411 );
3412 let resources = harness.get_resources().await.unwrap();
3413 assert_eq!(
3414 &resources.context_files[..2],
3415 &[
3416 path(agent.join("SYSTEM.md")),
3417 path(agent.join("APPEND_SYSTEM.md")),
3418 ]
3419 );
3420 assert!(resources
3421 .context_files
3422 .contains(&path(cwd.join("CLAUDE.md"))));
3423 assert!(!resources
3424 .context_files
3425 .contains(&path(project_config.join("SYSTEM.md"))));
3426
3427 let explicit = agent.join("custom-append.md");
3430 std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
3431 context.args.system_prompt = Some("EXPLICIT_BASE".into());
3432 context.args.append_system_prompt =
3433 vec![path(explicit.clone()), "literal append".into()];
3434 context.args.no_context_files = true;
3435 assert!(
3436 !reload_extension_resources(&harness, &context)
3437 .await
3438 .had_errors
3439 );
3440 assert_eq!(
3441 harness.get_resources().await.unwrap().context_files,
3442 vec![path(explicit)]
3443 );
3444
3445 context.args.system_prompt = None;
3447 context.args.append_system_prompt.clear();
3448 context.args.no_context_files = false;
3449 context.args.trust_override = Some(false);
3450 assert!(
3451 !reload_extension_resources(&harness, &context)
3452 .await
3453 .had_errors
3454 );
3455 assert!(harness
3456 .get_resources()
3457 .await
3458 .unwrap()
3459 .context_files
3460 .is_empty());
3461 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3462 assert!(
3463 !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
3464 );
3465 });
3466 }
3467
3468 #[test]
3469 fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
3470 let dir = tempfile::tempdir().unwrap();
3471 let file = dir.path().join("SYSTEM.md");
3472 let mut sources = Vec::new();
3473 assert!(read_prompt_source(&file, &mut sources).is_none());
3474 std::fs::write(&file, [0xff, 0xfe]).unwrap();
3475 assert!(read_prompt_source(&file, &mut sources).is_none());
3476 assert!(read_append_target("literal instructions", &mut sources).is_none());
3477 assert!(sources.is_empty());
3478 std::fs::write(&file, "loaded instructions").unwrap();
3479 assert_eq!(
3480 read_prompt_source(&file, &mut sources).as_deref(),
3481 Some("loaded instructions")
3482 );
3483 assert!(read_prompt_source(&file, &mut sources).is_some());
3484 assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
3485 }
3486
3487 extern "C" fn deny_test_tool_call(
3488 _event: rpi_plugin_sdk::StablePluginEvent,
3489 _data: *mut std::ffi::c_void,
3490 ) -> i32 {
3491 rpi_plugin_sdk::EVENT_HANDLER_ABORT
3492 }
3493
3494 async fn invoke_test_tool_hook(
3495 hook: &rpi_agent::BeforeToolCall,
3496 ) -> Option<rpi_agent::BeforeToolCallResult> {
3497 let message = rpi_ai::providers::faux::faux_assistant_message(
3498 "approval",
3499 rpi_ai::types::StopReason::ToolUse,
3500 );
3501 let rpi_ai::types::Content::ToolCall(call) =
3502 rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
3503 else {
3504 unreachable!()
3505 };
3506 hook(
3507 rpi_agent::BeforeToolCallContext {
3508 assistant_message: &message,
3509 tool_call: &call,
3510 args: &call.arguments,
3511 context: &rpi_agent::AgentContext::default(),
3512 },
3513 tokio_util::sync::CancellationToken::new(),
3514 )
3515 .await
3516 }
3517
3518 #[tokio::test]
3519 async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
3520 let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
3521 let hook =
3522 extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
3523 assert!(invoke_test_tool_hook(&hook).await.is_none());
3524 for name in ["first-gate", "reloaded-gate"] {
3525 let mut registry = rpi_extensions::ExtensionRegistry::new();
3526 registry.register_event_handler(
3527 name.into(),
3528 rpi_plugin_sdk::EventTag::ToolCall,
3529 deny_test_tool_call,
3530 std::ptr::null_mut(),
3531 );
3532 let fresh = ExtensionSession::from_parts(
3533 Arc::new(registry.snapshot()),
3534 rpi_extensions::PluginKeepalive::empty(),
3535 Vec::new(),
3536 None,
3537 );
3538 {
3539 let mut current = sessions.lock().unwrap();
3540 if let Some(snapshot) = current.snapshot() {
3541 snapshot.active_flag().store(false, Ordering::SeqCst);
3542 }
3543 *current = fresh;
3544 }
3545 let result = invoke_test_tool_hook(&hook).await.unwrap();
3546 assert!(result.block);
3547 assert!(result.reason.unwrap().contains(name));
3548 }
3549 *sessions.lock().unwrap() = ExtensionSession::none();
3550 assert!(invoke_test_tool_hook(&hook).await.is_none());
3551 }
3552
3553 fn scratch(name: &str) -> PathBuf {
3555 let nanos = std::time::SystemTime::now()
3556 .duration_since(std::time::UNIX_EPOCH)
3557 .unwrap()
3558 .as_nanos();
3559 let path = std::env::temp_dir().join(format!(
3560 "rpi-summary-{}-{nanos}-{name}.jsonl",
3561 std::process::id()
3562 ));
3563 path
3564 }
3565
3566 #[test]
3567 fn summary_marks_a_header_only_session_empty() {
3568 let path = scratch("empty");
3569 std::fs::write(
3570 &path,
3571 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3572 )
3573 .unwrap();
3574 let summary = summarize_session_file(&path);
3575 assert!(summary.empty, "a header-only file has nothing to resume");
3576 assert!(summary.preview.is_none());
3577 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
3578 std::fs::remove_file(&path).ok();
3579 }
3580
3581 #[test]
3582 fn summary_reads_the_first_user_prompt_and_name() {
3583 let path = scratch("content");
3584 let body = concat!(
3585 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3586 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
3587 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
3588 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
3589 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
3590 );
3591 std::fs::write(&path, body).unwrap();
3592 let summary = summarize_session_file(&path);
3593 assert!(!summary.empty);
3594 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
3595 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
3596 std::fs::remove_file(&path).ok();
3597 }
3598
3599 #[test]
3600 fn summary_extracts_a_block_style_user_prompt() {
3601 let path = scratch("blocks");
3602 let body = concat!(
3603 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3604 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
3605 );
3606 std::fs::write(&path, body).unwrap();
3607 let summary = summarize_session_file(&path);
3608 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
3609 assert_eq!(session_display_label(&summary), "line one line two");
3610 std::fs::remove_file(&path).ok();
3611 }
3612
3613 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
3614 rpi_harness::session::jsonl::JsonlSessionMetadata {
3615 id: id.to_string(),
3616 created_at: 0,
3617 cwd: ".".to_string(),
3618 path: path.to_string_lossy().into_owned(),
3619 modified_at: 0,
3620 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
3621 parent_session_id: None,
3622 legacy_parent_session_path: None,
3623 metadata: None,
3624 }
3625 }
3626
3627 #[test]
3628 fn continue_skips_newer_empty_sessions() {
3629 let empty = scratch("newest-empty");
3630 std::fs::write(
3631 &empty,
3632 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3633 )
3634 .unwrap();
3635 let content = scratch("older-content");
3636 std::fs::write(
3637 &content,
3638 concat!(
3639 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3640 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
3641 ),
3642 )
3643 .unwrap();
3644
3645 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
3646 let picked = first_resumable_session(&metas).expect("a resumable session");
3647 assert_eq!(picked.id, "old");
3648
3649 let all_empty = vec![meta_for(&empty, "empty")];
3652 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
3653
3654 std::fs::remove_file(&empty).ok();
3655 std::fs::remove_file(&content).ok();
3656 }
3657
3658 #[test]
3659 fn list_session_files_sync_walks_the_cwd_subdirectory() {
3660 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
3661 let nested = root.join("--proj--");
3662 std::fs::create_dir_all(&nested).unwrap();
3663 let file = nested.join("2026-01-01_a.jsonl");
3664 std::fs::write(&file, "{}\n").unwrap();
3665 let found = list_session_files_sync(&root);
3666 assert!(found.iter().any(|p| p == &file));
3667 std::fs::remove_dir_all(&root).ok();
3668 }
3669
3670 fn default_prompt_tools() -> Vec<String> {
3672 BUILTIN_TOOL_NAMES
3673 .iter()
3674 .copied()
3675 .chain(["todo"])
3676 .map(str::to_owned)
3677 .collect()
3678 }
3679
3680 #[test]
3681 fn default_prompt_lists_only_the_active_tools() {
3682 let tools = ["read", "grep", "find", "ls"].map(str::to_owned);
3683 let p = default_system_prompt_with_tools("/tmp/proj", true, &tools);
3684 assert!(p.contains("- read — Read file contents"));
3685 assert!(p.contains("- grep — Search file contents"));
3686 for absent in ["- edit", "- write", "- bash", "- docs", "todo", "Windows"] {
3687 assert!(
3688 !p.contains(absent),
3689 "{absent} must not appear for a read-only run: {p}"
3690 );
3691 }
3692 assert!(p.contains("Re-stating the same plan without acting on it is a bug"));
3693 let none = default_system_prompt_with_tools("/tmp/proj", false, &[]);
3694 assert!(none.contains("Available tools:\n(none)"));
3695 }
3696
3697 #[test]
3698 fn default_prompt_mentions_cwd_and_tools() {
3699 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
3700 assert!(p.contains("/tmp/proj"));
3701 assert!(p.contains("read"));
3702 assert!(p.contains("bash"));
3703 assert!(p.contains("edit"));
3704 assert!(p.contains("write"));
3705 assert!(p.contains("docs"));
3706 assert!(!p.contains("- grep"));
3707 assert!(!p.contains("- find"));
3708 assert!(!p.contains("- ls"));
3709 assert!(!p.contains("- powershell"));
3710 assert!(p.contains("Bash/POSIX syntax, also on Windows"));
3711 }
3712
3713 #[test]
3714 fn default_prompt_instructs_language_matching() {
3715 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
3718 assert!(
3719 p.contains("same language as the user's latest message"),
3720 "the prompt must tell the model to mirror the user's language: {p}"
3721 );
3722 }
3723
3724 #[test]
3725 fn default_prompt_keeps_working_state_in_the_visible_channel() {
3726 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
3734 let lower = p.to_lowercase();
3735 assert!(
3737 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
3738 "the prompt must explain that reasoning does not survive the turn: {p}"
3739 );
3740 assert!(
3742 lower.contains("visible") && !lower.contains("todo tool"),
3743 "the prompt must keep state visible without inventing extension rules: {p}"
3744 );
3745 assert!(
3747 lower.contains("re-stating the same plan"),
3748 "the prompt must call out plan re-statement as a bug: {p}"
3749 );
3750 assert!(
3752 lower.contains("already shown earlier"),
3753 "the prompt must discourage re-fetching known content: {p}"
3754 );
3755 }
3756
3757 #[test]
3758 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
3759 let p = default_system_prompt_with_tools("/tmp/proj", true, &default_prompt_tools());
3766 let lower = p.to_lowercase();
3767 assert!(
3768 lower.contains("carried back"),
3769 "a replaying model must be told its reasoning survives: {p}"
3770 );
3771 assert!(
3772 !lower.contains("reasoning is not"),
3773 "the replayed variant must not claim reasoning is dropped: {p}"
3774 );
3775 assert!(
3776 lower.contains("do not re-derive"),
3777 "the replayed variant must forbid re-deriving the plan: {p}"
3778 );
3779 assert!(
3780 lower.contains("do not re-derive") && lower.contains("re-stating the same plan"),
3781 "the anti-repetition rule must survive the flip: {p}"
3782 );
3783 assert!(
3784 lower.contains("already shown earlier"),
3785 "the re-read rule must survive the flip: {p}"
3786 );
3787 }
3788
3789 #[test]
3790 fn default_prompt_states_each_rule_once() {
3791 for replayed in [false, true] {
3794 let p =
3795 default_system_prompt_with_tools("/tmp/proj", replayed, &default_prompt_tools());
3796 for phrase in ["the same plan", "Windows", "guessing"] {
3797 assert_eq!(p.matches(phrase).count(), 1, "{phrase:?} repeated: {p}");
3798 }
3799 assert!(
3800 !p.contains("todo tool"),
3801 "extension rules require explicit metadata"
3802 );
3803 }
3804 }
3805
3806 #[test]
3814 fn only_read_only_tools_are_replayable() {
3815 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3816 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3817 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3818 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3819 let tools = build_tools(&context, &Args::default());
3820
3821 let replay_of = |name: &str| {
3822 tools
3823 .iter()
3824 .find(|tool| tool.tool.schema().name == name)
3825 .map(|tool| tool.replay)
3826 };
3827
3828 for read_only in ["read", "docs"] {
3829 assert_eq!(
3830 replay_of(read_only),
3831 Some(ToolReplay::Safe),
3832 "{read_only} is side-effect free, so recovery may re-run it"
3833 );
3834 }
3835 for mutating in ["bash", "edit", "write", "powershell"] {
3836 if let Some(replay) = replay_of(mutating) {
3837 assert_eq!(
3838 replay,
3839 ToolReplay::Never,
3840 "{mutating} must never be replayed: re-running it would duplicate its effect"
3841 );
3842 }
3843 }
3844 }
3845
3846 #[test]
3847 fn default_tools_include_docs_lookup() {
3848 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3849 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3850 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3851 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3852 let names: Vec<String> = build_tools(&context, &Args::default())
3853 .iter()
3854 .map(|tool| tool.tool.schema().name.clone())
3855 .collect();
3856
3857 assert!(names.contains(&"docs".to_string()));
3862 for legacy in ["grep", "find", "ls"] {
3863 assert!(
3864 !names.contains(&legacy.to_string()),
3865 "legacy tool {legacy} should not be registered by default"
3866 );
3867 }
3868 }
3869
3870 #[test]
3871 fn reload_settings_preflight_respects_trust() {
3872 struct RestoreConfigDir(Option<std::ffi::OsString>);
3873
3874 impl Drop for RestoreConfigDir {
3875 fn drop(&mut self) {
3876 match self.0.take() {
3877 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3878 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3879 }
3880 }
3881 }
3882
3883 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3884 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3885 let tmp = tempfile::tempdir().unwrap();
3886 let agent = tmp.path().join("agent");
3887 let cwd = tmp.path().join("project");
3888 std::fs::create_dir_all(&agent).unwrap();
3889 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3890 std::fs::write(agent.join("settings.json"), "{}").unwrap();
3891 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
3892 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3893 let args = Args::default();
3894
3895 let trusted = validate_settings_for_reload(&args, &cwd, true);
3896 let untrusted = validate_settings_for_reload(&args, &cwd, false);
3897 assert!(trusted
3898 .unwrap_err()
3899 .contains("could not load project settings"));
3900 assert!(untrusted.is_ok());
3901
3902 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3903 assert!(validate_settings_for_reload(&args, &cwd, false)
3904 .unwrap_err()
3905 .contains("could not load global settings"));
3906
3907 let local_only = Args {
3908 dev_local_only: true,
3909 ..Args::default()
3910 };
3911 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
3912 }
3913
3914 #[tokio::test(flavor = "current_thread")]
3915 async fn reload_preserves_live_resources_when_settings_break() {
3916 struct RestoreConfigDir(Option<std::ffi::OsString>);
3917
3918 impl Drop for RestoreConfigDir {
3919 fn drop(&mut self) {
3920 match self.0.take() {
3921 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3922 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3923 }
3924 }
3925 }
3926
3927 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3928 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3929 let tmp = tempfile::tempdir().unwrap();
3930 let agent = tmp.path().join("agent");
3931 let cwd = tmp.path().join("project");
3932 let skill_dir = tmp.path().join("configured-skills");
3933 std::fs::create_dir_all(&agent).unwrap();
3934 std::fs::create_dir_all(&cwd).unwrap();
3935 std::fs::create_dir_all(&skill_dir).unwrap();
3936 std::fs::write(
3937 skill_dir.join("SKILL.md"),
3938 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
3939 )
3940 .unwrap();
3941 std::fs::write(
3942 agent.join("settings.json"),
3943 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3944 )
3945 .unwrap();
3946 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3947
3948 let resolved = crate::provider::resolve(
3949 Some("anthropic"),
3950 Some(crate::provider::DEFAULT_MODEL_ID),
3951 None,
3952 Some("test-key"),
3953 None,
3954 )
3955 .unwrap();
3956 let args = Args {
3957 trust_override: Some(false),
3958 no_session: true,
3959 no_extensions: true,
3960 no_prompt_templates: true,
3961 no_context_files: true,
3962 system_prompt: Some("stable system prompt".into()),
3963 ..Args::default()
3964 };
3965 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3966 let before_resources = harness.get_resources().await.unwrap();
3967 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
3968 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
3969 let before_prompt = harness.get_system_prompt().await.unwrap();
3970 let before_tools: Vec<String> = harness
3971 .get_tools()
3972 .await
3973 .unwrap()
3974 .iter()
3975 .map(|tool| tool.tool.schema().name.clone())
3976 .collect();
3977 let before_bridge = context
3978 .action_bridge
3979 .lock()
3980 .unwrap()
3981 .as_ref()
3982 .unwrap()
3983 .clone();
3984
3985 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3986 let outcome = reload_extension_resources(&harness, &context).await;
3987
3988 assert!(outcome.had_warnings);
3989 assert!(outcome.summary.contains("Settings reload failed"));
3990 assert!(outcome.summary.contains("could not load global settings"));
3991 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
3992 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
3993 let after_tools: Vec<String> = harness
3994 .get_tools()
3995 .await
3996 .unwrap()
3997 .iter()
3998 .map(|tool| tool.tool.schema().name.clone())
3999 .collect();
4000 assert_eq!(after_tools, before_tools);
4001 let after_bridge = context
4002 .action_bridge
4003 .lock()
4004 .unwrap()
4005 .as_ref()
4006 .unwrap()
4007 .clone();
4008 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
4009 }
4010
4011 #[test]
4012 fn reload_preparation_rejects_settings_changed_during_derivation() {
4013 struct RestoreConfigDir(Option<std::ffi::OsString>);
4014
4015 impl Drop for RestoreConfigDir {
4016 fn drop(&mut self) {
4017 match self.0.take() {
4018 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4019 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4020 }
4021 }
4022 }
4023
4024 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4025 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4026 let tmp = tempfile::tempdir().unwrap();
4027 let agent = tmp.path().join("agent");
4028 let cwd = tmp.path().join("project");
4029 let first_skill_dir = tmp.path().join("first-skills");
4030 let second_skill_dir = tmp.path().join("second-skills");
4031 std::fs::create_dir_all(&agent).unwrap();
4032 std::fs::create_dir_all(&cwd).unwrap();
4033 std::fs::write(
4034 agent.join("settings.json"),
4035 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
4036 )
4037 .unwrap();
4038 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4039 let args = Args {
4040 trust_override: Some(false),
4041 no_extensions: true,
4042 no_prompt_templates: true,
4043 ..Args::default()
4044 };
4045
4046 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
4047 std::fs::write(
4048 agent.join("settings.json"),
4049 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
4050 )
4051 .unwrap();
4052 });
4053
4054 let error = result
4055 .err()
4056 .expect("settings mutation must fail preparation");
4057 assert!(error.contains("settings changed while reload inputs were being prepared"));
4058 }
4059
4060 #[tokio::test(flavor = "current_thread")]
4061 async fn reload_uses_frozen_settings_inputs_after_preparation() {
4062 struct RestoreConfigDir(Option<std::ffi::OsString>);
4063
4064 impl Drop for RestoreConfigDir {
4065 fn drop(&mut self) {
4066 match self.0.take() {
4067 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4068 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4069 }
4070 }
4071 }
4072
4073 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4074 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4075 let tmp = tempfile::tempdir().unwrap();
4076 let agent = tmp.path().join("agent");
4077 let cwd = tmp.path().join("project");
4078 let skill_dir = tmp.path().join("configured-skills");
4079 std::fs::create_dir_all(&agent).unwrap();
4080 std::fs::create_dir_all(&cwd).unwrap();
4081 std::fs::create_dir_all(&skill_dir).unwrap();
4082 std::fs::write(
4083 skill_dir.join("SKILL.md"),
4084 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
4085 )
4086 .unwrap();
4087 std::fs::write(
4088 agent.join("settings.json"),
4089 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
4090 )
4091 .unwrap();
4092 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4093
4094 let resolved = crate::provider::resolve(
4095 Some("anthropic"),
4096 Some(crate::provider::DEFAULT_MODEL_ID),
4097 None,
4098 Some("test-key"),
4099 None,
4100 )
4101 .unwrap();
4102 let args = Args {
4103 trust_override: Some(false),
4104 no_session: true,
4105 no_extensions: true,
4106 no_prompt_templates: true,
4107 no_context_files: true,
4108 system_prompt: Some("stable system prompt".into()),
4109 ..Args::default()
4110 };
4111 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
4112
4113 let outcome = reload_extension_resources_inner(&harness, &context, || {
4114 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
4115 })
4116 .await;
4117
4118 assert!(!outcome.summary.contains("Settings reload failed"));
4119 assert_eq!(
4120 outcome.summary,
4121 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
4122 );
4123 let resources = harness.get_resources().await.unwrap();
4124 let skills = resources.skills.as_ref().unwrap();
4125 assert_eq!(skills.len(), 1);
4126 assert_eq!(skills[0].name, "frozen-skill");
4127 }
4128
4129 #[tokio::test]
4130 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
4131 let tmp = tempfile::tempdir().unwrap();
4132 let skill_path = tmp.path().join("explicit-skill.md");
4133 std::fs::write(
4134 &skill_path,
4135 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
4136 )
4137 .unwrap();
4138 let prompt_path = tmp.path().join("explicit-prompt.md");
4139 std::fs::write(
4140 &prompt_path,
4141 "---\ndescription: Explicit prompt\n---\nPrompt body",
4142 )
4143 .unwrap();
4144
4145 let args = Args {
4146 skill: vec![skill_path.clone()],
4147 prompt_template: vec![prompt_path.clone()],
4148 ..Args::default()
4149 };
4150 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
4151 let prompt_paths =
4152 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
4153 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
4154 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
4155
4156 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
4157 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
4158 assert_eq!(skills.skills[0].name, "explicit-skill");
4159
4160 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
4161 assert_eq!(
4162 prompts.prompt_templates.len(),
4163 1,
4164 "{:?}",
4165 prompts.diagnostics
4166 );
4167 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
4168 }
4169
4170 #[test]
4171 fn tool_policy_applies_to_rust_and_js_active_names() {
4172 let names = vec![
4173 "read".to_string(),
4174 "ask_user_question".to_string(),
4175 "write".to_string(),
4176 ];
4177
4178 let args = Args {
4179 tools: Some(vec!["read".into(), "ask_user_question".into()]),
4180 ..Args::default()
4181 };
4182 assert_eq!(
4183 filter_active_tool_names(names.clone(), &args),
4184 vec!["read", "ask_user_question"]
4185 );
4186
4187 let args = Args {
4188 exclude_tools: Some(vec!["ask_user_question".into()]),
4189 ..Args::default()
4190 };
4191 assert_eq!(
4192 filter_active_tool_names(names.clone(), &args),
4193 vec!["read", "write"]
4194 );
4195
4196 let args = Args {
4197 no_tools: true,
4198 ..Args::default()
4199 };
4200 assert!(filter_active_tool_names(names, &args).is_empty());
4201 }
4202
4203 #[test]
4204 fn select_ephemeral_when_no_session() {
4205 let args = Args {
4206 no_session: true,
4207 ..Args::default()
4208 };
4209 let cwd = Path::new("/tmp");
4210 assert!(matches!(
4211 select_session(&args, cwd),
4212 SessionSelection::Ephemeral
4213 ));
4214 }
4215
4216 #[test]
4217 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
4218 let defaults = Args::default();
4219 assert!(resolve_project_trust(
4220 &defaults,
4221 Path::new("C:/definitely-not-a-project")
4222 ));
4223
4224 let denied = Args {
4225 trust_override: Some(false),
4226 ..Args::default()
4227 };
4228 assert!(!resolve_project_trust(
4229 &denied,
4230 Path::new("C:/definitely-not-a-project")
4231 ));
4232 }
4233
4234 #[tokio::test(flavor = "current_thread")]
4235 async fn build_preserves_the_supplied_startup_project_snapshot() {
4236 struct RestoreConfigDir(Option<std::ffi::OsString>);
4237
4238 impl Drop for RestoreConfigDir {
4239 fn drop(&mut self) {
4240 match self.0.take() {
4241 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4242 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4243 }
4244 }
4245 }
4246
4247 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4248 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4249 let tmp = tempfile::tempdir().unwrap();
4250 let agent = tmp.path().join("agent");
4251 let cwd = tmp.path().join("project");
4252 std::fs::create_dir_all(&agent).unwrap();
4253 std::fs::create_dir_all(&cwd).unwrap();
4254 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4255
4256 let resolved = crate::provider::resolve(
4257 Some("anthropic"),
4258 Some(crate::provider::DEFAULT_MODEL_ID),
4259 None,
4260 Some("test-key"),
4261 None,
4262 )
4263 .unwrap();
4264 let args = Args {
4265 trust_override: Some(false),
4266 no_session: true,
4267 no_tools: true,
4268 no_extensions: true,
4269 no_skills: true,
4270 no_prompt_templates: true,
4271 no_context_files: true,
4272 system_prompt: Some("test prompt".into()),
4273 ..Args::default()
4274 };
4275
4276 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
4277
4278 assert_eq!(context.cwd, cwd);
4279 assert!(context.project_trusted);
4280 }
4281
4282 #[tokio::test(flavor = "current_thread")]
4283 async fn build_applies_the_name_flag_to_the_session() {
4284 struct RestoreConfigDir(Option<std::ffi::OsString>);
4285
4286 impl Drop for RestoreConfigDir {
4287 fn drop(&mut self) {
4288 match self.0.take() {
4289 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4290 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4291 }
4292 }
4293 }
4294
4295 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4296 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4297 let tmp = tempfile::tempdir().unwrap();
4298 let agent = tmp.path().join("agent");
4299 let cwd = tmp.path().join("project");
4300 std::fs::create_dir_all(&agent).unwrap();
4301 std::fs::create_dir_all(&cwd).unwrap();
4302 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4303
4304 let resolved = crate::provider::resolve(
4305 Some("anthropic"),
4306 Some(crate::provider::DEFAULT_MODEL_ID),
4307 None,
4308 Some("test-key"),
4309 None,
4310 )
4311 .unwrap();
4312 let args = Args {
4313 trust_override: Some(false),
4314 no_tools: true,
4315 no_extensions: true,
4316 no_skills: true,
4317 no_prompt_templates: true,
4318 no_context_files: true,
4319 system_prompt: Some("test prompt".into()),
4320 name: Some("smoke name".into()),
4321 session_dir: Some(cwd.join("sessions")),
4322 ..Args::default()
4323 };
4324
4325 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
4326 assert_eq!(
4327 harness.get_name().await.unwrap().as_deref(),
4328 Some("smoke name")
4329 );
4330 }
4331
4332 #[test]
4333 fn select_latest_for_continue_and_resume() {
4334 let _env = crate::config::test_support::SessionDirEnv::new(None);
4335 let args = Args {
4336 continue_session: true,
4337 ..Args::default()
4338 };
4339 let cwd = Path::new("/tmp");
4340 assert!(matches!(
4341 select_session(&args, cwd),
4342 SessionSelection::Latest { .. }
4343 ));
4344
4345 let args = Args {
4346 resume: true,
4347 ..Args::default()
4348 };
4349 assert!(matches!(
4350 select_session(&args, cwd),
4351 SessionSelection::Latest { .. }
4352 ));
4353 }
4354
4355 #[test]
4356 fn select_by_id_for_session_flag() {
4357 let _env = crate::config::test_support::SessionDirEnv::new(None);
4358 let args = Args {
4359 session: Some("01a02ece".into()),
4360 ..Args::default()
4361 };
4362 let cwd = Path::new("/tmp");
4363 assert!(matches!(
4364 select_session(&args, cwd),
4365 SessionSelection::ById { id, .. } if id == "01a02ece"
4366 ));
4367 }
4368
4369 #[test]
4370 fn select_new_with_custom_dir() {
4371 let _env = crate::config::test_support::SessionDirEnv::new(None);
4372 let args = Args {
4373 session_dir: Some(PathBuf::from("/tmp/sess")),
4374 ..Args::default()
4375 };
4376 let cwd = Path::new("/tmp");
4377 match select_session(&args, cwd) {
4378 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
4379 other => panic!("expected New, got {other:?}"),
4380 }
4381 }
4382
4383 #[test]
4384 fn select_new_default_dir() {
4385 let _env = crate::config::test_support::SessionDirEnv::new(None);
4386 let args = Args::default();
4387 let cwd = Path::new("/proj");
4388 match select_session(&args, cwd) {
4389 SessionSelection::New { dir, .. } => {
4390 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
4391 }
4392 other => panic!("expected New, got {other:?}"),
4393 }
4394 }
4395
4396 #[test]
4397 fn default_session_dir_only_uses_rpi() {
4398 let _env = crate::config::test_support::SessionDirEnv::new(None);
4399 let tmp = tempfile::tempdir().unwrap();
4400 let cwd = tmp.path();
4401 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
4402 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
4403 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
4404 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
4405 }
4406
4407 #[test]
4408 fn default_session_dir_uses_env_var() {
4409 let tmp = tempfile::tempdir().unwrap();
4410 let cwd = tmp.path();
4411 let custom = tmp.path().join("custom_sessions");
4412 let _env = crate::config::test_support::SessionDirEnv::new(Some(&custom));
4413 assert_eq!(default_session_dir(cwd), custom);
4414 }
4415
4416 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4417 async fn ephemeral_session_builds_roundtrips() {
4418 let s = ephemeral_session();
4422 let leaf = s.get_leaf_id().await;
4423 assert!(leaf.is_ok());
4424 }
4425
4426 #[test]
4444 fn host_private_env_matches_the_declared_contract() {
4445 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
4447 assert_eq!(
4448 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
4449 [
4450 "LANGFUSE_PI_PARENT_TRACE_ID",
4451 "LANGFUSE_PI_PARENT_SPAN_ID",
4452 "LANGFUSE_PI_PARENT_SESSION_ID",
4453 "LANGFUSE_PI_PARENT_DEPTH",
4454 ],
4455 "these strings are what rpi-langfuse (a different repo) matches; a \
4456 change here is a breaking cross-repo change, not a refactor"
4457 );
4458
4459 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
4462 .iter()
4463 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
4464 {
4465 assert!(
4466 rpi_tools::is_host_private_env(name),
4467 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
4468 rpi_tools::HOST_PRIVATE_ENV
4469 );
4470 }
4471
4472 for visible in [
4475 crate::args::RPI_OFFLINE_ENV,
4476 crate::config::CONFIG_DIR_ENV,
4477 "PATH",
4478 ] {
4479 assert!(
4480 !rpi_tools::is_host_private_env(visible),
4481 "{visible} describes the user's configuration and must still reach the shell"
4482 );
4483 }
4484 }
4485 #[tokio::test]
4486 async fn reload_shutdown_joins_all_handlers_even_after_veto() {
4487 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
4488 use std::sync::atomic::AtomicUsize;
4489 static CONTEXTS: rpi_plugin_sdk::handles::ToolHandles<Arc<AtomicUsize>> =
4490 rpi_plugin_sdk::handles::ToolHandles::new();
4491 struct ContextOwner(usize);
4492 impl Drop for ContextOwner {
4493 fn drop(&mut self) {
4494 drop(CONTEXTS.remove(self.0 as rpi_plugin_sdk::StepHandle));
4495 }
4496 }
4497 extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
4498 let context = CONTEXTS.get(data).expect("live test context");
4499 let calls = context.lock().clone();
4500 std::thread::sleep(std::time::Duration::from_millis(20));
4501 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
4502 rpi_plugin_sdk::EVENT_HANDLER_ABORT
4503 } else {
4504 0
4505 }
4506 }
4507 let calls = Arc::new(AtomicUsize::new(0));
4508 let context = CONTEXTS.insert(calls.clone());
4509 let _owner = ContextOwner(context as usize);
4510 let mut registry = rpi_extensions::ExtensionRegistry::new();
4511 for name in ["first", "second"] {
4512 registry.register_event_handler(name.into(), EventTag::SessionShutdown, stop, context);
4513 }
4514 super::shutdown_extensions_for_reload(
4515 Some(Arc::new(registry.snapshot())),
4516 rpi_extensions::PluginKeepalive::empty(),
4517 )
4518 .await
4519 .unwrap();
4520 assert_eq!(calls.load(Ordering::SeqCst), 2);
4521 }
4522
4523 #[tokio::test]
4532 async fn a_restored_session_adopts_its_recorded_directory() {
4533 let tmp = tempfile::tempdir().unwrap();
4534 let project = tmp.path().join("project-a");
4535 let launch = tmp.path().join("launch-dir");
4536 let sessions = tmp.path().join("sessions");
4537 std::fs::create_dir_all(&project).unwrap();
4538 std::fs::create_dir_all(&launch).unwrap();
4539 std::fs::create_dir_all(&sessions).unwrap();
4540 create_jsonl_session_with_id(
4541 &sessions,
4542 &project.to_string_lossy(),
4543 Some("cwdadopt01".into()),
4544 )
4545 .await
4546 .unwrap();
4547
4548 let selection = SessionSelection::ById {
4549 id: "cwdadopt01".into(),
4550 dir: sessions.clone(),
4551 };
4552 let peeked = peek_restored_session_cwd(&selection, &launch)
4553 .await
4554 .expect("a resumable session with a recorded cwd");
4555 assert_eq!(peeked.recorded, project);
4556 assert_eq!(peeked.adopted, Some(project));
4557 }
4558
4559 #[tokio::test]
4563 async fn a_missing_recorded_directory_is_reported_not_adopted() {
4564 let tmp = tempfile::tempdir().unwrap();
4565 let gone = tmp.path().join("project-gone");
4566 let launch = tmp.path().join("launch-dir");
4567 let sessions = tmp.path().join("sessions");
4568 std::fs::create_dir_all(&launch).unwrap();
4569 std::fs::create_dir_all(&sessions).unwrap();
4570 create_jsonl_session_with_id(
4571 &sessions,
4572 &gone.to_string_lossy(),
4573 Some("cwdgone001".into()),
4574 )
4575 .await
4576 .unwrap();
4577
4578 let selection = SessionSelection::ById {
4579 id: "cwdgone001".into(),
4580 dir: sessions.clone(),
4581 };
4582 let peeked = peek_restored_session_cwd(&selection, &launch)
4583 .await
4584 .expect("the session itself is resumable");
4585 assert_eq!(peeked.recorded, gone);
4586 assert_eq!(peeked.adopted, None);
4587 let note = crate::session_cwd::missing_session_cwd_note(&peeked.recorded, &launch);
4588 assert!(note.contains("no longer exists"), "{note}");
4589 assert!(note.contains("launch-dir"), "{note}");
4590 }
4591
4592 #[tokio::test]
4595 async fn open_session_reports_the_recorded_directory() {
4596 let tmp = tempfile::tempdir().unwrap();
4597 let project = tmp.path().join("project-open");
4598 let launch = tmp.path().join("launch-dir");
4599 let sessions = tmp.path().join("sessions");
4600 std::fs::create_dir_all(&project).unwrap();
4601 std::fs::create_dir_all(&launch).unwrap();
4602 std::fs::create_dir_all(&sessions).unwrap();
4603 create_jsonl_session_with_id(
4604 &sessions,
4605 &project.to_string_lossy(),
4606 Some("cwdopen001".into()),
4607 )
4608 .await
4609 .unwrap();
4610
4611 let restored =
4612 open_session_by_id_in_dir("cwdopen001", &launch.to_string_lossy(), &sessions)
4613 .await
4614 .map_err(|error| error.to_string())
4615 .unwrap();
4616 assert_eq!(restored.recorded_cwd, Some(project.clone()));
4617 assert_eq!(restored.session.storage().metadata().id, "cwdopen001");
4618 }
4619
4620 #[tokio::test]
4629 async fn the_peeked_session_is_the_one_the_build_opens() {
4630 let tmp = tempfile::tempdir().unwrap();
4631 let first_project = tmp.path().join("project-first");
4632 let second_project = tmp.path().join("project-second");
4633 let launch = tmp.path().join("launch-dir");
4634 let sessions = tmp.path().join("sessions");
4635 for dir in [&first_project, &second_project, &launch, &sessions] {
4636 std::fs::create_dir_all(dir).unwrap();
4637 }
4638 create_jsonl_session_with_id(
4639 &sessions,
4640 &first_project.to_string_lossy(),
4641 Some("cwdrace001".into()),
4642 )
4643 .await
4644 .unwrap();
4645
4646 let mut args = Args {
4647 continue_session: true,
4648 session_dir: Some(sessions.clone()),
4649 ..Default::default()
4650 };
4651 let selection = select_session_in_dir(&args, sessions.clone());
4652 let peeked = peek_restored_session_cwd(&selection, &launch)
4653 .await
4654 .expect("a resumable session");
4655 assert_eq!(peeked.adopted, Some(first_project.clone()));
4656
4657 create_jsonl_session_with_id(
4659 &sessions,
4660 &second_project.to_string_lossy(),
4661 Some("cwdrace002".into()),
4662 )
4663 .await
4664 .unwrap();
4665
4666 pin_restored_selection(&mut args, &peeked);
4667 let pinned = select_session_in_dir(&args, sessions.clone());
4668 let reopened = resolve_restore_target(&pinned, &launch.to_string_lossy())
4669 .await
4670 .unwrap()
4671 .expect("the pinned session still exists");
4672 assert_eq!(
4673 reopened.id, peeked.id,
4674 "the build must open the session the peek adopted the directory of"
4675 );
4676 assert_eq!(reopened.cwd, first_project.to_string_lossy());
4677 }
4678
4679 #[test]
4684 fn trust_is_resolved_per_directory() {
4685 struct RestoreConfigDir(Option<std::ffi::OsString>);
4686
4687 impl Drop for RestoreConfigDir {
4688 fn drop(&mut self) {
4689 match self.0.take() {
4690 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4691 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4692 }
4693 }
4694 }
4695
4696 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4697 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4698 let tmp = tempfile::tempdir().unwrap();
4699 let agent = tmp.path().join("agent");
4700 std::fs::create_dir_all(&agent).unwrap();
4701 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4702
4703 let untrusted = tmp.path().join("project-untrusted");
4704 let launch = tmp.path().join("project-launch");
4705 std::fs::create_dir_all(&untrusted).unwrap();
4706 std::fs::create_dir_all(&launch).unwrap();
4707 crate::config::set_project_trust(&untrusted, Some(false)).unwrap();
4708
4709 let args = Args::default();
4710 assert!(!resolve_project_trust(&args, &untrusted));
4711 assert!(resolve_project_trust(&args, &launch));
4713 }
4714}