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}
950
951struct PreparedReloadInputs {
952 package_resources: crate::packages::PackageResources,
953 extension_dirs: Vec<PathBuf>,
954 skill_base_dirs: Vec<PathBuf>,
955 prompt_base_dirs: Vec<PathBuf>,
956}
957
958fn append_reload_resource_paths(
963 mut paths: Vec<PathBuf>,
964 explicit: &[PathBuf],
965 discovered: &[String],
966 js_paths: &[PathBuf],
967 package_paths: &[PathBuf],
968) -> Vec<PathBuf> {
969 paths.extend(explicit.iter().cloned());
970 paths.extend(discovered.iter().map(PathBuf::from));
971 paths.extend(js_paths.iter().cloned());
972 paths.extend(package_paths.iter().cloned());
973 paths
974}
975
976pub async fn reload_extension_resources(
988 harness: &AgentHarness,
989 ctx: &ReloadContext,
990) -> ReloadOutcome {
991 reload_extension_resources_inner(harness, ctx, || {}).await
992}
993
994async fn reload_extension_resources_inner<F>(
995 harness: &AgentHarness,
996 ctx: &ReloadContext,
997 after_prepare: F,
998) -> ReloadOutcome
999where
1000 F: FnOnce() + Send,
1001{
1002 let mut effective_args = ctx.args.clone();
1007 if let Some(dev) = &ctx.dev_extension {
1008 if let Err(error) = dev
1009 .rebuild()
1010 .and_then(|_| dev.apply_to_args(&mut effective_args))
1011 {
1012 return ReloadOutcome {
1013 summary: format!(
1014 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1015 dev.package_name()
1016 ),
1017 had_warnings: true,
1018 };
1019 }
1020 }
1021 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1022 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1023 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1024 Ok(prepared) => prepared,
1025 Err(error) => {
1026 return ReloadOutcome {
1027 summary: format!(
1028 "Settings reload failed: {error}. Keeping the currently loaded resources."
1029 ),
1030 had_warnings: true,
1031 };
1032 }
1033 };
1034 after_prepare();
1035 let PreparedReloadInputs {
1036 package_resources,
1037 extension_dirs,
1038 skill_base_dirs,
1039 prompt_base_dirs,
1040 } = prepared;
1041 let mut warnings = false;
1042
1043 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1050 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1051 Some(b) => b.clone_host(),
1052 None => {
1053 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1056 ctx.catalog.clone(),
1057 ctx.cwd.clone(),
1058 ctx.runtime.clone(),
1059 ctx.args.unknown_flags.clone(),
1060 );
1061 crate::extensions_actions::HarnessActionHost::set_harness(
1062 &_cell,
1063 Arc::new(harness.clone()),
1064 );
1065 Arc::new(action_host)
1066 }
1067 };
1068
1069 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1070 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1071 ctx.runtime.clone(),
1072 host,
1073 reload_cb,
1074 ctx.ui_dialog.clone(),
1075 );
1076
1077 let extension_session = if effective_args.no_extensions {
1078 rpi_extensions::ExtensionSession::none()
1079 } else {
1080 load_extensions_from_dirs(
1081 &effective_args,
1082 &extension_dirs,
1083 Some(Arc::clone(&fresh_bridge)),
1084 )
1085 };
1086 if extension_session.is_empty() && !effective_args.no_extensions {
1087 }
1090 if effective_args.verbose {
1091 if let Some(s) = extension_session.summary() {
1092 eprintln!("reload: {s}");
1093 }
1094 report_deferred_renderers(&extension_session);
1095 }
1096
1097 {
1111 let mut session_guard = ctx.extension_session.lock().unwrap();
1112 let old_session = std::mem::replace(
1113 &mut *session_guard,
1114 rpi_extensions::ExtensionSession::none(),
1115 );
1116 if let Some(old_snap) = old_session.snapshot_arc() {
1117 old_snap.active_flag().store(false, Ordering::SeqCst);
1121 }
1122 }
1126 if let Some(old_b) = old_bridge {
1127 old_b.invalidate();
1128 }
1129
1130 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1133 *ctx.extension_session.lock().unwrap() = extension_session.clone();
1134
1135 let discovered = extension_session
1137 .snapshot_arc()
1138 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1139 .unwrap_or_default();
1140
1141 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1143 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1144
1145 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1146 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1147 if !effective_args.no_skills {
1148 let js_paths: &[PathBuf] = ctx
1152 .js_extension_session
1153 .as_ref()
1154 .map(|js| js.resources.skill_paths.as_slice())
1155 .unwrap_or(&[]);
1156 let package_paths = package_resources.skill_dirs();
1157 let dirs = append_reload_resource_paths(
1158 skill_base_dirs,
1159 &effective_args.skill,
1160 &discovered.skill_paths,
1161 js_paths,
1162 &package_paths,
1163 );
1164 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1165 skills = result.skills;
1166 skill_diags = result.diagnostics;
1167 }
1168
1169 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1170 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1171 if !effective_args.no_prompt_templates {
1172 let js_paths: &[PathBuf] = ctx
1173 .js_extension_session
1174 .as_ref()
1175 .map(|js| js.resources.prompt_paths.as_slice())
1176 .unwrap_or(&[]);
1177 let package_paths = package_resources.prompt_dirs();
1178 let dirs = append_reload_resource_paths(
1179 prompt_base_dirs,
1180 &effective_args.prompt_template,
1181 &discovered.prompt_paths,
1182 js_paths,
1183 &package_paths,
1184 );
1185 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1186 prompt_templates = result.prompt_templates;
1187 prompt_diags = result.diagnostics;
1188 }
1189
1190 let context_block = if effective_args.no_context_files {
1191 String::new()
1192 } else {
1193 let agent_dir = crate::config::agent_dir().ok();
1194 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1195 let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
1196 format_project_context(&files)
1197 };
1198
1199 if !skill_diags.is_empty()
1200 || !prompt_diags.is_empty()
1201 || !package_resources.diagnostics.is_empty()
1202 {
1203 warnings = true;
1204 if effective_args.verbose {
1205 for d in &package_resources.diagnostics {
1206 eprintln!("warning: package {}: {}", d.spec, d.message);
1207 }
1208 for d in &skill_diags {
1209 eprintln!(
1210 "warning: skill {} ({}): {}",
1211 d.path,
1212 d.code.as_str(),
1213 d.message
1214 );
1215 }
1216 for d in &prompt_diags {
1217 eprintln!(
1218 "warning: prompt template {} ({}): {}",
1219 d.path,
1220 d.code.as_str(),
1221 d.message
1222 );
1223 }
1224 }
1225 }
1226
1227 let base_prompt = match effective_args.system_prompt.as_deref() {
1229 Some(explicit) => explicit.to_string(),
1230 None => match discover_system_prompt_file_with_packages(&ctx.cwd, &package_resources) {
1231 Some(path) => {
1232 std::fs::read_to_string(&path).unwrap_or_else(|_| default_system_prompt(&cwd_str))
1233 }
1234 None => default_system_prompt(&cwd_str),
1235 },
1236 };
1237 let mut append_texts: Vec<String> = Vec::new();
1238 for extra in &effective_args.append_system_prompt {
1239 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
1240 append_texts.push(text);
1241 }
1242 if effective_args.append_system_prompt.is_empty() {
1243 if let Some(path) =
1244 discover_append_system_prompt_file_with_packages(&ctx.cwd, &package_resources)
1245 {
1246 if let Ok(text) = std::fs::read_to_string(&path) {
1247 append_texts.push(text);
1248 }
1249 }
1250 }
1251 let append_join = if append_texts.is_empty() {
1252 None
1253 } else {
1254 Some(append_texts.join("\n\n"))
1255 };
1256 let system_prompt = compose_system_prompt(
1257 Some(&base_prompt),
1258 &[],
1259 if context_block.is_empty() {
1260 None
1261 } else {
1262 Some(&context_block)
1263 },
1264 append_join.as_deref(),
1265 );
1266
1267 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1269 Some(snapshot) => {
1270 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1271 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1272 }
1273 None => ctx.broadcast.clone(),
1274 };
1275
1276 let resources = AgentHarnessResources {
1278 skills: if skills.is_empty() {
1279 None
1280 } else {
1281 Some(skills.clone())
1282 },
1283 prompt_templates: if prompt_templates.is_empty() {
1284 None
1285 } else {
1286 Some(prompt_templates.clone())
1287 },
1288 };
1289 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1290 let _ = harness.set_resources(resources).await;
1291 let _ = harness.set_agent_emitter(Some(emitter)).await;
1292 let _ = harness
1293 .set_models(build_models_with_extensions_for_reload(
1294 &ctx.gateway,
1295 &extension_session,
1296 ctx.runtime.clone(),
1297 ))
1298 .await;
1299 let _ = harness
1300 .set_provider_hooks(
1301 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1302 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1303 )
1304 .await;
1305
1306 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1310 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1311 let mut tools = build_tools(&tool_ctx, &effective_args);
1312 merge_extension_tools(&mut tools, &extension_session, &effective_args);
1313 if let Some(js) = &ctx.js_extension_session {
1314 merge_js_extension_tools(&mut tools, js, &effective_args);
1315 }
1316 let mut active = active_tool_names(&tools, &effective_args);
1317 if let Some(js) = &ctx.js_extension_session {
1318 let js_names = js.tool_names();
1319 if let Some(js_active) = js.active_tools() {
1320 active.retain(|name| {
1321 tool_name_allowed(name, &effective_args)
1322 && !js_names.iter().any(|js_name| js_name == name)
1323 });
1324 active.extend(js_active.into_iter().filter(|name| {
1325 js_names.iter().any(|js_name| js_name == name)
1326 && tool_name_allowed(name, &effective_args)
1327 }));
1328 }
1329 }
1330 active = filter_active_tool_names(active, &effective_args);
1331 let _ = harness.set_tools(tools, Some(active)).await;
1332
1333 let summary = format!(
1334 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1335 extension_session.loaded_paths().len(),
1336 skills.len(),
1337 prompt_templates.len(),
1338 );
1339 ReloadOutcome {
1340 summary,
1341 had_warnings: warnings,
1342 }
1343}
1344
1345#[derive(Clone, Debug, PartialEq, Eq)]
1346struct ReloadSettingsFields {
1347 packages: Option<Vec<crate::settings::PackageSetting>>,
1348 npm_command: Option<Vec<String>>,
1349 skill_dirs: Option<Vec<String>>,
1350 prompt_dirs: Option<Vec<String>>,
1351 extension_dirs: Option<Vec<String>>,
1352}
1353
1354impl From<crate::settings::Settings> for ReloadSettingsFields {
1355 fn from(settings: crate::settings::Settings) -> Self {
1356 Self {
1357 packages: settings.packages,
1358 npm_command: settings.npm_command,
1359 skill_dirs: settings.skill_dirs,
1360 prompt_dirs: settings.prompt_dirs,
1361 extension_dirs: settings.extension_dirs,
1362 }
1363 }
1364}
1365
1366#[derive(Clone, Debug, PartialEq, Eq)]
1367struct ReloadSettingsSnapshot {
1368 global: ReloadSettingsFields,
1369 project: Option<ReloadSettingsFields>,
1370}
1371
1372fn reload_reads_settings(args: &Args) -> bool {
1373 !args.dev_local_only
1374 && (should_load_js_packages(args)
1375 || !args.no_extensions
1376 || !args.no_skills
1377 || !args.no_prompt_templates)
1378}
1379
1380fn load_reload_settings_snapshot(
1384 args: &Args,
1385 cwd: &Path,
1386 project_trusted: bool,
1387) -> Result<Option<ReloadSettingsSnapshot>, String> {
1388 if !reload_reads_settings(args) {
1389 return Ok(None);
1390 }
1391 let global = crate::settings::load_settings()
1392 .map_err(|error| format!("could not load global settings: {error}"))?
1393 .into();
1394 let project = if project_trusted {
1395 crate::settings::load_active_project_settings(cwd)
1396 .map_err(|error| format!("could not load project settings: {error}"))?
1397 .map(|(_, settings)| settings.into())
1398 } else {
1399 None
1400 };
1401 Ok(Some(ReloadSettingsSnapshot { global, project }))
1402}
1403
1404fn validate_settings_for_reload(
1407 args: &Args,
1408 cwd: &Path,
1409 project_trusted: bool,
1410) -> Result<(), String> {
1411 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1412}
1413
1414fn prepare_reload_inputs(
1415 args: &Args,
1416 cwd: &Path,
1417 project_trusted: bool,
1418) -> Result<PreparedReloadInputs, String> {
1419 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1420}
1421
1422fn prepare_reload_inputs_inner<F>(
1423 args: &Args,
1424 cwd: &Path,
1425 project_trusted: bool,
1426 before_verify: F,
1427) -> Result<PreparedReloadInputs, String>
1428where
1429 F: FnOnce(),
1430{
1431 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1432
1433 let package_resources = if args.dev_local_only {
1434 crate::packages::PackageResources::default()
1435 } else {
1436 package_resources_for(args, cwd, project_trusted)
1437 };
1438
1439 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1440 Vec::new()
1441 } else if project_trusted {
1442 extension_dirs(cwd)
1443 } else {
1444 global_extension_dirs()
1445 };
1446 if !args.no_extensions {
1447 extension_dirs.extend(args.extensions_dir.iter().cloned());
1448 }
1449
1450 let skill_base_dirs = if args.no_skills {
1451 Vec::new()
1452 } else if args.dev_local_only {
1453 project_skill_dirs(cwd)
1454 } else if project_trusted {
1455 skill_dirs(cwd)
1456 } else {
1457 global_skill_dirs()
1458 };
1459
1460 let prompt_base_dirs = if args.no_prompt_templates {
1461 Vec::new()
1462 } else if args.dev_local_only {
1463 project_prompt_template_dirs(cwd)
1464 } else if project_trusted {
1465 prompt_template_dirs(cwd)
1466 } else {
1467 global_prompt_template_dirs()
1468 };
1469
1470 before_verify();
1471 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1472 if settings_before != settings_after {
1473 return Err(
1474 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1475 );
1476 }
1477
1478 Ok(PreparedReloadInputs {
1479 package_resources,
1480 extension_dirs,
1481 skill_base_dirs,
1482 prompt_base_dirs,
1483 })
1484}
1485
1486fn build_models_with_extensions_for_reload(
1491 gateway: &Arc<dyn Provider>,
1492 extension_session: &ExtensionSession,
1493 runtime: tokio::runtime::Handle,
1494) -> Vec<Arc<dyn Provider>> {
1495 let mut models: Vec<Arc<dyn Provider>> = vec![gateway.clone()];
1496 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1497 models.extend(pluggable);
1498 models
1499}
1500
1501fn report_deferred_renderers(session: &ExtensionSession) {
1505 let Some(snap) = session.snapshot_arc() else {
1506 return;
1507 };
1508 let all = snap.renderers();
1509 let markdown = all
1510 .iter()
1511 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1512 .count();
1513 let message = all
1514 .iter()
1515 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1516 .count();
1517 let entry = all
1518 .iter()
1519 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1520 .count();
1521 if markdown + message + entry == 0 {
1522 return;
1523 }
1524 eprintln!(
1525 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1526 markdown, message, entry
1527 );
1528}
1529
1530#[derive(Debug, thiserror::Error)]
1532pub enum BuildError {
1533 #[error("Could not create the session directory: {0}")]
1534 SessionDir(String),
1535 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1536 SessionNotFound { requested: String, dir: String },
1537 #[error("Could not build the harness: {0}")]
1538 HarnessCreate(String),
1539}
1540
1541fn build_models_with_extensions(
1551 resolved: &ResolvedModel,
1552 extension_session: &ExtensionSession,
1553 runtime: tokio::runtime::Handle,
1554) -> Vec<Arc<dyn Provider>> {
1555 let mut models: Vec<Arc<dyn Provider>> = vec![resolved.provider.clone() as Arc<dyn Provider>];
1556 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1557 models.extend(pluggable);
1558 models
1559}
1560
1561pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1564 if let Some(override_value) = args.trust_override {
1565 return override_value;
1566 }
1567 crate::config::project_trust_decision(cwd)
1568 .ok()
1569 .flatten()
1570 .unwrap_or(true)
1571}
1572
1573fn load_extensions(
1581 args: &Args,
1582 cwd: &Path,
1583 project_trusted: bool,
1584 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1585) -> ExtensionSession {
1586 let mut dirs = if args.dev_local_only {
1587 Vec::new()
1588 } else if project_trusted {
1589 extension_dirs(cwd)
1590 } else {
1591 global_extension_dirs()
1592 };
1593 dirs.extend(args.extensions_dir.iter().cloned());
1594 load_extensions_from_dirs(args, &dirs, action_bridge)
1595}
1596
1597fn load_extensions_from_dirs(
1598 args: &Args,
1599 dirs: &[PathBuf],
1600 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1601) -> ExtensionSession {
1602 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1603 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1610}
1611
1612fn js_extension_paths(
1613 args: &Args,
1614 cwd: &Path,
1615 project_trusted: bool,
1616 packages: &crate::packages::PackageResources,
1617) -> Vec<PathBuf> {
1618 if args.dev_local_only {
1619 return Vec::new();
1620 }
1621 let mut paths = packages.extension_paths();
1622 let discovered_dirs = if project_trusted {
1623 extension_dirs(cwd)
1624 } else {
1625 global_extension_dirs()
1626 };
1627 for dir in discovered_dirs {
1628 if let Ok(entries) = std::fs::read_dir(dir) {
1629 paths.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
1630 matches!(
1631 path.extension()
1632 .and_then(|ext| ext.to_str())
1633 .map(|ext| ext.to_ascii_lowercase())
1634 .as_deref(),
1635 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1636 )
1637 }));
1638 }
1639 }
1640 paths.extend(
1641 args.extension
1642 .iter()
1643 .filter(|path| {
1644 matches!(
1645 path.extension()
1646 .and_then(|ext| ext.to_str())
1647 .map(|ext| ext.to_ascii_lowercase())
1648 .as_deref(),
1649 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1650 )
1651 })
1652 .cloned(),
1653 );
1654 paths
1655}
1656
1657fn merge_extension_tools(tools: &mut Vec<HarnessTool>, session: &ExtensionSession, args: &Args) {
1663 let Some(snapshot) = session.snapshot() else {
1664 return;
1665 };
1666 for et in snapshot.tools() {
1667 let name = &et.tool.name;
1668 if !tool_name_allowed(name, args) {
1669 continue;
1670 }
1671 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive());
1672 let harness_tool = HarnessTool::new(Arc::new(adapter));
1673 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1674 Some(slot) => *slot = harness_tool,
1675 None => tools.push(harness_tool),
1676 }
1677 }
1678}
1679
1680fn merge_js_extension_tools(
1681 tools: &mut Vec<HarnessTool>,
1682 session: &crate::js_extensions::JsExtensionSession,
1683 args: &Args,
1684) {
1685 for adapter in session.tools() {
1686 let name = adapter.schema().name.clone();
1687 if !tool_name_allowed(&name, args) {
1688 continue;
1689 }
1690 let harness_tool = HarnessTool::new(Arc::new(adapter));
1691 match tools
1692 .iter_mut()
1693 .find(|tool| tool.tool.schema().name == name)
1694 {
1695 Some(slot) => *slot = harness_tool,
1696 None => tools.push(harness_tool),
1697 }
1698 }
1699}
1700
1701pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1708 use rpi_tools::tools::bash::BashToolOptions;
1709 let default = std::env::var("RPI_BASH_TIMEOUT")
1710 .ok()
1711 .and_then(|v| v.parse::<f64>().ok())
1712 .unwrap_or(120.0);
1713 BashToolOptions {
1714 command_prefix: None,
1715 default_timeout: Some(default),
1716 }
1717}
1718
1719fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1720 if args.no_tools {
1721 return Vec::new();
1722 }
1723 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1726 ("read", HarnessTool::new(create_read_tool(ctx, None))),
1727 (
1728 "bash",
1729 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1730 ),
1731 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1732 ("write", HarnessTool::new(create_write_tool(ctx))),
1733 ("docs", HarnessTool::new(create_docs_tool())),
1734 ];
1735
1736 if args.no_builtin_tools {
1740 all.clear();
1741 }
1742
1743 if let Some(allow) = &args.tools {
1745 all.retain(|(name, _)| allow.iter().any(|a| a == name));
1746 }
1747 if let Some(deny) = &args.exclude_tools {
1749 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1750 }
1751
1752 all.into_iter()
1753 .map(|(_, t)| t.with_replay(ToolReplay::Safe))
1754 .collect()
1755}
1756
1757fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1761 filter_active_tool_names(
1762 tools.iter().map(|tool| tool.tool.schema().name.clone()),
1763 args,
1764 )
1765}
1766
1767pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
1771 if args.no_tools {
1772 return false;
1773 }
1774 if args
1775 .tools
1776 .as_ref()
1777 .is_some_and(|allow| !allow.iter().any(|value| value == name))
1778 {
1779 return false;
1780 }
1781 if args
1782 .exclude_tools
1783 .as_ref()
1784 .is_some_and(|deny| deny.iter().any(|value| value == name))
1785 {
1786 return false;
1787 }
1788 true
1789}
1790
1791pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
1792where
1793 I: IntoIterator<Item = String>,
1794{
1795 names
1796 .into_iter()
1797 .filter(|name| tool_name_allowed(name, args))
1798 .collect()
1799}
1800
1801async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1803 match selection {
1804 SessionSelection::Ephemeral => Ok(ephemeral_session()),
1805 SessionSelection::New { dir, .. } => {
1806 std::fs::create_dir_all(dir)
1809 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1810 let session = create_jsonl_session(dir, cwd)
1811 .await
1812 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1813 Ok(session)
1814 }
1815 SessionSelection::Latest
1816 | SessionSelection::ById { .. }
1817 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
1818 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
1819 }
1820}
1821
1822async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1828 match selection {
1832 SessionSelection::Latest => {
1833 let metas = list_session_metadata(cwd).await?;
1834 let Some(meta) = metas.first() else {
1835 return Err(BuildError::SessionNotFound {
1836 requested: "the most recent session".to_string(),
1837 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1838 });
1839 };
1840 open_session(meta, cwd).await
1841 }
1842 SessionSelection::ById { id } => open_session_by_id(id, cwd).await.map_err(|e| match e {
1843 OpenError::NotFound { requested } => BuildError::SessionNotFound {
1844 requested,
1845 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1846 },
1847 OpenError::Other(msg) => BuildError::SessionDir(msg),
1848 }),
1849 SessionSelection::ByExactId { id } => {
1850 let metas = list_session_metadata(cwd).await?;
1853 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
1854 return open_session(meta, cwd).await;
1855 }
1856 let dir = default_session_dir(Path::new(cwd));
1857 std::fs::create_dir_all(&dir)
1858 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1859 create_jsonl_session_with_id(&dir, cwd, Some(id.clone()))
1860 .await
1861 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
1862 }
1863 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
1864 }
1865}
1866
1867async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
1870 use rpi_harness::session::jsonl::{
1871 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1872 };
1873 use rpi_harness::session::types::{ForkOptions, SessionStorage};
1874 use rpi_tools::FileSystem;
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 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1880 let fs: Arc<dyn FileSystem> = env.clone();
1881 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1882 fs: fs.clone(),
1883 sessions_root: dir.to_string_lossy().into_owned(),
1884 clock: Arc::new(SystemClock),
1885 ids: Arc::new(DefaultIdGenerator::new()),
1886 });
1887 let metas = repo
1888 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
1889 .await
1890 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
1891 let source_meta = metas
1892 .iter()
1893 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
1894 .ok_or_else(|| BuildError::SessionNotFound {
1895 requested: format!("--fork {source}"),
1896 dir: dir.display().to_string(),
1897 })?;
1898 let fork_storage = repo
1899 .fork_typed(
1900 source_meta,
1901 &JsonlSessionCreateOptions {
1902 id: None,
1903 parent_session_id: Some(source_meta.id.clone()),
1904 cwd: cwd.to_string(),
1905 metadata: None,
1906 },
1907 &ForkOptions::default(),
1908 )
1909 .await
1910 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
1911 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
1912 Ok(Session::new(storage_arc, None))
1913}
1914
1915pub enum OpenError {
1918 NotFound { requested: String },
1920 Other(String),
1922}
1923
1924impl std::fmt::Display for OpenError {
1925 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1926 match self {
1927 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
1928 OpenError::Other(msg) => write!(f, "{msg}"),
1929 }
1930 }
1931}
1932
1933pub async fn list_session_metadata(
1936 cwd: &str,
1937) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
1938 use rpi_harness::session::jsonl::{
1939 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1940 };
1941 use rpi_tools::FileSystem;
1942
1943 let dir = default_session_dir(Path::new(cwd));
1944 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1945 let fs: Arc<dyn FileSystem> = env.clone();
1946 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1947 fs: fs.clone(),
1948 sessions_root: dir.to_string_lossy().into_owned(),
1949 clock: Arc::new(SystemClock),
1950 ids: Arc::new(DefaultIdGenerator::new()),
1951 });
1952 repo.list_typed(&JsonlSessionListOptions::default())
1953 .await
1954 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
1955}
1956
1957pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
1961 let metas = list_session_metadata(cwd)
1962 .await
1963 .map_err(|e| OpenError::Other(e.to_string()))?;
1964 let Some(meta) = metas
1965 .iter()
1966 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
1967 else {
1968 return Err(OpenError::NotFound {
1969 requested: format!("session {id}"),
1970 });
1971 };
1972 open_session(meta, cwd)
1973 .await
1974 .map_err(|e| OpenError::Other(e.to_string()))
1975}
1976
1977pub(crate) async fn fork_session_storage(
1984 harness: &AgentHarness,
1985 cwd: &str,
1986) -> Result<Session, String> {
1987 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
1988 use rpi_tools::FileSystem;
1989
1990 let dir = default_session_dir(Path::new(cwd));
1991 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1992 let fs: Arc<dyn FileSystem> = env.clone();
1993 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1994 fs,
1995 sessions_root: dir.to_string_lossy().into_owned(),
1996 clock: Arc::new(SystemClock),
1997 ids: Arc::new(DefaultIdGenerator::new()),
1998 });
1999 let id = harness.session().storage().metadata().id.clone();
2002 let metas = list_session_metadata(cwd)
2003 .await
2004 .map_err(|e| e.to_string())?;
2005 let Some(source) = metas.iter().find(|m| m.id == id) else {
2006 return Err(format!("current session {id} not found on disk"));
2007 };
2008 let fork_storage = repo
2009 .fork_typed(
2010 source,
2011 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2012 id: None,
2013 parent_session_id: Some(source.id.clone()),
2014 cwd: cwd.to_string(),
2015 metadata: None,
2016 },
2017 &rpi_harness::session::types::ForkOptions::default(),
2018 )
2019 .await
2020 .map_err(|e| e.to_string())?;
2021 Ok(Session::new(Arc::new(fork_storage), None))
2022}
2023
2024async fn open_session(
2027 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2028 cwd: &str,
2029) -> Result<Session, BuildError> {
2030 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2031 use rpi_harness::session::types::SessionStorage;
2032 use rpi_tools::FileSystem;
2033
2034 let dir = default_session_dir(Path::new(cwd));
2035 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2036 let fs: Arc<dyn FileSystem> = env.clone();
2037 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2038 fs: fs.clone(),
2039 sessions_root: dir.to_string_lossy().into_owned(),
2040 clock: Arc::new(SystemClock),
2041 ids: Arc::new(DefaultIdGenerator::new()),
2042 });
2043 let storage = repo
2044 .open_by_jsonl_metadata(meta)
2045 .await
2046 .map_err(|e| BuildError::SessionDir(format!("open {}: {e}", meta.path)))?;
2047 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2048 Ok(Session::new(storage_arc, None))
2049}
2050
2051fn ephemeral_session() -> Session {
2053 let storage = Arc::new(InMemorySessionStorage::new(
2054 SessionMetadata {
2055 id: "ephemeral".into(),
2056 created_at: 0,
2057 parent_session_id: None,
2058 },
2059 Arc::new(SystemClock),
2060 Arc::new(DefaultIdGenerator::new()),
2061 ));
2062 Session::new(storage, None)
2063}
2064
2065pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2076 create_jsonl_session_with_id(dir, cwd, None).await
2077}
2078
2079pub(crate) async fn create_jsonl_session_with_id(
2083 dir: &Path,
2084 cwd: &str,
2085 id: Option<String>,
2086) -> Result<Session, String> {
2087 use rpi_harness::session::jsonl::{
2088 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2089 };
2090 use rpi_tools::FileSystem;
2091
2092 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2095 let fs: Arc<dyn FileSystem> = env.clone();
2096
2097 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2098 fs: fs.clone(),
2099 sessions_root: dir.to_string_lossy().into_owned(),
2100 clock: Arc::new(SystemClock),
2101 ids: Arc::new(DefaultIdGenerator::new()),
2102 });
2103
2104 let opts = JsonlSessionCreateOptions {
2105 id, parent_session_id: None,
2107 cwd: cwd.to_string(),
2108 metadata: None,
2109 };
2110 let storage = repo
2111 .create_typed(&opts)
2112 .await
2113 .map_err(|e| format!("create session: {e}"))?;
2114 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2116 Ok(Session::new(storage_arc, None))
2117}
2118
2119fn read_append_target(target: &str) -> Option<String> {
2122 let path = Path::new(target);
2123 if path.is_file() {
2124 std::fs::read_to_string(path).ok()
2125 } else {
2126 None
2127 }
2128}
2129
2130#[cfg(test)]
2131mod tests {
2132 use super::*;
2133 use crate::args::Args;
2134
2135 #[test]
2136 fn default_prompt_mentions_cwd_and_tools() {
2137 let p = default_system_prompt("/tmp/proj");
2138 assert!(p.contains("/tmp/proj"));
2139 assert!(p.contains("read"));
2140 assert!(p.contains("bash"));
2141 assert!(p.contains("edit"));
2142 assert!(p.contains("write"));
2143 assert!(p.contains("docs"));
2144 assert!(!p.contains("- grep"));
2145 assert!(!p.contains("- find"));
2146 assert!(!p.contains("- ls"));
2147 assert!(!p.contains("powershell"));
2148 }
2149
2150 #[test]
2151 fn default_tools_include_docs_lookup() {
2152 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2153 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2154 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2155 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2156 let names: Vec<String> = build_tools(&context, &Args::default())
2157 .iter()
2158 .map(|tool| tool.tool.schema().name.clone())
2159 .collect();
2160
2161 assert_eq!(names, vec!["read", "bash", "edit", "write", "docs"]);
2162 }
2163
2164 #[test]
2165 fn pi_package_loading_is_opt_in_and_respects_no_extensions() {
2166 let args = Args::default();
2167 assert!(!should_load_js_packages(&args));
2168 let resources = package_resources_for(&args, Path::new("."), false);
2169 assert!(resources.packages.is_empty());
2170
2171 let args = Args {
2172 enable_pi_packages: true,
2173 ..Args::default()
2174 };
2175 assert!(should_load_js_packages(&args));
2176
2177 let args = Args {
2178 enable_pi_packages: true,
2179 no_extensions: true,
2180 ..Args::default()
2181 };
2182 assert!(!should_load_js_packages(&args));
2183 assert!(package_resources_for(&args, Path::new("."), false)
2184 .packages
2185 .is_empty());
2186 }
2187
2188 #[test]
2189 fn pi_offline_env_disables_startup_package_remediation() {
2190 struct RestoreEnv {
2191 name: &'static str,
2192 value: Option<std::ffi::OsString>,
2193 }
2194
2195 impl Drop for RestoreEnv {
2196 fn drop(&mut self) {
2197 match self.value.take() {
2198 Some(value) => std::env::set_var(self.name, value),
2199 None => std::env::remove_var(self.name),
2200 }
2201 }
2202 }
2203
2204 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2205 let _restore_config = RestoreEnv {
2206 name: crate::config::CONFIG_DIR_ENV,
2207 value: std::env::var_os(crate::config::CONFIG_DIR_ENV),
2208 };
2209 let _restore_offline = RestoreEnv {
2210 name: crate::args::PI_OFFLINE_ENV,
2211 value: std::env::var_os(crate::args::PI_OFFLINE_ENV),
2212 };
2213 let tmp = tempfile::tempdir().unwrap();
2214 let agent = tmp.path().join("agent");
2215 let cwd = tmp.path().join("project");
2216 let package = agent.join("npm/node_modules/demo");
2217 std::fs::create_dir_all(package.join("extensions")).unwrap();
2218 std::fs::create_dir_all(&cwd).unwrap();
2219 std::fs::write(
2220 package.join("package.json"),
2221 r#"{"name":"demo","version":"1.0.0"}"#,
2222 )
2223 .unwrap();
2224 std::fs::write(
2225 package.join("extensions/index.js"),
2226 "export default () => {};",
2227 )
2228 .unwrap();
2229 std::fs::write(
2230 agent.join("settings.json"),
2231 r#"{"npmCommand":[""],"packages":["npm:demo@2.0.0"]}"#,
2232 )
2233 .unwrap();
2234 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2235 std::env::set_var(crate::args::PI_OFFLINE_ENV, "TrUe");
2236 let args = Args {
2237 enable_pi_packages: true,
2238 offline: false,
2239 ..Args::default()
2240 };
2241
2242 let resources = package_resources_for(&args, &cwd, false);
2243
2244 assert!(resources.packages.is_empty());
2245 assert_eq!(resources.diagnostics.len(), 1);
2246 assert!(resources.diagnostics[0].message.contains("offline"));
2247 assert_eq!(
2248 std::fs::read(package.join("package.json")).unwrap(),
2249 br#"{"name":"demo","version":"1.0.0"}"#
2250 );
2251 }
2252
2253 #[test]
2254 fn package_discovery_uses_the_callers_trust_snapshot() {
2255 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2256 let previous = std::env::var_os(crate::config::CONFIG_DIR_ENV);
2257 let tmp = tempfile::tempdir().unwrap();
2258 let agent = tmp.path().join("agent");
2259 let cwd = tmp.path().join("project");
2260 let package = cwd.join("package");
2261 std::fs::create_dir_all(&agent).unwrap();
2262 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2263 std::fs::create_dir_all(&package).unwrap();
2264 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2265 std::fs::write(
2266 package.join("package.json"),
2267 r#"{"name":"snapshot-package","version":"1.0.0"}"#,
2268 )
2269 .unwrap();
2270 std::fs::write(
2271 cwd.join(".rpi/settings.json"),
2272 serde_json::json!({"packages": [package]}).to_string(),
2273 )
2274 .unwrap();
2275 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2276
2277 let args = Args {
2278 enable_pi_packages: true,
2279 ..Args::default()
2280 };
2281 assert_eq!(package_resources_for(&args, &cwd, true).packages.len(), 1);
2282 assert!(package_resources_for(&args, &cwd, false)
2283 .packages
2284 .is_empty());
2285 assert_eq!(
2286 package_resources_for_update_check(&args, &cwd, true)
2287 .packages
2288 .len(),
2289 1
2290 );
2291 assert!(package_resources_for_update_check(&args, &cwd, false)
2292 .packages
2293 .is_empty());
2294
2295 match previous {
2296 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2297 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2298 }
2299 }
2300
2301 #[test]
2302 fn reload_settings_preflight_is_independent_of_packages_and_respects_trust() {
2303 struct RestoreConfigDir(Option<std::ffi::OsString>);
2304
2305 impl Drop for RestoreConfigDir {
2306 fn drop(&mut self) {
2307 match self.0.take() {
2308 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2309 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2310 }
2311 }
2312 }
2313
2314 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2315 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2316 let tmp = tempfile::tempdir().unwrap();
2317 let agent = tmp.path().join("agent");
2318 let cwd = tmp.path().join("project");
2319 std::fs::create_dir_all(&agent).unwrap();
2320 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2321 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2322 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
2323 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2324 let packages_disabled = Args::default();
2325 let packages_enabled = Args {
2326 enable_pi_packages: true,
2327 ..Args::default()
2328 };
2329
2330 for args in [&packages_disabled, &packages_enabled] {
2331 let trusted = validate_settings_for_reload(args, &cwd, true);
2332 let untrusted = validate_settings_for_reload(args, &cwd, false);
2333 assert!(trusted
2334 .unwrap_err()
2335 .contains("could not load project settings"));
2336 assert!(untrusted.is_ok());
2337 }
2338
2339 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2340 for args in [&packages_disabled, &packages_enabled] {
2341 assert!(validate_settings_for_reload(args, &cwd, false)
2342 .unwrap_err()
2343 .contains("could not load global settings"));
2344 }
2345
2346 let local_only = Args {
2347 dev_local_only: true,
2348 ..Args::default()
2349 };
2350 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
2351 }
2352
2353 #[tokio::test(flavor = "current_thread")]
2354 async fn reload_with_packages_disabled_preserves_live_resources_when_settings_break() {
2355 struct RestoreConfigDir(Option<std::ffi::OsString>);
2356
2357 impl Drop for RestoreConfigDir {
2358 fn drop(&mut self) {
2359 match self.0.take() {
2360 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2361 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2362 }
2363 }
2364 }
2365
2366 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2367 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2368 let tmp = tempfile::tempdir().unwrap();
2369 let agent = tmp.path().join("agent");
2370 let cwd = tmp.path().join("project");
2371 let skill_dir = tmp.path().join("configured-skills");
2372 std::fs::create_dir_all(&agent).unwrap();
2373 std::fs::create_dir_all(&cwd).unwrap();
2374 std::fs::create_dir_all(&skill_dir).unwrap();
2375 std::fs::write(
2376 skill_dir.join("SKILL.md"),
2377 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
2378 )
2379 .unwrap();
2380 std::fs::write(
2381 agent.join("settings.json"),
2382 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2383 )
2384 .unwrap();
2385 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2386
2387 let resolved = crate::provider::resolve(
2388 Some("anthropic"),
2389 Some(crate::provider::DEFAULT_MODEL_ID),
2390 None,
2391 Some("test-key"),
2392 None,
2393 )
2394 .unwrap();
2395 let args = Args {
2396 trust_override: Some(false),
2397 no_session: true,
2398 no_extensions: true,
2399 no_prompt_templates: true,
2400 no_context_files: true,
2401 system_prompt: Some("stable system prompt".into()),
2402 ..Args::default()
2403 };
2404 assert!(!should_load_js_packages(&args));
2405
2406 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2407 let before_resources = harness.get_resources().await.unwrap();
2408 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
2409 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
2410 let before_prompt = harness.get_system_prompt().await.unwrap();
2411 let before_tools: Vec<String> = harness
2412 .get_tools()
2413 .await
2414 .unwrap()
2415 .iter()
2416 .map(|tool| tool.tool.schema().name.clone())
2417 .collect();
2418 let before_bridge = context
2419 .action_bridge
2420 .lock()
2421 .unwrap()
2422 .as_ref()
2423 .unwrap()
2424 .clone();
2425
2426 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2427 let outcome = reload_extension_resources(&harness, &context).await;
2428
2429 assert!(outcome.had_warnings);
2430 assert!(outcome.summary.contains("Settings reload failed"));
2431 assert!(outcome.summary.contains("could not load global settings"));
2432 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
2433 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
2434 let after_tools: Vec<String> = harness
2435 .get_tools()
2436 .await
2437 .unwrap()
2438 .iter()
2439 .map(|tool| tool.tool.schema().name.clone())
2440 .collect();
2441 assert_eq!(after_tools, before_tools);
2442 let after_bridge = context
2443 .action_bridge
2444 .lock()
2445 .unwrap()
2446 .as_ref()
2447 .unwrap()
2448 .clone();
2449 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
2450 }
2451
2452 #[test]
2453 fn reload_preparation_rejects_settings_changed_during_derivation() {
2454 struct RestoreConfigDir(Option<std::ffi::OsString>);
2455
2456 impl Drop for RestoreConfigDir {
2457 fn drop(&mut self) {
2458 match self.0.take() {
2459 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2460 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2461 }
2462 }
2463 }
2464
2465 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2466 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2467 let tmp = tempfile::tempdir().unwrap();
2468 let agent = tmp.path().join("agent");
2469 let cwd = tmp.path().join("project");
2470 let first_skill_dir = tmp.path().join("first-skills");
2471 let second_skill_dir = tmp.path().join("second-skills");
2472 std::fs::create_dir_all(&agent).unwrap();
2473 std::fs::create_dir_all(&cwd).unwrap();
2474 std::fs::write(
2475 agent.join("settings.json"),
2476 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
2477 )
2478 .unwrap();
2479 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2480 let args = Args {
2481 trust_override: Some(false),
2482 no_extensions: true,
2483 no_prompt_templates: true,
2484 ..Args::default()
2485 };
2486
2487 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
2488 std::fs::write(
2489 agent.join("settings.json"),
2490 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
2491 )
2492 .unwrap();
2493 });
2494
2495 let error = result
2496 .err()
2497 .expect("settings mutation must fail preparation");
2498 assert!(error.contains("settings changed while reload inputs were being prepared"));
2499 }
2500
2501 #[tokio::test(flavor = "current_thread")]
2502 async fn reload_uses_frozen_settings_inputs_after_preparation() {
2503 struct RestoreConfigDir(Option<std::ffi::OsString>);
2504
2505 impl Drop for RestoreConfigDir {
2506 fn drop(&mut self) {
2507 match self.0.take() {
2508 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2509 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2510 }
2511 }
2512 }
2513
2514 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2515 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2516 let tmp = tempfile::tempdir().unwrap();
2517 let agent = tmp.path().join("agent");
2518 let cwd = tmp.path().join("project");
2519 let skill_dir = tmp.path().join("configured-skills");
2520 std::fs::create_dir_all(&agent).unwrap();
2521 std::fs::create_dir_all(&cwd).unwrap();
2522 std::fs::create_dir_all(&skill_dir).unwrap();
2523 std::fs::write(
2524 skill_dir.join("SKILL.md"),
2525 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
2526 )
2527 .unwrap();
2528 std::fs::write(
2529 agent.join("settings.json"),
2530 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2531 )
2532 .unwrap();
2533 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2534
2535 let resolved = crate::provider::resolve(
2536 Some("anthropic"),
2537 Some(crate::provider::DEFAULT_MODEL_ID),
2538 None,
2539 Some("test-key"),
2540 None,
2541 )
2542 .unwrap();
2543 let args = Args {
2544 trust_override: Some(false),
2545 no_session: true,
2546 no_extensions: true,
2547 no_prompt_templates: true,
2548 no_context_files: true,
2549 system_prompt: Some("stable system prompt".into()),
2550 ..Args::default()
2551 };
2552 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2553
2554 let outcome = reload_extension_resources_inner(&harness, &context, || {
2555 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2556 })
2557 .await;
2558
2559 assert!(!outcome.summary.contains("Settings reload failed"));
2560 assert_eq!(
2561 outcome.summary,
2562 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
2563 );
2564 let resources = harness.get_resources().await.unwrap();
2565 let skills = resources.skills.as_ref().unwrap();
2566 assert_eq!(skills.len(), 1);
2567 assert_eq!(skills[0].name, "frozen-skill");
2568 }
2569
2570 #[tokio::test]
2571 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
2572 let tmp = tempfile::tempdir().unwrap();
2573 let skill_path = tmp.path().join("explicit-skill.md");
2574 std::fs::write(
2575 &skill_path,
2576 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
2577 )
2578 .unwrap();
2579 let prompt_path = tmp.path().join("explicit-prompt.md");
2580 std::fs::write(
2581 &prompt_path,
2582 "---\ndescription: Explicit prompt\n---\nPrompt body",
2583 )
2584 .unwrap();
2585
2586 let args = Args {
2587 skill: vec![skill_path.clone()],
2588 prompt_template: vec![prompt_path.clone()],
2589 ..Args::default()
2590 };
2591 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
2592 let prompt_paths =
2593 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
2594 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
2595 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
2596
2597 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
2598 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
2599 assert_eq!(skills.skills[0].name, "explicit-skill");
2600
2601 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
2602 assert_eq!(
2603 prompts.prompt_templates.len(),
2604 1,
2605 "{:?}",
2606 prompts.diagnostics
2607 );
2608 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
2609 }
2610
2611 #[test]
2612 fn tool_policy_applies_to_rust_and_js_active_names() {
2613 let names = vec![
2614 "read".to_string(),
2615 "ask_user_question".to_string(),
2616 "write".to_string(),
2617 ];
2618
2619 let args = Args {
2620 tools: Some(vec!["read".into(), "ask_user_question".into()]),
2621 ..Args::default()
2622 };
2623 assert_eq!(
2624 filter_active_tool_names(names.clone(), &args),
2625 vec!["read", "ask_user_question"]
2626 );
2627
2628 let args = Args {
2629 exclude_tools: Some(vec!["ask_user_question".into()]),
2630 ..Args::default()
2631 };
2632 assert_eq!(
2633 filter_active_tool_names(names.clone(), &args),
2634 vec!["read", "write"]
2635 );
2636
2637 let args = Args {
2638 no_tools: true,
2639 ..Args::default()
2640 };
2641 assert!(filter_active_tool_names(names, &args).is_empty());
2642 }
2643
2644 #[test]
2645 fn select_ephemeral_when_no_session() {
2646 let args = Args {
2647 no_session: true,
2648 ..Args::default()
2649 };
2650 let cwd = Path::new("/tmp");
2651 assert!(matches!(
2652 select_session(&args, cwd),
2653 SessionSelection::Ephemeral
2654 ));
2655 }
2656
2657 #[test]
2658 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
2659 let defaults = Args::default();
2660 assert!(resolve_project_trust(
2661 &defaults,
2662 Path::new("C:/definitely-not-a-project")
2663 ));
2664
2665 let denied = Args {
2666 trust_override: Some(false),
2667 ..Args::default()
2668 };
2669 assert!(!resolve_project_trust(
2670 &denied,
2671 Path::new("C:/definitely-not-a-project")
2672 ));
2673 }
2674
2675 #[tokio::test(flavor = "current_thread")]
2676 async fn build_preserves_the_supplied_startup_project_snapshot() {
2677 struct RestoreConfigDir(Option<std::ffi::OsString>);
2678
2679 impl Drop for RestoreConfigDir {
2680 fn drop(&mut self) {
2681 match self.0.take() {
2682 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2683 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2684 }
2685 }
2686 }
2687
2688 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2689 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2690 let tmp = tempfile::tempdir().unwrap();
2691 let agent = tmp.path().join("agent");
2692 let cwd = tmp.path().join("project");
2693 std::fs::create_dir_all(&agent).unwrap();
2694 std::fs::create_dir_all(&cwd).unwrap();
2695 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2696
2697 let resolved = crate::provider::resolve(
2698 Some("anthropic"),
2699 Some(crate::provider::DEFAULT_MODEL_ID),
2700 None,
2701 Some("test-key"),
2702 None,
2703 )
2704 .unwrap();
2705 let args = Args {
2706 trust_override: Some(false),
2707 no_session: true,
2708 no_tools: true,
2709 no_extensions: true,
2710 no_skills: true,
2711 no_prompt_templates: true,
2712 no_context_files: true,
2713 system_prompt: Some("test prompt".into()),
2714 ..Args::default()
2715 };
2716
2717 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
2718
2719 assert_eq!(context.cwd, cwd);
2720 assert!(context.project_trusted);
2721 }
2722
2723 #[test]
2724 fn select_latest_for_continue_and_resume() {
2725 let args = Args {
2726 continue_session: true,
2727 ..Args::default()
2728 };
2729 let cwd = Path::new("/tmp");
2730 assert!(matches!(
2731 select_session(&args, cwd),
2732 SessionSelection::Latest
2733 ));
2734
2735 let args = Args {
2736 resume: true,
2737 ..Args::default()
2738 };
2739 assert!(matches!(
2740 select_session(&args, cwd),
2741 SessionSelection::Latest
2742 ));
2743 }
2744
2745 #[test]
2746 fn select_by_id_for_session_flag() {
2747 let args = Args {
2748 session: Some("01a02ece".into()),
2749 ..Args::default()
2750 };
2751 let cwd = Path::new("/tmp");
2752 assert!(matches!(
2753 select_session(&args, cwd),
2754 SessionSelection::ById { id } if id == "01a02ece"
2755 ));
2756 }
2757
2758 #[test]
2759 fn select_new_with_custom_dir() {
2760 let args = Args {
2761 session_dir: Some(PathBuf::from("/tmp/sess")),
2762 ..Args::default()
2763 };
2764 let cwd = Path::new("/tmp");
2765 match select_session(&args, cwd) {
2766 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
2767 other => panic!("expected New, got {other:?}"),
2768 }
2769 }
2770
2771 #[test]
2772 fn select_new_default_dir() {
2773 let args = Args::default();
2774 let cwd = Path::new("/proj");
2775 match select_session(&args, cwd) {
2776 SessionSelection::New { dir, .. } => {
2777 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
2778 }
2779 other => panic!("expected New, got {other:?}"),
2780 }
2781 }
2782
2783 #[test]
2784 fn default_session_dir_prefers_rpi_but_reads_legacy_pi() {
2785 let tmp = tempfile::tempdir().unwrap();
2786 let cwd = tmp.path();
2787 std::fs::create_dir_all(cwd.join(".pi/sessions")).unwrap();
2788 assert_eq!(default_session_dir(cwd), cwd.join(".pi/sessions"));
2789 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
2790 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
2791 }
2792
2793 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2794 async fn ephemeral_session_builds_roundtrips() {
2795 let s = ephemeral_session();
2799 let leaf = s.get_leaf_id().await;
2800 assert!(leaf.is_ok());
2801 }
2802
2803 }