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