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 ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
297 let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
298 runtime.clone(),
299 host_arc,
300 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
301 ui_dialog_mailbox.clone(),
302 );
303
304 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
306 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
307 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
308 let _registry = Arc::new(MutationQueueRegistry::new());
309 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
312
313 let tools = build_tools(&ctx, args);
314 let mut tools = tools;
315
316 let extension_session = if args.no_extensions {
324 ExtensionSession::none()
325 } else {
326 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
327 };
328 let js_extension_session = if !should_load_js_packages(args) {
329 None
330 } else {
331 let paths = js_extension_paths(args, cwd, project_trusted, &package_resources);
332 let js_context = serde_json::json!({
333 "cwd": cwd_str,
334 "theme": resolved.theme.clone(),
335 "currentModel": resolved.model.clone(),
336 "models": catalog.clone(),
337 "thinkingLevel": resolved.thinking_level,
338 });
339 match crate::js_extensions::JsExtensionSession::load_with_context(
340 &paths,
341 args.verbose,
342 js_context,
343 ) {
344 Ok(session) => session,
345 Err(error) => {
346 eprintln!("warning: JS/TS extensions were not loaded: {error}");
347 None
348 }
349 }
350 };
351 if js_extension_session.is_some() {
352 eprintln!(
353 "warning: enabled Pi JS/TS extensions execute with the current user's permissions"
354 );
355 }
356 if let Some(session) = &js_extension_session {
357 if let Err(error) =
358 session.enable_provider_runtime(resolved.provider.clone(), runtime.clone())
359 {
360 if args.verbose {
361 eprintln!("warning: JS provider runtime was not enabled: {error}");
362 }
363 }
364 }
365 if args.verbose {
366 if let Some(session) = &js_extension_session {
367 let info = session.backend_info();
368 eprintln!(
369 "JS extension backend: {} v{} ({})",
370 info.name,
371 info.api_version,
372 info.capability_names().join(", ")
373 );
374 }
375 if let Some(s) = extension_session.summary() {
376 eprintln!("extensions: {s}");
377 }
378 report_deferred_renderers(&extension_session);
379 }
380 merge_extension_tools(&mut tools, &extension_session, args);
381 if let Some(session) = &js_extension_session {
382 merge_js_extension_tools(&mut tools, session, args);
383 if args.verbose && !session.commands.is_empty() {
384 eprintln!("JS extension commands: {}", session.commands.join(", "));
385 }
386 }
387 let mut active = active_tool_names(&tools, args);
388 if let Some(session) = &js_extension_session {
393 let js_names = session.tool_names();
394 if let Some(js_active) = session.active_tools() {
395 active.retain(|name| !js_names.iter().any(|js| js == name));
396 active.extend(js_active.into_iter().filter(|name| {
397 js_names.iter().any(|js| js == name) && tool_name_allowed(name, args)
398 }));
399 }
400 }
401 active = filter_active_tool_names(active, args);
402
403 let selection = select_session(args, cwd);
405 let session = build_session(&selection, &cwd_str).await?;
406 if let Some(js) = &js_extension_session {
407 let session_id = session
408 .get_metadata()
409 .await
410 .ok()
411 .map(|metadata| metadata.id);
412 let leaf_id = session.get_leaf_id().await.ok().flatten();
413 if let Some(session_id) = session_id {
414 let branch = session
415 .find_entries_on_branch(&EntryQuery::default(), &BranchBounds::default())
416 .await
417 .ok()
418 .unwrap_or_default();
419 let branch_json =
420 serde_json::to_value(&branch).unwrap_or_else(|_| serde_json::json!([]));
421 let runtime_context = serde_json::json!({
422 "session": {
423 "id": session_id,
424 "leafId": leaf_id,
425 "branch": branch_json,
426 "entries": branch_json.clone(),
427 },
428 });
429 if let Err(error) = js.set_runtime_context(runtime_context) {
430 if args.verbose {
431 eprintln!("warning: could not sync JS session context: {error}");
432 }
433 }
434 }
435 }
436
437 let base_prompt = match args.system_prompt.as_deref() {
445 Some(explicit) => explicit.to_string(),
446 None if project_trusted => {
447 match discover_system_prompt_file_with_packages(cwd, &package_resources) {
448 Some(path) => std::fs::read_to_string(&path)
449 .unwrap_or_else(|_| default_system_prompt(&cwd_str)),
450 None => default_system_prompt(&cwd_str),
451 }
452 }
453 None => default_system_prompt(&cwd_str),
454 };
455
456 let mut append_texts: Vec<String> = Vec::new();
463 for extra in &args.append_system_prompt {
464 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
465 append_texts.push(text);
466 }
467 if args.append_system_prompt.is_empty() {
468 if let Some(path) = project_trusted
469 .then(|| discover_append_system_prompt_file_with_packages(cwd, &package_resources))
470 .flatten()
471 {
472 if let Ok(text) = std::fs::read_to_string(&path) {
473 append_texts.push(text);
474 }
475 }
476 }
477 let append_join = if append_texts.is_empty() {
478 None
479 } else {
480 Some(append_texts.join("\n\n"))
481 };
482
483 let agent_dir = crate::config::agent_dir().ok();
495
496 let discovered = extension_session
512 .snapshot_arc()
513 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
514 .unwrap_or_default();
515
516 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
517 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
518 if !args.no_skills {
519 let mut dirs = if args.dev_local_only {
520 project_skill_dirs(cwd)
521 } else if project_trusted {
522 skill_dirs(cwd)
523 } else {
524 global_skill_dirs()
525 };
526 dirs.extend(args.skill.iter().cloned());
527 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
528 if let Some(session) = &js_extension_session {
529 dirs.extend(session.resources.skill_paths.iter().cloned());
530 }
531 dirs.extend(package_resources.skill_dirs());
532 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
533 skills = result.skills;
534 skill_diags = result.diagnostics;
535 }
536
537 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
538 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
539 if !args.no_prompt_templates {
540 let mut dirs = if args.dev_local_only {
541 project_prompt_template_dirs(cwd)
542 } else if project_trusted {
543 prompt_template_dirs(cwd)
544 } else {
545 global_prompt_template_dirs()
546 };
547 dirs.extend(args.prompt_template.iter().cloned());
548 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
549 if let Some(session) = &js_extension_session {
550 dirs.extend(session.resources.prompt_paths.iter().cloned());
551 }
552 dirs.extend(package_resources.prompt_dirs());
553 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
554 prompt_templates = result.prompt_templates;
555 prompt_diags = result.diagnostics;
556 }
557
558 let context_block = if args.no_context_files || !project_trusted {
559 String::new()
560 } else {
561 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
566 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
567 format_project_context(&files)
568 };
569
570 if args.verbose {
572 for d in &package_resources.diagnostics {
573 eprintln!("warning: package {}: {}", d.spec, d.message);
574 }
575 for d in &skill_diags {
576 eprintln!(
577 "warning: skill {} ({}): {}",
578 d.path,
579 d.code.as_str(),
580 d.message
581 );
582 }
583 for d in &prompt_diags {
584 eprintln!(
585 "warning: prompt template {} ({}): {}",
586 d.path,
587 d.code.as_str(),
588 d.message
589 );
590 }
591 }
592
593 let system_prompt = compose_system_prompt(
602 Some(&base_prompt),
603 &[], if context_block.is_empty() {
605 None
606 } else {
607 Some(&context_block)
608 },
609 append_join.as_deref(),
610 );
611
612 if args.debug_system_prompt {
621 eprintln!("=== --debug-system-prompt ===");
622 let base_src = if args.system_prompt.is_some() {
623 "--system-prompt"
624 } else if discover_system_prompt_file_with_packages(cwd, &package_resources).is_some() {
625 "SYSTEM.md"
626 } else {
627 "default"
628 };
629 eprintln!("[base source: {base_src}]");
630 eprintln!("--- base ---\n{base_prompt}");
631 if let Some(append) = append_join.as_deref() {
632 eprintln!("--- append ---\n{append}");
633 } else {
634 eprintln!("--- append: (none) ---");
635 }
636 if context_block.is_empty() {
637 eprintln!("--- context: (none) ---");
638 } else {
639 eprintln!("--- context ---{context_block}");
640 }
641 let visible_skills = skills
642 .iter()
643 .filter(|s| s.disable_model_invocation != Some(true))
644 .count();
645 eprintln!(
646 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
647 skills.len(),
648 visible_skills,
649 skills.len() - visible_skills
650 );
651 for s in &skills {
652 let hidden = if s.disable_model_invocation == Some(true) {
653 " [hidden]"
654 } else {
655 ""
656 };
657 eprintln!(" {}{hidden} — {}", s.name, s.description);
658 }
659 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
660 for t in &prompt_templates {
661 eprintln!(" /{}", t.name);
662 }
663 eprintln!(
667 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
668 discovered.skill_paths.len(),
669 discovered.prompt_paths.len(),
670 discovered.theme_paths.len(),
671 );
672 for p in &discovered.skill_paths {
673 eprintln!(" skill: {p}");
674 }
675 for p in &discovered.prompt_paths {
676 eprintln!(" prompt: {p}");
677 }
678 eprintln!(
679 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
680 );
681 eprintln!("=== end --debug-system-prompt ===");
682 }
683
684 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
689 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
690 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
694
695 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
704 Some(snapshot) => {
705 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
706 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
707 }
708 None => broadcast_emitter,
709 };
710
711 let options = AgentHarnessOptions {
712 model: resolved.model.clone(),
713 thinking_level: resolved.thinking_level,
714 active_tool_names: active,
715 tools,
716 system_prompt: Some(system_prompt),
717 resources: AgentHarnessResources {
718 skills: if skills.is_empty() {
719 None
720 } else {
721 Some(skills)
722 },
723 prompt_templates: if prompt_templates.is_empty() {
724 None
725 } else {
726 Some(prompt_templates)
727 },
728 },
729 allow_existing_session: matches!(
732 selection,
733 SessionSelection::Latest
734 | SessionSelection::ById { .. }
735 | SessionSelection::ByExactId { .. }
736 | SessionSelection::Fork { .. }
737 ),
738 stream_options: AgentHarnessStreamOptions {
739 timeout: args.timeout,
740 ..Default::default()
741 },
742 retry: RetryPolicy::default(),
743 compaction: CompactionSettings::default(),
744 steering_mode: Default::default(),
745 follow_up_mode: Default::default(),
746 tool_execution: HarnessToolExecution::default(),
747 drive: DrivingMode::default(),
748 session,
749 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
757 to_provider_messages: None,
758 entry_projectors: Default::default(),
759 agent_emitter: Some(emitter),
760 before_tool_call: None,
766 after_tool_call: None,
767 transform_context: None,
768 entry_transforms: Vec::new(),
769 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
775 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
776 };
777
778 let harness = match AgentHarness::create(options).await {
779 Ok(h) => {
780 crate::extensions_actions::HarnessActionHost::set_harness(
783 &harness_cell,
784 Arc::new(h.clone()),
785 );
786 h
787 }
788 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
789 };
790
791 let reload_context = ReloadContext {
798 extension_session: Arc::new(Mutex::new(extension_session)),
799 js_extension_session: js_extension_session.clone(),
800 package_resources: Arc::new(package_resources.clone()),
801 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
802 catalog,
803 gateway: resolved.provider.clone(),
804 runtime: runtime.clone(),
805 cwd: cwd.to_path_buf(),
806 project_trusted,
807 args: args.clone(),
808 resolved_model: resolved.model.clone(),
809 broadcast: broadcast_for_context,
810 mailbox: reload_mailbox,
811 ui_dialog: ui_dialog_mailbox,
812 dev_extension: None,
813 };
814
815 Ok((harness, event_rx, reload_context))
816}
817
818pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
863
864pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
869
870#[derive(Clone)]
878pub struct ReloadContext {
879 pub extension_session: ExtensionSessionCell,
881 pub js_extension_session: Option<crate::js_extensions::JsExtensionSession>,
883 pub package_resources: Arc<crate::packages::PackageResources>,
887 pub action_bridge: ActionBridgeCell,
889 pub catalog: Vec<rpi_ai::Model>,
894 pub gateway: Arc<dyn Provider>,
897 pub runtime: tokio::runtime::Handle,
901 pub cwd: PathBuf,
903 pub project_trusted: bool,
907 pub args: Args,
913 pub resolved_model: rpi_ai::Model,
916 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
922 pub mailbox: rpi_extensions::ReloadMailbox,
929 pub ui_dialog: rpi_extensions::UiDialogMailbox,
935 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
939}
940
941pub struct ReloadOutcome {
944 pub summary: String,
947 pub had_warnings: bool,
949 pub details: Vec<String>,
954 pub had_errors: bool,
958}
959
960struct PreparedReloadInputs {
961 package_resources: crate::packages::PackageResources,
962 extension_dirs: Vec<PathBuf>,
963 skill_base_dirs: Vec<PathBuf>,
964 prompt_base_dirs: Vec<PathBuf>,
965}
966
967fn append_reload_resource_paths(
972 mut paths: Vec<PathBuf>,
973 explicit: &[PathBuf],
974 discovered: &[String],
975 js_paths: &[PathBuf],
976 package_paths: &[PathBuf],
977) -> Vec<PathBuf> {
978 paths.extend(explicit.iter().cloned());
979 paths.extend(discovered.iter().map(PathBuf::from));
980 paths.extend(js_paths.iter().cloned());
981 paths.extend(package_paths.iter().cloned());
982 paths
983}
984
985pub async fn reload_extension_resources(
997 harness: &AgentHarness,
998 ctx: &ReloadContext,
999) -> ReloadOutcome {
1000 reload_extension_resources_inner(harness, ctx, || {}).await
1001}
1002
1003async fn reload_extension_resources_inner<F>(
1004 harness: &AgentHarness,
1005 ctx: &ReloadContext,
1006 after_prepare: F,
1007) -> ReloadOutcome
1008where
1009 F: FnOnce() + Send,
1010{
1011 let mut effective_args = ctx.args.clone();
1016 if let Some(dev) = &ctx.dev_extension {
1017 if let Err(error) = dev
1018 .rebuild()
1019 .and_then(|_| dev.apply_to_args(&mut effective_args))
1020 {
1021 return ReloadOutcome {
1022 summary: format!(
1023 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1024 dev.package_name()
1025 ),
1026 had_warnings: true,
1027 details: Vec::new(),
1028 had_errors: true,
1029 };
1030 }
1031 }
1032 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1033 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1034 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1035 Ok(prepared) => prepared,
1036 Err(error) => {
1037 return ReloadOutcome {
1038 summary: format!(
1039 "Settings reload failed: {error}. Keeping the currently loaded resources."
1040 ),
1041 had_warnings: true,
1042 details: Vec::new(),
1043 had_errors: true,
1044 };
1045 }
1046 };
1047 after_prepare();
1048 let PreparedReloadInputs {
1049 package_resources,
1050 extension_dirs,
1051 skill_base_dirs,
1052 prompt_base_dirs,
1053 } = prepared;
1054 let mut warnings = false;
1055
1056 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1063 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1064 Some(b) => b.clone_host(),
1065 None => {
1066 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1069 ctx.catalog.clone(),
1070 ctx.cwd.clone(),
1071 ctx.runtime.clone(),
1072 ctx.args.unknown_flags.clone(),
1073 );
1074 crate::extensions_actions::HarnessActionHost::set_harness(
1075 &_cell,
1076 Arc::new(harness.clone()),
1077 );
1078 Arc::new(action_host)
1079 }
1080 };
1081
1082 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1083 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1084 ctx.runtime.clone(),
1085 host,
1086 reload_cb,
1087 ctx.ui_dialog.clone(),
1088 );
1089
1090 let extension_session = if effective_args.no_extensions {
1091 rpi_extensions::ExtensionSession::none()
1092 } else {
1093 load_extensions_from_dirs(
1094 &effective_args,
1095 &extension_dirs,
1096 Some(Arc::clone(&fresh_bridge)),
1097 )
1098 };
1099 if extension_session.is_empty() && !effective_args.no_extensions {
1100 }
1103 if effective_args.verbose {
1104 if let Some(s) = extension_session.summary() {
1105 eprintln!("reload: {s}");
1106 }
1107 report_deferred_renderers(&extension_session);
1108 }
1109
1110 {
1124 let mut session_guard = ctx.extension_session.lock().unwrap();
1125 let old_session = std::mem::replace(
1126 &mut *session_guard,
1127 rpi_extensions::ExtensionSession::none(),
1128 );
1129 if let Some(old_snap) = old_session.snapshot_arc() {
1130 old_snap.active_flag().store(false, Ordering::SeqCst);
1134 }
1135 }
1139 if let Some(old_b) = old_bridge {
1140 old_b.invalidate();
1141 }
1142
1143 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1146 *ctx.extension_session.lock().unwrap() = extension_session.clone();
1147
1148 let discovered = extension_session
1150 .snapshot_arc()
1151 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1152 .unwrap_or_default();
1153
1154 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1156 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1157
1158 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1159 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1160 if !effective_args.no_skills {
1161 let js_paths: &[PathBuf] = ctx
1165 .js_extension_session
1166 .as_ref()
1167 .map(|js| js.resources.skill_paths.as_slice())
1168 .unwrap_or(&[]);
1169 let package_paths = package_resources.skill_dirs();
1170 let dirs = append_reload_resource_paths(
1171 skill_base_dirs,
1172 &effective_args.skill,
1173 &discovered.skill_paths,
1174 js_paths,
1175 &package_paths,
1176 );
1177 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1178 skills = result.skills;
1179 skill_diags = result.diagnostics;
1180 }
1181
1182 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1183 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1184 if !effective_args.no_prompt_templates {
1185 let js_paths: &[PathBuf] = ctx
1186 .js_extension_session
1187 .as_ref()
1188 .map(|js| js.resources.prompt_paths.as_slice())
1189 .unwrap_or(&[]);
1190 let package_paths = package_resources.prompt_dirs();
1191 let dirs = append_reload_resource_paths(
1192 prompt_base_dirs,
1193 &effective_args.prompt_template,
1194 &discovered.prompt_paths,
1195 js_paths,
1196 &package_paths,
1197 );
1198 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1199 prompt_templates = result.prompt_templates;
1200 prompt_diags = result.diagnostics;
1201 }
1202
1203 let context_block = if effective_args.no_context_files {
1204 String::new()
1205 } else {
1206 let agent_dir = crate::config::agent_dir().ok();
1207 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1208 let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
1209 format_project_context(&files)
1210 };
1211
1212 let mut details: Vec<String> = Vec::new();
1213 if !skill_diags.is_empty()
1214 || !prompt_diags.is_empty()
1215 || !package_resources.diagnostics.is_empty()
1216 {
1217 warnings = true;
1218 for d in &package_resources.diagnostics {
1223 details.push(format!("warning: package {}: {}", d.spec, d.message));
1224 }
1225 for d in &skill_diags {
1226 details.push(format!(
1227 "warning: skill {} ({}): {}",
1228 d.path,
1229 d.code.as_str(),
1230 d.message
1231 ));
1232 }
1233 for d in &prompt_diags {
1234 details.push(format!(
1235 "warning: prompt template {} ({}): {}",
1236 d.path,
1237 d.code.as_str(),
1238 d.message
1239 ));
1240 }
1241 }
1242
1243 let base_prompt = match effective_args.system_prompt.as_deref() {
1245 Some(explicit) => explicit.to_string(),
1246 None => match discover_system_prompt_file_with_packages(&ctx.cwd, &package_resources) {
1247 Some(path) => {
1248 std::fs::read_to_string(&path).unwrap_or_else(|_| default_system_prompt(&cwd_str))
1249 }
1250 None => default_system_prompt(&cwd_str),
1251 },
1252 };
1253 let mut append_texts: Vec<String> = Vec::new();
1254 for extra in &effective_args.append_system_prompt {
1255 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
1256 append_texts.push(text);
1257 }
1258 if effective_args.append_system_prompt.is_empty() {
1259 if let Some(path) =
1260 discover_append_system_prompt_file_with_packages(&ctx.cwd, &package_resources)
1261 {
1262 if let Ok(text) = std::fs::read_to_string(&path) {
1263 append_texts.push(text);
1264 }
1265 }
1266 }
1267 let append_join = if append_texts.is_empty() {
1268 None
1269 } else {
1270 Some(append_texts.join("\n\n"))
1271 };
1272 let system_prompt = compose_system_prompt(
1273 Some(&base_prompt),
1274 &[],
1275 if context_block.is_empty() {
1276 None
1277 } else {
1278 Some(&context_block)
1279 },
1280 append_join.as_deref(),
1281 );
1282
1283 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1285 Some(snapshot) => {
1286 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1287 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1288 }
1289 None => ctx.broadcast.clone(),
1290 };
1291
1292 let resources = AgentHarnessResources {
1294 skills: if skills.is_empty() {
1295 None
1296 } else {
1297 Some(skills.clone())
1298 },
1299 prompt_templates: if prompt_templates.is_empty() {
1300 None
1301 } else {
1302 Some(prompt_templates.clone())
1303 },
1304 };
1305 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1306 let _ = harness.set_resources(resources).await;
1307 let _ = harness.set_agent_emitter(Some(emitter)).await;
1308 let _ = harness
1309 .set_models(build_models_with_extensions_for_reload(
1310 &ctx.gateway,
1311 &extension_session,
1312 ctx.runtime.clone(),
1313 ))
1314 .await;
1315 let _ = harness
1316 .set_provider_hooks(
1317 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1318 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1319 )
1320 .await;
1321
1322 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1326 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1327 let mut tools = build_tools(&tool_ctx, &effective_args);
1328 merge_extension_tools(&mut tools, &extension_session, &effective_args);
1329 if let Some(js) = &ctx.js_extension_session {
1330 merge_js_extension_tools(&mut tools, js, &effective_args);
1331 }
1332 let mut active = active_tool_names(&tools, &effective_args);
1333 if let Some(js) = &ctx.js_extension_session {
1334 let js_names = js.tool_names();
1335 if let Some(js_active) = js.active_tools() {
1336 active.retain(|name| {
1337 tool_name_allowed(name, &effective_args)
1338 && !js_names.iter().any(|js_name| js_name == name)
1339 });
1340 active.extend(js_active.into_iter().filter(|name| {
1341 js_names.iter().any(|js_name| js_name == name)
1342 && tool_name_allowed(name, &effective_args)
1343 }));
1344 }
1345 }
1346 active = filter_active_tool_names(active, &effective_args);
1347 let _ = harness.set_tools(tools, Some(active)).await;
1348
1349 let summary = format!(
1350 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1351 extension_session.loaded_paths().len(),
1352 skills.len(),
1353 prompt_templates.len(),
1354 );
1355 ReloadOutcome {
1356 summary,
1357 had_warnings: warnings,
1358 details,
1359 had_errors: false,
1362 }
1363}
1364
1365#[derive(Clone, Debug, PartialEq, Eq)]
1366struct ReloadSettingsFields {
1367 packages: Option<Vec<crate::settings::PackageSetting>>,
1368 npm_command: Option<Vec<String>>,
1369 skill_dirs: Option<Vec<String>>,
1370 prompt_dirs: Option<Vec<String>>,
1371 extension_dirs: Option<Vec<String>>,
1372}
1373
1374impl From<crate::settings::Settings> for ReloadSettingsFields {
1375 fn from(settings: crate::settings::Settings) -> Self {
1376 Self {
1377 packages: settings.packages,
1378 npm_command: settings.npm_command,
1379 skill_dirs: settings.skill_dirs,
1380 prompt_dirs: settings.prompt_dirs,
1381 extension_dirs: settings.extension_dirs,
1382 }
1383 }
1384}
1385
1386#[derive(Clone, Debug, PartialEq, Eq)]
1387struct ReloadSettingsSnapshot {
1388 global: ReloadSettingsFields,
1389 project: Option<ReloadSettingsFields>,
1390}
1391
1392fn reload_reads_settings(args: &Args) -> bool {
1393 !args.dev_local_only
1394 && (should_load_js_packages(args)
1395 || !args.no_extensions
1396 || !args.no_skills
1397 || !args.no_prompt_templates)
1398}
1399
1400fn load_reload_settings_snapshot(
1404 args: &Args,
1405 cwd: &Path,
1406 project_trusted: bool,
1407) -> Result<Option<ReloadSettingsSnapshot>, String> {
1408 if !reload_reads_settings(args) {
1409 return Ok(None);
1410 }
1411 let global = crate::settings::load_settings()
1412 .map_err(|error| format!("could not load global settings: {error}"))?
1413 .into();
1414 let project = if project_trusted {
1415 crate::settings::load_active_project_settings(cwd)
1416 .map_err(|error| format!("could not load project settings: {error}"))?
1417 .map(|(_, settings)| settings.into())
1418 } else {
1419 None
1420 };
1421 Ok(Some(ReloadSettingsSnapshot { global, project }))
1422}
1423
1424fn validate_settings_for_reload(
1427 args: &Args,
1428 cwd: &Path,
1429 project_trusted: bool,
1430) -> Result<(), String> {
1431 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1432}
1433
1434fn prepare_reload_inputs(
1435 args: &Args,
1436 cwd: &Path,
1437 project_trusted: bool,
1438) -> Result<PreparedReloadInputs, String> {
1439 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1440}
1441
1442fn prepare_reload_inputs_inner<F>(
1443 args: &Args,
1444 cwd: &Path,
1445 project_trusted: bool,
1446 before_verify: F,
1447) -> Result<PreparedReloadInputs, String>
1448where
1449 F: FnOnce(),
1450{
1451 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1452
1453 let package_resources = if args.dev_local_only {
1454 crate::packages::PackageResources::default()
1455 } else {
1456 package_resources_for(args, cwd, project_trusted)
1457 };
1458
1459 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1460 Vec::new()
1461 } else if project_trusted {
1462 extension_dirs(cwd)
1463 } else {
1464 global_extension_dirs()
1465 };
1466 if !args.no_extensions {
1467 extension_dirs.extend(args.extensions_dir.iter().cloned());
1468 }
1469
1470 let skill_base_dirs = if args.no_skills {
1471 Vec::new()
1472 } else if args.dev_local_only {
1473 project_skill_dirs(cwd)
1474 } else if project_trusted {
1475 skill_dirs(cwd)
1476 } else {
1477 global_skill_dirs()
1478 };
1479
1480 let prompt_base_dirs = if args.no_prompt_templates {
1481 Vec::new()
1482 } else if args.dev_local_only {
1483 project_prompt_template_dirs(cwd)
1484 } else if project_trusted {
1485 prompt_template_dirs(cwd)
1486 } else {
1487 global_prompt_template_dirs()
1488 };
1489
1490 before_verify();
1491 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1492 if settings_before != settings_after {
1493 return Err(
1494 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1495 );
1496 }
1497
1498 Ok(PreparedReloadInputs {
1499 package_resources,
1500 extension_dirs,
1501 skill_base_dirs,
1502 prompt_base_dirs,
1503 })
1504}
1505
1506fn build_models_with_extensions_for_reload(
1511 gateway: &Arc<dyn Provider>,
1512 extension_session: &ExtensionSession,
1513 runtime: tokio::runtime::Handle,
1514) -> Vec<Arc<dyn Provider>> {
1515 let mut models: Vec<Arc<dyn Provider>> = vec![gateway.clone()];
1516 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1517 models.extend(pluggable);
1518 models
1519}
1520
1521fn report_deferred_renderers(session: &ExtensionSession) {
1525 let Some(snap) = session.snapshot_arc() else {
1526 return;
1527 };
1528 let all = snap.renderers();
1529 let markdown = all
1530 .iter()
1531 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1532 .count();
1533 let message = all
1534 .iter()
1535 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1536 .count();
1537 let entry = all
1538 .iter()
1539 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1540 .count();
1541 if markdown + message + entry == 0 {
1542 return;
1543 }
1544 eprintln!(
1545 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1546 markdown, message, entry
1547 );
1548}
1549
1550#[derive(Debug, thiserror::Error)]
1552pub enum BuildError {
1553 #[error("Could not create the session directory: {0}")]
1554 SessionDir(String),
1555 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1556 SessionNotFound { requested: String, dir: String },
1557 #[error("Could not build the harness: {0}")]
1558 HarnessCreate(String),
1559}
1560
1561fn build_models_with_extensions(
1571 resolved: &ResolvedModel,
1572 extension_session: &ExtensionSession,
1573 runtime: tokio::runtime::Handle,
1574) -> Vec<Arc<dyn Provider>> {
1575 let mut models: Vec<Arc<dyn Provider>> = vec![resolved.provider.clone() as Arc<dyn Provider>];
1576 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1577 models.extend(pluggable);
1578 models
1579}
1580
1581pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1584 if let Some(override_value) = args.trust_override {
1585 return override_value;
1586 }
1587 crate::config::project_trust_decision(cwd)
1588 .ok()
1589 .flatten()
1590 .unwrap_or(true)
1591}
1592
1593fn load_extensions(
1601 args: &Args,
1602 cwd: &Path,
1603 project_trusted: bool,
1604 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1605) -> ExtensionSession {
1606 let mut dirs = if args.dev_local_only {
1607 Vec::new()
1608 } else if project_trusted {
1609 extension_dirs(cwd)
1610 } else {
1611 global_extension_dirs()
1612 };
1613 dirs.extend(args.extensions_dir.iter().cloned());
1614 load_extensions_from_dirs(args, &dirs, action_bridge)
1615}
1616
1617fn load_extensions_from_dirs(
1618 args: &Args,
1619 dirs: &[PathBuf],
1620 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1621) -> ExtensionSession {
1622 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1623 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1630}
1631
1632fn js_extension_paths(
1633 args: &Args,
1634 cwd: &Path,
1635 project_trusted: bool,
1636 packages: &crate::packages::PackageResources,
1637) -> Vec<PathBuf> {
1638 if args.dev_local_only {
1639 return Vec::new();
1640 }
1641 let mut paths = packages.extension_paths();
1642 let discovered_dirs = if project_trusted {
1643 extension_dirs(cwd)
1644 } else {
1645 global_extension_dirs()
1646 };
1647 for dir in discovered_dirs {
1648 if let Ok(entries) = std::fs::read_dir(dir) {
1649 paths.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
1650 matches!(
1651 path.extension()
1652 .and_then(|ext| ext.to_str())
1653 .map(|ext| ext.to_ascii_lowercase())
1654 .as_deref(),
1655 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1656 )
1657 }));
1658 }
1659 }
1660 paths.extend(
1661 args.extension
1662 .iter()
1663 .filter(|path| {
1664 matches!(
1665 path.extension()
1666 .and_then(|ext| ext.to_str())
1667 .map(|ext| ext.to_ascii_lowercase())
1668 .as_deref(),
1669 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1670 )
1671 })
1672 .cloned(),
1673 );
1674 paths
1675}
1676
1677fn merge_extension_tools(tools: &mut Vec<HarnessTool>, session: &ExtensionSession, args: &Args) {
1683 let Some(snapshot) = session.snapshot() else {
1684 return;
1685 };
1686 for et in snapshot.tools() {
1687 let name = &et.tool.name;
1688 if !tool_name_allowed(name, args) {
1689 continue;
1690 }
1691 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive());
1692 let harness_tool = HarnessTool::new(Arc::new(adapter));
1693 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1694 Some(slot) => *slot = harness_tool,
1695 None => tools.push(harness_tool),
1696 }
1697 }
1698}
1699
1700fn merge_js_extension_tools(
1701 tools: &mut Vec<HarnessTool>,
1702 session: &crate::js_extensions::JsExtensionSession,
1703 args: &Args,
1704) {
1705 for adapter in session.tools() {
1706 let name = adapter.schema().name.clone();
1707 if !tool_name_allowed(&name, args) {
1708 continue;
1709 }
1710 let harness_tool = HarnessTool::new(Arc::new(adapter));
1711 match tools
1712 .iter_mut()
1713 .find(|tool| tool.tool.schema().name == name)
1714 {
1715 Some(slot) => *slot = harness_tool,
1716 None => tools.push(harness_tool),
1717 }
1718 }
1719}
1720
1721pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1728 use rpi_tools::tools::bash::BashToolOptions;
1729 let default = std::env::var("RPI_BASH_TIMEOUT")
1730 .ok()
1731 .and_then(|v| v.parse::<f64>().ok())
1732 .unwrap_or(120.0);
1733 BashToolOptions {
1734 command_prefix: None,
1735 default_timeout: Some(default),
1736 }
1737}
1738
1739fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1740 if args.no_tools {
1741 return Vec::new();
1742 }
1743 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1746 ("read", HarnessTool::new(create_read_tool(ctx, None))),
1747 (
1748 "bash",
1749 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1750 ),
1751 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1752 ("write", HarnessTool::new(create_write_tool(ctx))),
1753 ("docs", HarnessTool::new(create_docs_tool())),
1754 ];
1755
1756 if args.no_builtin_tools {
1760 all.clear();
1761 }
1762
1763 if let Some(allow) = &args.tools {
1765 all.retain(|(name, _)| allow.iter().any(|a| a == name));
1766 }
1767 if let Some(deny) = &args.exclude_tools {
1769 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1770 }
1771
1772 all.into_iter()
1773 .map(|(_, t)| t.with_replay(ToolReplay::Safe))
1774 .collect()
1775}
1776
1777fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1781 filter_active_tool_names(
1782 tools.iter().map(|tool| tool.tool.schema().name.clone()),
1783 args,
1784 )
1785}
1786
1787pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
1791 if args.no_tools {
1792 return false;
1793 }
1794 if args
1795 .tools
1796 .as_ref()
1797 .is_some_and(|allow| !allow.iter().any(|value| value == name))
1798 {
1799 return false;
1800 }
1801 if args
1802 .exclude_tools
1803 .as_ref()
1804 .is_some_and(|deny| deny.iter().any(|value| value == name))
1805 {
1806 return false;
1807 }
1808 true
1809}
1810
1811pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
1812where
1813 I: IntoIterator<Item = String>,
1814{
1815 names
1816 .into_iter()
1817 .filter(|name| tool_name_allowed(name, args))
1818 .collect()
1819}
1820
1821async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1823 match selection {
1824 SessionSelection::Ephemeral => Ok(ephemeral_session()),
1825 SessionSelection::New { dir, .. } => {
1826 std::fs::create_dir_all(dir)
1829 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1830 let session = create_jsonl_session(dir, cwd)
1831 .await
1832 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1833 Ok(session)
1834 }
1835 SessionSelection::Latest
1836 | SessionSelection::ById { .. }
1837 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
1838 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
1839 }
1840}
1841
1842async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1848 match selection {
1852 SessionSelection::Latest => {
1853 let metas = list_session_metadata(cwd).await?;
1854 let Some(meta) = metas.first() else {
1855 return Err(BuildError::SessionNotFound {
1856 requested: "the most recent session".to_string(),
1857 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1858 });
1859 };
1860 open_session(meta, cwd).await
1861 }
1862 SessionSelection::ById { id } => open_session_by_id(id, cwd).await.map_err(|e| match e {
1863 OpenError::NotFound { requested } => BuildError::SessionNotFound {
1864 requested,
1865 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1866 },
1867 OpenError::Other(msg) => BuildError::SessionDir(msg),
1868 }),
1869 SessionSelection::ByExactId { id } => {
1870 let metas = list_session_metadata(cwd).await?;
1873 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
1874 return open_session(meta, cwd).await;
1875 }
1876 let dir = default_session_dir(Path::new(cwd));
1877 std::fs::create_dir_all(&dir)
1878 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1879 create_jsonl_session_with_id(&dir, cwd, Some(id.clone()))
1880 .await
1881 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
1882 }
1883 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
1884 }
1885}
1886
1887async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
1890 use rpi_harness::session::jsonl::{
1891 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1892 };
1893 use rpi_harness::session::types::{ForkOptions, SessionStorage};
1894 use rpi_tools::FileSystem;
1895
1896 let dir = default_session_dir(Path::new(cwd));
1897 std::fs::create_dir_all(&dir)
1898 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1899 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1900 let fs: Arc<dyn FileSystem> = env.clone();
1901 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1902 fs: fs.clone(),
1903 sessions_root: dir.to_string_lossy().into_owned(),
1904 clock: Arc::new(SystemClock),
1905 ids: Arc::new(DefaultIdGenerator::new()),
1906 });
1907 let metas = repo
1908 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
1909 .await
1910 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
1911 let source_meta = metas
1912 .iter()
1913 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
1914 .ok_or_else(|| BuildError::SessionNotFound {
1915 requested: format!("--fork {source}"),
1916 dir: dir.display().to_string(),
1917 })?;
1918 let fork_storage = repo
1919 .fork_typed(
1920 source_meta,
1921 &JsonlSessionCreateOptions {
1922 id: None,
1923 parent_session_id: Some(source_meta.id.clone()),
1924 cwd: cwd.to_string(),
1925 metadata: None,
1926 },
1927 &ForkOptions::default(),
1928 )
1929 .await
1930 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
1931 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
1932 Ok(Session::new(storage_arc, None))
1933}
1934
1935pub enum OpenError {
1938 NotFound { requested: String },
1940 Other(String),
1942}
1943
1944impl std::fmt::Display for OpenError {
1945 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1946 match self {
1947 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
1948 OpenError::Other(msg) => write!(f, "{msg}"),
1949 }
1950 }
1951}
1952
1953pub async fn list_session_metadata(
1956 cwd: &str,
1957) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
1958 use rpi_harness::session::jsonl::{
1959 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1960 };
1961 use rpi_tools::FileSystem;
1962
1963 let dir = default_session_dir(Path::new(cwd));
1964 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1965 let fs: Arc<dyn FileSystem> = env.clone();
1966 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1967 fs: fs.clone(),
1968 sessions_root: dir.to_string_lossy().into_owned(),
1969 clock: Arc::new(SystemClock),
1970 ids: Arc::new(DefaultIdGenerator::new()),
1971 });
1972 repo.list_typed(&JsonlSessionListOptions::default())
1973 .await
1974 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
1975}
1976
1977pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
1981 let metas = list_session_metadata(cwd)
1982 .await
1983 .map_err(|e| OpenError::Other(e.to_string()))?;
1984 let Some(meta) = metas
1985 .iter()
1986 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
1987 else {
1988 return Err(OpenError::NotFound {
1989 requested: format!("session {id}"),
1990 });
1991 };
1992 open_session(meta, cwd)
1993 .await
1994 .map_err(|e| OpenError::Other(e.to_string()))
1995}
1996
1997pub(crate) async fn fork_session_storage(
2004 harness: &AgentHarness,
2005 cwd: &str,
2006) -> Result<Session, String> {
2007 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2008 use rpi_tools::FileSystem;
2009
2010 let dir = default_session_dir(Path::new(cwd));
2011 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2012 let fs: Arc<dyn FileSystem> = env.clone();
2013 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2014 fs,
2015 sessions_root: dir.to_string_lossy().into_owned(),
2016 clock: Arc::new(SystemClock),
2017 ids: Arc::new(DefaultIdGenerator::new()),
2018 });
2019 let id = harness.session().storage().metadata().id.clone();
2022 let metas = list_session_metadata(cwd)
2023 .await
2024 .map_err(|e| e.to_string())?;
2025 let Some(source) = metas.iter().find(|m| m.id == id) else {
2026 return Err(format!("current session {id} not found on disk"));
2027 };
2028 let fork_storage = repo
2029 .fork_typed(
2030 source,
2031 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2032 id: None,
2033 parent_session_id: Some(source.id.clone()),
2034 cwd: cwd.to_string(),
2035 metadata: None,
2036 },
2037 &rpi_harness::session::types::ForkOptions::default(),
2038 )
2039 .await
2040 .map_err(|e| e.to_string())?;
2041 Ok(Session::new(Arc::new(fork_storage), None))
2042}
2043
2044async fn open_session(
2047 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2048 cwd: &str,
2049) -> Result<Session, BuildError> {
2050 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2051 use rpi_harness::session::types::SessionStorage;
2052 use rpi_tools::FileSystem;
2053
2054 let dir = default_session_dir(Path::new(cwd));
2055 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2056 let fs: Arc<dyn FileSystem> = env.clone();
2057 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2058 fs: fs.clone(),
2059 sessions_root: dir.to_string_lossy().into_owned(),
2060 clock: Arc::new(SystemClock),
2061 ids: Arc::new(DefaultIdGenerator::new()),
2062 });
2063 let storage = repo
2064 .open_by_jsonl_metadata(meta)
2065 .await
2066 .map_err(|e| BuildError::SessionDir(format!("open {}: {e}", meta.path)))?;
2067 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2068 Ok(Session::new(storage_arc, None))
2069}
2070
2071fn ephemeral_session() -> Session {
2073 let storage = Arc::new(InMemorySessionStorage::new(
2074 SessionMetadata {
2075 id: "ephemeral".into(),
2076 created_at: 0,
2077 parent_session_id: None,
2078 },
2079 Arc::new(SystemClock),
2080 Arc::new(DefaultIdGenerator::new()),
2081 ));
2082 Session::new(storage, None)
2083}
2084
2085pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2096 create_jsonl_session_with_id(dir, cwd, None).await
2097}
2098
2099pub(crate) async fn create_jsonl_session_with_id(
2103 dir: &Path,
2104 cwd: &str,
2105 id: Option<String>,
2106) -> Result<Session, String> {
2107 use rpi_harness::session::jsonl::{
2108 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2109 };
2110 use rpi_tools::FileSystem;
2111
2112 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2115 let fs: Arc<dyn FileSystem> = env.clone();
2116
2117 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2118 fs: fs.clone(),
2119 sessions_root: dir.to_string_lossy().into_owned(),
2120 clock: Arc::new(SystemClock),
2121 ids: Arc::new(DefaultIdGenerator::new()),
2122 });
2123
2124 let opts = JsonlSessionCreateOptions {
2125 id, parent_session_id: None,
2127 cwd: cwd.to_string(),
2128 metadata: None,
2129 };
2130 let storage = repo
2131 .create_typed(&opts)
2132 .await
2133 .map_err(|e| format!("create session: {e}"))?;
2134 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2136 Ok(Session::new(storage_arc, None))
2137}
2138
2139fn read_append_target(target: &str) -> Option<String> {
2142 let path = Path::new(target);
2143 if path.is_file() {
2144 std::fs::read_to_string(path).ok()
2145 } else {
2146 None
2147 }
2148}
2149
2150#[cfg(test)]
2151mod tests {
2152 use super::*;
2153 use crate::args::Args;
2154
2155 #[test]
2156 fn default_prompt_mentions_cwd_and_tools() {
2157 let p = default_system_prompt("/tmp/proj");
2158 assert!(p.contains("/tmp/proj"));
2159 assert!(p.contains("read"));
2160 assert!(p.contains("bash"));
2161 assert!(p.contains("edit"));
2162 assert!(p.contains("write"));
2163 assert!(p.contains("docs"));
2164 assert!(!p.contains("- grep"));
2165 assert!(!p.contains("- find"));
2166 assert!(!p.contains("- ls"));
2167 assert!(!p.contains("powershell"));
2168 }
2169
2170 #[test]
2171 fn default_tools_include_docs_lookup() {
2172 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2173 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2174 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2175 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2176 let names: Vec<String> = build_tools(&context, &Args::default())
2177 .iter()
2178 .map(|tool| tool.tool.schema().name.clone())
2179 .collect();
2180
2181 assert_eq!(names, vec!["read", "bash", "edit", "write", "docs"]);
2182 }
2183
2184 #[test]
2185 fn pi_package_loading_is_opt_in_and_respects_no_extensions() {
2186 let args = Args::default();
2187 assert!(!should_load_js_packages(&args));
2188 let resources = package_resources_for(&args, Path::new("."), false);
2189 assert!(resources.packages.is_empty());
2190
2191 let args = Args {
2192 enable_pi_packages: true,
2193 ..Args::default()
2194 };
2195 assert!(should_load_js_packages(&args));
2196
2197 let args = Args {
2198 enable_pi_packages: true,
2199 no_extensions: true,
2200 ..Args::default()
2201 };
2202 assert!(!should_load_js_packages(&args));
2203 assert!(package_resources_for(&args, Path::new("."), false)
2204 .packages
2205 .is_empty());
2206 }
2207
2208 #[test]
2209 fn pi_offline_env_disables_startup_package_remediation() {
2210 struct RestoreEnv {
2211 name: &'static str,
2212 value: Option<std::ffi::OsString>,
2213 }
2214
2215 impl Drop for RestoreEnv {
2216 fn drop(&mut self) {
2217 match self.value.take() {
2218 Some(value) => std::env::set_var(self.name, value),
2219 None => std::env::remove_var(self.name),
2220 }
2221 }
2222 }
2223
2224 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2225 let _restore_config = RestoreEnv {
2226 name: crate::config::CONFIG_DIR_ENV,
2227 value: std::env::var_os(crate::config::CONFIG_DIR_ENV),
2228 };
2229 let _restore_offline = RestoreEnv {
2230 name: crate::args::PI_OFFLINE_ENV,
2231 value: std::env::var_os(crate::args::PI_OFFLINE_ENV),
2232 };
2233 let tmp = tempfile::tempdir().unwrap();
2234 let agent = tmp.path().join("agent");
2235 let cwd = tmp.path().join("project");
2236 let package = agent.join("npm/node_modules/demo");
2237 std::fs::create_dir_all(package.join("extensions")).unwrap();
2238 std::fs::create_dir_all(&cwd).unwrap();
2239 std::fs::write(
2240 package.join("package.json"),
2241 r#"{"name":"demo","version":"1.0.0"}"#,
2242 )
2243 .unwrap();
2244 std::fs::write(
2245 package.join("extensions/index.js"),
2246 "export default () => {};",
2247 )
2248 .unwrap();
2249 std::fs::write(
2250 agent.join("settings.json"),
2251 r#"{"npmCommand":[""],"packages":["npm:demo@2.0.0"]}"#,
2252 )
2253 .unwrap();
2254 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2255 std::env::set_var(crate::args::PI_OFFLINE_ENV, "TrUe");
2256 let args = Args {
2257 enable_pi_packages: true,
2258 offline: false,
2259 ..Args::default()
2260 };
2261
2262 let resources = package_resources_for(&args, &cwd, false);
2263
2264 assert!(resources.packages.is_empty());
2265 assert_eq!(resources.diagnostics.len(), 1);
2266 assert!(resources.diagnostics[0].message.contains("offline"));
2267 assert_eq!(
2268 std::fs::read(package.join("package.json")).unwrap(),
2269 br#"{"name":"demo","version":"1.0.0"}"#
2270 );
2271 }
2272
2273 #[test]
2274 fn package_discovery_uses_the_callers_trust_snapshot() {
2275 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2276 let previous = std::env::var_os(crate::config::CONFIG_DIR_ENV);
2277 let tmp = tempfile::tempdir().unwrap();
2278 let agent = tmp.path().join("agent");
2279 let cwd = tmp.path().join("project");
2280 let package = cwd.join("package");
2281 std::fs::create_dir_all(&agent).unwrap();
2282 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2283 std::fs::create_dir_all(&package).unwrap();
2284 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2285 std::fs::write(
2286 package.join("package.json"),
2287 r#"{"name":"snapshot-package","version":"1.0.0"}"#,
2288 )
2289 .unwrap();
2290 std::fs::write(
2291 cwd.join(".rpi/settings.json"),
2292 serde_json::json!({"packages": [package]}).to_string(),
2293 )
2294 .unwrap();
2295 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2296
2297 let args = Args {
2298 enable_pi_packages: true,
2299 ..Args::default()
2300 };
2301 assert_eq!(package_resources_for(&args, &cwd, true).packages.len(), 1);
2302 assert!(package_resources_for(&args, &cwd, false)
2303 .packages
2304 .is_empty());
2305 assert_eq!(
2306 package_resources_for_update_check(&args, &cwd, true)
2307 .packages
2308 .len(),
2309 1
2310 );
2311 assert!(package_resources_for_update_check(&args, &cwd, false)
2312 .packages
2313 .is_empty());
2314
2315 match previous {
2316 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2317 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2318 }
2319 }
2320
2321 #[test]
2322 fn reload_settings_preflight_is_independent_of_packages_and_respects_trust() {
2323 struct RestoreConfigDir(Option<std::ffi::OsString>);
2324
2325 impl Drop for RestoreConfigDir {
2326 fn drop(&mut self) {
2327 match self.0.take() {
2328 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2329 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2330 }
2331 }
2332 }
2333
2334 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2335 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2336 let tmp = tempfile::tempdir().unwrap();
2337 let agent = tmp.path().join("agent");
2338 let cwd = tmp.path().join("project");
2339 std::fs::create_dir_all(&agent).unwrap();
2340 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2341 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2342 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
2343 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2344 let packages_disabled = Args::default();
2345 let packages_enabled = Args {
2346 enable_pi_packages: true,
2347 ..Args::default()
2348 };
2349
2350 for args in [&packages_disabled, &packages_enabled] {
2351 let trusted = validate_settings_for_reload(args, &cwd, true);
2352 let untrusted = validate_settings_for_reload(args, &cwd, false);
2353 assert!(trusted
2354 .unwrap_err()
2355 .contains("could not load project settings"));
2356 assert!(untrusted.is_ok());
2357 }
2358
2359 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2360 for args in [&packages_disabled, &packages_enabled] {
2361 assert!(validate_settings_for_reload(args, &cwd, false)
2362 .unwrap_err()
2363 .contains("could not load global settings"));
2364 }
2365
2366 let local_only = Args {
2367 dev_local_only: true,
2368 ..Args::default()
2369 };
2370 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
2371 }
2372
2373 #[tokio::test(flavor = "current_thread")]
2374 async fn reload_with_packages_disabled_preserves_live_resources_when_settings_break() {
2375 struct RestoreConfigDir(Option<std::ffi::OsString>);
2376
2377 impl Drop for RestoreConfigDir {
2378 fn drop(&mut self) {
2379 match self.0.take() {
2380 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2381 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2382 }
2383 }
2384 }
2385
2386 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2387 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2388 let tmp = tempfile::tempdir().unwrap();
2389 let agent = tmp.path().join("agent");
2390 let cwd = tmp.path().join("project");
2391 let skill_dir = tmp.path().join("configured-skills");
2392 std::fs::create_dir_all(&agent).unwrap();
2393 std::fs::create_dir_all(&cwd).unwrap();
2394 std::fs::create_dir_all(&skill_dir).unwrap();
2395 std::fs::write(
2396 skill_dir.join("SKILL.md"),
2397 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
2398 )
2399 .unwrap();
2400 std::fs::write(
2401 agent.join("settings.json"),
2402 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2403 )
2404 .unwrap();
2405 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2406
2407 let resolved = crate::provider::resolve(
2408 Some("anthropic"),
2409 Some(crate::provider::DEFAULT_MODEL_ID),
2410 None,
2411 Some("test-key"),
2412 None,
2413 )
2414 .unwrap();
2415 let args = Args {
2416 trust_override: Some(false),
2417 no_session: true,
2418 no_extensions: true,
2419 no_prompt_templates: true,
2420 no_context_files: true,
2421 system_prompt: Some("stable system prompt".into()),
2422 ..Args::default()
2423 };
2424 assert!(!should_load_js_packages(&args));
2425
2426 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2427 let before_resources = harness.get_resources().await.unwrap();
2428 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
2429 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
2430 let before_prompt = harness.get_system_prompt().await.unwrap();
2431 let before_tools: Vec<String> = harness
2432 .get_tools()
2433 .await
2434 .unwrap()
2435 .iter()
2436 .map(|tool| tool.tool.schema().name.clone())
2437 .collect();
2438 let before_bridge = context
2439 .action_bridge
2440 .lock()
2441 .unwrap()
2442 .as_ref()
2443 .unwrap()
2444 .clone();
2445
2446 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2447 let outcome = reload_extension_resources(&harness, &context).await;
2448
2449 assert!(outcome.had_warnings);
2450 assert!(outcome.summary.contains("Settings reload failed"));
2451 assert!(outcome.summary.contains("could not load global settings"));
2452 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
2453 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
2454 let after_tools: Vec<String> = harness
2455 .get_tools()
2456 .await
2457 .unwrap()
2458 .iter()
2459 .map(|tool| tool.tool.schema().name.clone())
2460 .collect();
2461 assert_eq!(after_tools, before_tools);
2462 let after_bridge = context
2463 .action_bridge
2464 .lock()
2465 .unwrap()
2466 .as_ref()
2467 .unwrap()
2468 .clone();
2469 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
2470 }
2471
2472 #[test]
2473 fn reload_preparation_rejects_settings_changed_during_derivation() {
2474 struct RestoreConfigDir(Option<std::ffi::OsString>);
2475
2476 impl Drop for RestoreConfigDir {
2477 fn drop(&mut self) {
2478 match self.0.take() {
2479 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2480 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2481 }
2482 }
2483 }
2484
2485 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2486 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2487 let tmp = tempfile::tempdir().unwrap();
2488 let agent = tmp.path().join("agent");
2489 let cwd = tmp.path().join("project");
2490 let first_skill_dir = tmp.path().join("first-skills");
2491 let second_skill_dir = tmp.path().join("second-skills");
2492 std::fs::create_dir_all(&agent).unwrap();
2493 std::fs::create_dir_all(&cwd).unwrap();
2494 std::fs::write(
2495 agent.join("settings.json"),
2496 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
2497 )
2498 .unwrap();
2499 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2500 let args = Args {
2501 trust_override: Some(false),
2502 no_extensions: true,
2503 no_prompt_templates: true,
2504 ..Args::default()
2505 };
2506
2507 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
2508 std::fs::write(
2509 agent.join("settings.json"),
2510 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
2511 )
2512 .unwrap();
2513 });
2514
2515 let error = result
2516 .err()
2517 .expect("settings mutation must fail preparation");
2518 assert!(error.contains("settings changed while reload inputs were being prepared"));
2519 }
2520
2521 #[tokio::test(flavor = "current_thread")]
2522 async fn reload_uses_frozen_settings_inputs_after_preparation() {
2523 struct RestoreConfigDir(Option<std::ffi::OsString>);
2524
2525 impl Drop for RestoreConfigDir {
2526 fn drop(&mut self) {
2527 match self.0.take() {
2528 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2529 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2530 }
2531 }
2532 }
2533
2534 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2535 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2536 let tmp = tempfile::tempdir().unwrap();
2537 let agent = tmp.path().join("agent");
2538 let cwd = tmp.path().join("project");
2539 let skill_dir = tmp.path().join("configured-skills");
2540 std::fs::create_dir_all(&agent).unwrap();
2541 std::fs::create_dir_all(&cwd).unwrap();
2542 std::fs::create_dir_all(&skill_dir).unwrap();
2543 std::fs::write(
2544 skill_dir.join("SKILL.md"),
2545 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
2546 )
2547 .unwrap();
2548 std::fs::write(
2549 agent.join("settings.json"),
2550 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2551 )
2552 .unwrap();
2553 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2554
2555 let resolved = crate::provider::resolve(
2556 Some("anthropic"),
2557 Some(crate::provider::DEFAULT_MODEL_ID),
2558 None,
2559 Some("test-key"),
2560 None,
2561 )
2562 .unwrap();
2563 let args = Args {
2564 trust_override: Some(false),
2565 no_session: true,
2566 no_extensions: true,
2567 no_prompt_templates: true,
2568 no_context_files: true,
2569 system_prompt: Some("stable system prompt".into()),
2570 ..Args::default()
2571 };
2572 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2573
2574 let outcome = reload_extension_resources_inner(&harness, &context, || {
2575 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2576 })
2577 .await;
2578
2579 assert!(!outcome.summary.contains("Settings reload failed"));
2580 assert_eq!(
2581 outcome.summary,
2582 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
2583 );
2584 let resources = harness.get_resources().await.unwrap();
2585 let skills = resources.skills.as_ref().unwrap();
2586 assert_eq!(skills.len(), 1);
2587 assert_eq!(skills[0].name, "frozen-skill");
2588 }
2589
2590 #[tokio::test]
2591 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
2592 let tmp = tempfile::tempdir().unwrap();
2593 let skill_path = tmp.path().join("explicit-skill.md");
2594 std::fs::write(
2595 &skill_path,
2596 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
2597 )
2598 .unwrap();
2599 let prompt_path = tmp.path().join("explicit-prompt.md");
2600 std::fs::write(
2601 &prompt_path,
2602 "---\ndescription: Explicit prompt\n---\nPrompt body",
2603 )
2604 .unwrap();
2605
2606 let args = Args {
2607 skill: vec![skill_path.clone()],
2608 prompt_template: vec![prompt_path.clone()],
2609 ..Args::default()
2610 };
2611 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
2612 let prompt_paths =
2613 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
2614 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
2615 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
2616
2617 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
2618 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
2619 assert_eq!(skills.skills[0].name, "explicit-skill");
2620
2621 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
2622 assert_eq!(
2623 prompts.prompt_templates.len(),
2624 1,
2625 "{:?}",
2626 prompts.diagnostics
2627 );
2628 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
2629 }
2630
2631 #[test]
2632 fn tool_policy_applies_to_rust_and_js_active_names() {
2633 let names = vec![
2634 "read".to_string(),
2635 "ask_user_question".to_string(),
2636 "write".to_string(),
2637 ];
2638
2639 let args = Args {
2640 tools: Some(vec!["read".into(), "ask_user_question".into()]),
2641 ..Args::default()
2642 };
2643 assert_eq!(
2644 filter_active_tool_names(names.clone(), &args),
2645 vec!["read", "ask_user_question"]
2646 );
2647
2648 let args = Args {
2649 exclude_tools: Some(vec!["ask_user_question".into()]),
2650 ..Args::default()
2651 };
2652 assert_eq!(
2653 filter_active_tool_names(names.clone(), &args),
2654 vec!["read", "write"]
2655 );
2656
2657 let args = Args {
2658 no_tools: true,
2659 ..Args::default()
2660 };
2661 assert!(filter_active_tool_names(names, &args).is_empty());
2662 }
2663
2664 #[test]
2665 fn select_ephemeral_when_no_session() {
2666 let args = Args {
2667 no_session: true,
2668 ..Args::default()
2669 };
2670 let cwd = Path::new("/tmp");
2671 assert!(matches!(
2672 select_session(&args, cwd),
2673 SessionSelection::Ephemeral
2674 ));
2675 }
2676
2677 #[test]
2678 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
2679 let defaults = Args::default();
2680 assert!(resolve_project_trust(
2681 &defaults,
2682 Path::new("C:/definitely-not-a-project")
2683 ));
2684
2685 let denied = Args {
2686 trust_override: Some(false),
2687 ..Args::default()
2688 };
2689 assert!(!resolve_project_trust(
2690 &denied,
2691 Path::new("C:/definitely-not-a-project")
2692 ));
2693 }
2694
2695 #[tokio::test(flavor = "current_thread")]
2696 async fn build_preserves_the_supplied_startup_project_snapshot() {
2697 struct RestoreConfigDir(Option<std::ffi::OsString>);
2698
2699 impl Drop for RestoreConfigDir {
2700 fn drop(&mut self) {
2701 match self.0.take() {
2702 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2703 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2704 }
2705 }
2706 }
2707
2708 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2709 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2710 let tmp = tempfile::tempdir().unwrap();
2711 let agent = tmp.path().join("agent");
2712 let cwd = tmp.path().join("project");
2713 std::fs::create_dir_all(&agent).unwrap();
2714 std::fs::create_dir_all(&cwd).unwrap();
2715 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2716
2717 let resolved = crate::provider::resolve(
2718 Some("anthropic"),
2719 Some(crate::provider::DEFAULT_MODEL_ID),
2720 None,
2721 Some("test-key"),
2722 None,
2723 )
2724 .unwrap();
2725 let args = Args {
2726 trust_override: Some(false),
2727 no_session: true,
2728 no_tools: true,
2729 no_extensions: true,
2730 no_skills: true,
2731 no_prompt_templates: true,
2732 no_context_files: true,
2733 system_prompt: Some("test prompt".into()),
2734 ..Args::default()
2735 };
2736
2737 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
2738
2739 assert_eq!(context.cwd, cwd);
2740 assert!(context.project_trusted);
2741 }
2742
2743 #[test]
2744 fn select_latest_for_continue_and_resume() {
2745 let args = Args {
2746 continue_session: true,
2747 ..Args::default()
2748 };
2749 let cwd = Path::new("/tmp");
2750 assert!(matches!(
2751 select_session(&args, cwd),
2752 SessionSelection::Latest
2753 ));
2754
2755 let args = Args {
2756 resume: true,
2757 ..Args::default()
2758 };
2759 assert!(matches!(
2760 select_session(&args, cwd),
2761 SessionSelection::Latest
2762 ));
2763 }
2764
2765 #[test]
2766 fn select_by_id_for_session_flag() {
2767 let args = Args {
2768 session: Some("01a02ece".into()),
2769 ..Args::default()
2770 };
2771 let cwd = Path::new("/tmp");
2772 assert!(matches!(
2773 select_session(&args, cwd),
2774 SessionSelection::ById { id } if id == "01a02ece"
2775 ));
2776 }
2777
2778 #[test]
2779 fn select_new_with_custom_dir() {
2780 let args = Args {
2781 session_dir: Some(PathBuf::from("/tmp/sess")),
2782 ..Args::default()
2783 };
2784 let cwd = Path::new("/tmp");
2785 match select_session(&args, cwd) {
2786 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
2787 other => panic!("expected New, got {other:?}"),
2788 }
2789 }
2790
2791 #[test]
2792 fn select_new_default_dir() {
2793 let args = Args::default();
2794 let cwd = Path::new("/proj");
2795 match select_session(&args, cwd) {
2796 SessionSelection::New { dir, .. } => {
2797 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
2798 }
2799 other => panic!("expected New, got {other:?}"),
2800 }
2801 }
2802
2803 #[test]
2804 fn default_session_dir_prefers_rpi_but_reads_legacy_pi() {
2805 let tmp = tempfile::tempdir().unwrap();
2806 let cwd = tmp.path();
2807 std::fs::create_dir_all(cwd.join(".pi/sessions")).unwrap();
2808 assert_eq!(default_session_dir(cwd), cwd.join(".pi/sessions"));
2809 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
2810 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
2811 }
2812
2813 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2814 async fn ephemeral_session_builds_roundtrips() {
2815 let s = ephemeral_session();
2819 let leaf = s.get_leaf_id().await;
2820 assert!(leaf.is_ok());
2821 }
2822
2823 }