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, prior_reasoning_replayed: bool) -> String {
125 let working_state_rule = if prior_reasoning_replayed {
131 "- Keep a SHORT working plan visible (a few bullets, or the todo tool) and update it as you go.
132 Your own reasoning is carried back to you on the next turn, so do not re-derive it or restate
133 the whole plan: continue from the last unfinished step, and when a step lands, say which one.
134 Re-listing the same plan without acting on it is a bug, not progress."
135 } else {
136 "- Keep your working state in your VISIBLE replies, not only in reasoning. Reasoning is not
137 carried into your next turn: only the text you write and the tool output you produce come
138 back. Before each batch of tool calls, write one short line naming the task you are on and
139 what remains. When you finish a step, say which one is done. If you keep the plan only in
140 your head you will re-derive it from scratch every turn."
141 };
142
143 format!(
144 "You are an expert coding assistant operating inside rpi, a coding agent harness. \
145You help users by reading files, executing commands, editing code, and writing new files.
146
147Available tools:
148- read — Read file contents
149- bash — Execute shell commands
150- edit — Find/replace edits to existing files
151- write — Create or overwrite files
152- docs — Look up rpi usage, extension, package, and compatibility documentation
153
154Guidelines:
155- Be concise in your responses
156- Show file paths clearly when working with files
157- Prefer the smallest change that solves the problem
158{working_state_rule}
159- Track multi-step work with the todo tool instead of re-listing the plan in prose: add the
160 steps once, then mark them done as you go. Re-stating the same plan without acting on it is
161 a bug, not progress.
162- Read only what you need. If a file or search result was already shown earlier in this
163 conversation, use it instead of fetching it again.
164- When unsure about rpi commands, extensions, Pi package compatibility, or .rpi configuration, consult the project documentation before guessing
165
166Current working directory: {cwd}"
167 )
168}
169
170#[derive(Debug, Clone)]
177pub enum SessionSelection {
178 Ephemeral,
180 New { dir: PathBuf, name: Option<String> },
182 Latest,
184 ById { id: String },
187 ByExactId { id: String },
189 Fork { source: String },
192}
193
194pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
196 if args.no_session {
197 return SessionSelection::Ephemeral;
198 }
199 if args.continue_session || args.resume {
200 return SessionSelection::Latest;
202 }
203 if let Some(s) = &args.fork {
204 return SessionSelection::Fork { source: s.clone() };
205 }
206 if let Some(s) = &args.session_id {
207 return SessionSelection::ByExactId { id: s.clone() };
208 }
209 if let Some(s) = &args.session {
210 return SessionSelection::ById { id: s.clone() };
211 }
212 let dir = args
215 .session_dir
216 .clone()
217 .or_else(|| {
218 crate::settings::load_settings()
219 .ok()
220 .and_then(|settings| settings.session_dir)
221 })
222 .unwrap_or_else(|| default_session_dir(cwd));
223 SessionSelection::New {
224 dir,
225 name: args.name.clone(),
226 }
227}
228
229pub fn default_session_dir(cwd: &Path) -> PathBuf {
233 let preferred = cwd.join(".rpi").join("sessions");
234 let legacy = cwd.join(".pi").join("sessions");
235 if preferred.exists() || !legacy.exists() {
236 preferred
237 } else {
238 legacy
239 }
240}
241
242pub async fn build(
260 resolved: &ResolvedModel,
261 args: &Args,
262 cwd: &Path,
263 project_trusted: bool,
264) -> Result<
265 (
266 AgentHarness,
267 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
268 ReloadContext,
269 ),
270 BuildError,
271> {
272 let cwd_str = cwd.to_string_lossy().to_string();
273 if !project_trusted && args.verbose {
274 eprintln!(
275 "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)"
276 );
277 }
278 let package_resources = if args.dev_local_only {
282 crate::packages::PackageResources::default()
283 } else {
284 package_resources_for(args, cwd, project_trusted)
285 };
286
287 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
302 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
303 })?;
304 let catalog = crate::provider::available_catalog(resolved);
305 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
306 catalog.clone(),
307 cwd.to_path_buf(),
308 runtime.clone(),
309 args.unknown_flags.clone(),
310 );
311 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = Arc::new(action_host);
312 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
322 let ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
328 let ext_status_mailbox = rpi_extensions::ExtensionStatusMailbox::new();
332 let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
333 runtime.clone(),
334 host_arc,
335 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
336 ui_dialog_mailbox.clone(),
337 ext_status_mailbox.clone(),
338 );
339
340 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
342 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
343 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
344 let _registry = Arc::new(MutationQueueRegistry::new());
345 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
348
349 let tools = build_tools(&ctx, args);
350 let mut tools = tools;
351
352 let extension_session = if args.no_extensions {
360 ExtensionSession::none()
361 } else {
362 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
363 };
364 let js_extension_session = if !should_load_js_packages(args) {
365 None
366 } else {
367 let paths = js_extension_paths(args, cwd, project_trusted, &package_resources);
368 let js_context = serde_json::json!({
369 "cwd": cwd_str,
370 "theme": resolved.theme.clone(),
371 "currentModel": resolved.model.clone(),
372 "models": catalog.clone(),
373 "thinkingLevel": resolved.thinking_level,
374 });
375 match crate::js_extensions::JsExtensionSession::load_with_context(
376 &paths,
377 args.verbose,
378 js_context,
379 ) {
380 Ok(session) => session,
381 Err(error) => {
382 eprintln!("warning: JS/TS extensions were not loaded: {error}");
383 None
384 }
385 }
386 };
387 if js_extension_session.is_some() {
388 eprintln!(
389 "warning: enabled Pi JS/TS extensions execute with the current user's permissions"
390 );
391 }
392 if let Some(session) = &js_extension_session {
393 if let Err(error) =
394 session.enable_provider_runtime(resolved.provider.clone(), runtime.clone())
395 {
396 if args.verbose {
397 eprintln!("warning: JS provider runtime was not enabled: {error}");
398 }
399 }
400 }
401 if args.verbose {
402 if let Some(session) = &js_extension_session {
403 let info = session.backend_info();
404 eprintln!(
405 "JS extension backend: {} v{} ({})",
406 info.name,
407 info.api_version,
408 info.capability_names().join(", ")
409 );
410 }
411 if let Some(s) = extension_session.summary() {
412 eprintln!("extensions: {s}");
413 }
414 report_deferred_renderers(&extension_session);
415 }
416 merge_extension_tools(&mut tools, &extension_session, args);
417 if let Some(session) = &js_extension_session {
418 merge_js_extension_tools(&mut tools, session, args);
419 if args.verbose && !session.commands.is_empty() {
420 eprintln!("JS extension commands: {}", session.commands.join(", "));
421 }
422 }
423 let mut active = active_tool_names(&tools, args);
424 if let Some(session) = &js_extension_session {
429 let js_names = session.tool_names();
430 if let Some(js_active) = session.active_tools() {
431 active.retain(|name| !js_names.iter().any(|js| js == name));
432 active.extend(js_active.into_iter().filter(|name| {
433 js_names.iter().any(|js| js == name) && tool_name_allowed(name, args)
434 }));
435 }
436 }
437 active = filter_active_tool_names(active, args);
438
439 let selection = select_session(args, cwd);
441 let session = build_session(&selection, &cwd_str).await?;
442 if let Some(js) = &js_extension_session {
443 let session_id = session
444 .get_metadata()
445 .await
446 .ok()
447 .map(|metadata| metadata.id);
448 let leaf_id = session.get_leaf_id().await.ok().flatten();
449 if let Some(session_id) = session_id {
450 let branch = session
451 .find_entries_on_branch(&EntryQuery::default(), &BranchBounds::default())
452 .await
453 .ok()
454 .unwrap_or_default();
455 let branch_json =
456 serde_json::to_value(&branch).unwrap_or_else(|_| serde_json::json!([]));
457 let runtime_context = serde_json::json!({
458 "session": {
459 "id": session_id,
460 "leafId": leaf_id,
461 "branch": branch_json,
462 "entries": branch_json.clone(),
463 },
464 });
465 if let Err(error) = js.set_runtime_context(runtime_context) {
466 if args.verbose {
467 eprintln!("warning: could not sync JS session context: {error}");
468 }
469 }
470 }
471 }
472
473 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
483 let base_prompt = match args.system_prompt.as_deref() {
484 Some(explicit) => explicit.to_string(),
485 None if project_trusted => {
486 match discover_system_prompt_file_with_packages(cwd, &package_resources) {
487 Some(path) => std::fs::read_to_string(&path)
488 .unwrap_or_else(|_| {
489 default_system_prompt(&cwd_str, prior_reasoning_replayed)
490 }),
491 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
492 }
493 }
494 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
495 };
496
497 let mut append_texts: Vec<String> = Vec::new();
504 for extra in &args.append_system_prompt {
505 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
506 append_texts.push(text);
507 }
508 if args.append_system_prompt.is_empty() {
509 if let Some(path) = project_trusted
510 .then(|| discover_append_system_prompt_file_with_packages(cwd, &package_resources))
511 .flatten()
512 {
513 if let Ok(text) = std::fs::read_to_string(&path) {
514 append_texts.push(text);
515 }
516 }
517 }
518 let append_join = if append_texts.is_empty() {
519 None
520 } else {
521 Some(append_texts.join("\n\n"))
522 };
523
524 let agent_dir = crate::config::agent_dir().ok();
536
537 let discovered = extension_session
553 .snapshot_arc()
554 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
555 .unwrap_or_default();
556
557 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
558 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
559 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
560 if !args.no_skills {
561 let mut dirs = if args.dev_local_only {
562 project_skill_dirs(cwd)
563 } else if project_trusted {
564 skill_dirs(cwd)
565 } else {
566 global_skill_dirs()
567 };
568 dirs.extend(args.skill.iter().cloned());
569 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
570 if let Some(session) = &js_extension_session {
571 dirs.extend(session.resources.skill_paths.iter().cloned());
572 }
573 dirs.extend(package_resources.skill_dirs());
574 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
575 skills = result.skills;
576 skill_diags = result.diagnostics;
577 resource_diags.extend(result.resource_diagnostics);
578 }
579
580 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
581 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
582 if !args.no_prompt_templates {
583 let mut dirs = if args.dev_local_only {
584 project_prompt_template_dirs(cwd)
585 } else if project_trusted {
586 prompt_template_dirs(cwd)
587 } else {
588 global_prompt_template_dirs()
589 };
590 dirs.extend(args.prompt_template.iter().cloned());
591 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
592 if let Some(session) = &js_extension_session {
593 dirs.extend(session.resources.prompt_paths.iter().cloned());
594 }
595 dirs.extend(package_resources.prompt_dirs());
596 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
597 prompt_templates = result.prompt_templates;
598 prompt_diags = result.diagnostics;
599 resource_diags.extend(result.resource_diagnostics);
600 }
601
602 let context_block = if args.no_context_files || !project_trusted {
603 String::new()
604 } else {
605 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
610 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
611 format_project_context(&files)
612 };
613
614 if args.verbose {
616 for d in &package_resources.diagnostics {
617 eprintln!("warning: package {}: {}", d.spec, d.message);
618 }
619 for d in &skill_diags {
620 eprintln!(
621 "warning: skill {} ({}): {}",
622 d.path,
623 d.code.as_str(),
624 d.message
625 );
626 }
627 for d in &prompt_diags {
628 eprintln!(
629 "warning: prompt template {} ({}): {}",
630 d.path,
631 d.code.as_str(),
632 d.message
633 );
634 }
635 for d in &resource_diags {
637 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
638 eprintln!(
639 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
640 c.resource_type.as_str(),
641 c.name,
642 c.winner_path,
643 c.loser_path
644 );
645 }
646 }
647 }
648
649 let system_prompt = compose_system_prompt(
658 Some(&base_prompt),
659 &[], if context_block.is_empty() {
661 None
662 } else {
663 Some(&context_block)
664 },
665 append_join.as_deref(),
666 );
667
668 if args.debug_system_prompt {
677 eprintln!("=== --debug-system-prompt ===");
678 let base_src = if args.system_prompt.is_some() {
679 "--system-prompt"
680 } else if discover_system_prompt_file_with_packages(cwd, &package_resources).is_some() {
681 "SYSTEM.md"
682 } else {
683 "default"
684 };
685 eprintln!("[base source: {base_src}]");
686 eprintln!("--- base ---\n{base_prompt}");
687 if let Some(append) = append_join.as_deref() {
688 eprintln!("--- append ---\n{append}");
689 } else {
690 eprintln!("--- append: (none) ---");
691 }
692 if context_block.is_empty() {
693 eprintln!("--- context: (none) ---");
694 } else {
695 eprintln!("--- context ---{context_block}");
696 }
697 let visible_skills = skills
698 .iter()
699 .filter(|s| s.disable_model_invocation != Some(true))
700 .count();
701 eprintln!(
702 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
703 skills.len(),
704 visible_skills,
705 skills.len() - visible_skills
706 );
707 for s in &skills {
708 let hidden = if s.disable_model_invocation == Some(true) {
709 " [hidden]"
710 } else {
711 ""
712 };
713 eprintln!(" {}{hidden} — {}", s.name, s.description);
714 }
715 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
716 for t in &prompt_templates {
717 eprintln!(" /{}", t.name);
718 }
719 eprintln!(
723 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
724 discovered.skill_paths.len(),
725 discovered.prompt_paths.len(),
726 discovered.theme_paths.len(),
727 );
728 for p in &discovered.skill_paths {
729 eprintln!(" skill: {p}");
730 }
731 for p in &discovered.prompt_paths {
732 eprintln!(" prompt: {p}");
733 }
734 eprintln!(
735 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
736 );
737 eprintln!("=== end --debug-system-prompt ===");
738 }
739
740 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
745 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
746 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
750
751 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
760 Some(snapshot) => {
761 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
762 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
763 }
764 None => broadcast_emitter,
765 };
766
767 let saved_settings = crate::settings::load_settings().unwrap_or_default();
771
772 if let Some(proxy) = &saved_settings.http_proxy {
777 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
778 if std::env::var_os(key).is_none() {
779 std::env::set_var(key, proxy);
780 }
781 }
782 }
783
784 let options = AgentHarnessOptions {
785 model: resolved.model.clone(),
786 thinking_level: resolved.thinking_level,
787 active_tool_names: active,
788 tools,
789 system_prompt: Some(system_prompt),
790 resources: AgentHarnessResources {
791 skills: if skills.is_empty() {
792 None
793 } else {
794 Some(skills)
795 },
796 prompt_templates: if prompt_templates.is_empty() {
797 None
798 } else {
799 Some(prompt_templates)
800 },
801 },
802 allow_existing_session: matches!(
805 selection,
806 SessionSelection::Latest
807 | SessionSelection::ById { .. }
808 | SessionSelection::ByExactId { .. }
809 | SessionSelection::Fork { .. }
810 ),
811 stream_options: AgentHarnessStreamOptions {
812 timeout: args.timeout,
813 ..Default::default()
814 },
815 retry: saved_settings.retry_policy(),
820 compaction: saved_settings.compaction_settings(),
821 steering_mode: saved_settings.steering_mode(),
822 follow_up_mode: saved_settings.follow_up_mode(),
823 tool_execution: HarnessToolExecution::default(),
824 drive: DrivingMode::default(),
825 session,
826 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
834 to_provider_messages: None,
835 entry_projectors: Default::default(),
836 agent_emitter: Some(emitter),
837 before_tool_call: None,
843 after_tool_call: None,
844 transform_context: None,
845 entry_transforms: Vec::new(),
846 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
852 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
853 };
854
855 let harness = match AgentHarness::create(options).await {
856 Ok(h) => {
857 crate::extensions_actions::HarnessActionHost::set_harness(
860 &harness_cell,
861 Arc::new(h.clone()),
862 );
863 h
864 }
865 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
866 };
867
868 {
876 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
877 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
878 Ok(reports) => {
879 for (lane, report) in reports {
880 if report.is_clean() {
881 continue;
882 }
883 if report.is_corrupt() {
884 eprintln!(
885 "warning: session lane '{lane}' is corrupt ({} open operations); \
886 refusing to auto-repair — inspect the session file",
887 report.open_operations.len()
888 );
889 continue;
890 }
891 for decision in runtime.reconcile(&report) {
892 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
893 if args.verbose {
894 eprintln!(
895 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
896 );
897 }
898 }
899 }
900 runtime.emit_recovery_events(harness.events(), &report);
901 }
902 }
903 Err(e) => {
904 if args.verbose {
905 eprintln!("warning: session recovery sweep failed: {e}");
906 }
907 }
908 }
909 }
910
911 let reload_context = ReloadContext {
918 extension_session: Arc::new(Mutex::new(extension_session)),
919 js_extension_session: js_extension_session.clone(),
920 package_resources: Arc::new(package_resources.clone()),
921 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
922 catalog,
923 gateway: resolved.provider.clone(),
924 runtime: runtime.clone(),
925 cwd: cwd.to_path_buf(),
926 project_trusted,
927 args: args.clone(),
928 resolved_model: resolved.model.clone(),
929 broadcast: broadcast_for_context,
930 mailbox: reload_mailbox,
931 ui_dialog: ui_dialog_mailbox,
932 ext_status: ext_status_mailbox,
933 dev_extension: None,
934 };
935
936 Ok((harness, event_rx, reload_context))
937}
938
939pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
984
985pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
990
991#[derive(Clone)]
999pub struct ReloadContext {
1000 pub extension_session: ExtensionSessionCell,
1002 pub js_extension_session: Option<crate::js_extensions::JsExtensionSession>,
1004 pub package_resources: Arc<crate::packages::PackageResources>,
1008 pub action_bridge: ActionBridgeCell,
1010 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
1013 pub catalog: Vec<rpi_ai::Model>,
1018 pub gateway: Arc<dyn Provider>,
1021 pub runtime: tokio::runtime::Handle,
1025 pub cwd: PathBuf,
1027 pub project_trusted: bool,
1031 pub args: Args,
1037 pub resolved_model: rpi_ai::Model,
1040 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
1046 pub mailbox: rpi_extensions::ReloadMailbox,
1053 pub ui_dialog: rpi_extensions::UiDialogMailbox,
1059 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
1063}
1064
1065pub async fn dispatch_session_event_async(
1080 reload_context: &ReloadContext,
1081 tag: rpi_plugin_sdk::EventTag,
1082) -> Option<String> {
1083 let (snapshot, keepalive) = {
1089 let session = reload_context.extension_session.lock().unwrap();
1090 (session.snapshot_arc(), session.keepalive())
1091 };
1092 match snapshot {
1093 Some(snapshot) => {
1094 let _keepalive = keepalive;
1095 rpi_extensions::dispatch_lifecycle_event(&snapshot, tag).await
1096 }
1097 None => None,
1098 }
1099}
1100
1101pub struct ReloadOutcome {
1104 pub summary: String,
1107 pub had_warnings: bool,
1109 pub details: Vec<String>,
1114 pub had_errors: bool,
1118}
1119
1120struct PreparedReloadInputs {
1121 package_resources: crate::packages::PackageResources,
1122 extension_dirs: Vec<PathBuf>,
1123 skill_base_dirs: Vec<PathBuf>,
1124 prompt_base_dirs: Vec<PathBuf>,
1125}
1126
1127fn append_reload_resource_paths(
1132 mut paths: Vec<PathBuf>,
1133 explicit: &[PathBuf],
1134 discovered: &[String],
1135 js_paths: &[PathBuf],
1136 package_paths: &[PathBuf],
1137) -> Vec<PathBuf> {
1138 paths.extend(explicit.iter().cloned());
1139 paths.extend(discovered.iter().map(PathBuf::from));
1140 paths.extend(js_paths.iter().cloned());
1141 paths.extend(package_paths.iter().cloned());
1142 paths
1143}
1144
1145pub async fn reload_extension_resources(
1157 harness: &AgentHarness,
1158 ctx: &ReloadContext,
1159) -> ReloadOutcome {
1160 reload_extension_resources_inner(harness, ctx, || {}).await
1161}
1162
1163async fn reload_extension_resources_inner<F>(
1164 harness: &AgentHarness,
1165 ctx: &ReloadContext,
1166 after_prepare: F,
1167) -> ReloadOutcome
1168where
1169 F: FnOnce() + Send,
1170{
1171 let mut effective_args = ctx.args.clone();
1176 if let Some(dev) = &ctx.dev_extension {
1177 if let Err(error) = dev
1178 .rebuild()
1179 .and_then(|_| dev.apply_to_args(&mut effective_args))
1180 {
1181 return ReloadOutcome {
1182 summary: format!(
1183 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1184 dev.package_name()
1185 ),
1186 had_warnings: true,
1187 details: Vec::new(),
1188 had_errors: true,
1189 };
1190 }
1191 }
1192 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1193 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1194 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1195 Ok(prepared) => prepared,
1196 Err(error) => {
1197 return ReloadOutcome {
1198 summary: format!(
1199 "Settings reload failed: {error}. Keeping the currently loaded resources."
1200 ),
1201 had_warnings: true,
1202 details: Vec::new(),
1203 had_errors: true,
1204 };
1205 }
1206 };
1207 after_prepare();
1208 let PreparedReloadInputs {
1209 package_resources,
1210 extension_dirs,
1211 skill_base_dirs,
1212 prompt_base_dirs,
1213 } = prepared;
1214 let mut warnings = false;
1215
1216 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1223 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1224 Some(b) => b.clone_host(),
1225 None => {
1226 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1229 ctx.catalog.clone(),
1230 ctx.cwd.clone(),
1231 ctx.runtime.clone(),
1232 ctx.args.unknown_flags.clone(),
1233 );
1234 crate::extensions_actions::HarnessActionHost::set_harness(
1235 &_cell,
1236 Arc::new(harness.clone()),
1237 );
1238 Arc::new(action_host)
1239 }
1240 };
1241
1242 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1243 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1244 ctx.runtime.clone(),
1245 host,
1246 reload_cb,
1247 ctx.ui_dialog.clone(),
1248 ctx.ext_status.clone(),
1249 );
1250
1251 let extension_session = if effective_args.no_extensions {
1252 rpi_extensions::ExtensionSession::none()
1253 } else {
1254 load_extensions_from_dirs(
1255 &effective_args,
1256 &extension_dirs,
1257 Some(Arc::clone(&fresh_bridge)),
1258 )
1259 };
1260 if extension_session.is_empty() && !effective_args.no_extensions {
1261 }
1264 if effective_args.verbose {
1265 if let Some(s) = extension_session.summary() {
1266 eprintln!("reload: {s}");
1267 }
1268 report_deferred_renderers(&extension_session);
1269 }
1270
1271 {
1285 let mut session_guard = ctx.extension_session.lock().unwrap();
1286 let old_session = std::mem::replace(
1287 &mut *session_guard,
1288 rpi_extensions::ExtensionSession::none(),
1289 );
1290 if let Some(old_snap) = old_session.snapshot_arc() {
1291 old_snap.active_flag().store(false, Ordering::SeqCst);
1295 }
1296 }
1300 if let Some(old_b) = old_bridge {
1301 old_b.invalidate();
1302 }
1303
1304 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1307 *ctx.extension_session.lock().unwrap() = extension_session.clone();
1308
1309 let discovered = extension_session
1311 .snapshot_arc()
1312 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1313 .unwrap_or_default();
1314
1315 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1317 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1318
1319 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1320 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1321 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1322 if !effective_args.no_skills {
1323 let js_paths: &[PathBuf] = ctx
1327 .js_extension_session
1328 .as_ref()
1329 .map(|js| js.resources.skill_paths.as_slice())
1330 .unwrap_or(&[]);
1331 let package_paths = package_resources.skill_dirs();
1332 let dirs = append_reload_resource_paths(
1333 skill_base_dirs,
1334 &effective_args.skill,
1335 &discovered.skill_paths,
1336 js_paths,
1337 &package_paths,
1338 );
1339 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1340 skills = result.skills;
1341 skill_diags = result.diagnostics;
1342 resource_diags.extend(result.resource_diagnostics);
1343 }
1344
1345 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1346 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1347 if !effective_args.no_prompt_templates {
1348 let js_paths: &[PathBuf] = ctx
1349 .js_extension_session
1350 .as_ref()
1351 .map(|js| js.resources.prompt_paths.as_slice())
1352 .unwrap_or(&[]);
1353 let package_paths = package_resources.prompt_dirs();
1354 let dirs = append_reload_resource_paths(
1355 prompt_base_dirs,
1356 &effective_args.prompt_template,
1357 &discovered.prompt_paths,
1358 js_paths,
1359 &package_paths,
1360 );
1361 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1362 prompt_templates = result.prompt_templates;
1363 prompt_diags = result.diagnostics;
1364 resource_diags.extend(result.resource_diagnostics);
1365 }
1366
1367 let context_block = if effective_args.no_context_files {
1368 String::new()
1369 } else {
1370 let agent_dir = crate::config::agent_dir().ok();
1371 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1372 let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
1373 format_project_context(&files)
1374 };
1375
1376 let mut details: Vec<String> = Vec::new();
1377 if !skill_diags.is_empty()
1378 || !prompt_diags.is_empty()
1379 || !package_resources.diagnostics.is_empty()
1380 || !resource_diags.is_empty()
1381 {
1382 warnings = true;
1383 for d in &package_resources.diagnostics {
1388 details.push(format!("warning: package {}: {}", d.spec, d.message));
1389 }
1390 for d in &skill_diags {
1391 details.push(format!(
1392 "warning: skill {} ({}): {}",
1393 d.path,
1394 d.code.as_str(),
1395 d.message
1396 ));
1397 }
1398 for d in &prompt_diags {
1399 details.push(format!(
1400 "warning: prompt template {} ({}): {}",
1401 d.path,
1402 d.code.as_str(),
1403 d.message
1404 ));
1405 }
1406 for d in &resource_diags {
1408 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1409 details.push(format!(
1410 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1411 c.resource_type.as_str(),
1412 c.name,
1413 c.winner_path,
1414 c.loser_path
1415 ));
1416 }
1417 }
1418 }
1419
1420 let prior_reasoning_replayed =
1425 rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1426 let base_prompt = match effective_args.system_prompt.as_deref() {
1427 Some(explicit) => explicit.to_string(),
1428 None => match discover_system_prompt_file_with_packages(&ctx.cwd, &package_resources) {
1429 Some(path) => std::fs::read_to_string(&path)
1430 .unwrap_or_else(|_| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
1431 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1432 },
1433 };
1434 let mut append_texts: Vec<String> = Vec::new();
1435 for extra in &effective_args.append_system_prompt {
1436 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
1437 append_texts.push(text);
1438 }
1439 if effective_args.append_system_prompt.is_empty() {
1440 if let Some(path) =
1441 discover_append_system_prompt_file_with_packages(&ctx.cwd, &package_resources)
1442 {
1443 if let Ok(text) = std::fs::read_to_string(&path) {
1444 append_texts.push(text);
1445 }
1446 }
1447 }
1448 let append_join = if append_texts.is_empty() {
1449 None
1450 } else {
1451 Some(append_texts.join("\n\n"))
1452 };
1453 let system_prompt = compose_system_prompt(
1454 Some(&base_prompt),
1455 &[],
1456 if context_block.is_empty() {
1457 None
1458 } else {
1459 Some(&context_block)
1460 },
1461 append_join.as_deref(),
1462 );
1463
1464 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1466 Some(snapshot) => {
1467 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1468 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1469 }
1470 None => ctx.broadcast.clone(),
1471 };
1472
1473 let resources = AgentHarnessResources {
1475 skills: if skills.is_empty() {
1476 None
1477 } else {
1478 Some(skills.clone())
1479 },
1480 prompt_templates: if prompt_templates.is_empty() {
1481 None
1482 } else {
1483 Some(prompt_templates.clone())
1484 },
1485 };
1486 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1487 let _ = harness.set_resources(resources).await;
1488 let _ = harness.set_agent_emitter(Some(emitter)).await;
1489 let _ = harness
1490 .set_models(build_models_with_extensions_for_reload(
1491 &ctx.gateway,
1492 &extension_session,
1493 ctx.runtime.clone(),
1494 ))
1495 .await;
1496 let _ = harness
1497 .set_provider_hooks(
1498 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1499 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1500 )
1501 .await;
1502
1503 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1507 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1508 let mut tools = build_tools(&tool_ctx, &effective_args);
1509 merge_extension_tools(&mut tools, &extension_session, &effective_args);
1510 if let Some(js) = &ctx.js_extension_session {
1511 merge_js_extension_tools(&mut tools, js, &effective_args);
1512 }
1513 let mut active = active_tool_names(&tools, &effective_args);
1514 if let Some(js) = &ctx.js_extension_session {
1515 let js_names = js.tool_names();
1516 if let Some(js_active) = js.active_tools() {
1517 active.retain(|name| {
1518 tool_name_allowed(name, &effective_args)
1519 && !js_names.iter().any(|js_name| js_name == name)
1520 });
1521 active.extend(js_active.into_iter().filter(|name| {
1522 js_names.iter().any(|js_name| js_name == name)
1523 && tool_name_allowed(name, &effective_args)
1524 }));
1525 }
1526 }
1527 active = filter_active_tool_names(active, &effective_args);
1528 let _ = harness.set_tools(tools, Some(active)).await;
1529
1530 let summary = format!(
1531 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1532 extension_session.loaded_paths().len(),
1533 skills.len(),
1534 prompt_templates.len(),
1535 );
1536 ReloadOutcome {
1537 summary,
1538 had_warnings: warnings,
1539 details,
1540 had_errors: false,
1543 }
1544}
1545
1546#[derive(Clone, Debug, PartialEq, Eq)]
1547struct ReloadSettingsFields {
1548 packages: Option<Vec<crate::settings::PackageSetting>>,
1549 npm_command: Option<Vec<String>>,
1550 skill_dirs: Option<Vec<String>>,
1551 prompt_dirs: Option<Vec<String>>,
1552 extension_dirs: Option<Vec<String>>,
1553}
1554
1555impl From<crate::settings::Settings> for ReloadSettingsFields {
1556 fn from(settings: crate::settings::Settings) -> Self {
1557 Self {
1558 packages: settings.packages,
1559 npm_command: settings.npm_command,
1560 skill_dirs: settings.skill_dirs,
1561 prompt_dirs: settings.prompt_dirs,
1562 extension_dirs: settings.extension_dirs,
1563 }
1564 }
1565}
1566
1567#[derive(Clone, Debug, PartialEq, Eq)]
1568struct ReloadSettingsSnapshot {
1569 global: ReloadSettingsFields,
1570 project: Option<ReloadSettingsFields>,
1571}
1572
1573fn reload_reads_settings(args: &Args) -> bool {
1574 !args.dev_local_only
1575 && (should_load_js_packages(args)
1576 || !args.no_extensions
1577 || !args.no_skills
1578 || !args.no_prompt_templates)
1579}
1580
1581fn load_reload_settings_snapshot(
1585 args: &Args,
1586 cwd: &Path,
1587 project_trusted: bool,
1588) -> Result<Option<ReloadSettingsSnapshot>, String> {
1589 if !reload_reads_settings(args) {
1590 return Ok(None);
1591 }
1592 let global = crate::settings::load_settings()
1593 .map_err(|error| format!("could not load global settings: {error}"))?
1594 .into();
1595 let project = if project_trusted {
1596 crate::settings::load_active_project_settings(cwd)
1597 .map_err(|error| format!("could not load project settings: {error}"))?
1598 .map(|(_, settings)| settings.into())
1599 } else {
1600 None
1601 };
1602 Ok(Some(ReloadSettingsSnapshot { global, project }))
1603}
1604
1605#[cfg(test)]
1608fn validate_settings_for_reload(
1609 args: &Args,
1610 cwd: &Path,
1611 project_trusted: bool,
1612) -> Result<(), String> {
1613 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1614}
1615
1616fn prepare_reload_inputs(
1617 args: &Args,
1618 cwd: &Path,
1619 project_trusted: bool,
1620) -> Result<PreparedReloadInputs, String> {
1621 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1622}
1623
1624fn prepare_reload_inputs_inner<F>(
1625 args: &Args,
1626 cwd: &Path,
1627 project_trusted: bool,
1628 before_verify: F,
1629) -> Result<PreparedReloadInputs, String>
1630where
1631 F: FnOnce(),
1632{
1633 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1634
1635 let package_resources = if args.dev_local_only {
1636 crate::packages::PackageResources::default()
1637 } else {
1638 package_resources_for(args, cwd, project_trusted)
1639 };
1640
1641 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1642 Vec::new()
1643 } else if project_trusted {
1644 extension_dirs(cwd)
1645 } else {
1646 global_extension_dirs()
1647 };
1648 if !args.no_extensions {
1649 extension_dirs.extend(args.extensions_dir.iter().cloned());
1650 }
1651
1652 let skill_base_dirs = if args.no_skills {
1653 Vec::new()
1654 } else if args.dev_local_only {
1655 project_skill_dirs(cwd)
1656 } else if project_trusted {
1657 skill_dirs(cwd)
1658 } else {
1659 global_skill_dirs()
1660 };
1661
1662 let prompt_base_dirs = if args.no_prompt_templates {
1663 Vec::new()
1664 } else if args.dev_local_only {
1665 project_prompt_template_dirs(cwd)
1666 } else if project_trusted {
1667 prompt_template_dirs(cwd)
1668 } else {
1669 global_prompt_template_dirs()
1670 };
1671
1672 before_verify();
1673 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1674 if settings_before != settings_after {
1675 return Err(
1676 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1677 );
1678 }
1679
1680 Ok(PreparedReloadInputs {
1681 package_resources,
1682 extension_dirs,
1683 skill_base_dirs,
1684 prompt_base_dirs,
1685 })
1686}
1687
1688fn build_models_with_extensions_for_reload(
1693 gateway: &Arc<dyn Provider>,
1694 extension_session: &ExtensionSession,
1695 runtime: tokio::runtime::Handle,
1696) -> Vec<Arc<dyn Provider>> {
1697 let mut models: Vec<Arc<dyn Provider>> = vec![gateway.clone()];
1698 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1699 models.extend(pluggable);
1700 models
1701}
1702
1703fn report_deferred_renderers(session: &ExtensionSession) {
1707 let Some(snap) = session.snapshot_arc() else {
1708 return;
1709 };
1710 let all = snap.renderers();
1711 let markdown = all
1712 .iter()
1713 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1714 .count();
1715 let message = all
1716 .iter()
1717 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1718 .count();
1719 let entry = all
1720 .iter()
1721 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1722 .count();
1723 if markdown + message + entry == 0 {
1724 return;
1725 }
1726 eprintln!(
1727 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1728 markdown, message, entry
1729 );
1730}
1731
1732#[derive(Debug, thiserror::Error)]
1734pub enum BuildError {
1735 #[error("Could not create the session directory: {0}")]
1736 SessionDir(String),
1737 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1738 SessionNotFound { requested: String, dir: String },
1739 #[error("Could not build the harness: {0}")]
1740 HarnessCreate(String),
1741}
1742
1743fn build_models_with_extensions(
1753 resolved: &ResolvedModel,
1754 extension_session: &ExtensionSession,
1755 runtime: tokio::runtime::Handle,
1756) -> Vec<Arc<dyn Provider>> {
1757 let mut models: Vec<Arc<dyn Provider>> = vec![resolved.provider.clone() as Arc<dyn Provider>];
1758 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1759 models.extend(pluggable);
1760 models
1761}
1762
1763pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1766 if let Some(override_value) = args.trust_override {
1767 return override_value;
1768 }
1769 crate::config::project_trust_decision(cwd)
1770 .ok()
1771 .flatten()
1772 .unwrap_or(true)
1773}
1774
1775fn load_extensions(
1783 args: &Args,
1784 cwd: &Path,
1785 project_trusted: bool,
1786 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1787) -> ExtensionSession {
1788 let mut dirs = if args.dev_local_only {
1789 Vec::new()
1790 } else if project_trusted {
1791 extension_dirs(cwd)
1792 } else {
1793 global_extension_dirs()
1794 };
1795 dirs.extend(args.extensions_dir.iter().cloned());
1796 load_extensions_from_dirs(args, &dirs, action_bridge)
1797}
1798
1799fn load_extensions_from_dirs(
1800 args: &Args,
1801 dirs: &[PathBuf],
1802 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1803) -> ExtensionSession {
1804 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1805 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1812}
1813
1814fn js_extension_paths(
1815 args: &Args,
1816 cwd: &Path,
1817 project_trusted: bool,
1818 packages: &crate::packages::PackageResources,
1819) -> Vec<PathBuf> {
1820 if args.dev_local_only {
1821 return Vec::new();
1822 }
1823 let mut paths = packages.extension_paths();
1824 let discovered_dirs = if project_trusted {
1825 extension_dirs(cwd)
1826 } else {
1827 global_extension_dirs()
1828 };
1829 for dir in discovered_dirs {
1830 if let Ok(entries) = std::fs::read_dir(dir) {
1831 paths.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
1832 matches!(
1833 path.extension()
1834 .and_then(|ext| ext.to_str())
1835 .map(|ext| ext.to_ascii_lowercase())
1836 .as_deref(),
1837 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1838 )
1839 }));
1840 }
1841 }
1842 paths.extend(
1843 args.extension
1844 .iter()
1845 .filter(|path| {
1846 matches!(
1847 path.extension()
1848 .and_then(|ext| ext.to_str())
1849 .map(|ext| ext.to_ascii_lowercase())
1850 .as_deref(),
1851 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1852 )
1853 })
1854 .cloned(),
1855 );
1856 paths
1857}
1858
1859fn merge_extension_tools(tools: &mut Vec<HarnessTool>, session: &ExtensionSession, args: &Args) {
1865 let Some(snapshot) = session.snapshot() else {
1866 return;
1867 };
1868 for et in snapshot.tools() {
1869 let name = &et.tool.name;
1870 if !tool_name_allowed(name, args) {
1871 continue;
1872 }
1873 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive());
1874 let harness_tool = HarnessTool::new(Arc::new(adapter));
1875 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1876 Some(slot) => *slot = harness_tool,
1877 None => tools.push(harness_tool),
1878 }
1879 }
1880}
1881
1882fn merge_js_extension_tools(
1883 tools: &mut Vec<HarnessTool>,
1884 session: &crate::js_extensions::JsExtensionSession,
1885 args: &Args,
1886) {
1887 for adapter in session.tools() {
1888 let name = adapter.schema().name.clone();
1889 if !tool_name_allowed(&name, args) {
1890 continue;
1891 }
1892 let harness_tool = HarnessTool::new(Arc::new(adapter));
1893 match tools
1894 .iter_mut()
1895 .find(|tool| tool.tool.schema().name == name)
1896 {
1897 Some(slot) => *slot = harness_tool,
1898 None => tools.push(harness_tool),
1899 }
1900 }
1901}
1902
1903pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1910 use rpi_tools::tools::bash::BashToolOptions;
1911 let default = std::env::var("RPI_BASH_TIMEOUT")
1912 .ok()
1913 .and_then(|v| v.parse::<f64>().ok())
1914 .unwrap_or(120.0);
1915 let settings = crate::settings::load_settings().unwrap_or_default();
1921 BashToolOptions {
1922 command_prefix: settings.shell_command_prefix.clone(),
1923 default_timeout: Some(default),
1924 }
1925}
1926
1927fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1928 if args.no_tools {
1929 return Vec::new();
1930 }
1931 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1940 (
1941 "read",
1942 HarnessTool::new(create_read_tool(ctx, None)).with_replay(ToolReplay::Safe),
1943 ),
1944 (
1945 "bash",
1946 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1947 ),
1948 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1949 ("write", HarnessTool::new(create_write_tool(ctx))),
1950 (
1951 "docs",
1952 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
1953 ),
1954 ];
1955
1956 #[cfg(target_os = "windows")]
1958 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
1959
1960 if args.tools.is_none() {
1962 if let Ok(settings) = crate::settings::load_settings() {
1963 if let Some(default_tools) = &settings.default_tools {
1964 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
1965 }
1966 }
1967 }
1968
1969 if args.no_builtin_tools {
1973 all.clear();
1974 }
1975
1976 if let Some(allow) = &args.tools {
1978 all.retain(|(name, _)| allow.iter().any(|a| a == name));
1979 }
1980 if let Some(deny) = &args.exclude_tools {
1982 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1983 }
1984
1985 all.into_iter().map(|(_, t)| t).collect()
1986}
1987
1988fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1992 filter_active_tool_names(
1993 tools.iter().map(|tool| tool.tool.schema().name.clone()),
1994 args,
1995 )
1996}
1997
1998pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
2002 if args.no_tools {
2003 return false;
2004 }
2005 if args
2006 .tools
2007 .as_ref()
2008 .is_some_and(|allow| !allow.iter().any(|value| value == name))
2009 {
2010 return false;
2011 }
2012 if args
2013 .exclude_tools
2014 .as_ref()
2015 .is_some_and(|deny| deny.iter().any(|value| value == name))
2016 {
2017 return false;
2018 }
2019 true
2020}
2021
2022pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
2023where
2024 I: IntoIterator<Item = String>,
2025{
2026 names
2027 .into_iter()
2028 .filter(|name| tool_name_allowed(name, args))
2029 .collect()
2030}
2031
2032async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2034 match selection {
2035 SessionSelection::Ephemeral => Ok(ephemeral_session()),
2036 SessionSelection::New { dir, .. } => {
2037 std::fs::create_dir_all(dir)
2040 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2041 let session = create_jsonl_session(dir, cwd)
2042 .await
2043 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2044 Ok(session)
2045 }
2046 SessionSelection::Latest
2047 | SessionSelection::ById { .. }
2048 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2049 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
2050 }
2051}
2052
2053async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2059 match selection {
2063 SessionSelection::Latest => {
2064 let metas = list_session_metadata(cwd).await?;
2065 let Some(meta) = metas.first() else {
2066 return Err(BuildError::SessionNotFound {
2067 requested: "the most recent session".to_string(),
2068 dir: default_session_dir(Path::new(cwd)).display().to_string(),
2069 });
2070 };
2071 open_session(meta, cwd).await
2072 }
2073 SessionSelection::ById { id } => open_session_by_id(id, cwd).await.map_err(|e| match e {
2074 OpenError::NotFound { requested } => BuildError::SessionNotFound {
2075 requested,
2076 dir: default_session_dir(Path::new(cwd)).display().to_string(),
2077 },
2078 OpenError::Other(msg) => BuildError::SessionDir(msg),
2079 }),
2080 SessionSelection::ByExactId { id } => {
2081 let metas = list_session_metadata(cwd).await?;
2084 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
2085 return open_session(meta, cwd).await;
2086 }
2087 let dir = default_session_dir(Path::new(cwd));
2088 std::fs::create_dir_all(&dir)
2089 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2090 create_jsonl_session_with_id(&dir, cwd, Some(id.clone()))
2091 .await
2092 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2093 }
2094 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
2095 }
2096}
2097
2098async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
2101 use rpi_harness::session::jsonl::{
2102 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2103 };
2104 use rpi_harness::session::types::{ForkOptions, SessionStorage};
2105 use rpi_tools::FileSystem;
2106
2107 let dir = default_session_dir(Path::new(cwd));
2108 std::fs::create_dir_all(&dir)
2109 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2110 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2111 let fs: Arc<dyn FileSystem> = env.clone();
2112 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2113 fs: fs.clone(),
2114 sessions_root: dir.to_string_lossy().into_owned(),
2115 clock: Arc::new(SystemClock),
2116 ids: Arc::new(DefaultIdGenerator::new()),
2117 });
2118 let metas = repo
2119 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2120 .await
2121 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2122 let source_meta = metas
2123 .iter()
2124 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2125 .ok_or_else(|| BuildError::SessionNotFound {
2126 requested: format!("--fork {source}"),
2127 dir: dir.display().to_string(),
2128 })?;
2129 let fork_storage = repo
2130 .fork_typed(
2131 source_meta,
2132 &JsonlSessionCreateOptions {
2133 id: None,
2134 parent_session_id: Some(source_meta.id.clone()),
2135 cwd: cwd.to_string(),
2136 metadata: None,
2137 },
2138 &ForkOptions::default(),
2139 )
2140 .await
2141 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2142 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2143 Ok(Session::new(storage_arc, None))
2144}
2145
2146pub enum OpenError {
2149 NotFound { requested: String },
2151 Other(String),
2153}
2154
2155impl std::fmt::Display for OpenError {
2156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2157 match self {
2158 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2159 OpenError::Other(msg) => write!(f, "{msg}"),
2160 }
2161 }
2162}
2163
2164pub async fn list_session_metadata(
2167 cwd: &str,
2168) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2169 use rpi_harness::session::jsonl::{
2170 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2171 };
2172 use rpi_tools::FileSystem;
2173
2174 let dir = default_session_dir(Path::new(cwd));
2175 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2176 let fs: Arc<dyn FileSystem> = env.clone();
2177 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2178 fs: fs.clone(),
2179 sessions_root: dir.to_string_lossy().into_owned(),
2180 clock: Arc::new(SystemClock),
2181 ids: Arc::new(DefaultIdGenerator::new()),
2182 });
2183 repo.list_typed(&JsonlSessionListOptions::default())
2184 .await
2185 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2186}
2187
2188pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2192 let metas = list_session_metadata(cwd)
2193 .await
2194 .map_err(|e| OpenError::Other(e.to_string()))?;
2195 let Some(meta) = metas
2196 .iter()
2197 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2198 else {
2199 return Err(OpenError::NotFound {
2200 requested: format!("session {id}"),
2201 });
2202 };
2203 open_session(meta, cwd)
2204 .await
2205 .map_err(|e| OpenError::Other(e.to_string()))
2206}
2207
2208pub(crate) async fn fork_session_storage(
2215 harness: &AgentHarness,
2216 cwd: &str,
2217) -> Result<Session, String> {
2218 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2219 use rpi_tools::FileSystem;
2220
2221 let dir = default_session_dir(Path::new(cwd));
2222 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2223 let fs: Arc<dyn FileSystem> = env.clone();
2224 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2225 fs,
2226 sessions_root: dir.to_string_lossy().into_owned(),
2227 clock: Arc::new(SystemClock),
2228 ids: Arc::new(DefaultIdGenerator::new()),
2229 });
2230 let id = harness.session().storage().metadata().id.clone();
2233 let metas = list_session_metadata(cwd)
2234 .await
2235 .map_err(|e| e.to_string())?;
2236 let Some(source) = metas.iter().find(|m| m.id == id) else {
2237 return Err(format!("current session {id} not found on disk"));
2238 };
2239 let fork_storage = repo
2240 .fork_typed(
2241 source,
2242 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2243 id: None,
2244 parent_session_id: Some(source.id.clone()),
2245 cwd: cwd.to_string(),
2246 metadata: None,
2247 },
2248 &rpi_harness::session::types::ForkOptions::default(),
2249 )
2250 .await
2251 .map_err(|e| e.to_string())?;
2252 Ok(Session::new(Arc::new(fork_storage), None))
2253}
2254
2255async fn open_session(
2258 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2259 cwd: &str,
2260) -> Result<Session, BuildError> {
2261 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2262 use rpi_harness::session::types::SessionStorage;
2263 use rpi_tools::FileSystem;
2264
2265 let dir = default_session_dir(Path::new(cwd));
2266 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2267 let fs: Arc<dyn FileSystem> = env.clone();
2268 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2269 fs: fs.clone(),
2270 sessions_root: dir.to_string_lossy().into_owned(),
2271 clock: Arc::new(SystemClock),
2272 ids: Arc::new(DefaultIdGenerator::new()),
2273 });
2274 let storage = repo
2275 .open_by_jsonl_metadata(meta)
2276 .await
2277 .map_err(|e| BuildError::SessionDir(format!("open {}: {e}", meta.path)))?;
2278 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2282 Some(Path::new(&meta.path)),
2283 Path::new(&meta.cwd),
2284 Path::new(cwd),
2285 ) {
2286 eprintln!(
2287 "warning: {}",
2288 crate::session_cwd::format_missing_session_cwd_error(&issue)
2289 );
2290 }
2291 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2292 Ok(Session::new(storage_arc, None))
2293}
2294
2295fn ephemeral_session() -> Session {
2297 let storage = Arc::new(InMemorySessionStorage::new(
2298 SessionMetadata {
2299 id: "ephemeral".into(),
2300 created_at: 0,
2301 parent_session_id: None,
2302 },
2303 Arc::new(SystemClock),
2304 Arc::new(DefaultIdGenerator::new()),
2305 ));
2306 Session::new(storage, None)
2307}
2308
2309pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2320 create_jsonl_session_with_id(dir, cwd, None).await
2321}
2322
2323pub(crate) async fn create_jsonl_session_with_id(
2327 dir: &Path,
2328 cwd: &str,
2329 id: Option<String>,
2330) -> Result<Session, String> {
2331 use rpi_harness::session::jsonl::{
2332 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2333 };
2334 use rpi_tools::FileSystem;
2335
2336 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2339 let fs: Arc<dyn FileSystem> = env.clone();
2340
2341 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2342 fs: fs.clone(),
2343 sessions_root: dir.to_string_lossy().into_owned(),
2344 clock: Arc::new(SystemClock),
2345 ids: Arc::new(DefaultIdGenerator::new()),
2346 });
2347
2348 let opts = JsonlSessionCreateOptions {
2349 id, parent_session_id: None,
2351 cwd: cwd.to_string(),
2352 metadata: None,
2353 };
2354 let storage = repo
2355 .create_typed(&opts)
2356 .await
2357 .map_err(|e| format!("create session: {e}"))?;
2358 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2360 Ok(Session::new(storage_arc, None))
2361}
2362
2363fn read_append_target(target: &str) -> Option<String> {
2366 let path = Path::new(target);
2367 if path.is_file() {
2368 std::fs::read_to_string(path).ok()
2369 } else {
2370 None
2371 }
2372}
2373
2374#[cfg(test)]
2375mod tests {
2376 use super::*;
2377 use crate::args::Args;
2378
2379 #[test]
2380 fn default_prompt_mentions_cwd_and_tools() {
2381 let p = default_system_prompt("/tmp/proj", false);
2382 assert!(p.contains("/tmp/proj"));
2383 assert!(p.contains("read"));
2384 assert!(p.contains("bash"));
2385 assert!(p.contains("edit"));
2386 assert!(p.contains("write"));
2387 assert!(p.contains("docs"));
2388 assert!(!p.contains("- grep"));
2389 assert!(!p.contains("- find"));
2390 assert!(!p.contains("- ls"));
2391 assert!(!p.contains("powershell"));
2392 }
2393
2394 #[test]
2395 fn default_prompt_keeps_working_state_in_the_visible_channel() {
2396 let p = default_system_prompt("/tmp/proj", false);
2404 let lower = p.to_lowercase();
2405 assert!(
2407 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
2408 "the prompt must explain that reasoning does not survive the turn: {p}"
2409 );
2410 assert!(
2412 lower.contains("visible") && lower.contains("todo tool"),
2413 "the prompt must point at visible text and the todo tool: {p}"
2414 );
2415 assert!(
2417 lower.contains("re-stating the same plan"),
2418 "the prompt must call out plan re-statement as a bug: {p}"
2419 );
2420 assert!(
2422 lower.contains("already shown earlier"),
2423 "the prompt must discourage re-fetching known content: {p}"
2424 );
2425 }
2426
2427 #[test]
2428 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
2429 let p = default_system_prompt("/tmp/proj", true);
2436 let lower = p.to_lowercase();
2437 assert!(
2438 lower.contains("carried back"),
2439 "a replaying model must be told its reasoning survives: {p}"
2440 );
2441 assert!(
2442 !lower.contains("reasoning is not"),
2443 "the replayed variant must not claim reasoning is dropped: {p}"
2444 );
2445 assert!(
2446 lower.contains("do not re-derive"),
2447 "the replayed variant must forbid re-deriving the plan: {p}"
2448 );
2449 assert!(
2450 lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
2451 "the anti-repetition rule must survive the flip: {p}"
2452 );
2453 assert!(
2454 lower.contains("already shown earlier"),
2455 "the re-read rule must survive the flip: {p}"
2456 );
2457 }
2458
2459 #[test]
2467 fn only_read_only_tools_are_replayable() {
2468 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2469 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2470 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2471 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2472 let tools = build_tools(&context, &Args::default());
2473
2474 let replay_of = |name: &str| {
2475 tools
2476 .iter()
2477 .find(|tool| tool.tool.schema().name == name)
2478 .map(|tool| tool.replay)
2479 };
2480
2481 for read_only in ["read", "docs"] {
2482 assert_eq!(
2483 replay_of(read_only),
2484 Some(ToolReplay::Safe),
2485 "{read_only} is side-effect free, so recovery may re-run it"
2486 );
2487 }
2488 for mutating in ["bash", "edit", "write", "powershell"] {
2489 if let Some(replay) = replay_of(mutating) {
2490 assert_eq!(
2491 replay,
2492 ToolReplay::Never,
2493 "{mutating} must never be replayed: re-running it would duplicate its effect"
2494 );
2495 }
2496 }
2497 }
2498
2499 #[test]
2500 fn default_tools_include_docs_lookup() {
2501 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2502 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2503 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2504 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2505 let names: Vec<String> = build_tools(&context, &Args::default())
2506 .iter()
2507 .map(|tool| tool.tool.schema().name.clone())
2508 .collect();
2509
2510 assert!(names.contains(&"docs".to_string()));
2515 for legacy in ["grep", "find", "ls"] {
2516 assert!(
2517 !names.contains(&legacy.to_string()),
2518 "legacy tool {legacy} should not be registered by default"
2519 );
2520 }
2521 }
2522
2523 #[test]
2524 fn pi_package_loading_is_opt_in_and_respects_no_extensions() {
2525 let args = Args::default();
2526 assert!(!should_load_js_packages(&args));
2527 let resources = package_resources_for(&args, Path::new("."), false);
2528 assert!(resources.packages.is_empty());
2529
2530 let args = Args {
2531 enable_pi_packages: true,
2532 ..Args::default()
2533 };
2534 assert!(should_load_js_packages(&args));
2535
2536 let args = Args {
2537 enable_pi_packages: true,
2538 no_extensions: true,
2539 ..Args::default()
2540 };
2541 assert!(!should_load_js_packages(&args));
2542 assert!(package_resources_for(&args, Path::new("."), false)
2543 .packages
2544 .is_empty());
2545 }
2546
2547 #[test]
2548 fn pi_offline_env_disables_startup_package_remediation() {
2549 struct RestoreEnv {
2550 name: &'static str,
2551 value: Option<std::ffi::OsString>,
2552 }
2553
2554 impl Drop for RestoreEnv {
2555 fn drop(&mut self) {
2556 match self.value.take() {
2557 Some(value) => std::env::set_var(self.name, value),
2558 None => std::env::remove_var(self.name),
2559 }
2560 }
2561 }
2562
2563 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2564 let _restore_config = RestoreEnv {
2565 name: crate::config::CONFIG_DIR_ENV,
2566 value: std::env::var_os(crate::config::CONFIG_DIR_ENV),
2567 };
2568 let _restore_offline = RestoreEnv {
2569 name: crate::args::PI_OFFLINE_ENV,
2570 value: std::env::var_os(crate::args::PI_OFFLINE_ENV),
2571 };
2572 let tmp = tempfile::tempdir().unwrap();
2573 let agent = tmp.path().join("agent");
2574 let cwd = tmp.path().join("project");
2575 let package = agent.join("npm/node_modules/demo");
2576 std::fs::create_dir_all(package.join("extensions")).unwrap();
2577 std::fs::create_dir_all(&cwd).unwrap();
2578 std::fs::write(
2579 package.join("package.json"),
2580 r#"{"name":"demo","version":"1.0.0"}"#,
2581 )
2582 .unwrap();
2583 std::fs::write(
2584 package.join("extensions/index.js"),
2585 "export default () => {};",
2586 )
2587 .unwrap();
2588 std::fs::write(
2589 agent.join("settings.json"),
2590 r#"{"npmCommand":[""],"packages":["npm:demo@2.0.0"]}"#,
2591 )
2592 .unwrap();
2593 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2594 std::env::set_var(crate::args::PI_OFFLINE_ENV, "TrUe");
2595 let args = Args {
2596 enable_pi_packages: true,
2597 offline: false,
2598 ..Args::default()
2599 };
2600
2601 let resources = package_resources_for(&args, &cwd, false);
2602
2603 assert!(resources.packages.is_empty());
2604 assert_eq!(resources.diagnostics.len(), 1);
2605 assert!(resources.diagnostics[0].message.contains("offline"));
2606 assert_eq!(
2607 std::fs::read(package.join("package.json")).unwrap(),
2608 br#"{"name":"demo","version":"1.0.0"}"#
2609 );
2610 }
2611
2612 #[test]
2613 fn package_discovery_uses_the_callers_trust_snapshot() {
2614 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2615 let previous = std::env::var_os(crate::config::CONFIG_DIR_ENV);
2616 let tmp = tempfile::tempdir().unwrap();
2617 let agent = tmp.path().join("agent");
2618 let cwd = tmp.path().join("project");
2619 let package = cwd.join("package");
2620 std::fs::create_dir_all(&agent).unwrap();
2621 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2622 std::fs::create_dir_all(&package).unwrap();
2623 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2624 std::fs::write(
2625 package.join("package.json"),
2626 r#"{"name":"snapshot-package","version":"1.0.0"}"#,
2627 )
2628 .unwrap();
2629 std::fs::write(
2630 cwd.join(".rpi/settings.json"),
2631 serde_json::json!({"packages": [package]}).to_string(),
2632 )
2633 .unwrap();
2634 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2635
2636 let args = Args {
2637 enable_pi_packages: true,
2638 ..Args::default()
2639 };
2640 assert_eq!(package_resources_for(&args, &cwd, true).packages.len(), 1);
2641 assert!(package_resources_for(&args, &cwd, false)
2642 .packages
2643 .is_empty());
2644 assert_eq!(
2645 package_resources_for_update_check(&args, &cwd, true)
2646 .packages
2647 .len(),
2648 1
2649 );
2650 assert!(package_resources_for_update_check(&args, &cwd, false)
2651 .packages
2652 .is_empty());
2653
2654 match previous {
2655 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2656 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2657 }
2658 }
2659
2660 #[test]
2661 fn reload_settings_preflight_is_independent_of_packages_and_respects_trust() {
2662 struct RestoreConfigDir(Option<std::ffi::OsString>);
2663
2664 impl Drop for RestoreConfigDir {
2665 fn drop(&mut self) {
2666 match self.0.take() {
2667 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2668 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2669 }
2670 }
2671 }
2672
2673 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2674 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2675 let tmp = tempfile::tempdir().unwrap();
2676 let agent = tmp.path().join("agent");
2677 let cwd = tmp.path().join("project");
2678 std::fs::create_dir_all(&agent).unwrap();
2679 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2680 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2681 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
2682 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2683 let packages_disabled = Args::default();
2684 let packages_enabled = Args {
2685 enable_pi_packages: true,
2686 ..Args::default()
2687 };
2688
2689 for args in [&packages_disabled, &packages_enabled] {
2690 let trusted = validate_settings_for_reload(args, &cwd, true);
2691 let untrusted = validate_settings_for_reload(args, &cwd, false);
2692 assert!(trusted
2693 .unwrap_err()
2694 .contains("could not load project settings"));
2695 assert!(untrusted.is_ok());
2696 }
2697
2698 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2699 for args in [&packages_disabled, &packages_enabled] {
2700 assert!(validate_settings_for_reload(args, &cwd, false)
2701 .unwrap_err()
2702 .contains("could not load global settings"));
2703 }
2704
2705 let local_only = Args {
2706 dev_local_only: true,
2707 ..Args::default()
2708 };
2709 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
2710 }
2711
2712 #[tokio::test(flavor = "current_thread")]
2713 async fn reload_with_packages_disabled_preserves_live_resources_when_settings_break() {
2714 struct RestoreConfigDir(Option<std::ffi::OsString>);
2715
2716 impl Drop for RestoreConfigDir {
2717 fn drop(&mut self) {
2718 match self.0.take() {
2719 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2720 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2721 }
2722 }
2723 }
2724
2725 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2726 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2727 let tmp = tempfile::tempdir().unwrap();
2728 let agent = tmp.path().join("agent");
2729 let cwd = tmp.path().join("project");
2730 let skill_dir = tmp.path().join("configured-skills");
2731 std::fs::create_dir_all(&agent).unwrap();
2732 std::fs::create_dir_all(&cwd).unwrap();
2733 std::fs::create_dir_all(&skill_dir).unwrap();
2734 std::fs::write(
2735 skill_dir.join("SKILL.md"),
2736 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
2737 )
2738 .unwrap();
2739 std::fs::write(
2740 agent.join("settings.json"),
2741 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2742 )
2743 .unwrap();
2744 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2745
2746 let resolved = crate::provider::resolve(
2747 Some("anthropic"),
2748 Some(crate::provider::DEFAULT_MODEL_ID),
2749 None,
2750 Some("test-key"),
2751 None,
2752 )
2753 .unwrap();
2754 let args = Args {
2755 trust_override: Some(false),
2756 no_session: true,
2757 no_extensions: true,
2758 no_prompt_templates: true,
2759 no_context_files: true,
2760 system_prompt: Some("stable system prompt".into()),
2761 ..Args::default()
2762 };
2763 assert!(!should_load_js_packages(&args));
2764
2765 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2766 let before_resources = harness.get_resources().await.unwrap();
2767 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
2768 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
2769 let before_prompt = harness.get_system_prompt().await.unwrap();
2770 let before_tools: Vec<String> = harness
2771 .get_tools()
2772 .await
2773 .unwrap()
2774 .iter()
2775 .map(|tool| tool.tool.schema().name.clone())
2776 .collect();
2777 let before_bridge = context
2778 .action_bridge
2779 .lock()
2780 .unwrap()
2781 .as_ref()
2782 .unwrap()
2783 .clone();
2784
2785 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2786 let outcome = reload_extension_resources(&harness, &context).await;
2787
2788 assert!(outcome.had_warnings);
2789 assert!(outcome.summary.contains("Settings reload failed"));
2790 assert!(outcome.summary.contains("could not load global settings"));
2791 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
2792 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
2793 let after_tools: Vec<String> = harness
2794 .get_tools()
2795 .await
2796 .unwrap()
2797 .iter()
2798 .map(|tool| tool.tool.schema().name.clone())
2799 .collect();
2800 assert_eq!(after_tools, before_tools);
2801 let after_bridge = context
2802 .action_bridge
2803 .lock()
2804 .unwrap()
2805 .as_ref()
2806 .unwrap()
2807 .clone();
2808 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
2809 }
2810
2811 #[test]
2812 fn reload_preparation_rejects_settings_changed_during_derivation() {
2813 struct RestoreConfigDir(Option<std::ffi::OsString>);
2814
2815 impl Drop for RestoreConfigDir {
2816 fn drop(&mut self) {
2817 match self.0.take() {
2818 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2819 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2820 }
2821 }
2822 }
2823
2824 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2825 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2826 let tmp = tempfile::tempdir().unwrap();
2827 let agent = tmp.path().join("agent");
2828 let cwd = tmp.path().join("project");
2829 let first_skill_dir = tmp.path().join("first-skills");
2830 let second_skill_dir = tmp.path().join("second-skills");
2831 std::fs::create_dir_all(&agent).unwrap();
2832 std::fs::create_dir_all(&cwd).unwrap();
2833 std::fs::write(
2834 agent.join("settings.json"),
2835 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
2836 )
2837 .unwrap();
2838 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2839 let args = Args {
2840 trust_override: Some(false),
2841 no_extensions: true,
2842 no_prompt_templates: true,
2843 ..Args::default()
2844 };
2845
2846 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
2847 std::fs::write(
2848 agent.join("settings.json"),
2849 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
2850 )
2851 .unwrap();
2852 });
2853
2854 let error = result
2855 .err()
2856 .expect("settings mutation must fail preparation");
2857 assert!(error.contains("settings changed while reload inputs were being prepared"));
2858 }
2859
2860 #[tokio::test(flavor = "current_thread")]
2861 async fn reload_uses_frozen_settings_inputs_after_preparation() {
2862 struct RestoreConfigDir(Option<std::ffi::OsString>);
2863
2864 impl Drop for RestoreConfigDir {
2865 fn drop(&mut self) {
2866 match self.0.take() {
2867 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2868 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2869 }
2870 }
2871 }
2872
2873 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2874 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2875 let tmp = tempfile::tempdir().unwrap();
2876 let agent = tmp.path().join("agent");
2877 let cwd = tmp.path().join("project");
2878 let skill_dir = tmp.path().join("configured-skills");
2879 std::fs::create_dir_all(&agent).unwrap();
2880 std::fs::create_dir_all(&cwd).unwrap();
2881 std::fs::create_dir_all(&skill_dir).unwrap();
2882 std::fs::write(
2883 skill_dir.join("SKILL.md"),
2884 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
2885 )
2886 .unwrap();
2887 std::fs::write(
2888 agent.join("settings.json"),
2889 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2890 )
2891 .unwrap();
2892 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2893
2894 let resolved = crate::provider::resolve(
2895 Some("anthropic"),
2896 Some(crate::provider::DEFAULT_MODEL_ID),
2897 None,
2898 Some("test-key"),
2899 None,
2900 )
2901 .unwrap();
2902 let args = Args {
2903 trust_override: Some(false),
2904 no_session: true,
2905 no_extensions: true,
2906 no_prompt_templates: true,
2907 no_context_files: true,
2908 system_prompt: Some("stable system prompt".into()),
2909 ..Args::default()
2910 };
2911 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2912
2913 let outcome = reload_extension_resources_inner(&harness, &context, || {
2914 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2915 })
2916 .await;
2917
2918 assert!(!outcome.summary.contains("Settings reload failed"));
2919 assert_eq!(
2920 outcome.summary,
2921 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
2922 );
2923 let resources = harness.get_resources().await.unwrap();
2924 let skills = resources.skills.as_ref().unwrap();
2925 assert_eq!(skills.len(), 1);
2926 assert_eq!(skills[0].name, "frozen-skill");
2927 }
2928
2929 #[tokio::test]
2930 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
2931 let tmp = tempfile::tempdir().unwrap();
2932 let skill_path = tmp.path().join("explicit-skill.md");
2933 std::fs::write(
2934 &skill_path,
2935 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
2936 )
2937 .unwrap();
2938 let prompt_path = tmp.path().join("explicit-prompt.md");
2939 std::fs::write(
2940 &prompt_path,
2941 "---\ndescription: Explicit prompt\n---\nPrompt body",
2942 )
2943 .unwrap();
2944
2945 let args = Args {
2946 skill: vec![skill_path.clone()],
2947 prompt_template: vec![prompt_path.clone()],
2948 ..Args::default()
2949 };
2950 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
2951 let prompt_paths =
2952 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
2953 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
2954 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
2955
2956 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
2957 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
2958 assert_eq!(skills.skills[0].name, "explicit-skill");
2959
2960 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
2961 assert_eq!(
2962 prompts.prompt_templates.len(),
2963 1,
2964 "{:?}",
2965 prompts.diagnostics
2966 );
2967 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
2968 }
2969
2970 #[test]
2971 fn tool_policy_applies_to_rust_and_js_active_names() {
2972 let names = vec![
2973 "read".to_string(),
2974 "ask_user_question".to_string(),
2975 "write".to_string(),
2976 ];
2977
2978 let args = Args {
2979 tools: Some(vec!["read".into(), "ask_user_question".into()]),
2980 ..Args::default()
2981 };
2982 assert_eq!(
2983 filter_active_tool_names(names.clone(), &args),
2984 vec!["read", "ask_user_question"]
2985 );
2986
2987 let args = Args {
2988 exclude_tools: Some(vec!["ask_user_question".into()]),
2989 ..Args::default()
2990 };
2991 assert_eq!(
2992 filter_active_tool_names(names.clone(), &args),
2993 vec!["read", "write"]
2994 );
2995
2996 let args = Args {
2997 no_tools: true,
2998 ..Args::default()
2999 };
3000 assert!(filter_active_tool_names(names, &args).is_empty());
3001 }
3002
3003 #[test]
3004 fn select_ephemeral_when_no_session() {
3005 let args = Args {
3006 no_session: true,
3007 ..Args::default()
3008 };
3009 let cwd = Path::new("/tmp");
3010 assert!(matches!(
3011 select_session(&args, cwd),
3012 SessionSelection::Ephemeral
3013 ));
3014 }
3015
3016 #[test]
3017 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3018 let defaults = Args::default();
3019 assert!(resolve_project_trust(
3020 &defaults,
3021 Path::new("C:/definitely-not-a-project")
3022 ));
3023
3024 let denied = Args {
3025 trust_override: Some(false),
3026 ..Args::default()
3027 };
3028 assert!(!resolve_project_trust(
3029 &denied,
3030 Path::new("C:/definitely-not-a-project")
3031 ));
3032 }
3033
3034 #[tokio::test(flavor = "current_thread")]
3035 async fn build_preserves_the_supplied_startup_project_snapshot() {
3036 struct RestoreConfigDir(Option<std::ffi::OsString>);
3037
3038 impl Drop for RestoreConfigDir {
3039 fn drop(&mut self) {
3040 match self.0.take() {
3041 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3042 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3043 }
3044 }
3045 }
3046
3047 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3048 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3049 let tmp = tempfile::tempdir().unwrap();
3050 let agent = tmp.path().join("agent");
3051 let cwd = tmp.path().join("project");
3052 std::fs::create_dir_all(&agent).unwrap();
3053 std::fs::create_dir_all(&cwd).unwrap();
3054 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3055
3056 let resolved = crate::provider::resolve(
3057 Some("anthropic"),
3058 Some(crate::provider::DEFAULT_MODEL_ID),
3059 None,
3060 Some("test-key"),
3061 None,
3062 )
3063 .unwrap();
3064 let args = Args {
3065 trust_override: Some(false),
3066 no_session: true,
3067 no_tools: true,
3068 no_extensions: true,
3069 no_skills: true,
3070 no_prompt_templates: true,
3071 no_context_files: true,
3072 system_prompt: Some("test prompt".into()),
3073 ..Args::default()
3074 };
3075
3076 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3077
3078 assert_eq!(context.cwd, cwd);
3079 assert!(context.project_trusted);
3080 }
3081
3082 #[test]
3083 fn select_latest_for_continue_and_resume() {
3084 let args = Args {
3085 continue_session: true,
3086 ..Args::default()
3087 };
3088 let cwd = Path::new("/tmp");
3089 assert!(matches!(
3090 select_session(&args, cwd),
3091 SessionSelection::Latest
3092 ));
3093
3094 let args = Args {
3095 resume: true,
3096 ..Args::default()
3097 };
3098 assert!(matches!(
3099 select_session(&args, cwd),
3100 SessionSelection::Latest
3101 ));
3102 }
3103
3104 #[test]
3105 fn select_by_id_for_session_flag() {
3106 let args = Args {
3107 session: Some("01a02ece".into()),
3108 ..Args::default()
3109 };
3110 let cwd = Path::new("/tmp");
3111 assert!(matches!(
3112 select_session(&args, cwd),
3113 SessionSelection::ById { id } if id == "01a02ece"
3114 ));
3115 }
3116
3117 #[test]
3118 fn select_new_with_custom_dir() {
3119 let args = Args {
3120 session_dir: Some(PathBuf::from("/tmp/sess")),
3121 ..Args::default()
3122 };
3123 let cwd = Path::new("/tmp");
3124 match select_session(&args, cwd) {
3125 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
3126 other => panic!("expected New, got {other:?}"),
3127 }
3128 }
3129
3130 #[test]
3131 fn select_new_default_dir() {
3132 let args = Args::default();
3133 let cwd = Path::new("/proj");
3134 match select_session(&args, cwd) {
3135 SessionSelection::New { dir, .. } => {
3136 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
3137 }
3138 other => panic!("expected New, got {other:?}"),
3139 }
3140 }
3141
3142 #[test]
3143 fn default_session_dir_prefers_rpi_but_reads_legacy_pi() {
3144 let tmp = tempfile::tempdir().unwrap();
3145 let cwd = tmp.path();
3146 std::fs::create_dir_all(cwd.join(".pi/sessions")).unwrap();
3147 assert_eq!(default_session_dir(cwd), cwd.join(".pi/sessions"));
3148 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3149 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3150 }
3151
3152 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3153 async fn ephemeral_session_builds_roundtrips() {
3154 let s = ephemeral_session();
3158 let leaf = s.get_leaf_id().await;
3159 assert!(leaf.is_ok());
3160 }
3161
3162 }