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, DrivingMode,
48 HarnessTool, HarnessToolExecution, ToolReplay,
49};
50use rpi_tools::{
51 create_bash_tool, create_edit_tool, create_powershell_tool, create_read_tool,
52 create_write_tool, ExecutionToolContext, 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- Keep your working state in your VISIBLE replies, not only in reasoning. Reasoning is not
141 carried into your next turn: only the text you write and the tool output you produce come
142 back. Before each batch of tool calls, write one short line naming the task you are on and
143 what remains. When you finish a step, say which one is done. If you keep the plan only in
144 your head you will re-derive it from scratch every turn.
145- Track multi-step work with the todo tool instead of re-listing the plan in prose: add the
146 steps once, then mark them done as you go. Re-stating the same plan without acting on it is
147 a bug, not progress.
148- Read only what you need. If a file or search result was already shown earlier in this
149 conversation, use it instead of fetching it again.
150- When unsure about rpi commands, extensions, Pi package compatibility, or .rpi configuration, consult the project documentation before guessing
151
152Current working directory: {cwd}"
153 )
154}
155
156#[derive(Debug, Clone)]
163pub enum SessionSelection {
164 Ephemeral,
166 New { dir: PathBuf, name: Option<String> },
168 Latest,
170 ById { id: String },
173 ByExactId { id: String },
175 Fork { source: String },
178}
179
180pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
182 if args.no_session {
183 return SessionSelection::Ephemeral;
184 }
185 if args.continue_session || args.resume {
186 return SessionSelection::Latest;
188 }
189 if let Some(s) = &args.fork {
190 return SessionSelection::Fork { source: s.clone() };
191 }
192 if let Some(s) = &args.session_id {
193 return SessionSelection::ByExactId { id: s.clone() };
194 }
195 if let Some(s) = &args.session {
196 return SessionSelection::ById { id: s.clone() };
197 }
198 let dir = args
201 .session_dir
202 .clone()
203 .or_else(|| {
204 crate::settings::load_settings()
205 .ok()
206 .and_then(|settings| settings.session_dir)
207 })
208 .unwrap_or_else(|| default_session_dir(cwd));
209 SessionSelection::New {
210 dir,
211 name: args.name.clone(),
212 }
213}
214
215pub fn default_session_dir(cwd: &Path) -> PathBuf {
219 let preferred = cwd.join(".rpi").join("sessions");
220 let legacy = cwd.join(".pi").join("sessions");
221 if preferred.exists() || !legacy.exists() {
222 preferred
223 } else {
224 legacy
225 }
226}
227
228pub async fn build(
246 resolved: &ResolvedModel,
247 args: &Args,
248 cwd: &Path,
249 project_trusted: bool,
250) -> Result<
251 (
252 AgentHarness,
253 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
254 ReloadContext,
255 ),
256 BuildError,
257> {
258 let cwd_str = cwd.to_string_lossy().to_string();
259 if !project_trusted && args.verbose {
260 eprintln!(
261 "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)"
262 );
263 }
264 let package_resources = if args.dev_local_only {
268 crate::packages::PackageResources::default()
269 } else {
270 package_resources_for(args, cwd, project_trusted)
271 };
272
273 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
288 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
289 })?;
290 let catalog = crate::provider::available_catalog(resolved);
291 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
292 catalog.clone(),
293 cwd.to_path_buf(),
294 runtime.clone(),
295 args.unknown_flags.clone(),
296 );
297 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = Arc::new(action_host);
298 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
308 let ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
314 let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
315 runtime.clone(),
316 host_arc,
317 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
318 ui_dialog_mailbox.clone(),
319 );
320
321 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
323 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
324 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
325 let _registry = Arc::new(MutationQueueRegistry::new());
326 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
329
330 let tools = build_tools(&ctx, args);
331 let mut tools = tools;
332
333 let extension_session = if args.no_extensions {
341 ExtensionSession::none()
342 } else {
343 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
344 };
345 let js_extension_session = if !should_load_js_packages(args) {
346 None
347 } else {
348 let paths = js_extension_paths(args, cwd, project_trusted, &package_resources);
349 let js_context = serde_json::json!({
350 "cwd": cwd_str,
351 "theme": resolved.theme.clone(),
352 "currentModel": resolved.model.clone(),
353 "models": catalog.clone(),
354 "thinkingLevel": resolved.thinking_level,
355 });
356 match crate::js_extensions::JsExtensionSession::load_with_context(
357 &paths,
358 args.verbose,
359 js_context,
360 ) {
361 Ok(session) => session,
362 Err(error) => {
363 eprintln!("warning: JS/TS extensions were not loaded: {error}");
364 None
365 }
366 }
367 };
368 if js_extension_session.is_some() {
369 eprintln!(
370 "warning: enabled Pi JS/TS extensions execute with the current user's permissions"
371 );
372 }
373 if let Some(session) = &js_extension_session {
374 if let Err(error) =
375 session.enable_provider_runtime(resolved.provider.clone(), runtime.clone())
376 {
377 if args.verbose {
378 eprintln!("warning: JS provider runtime was not enabled: {error}");
379 }
380 }
381 }
382 if args.verbose {
383 if let Some(session) = &js_extension_session {
384 let info = session.backend_info();
385 eprintln!(
386 "JS extension backend: {} v{} ({})",
387 info.name,
388 info.api_version,
389 info.capability_names().join(", ")
390 );
391 }
392 if let Some(s) = extension_session.summary() {
393 eprintln!("extensions: {s}");
394 }
395 report_deferred_renderers(&extension_session);
396 }
397 merge_extension_tools(&mut tools, &extension_session, args);
398 if let Some(session) = &js_extension_session {
399 merge_js_extension_tools(&mut tools, session, args);
400 if args.verbose && !session.commands.is_empty() {
401 eprintln!("JS extension commands: {}", session.commands.join(", "));
402 }
403 }
404 let mut active = active_tool_names(&tools, args);
405 if let Some(session) = &js_extension_session {
410 let js_names = session.tool_names();
411 if let Some(js_active) = session.active_tools() {
412 active.retain(|name| !js_names.iter().any(|js| js == name));
413 active.extend(js_active.into_iter().filter(|name| {
414 js_names.iter().any(|js| js == name) && tool_name_allowed(name, args)
415 }));
416 }
417 }
418 active = filter_active_tool_names(active, args);
419
420 let selection = select_session(args, cwd);
422 let session = build_session(&selection, &cwd_str).await?;
423 if let Some(js) = &js_extension_session {
424 let session_id = session
425 .get_metadata()
426 .await
427 .ok()
428 .map(|metadata| metadata.id);
429 let leaf_id = session.get_leaf_id().await.ok().flatten();
430 if let Some(session_id) = session_id {
431 let branch = session
432 .find_entries_on_branch(&EntryQuery::default(), &BranchBounds::default())
433 .await
434 .ok()
435 .unwrap_or_default();
436 let branch_json =
437 serde_json::to_value(&branch).unwrap_or_else(|_| serde_json::json!([]));
438 let runtime_context = serde_json::json!({
439 "session": {
440 "id": session_id,
441 "leafId": leaf_id,
442 "branch": branch_json,
443 "entries": branch_json.clone(),
444 },
445 });
446 if let Err(error) = js.set_runtime_context(runtime_context) {
447 if args.verbose {
448 eprintln!("warning: could not sync JS session context: {error}");
449 }
450 }
451 }
452 }
453
454 let base_prompt = match args.system_prompt.as_deref() {
462 Some(explicit) => explicit.to_string(),
463 None if project_trusted => {
464 match discover_system_prompt_file_with_packages(cwd, &package_resources) {
465 Some(path) => std::fs::read_to_string(&path)
466 .unwrap_or_else(|_| default_system_prompt(&cwd_str)),
467 None => default_system_prompt(&cwd_str),
468 }
469 }
470 None => default_system_prompt(&cwd_str),
471 };
472
473 let mut append_texts: Vec<String> = Vec::new();
480 for extra in &args.append_system_prompt {
481 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
482 append_texts.push(text);
483 }
484 if args.append_system_prompt.is_empty() {
485 if let Some(path) = project_trusted
486 .then(|| discover_append_system_prompt_file_with_packages(cwd, &package_resources))
487 .flatten()
488 {
489 if let Ok(text) = std::fs::read_to_string(&path) {
490 append_texts.push(text);
491 }
492 }
493 }
494 let append_join = if append_texts.is_empty() {
495 None
496 } else {
497 Some(append_texts.join("\n\n"))
498 };
499
500 let agent_dir = crate::config::agent_dir().ok();
512
513 let discovered = extension_session
529 .snapshot_arc()
530 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
531 .unwrap_or_default();
532
533 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
534 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
535 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
536 if !args.no_skills {
537 let mut dirs = if args.dev_local_only {
538 project_skill_dirs(cwd)
539 } else if project_trusted {
540 skill_dirs(cwd)
541 } else {
542 global_skill_dirs()
543 };
544 dirs.extend(args.skill.iter().cloned());
545 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
546 if let Some(session) = &js_extension_session {
547 dirs.extend(session.resources.skill_paths.iter().cloned());
548 }
549 dirs.extend(package_resources.skill_dirs());
550 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
551 skills = result.skills;
552 skill_diags = result.diagnostics;
553 resource_diags.extend(result.resource_diagnostics);
554 }
555
556 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
557 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
558 if !args.no_prompt_templates {
559 let mut dirs = if args.dev_local_only {
560 project_prompt_template_dirs(cwd)
561 } else if project_trusted {
562 prompt_template_dirs(cwd)
563 } else {
564 global_prompt_template_dirs()
565 };
566 dirs.extend(args.prompt_template.iter().cloned());
567 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
568 if let Some(session) = &js_extension_session {
569 dirs.extend(session.resources.prompt_paths.iter().cloned());
570 }
571 dirs.extend(package_resources.prompt_dirs());
572 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
573 prompt_templates = result.prompt_templates;
574 prompt_diags = result.diagnostics;
575 resource_diags.extend(result.resource_diagnostics);
576 }
577
578 let context_block = if args.no_context_files || !project_trusted {
579 String::new()
580 } else {
581 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
586 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
587 format_project_context(&files)
588 };
589
590 if args.verbose {
592 for d in &package_resources.diagnostics {
593 eprintln!("warning: package {}: {}", d.spec, d.message);
594 }
595 for d in &skill_diags {
596 eprintln!(
597 "warning: skill {} ({}): {}",
598 d.path,
599 d.code.as_str(),
600 d.message
601 );
602 }
603 for d in &prompt_diags {
604 eprintln!(
605 "warning: prompt template {} ({}): {}",
606 d.path,
607 d.code.as_str(),
608 d.message
609 );
610 }
611 for d in &resource_diags {
613 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
614 eprintln!(
615 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
616 c.resource_type.as_str(),
617 c.name,
618 c.winner_path,
619 c.loser_path
620 );
621 }
622 }
623 }
624
625 let system_prompt = compose_system_prompt(
634 Some(&base_prompt),
635 &[], if context_block.is_empty() {
637 None
638 } else {
639 Some(&context_block)
640 },
641 append_join.as_deref(),
642 );
643
644 if args.debug_system_prompt {
653 eprintln!("=== --debug-system-prompt ===");
654 let base_src = if args.system_prompt.is_some() {
655 "--system-prompt"
656 } else if discover_system_prompt_file_with_packages(cwd, &package_resources).is_some() {
657 "SYSTEM.md"
658 } else {
659 "default"
660 };
661 eprintln!("[base source: {base_src}]");
662 eprintln!("--- base ---\n{base_prompt}");
663 if let Some(append) = append_join.as_deref() {
664 eprintln!("--- append ---\n{append}");
665 } else {
666 eprintln!("--- append: (none) ---");
667 }
668 if context_block.is_empty() {
669 eprintln!("--- context: (none) ---");
670 } else {
671 eprintln!("--- context ---{context_block}");
672 }
673 let visible_skills = skills
674 .iter()
675 .filter(|s| s.disable_model_invocation != Some(true))
676 .count();
677 eprintln!(
678 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
679 skills.len(),
680 visible_skills,
681 skills.len() - visible_skills
682 );
683 for s in &skills {
684 let hidden = if s.disable_model_invocation == Some(true) {
685 " [hidden]"
686 } else {
687 ""
688 };
689 eprintln!(" {}{hidden} — {}", s.name, s.description);
690 }
691 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
692 for t in &prompt_templates {
693 eprintln!(" /{}", t.name);
694 }
695 eprintln!(
699 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
700 discovered.skill_paths.len(),
701 discovered.prompt_paths.len(),
702 discovered.theme_paths.len(),
703 );
704 for p in &discovered.skill_paths {
705 eprintln!(" skill: {p}");
706 }
707 for p in &discovered.prompt_paths {
708 eprintln!(" prompt: {p}");
709 }
710 eprintln!(
711 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
712 );
713 eprintln!("=== end --debug-system-prompt ===");
714 }
715
716 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
721 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
722 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
726
727 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
736 Some(snapshot) => {
737 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
738 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
739 }
740 None => broadcast_emitter,
741 };
742
743 let saved_settings = crate::settings::load_settings().unwrap_or_default();
747
748 if let Some(proxy) = &saved_settings.http_proxy {
753 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
754 if std::env::var_os(key).is_none() {
755 std::env::set_var(key, proxy);
756 }
757 }
758 }
759
760 let options = AgentHarnessOptions {
761 model: resolved.model.clone(),
762 thinking_level: resolved.thinking_level,
763 active_tool_names: active,
764 tools,
765 system_prompt: Some(system_prompt),
766 resources: AgentHarnessResources {
767 skills: if skills.is_empty() {
768 None
769 } else {
770 Some(skills)
771 },
772 prompt_templates: if prompt_templates.is_empty() {
773 None
774 } else {
775 Some(prompt_templates)
776 },
777 },
778 allow_existing_session: matches!(
781 selection,
782 SessionSelection::Latest
783 | SessionSelection::ById { .. }
784 | SessionSelection::ByExactId { .. }
785 | SessionSelection::Fork { .. }
786 ),
787 stream_options: AgentHarnessStreamOptions {
788 timeout: args.timeout,
789 ..Default::default()
790 },
791 retry: saved_settings.retry_policy(),
796 compaction: saved_settings.compaction_settings(),
797 steering_mode: saved_settings.steering_mode(),
798 follow_up_mode: saved_settings.follow_up_mode(),
799 tool_execution: HarnessToolExecution::default(),
800 drive: DrivingMode::default(),
801 session,
802 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
810 to_provider_messages: None,
811 entry_projectors: Default::default(),
812 agent_emitter: Some(emitter),
813 before_tool_call: None,
819 after_tool_call: None,
820 transform_context: None,
821 entry_transforms: Vec::new(),
822 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
828 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
829 };
830
831 let harness = match AgentHarness::create(options).await {
832 Ok(h) => {
833 crate::extensions_actions::HarnessActionHost::set_harness(
836 &harness_cell,
837 Arc::new(h.clone()),
838 );
839 h
840 }
841 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
842 };
843
844 {
852 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
853 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
854 Ok(reports) => {
855 for (lane, report) in reports {
856 if report.is_clean() {
857 continue;
858 }
859 if report.is_corrupt() {
860 eprintln!(
861 "warning: session lane '{lane}' is corrupt ({} open operations); \
862 refusing to auto-repair — inspect the session file",
863 report.open_operations.len()
864 );
865 continue;
866 }
867 for decision in runtime.reconcile(&report) {
868 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
869 if args.verbose {
870 eprintln!(
871 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
872 );
873 }
874 }
875 }
876 runtime.emit_recovery_events(harness.events(), &report);
877 }
878 }
879 Err(e) => {
880 if args.verbose {
881 eprintln!("warning: session recovery sweep failed: {e}");
882 }
883 }
884 }
885 }
886
887 let reload_context = ReloadContext {
894 extension_session: Arc::new(Mutex::new(extension_session)),
895 js_extension_session: js_extension_session.clone(),
896 package_resources: Arc::new(package_resources.clone()),
897 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
898 catalog,
899 gateway: resolved.provider.clone(),
900 runtime: runtime.clone(),
901 cwd: cwd.to_path_buf(),
902 project_trusted,
903 args: args.clone(),
904 resolved_model: resolved.model.clone(),
905 broadcast: broadcast_for_context,
906 mailbox: reload_mailbox,
907 ui_dialog: ui_dialog_mailbox,
908 dev_extension: None,
909 };
910
911 Ok((harness, event_rx, reload_context))
912}
913
914pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
959
960pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
965
966#[derive(Clone)]
974pub struct ReloadContext {
975 pub extension_session: ExtensionSessionCell,
977 pub js_extension_session: Option<crate::js_extensions::JsExtensionSession>,
979 pub package_resources: Arc<crate::packages::PackageResources>,
983 pub action_bridge: ActionBridgeCell,
985 pub catalog: Vec<rpi_ai::Model>,
990 pub gateway: Arc<dyn Provider>,
993 pub runtime: tokio::runtime::Handle,
997 pub cwd: PathBuf,
999 pub project_trusted: bool,
1003 pub args: Args,
1009 pub resolved_model: rpi_ai::Model,
1012 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
1018 pub mailbox: rpi_extensions::ReloadMailbox,
1025 pub ui_dialog: rpi_extensions::UiDialogMailbox,
1031 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
1035}
1036
1037pub async fn dispatch_session_event_async(
1052 reload_context: &ReloadContext,
1053 tag: rpi_plugin_sdk::EventTag,
1054) -> Option<String> {
1055 let (snapshot, keepalive) = {
1061 let session = reload_context.extension_session.lock().unwrap();
1062 (session.snapshot_arc(), session.keepalive())
1063 };
1064 match snapshot {
1065 Some(snapshot) => {
1066 let _keepalive = keepalive;
1067 rpi_extensions::dispatch_lifecycle_event(&snapshot, tag).await
1068 }
1069 None => None,
1070 }
1071}
1072
1073pub struct ReloadOutcome {
1076 pub summary: String,
1079 pub had_warnings: bool,
1081 pub details: Vec<String>,
1086 pub had_errors: bool,
1090}
1091
1092struct PreparedReloadInputs {
1093 package_resources: crate::packages::PackageResources,
1094 extension_dirs: Vec<PathBuf>,
1095 skill_base_dirs: Vec<PathBuf>,
1096 prompt_base_dirs: Vec<PathBuf>,
1097}
1098
1099fn append_reload_resource_paths(
1104 mut paths: Vec<PathBuf>,
1105 explicit: &[PathBuf],
1106 discovered: &[String],
1107 js_paths: &[PathBuf],
1108 package_paths: &[PathBuf],
1109) -> Vec<PathBuf> {
1110 paths.extend(explicit.iter().cloned());
1111 paths.extend(discovered.iter().map(PathBuf::from));
1112 paths.extend(js_paths.iter().cloned());
1113 paths.extend(package_paths.iter().cloned());
1114 paths
1115}
1116
1117pub async fn reload_extension_resources(
1129 harness: &AgentHarness,
1130 ctx: &ReloadContext,
1131) -> ReloadOutcome {
1132 reload_extension_resources_inner(harness, ctx, || {}).await
1133}
1134
1135async fn reload_extension_resources_inner<F>(
1136 harness: &AgentHarness,
1137 ctx: &ReloadContext,
1138 after_prepare: F,
1139) -> ReloadOutcome
1140where
1141 F: FnOnce() + Send,
1142{
1143 let mut effective_args = ctx.args.clone();
1148 if let Some(dev) = &ctx.dev_extension {
1149 if let Err(error) = dev
1150 .rebuild()
1151 .and_then(|_| dev.apply_to_args(&mut effective_args))
1152 {
1153 return ReloadOutcome {
1154 summary: format!(
1155 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1156 dev.package_name()
1157 ),
1158 had_warnings: true,
1159 details: Vec::new(),
1160 had_errors: true,
1161 };
1162 }
1163 }
1164 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1165 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1166 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1167 Ok(prepared) => prepared,
1168 Err(error) => {
1169 return ReloadOutcome {
1170 summary: format!(
1171 "Settings reload failed: {error}. Keeping the currently loaded resources."
1172 ),
1173 had_warnings: true,
1174 details: Vec::new(),
1175 had_errors: true,
1176 };
1177 }
1178 };
1179 after_prepare();
1180 let PreparedReloadInputs {
1181 package_resources,
1182 extension_dirs,
1183 skill_base_dirs,
1184 prompt_base_dirs,
1185 } = prepared;
1186 let mut warnings = false;
1187
1188 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1195 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1196 Some(b) => b.clone_host(),
1197 None => {
1198 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1201 ctx.catalog.clone(),
1202 ctx.cwd.clone(),
1203 ctx.runtime.clone(),
1204 ctx.args.unknown_flags.clone(),
1205 );
1206 crate::extensions_actions::HarnessActionHost::set_harness(
1207 &_cell,
1208 Arc::new(harness.clone()),
1209 );
1210 Arc::new(action_host)
1211 }
1212 };
1213
1214 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1215 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1216 ctx.runtime.clone(),
1217 host,
1218 reload_cb,
1219 ctx.ui_dialog.clone(),
1220 );
1221
1222 let extension_session = if effective_args.no_extensions {
1223 rpi_extensions::ExtensionSession::none()
1224 } else {
1225 load_extensions_from_dirs(
1226 &effective_args,
1227 &extension_dirs,
1228 Some(Arc::clone(&fresh_bridge)),
1229 )
1230 };
1231 if extension_session.is_empty() && !effective_args.no_extensions {
1232 }
1235 if effective_args.verbose {
1236 if let Some(s) = extension_session.summary() {
1237 eprintln!("reload: {s}");
1238 }
1239 report_deferred_renderers(&extension_session);
1240 }
1241
1242 {
1256 let mut session_guard = ctx.extension_session.lock().unwrap();
1257 let old_session = std::mem::replace(
1258 &mut *session_guard,
1259 rpi_extensions::ExtensionSession::none(),
1260 );
1261 if let Some(old_snap) = old_session.snapshot_arc() {
1262 old_snap.active_flag().store(false, Ordering::SeqCst);
1266 }
1267 }
1271 if let Some(old_b) = old_bridge {
1272 old_b.invalidate();
1273 }
1274
1275 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1278 *ctx.extension_session.lock().unwrap() = extension_session.clone();
1279
1280 let discovered = extension_session
1282 .snapshot_arc()
1283 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1284 .unwrap_or_default();
1285
1286 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1288 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1289
1290 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1291 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1292 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1293 if !effective_args.no_skills {
1294 let js_paths: &[PathBuf] = ctx
1298 .js_extension_session
1299 .as_ref()
1300 .map(|js| js.resources.skill_paths.as_slice())
1301 .unwrap_or(&[]);
1302 let package_paths = package_resources.skill_dirs();
1303 let dirs = append_reload_resource_paths(
1304 skill_base_dirs,
1305 &effective_args.skill,
1306 &discovered.skill_paths,
1307 js_paths,
1308 &package_paths,
1309 );
1310 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1311 skills = result.skills;
1312 skill_diags = result.diagnostics;
1313 resource_diags.extend(result.resource_diagnostics);
1314 }
1315
1316 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1317 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1318 if !effective_args.no_prompt_templates {
1319 let js_paths: &[PathBuf] = ctx
1320 .js_extension_session
1321 .as_ref()
1322 .map(|js| js.resources.prompt_paths.as_slice())
1323 .unwrap_or(&[]);
1324 let package_paths = package_resources.prompt_dirs();
1325 let dirs = append_reload_resource_paths(
1326 prompt_base_dirs,
1327 &effective_args.prompt_template,
1328 &discovered.prompt_paths,
1329 js_paths,
1330 &package_paths,
1331 );
1332 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1333 prompt_templates = result.prompt_templates;
1334 prompt_diags = result.diagnostics;
1335 resource_diags.extend(result.resource_diagnostics);
1336 }
1337
1338 let context_block = if effective_args.no_context_files {
1339 String::new()
1340 } else {
1341 let agent_dir = crate::config::agent_dir().ok();
1342 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1343 let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
1344 format_project_context(&files)
1345 };
1346
1347 let mut details: Vec<String> = Vec::new();
1348 if !skill_diags.is_empty()
1349 || !prompt_diags.is_empty()
1350 || !package_resources.diagnostics.is_empty()
1351 || !resource_diags.is_empty()
1352 {
1353 warnings = true;
1354 for d in &package_resources.diagnostics {
1359 details.push(format!("warning: package {}: {}", d.spec, d.message));
1360 }
1361 for d in &skill_diags {
1362 details.push(format!(
1363 "warning: skill {} ({}): {}",
1364 d.path,
1365 d.code.as_str(),
1366 d.message
1367 ));
1368 }
1369 for d in &prompt_diags {
1370 details.push(format!(
1371 "warning: prompt template {} ({}): {}",
1372 d.path,
1373 d.code.as_str(),
1374 d.message
1375 ));
1376 }
1377 for d in &resource_diags {
1379 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1380 details.push(format!(
1381 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1382 c.resource_type.as_str(),
1383 c.name,
1384 c.winner_path,
1385 c.loser_path
1386 ));
1387 }
1388 }
1389 }
1390
1391 let base_prompt = match effective_args.system_prompt.as_deref() {
1393 Some(explicit) => explicit.to_string(),
1394 None => match discover_system_prompt_file_with_packages(&ctx.cwd, &package_resources) {
1395 Some(path) => {
1396 std::fs::read_to_string(&path).unwrap_or_else(|_| default_system_prompt(&cwd_str))
1397 }
1398 None => default_system_prompt(&cwd_str),
1399 },
1400 };
1401 let mut append_texts: Vec<String> = Vec::new();
1402 for extra in &effective_args.append_system_prompt {
1403 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
1404 append_texts.push(text);
1405 }
1406 if effective_args.append_system_prompt.is_empty() {
1407 if let Some(path) =
1408 discover_append_system_prompt_file_with_packages(&ctx.cwd, &package_resources)
1409 {
1410 if let Ok(text) = std::fs::read_to_string(&path) {
1411 append_texts.push(text);
1412 }
1413 }
1414 }
1415 let append_join = if append_texts.is_empty() {
1416 None
1417 } else {
1418 Some(append_texts.join("\n\n"))
1419 };
1420 let system_prompt = compose_system_prompt(
1421 Some(&base_prompt),
1422 &[],
1423 if context_block.is_empty() {
1424 None
1425 } else {
1426 Some(&context_block)
1427 },
1428 append_join.as_deref(),
1429 );
1430
1431 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1433 Some(snapshot) => {
1434 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1435 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1436 }
1437 None => ctx.broadcast.clone(),
1438 };
1439
1440 let resources = AgentHarnessResources {
1442 skills: if skills.is_empty() {
1443 None
1444 } else {
1445 Some(skills.clone())
1446 },
1447 prompt_templates: if prompt_templates.is_empty() {
1448 None
1449 } else {
1450 Some(prompt_templates.clone())
1451 },
1452 };
1453 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1454 let _ = harness.set_resources(resources).await;
1455 let _ = harness.set_agent_emitter(Some(emitter)).await;
1456 let _ = harness
1457 .set_models(build_models_with_extensions_for_reload(
1458 &ctx.gateway,
1459 &extension_session,
1460 ctx.runtime.clone(),
1461 ))
1462 .await;
1463 let _ = harness
1464 .set_provider_hooks(
1465 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1466 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1467 )
1468 .await;
1469
1470 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1474 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1475 let mut tools = build_tools(&tool_ctx, &effective_args);
1476 merge_extension_tools(&mut tools, &extension_session, &effective_args);
1477 if let Some(js) = &ctx.js_extension_session {
1478 merge_js_extension_tools(&mut tools, js, &effective_args);
1479 }
1480 let mut active = active_tool_names(&tools, &effective_args);
1481 if let Some(js) = &ctx.js_extension_session {
1482 let js_names = js.tool_names();
1483 if let Some(js_active) = js.active_tools() {
1484 active.retain(|name| {
1485 tool_name_allowed(name, &effective_args)
1486 && !js_names.iter().any(|js_name| js_name == name)
1487 });
1488 active.extend(js_active.into_iter().filter(|name| {
1489 js_names.iter().any(|js_name| js_name == name)
1490 && tool_name_allowed(name, &effective_args)
1491 }));
1492 }
1493 }
1494 active = filter_active_tool_names(active, &effective_args);
1495 let _ = harness.set_tools(tools, Some(active)).await;
1496
1497 let summary = format!(
1498 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1499 extension_session.loaded_paths().len(),
1500 skills.len(),
1501 prompt_templates.len(),
1502 );
1503 ReloadOutcome {
1504 summary,
1505 had_warnings: warnings,
1506 details,
1507 had_errors: false,
1510 }
1511}
1512
1513#[derive(Clone, Debug, PartialEq, Eq)]
1514struct ReloadSettingsFields {
1515 packages: Option<Vec<crate::settings::PackageSetting>>,
1516 npm_command: Option<Vec<String>>,
1517 skill_dirs: Option<Vec<String>>,
1518 prompt_dirs: Option<Vec<String>>,
1519 extension_dirs: Option<Vec<String>>,
1520}
1521
1522impl From<crate::settings::Settings> for ReloadSettingsFields {
1523 fn from(settings: crate::settings::Settings) -> Self {
1524 Self {
1525 packages: settings.packages,
1526 npm_command: settings.npm_command,
1527 skill_dirs: settings.skill_dirs,
1528 prompt_dirs: settings.prompt_dirs,
1529 extension_dirs: settings.extension_dirs,
1530 }
1531 }
1532}
1533
1534#[derive(Clone, Debug, PartialEq, Eq)]
1535struct ReloadSettingsSnapshot {
1536 global: ReloadSettingsFields,
1537 project: Option<ReloadSettingsFields>,
1538}
1539
1540fn reload_reads_settings(args: &Args) -> bool {
1541 !args.dev_local_only
1542 && (should_load_js_packages(args)
1543 || !args.no_extensions
1544 || !args.no_skills
1545 || !args.no_prompt_templates)
1546}
1547
1548fn load_reload_settings_snapshot(
1552 args: &Args,
1553 cwd: &Path,
1554 project_trusted: bool,
1555) -> Result<Option<ReloadSettingsSnapshot>, String> {
1556 if !reload_reads_settings(args) {
1557 return Ok(None);
1558 }
1559 let global = crate::settings::load_settings()
1560 .map_err(|error| format!("could not load global settings: {error}"))?
1561 .into();
1562 let project = if project_trusted {
1563 crate::settings::load_active_project_settings(cwd)
1564 .map_err(|error| format!("could not load project settings: {error}"))?
1565 .map(|(_, settings)| settings.into())
1566 } else {
1567 None
1568 };
1569 Ok(Some(ReloadSettingsSnapshot { global, project }))
1570}
1571
1572#[cfg(test)]
1575fn validate_settings_for_reload(
1576 args: &Args,
1577 cwd: &Path,
1578 project_trusted: bool,
1579) -> Result<(), String> {
1580 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1581}
1582
1583fn prepare_reload_inputs(
1584 args: &Args,
1585 cwd: &Path,
1586 project_trusted: bool,
1587) -> Result<PreparedReloadInputs, String> {
1588 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1589}
1590
1591fn prepare_reload_inputs_inner<F>(
1592 args: &Args,
1593 cwd: &Path,
1594 project_trusted: bool,
1595 before_verify: F,
1596) -> Result<PreparedReloadInputs, String>
1597where
1598 F: FnOnce(),
1599{
1600 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1601
1602 let package_resources = if args.dev_local_only {
1603 crate::packages::PackageResources::default()
1604 } else {
1605 package_resources_for(args, cwd, project_trusted)
1606 };
1607
1608 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1609 Vec::new()
1610 } else if project_trusted {
1611 extension_dirs(cwd)
1612 } else {
1613 global_extension_dirs()
1614 };
1615 if !args.no_extensions {
1616 extension_dirs.extend(args.extensions_dir.iter().cloned());
1617 }
1618
1619 let skill_base_dirs = if args.no_skills {
1620 Vec::new()
1621 } else if args.dev_local_only {
1622 project_skill_dirs(cwd)
1623 } else if project_trusted {
1624 skill_dirs(cwd)
1625 } else {
1626 global_skill_dirs()
1627 };
1628
1629 let prompt_base_dirs = if args.no_prompt_templates {
1630 Vec::new()
1631 } else if args.dev_local_only {
1632 project_prompt_template_dirs(cwd)
1633 } else if project_trusted {
1634 prompt_template_dirs(cwd)
1635 } else {
1636 global_prompt_template_dirs()
1637 };
1638
1639 before_verify();
1640 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1641 if settings_before != settings_after {
1642 return Err(
1643 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1644 );
1645 }
1646
1647 Ok(PreparedReloadInputs {
1648 package_resources,
1649 extension_dirs,
1650 skill_base_dirs,
1651 prompt_base_dirs,
1652 })
1653}
1654
1655fn build_models_with_extensions_for_reload(
1660 gateway: &Arc<dyn Provider>,
1661 extension_session: &ExtensionSession,
1662 runtime: tokio::runtime::Handle,
1663) -> Vec<Arc<dyn Provider>> {
1664 let mut models: Vec<Arc<dyn Provider>> = vec![gateway.clone()];
1665 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1666 models.extend(pluggable);
1667 models
1668}
1669
1670fn report_deferred_renderers(session: &ExtensionSession) {
1674 let Some(snap) = session.snapshot_arc() else {
1675 return;
1676 };
1677 let all = snap.renderers();
1678 let markdown = all
1679 .iter()
1680 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1681 .count();
1682 let message = all
1683 .iter()
1684 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1685 .count();
1686 let entry = all
1687 .iter()
1688 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1689 .count();
1690 if markdown + message + entry == 0 {
1691 return;
1692 }
1693 eprintln!(
1694 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1695 markdown, message, entry
1696 );
1697}
1698
1699#[derive(Debug, thiserror::Error)]
1701pub enum BuildError {
1702 #[error("Could not create the session directory: {0}")]
1703 SessionDir(String),
1704 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1705 SessionNotFound { requested: String, dir: String },
1706 #[error("Could not build the harness: {0}")]
1707 HarnessCreate(String),
1708}
1709
1710fn build_models_with_extensions(
1720 resolved: &ResolvedModel,
1721 extension_session: &ExtensionSession,
1722 runtime: tokio::runtime::Handle,
1723) -> Vec<Arc<dyn Provider>> {
1724 let mut models: Vec<Arc<dyn Provider>> = vec![resolved.provider.clone() as Arc<dyn Provider>];
1725 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1726 models.extend(pluggable);
1727 models
1728}
1729
1730pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1733 if let Some(override_value) = args.trust_override {
1734 return override_value;
1735 }
1736 crate::config::project_trust_decision(cwd)
1737 .ok()
1738 .flatten()
1739 .unwrap_or(true)
1740}
1741
1742fn load_extensions(
1750 args: &Args,
1751 cwd: &Path,
1752 project_trusted: bool,
1753 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1754) -> ExtensionSession {
1755 let mut dirs = if args.dev_local_only {
1756 Vec::new()
1757 } else if project_trusted {
1758 extension_dirs(cwd)
1759 } else {
1760 global_extension_dirs()
1761 };
1762 dirs.extend(args.extensions_dir.iter().cloned());
1763 load_extensions_from_dirs(args, &dirs, action_bridge)
1764}
1765
1766fn load_extensions_from_dirs(
1767 args: &Args,
1768 dirs: &[PathBuf],
1769 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1770) -> ExtensionSession {
1771 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1772 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1779}
1780
1781fn js_extension_paths(
1782 args: &Args,
1783 cwd: &Path,
1784 project_trusted: bool,
1785 packages: &crate::packages::PackageResources,
1786) -> Vec<PathBuf> {
1787 if args.dev_local_only {
1788 return Vec::new();
1789 }
1790 let mut paths = packages.extension_paths();
1791 let discovered_dirs = if project_trusted {
1792 extension_dirs(cwd)
1793 } else {
1794 global_extension_dirs()
1795 };
1796 for dir in discovered_dirs {
1797 if let Ok(entries) = std::fs::read_dir(dir) {
1798 paths.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
1799 matches!(
1800 path.extension()
1801 .and_then(|ext| ext.to_str())
1802 .map(|ext| ext.to_ascii_lowercase())
1803 .as_deref(),
1804 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1805 )
1806 }));
1807 }
1808 }
1809 paths.extend(
1810 args.extension
1811 .iter()
1812 .filter(|path| {
1813 matches!(
1814 path.extension()
1815 .and_then(|ext| ext.to_str())
1816 .map(|ext| ext.to_ascii_lowercase())
1817 .as_deref(),
1818 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1819 )
1820 })
1821 .cloned(),
1822 );
1823 paths
1824}
1825
1826fn merge_extension_tools(tools: &mut Vec<HarnessTool>, session: &ExtensionSession, args: &Args) {
1832 let Some(snapshot) = session.snapshot() else {
1833 return;
1834 };
1835 for et in snapshot.tools() {
1836 let name = &et.tool.name;
1837 if !tool_name_allowed(name, args) {
1838 continue;
1839 }
1840 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive());
1841 let harness_tool = HarnessTool::new(Arc::new(adapter));
1842 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1843 Some(slot) => *slot = harness_tool,
1844 None => tools.push(harness_tool),
1845 }
1846 }
1847}
1848
1849fn merge_js_extension_tools(
1850 tools: &mut Vec<HarnessTool>,
1851 session: &crate::js_extensions::JsExtensionSession,
1852 args: &Args,
1853) {
1854 for adapter in session.tools() {
1855 let name = adapter.schema().name.clone();
1856 if !tool_name_allowed(&name, args) {
1857 continue;
1858 }
1859 let harness_tool = HarnessTool::new(Arc::new(adapter));
1860 match tools
1861 .iter_mut()
1862 .find(|tool| tool.tool.schema().name == name)
1863 {
1864 Some(slot) => *slot = harness_tool,
1865 None => tools.push(harness_tool),
1866 }
1867 }
1868}
1869
1870pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1877 use rpi_tools::tools::bash::BashToolOptions;
1878 let default = std::env::var("RPI_BASH_TIMEOUT")
1879 .ok()
1880 .and_then(|v| v.parse::<f64>().ok())
1881 .unwrap_or(120.0);
1882 let settings = crate::settings::load_settings().unwrap_or_default();
1888 BashToolOptions {
1889 command_prefix: settings.shell_command_prefix.clone(),
1890 default_timeout: Some(default),
1891 }
1892}
1893
1894fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1895 if args.no_tools {
1896 return Vec::new();
1897 }
1898 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1907 (
1908 "read",
1909 HarnessTool::new(create_read_tool(ctx, None)).with_replay(ToolReplay::Safe),
1910 ),
1911 (
1912 "bash",
1913 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1914 ),
1915 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1916 ("write", HarnessTool::new(create_write_tool(ctx))),
1917 (
1918 "docs",
1919 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
1920 ),
1921 ];
1922
1923 #[cfg(target_os = "windows")]
1925 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
1926
1927 if args.tools.is_none() {
1929 if let Ok(settings) = crate::settings::load_settings() {
1930 if let Some(default_tools) = &settings.default_tools {
1931 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
1932 }
1933 }
1934 }
1935
1936 if args.no_builtin_tools {
1940 all.clear();
1941 }
1942
1943 if let Some(allow) = &args.tools {
1945 all.retain(|(name, _)| allow.iter().any(|a| a == name));
1946 }
1947 if let Some(deny) = &args.exclude_tools {
1949 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1950 }
1951
1952 all.into_iter().map(|(_, t)| t).collect()
1953}
1954
1955fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1959 filter_active_tool_names(
1960 tools.iter().map(|tool| tool.tool.schema().name.clone()),
1961 args,
1962 )
1963}
1964
1965pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
1969 if args.no_tools {
1970 return false;
1971 }
1972 if args
1973 .tools
1974 .as_ref()
1975 .is_some_and(|allow| !allow.iter().any(|value| value == name))
1976 {
1977 return false;
1978 }
1979 if args
1980 .exclude_tools
1981 .as_ref()
1982 .is_some_and(|deny| deny.iter().any(|value| value == name))
1983 {
1984 return false;
1985 }
1986 true
1987}
1988
1989pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
1990where
1991 I: IntoIterator<Item = String>,
1992{
1993 names
1994 .into_iter()
1995 .filter(|name| tool_name_allowed(name, args))
1996 .collect()
1997}
1998
1999async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2001 match selection {
2002 SessionSelection::Ephemeral => Ok(ephemeral_session()),
2003 SessionSelection::New { dir, .. } => {
2004 std::fs::create_dir_all(dir)
2007 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2008 let session = create_jsonl_session(dir, cwd)
2009 .await
2010 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2011 Ok(session)
2012 }
2013 SessionSelection::Latest
2014 | SessionSelection::ById { .. }
2015 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2016 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
2017 }
2018}
2019
2020async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2026 match selection {
2030 SessionSelection::Latest => {
2031 let metas = list_session_metadata(cwd).await?;
2032 let Some(meta) = metas.first() else {
2033 return Err(BuildError::SessionNotFound {
2034 requested: "the most recent session".to_string(),
2035 dir: default_session_dir(Path::new(cwd)).display().to_string(),
2036 });
2037 };
2038 open_session(meta, cwd).await
2039 }
2040 SessionSelection::ById { id } => open_session_by_id(id, cwd).await.map_err(|e| match e {
2041 OpenError::NotFound { requested } => BuildError::SessionNotFound {
2042 requested,
2043 dir: default_session_dir(Path::new(cwd)).display().to_string(),
2044 },
2045 OpenError::Other(msg) => BuildError::SessionDir(msg),
2046 }),
2047 SessionSelection::ByExactId { id } => {
2048 let metas = list_session_metadata(cwd).await?;
2051 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
2052 return open_session(meta, cwd).await;
2053 }
2054 let dir = default_session_dir(Path::new(cwd));
2055 std::fs::create_dir_all(&dir)
2056 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2057 create_jsonl_session_with_id(&dir, cwd, Some(id.clone()))
2058 .await
2059 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2060 }
2061 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
2062 }
2063}
2064
2065async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
2068 use rpi_harness::session::jsonl::{
2069 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2070 };
2071 use rpi_harness::session::types::{ForkOptions, SessionStorage};
2072 use rpi_tools::FileSystem;
2073
2074 let dir = default_session_dir(Path::new(cwd));
2075 std::fs::create_dir_all(&dir)
2076 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2077 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2078 let fs: Arc<dyn FileSystem> = env.clone();
2079 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2080 fs: fs.clone(),
2081 sessions_root: dir.to_string_lossy().into_owned(),
2082 clock: Arc::new(SystemClock),
2083 ids: Arc::new(DefaultIdGenerator::new()),
2084 });
2085 let metas = repo
2086 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2087 .await
2088 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2089 let source_meta = metas
2090 .iter()
2091 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2092 .ok_or_else(|| BuildError::SessionNotFound {
2093 requested: format!("--fork {source}"),
2094 dir: dir.display().to_string(),
2095 })?;
2096 let fork_storage = repo
2097 .fork_typed(
2098 source_meta,
2099 &JsonlSessionCreateOptions {
2100 id: None,
2101 parent_session_id: Some(source_meta.id.clone()),
2102 cwd: cwd.to_string(),
2103 metadata: None,
2104 },
2105 &ForkOptions::default(),
2106 )
2107 .await
2108 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2109 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2110 Ok(Session::new(storage_arc, None))
2111}
2112
2113pub enum OpenError {
2116 NotFound { requested: String },
2118 Other(String),
2120}
2121
2122impl std::fmt::Display for OpenError {
2123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2124 match self {
2125 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2126 OpenError::Other(msg) => write!(f, "{msg}"),
2127 }
2128 }
2129}
2130
2131pub async fn list_session_metadata(
2134 cwd: &str,
2135) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2136 use rpi_harness::session::jsonl::{
2137 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2138 };
2139 use rpi_tools::FileSystem;
2140
2141 let dir = default_session_dir(Path::new(cwd));
2142 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2143 let fs: Arc<dyn FileSystem> = env.clone();
2144 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2145 fs: fs.clone(),
2146 sessions_root: dir.to_string_lossy().into_owned(),
2147 clock: Arc::new(SystemClock),
2148 ids: Arc::new(DefaultIdGenerator::new()),
2149 });
2150 repo.list_typed(&JsonlSessionListOptions::default())
2151 .await
2152 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2153}
2154
2155pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2159 let metas = list_session_metadata(cwd)
2160 .await
2161 .map_err(|e| OpenError::Other(e.to_string()))?;
2162 let Some(meta) = metas
2163 .iter()
2164 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2165 else {
2166 return Err(OpenError::NotFound {
2167 requested: format!("session {id}"),
2168 });
2169 };
2170 open_session(meta, cwd)
2171 .await
2172 .map_err(|e| OpenError::Other(e.to_string()))
2173}
2174
2175pub(crate) async fn fork_session_storage(
2182 harness: &AgentHarness,
2183 cwd: &str,
2184) -> Result<Session, String> {
2185 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2186 use rpi_tools::FileSystem;
2187
2188 let dir = default_session_dir(Path::new(cwd));
2189 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2190 let fs: Arc<dyn FileSystem> = env.clone();
2191 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2192 fs,
2193 sessions_root: dir.to_string_lossy().into_owned(),
2194 clock: Arc::new(SystemClock),
2195 ids: Arc::new(DefaultIdGenerator::new()),
2196 });
2197 let id = harness.session().storage().metadata().id.clone();
2200 let metas = list_session_metadata(cwd)
2201 .await
2202 .map_err(|e| e.to_string())?;
2203 let Some(source) = metas.iter().find(|m| m.id == id) else {
2204 return Err(format!("current session {id} not found on disk"));
2205 };
2206 let fork_storage = repo
2207 .fork_typed(
2208 source,
2209 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2210 id: None,
2211 parent_session_id: Some(source.id.clone()),
2212 cwd: cwd.to_string(),
2213 metadata: None,
2214 },
2215 &rpi_harness::session::types::ForkOptions::default(),
2216 )
2217 .await
2218 .map_err(|e| e.to_string())?;
2219 Ok(Session::new(Arc::new(fork_storage), None))
2220}
2221
2222async fn open_session(
2225 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2226 cwd: &str,
2227) -> Result<Session, BuildError> {
2228 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2229 use rpi_harness::session::types::SessionStorage;
2230 use rpi_tools::FileSystem;
2231
2232 let dir = default_session_dir(Path::new(cwd));
2233 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2234 let fs: Arc<dyn FileSystem> = env.clone();
2235 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2236 fs: fs.clone(),
2237 sessions_root: dir.to_string_lossy().into_owned(),
2238 clock: Arc::new(SystemClock),
2239 ids: Arc::new(DefaultIdGenerator::new()),
2240 });
2241 let storage = repo
2242 .open_by_jsonl_metadata(meta)
2243 .await
2244 .map_err(|e| BuildError::SessionDir(format!("open {}: {e}", meta.path)))?;
2245 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2249 Some(Path::new(&meta.path)),
2250 Path::new(&meta.cwd),
2251 Path::new(cwd),
2252 ) {
2253 eprintln!(
2254 "warning: {}",
2255 crate::session_cwd::format_missing_session_cwd_error(&issue)
2256 );
2257 }
2258 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2259 Ok(Session::new(storage_arc, None))
2260}
2261
2262fn ephemeral_session() -> Session {
2264 let storage = Arc::new(InMemorySessionStorage::new(
2265 SessionMetadata {
2266 id: "ephemeral".into(),
2267 created_at: 0,
2268 parent_session_id: None,
2269 },
2270 Arc::new(SystemClock),
2271 Arc::new(DefaultIdGenerator::new()),
2272 ));
2273 Session::new(storage, None)
2274}
2275
2276pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2287 create_jsonl_session_with_id(dir, cwd, None).await
2288}
2289
2290pub(crate) async fn create_jsonl_session_with_id(
2294 dir: &Path,
2295 cwd: &str,
2296 id: Option<String>,
2297) -> Result<Session, String> {
2298 use rpi_harness::session::jsonl::{
2299 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2300 };
2301 use rpi_tools::FileSystem;
2302
2303 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2306 let fs: Arc<dyn FileSystem> = env.clone();
2307
2308 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2309 fs: fs.clone(),
2310 sessions_root: dir.to_string_lossy().into_owned(),
2311 clock: Arc::new(SystemClock),
2312 ids: Arc::new(DefaultIdGenerator::new()),
2313 });
2314
2315 let opts = JsonlSessionCreateOptions {
2316 id, parent_session_id: None,
2318 cwd: cwd.to_string(),
2319 metadata: None,
2320 };
2321 let storage = repo
2322 .create_typed(&opts)
2323 .await
2324 .map_err(|e| format!("create session: {e}"))?;
2325 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2327 Ok(Session::new(storage_arc, None))
2328}
2329
2330fn read_append_target(target: &str) -> Option<String> {
2333 let path = Path::new(target);
2334 if path.is_file() {
2335 std::fs::read_to_string(path).ok()
2336 } else {
2337 None
2338 }
2339}
2340
2341#[cfg(test)]
2342mod tests {
2343 use super::*;
2344 use crate::args::Args;
2345
2346 #[test]
2347 fn default_prompt_mentions_cwd_and_tools() {
2348 let p = default_system_prompt("/tmp/proj");
2349 assert!(p.contains("/tmp/proj"));
2350 assert!(p.contains("read"));
2351 assert!(p.contains("bash"));
2352 assert!(p.contains("edit"));
2353 assert!(p.contains("write"));
2354 assert!(p.contains("docs"));
2355 assert!(!p.contains("- grep"));
2356 assert!(!p.contains("- find"));
2357 assert!(!p.contains("- ls"));
2358 assert!(!p.contains("powershell"));
2359 }
2360
2361 #[test]
2362 fn default_prompt_keeps_working_state_in_the_visible_channel() {
2363 let p = default_system_prompt("/tmp/proj");
2371 let lower = p.to_lowercase();
2372 assert!(
2374 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
2375 "the prompt must explain that reasoning does not survive the turn: {p}"
2376 );
2377 assert!(
2379 lower.contains("visible") && lower.contains("todo tool"),
2380 "the prompt must point at visible text and the todo tool: {p}"
2381 );
2382 assert!(
2384 lower.contains("re-stating the same plan"),
2385 "the prompt must call out plan re-statement as a bug: {p}"
2386 );
2387 assert!(
2389 lower.contains("already shown earlier"),
2390 "the prompt must discourage re-fetching known content: {p}"
2391 );
2392 }
2393
2394 #[test]
2402 fn only_read_only_tools_are_replayable() {
2403 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2404 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2405 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2406 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2407 let tools = build_tools(&context, &Args::default());
2408
2409 let replay_of = |name: &str| {
2410 tools
2411 .iter()
2412 .find(|tool| tool.tool.schema().name == name)
2413 .map(|tool| tool.replay)
2414 };
2415
2416 for read_only in ["read", "docs"] {
2417 assert_eq!(
2418 replay_of(read_only),
2419 Some(ToolReplay::Safe),
2420 "{read_only} is side-effect free, so recovery may re-run it"
2421 );
2422 }
2423 for mutating in ["bash", "edit", "write", "powershell"] {
2424 if let Some(replay) = replay_of(mutating) {
2425 assert_eq!(
2426 replay,
2427 ToolReplay::Never,
2428 "{mutating} must never be replayed: re-running it would duplicate its effect"
2429 );
2430 }
2431 }
2432 }
2433
2434 #[test]
2435 fn default_tools_include_docs_lookup() {
2436 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2437 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2438 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2439 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2440 let names: Vec<String> = build_tools(&context, &Args::default())
2441 .iter()
2442 .map(|tool| tool.tool.schema().name.clone())
2443 .collect();
2444
2445 assert!(names.contains(&"docs".to_string()));
2450 for legacy in ["grep", "find", "ls"] {
2451 assert!(
2452 !names.contains(&legacy.to_string()),
2453 "legacy tool {legacy} should not be registered by default"
2454 );
2455 }
2456 }
2457
2458 #[test]
2459 fn pi_package_loading_is_opt_in_and_respects_no_extensions() {
2460 let args = Args::default();
2461 assert!(!should_load_js_packages(&args));
2462 let resources = package_resources_for(&args, Path::new("."), false);
2463 assert!(resources.packages.is_empty());
2464
2465 let args = Args {
2466 enable_pi_packages: true,
2467 ..Args::default()
2468 };
2469 assert!(should_load_js_packages(&args));
2470
2471 let args = Args {
2472 enable_pi_packages: true,
2473 no_extensions: true,
2474 ..Args::default()
2475 };
2476 assert!(!should_load_js_packages(&args));
2477 assert!(package_resources_for(&args, Path::new("."), false)
2478 .packages
2479 .is_empty());
2480 }
2481
2482 #[test]
2483 fn pi_offline_env_disables_startup_package_remediation() {
2484 struct RestoreEnv {
2485 name: &'static str,
2486 value: Option<std::ffi::OsString>,
2487 }
2488
2489 impl Drop for RestoreEnv {
2490 fn drop(&mut self) {
2491 match self.value.take() {
2492 Some(value) => std::env::set_var(self.name, value),
2493 None => std::env::remove_var(self.name),
2494 }
2495 }
2496 }
2497
2498 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2499 let _restore_config = RestoreEnv {
2500 name: crate::config::CONFIG_DIR_ENV,
2501 value: std::env::var_os(crate::config::CONFIG_DIR_ENV),
2502 };
2503 let _restore_offline = RestoreEnv {
2504 name: crate::args::PI_OFFLINE_ENV,
2505 value: std::env::var_os(crate::args::PI_OFFLINE_ENV),
2506 };
2507 let tmp = tempfile::tempdir().unwrap();
2508 let agent = tmp.path().join("agent");
2509 let cwd = tmp.path().join("project");
2510 let package = agent.join("npm/node_modules/demo");
2511 std::fs::create_dir_all(package.join("extensions")).unwrap();
2512 std::fs::create_dir_all(&cwd).unwrap();
2513 std::fs::write(
2514 package.join("package.json"),
2515 r#"{"name":"demo","version":"1.0.0"}"#,
2516 )
2517 .unwrap();
2518 std::fs::write(
2519 package.join("extensions/index.js"),
2520 "export default () => {};",
2521 )
2522 .unwrap();
2523 std::fs::write(
2524 agent.join("settings.json"),
2525 r#"{"npmCommand":[""],"packages":["npm:demo@2.0.0"]}"#,
2526 )
2527 .unwrap();
2528 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2529 std::env::set_var(crate::args::PI_OFFLINE_ENV, "TrUe");
2530 let args = Args {
2531 enable_pi_packages: true,
2532 offline: false,
2533 ..Args::default()
2534 };
2535
2536 let resources = package_resources_for(&args, &cwd, false);
2537
2538 assert!(resources.packages.is_empty());
2539 assert_eq!(resources.diagnostics.len(), 1);
2540 assert!(resources.diagnostics[0].message.contains("offline"));
2541 assert_eq!(
2542 std::fs::read(package.join("package.json")).unwrap(),
2543 br#"{"name":"demo","version":"1.0.0"}"#
2544 );
2545 }
2546
2547 #[test]
2548 fn package_discovery_uses_the_callers_trust_snapshot() {
2549 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2550 let previous = std::env::var_os(crate::config::CONFIG_DIR_ENV);
2551 let tmp = tempfile::tempdir().unwrap();
2552 let agent = tmp.path().join("agent");
2553 let cwd = tmp.path().join("project");
2554 let package = cwd.join("package");
2555 std::fs::create_dir_all(&agent).unwrap();
2556 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2557 std::fs::create_dir_all(&package).unwrap();
2558 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2559 std::fs::write(
2560 package.join("package.json"),
2561 r#"{"name":"snapshot-package","version":"1.0.0"}"#,
2562 )
2563 .unwrap();
2564 std::fs::write(
2565 cwd.join(".rpi/settings.json"),
2566 serde_json::json!({"packages": [package]}).to_string(),
2567 )
2568 .unwrap();
2569 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2570
2571 let args = Args {
2572 enable_pi_packages: true,
2573 ..Args::default()
2574 };
2575 assert_eq!(package_resources_for(&args, &cwd, true).packages.len(), 1);
2576 assert!(package_resources_for(&args, &cwd, false)
2577 .packages
2578 .is_empty());
2579 assert_eq!(
2580 package_resources_for_update_check(&args, &cwd, true)
2581 .packages
2582 .len(),
2583 1
2584 );
2585 assert!(package_resources_for_update_check(&args, &cwd, false)
2586 .packages
2587 .is_empty());
2588
2589 match previous {
2590 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2591 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2592 }
2593 }
2594
2595 #[test]
2596 fn reload_settings_preflight_is_independent_of_packages_and_respects_trust() {
2597 struct RestoreConfigDir(Option<std::ffi::OsString>);
2598
2599 impl Drop for RestoreConfigDir {
2600 fn drop(&mut self) {
2601 match self.0.take() {
2602 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2603 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2604 }
2605 }
2606 }
2607
2608 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2609 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2610 let tmp = tempfile::tempdir().unwrap();
2611 let agent = tmp.path().join("agent");
2612 let cwd = tmp.path().join("project");
2613 std::fs::create_dir_all(&agent).unwrap();
2614 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2615 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2616 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
2617 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2618 let packages_disabled = Args::default();
2619 let packages_enabled = Args {
2620 enable_pi_packages: true,
2621 ..Args::default()
2622 };
2623
2624 for args in [&packages_disabled, &packages_enabled] {
2625 let trusted = validate_settings_for_reload(args, &cwd, true);
2626 let untrusted = validate_settings_for_reload(args, &cwd, false);
2627 assert!(trusted
2628 .unwrap_err()
2629 .contains("could not load project settings"));
2630 assert!(untrusted.is_ok());
2631 }
2632
2633 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2634 for args in [&packages_disabled, &packages_enabled] {
2635 assert!(validate_settings_for_reload(args, &cwd, false)
2636 .unwrap_err()
2637 .contains("could not load global settings"));
2638 }
2639
2640 let local_only = Args {
2641 dev_local_only: true,
2642 ..Args::default()
2643 };
2644 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
2645 }
2646
2647 #[tokio::test(flavor = "current_thread")]
2648 async fn reload_with_packages_disabled_preserves_live_resources_when_settings_break() {
2649 struct RestoreConfigDir(Option<std::ffi::OsString>);
2650
2651 impl Drop for RestoreConfigDir {
2652 fn drop(&mut self) {
2653 match self.0.take() {
2654 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2655 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2656 }
2657 }
2658 }
2659
2660 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2661 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2662 let tmp = tempfile::tempdir().unwrap();
2663 let agent = tmp.path().join("agent");
2664 let cwd = tmp.path().join("project");
2665 let skill_dir = tmp.path().join("configured-skills");
2666 std::fs::create_dir_all(&agent).unwrap();
2667 std::fs::create_dir_all(&cwd).unwrap();
2668 std::fs::create_dir_all(&skill_dir).unwrap();
2669 std::fs::write(
2670 skill_dir.join("SKILL.md"),
2671 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
2672 )
2673 .unwrap();
2674 std::fs::write(
2675 agent.join("settings.json"),
2676 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2677 )
2678 .unwrap();
2679 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2680
2681 let resolved = crate::provider::resolve(
2682 Some("anthropic"),
2683 Some(crate::provider::DEFAULT_MODEL_ID),
2684 None,
2685 Some("test-key"),
2686 None,
2687 )
2688 .unwrap();
2689 let args = Args {
2690 trust_override: Some(false),
2691 no_session: true,
2692 no_extensions: true,
2693 no_prompt_templates: true,
2694 no_context_files: true,
2695 system_prompt: Some("stable system prompt".into()),
2696 ..Args::default()
2697 };
2698 assert!(!should_load_js_packages(&args));
2699
2700 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2701 let before_resources = harness.get_resources().await.unwrap();
2702 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
2703 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
2704 let before_prompt = harness.get_system_prompt().await.unwrap();
2705 let before_tools: Vec<String> = harness
2706 .get_tools()
2707 .await
2708 .unwrap()
2709 .iter()
2710 .map(|tool| tool.tool.schema().name.clone())
2711 .collect();
2712 let before_bridge = context
2713 .action_bridge
2714 .lock()
2715 .unwrap()
2716 .as_ref()
2717 .unwrap()
2718 .clone();
2719
2720 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2721 let outcome = reload_extension_resources(&harness, &context).await;
2722
2723 assert!(outcome.had_warnings);
2724 assert!(outcome.summary.contains("Settings reload failed"));
2725 assert!(outcome.summary.contains("could not load global settings"));
2726 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
2727 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
2728 let after_tools: Vec<String> = harness
2729 .get_tools()
2730 .await
2731 .unwrap()
2732 .iter()
2733 .map(|tool| tool.tool.schema().name.clone())
2734 .collect();
2735 assert_eq!(after_tools, before_tools);
2736 let after_bridge = context
2737 .action_bridge
2738 .lock()
2739 .unwrap()
2740 .as_ref()
2741 .unwrap()
2742 .clone();
2743 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
2744 }
2745
2746 #[test]
2747 fn reload_preparation_rejects_settings_changed_during_derivation() {
2748 struct RestoreConfigDir(Option<std::ffi::OsString>);
2749
2750 impl Drop for RestoreConfigDir {
2751 fn drop(&mut self) {
2752 match self.0.take() {
2753 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2754 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2755 }
2756 }
2757 }
2758
2759 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2760 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2761 let tmp = tempfile::tempdir().unwrap();
2762 let agent = tmp.path().join("agent");
2763 let cwd = tmp.path().join("project");
2764 let first_skill_dir = tmp.path().join("first-skills");
2765 let second_skill_dir = tmp.path().join("second-skills");
2766 std::fs::create_dir_all(&agent).unwrap();
2767 std::fs::create_dir_all(&cwd).unwrap();
2768 std::fs::write(
2769 agent.join("settings.json"),
2770 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
2771 )
2772 .unwrap();
2773 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2774 let args = Args {
2775 trust_override: Some(false),
2776 no_extensions: true,
2777 no_prompt_templates: true,
2778 ..Args::default()
2779 };
2780
2781 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
2782 std::fs::write(
2783 agent.join("settings.json"),
2784 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
2785 )
2786 .unwrap();
2787 });
2788
2789 let error = result
2790 .err()
2791 .expect("settings mutation must fail preparation");
2792 assert!(error.contains("settings changed while reload inputs were being prepared"));
2793 }
2794
2795 #[tokio::test(flavor = "current_thread")]
2796 async fn reload_uses_frozen_settings_inputs_after_preparation() {
2797 struct RestoreConfigDir(Option<std::ffi::OsString>);
2798
2799 impl Drop for RestoreConfigDir {
2800 fn drop(&mut self) {
2801 match self.0.take() {
2802 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2803 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2804 }
2805 }
2806 }
2807
2808 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2809 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2810 let tmp = tempfile::tempdir().unwrap();
2811 let agent = tmp.path().join("agent");
2812 let cwd = tmp.path().join("project");
2813 let skill_dir = tmp.path().join("configured-skills");
2814 std::fs::create_dir_all(&agent).unwrap();
2815 std::fs::create_dir_all(&cwd).unwrap();
2816 std::fs::create_dir_all(&skill_dir).unwrap();
2817 std::fs::write(
2818 skill_dir.join("SKILL.md"),
2819 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
2820 )
2821 .unwrap();
2822 std::fs::write(
2823 agent.join("settings.json"),
2824 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2825 )
2826 .unwrap();
2827 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2828
2829 let resolved = crate::provider::resolve(
2830 Some("anthropic"),
2831 Some(crate::provider::DEFAULT_MODEL_ID),
2832 None,
2833 Some("test-key"),
2834 None,
2835 )
2836 .unwrap();
2837 let args = Args {
2838 trust_override: Some(false),
2839 no_session: true,
2840 no_extensions: true,
2841 no_prompt_templates: true,
2842 no_context_files: true,
2843 system_prompt: Some("stable system prompt".into()),
2844 ..Args::default()
2845 };
2846 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2847
2848 let outcome = reload_extension_resources_inner(&harness, &context, || {
2849 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2850 })
2851 .await;
2852
2853 assert!(!outcome.summary.contains("Settings reload failed"));
2854 assert_eq!(
2855 outcome.summary,
2856 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
2857 );
2858 let resources = harness.get_resources().await.unwrap();
2859 let skills = resources.skills.as_ref().unwrap();
2860 assert_eq!(skills.len(), 1);
2861 assert_eq!(skills[0].name, "frozen-skill");
2862 }
2863
2864 #[tokio::test]
2865 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
2866 let tmp = tempfile::tempdir().unwrap();
2867 let skill_path = tmp.path().join("explicit-skill.md");
2868 std::fs::write(
2869 &skill_path,
2870 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
2871 )
2872 .unwrap();
2873 let prompt_path = tmp.path().join("explicit-prompt.md");
2874 std::fs::write(
2875 &prompt_path,
2876 "---\ndescription: Explicit prompt\n---\nPrompt body",
2877 )
2878 .unwrap();
2879
2880 let args = Args {
2881 skill: vec![skill_path.clone()],
2882 prompt_template: vec![prompt_path.clone()],
2883 ..Args::default()
2884 };
2885 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
2886 let prompt_paths =
2887 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
2888 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
2889 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
2890
2891 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
2892 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
2893 assert_eq!(skills.skills[0].name, "explicit-skill");
2894
2895 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
2896 assert_eq!(
2897 prompts.prompt_templates.len(),
2898 1,
2899 "{:?}",
2900 prompts.diagnostics
2901 );
2902 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
2903 }
2904
2905 #[test]
2906 fn tool_policy_applies_to_rust_and_js_active_names() {
2907 let names = vec![
2908 "read".to_string(),
2909 "ask_user_question".to_string(),
2910 "write".to_string(),
2911 ];
2912
2913 let args = Args {
2914 tools: Some(vec!["read".into(), "ask_user_question".into()]),
2915 ..Args::default()
2916 };
2917 assert_eq!(
2918 filter_active_tool_names(names.clone(), &args),
2919 vec!["read", "ask_user_question"]
2920 );
2921
2922 let args = Args {
2923 exclude_tools: Some(vec!["ask_user_question".into()]),
2924 ..Args::default()
2925 };
2926 assert_eq!(
2927 filter_active_tool_names(names.clone(), &args),
2928 vec!["read", "write"]
2929 );
2930
2931 let args = Args {
2932 no_tools: true,
2933 ..Args::default()
2934 };
2935 assert!(filter_active_tool_names(names, &args).is_empty());
2936 }
2937
2938 #[test]
2939 fn select_ephemeral_when_no_session() {
2940 let args = Args {
2941 no_session: true,
2942 ..Args::default()
2943 };
2944 let cwd = Path::new("/tmp");
2945 assert!(matches!(
2946 select_session(&args, cwd),
2947 SessionSelection::Ephemeral
2948 ));
2949 }
2950
2951 #[test]
2952 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
2953 let defaults = Args::default();
2954 assert!(resolve_project_trust(
2955 &defaults,
2956 Path::new("C:/definitely-not-a-project")
2957 ));
2958
2959 let denied = Args {
2960 trust_override: Some(false),
2961 ..Args::default()
2962 };
2963 assert!(!resolve_project_trust(
2964 &denied,
2965 Path::new("C:/definitely-not-a-project")
2966 ));
2967 }
2968
2969 #[tokio::test(flavor = "current_thread")]
2970 async fn build_preserves_the_supplied_startup_project_snapshot() {
2971 struct RestoreConfigDir(Option<std::ffi::OsString>);
2972
2973 impl Drop for RestoreConfigDir {
2974 fn drop(&mut self) {
2975 match self.0.take() {
2976 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2977 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2978 }
2979 }
2980 }
2981
2982 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2983 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2984 let tmp = tempfile::tempdir().unwrap();
2985 let agent = tmp.path().join("agent");
2986 let cwd = tmp.path().join("project");
2987 std::fs::create_dir_all(&agent).unwrap();
2988 std::fs::create_dir_all(&cwd).unwrap();
2989 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2990
2991 let resolved = crate::provider::resolve(
2992 Some("anthropic"),
2993 Some(crate::provider::DEFAULT_MODEL_ID),
2994 None,
2995 Some("test-key"),
2996 None,
2997 )
2998 .unwrap();
2999 let args = Args {
3000 trust_override: Some(false),
3001 no_session: true,
3002 no_tools: true,
3003 no_extensions: true,
3004 no_skills: true,
3005 no_prompt_templates: true,
3006 no_context_files: true,
3007 system_prompt: Some("test prompt".into()),
3008 ..Args::default()
3009 };
3010
3011 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3012
3013 assert_eq!(context.cwd, cwd);
3014 assert!(context.project_trusted);
3015 }
3016
3017 #[test]
3018 fn select_latest_for_continue_and_resume() {
3019 let args = Args {
3020 continue_session: true,
3021 ..Args::default()
3022 };
3023 let cwd = Path::new("/tmp");
3024 assert!(matches!(
3025 select_session(&args, cwd),
3026 SessionSelection::Latest
3027 ));
3028
3029 let args = Args {
3030 resume: true,
3031 ..Args::default()
3032 };
3033 assert!(matches!(
3034 select_session(&args, cwd),
3035 SessionSelection::Latest
3036 ));
3037 }
3038
3039 #[test]
3040 fn select_by_id_for_session_flag() {
3041 let args = Args {
3042 session: Some("01a02ece".into()),
3043 ..Args::default()
3044 };
3045 let cwd = Path::new("/tmp");
3046 assert!(matches!(
3047 select_session(&args, cwd),
3048 SessionSelection::ById { id } if id == "01a02ece"
3049 ));
3050 }
3051
3052 #[test]
3053 fn select_new_with_custom_dir() {
3054 let args = Args {
3055 session_dir: Some(PathBuf::from("/tmp/sess")),
3056 ..Args::default()
3057 };
3058 let cwd = Path::new("/tmp");
3059 match select_session(&args, cwd) {
3060 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
3061 other => panic!("expected New, got {other:?}"),
3062 }
3063 }
3064
3065 #[test]
3066 fn select_new_default_dir() {
3067 let args = Args::default();
3068 let cwd = Path::new("/proj");
3069 match select_session(&args, cwd) {
3070 SessionSelection::New { dir, .. } => {
3071 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
3072 }
3073 other => panic!("expected New, got {other:?}"),
3074 }
3075 }
3076
3077 #[test]
3078 fn default_session_dir_prefers_rpi_but_reads_legacy_pi() {
3079 let tmp = tempfile::tempdir().unwrap();
3080 let cwd = tmp.path();
3081 std::fs::create_dir_all(cwd.join(".pi/sessions")).unwrap();
3082 assert_eq!(default_session_dir(cwd), cwd.join(".pi/sessions"));
3083 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3084 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3085 }
3086
3087 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3088 async fn ephemeral_session_builds_roundtrips() {
3089 let s = ephemeral_session();
3093 let leaf = s.get_leaf_id().await;
3094 assert!(leaf.is_ok());
3095 }
3096
3097 }