1use std::path::{Path, PathBuf};
34use std::sync::atomic::Ordering;
35use std::sync::{Arc, Mutex};
36
37use rpi_agent::AgentTool;
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::{BranchBounds, EntryQuery, SessionMetadata};
44use rpi_harness::session::Session;
45use rpi_harness::system_prompt::compose_system_prompt;
46use rpi_harness::types::{
47 AgentHarnessOptions, AgentHarnessResources, AgentHarnessStreamOptions, CompactionSettings,
48 DrivingMode, HarnessTool, HarnessToolExecution, RetryPolicy, ToolReplay,
49};
50use rpi_tools::{
51 create_bash_tool, create_edit_tool, create_read_tool, create_write_tool, ExecutionToolContext,
52 MutationQueueRegistry, OsExecutionEnv,
53};
54
55use crate::args::Args;
56use crate::docs_tool::create_docs_tool;
57use crate::extension_api::ExtensionBackend;
58use crate::provider::ResolvedModel;
59use crate::resource_dirs::{
60 discover_append_system_prompt_file_with_packages, discover_system_prompt_file_with_packages,
61 extension_dirs, global_extension_dirs, global_prompt_template_dirs, global_skill_dirs,
62 load_prompt_templates_with_precedence, load_skills_with_precedence,
63 project_prompt_template_dirs, project_skill_dirs, prompt_template_dirs, skill_dirs,
64};
65use rpi_extensions::{
66 emit_resources_discover, ExtensionEmitter, ExtensionSession, NullDiagnostics,
67 PluginDiagnostics, PluginToolAdapter, TeeEmitter,
68};
69
70pub const BUILTIN_TOOL_NAMES: &[&str] = &["read", "bash", "edit", "write", "docs"];
72
73pub(crate) fn should_load_js_packages(args: &Args) -> bool {
76 args.enable_pi_packages && !args.no_extensions
77}
78
79pub(crate) fn package_resources_for(
83 args: &Args,
84 cwd: &Path,
85 project_trusted: bool,
86) -> crate::packages::PackageResources {
87 if should_load_js_packages(args) {
88 if crate::args::offline_mode_enabled(args.offline) {
89 if project_trusted {
90 crate::packages::resolve_offline_from_settings(cwd)
91 } else {
92 crate::packages::resolve_offline_from_global_settings(cwd)
93 }
94 } else if project_trusted {
95 crate::packages::resolve_from_settings(cwd)
96 } else {
97 crate::packages::resolve_from_global_settings(cwd)
98 }
99 } else {
100 crate::packages::PackageResources::default()
101 }
102}
103
104pub(crate) fn package_resources_for_update_check(
109 args: &Args,
110 cwd: &Path,
111 project_trusted: bool,
112) -> crate::packages::PackageResources {
113 if args.dev_local_only {
114 crate::packages::PackageResources::default()
115 } else if project_trusted {
116 crate::packages::discover_from_settings(cwd)
117 } else {
118 crate::packages::discover_from_global_settings(cwd)
119 }
120}
121
122pub fn default_system_prompt(cwd: &str) -> String {
125 format!(
126 "You are an expert coding assistant operating inside rpi, a coding agent harness. \
127You help users by reading files, executing commands, editing code, and writing new files.
128
129Available tools:
130- read — Read file contents
131- bash — Execute shell commands
132- edit — Find/replace edits to existing files
133- write — Create or overwrite files
134- docs — Look up rpi usage, extension, package, and compatibility documentation
135
136Guidelines:
137- Be concise in your responses
138- Show file paths clearly when working with files
139- Prefer the smallest change that solves the problem
140- When unsure about rpi commands, extensions, Pi package compatibility, or .rpi configuration, consult the project documentation before guessing
141
142Current working directory: {cwd}"
143 )
144}
145
146#[derive(Debug, Clone)]
153pub enum SessionSelection {
154 Ephemeral,
156 New { dir: PathBuf, name: Option<String> },
158 Latest,
160 ById { id: String },
163 ByExactId { id: String },
165 Fork { source: String },
168}
169
170pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
172 if args.no_session {
173 return SessionSelection::Ephemeral;
174 }
175 if args.continue_session || args.resume {
176 return SessionSelection::Latest;
178 }
179 if let Some(s) = &args.fork {
180 return SessionSelection::Fork { source: s.clone() };
181 }
182 if let Some(s) = &args.session_id {
183 return SessionSelection::ByExactId { id: s.clone() };
184 }
185 if let Some(s) = &args.session {
186 return SessionSelection::ById { id: s.clone() };
187 }
188 let dir = args
189 .session_dir
190 .clone()
191 .unwrap_or_else(|| default_session_dir(cwd));
192 SessionSelection::New {
193 dir,
194 name: args.name.clone(),
195 }
196}
197
198pub fn default_session_dir(cwd: &Path) -> PathBuf {
202 let preferred = cwd.join(".rpi").join("sessions");
203 let legacy = cwd.join(".pi").join("sessions");
204 if preferred.exists() || !legacy.exists() {
205 preferred
206 } else {
207 legacy
208 }
209}
210
211pub async fn build(
229 resolved: &ResolvedModel,
230 args: &Args,
231 cwd: &Path,
232 project_trusted: bool,
233) -> Result<
234 (
235 AgentHarness,
236 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
237 ReloadContext,
238 ),
239 BuildError,
240> {
241 let cwd_str = cwd.to_string_lossy().to_string();
242 if !project_trusted && args.verbose {
243 eprintln!(
244 "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)"
245 );
246 }
247 let package_resources = if args.dev_local_only {
251 crate::packages::PackageResources::default()
252 } else {
253 package_resources_for(args, cwd, project_trusted)
254 };
255
256 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
271 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
272 })?;
273 let catalog = crate::provider::available_catalog(resolved);
274 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
275 catalog.clone(),
276 cwd.to_path_buf(),
277 runtime.clone(),
278 args.unknown_flags.clone(),
279 );
280 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = Arc::new(action_host);
281 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
291 let action_bridge = rpi_extensions::ActionBridge::with_reload(
292 runtime.clone(),
293 host_arc,
294 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
295 );
296
297 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
299 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
300 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
301 let _registry = Arc::new(MutationQueueRegistry::new());
302 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
305
306 let tools = build_tools(&ctx, args);
307 let mut tools = tools;
308
309 let extension_session = if args.no_extensions {
317 ExtensionSession::none()
318 } else {
319 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
320 };
321 let js_extension_session = if !should_load_js_packages(args) {
322 None
323 } else {
324 let paths = js_extension_paths(args, cwd, project_trusted, &package_resources);
325 let js_context = serde_json::json!({
326 "cwd": cwd_str,
327 "theme": resolved.theme.clone(),
328 "currentModel": resolved.model.clone(),
329 "models": catalog.clone(),
330 "thinkingLevel": resolved.thinking_level,
331 });
332 match crate::js_extensions::JsExtensionSession::load_with_context(
333 &paths,
334 args.verbose,
335 js_context,
336 ) {
337 Ok(session) => session,
338 Err(error) => {
339 eprintln!("warning: JS/TS extensions were not loaded: {error}");
340 None
341 }
342 }
343 };
344 if js_extension_session.is_some() {
345 eprintln!(
346 "warning: enabled Pi JS/TS extensions execute with the current user's permissions"
347 );
348 }
349 if let Some(session) = &js_extension_session {
350 if let Err(error) =
351 session.enable_provider_runtime(resolved.provider.clone(), runtime.clone())
352 {
353 if args.verbose {
354 eprintln!("warning: JS provider runtime was not enabled: {error}");
355 }
356 }
357 }
358 if args.verbose {
359 if let Some(session) = &js_extension_session {
360 let info = session.backend_info();
361 eprintln!(
362 "JS extension backend: {} v{} ({})",
363 info.name,
364 info.api_version,
365 info.capability_names().join(", ")
366 );
367 }
368 if let Some(s) = extension_session.summary() {
369 eprintln!("extensions: {s}");
370 }
371 report_deferred_renderers(&extension_session);
372 }
373 merge_extension_tools(&mut tools, &extension_session, args);
374 if let Some(session) = &js_extension_session {
375 merge_js_extension_tools(&mut tools, session, args);
376 if args.verbose && !session.commands.is_empty() {
377 eprintln!("JS extension commands: {}", session.commands.join(", "));
378 }
379 }
380 let mut active = active_tool_names(&tools, args);
381 if let Some(session) = &js_extension_session {
386 let js_names = session.tool_names();
387 if let Some(js_active) = session.active_tools() {
388 active.retain(|name| !js_names.iter().any(|js| js == name));
389 active.extend(js_active.into_iter().filter(|name| {
390 js_names.iter().any(|js| js == name) && tool_name_allowed(name, args)
391 }));
392 }
393 }
394 active = filter_active_tool_names(active, args);
395
396 let selection = select_session(args, cwd);
398 let session = build_session(&selection, &cwd_str).await?;
399 if let Some(js) = &js_extension_session {
400 let session_id = session
401 .get_metadata()
402 .await
403 .ok()
404 .map(|metadata| metadata.id);
405 let leaf_id = session.get_leaf_id().await.ok().flatten();
406 if let Some(session_id) = session_id {
407 let branch = session
408 .find_entries_on_branch(&EntryQuery::default(), &BranchBounds::default())
409 .await
410 .ok()
411 .unwrap_or_default();
412 let branch_json =
413 serde_json::to_value(&branch).unwrap_or_else(|_| serde_json::json!([]));
414 let runtime_context = serde_json::json!({
415 "session": {
416 "id": session_id,
417 "leafId": leaf_id,
418 "branch": branch_json,
419 "entries": branch_json.clone(),
420 },
421 });
422 if let Err(error) = js.set_runtime_context(runtime_context) {
423 if args.verbose {
424 eprintln!("warning: could not sync JS session context: {error}");
425 }
426 }
427 }
428 }
429
430 let base_prompt = match args.system_prompt.as_deref() {
438 Some(explicit) => explicit.to_string(),
439 None if project_trusted => {
440 match discover_system_prompt_file_with_packages(cwd, &package_resources) {
441 Some(path) => std::fs::read_to_string(&path)
442 .unwrap_or_else(|_| default_system_prompt(&cwd_str)),
443 None => default_system_prompt(&cwd_str),
444 }
445 }
446 None => default_system_prompt(&cwd_str),
447 };
448
449 let mut append_texts: Vec<String> = Vec::new();
456 for extra in &args.append_system_prompt {
457 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
458 append_texts.push(text);
459 }
460 if args.append_system_prompt.is_empty() {
461 if let Some(path) = project_trusted
462 .then(|| discover_append_system_prompt_file_with_packages(cwd, &package_resources))
463 .flatten()
464 {
465 if let Ok(text) = std::fs::read_to_string(&path) {
466 append_texts.push(text);
467 }
468 }
469 }
470 let append_join = if append_texts.is_empty() {
471 None
472 } else {
473 Some(append_texts.join("\n\n"))
474 };
475
476 let agent_dir = crate::config::agent_dir().ok();
488
489 let discovered = extension_session
505 .snapshot_arc()
506 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
507 .unwrap_or_default();
508
509 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
510 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
511 if !args.no_skills {
512 let mut dirs = if args.dev_local_only {
513 project_skill_dirs(cwd)
514 } else if project_trusted {
515 skill_dirs(cwd)
516 } else {
517 global_skill_dirs()
518 };
519 dirs.extend(args.skill.iter().cloned());
520 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
521 if let Some(session) = &js_extension_session {
522 dirs.extend(session.resources.skill_paths.iter().cloned());
523 }
524 dirs.extend(package_resources.skill_dirs());
525 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
526 skills = result.skills;
527 skill_diags = result.diagnostics;
528 }
529
530 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
531 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
532 if !args.no_prompt_templates {
533 let mut dirs = if args.dev_local_only {
534 project_prompt_template_dirs(cwd)
535 } else if project_trusted {
536 prompt_template_dirs(cwd)
537 } else {
538 global_prompt_template_dirs()
539 };
540 dirs.extend(args.prompt_template.iter().cloned());
541 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
542 if let Some(session) = &js_extension_session {
543 dirs.extend(session.resources.prompt_paths.iter().cloned());
544 }
545 dirs.extend(package_resources.prompt_dirs());
546 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
547 prompt_templates = result.prompt_templates;
548 prompt_diags = result.diagnostics;
549 }
550
551 let context_block = if args.no_context_files || !project_trusted {
552 String::new()
553 } else {
554 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
559 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
560 format_project_context(&files)
561 };
562
563 if args.verbose {
565 for d in &package_resources.diagnostics {
566 eprintln!("warning: package {}: {}", d.spec, d.message);
567 }
568 for d in &skill_diags {
569 eprintln!(
570 "warning: skill {} ({}): {}",
571 d.path,
572 d.code.as_str(),
573 d.message
574 );
575 }
576 for d in &prompt_diags {
577 eprintln!(
578 "warning: prompt template {} ({}): {}",
579 d.path,
580 d.code.as_str(),
581 d.message
582 );
583 }
584 }
585
586 let system_prompt = compose_system_prompt(
595 Some(&base_prompt),
596 &[], if context_block.is_empty() {
598 None
599 } else {
600 Some(&context_block)
601 },
602 append_join.as_deref(),
603 );
604
605 if args.debug_system_prompt {
614 eprintln!("=== --debug-system-prompt ===");
615 let base_src = if args.system_prompt.is_some() {
616 "--system-prompt"
617 } else if discover_system_prompt_file_with_packages(cwd, &package_resources).is_some() {
618 "SYSTEM.md"
619 } else {
620 "default"
621 };
622 eprintln!("[base source: {base_src}]");
623 eprintln!("--- base ---\n{base_prompt}");
624 if let Some(append) = append_join.as_deref() {
625 eprintln!("--- append ---\n{append}");
626 } else {
627 eprintln!("--- append: (none) ---");
628 }
629 if context_block.is_empty() {
630 eprintln!("--- context: (none) ---");
631 } else {
632 eprintln!("--- context ---{context_block}");
633 }
634 let visible_skills = skills
635 .iter()
636 .filter(|s| s.disable_model_invocation != Some(true))
637 .count();
638 eprintln!(
639 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
640 skills.len(),
641 visible_skills,
642 skills.len() - visible_skills
643 );
644 for s in &skills {
645 let hidden = if s.disable_model_invocation == Some(true) {
646 " [hidden]"
647 } else {
648 ""
649 };
650 eprintln!(" {}{hidden} — {}", s.name, s.description);
651 }
652 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
653 for t in &prompt_templates {
654 eprintln!(" /{}", t.name);
655 }
656 eprintln!(
660 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
661 discovered.skill_paths.len(),
662 discovered.prompt_paths.len(),
663 discovered.theme_paths.len(),
664 );
665 for p in &discovered.skill_paths {
666 eprintln!(" skill: {p}");
667 }
668 for p in &discovered.prompt_paths {
669 eprintln!(" prompt: {p}");
670 }
671 eprintln!(
672 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
673 );
674 eprintln!("=== end --debug-system-prompt ===");
675 }
676
677 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
682 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
683 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
687
688 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
697 Some(snapshot) => {
698 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
699 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
700 }
701 None => broadcast_emitter,
702 };
703
704 let options = AgentHarnessOptions {
705 model: resolved.model.clone(),
706 thinking_level: resolved.thinking_level,
707 active_tool_names: active,
708 tools,
709 system_prompt: Some(system_prompt),
710 resources: AgentHarnessResources {
711 skills: if skills.is_empty() {
712 None
713 } else {
714 Some(skills)
715 },
716 prompt_templates: if prompt_templates.is_empty() {
717 None
718 } else {
719 Some(prompt_templates)
720 },
721 },
722 allow_existing_session: matches!(
725 selection,
726 SessionSelection::Latest
727 | SessionSelection::ById { .. }
728 | SessionSelection::ByExactId { .. }
729 | SessionSelection::Fork { .. }
730 ),
731 stream_options: AgentHarnessStreamOptions {
732 timeout: args.timeout,
733 ..Default::default()
734 },
735 retry: RetryPolicy::default(),
736 compaction: CompactionSettings::default(),
737 steering_mode: Default::default(),
738 follow_up_mode: Default::default(),
739 tool_execution: HarnessToolExecution::default(),
740 drive: DrivingMode::default(),
741 session,
742 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
750 to_provider_messages: None,
751 entry_projectors: Default::default(),
752 agent_emitter: Some(emitter),
753 before_tool_call: None,
759 after_tool_call: None,
760 transform_context: None,
761 entry_transforms: Vec::new(),
762 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
768 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
769 };
770
771 let harness = match AgentHarness::create(options).await {
772 Ok(h) => {
773 crate::extensions_actions::HarnessActionHost::set_harness(
776 &harness_cell,
777 Arc::new(h.clone()),
778 );
779 h
780 }
781 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
782 };
783
784 let reload_context = ReloadContext {
791 extension_session: Arc::new(Mutex::new(extension_session)),
792 js_extension_session: js_extension_session.clone(),
793 package_resources: Arc::new(package_resources.clone()),
794 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
795 catalog,
796 gateway: resolved.provider.clone(),
797 runtime: runtime.clone(),
798 cwd: cwd.to_path_buf(),
799 project_trusted,
800 args: args.clone(),
801 resolved_model: resolved.model.clone(),
802 broadcast: broadcast_for_context,
803 mailbox: reload_mailbox,
804 dev_extension: None,
805 };
806
807 Ok((harness, event_rx, reload_context))
808}
809
810pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
855
856pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
861
862#[derive(Clone)]
870pub struct ReloadContext {
871 pub extension_session: ExtensionSessionCell,
873 pub js_extension_session: Option<crate::js_extensions::JsExtensionSession>,
875 pub package_resources: Arc<crate::packages::PackageResources>,
879 pub action_bridge: ActionBridgeCell,
881 pub catalog: Vec<rpi_ai::Model>,
886 pub gateway: Arc<dyn Provider>,
889 pub runtime: tokio::runtime::Handle,
893 pub cwd: PathBuf,
895 pub project_trusted: bool,
899 pub args: Args,
905 pub resolved_model: rpi_ai::Model,
908 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
914 pub mailbox: rpi_extensions::ReloadMailbox,
921 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
925}
926
927pub struct ReloadOutcome {
930 pub summary: String,
933 pub had_warnings: bool,
935}
936
937struct PreparedReloadInputs {
938 package_resources: crate::packages::PackageResources,
939 extension_dirs: Vec<PathBuf>,
940 skill_base_dirs: Vec<PathBuf>,
941 prompt_base_dirs: Vec<PathBuf>,
942}
943
944fn append_reload_resource_paths(
949 mut paths: Vec<PathBuf>,
950 explicit: &[PathBuf],
951 discovered: &[String],
952 js_paths: &[PathBuf],
953 package_paths: &[PathBuf],
954) -> Vec<PathBuf> {
955 paths.extend(explicit.iter().cloned());
956 paths.extend(discovered.iter().map(PathBuf::from));
957 paths.extend(js_paths.iter().cloned());
958 paths.extend(package_paths.iter().cloned());
959 paths
960}
961
962pub async fn reload_extension_resources(
974 harness: &AgentHarness,
975 ctx: &ReloadContext,
976) -> ReloadOutcome {
977 reload_extension_resources_inner(harness, ctx, || {}).await
978}
979
980async fn reload_extension_resources_inner<F>(
981 harness: &AgentHarness,
982 ctx: &ReloadContext,
983 after_prepare: F,
984) -> ReloadOutcome
985where
986 F: FnOnce() + Send,
987{
988 let mut effective_args = ctx.args.clone();
993 if let Some(dev) = &ctx.dev_extension {
994 if let Err(error) = dev
995 .rebuild()
996 .and_then(|_| dev.apply_to_args(&mut effective_args))
997 {
998 return ReloadOutcome {
999 summary: format!(
1000 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1001 dev.package_name()
1002 ),
1003 had_warnings: true,
1004 };
1005 }
1006 }
1007 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1008 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1009 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1010 Ok(prepared) => prepared,
1011 Err(error) => {
1012 return ReloadOutcome {
1013 summary: format!(
1014 "Settings reload failed: {error}. Keeping the currently loaded resources."
1015 ),
1016 had_warnings: true,
1017 };
1018 }
1019 };
1020 after_prepare();
1021 let PreparedReloadInputs {
1022 package_resources,
1023 extension_dirs,
1024 skill_base_dirs,
1025 prompt_base_dirs,
1026 } = prepared;
1027 let mut warnings = false;
1028
1029 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1036 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1037 Some(b) => b.clone_host(),
1038 None => {
1039 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1042 ctx.catalog.clone(),
1043 ctx.cwd.clone(),
1044 ctx.runtime.clone(),
1045 ctx.args.unknown_flags.clone(),
1046 );
1047 crate::extensions_actions::HarnessActionHost::set_harness(
1048 &_cell,
1049 Arc::new(harness.clone()),
1050 );
1051 Arc::new(action_host)
1052 }
1053 };
1054
1055 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1056 let fresh_bridge =
1057 rpi_extensions::ActionBridge::with_reload(ctx.runtime.clone(), host, reload_cb);
1058
1059 let extension_session = if effective_args.no_extensions {
1060 rpi_extensions::ExtensionSession::none()
1061 } else {
1062 load_extensions_from_dirs(
1063 &effective_args,
1064 &extension_dirs,
1065 Some(Arc::clone(&fresh_bridge)),
1066 )
1067 };
1068 if extension_session.is_empty() && !effective_args.no_extensions {
1069 }
1072 if effective_args.verbose {
1073 if let Some(s) = extension_session.summary() {
1074 eprintln!("reload: {s}");
1075 }
1076 report_deferred_renderers(&extension_session);
1077 }
1078
1079 {
1093 let mut session_guard = ctx.extension_session.lock().unwrap();
1094 let old_session = std::mem::replace(
1095 &mut *session_guard,
1096 rpi_extensions::ExtensionSession::none(),
1097 );
1098 if let Some(old_snap) = old_session.snapshot_arc() {
1099 old_snap.active_flag().store(false, Ordering::SeqCst);
1103 }
1104 }
1108 if let Some(old_b) = old_bridge {
1109 old_b.invalidate();
1110 }
1111
1112 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1115 *ctx.extension_session.lock().unwrap() = extension_session.clone();
1116
1117 let discovered = extension_session
1119 .snapshot_arc()
1120 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1121 .unwrap_or_default();
1122
1123 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1125 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1126
1127 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1128 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1129 if !effective_args.no_skills {
1130 let js_paths: &[PathBuf] = ctx
1134 .js_extension_session
1135 .as_ref()
1136 .map(|js| js.resources.skill_paths.as_slice())
1137 .unwrap_or(&[]);
1138 let package_paths = package_resources.skill_dirs();
1139 let dirs = append_reload_resource_paths(
1140 skill_base_dirs,
1141 &effective_args.skill,
1142 &discovered.skill_paths,
1143 js_paths,
1144 &package_paths,
1145 );
1146 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1147 skills = result.skills;
1148 skill_diags = result.diagnostics;
1149 }
1150
1151 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1152 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1153 if !effective_args.no_prompt_templates {
1154 let js_paths: &[PathBuf] = ctx
1155 .js_extension_session
1156 .as_ref()
1157 .map(|js| js.resources.prompt_paths.as_slice())
1158 .unwrap_or(&[]);
1159 let package_paths = package_resources.prompt_dirs();
1160 let dirs = append_reload_resource_paths(
1161 prompt_base_dirs,
1162 &effective_args.prompt_template,
1163 &discovered.prompt_paths,
1164 js_paths,
1165 &package_paths,
1166 );
1167 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1168 prompt_templates = result.prompt_templates;
1169 prompt_diags = result.diagnostics;
1170 }
1171
1172 let context_block = if effective_args.no_context_files {
1173 String::new()
1174 } else {
1175 let agent_dir = crate::config::agent_dir().ok();
1176 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1177 let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
1178 format_project_context(&files)
1179 };
1180
1181 if !skill_diags.is_empty()
1182 || !prompt_diags.is_empty()
1183 || !package_resources.diagnostics.is_empty()
1184 {
1185 warnings = true;
1186 if effective_args.verbose {
1187 for d in &package_resources.diagnostics {
1188 eprintln!("warning: package {}: {}", d.spec, d.message);
1189 }
1190 for d in &skill_diags {
1191 eprintln!(
1192 "warning: skill {} ({}): {}",
1193 d.path,
1194 d.code.as_str(),
1195 d.message
1196 );
1197 }
1198 for d in &prompt_diags {
1199 eprintln!(
1200 "warning: prompt template {} ({}): {}",
1201 d.path,
1202 d.code.as_str(),
1203 d.message
1204 );
1205 }
1206 }
1207 }
1208
1209 let base_prompt = match effective_args.system_prompt.as_deref() {
1211 Some(explicit) => explicit.to_string(),
1212 None => match discover_system_prompt_file_with_packages(&ctx.cwd, &package_resources) {
1213 Some(path) => {
1214 std::fs::read_to_string(&path).unwrap_or_else(|_| default_system_prompt(&cwd_str))
1215 }
1216 None => default_system_prompt(&cwd_str),
1217 },
1218 };
1219 let mut append_texts: Vec<String> = Vec::new();
1220 for extra in &effective_args.append_system_prompt {
1221 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
1222 append_texts.push(text);
1223 }
1224 if effective_args.append_system_prompt.is_empty() {
1225 if let Some(path) =
1226 discover_append_system_prompt_file_with_packages(&ctx.cwd, &package_resources)
1227 {
1228 if let Ok(text) = std::fs::read_to_string(&path) {
1229 append_texts.push(text);
1230 }
1231 }
1232 }
1233 let append_join = if append_texts.is_empty() {
1234 None
1235 } else {
1236 Some(append_texts.join("\n\n"))
1237 };
1238 let system_prompt = compose_system_prompt(
1239 Some(&base_prompt),
1240 &[],
1241 if context_block.is_empty() {
1242 None
1243 } else {
1244 Some(&context_block)
1245 },
1246 append_join.as_deref(),
1247 );
1248
1249 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1251 Some(snapshot) => {
1252 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1253 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1254 }
1255 None => ctx.broadcast.clone(),
1256 };
1257
1258 let resources = AgentHarnessResources {
1260 skills: if skills.is_empty() {
1261 None
1262 } else {
1263 Some(skills.clone())
1264 },
1265 prompt_templates: if prompt_templates.is_empty() {
1266 None
1267 } else {
1268 Some(prompt_templates.clone())
1269 },
1270 };
1271 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1272 let _ = harness.set_resources(resources).await;
1273 let _ = harness.set_agent_emitter(Some(emitter)).await;
1274 let _ = harness
1275 .set_models(build_models_with_extensions_for_reload(
1276 &ctx.gateway,
1277 &extension_session,
1278 ctx.runtime.clone(),
1279 ))
1280 .await;
1281 let _ = harness
1282 .set_provider_hooks(
1283 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1284 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1285 )
1286 .await;
1287
1288 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1292 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1293 let mut tools = build_tools(&tool_ctx, &effective_args);
1294 merge_extension_tools(&mut tools, &extension_session, &effective_args);
1295 if let Some(js) = &ctx.js_extension_session {
1296 merge_js_extension_tools(&mut tools, js, &effective_args);
1297 }
1298 let mut active = active_tool_names(&tools, &effective_args);
1299 if let Some(js) = &ctx.js_extension_session {
1300 let js_names = js.tool_names();
1301 if let Some(js_active) = js.active_tools() {
1302 active.retain(|name| {
1303 tool_name_allowed(name, &effective_args)
1304 && !js_names.iter().any(|js_name| js_name == name)
1305 });
1306 active.extend(js_active.into_iter().filter(|name| {
1307 js_names.iter().any(|js_name| js_name == name)
1308 && tool_name_allowed(name, &effective_args)
1309 }));
1310 }
1311 }
1312 active = filter_active_tool_names(active, &effective_args);
1313 let _ = harness.set_tools(tools, Some(active)).await;
1314
1315 let summary = format!(
1316 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1317 extension_session.loaded_paths().len(),
1318 skills.len(),
1319 prompt_templates.len(),
1320 );
1321 ReloadOutcome {
1322 summary,
1323 had_warnings: warnings,
1324 }
1325}
1326
1327#[derive(Clone, Debug, PartialEq, Eq)]
1328struct ReloadSettingsFields {
1329 packages: Option<Vec<crate::settings::PackageSetting>>,
1330 npm_command: Option<Vec<String>>,
1331 skill_dirs: Option<Vec<String>>,
1332 prompt_dirs: Option<Vec<String>>,
1333 extension_dirs: Option<Vec<String>>,
1334}
1335
1336impl From<crate::settings::Settings> for ReloadSettingsFields {
1337 fn from(settings: crate::settings::Settings) -> Self {
1338 Self {
1339 packages: settings.packages,
1340 npm_command: settings.npm_command,
1341 skill_dirs: settings.skill_dirs,
1342 prompt_dirs: settings.prompt_dirs,
1343 extension_dirs: settings.extension_dirs,
1344 }
1345 }
1346}
1347
1348#[derive(Clone, Debug, PartialEq, Eq)]
1349struct ReloadSettingsSnapshot {
1350 global: ReloadSettingsFields,
1351 project: Option<ReloadSettingsFields>,
1352}
1353
1354fn reload_reads_settings(args: &Args) -> bool {
1355 !args.dev_local_only
1356 && (should_load_js_packages(args)
1357 || !args.no_extensions
1358 || !args.no_skills
1359 || !args.no_prompt_templates)
1360}
1361
1362fn load_reload_settings_snapshot(
1366 args: &Args,
1367 cwd: &Path,
1368 project_trusted: bool,
1369) -> Result<Option<ReloadSettingsSnapshot>, String> {
1370 if !reload_reads_settings(args) {
1371 return Ok(None);
1372 }
1373 let global = crate::settings::load_settings()
1374 .map_err(|error| format!("could not load global settings: {error}"))?
1375 .into();
1376 let project = if project_trusted {
1377 crate::settings::load_active_project_settings(cwd)
1378 .map_err(|error| format!("could not load project settings: {error}"))?
1379 .map(|(_, settings)| settings.into())
1380 } else {
1381 None
1382 };
1383 Ok(Some(ReloadSettingsSnapshot { global, project }))
1384}
1385
1386fn validate_settings_for_reload(
1389 args: &Args,
1390 cwd: &Path,
1391 project_trusted: bool,
1392) -> Result<(), String> {
1393 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1394}
1395
1396fn prepare_reload_inputs(
1397 args: &Args,
1398 cwd: &Path,
1399 project_trusted: bool,
1400) -> Result<PreparedReloadInputs, String> {
1401 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1402}
1403
1404fn prepare_reload_inputs_inner<F>(
1405 args: &Args,
1406 cwd: &Path,
1407 project_trusted: bool,
1408 before_verify: F,
1409) -> Result<PreparedReloadInputs, String>
1410where
1411 F: FnOnce(),
1412{
1413 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1414
1415 let package_resources = if args.dev_local_only {
1416 crate::packages::PackageResources::default()
1417 } else {
1418 package_resources_for(args, cwd, project_trusted)
1419 };
1420
1421 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1422 Vec::new()
1423 } else if project_trusted {
1424 extension_dirs(cwd)
1425 } else {
1426 global_extension_dirs()
1427 };
1428 if !args.no_extensions {
1429 extension_dirs.extend(args.extensions_dir.iter().cloned());
1430 }
1431
1432 let skill_base_dirs = if args.no_skills {
1433 Vec::new()
1434 } else if args.dev_local_only {
1435 project_skill_dirs(cwd)
1436 } else if project_trusted {
1437 skill_dirs(cwd)
1438 } else {
1439 global_skill_dirs()
1440 };
1441
1442 let prompt_base_dirs = if args.no_prompt_templates {
1443 Vec::new()
1444 } else if args.dev_local_only {
1445 project_prompt_template_dirs(cwd)
1446 } else if project_trusted {
1447 prompt_template_dirs(cwd)
1448 } else {
1449 global_prompt_template_dirs()
1450 };
1451
1452 before_verify();
1453 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1454 if settings_before != settings_after {
1455 return Err(
1456 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1457 );
1458 }
1459
1460 Ok(PreparedReloadInputs {
1461 package_resources,
1462 extension_dirs,
1463 skill_base_dirs,
1464 prompt_base_dirs,
1465 })
1466}
1467
1468fn build_models_with_extensions_for_reload(
1473 gateway: &Arc<dyn Provider>,
1474 extension_session: &ExtensionSession,
1475 runtime: tokio::runtime::Handle,
1476) -> Vec<Arc<dyn Provider>> {
1477 let mut models: Vec<Arc<dyn Provider>> = vec![gateway.clone()];
1478 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1479 models.extend(pluggable);
1480 models
1481}
1482
1483fn report_deferred_renderers(session: &ExtensionSession) {
1487 let Some(snap) = session.snapshot_arc() else {
1488 return;
1489 };
1490 let all = snap.renderers();
1491 let markdown = all
1492 .iter()
1493 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1494 .count();
1495 let message = all
1496 .iter()
1497 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1498 .count();
1499 let entry = all
1500 .iter()
1501 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1502 .count();
1503 if markdown + message + entry == 0 {
1504 return;
1505 }
1506 eprintln!(
1507 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1508 markdown, message, entry
1509 );
1510}
1511
1512#[derive(Debug, thiserror::Error)]
1514pub enum BuildError {
1515 #[error("Could not create the session directory: {0}")]
1516 SessionDir(String),
1517 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1518 SessionNotFound { requested: String, dir: String },
1519 #[error("Could not build the harness: {0}")]
1520 HarnessCreate(String),
1521}
1522
1523fn build_models_with_extensions(
1533 resolved: &ResolvedModel,
1534 extension_session: &ExtensionSession,
1535 runtime: tokio::runtime::Handle,
1536) -> Vec<Arc<dyn Provider>> {
1537 let mut models: Vec<Arc<dyn Provider>> = vec![resolved.provider.clone() as Arc<dyn Provider>];
1538 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1539 models.extend(pluggable);
1540 models
1541}
1542
1543pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1546 if let Some(override_value) = args.trust_override {
1547 return override_value;
1548 }
1549 crate::config::project_trust_decision(cwd)
1550 .ok()
1551 .flatten()
1552 .unwrap_or(true)
1553}
1554
1555fn load_extensions(
1563 args: &Args,
1564 cwd: &Path,
1565 project_trusted: bool,
1566 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1567) -> ExtensionSession {
1568 let mut dirs = if args.dev_local_only {
1569 Vec::new()
1570 } else if project_trusted {
1571 extension_dirs(cwd)
1572 } else {
1573 global_extension_dirs()
1574 };
1575 dirs.extend(args.extensions_dir.iter().cloned());
1576 load_extensions_from_dirs(args, &dirs, action_bridge)
1577}
1578
1579fn load_extensions_from_dirs(
1580 args: &Args,
1581 dirs: &[PathBuf],
1582 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1583) -> ExtensionSession {
1584 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1585 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1592}
1593
1594fn js_extension_paths(
1595 args: &Args,
1596 cwd: &Path,
1597 project_trusted: bool,
1598 packages: &crate::packages::PackageResources,
1599) -> Vec<PathBuf> {
1600 if args.dev_local_only {
1601 return Vec::new();
1602 }
1603 let mut paths = packages.extension_paths();
1604 let discovered_dirs = if project_trusted {
1605 extension_dirs(cwd)
1606 } else {
1607 global_extension_dirs()
1608 };
1609 for dir in discovered_dirs {
1610 if let Ok(entries) = std::fs::read_dir(dir) {
1611 paths.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
1612 matches!(
1613 path.extension()
1614 .and_then(|ext| ext.to_str())
1615 .map(|ext| ext.to_ascii_lowercase())
1616 .as_deref(),
1617 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1618 )
1619 }));
1620 }
1621 }
1622 paths.extend(
1623 args.extension
1624 .iter()
1625 .filter(|path| {
1626 matches!(
1627 path.extension()
1628 .and_then(|ext| ext.to_str())
1629 .map(|ext| ext.to_ascii_lowercase())
1630 .as_deref(),
1631 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1632 )
1633 })
1634 .cloned(),
1635 );
1636 paths
1637}
1638
1639fn merge_extension_tools(tools: &mut Vec<HarnessTool>, session: &ExtensionSession, args: &Args) {
1645 let Some(snapshot) = session.snapshot() else {
1646 return;
1647 };
1648 for et in snapshot.tools() {
1649 let name = &et.tool.name;
1650 if !tool_name_allowed(name, args) {
1651 continue;
1652 }
1653 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive());
1654 let harness_tool = HarnessTool::new(Arc::new(adapter));
1655 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1656 Some(slot) => *slot = harness_tool,
1657 None => tools.push(harness_tool),
1658 }
1659 }
1660}
1661
1662fn merge_js_extension_tools(
1663 tools: &mut Vec<HarnessTool>,
1664 session: &crate::js_extensions::JsExtensionSession,
1665 args: &Args,
1666) {
1667 for adapter in session.tools() {
1668 let name = adapter.schema().name.clone();
1669 if !tool_name_allowed(&name, args) {
1670 continue;
1671 }
1672 let harness_tool = HarnessTool::new(Arc::new(adapter));
1673 match tools
1674 .iter_mut()
1675 .find(|tool| tool.tool.schema().name == name)
1676 {
1677 Some(slot) => *slot = harness_tool,
1678 None => tools.push(harness_tool),
1679 }
1680 }
1681}
1682
1683pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1690 use rpi_tools::tools::bash::BashToolOptions;
1691 let default = std::env::var("RPI_BASH_TIMEOUT")
1692 .ok()
1693 .and_then(|v| v.parse::<f64>().ok())
1694 .unwrap_or(120.0);
1695 BashToolOptions {
1696 command_prefix: None,
1697 default_timeout: Some(default),
1698 }
1699}
1700
1701fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1702 if args.no_tools {
1703 return Vec::new();
1704 }
1705 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1708 ("read", HarnessTool::new(create_read_tool(ctx, None))),
1709 (
1710 "bash",
1711 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1712 ),
1713 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1714 ("write", HarnessTool::new(create_write_tool(ctx))),
1715 ("docs", HarnessTool::new(create_docs_tool())),
1716 ];
1717
1718 if args.no_builtin_tools {
1722 all.clear();
1723 }
1724
1725 if let Some(allow) = &args.tools {
1727 all.retain(|(name, _)| allow.iter().any(|a| a == name));
1728 }
1729 if let Some(deny) = &args.exclude_tools {
1731 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1732 }
1733
1734 all.into_iter()
1735 .map(|(_, t)| t.with_replay(ToolReplay::Safe))
1736 .collect()
1737}
1738
1739fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1743 filter_active_tool_names(
1744 tools.iter().map(|tool| tool.tool.schema().name.clone()),
1745 args,
1746 )
1747}
1748
1749pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
1753 if args.no_tools {
1754 return false;
1755 }
1756 if args
1757 .tools
1758 .as_ref()
1759 .is_some_and(|allow| !allow.iter().any(|value| value == name))
1760 {
1761 return false;
1762 }
1763 if args
1764 .exclude_tools
1765 .as_ref()
1766 .is_some_and(|deny| deny.iter().any(|value| value == name))
1767 {
1768 return false;
1769 }
1770 true
1771}
1772
1773pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
1774where
1775 I: IntoIterator<Item = String>,
1776{
1777 names
1778 .into_iter()
1779 .filter(|name| tool_name_allowed(name, args))
1780 .collect()
1781}
1782
1783async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1785 match selection {
1786 SessionSelection::Ephemeral => Ok(ephemeral_session()),
1787 SessionSelection::New { dir, .. } => {
1788 std::fs::create_dir_all(dir)
1791 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1792 let session = create_jsonl_session(dir, cwd)
1793 .await
1794 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1795 Ok(session)
1796 }
1797 SessionSelection::Latest
1798 | SessionSelection::ById { .. }
1799 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
1800 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
1801 }
1802}
1803
1804async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1810 match selection {
1814 SessionSelection::Latest => {
1815 let metas = list_session_metadata(cwd).await?;
1816 let Some(meta) = metas.first() else {
1817 return Err(BuildError::SessionNotFound {
1818 requested: "the most recent session".to_string(),
1819 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1820 });
1821 };
1822 open_session(meta, cwd).await
1823 }
1824 SessionSelection::ById { id } => open_session_by_id(id, cwd).await.map_err(|e| match e {
1825 OpenError::NotFound { requested } => BuildError::SessionNotFound {
1826 requested,
1827 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1828 },
1829 OpenError::Other(msg) => BuildError::SessionDir(msg),
1830 }),
1831 SessionSelection::ByExactId { id } => {
1832 let metas = list_session_metadata(cwd).await?;
1835 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
1836 return open_session(meta, cwd).await;
1837 }
1838 let dir = default_session_dir(Path::new(cwd));
1839 std::fs::create_dir_all(&dir)
1840 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1841 create_jsonl_session_with_id(&dir, cwd, Some(id.clone()))
1842 .await
1843 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
1844 }
1845 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
1846 }
1847}
1848
1849async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
1852 use rpi_harness::session::jsonl::{
1853 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1854 };
1855 use rpi_harness::session::types::{ForkOptions, SessionStorage};
1856 use rpi_tools::FileSystem;
1857
1858 let dir = default_session_dir(Path::new(cwd));
1859 std::fs::create_dir_all(&dir)
1860 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1861 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1862 let fs: Arc<dyn FileSystem> = env.clone();
1863 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1864 fs: fs.clone(),
1865 sessions_root: dir.to_string_lossy().into_owned(),
1866 clock: Arc::new(SystemClock),
1867 ids: Arc::new(DefaultIdGenerator::new()),
1868 });
1869 let metas = repo
1870 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
1871 .await
1872 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
1873 let source_meta = metas
1874 .iter()
1875 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
1876 .ok_or_else(|| BuildError::SessionNotFound {
1877 requested: format!("--fork {source}"),
1878 dir: dir.display().to_string(),
1879 })?;
1880 let fork_storage = repo
1881 .fork_typed(
1882 source_meta,
1883 &JsonlSessionCreateOptions {
1884 id: None,
1885 parent_session_id: Some(source_meta.id.clone()),
1886 cwd: cwd.to_string(),
1887 metadata: None,
1888 },
1889 &ForkOptions::default(),
1890 )
1891 .await
1892 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
1893 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
1894 Ok(Session::new(storage_arc, None))
1895}
1896
1897pub enum OpenError {
1900 NotFound { requested: String },
1902 Other(String),
1904}
1905
1906impl std::fmt::Display for OpenError {
1907 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1908 match self {
1909 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
1910 OpenError::Other(msg) => write!(f, "{msg}"),
1911 }
1912 }
1913}
1914
1915pub async fn list_session_metadata(
1918 cwd: &str,
1919) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
1920 use rpi_harness::session::jsonl::{
1921 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1922 };
1923 use rpi_tools::FileSystem;
1924
1925 let dir = default_session_dir(Path::new(cwd));
1926 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1927 let fs: Arc<dyn FileSystem> = env.clone();
1928 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1929 fs: fs.clone(),
1930 sessions_root: dir.to_string_lossy().into_owned(),
1931 clock: Arc::new(SystemClock),
1932 ids: Arc::new(DefaultIdGenerator::new()),
1933 });
1934 repo.list_typed(&JsonlSessionListOptions::default())
1935 .await
1936 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
1937}
1938
1939pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
1943 let metas = list_session_metadata(cwd)
1944 .await
1945 .map_err(|e| OpenError::Other(e.to_string()))?;
1946 let Some(meta) = metas
1947 .iter()
1948 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
1949 else {
1950 return Err(OpenError::NotFound {
1951 requested: format!("session {id}"),
1952 });
1953 };
1954 open_session(meta, cwd)
1955 .await
1956 .map_err(|e| OpenError::Other(e.to_string()))
1957}
1958
1959pub(crate) async fn fork_session_storage(
1966 harness: &AgentHarness,
1967 cwd: &str,
1968) -> Result<Session, String> {
1969 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
1970 use rpi_tools::FileSystem;
1971
1972 let dir = default_session_dir(Path::new(cwd));
1973 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1974 let fs: Arc<dyn FileSystem> = env.clone();
1975 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1976 fs,
1977 sessions_root: dir.to_string_lossy().into_owned(),
1978 clock: Arc::new(SystemClock),
1979 ids: Arc::new(DefaultIdGenerator::new()),
1980 });
1981 let id = harness.session().storage().metadata().id.clone();
1984 let metas = list_session_metadata(cwd)
1985 .await
1986 .map_err(|e| e.to_string())?;
1987 let Some(source) = metas.iter().find(|m| m.id == id) else {
1988 return Err(format!("current session {id} not found on disk"));
1989 };
1990 let fork_storage = repo
1991 .fork_typed(
1992 source,
1993 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
1994 id: None,
1995 parent_session_id: Some(source.id.clone()),
1996 cwd: cwd.to_string(),
1997 metadata: None,
1998 },
1999 &rpi_harness::session::types::ForkOptions::default(),
2000 )
2001 .await
2002 .map_err(|e| e.to_string())?;
2003 Ok(Session::new(Arc::new(fork_storage), None))
2004}
2005
2006async fn open_session(
2009 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2010 cwd: &str,
2011) -> Result<Session, BuildError> {
2012 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2013 use rpi_harness::session::types::SessionStorage;
2014 use rpi_tools::FileSystem;
2015
2016 let dir = default_session_dir(Path::new(cwd));
2017 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2018 let fs: Arc<dyn FileSystem> = env.clone();
2019 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2020 fs: fs.clone(),
2021 sessions_root: dir.to_string_lossy().into_owned(),
2022 clock: Arc::new(SystemClock),
2023 ids: Arc::new(DefaultIdGenerator::new()),
2024 });
2025 let storage = repo
2026 .open_by_jsonl_metadata(meta)
2027 .await
2028 .map_err(|e| BuildError::SessionDir(format!("open {}: {e}", meta.path)))?;
2029 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2030 Ok(Session::new(storage_arc, None))
2031}
2032
2033fn ephemeral_session() -> Session {
2035 let storage = Arc::new(InMemorySessionStorage::new(
2036 SessionMetadata {
2037 id: "ephemeral".into(),
2038 created_at: 0,
2039 parent_session_id: None,
2040 },
2041 Arc::new(SystemClock),
2042 Arc::new(DefaultIdGenerator::new()),
2043 ));
2044 Session::new(storage, None)
2045}
2046
2047pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2058 create_jsonl_session_with_id(dir, cwd, None).await
2059}
2060
2061pub(crate) async fn create_jsonl_session_with_id(
2065 dir: &Path,
2066 cwd: &str,
2067 id: Option<String>,
2068) -> Result<Session, String> {
2069 use rpi_harness::session::jsonl::{
2070 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2071 };
2072 use rpi_tools::FileSystem;
2073
2074 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2077 let fs: Arc<dyn FileSystem> = env.clone();
2078
2079 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2080 fs: fs.clone(),
2081 sessions_root: dir.to_string_lossy().into_owned(),
2082 clock: Arc::new(SystemClock),
2083 ids: Arc::new(DefaultIdGenerator::new()),
2084 });
2085
2086 let opts = JsonlSessionCreateOptions {
2087 id, parent_session_id: None,
2089 cwd: cwd.to_string(),
2090 metadata: None,
2091 };
2092 let storage = repo
2093 .create_typed(&opts)
2094 .await
2095 .map_err(|e| format!("create session: {e}"))?;
2096 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2098 Ok(Session::new(storage_arc, None))
2099}
2100
2101fn read_append_target(target: &str) -> Option<String> {
2104 let path = Path::new(target);
2105 if path.is_file() {
2106 std::fs::read_to_string(path).ok()
2107 } else {
2108 None
2109 }
2110}
2111
2112#[cfg(test)]
2113mod tests {
2114 use super::*;
2115 use crate::args::Args;
2116
2117 #[test]
2118 fn default_prompt_mentions_cwd_and_tools() {
2119 let p = default_system_prompt("/tmp/proj");
2120 assert!(p.contains("/tmp/proj"));
2121 assert!(p.contains("read"));
2122 assert!(p.contains("bash"));
2123 assert!(p.contains("edit"));
2124 assert!(p.contains("write"));
2125 assert!(p.contains("docs"));
2126 assert!(!p.contains("- grep"));
2127 assert!(!p.contains("- find"));
2128 assert!(!p.contains("- ls"));
2129 assert!(!p.contains("powershell"));
2130 }
2131
2132 #[test]
2133 fn default_tools_include_docs_lookup() {
2134 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2135 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2136 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2137 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2138 let names: Vec<String> = build_tools(&context, &Args::default())
2139 .iter()
2140 .map(|tool| tool.tool.schema().name.clone())
2141 .collect();
2142
2143 assert_eq!(names, vec!["read", "bash", "edit", "write", "docs"]);
2144 }
2145
2146 #[test]
2147 fn pi_package_loading_is_opt_in_and_respects_no_extensions() {
2148 let args = Args::default();
2149 assert!(!should_load_js_packages(&args));
2150 let resources = package_resources_for(&args, Path::new("."), false);
2151 assert!(resources.packages.is_empty());
2152
2153 let args = Args {
2154 enable_pi_packages: true,
2155 ..Args::default()
2156 };
2157 assert!(should_load_js_packages(&args));
2158
2159 let args = Args {
2160 enable_pi_packages: true,
2161 no_extensions: true,
2162 ..Args::default()
2163 };
2164 assert!(!should_load_js_packages(&args));
2165 assert!(package_resources_for(&args, Path::new("."), false)
2166 .packages
2167 .is_empty());
2168 }
2169
2170 #[test]
2171 fn pi_offline_env_disables_startup_package_remediation() {
2172 struct RestoreEnv {
2173 name: &'static str,
2174 value: Option<std::ffi::OsString>,
2175 }
2176
2177 impl Drop for RestoreEnv {
2178 fn drop(&mut self) {
2179 match self.value.take() {
2180 Some(value) => std::env::set_var(self.name, value),
2181 None => std::env::remove_var(self.name),
2182 }
2183 }
2184 }
2185
2186 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2187 let _restore_config = RestoreEnv {
2188 name: crate::config::CONFIG_DIR_ENV,
2189 value: std::env::var_os(crate::config::CONFIG_DIR_ENV),
2190 };
2191 let _restore_offline = RestoreEnv {
2192 name: crate::args::PI_OFFLINE_ENV,
2193 value: std::env::var_os(crate::args::PI_OFFLINE_ENV),
2194 };
2195 let tmp = tempfile::tempdir().unwrap();
2196 let agent = tmp.path().join("agent");
2197 let cwd = tmp.path().join("project");
2198 let package = agent.join("npm/node_modules/demo");
2199 std::fs::create_dir_all(package.join("extensions")).unwrap();
2200 std::fs::create_dir_all(&cwd).unwrap();
2201 std::fs::write(
2202 package.join("package.json"),
2203 r#"{"name":"demo","version":"1.0.0"}"#,
2204 )
2205 .unwrap();
2206 std::fs::write(
2207 package.join("extensions/index.js"),
2208 "export default () => {};",
2209 )
2210 .unwrap();
2211 std::fs::write(
2212 agent.join("settings.json"),
2213 r#"{"npmCommand":[""],"packages":["npm:demo@2.0.0"]}"#,
2214 )
2215 .unwrap();
2216 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2217 std::env::set_var(crate::args::PI_OFFLINE_ENV, "TrUe");
2218 let args = Args {
2219 enable_pi_packages: true,
2220 offline: false,
2221 ..Args::default()
2222 };
2223
2224 let resources = package_resources_for(&args, &cwd, false);
2225
2226 assert!(resources.packages.is_empty());
2227 assert_eq!(resources.diagnostics.len(), 1);
2228 assert!(resources.diagnostics[0].message.contains("offline"));
2229 assert_eq!(
2230 std::fs::read(package.join("package.json")).unwrap(),
2231 br#"{"name":"demo","version":"1.0.0"}"#
2232 );
2233 }
2234
2235 #[test]
2236 fn package_discovery_uses_the_callers_trust_snapshot() {
2237 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2238 let previous = std::env::var_os(crate::config::CONFIG_DIR_ENV);
2239 let tmp = tempfile::tempdir().unwrap();
2240 let agent = tmp.path().join("agent");
2241 let cwd = tmp.path().join("project");
2242 let package = cwd.join("package");
2243 std::fs::create_dir_all(&agent).unwrap();
2244 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2245 std::fs::create_dir_all(&package).unwrap();
2246 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2247 std::fs::write(
2248 package.join("package.json"),
2249 r#"{"name":"snapshot-package","version":"1.0.0"}"#,
2250 )
2251 .unwrap();
2252 std::fs::write(
2253 cwd.join(".rpi/settings.json"),
2254 serde_json::json!({"packages": [package]}).to_string(),
2255 )
2256 .unwrap();
2257 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2258
2259 let args = Args {
2260 enable_pi_packages: true,
2261 ..Args::default()
2262 };
2263 assert_eq!(package_resources_for(&args, &cwd, true).packages.len(), 1);
2264 assert!(package_resources_for(&args, &cwd, false)
2265 .packages
2266 .is_empty());
2267 assert_eq!(
2268 package_resources_for_update_check(&args, &cwd, true)
2269 .packages
2270 .len(),
2271 1
2272 );
2273 assert!(package_resources_for_update_check(&args, &cwd, false)
2274 .packages
2275 .is_empty());
2276
2277 match previous {
2278 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2279 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2280 }
2281 }
2282
2283 #[test]
2284 fn reload_settings_preflight_is_independent_of_packages_and_respects_trust() {
2285 struct RestoreConfigDir(Option<std::ffi::OsString>);
2286
2287 impl Drop for RestoreConfigDir {
2288 fn drop(&mut self) {
2289 match self.0.take() {
2290 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2291 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2292 }
2293 }
2294 }
2295
2296 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2297 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2298 let tmp = tempfile::tempdir().unwrap();
2299 let agent = tmp.path().join("agent");
2300 let cwd = tmp.path().join("project");
2301 std::fs::create_dir_all(&agent).unwrap();
2302 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2303 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2304 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
2305 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2306 let packages_disabled = Args::default();
2307 let packages_enabled = Args {
2308 enable_pi_packages: true,
2309 ..Args::default()
2310 };
2311
2312 for args in [&packages_disabled, &packages_enabled] {
2313 let trusted = validate_settings_for_reload(args, &cwd, true);
2314 let untrusted = validate_settings_for_reload(args, &cwd, false);
2315 assert!(trusted
2316 .unwrap_err()
2317 .contains("could not load project settings"));
2318 assert!(untrusted.is_ok());
2319 }
2320
2321 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2322 for args in [&packages_disabled, &packages_enabled] {
2323 assert!(validate_settings_for_reload(args, &cwd, false)
2324 .unwrap_err()
2325 .contains("could not load global settings"));
2326 }
2327
2328 let local_only = Args {
2329 dev_local_only: true,
2330 ..Args::default()
2331 };
2332 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
2333 }
2334
2335 #[tokio::test(flavor = "current_thread")]
2336 async fn reload_with_packages_disabled_preserves_live_resources_when_settings_break() {
2337 struct RestoreConfigDir(Option<std::ffi::OsString>);
2338
2339 impl Drop for RestoreConfigDir {
2340 fn drop(&mut self) {
2341 match self.0.take() {
2342 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2343 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2344 }
2345 }
2346 }
2347
2348 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2349 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2350 let tmp = tempfile::tempdir().unwrap();
2351 let agent = tmp.path().join("agent");
2352 let cwd = tmp.path().join("project");
2353 let skill_dir = tmp.path().join("configured-skills");
2354 std::fs::create_dir_all(&agent).unwrap();
2355 std::fs::create_dir_all(&cwd).unwrap();
2356 std::fs::create_dir_all(&skill_dir).unwrap();
2357 std::fs::write(
2358 skill_dir.join("SKILL.md"),
2359 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
2360 )
2361 .unwrap();
2362 std::fs::write(
2363 agent.join("settings.json"),
2364 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2365 )
2366 .unwrap();
2367 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2368
2369 let resolved = crate::provider::resolve(
2370 Some("anthropic"),
2371 Some(crate::provider::DEFAULT_MODEL_ID),
2372 None,
2373 Some("test-key"),
2374 None,
2375 )
2376 .unwrap();
2377 let args = Args {
2378 trust_override: Some(false),
2379 no_session: true,
2380 no_extensions: true,
2381 no_prompt_templates: true,
2382 no_context_files: true,
2383 system_prompt: Some("stable system prompt".into()),
2384 ..Args::default()
2385 };
2386 assert!(!should_load_js_packages(&args));
2387
2388 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2389 let before_resources = harness.get_resources().await.unwrap();
2390 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
2391 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
2392 let before_prompt = harness.get_system_prompt().await.unwrap();
2393 let before_tools: Vec<String> = harness
2394 .get_tools()
2395 .await
2396 .unwrap()
2397 .iter()
2398 .map(|tool| tool.tool.schema().name.clone())
2399 .collect();
2400 let before_bridge = context
2401 .action_bridge
2402 .lock()
2403 .unwrap()
2404 .as_ref()
2405 .unwrap()
2406 .clone();
2407
2408 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2409 let outcome = reload_extension_resources(&harness, &context).await;
2410
2411 assert!(outcome.had_warnings);
2412 assert!(outcome.summary.contains("Settings reload failed"));
2413 assert!(outcome.summary.contains("could not load global settings"));
2414 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
2415 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
2416 let after_tools: Vec<String> = harness
2417 .get_tools()
2418 .await
2419 .unwrap()
2420 .iter()
2421 .map(|tool| tool.tool.schema().name.clone())
2422 .collect();
2423 assert_eq!(after_tools, before_tools);
2424 let after_bridge = context
2425 .action_bridge
2426 .lock()
2427 .unwrap()
2428 .as_ref()
2429 .unwrap()
2430 .clone();
2431 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
2432 }
2433
2434 #[test]
2435 fn reload_preparation_rejects_settings_changed_during_derivation() {
2436 struct RestoreConfigDir(Option<std::ffi::OsString>);
2437
2438 impl Drop for RestoreConfigDir {
2439 fn drop(&mut self) {
2440 match self.0.take() {
2441 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2442 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2443 }
2444 }
2445 }
2446
2447 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2448 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2449 let tmp = tempfile::tempdir().unwrap();
2450 let agent = tmp.path().join("agent");
2451 let cwd = tmp.path().join("project");
2452 let first_skill_dir = tmp.path().join("first-skills");
2453 let second_skill_dir = tmp.path().join("second-skills");
2454 std::fs::create_dir_all(&agent).unwrap();
2455 std::fs::create_dir_all(&cwd).unwrap();
2456 std::fs::write(
2457 agent.join("settings.json"),
2458 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
2459 )
2460 .unwrap();
2461 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2462 let args = Args {
2463 trust_override: Some(false),
2464 no_extensions: true,
2465 no_prompt_templates: true,
2466 ..Args::default()
2467 };
2468
2469 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
2470 std::fs::write(
2471 agent.join("settings.json"),
2472 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
2473 )
2474 .unwrap();
2475 });
2476
2477 let error = result
2478 .err()
2479 .expect("settings mutation must fail preparation");
2480 assert!(error.contains("settings changed while reload inputs were being prepared"));
2481 }
2482
2483 #[tokio::test(flavor = "current_thread")]
2484 async fn reload_uses_frozen_settings_inputs_after_preparation() {
2485 struct RestoreConfigDir(Option<std::ffi::OsString>);
2486
2487 impl Drop for RestoreConfigDir {
2488 fn drop(&mut self) {
2489 match self.0.take() {
2490 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2491 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2492 }
2493 }
2494 }
2495
2496 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2497 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2498 let tmp = tempfile::tempdir().unwrap();
2499 let agent = tmp.path().join("agent");
2500 let cwd = tmp.path().join("project");
2501 let skill_dir = tmp.path().join("configured-skills");
2502 std::fs::create_dir_all(&agent).unwrap();
2503 std::fs::create_dir_all(&cwd).unwrap();
2504 std::fs::create_dir_all(&skill_dir).unwrap();
2505 std::fs::write(
2506 skill_dir.join("SKILL.md"),
2507 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
2508 )
2509 .unwrap();
2510 std::fs::write(
2511 agent.join("settings.json"),
2512 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2513 )
2514 .unwrap();
2515 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2516
2517 let resolved = crate::provider::resolve(
2518 Some("anthropic"),
2519 Some(crate::provider::DEFAULT_MODEL_ID),
2520 None,
2521 Some("test-key"),
2522 None,
2523 )
2524 .unwrap();
2525 let args = Args {
2526 trust_override: Some(false),
2527 no_session: true,
2528 no_extensions: true,
2529 no_prompt_templates: true,
2530 no_context_files: true,
2531 system_prompt: Some("stable system prompt".into()),
2532 ..Args::default()
2533 };
2534 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2535
2536 let outcome = reload_extension_resources_inner(&harness, &context, || {
2537 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2538 })
2539 .await;
2540
2541 assert!(!outcome.summary.contains("Settings reload failed"));
2542 assert_eq!(
2543 outcome.summary,
2544 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
2545 );
2546 let resources = harness.get_resources().await.unwrap();
2547 let skills = resources.skills.as_ref().unwrap();
2548 assert_eq!(skills.len(), 1);
2549 assert_eq!(skills[0].name, "frozen-skill");
2550 }
2551
2552 #[tokio::test]
2553 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
2554 let tmp = tempfile::tempdir().unwrap();
2555 let skill_path = tmp.path().join("explicit-skill.md");
2556 std::fs::write(
2557 &skill_path,
2558 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
2559 )
2560 .unwrap();
2561 let prompt_path = tmp.path().join("explicit-prompt.md");
2562 std::fs::write(
2563 &prompt_path,
2564 "---\ndescription: Explicit prompt\n---\nPrompt body",
2565 )
2566 .unwrap();
2567
2568 let args = Args {
2569 skill: vec![skill_path.clone()],
2570 prompt_template: vec![prompt_path.clone()],
2571 ..Args::default()
2572 };
2573 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
2574 let prompt_paths =
2575 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
2576 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
2577 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
2578
2579 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
2580 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
2581 assert_eq!(skills.skills[0].name, "explicit-skill");
2582
2583 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
2584 assert_eq!(
2585 prompts.prompt_templates.len(),
2586 1,
2587 "{:?}",
2588 prompts.diagnostics
2589 );
2590 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
2591 }
2592
2593 #[test]
2594 fn tool_policy_applies_to_rust_and_js_active_names() {
2595 let names = vec![
2596 "read".to_string(),
2597 "ask_user_question".to_string(),
2598 "write".to_string(),
2599 ];
2600
2601 let args = Args {
2602 tools: Some(vec!["read".into(), "ask_user_question".into()]),
2603 ..Args::default()
2604 };
2605 assert_eq!(
2606 filter_active_tool_names(names.clone(), &args),
2607 vec!["read", "ask_user_question"]
2608 );
2609
2610 let args = Args {
2611 exclude_tools: Some(vec!["ask_user_question".into()]),
2612 ..Args::default()
2613 };
2614 assert_eq!(
2615 filter_active_tool_names(names.clone(), &args),
2616 vec!["read", "write"]
2617 );
2618
2619 let args = Args {
2620 no_tools: true,
2621 ..Args::default()
2622 };
2623 assert!(filter_active_tool_names(names, &args).is_empty());
2624 }
2625
2626 #[test]
2627 fn select_ephemeral_when_no_session() {
2628 let args = Args {
2629 no_session: true,
2630 ..Args::default()
2631 };
2632 let cwd = Path::new("/tmp");
2633 assert!(matches!(
2634 select_session(&args, cwd),
2635 SessionSelection::Ephemeral
2636 ));
2637 }
2638
2639 #[test]
2640 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
2641 let defaults = Args::default();
2642 assert!(resolve_project_trust(
2643 &defaults,
2644 Path::new("C:/definitely-not-a-project")
2645 ));
2646
2647 let denied = Args {
2648 trust_override: Some(false),
2649 ..Args::default()
2650 };
2651 assert!(!resolve_project_trust(
2652 &denied,
2653 Path::new("C:/definitely-not-a-project")
2654 ));
2655 }
2656
2657 #[tokio::test(flavor = "current_thread")]
2658 async fn build_preserves_the_supplied_startup_project_snapshot() {
2659 struct RestoreConfigDir(Option<std::ffi::OsString>);
2660
2661 impl Drop for RestoreConfigDir {
2662 fn drop(&mut self) {
2663 match self.0.take() {
2664 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2665 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2666 }
2667 }
2668 }
2669
2670 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2671 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2672 let tmp = tempfile::tempdir().unwrap();
2673 let agent = tmp.path().join("agent");
2674 let cwd = tmp.path().join("project");
2675 std::fs::create_dir_all(&agent).unwrap();
2676 std::fs::create_dir_all(&cwd).unwrap();
2677 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2678
2679 let resolved = crate::provider::resolve(
2680 Some("anthropic"),
2681 Some(crate::provider::DEFAULT_MODEL_ID),
2682 None,
2683 Some("test-key"),
2684 None,
2685 )
2686 .unwrap();
2687 let args = Args {
2688 trust_override: Some(false),
2689 no_session: true,
2690 no_tools: true,
2691 no_extensions: true,
2692 no_skills: true,
2693 no_prompt_templates: true,
2694 no_context_files: true,
2695 system_prompt: Some("test prompt".into()),
2696 ..Args::default()
2697 };
2698
2699 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
2700
2701 assert_eq!(context.cwd, cwd);
2702 assert!(context.project_trusted);
2703 }
2704
2705 #[test]
2706 fn select_latest_for_continue_and_resume() {
2707 let args = Args {
2708 continue_session: true,
2709 ..Args::default()
2710 };
2711 let cwd = Path::new("/tmp");
2712 assert!(matches!(
2713 select_session(&args, cwd),
2714 SessionSelection::Latest
2715 ));
2716
2717 let args = Args {
2718 resume: true,
2719 ..Args::default()
2720 };
2721 assert!(matches!(
2722 select_session(&args, cwd),
2723 SessionSelection::Latest
2724 ));
2725 }
2726
2727 #[test]
2728 fn select_by_id_for_session_flag() {
2729 let args = Args {
2730 session: Some("01a02ece".into()),
2731 ..Args::default()
2732 };
2733 let cwd = Path::new("/tmp");
2734 assert!(matches!(
2735 select_session(&args, cwd),
2736 SessionSelection::ById { id } if id == "01a02ece"
2737 ));
2738 }
2739
2740 #[test]
2741 fn select_new_with_custom_dir() {
2742 let args = Args {
2743 session_dir: Some(PathBuf::from("/tmp/sess")),
2744 ..Args::default()
2745 };
2746 let cwd = Path::new("/tmp");
2747 match select_session(&args, cwd) {
2748 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
2749 other => panic!("expected New, got {other:?}"),
2750 }
2751 }
2752
2753 #[test]
2754 fn select_new_default_dir() {
2755 let args = Args::default();
2756 let cwd = Path::new("/proj");
2757 match select_session(&args, cwd) {
2758 SessionSelection::New { dir, .. } => {
2759 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
2760 }
2761 other => panic!("expected New, got {other:?}"),
2762 }
2763 }
2764
2765 #[test]
2766 fn default_session_dir_prefers_rpi_but_reads_legacy_pi() {
2767 let tmp = tempfile::tempdir().unwrap();
2768 let cwd = tmp.path();
2769 std::fs::create_dir_all(cwd.join(".pi/sessions")).unwrap();
2770 assert_eq!(default_session_dir(cwd), cwd.join(".pi/sessions"));
2771 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
2772 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
2773 }
2774
2775 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2776 async fn ephemeral_session_builds_roundtrips() {
2777 let s = ephemeral_session();
2781 let leaf = s.get_leaf_id().await;
2782 assert!(leaf.is_ok());
2783 }
2784
2785 }