1use std::collections::HashSet;
34use std::path::{Path, PathBuf};
35use std::sync::atomic::Ordering;
36use std::sync::{Arc, Mutex, OnceLock};
37
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::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::provider::ResolvedModel;
58use crate::resource_dirs::{
59 discover_append_system_prompt_file, discover_system_prompt_file, extension_dirs,
60 global_extension_dirs, global_prompt_template_dirs, global_skill_dirs,
61 load_prompt_templates_with_precedence, load_skills_with_precedence, project_extension_dirs,
62 project_prompt_template_dirs, project_skill_dirs, prompt_template_dirs, skill_dirs,
63};
64use rpi_extensions::{
65 emit_resources_discover, ExtensionEmitter, ExtensionSession, NullDiagnostics,
66 PluginDiagnostics, PluginToolAdapter, TeeEmitter,
67};
68
69pub const BUILTIN_TOOL_NAMES: &[&str] = &["read", "bash", "edit", "write", "docs"];
71
72pub fn default_system_prompt(cwd: &str, prior_reasoning_replayed: bool) -> String {
75 let working_state_rule = if prior_reasoning_replayed {
81 "- Keep a SHORT working plan visible (a few bullets, or the todo tool) and update it as you go.
82 Your own reasoning is carried back to you on the next turn, so do not re-derive it or restate
83 the whole plan: continue from the last unfinished step, and when a step lands, say which one.
84 Re-listing the same plan without acting on it is a bug, not progress."
85 } else {
86 "- Keep your working state in your VISIBLE replies, not only in reasoning. Reasoning is not
87 carried into your next turn: only the text you write and the tool output you produce come
88 back. Before each batch of tool calls, write one short line naming the task you are on and
89 what remains. When you finish a step, say which one is done. If you keep the plan only in
90 your head you will re-derive it from scratch every turn."
91 };
92
93 format!(
94 "You are an expert coding assistant operating inside rpi, a coding agent harness. \
95You help users by reading files, executing commands, editing code, and writing new files.
96
97Available tools:
98- read — Read file contents
99- bash — Execute Bash commands using Bash/POSIX syntax, including on Windows
100- edit — Find/replace edits to existing files
101- write — Create or overwrite files
102- docs — Look up rpi usage, extension, package, and compatibility documentation
103
104Guidelines:
105- Reply in the SAME language the user is writing in. Detect the language of the user's
106 latest message (and, when it is too short to be sure, the working language of the
107 conversation so far) and use it for your prose replies. Mirror it unless the user
108 explicitly asks for a different language; only fall back to English when the
109 user's language cannot be determined.
110- Be concise in your responses
111- Show file paths clearly when working with files
112- Prefer the smallest change that solves the problem
113- The bash tool runs Bash, even on Windows. Use ls, find, rg, and POSIX pipelines;
114 do not pass PowerShell cmdlets such as Get-ChildItem, Select-Object, or Select-String.
115 If a powershell tool is available, use it for PowerShell commands.
116{working_state_rule}
117- Track multi-step work with the todo tool instead of re-listing the plan in prose: add the
118 steps once, then mark them done as you go. Re-stating the same plan without acting on it is
119 a bug, not progress.
120- Read only what you need. If a file or search result was already shown earlier in this
121 conversation, use it instead of fetching it again.
122- When unsure about rpi commands, extensions, Pi package compatibility, or .rpi configuration, consult the project documentation before guessing
123
124Current working directory: {cwd}"
125 )
126}
127
128#[derive(Debug, Clone)]
135pub enum SessionSelection {
136 Ephemeral,
138 New { dir: PathBuf, name: Option<String> },
140 Latest { dir: PathBuf },
142 ById { id: String, dir: PathBuf },
145 ByExactId { id: String, dir: PathBuf },
147 Fork { source: String },
150}
151
152pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
154 if args.no_session {
155 return SessionSelection::Ephemeral;
156 }
157 let dir = args
161 .session_dir
162 .clone()
163 .or_else(|| {
164 crate::settings::load_settings()
165 .ok()
166 .and_then(|settings| settings.session_dir)
167 })
168 .unwrap_or_else(|| default_session_dir(cwd));
169 if args.continue_session || args.resume {
170 return SessionSelection::Latest { dir };
171 }
172 if let Some(s) = &args.fork {
173 return SessionSelection::Fork { source: s.clone() };
174 }
175 if let Some(s) = &args.session_id {
176 return SessionSelection::ByExactId { id: s.clone(), dir };
177 }
178 if let Some(s) = &args.session {
179 return SessionSelection::ById { id: s.clone(), dir };
180 }
181 SessionSelection::New {
182 dir,
183 name: args.name.clone(),
184 }
185}
186
187pub fn default_session_dir(cwd: &Path) -> PathBuf {
189 cwd.join(".rpi").join("sessions")
190}
191
192pub async fn build(
210 resolved: &ResolvedModel,
211 args: &Args,
212 cwd: &Path,
213 project_trusted: bool,
214) -> Result<
215 (
216 AgentHarness,
217 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
218 ReloadContext,
219 ),
220 BuildError,
221> {
222 let cwd_str = cwd.to_string_lossy().to_string();
223 let tool_context = rpi_extensions::ToolCallContext::new(cwd_str.clone());
228 if !project_trusted && args.verbose {
229 eprintln!(
230 "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)"
231 );
232 }
233 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
252 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
253 })?;
254 let catalog = crate::provider::available_catalog(resolved);
255 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
256 catalog.clone(),
257 cwd.to_path_buf(),
258 runtime.clone(),
259 args.unknown_flags.clone(),
260 tool_context.clone(),
261 );
262 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = Arc::new(action_host);
263 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
273 let ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
279 let ext_status_mailbox = rpi_extensions::ExtensionStatusMailbox::new();
283 let editor_text_mailbox = rpi_extensions::EditorTextMailbox::new();
288 let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
289 runtime.clone(),
290 host_arc,
291 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
292 ui_dialog_mailbox.clone(),
293 ext_status_mailbox.clone(),
294 editor_text_mailbox.clone(),
295 );
296
297 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
299 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
300 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
301 let _registry = Arc::new(MutationQueueRegistry::new());
302 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
305
306 let mut tools = build_tools(&ctx, args);
307
308 let extension_session = if args.no_extensions {
316 ExtensionSession::none()
317 } else {
318 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
319 };
320 if args.verbose {
321 if let Some(s) = extension_session.summary() {
322 eprintln!("extensions: {s}");
323 }
324 report_deferred_renderers(&extension_session);
325 }
326 merge_extension_tools(&mut tools, &extension_session, args, &tool_context);
327 let active = filter_active_tool_names(active_tool_names(&tools, args), args);
328
329 let selection = select_session(args, cwd);
331 let session = build_session(&selection, &cwd_str).await?;
332 if let Ok(metadata) = session.get_metadata().await {
336 publish_session_identity(&tool_context, &metadata.id);
337 }
338 if let Some(requested) = args.name.as_deref() {
344 let name = requested.trim();
345 let name = if name.is_empty() { None } else { Some(name) };
346 if let Err(error) = session.set_name(name).await {
347 if args.verbose {
348 eprintln!("warning: could not set session name: {error}");
349 }
350 }
351 }
352 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
362 let mut context_paths = Vec::new();
363 let base_prompt = match args.system_prompt.as_deref() {
364 Some(explicit) => explicit.to_string(),
365 None if project_trusted => match discover_system_prompt_file(cwd) {
366 Some(path) => read_prompt_source(&path, &mut context_paths)
367 .unwrap_or_else(|| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
368 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
369 },
370 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
371 };
372
373 let mut append_texts: Vec<String> = Vec::new();
380 for extra in &args.append_system_prompt {
381 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
382 append_texts.push(text);
383 }
384 if args.append_system_prompt.is_empty() {
385 if let Some(path) = project_trusted
386 .then(|| discover_append_system_prompt_file(cwd))
387 .flatten()
388 {
389 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
390 append_texts.push(text);
391 }
392 }
393 }
394 let append_join = if append_texts.is_empty() {
395 None
396 } else {
397 Some(append_texts.join("\n\n"))
398 };
399
400 let agent_dir = crate::config::agent_dir().ok();
412
413 let discovered = extension_session
429 .snapshot_arc()
430 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
431 .unwrap_or_default();
432
433 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
434 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
435 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
436 if !args.no_skills {
437 let mut dirs = if args.dev_local_only || args.project_only {
438 project_skill_dirs(cwd)
439 } else if project_trusted {
440 skill_dirs(cwd)
441 } else {
442 global_skill_dirs()
443 };
444 dirs.extend(args.skill.iter().cloned());
445 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
446 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
447 skills = result.skills;
448 skill_diags = result.diagnostics;
449 resource_diags.extend(result.resource_diagnostics);
450 }
451
452 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
453 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
454 if !args.no_prompt_templates {
455 let mut dirs = if args.dev_local_only || args.project_only {
456 project_prompt_template_dirs(cwd)
457 } else if project_trusted {
458 prompt_template_dirs(cwd)
459 } else {
460 global_prompt_template_dirs()
461 };
462 dirs.extend(args.prompt_template.iter().cloned());
463 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
464 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
465 prompt_templates = result.prompt_templates;
466 prompt_diags = result.diagnostics;
467 resource_diags.extend(result.resource_diagnostics);
468 }
469
470 let context_block = if args.no_context_files || !project_trusted {
471 String::new()
472 } else {
473 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
478 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
479 context_paths.extend(
480 files
481 .iter()
482 .map(|file| file.path.to_string_lossy().into_owned()),
483 );
484 format_project_context(&files)
485 };
486
487 if args.verbose {
489 for d in &skill_diags {
490 eprintln!(
491 "warning: skill {} ({}): {}",
492 d.path,
493 d.code.as_str(),
494 d.message
495 );
496 }
497 for d in &prompt_diags {
498 eprintln!(
499 "warning: prompt template {} ({}): {}",
500 d.path,
501 d.code.as_str(),
502 d.message
503 );
504 }
505 for d in &resource_diags {
507 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
508 eprintln!(
509 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
510 c.resource_type.as_str(),
511 c.name,
512 c.winner_path,
513 c.loser_path
514 );
515 }
516 }
517 }
518
519 let system_prompt = compose_system_prompt(
528 Some(&base_prompt),
529 &[], if context_block.is_empty() {
531 None
532 } else {
533 Some(&context_block)
534 },
535 append_join.as_deref(),
536 );
537
538 if args.debug_system_prompt {
547 eprintln!("=== --debug-system-prompt ===");
548 let base_src = if args.system_prompt.is_some() {
549 "--system-prompt"
550 } else if discover_system_prompt_file(cwd).is_some() {
551 "SYSTEM.md"
552 } else {
553 "default"
554 };
555 eprintln!("[base source: {base_src}]");
556 eprintln!("--- base ---\n{base_prompt}");
557 if let Some(append) = append_join.as_deref() {
558 eprintln!("--- append ---\n{append}");
559 } else {
560 eprintln!("--- append: (none) ---");
561 }
562 if context_block.is_empty() {
563 eprintln!("--- context: (none) ---");
564 } else {
565 eprintln!("--- context ---{context_block}");
566 }
567 let visible_skills = skills
568 .iter()
569 .filter(|s| s.disable_model_invocation != Some(true))
570 .count();
571 eprintln!(
572 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
573 skills.len(),
574 visible_skills,
575 skills.len() - visible_skills
576 );
577 for s in &skills {
578 let hidden = if s.disable_model_invocation == Some(true) {
579 " [hidden]"
580 } else {
581 ""
582 };
583 eprintln!(" {}{hidden} — {}", s.name, s.description);
584 }
585 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
586 for t in &prompt_templates {
587 eprintln!(" /{}", t.name);
588 }
589 eprintln!(
593 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
594 discovered.skill_paths.len(),
595 discovered.prompt_paths.len(),
596 discovered.theme_paths.len(),
597 );
598 for p in &discovered.skill_paths {
599 eprintln!(" skill: {p}");
600 }
601 for p in &discovered.prompt_paths {
602 eprintln!(" prompt: {p}");
603 }
604 eprintln!(
605 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
606 );
607 eprintln!("=== end --debug-system-prompt ===");
608 }
609
610 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
615 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
616 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
620
621 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
630 Some(snapshot) => {
631 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
632 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
633 }
634 None => broadcast_emitter,
635 };
636
637 let saved_settings = crate::settings::load_settings().unwrap_or_default();
641
642 if let Some(proxy) = &saved_settings.http_proxy {
647 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
648 if std::env::var_os(key).is_none() {
649 std::env::set_var(key, proxy);
650 }
651 }
652 }
653
654 let extension_session_cell = Arc::new(Mutex::new(extension_session.clone()));
658 let before_tool_call = Some(extension_tool_call_hook(
659 Arc::clone(&extension_session_cell),
660 ui_dialog_mailbox.clone(),
661 ));
662
663 let options = AgentHarnessOptions {
664 model: resolved.model.clone(),
665 thinking_level: resolved.thinking_level,
666 active_tool_names: active,
667 tools,
668 system_prompt: Some(system_prompt),
669 resources: AgentHarnessResources {
670 context_files: context_paths,
671 skills: if skills.is_empty() {
672 None
673 } else {
674 Some(skills)
675 },
676 prompt_templates: if prompt_templates.is_empty() {
677 None
678 } else {
679 Some(prompt_templates)
680 },
681 },
682 allow_existing_session: matches!(
685 selection,
686 SessionSelection::Latest { .. }
687 | SessionSelection::ById { .. }
688 | SessionSelection::ByExactId { .. }
689 | SessionSelection::Fork { .. }
690 ),
691 stream_options: AgentHarnessStreamOptions {
692 timeout: args.timeout,
693 ..Default::default()
694 },
695 retry: saved_settings.retry_policy(),
700 compaction: saved_settings.compaction_settings(),
701 steering_mode: saved_settings.steering_mode(),
702 follow_up_mode: saved_settings.follow_up_mode(),
703 tool_execution: HarnessToolExecution::default(),
704 drive: DrivingMode::default(),
705 session,
706 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
714 to_provider_messages: None,
715 entry_projectors: Default::default(),
716 agent_emitter: Some(emitter),
717 before_tool_call,
722 after_tool_call: None,
723 transform_context: None,
724 entry_transforms: Vec::new(),
725 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
731 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
732 };
733
734 let harness = match AgentHarness::create(options).await {
735 Ok(h) => {
736 crate::extensions_actions::HarnessActionHost::set_harness(
739 &harness_cell,
740 Arc::new(h.clone()),
741 );
742 h
743 }
744 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
745 };
746
747 {
755 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
756 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
757 Ok(reports) => {
758 for (lane, report) in reports {
759 if report.is_clean() {
760 continue;
761 }
762 if report.is_corrupt() {
763 eprintln!(
764 "warning: session lane '{lane}' is corrupt ({} open operations); \
765 refusing to auto-repair — inspect the session file",
766 report.open_operations.len()
767 );
768 continue;
769 }
770 for decision in runtime.reconcile(&report) {
771 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
772 if args.verbose {
773 eprintln!(
774 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
775 );
776 }
777 }
778 }
779 runtime.emit_recovery_events(harness.events(), &report);
780 }
781 }
782 Err(e) => {
783 if args.verbose {
784 eprintln!("warning: session recovery sweep failed: {e}");
785 }
786 }
787 }
788 }
789
790 let reload_context = ReloadContext {
797 extension_session: extension_session_cell,
798 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
799 tool_context,
800 catalog,
801 providers: resolved.providers.clone(),
802 runtime: runtime.clone(),
803 cwd: cwd.to_path_buf(),
804 project_trusted,
805 args: args.clone(),
806 resolved_model: resolved.model.clone(),
807 broadcast: broadcast_for_context,
808 mailbox: reload_mailbox,
809 ui_dialog: ui_dialog_mailbox,
810 ext_status: ext_status_mailbox,
811 editor_text: editor_text_mailbox,
812 dev_extension: None,
813 };
814
815 Ok((harness, event_rx, reload_context))
816}
817
818fn extension_tool_call_hook(
821 sessions: ExtensionSessionCell,
822 ui_dialog: rpi_extensions::UiDialogMailbox,
823) -> rpi_agent::BeforeToolCall {
824 Arc::new(move |ctx: rpi_agent::BeforeToolCallContext<'_>, signal| {
825 let session = sessions
826 .lock()
827 .map(|session| session.clone())
828 .map_err(|_| ());
829 let ui_dialog = ui_dialog.clone();
830 let tool_call_id = ctx.tool_call.id.clone();
831 let tool_name = ctx.tool_call.name.clone();
832 let params = ctx.args.clone();
833 Box::pin(async move {
834 let reason = match session {
835 Ok(session) => {
836 rpi_extensions::dispatch_tool_call_event(
837 &session,
838 &tool_call_id,
839 &tool_name,
840 ¶ms,
841 &signal,
842 &ui_dialog,
843 )
844 .await
845 }
846 Err(_) => Some("extension approval session is unavailable".to_string()),
847 };
848 reason.map(|reason| rpi_agent::BeforeToolCallResult {
849 block: true,
850 reason: Some(reason),
851 terminate: false,
852 args: None,
853 })
854 })
855 })
856}
857
858pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
903
904pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
909
910#[derive(Clone)]
918pub struct ReloadContext {
919 pub extension_session: ExtensionSessionCell,
921 pub tool_context: rpi_extensions::ToolCallContext,
926 pub action_bridge: ActionBridgeCell,
928 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
931 pub editor_text: rpi_extensions::EditorTextMailbox,
938 pub catalog: Vec<rpi_ai::Model>,
943 pub providers: Vec<Arc<dyn Provider>>,
946 pub runtime: tokio::runtime::Handle,
950 pub cwd: PathBuf,
952 pub project_trusted: bool,
956 pub args: Args,
962 pub resolved_model: rpi_ai::Model,
965 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
971 pub mailbox: rpi_extensions::ReloadMailbox,
978 pub ui_dialog: rpi_extensions::UiDialogMailbox,
984 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
988}
989
990pub async fn dispatch_session_event_async(
1005 reload_context: &ReloadContext,
1006 tag: rpi_plugin_sdk::EventTag,
1007) -> Option<String> {
1008 let (snapshot, keepalive) = {
1014 let session = reload_context.extension_session.lock().unwrap();
1015 (session.snapshot_arc(), session.keepalive())
1016 };
1017 match snapshot {
1018 Some(snapshot) => {
1019 let _keepalive = keepalive;
1020 rpi_extensions::dispatch_lifecycle_event(&snapshot, tag).await
1021 }
1022 None => None,
1023 }
1024}
1025
1026async fn shutdown_extensions_for_reload(
1029 snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
1030 keepalive: Arc<rpi_extensions::PluginKeepalive>,
1031) -> Result<(), String> {
1032 if let Some(snapshot) = snapshot {
1033 tokio::task::spawn_blocking(move || {
1034 let _keepalive = keepalive;
1035 rpi_extensions::dispatch_empty_event(
1036 &snapshot,
1037 rpi_plugin_sdk::EventTag::SessionShutdown,
1038 );
1039 })
1040 .await
1041 .map_err(|error| error.to_string())?;
1042 }
1043 Ok(())
1044}
1045
1046pub struct ReloadOutcome {
1049 pub summary: String,
1052 pub had_warnings: bool,
1054 pub details: Vec<String>,
1059 pub had_errors: bool,
1063}
1064
1065struct PreparedReloadInputs {
1066 extension_dirs: Vec<PathBuf>,
1067 skill_base_dirs: Vec<PathBuf>,
1068 prompt_base_dirs: Vec<PathBuf>,
1069}
1070
1071fn append_reload_resource_paths(
1076 mut paths: Vec<PathBuf>,
1077 explicit: &[PathBuf],
1078 discovered: &[String],
1079 js_paths: &[PathBuf],
1080 package_paths: &[PathBuf],
1081) -> Vec<PathBuf> {
1082 paths.extend(explicit.iter().cloned());
1083 paths.extend(discovered.iter().map(PathBuf::from));
1084 paths.extend(js_paths.iter().cloned());
1085 paths.extend(package_paths.iter().cloned());
1086 paths
1087}
1088
1089pub async fn reload_extension_resources(
1101 harness: &AgentHarness,
1102 ctx: &ReloadContext,
1103) -> ReloadOutcome {
1104 reload_extension_resources_inner(harness, ctx, || {}).await
1105}
1106
1107async fn reload_extension_resources_inner<F>(
1108 harness: &AgentHarness,
1109 ctx: &ReloadContext,
1110 after_prepare: F,
1111) -> ReloadOutcome
1112where
1113 F: FnOnce() + Send,
1114{
1115 let mut effective_args = ctx.args.clone();
1120 if let Some(dev) = &ctx.dev_extension {
1121 if let Err(error) = dev
1122 .rebuild()
1123 .and_then(|_| dev.apply_to_args(&mut effective_args))
1124 {
1125 return ReloadOutcome {
1126 summary: format!(
1127 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1128 dev.package_name()
1129 ),
1130 had_warnings: true,
1131 details: Vec::new(),
1132 had_errors: true,
1133 };
1134 }
1135 }
1136 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1137 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1138 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1139 Ok(prepared) => prepared,
1140 Err(error) => {
1141 return ReloadOutcome {
1142 summary: format!(
1143 "Settings reload failed: {error}. Keeping the currently loaded resources."
1144 ),
1145 had_warnings: true,
1146 details: Vec::new(),
1147 had_errors: true,
1148 };
1149 }
1150 };
1151 after_prepare();
1152 let PreparedReloadInputs {
1153 extension_dirs,
1154 skill_base_dirs,
1155 prompt_base_dirs,
1156 } = prepared;
1157 let mut warnings = false;
1158
1159 let (shutdown_snapshot, shutdown_keepalive) = {
1163 let session = ctx.extension_session.lock().unwrap();
1164 (session.snapshot_arc(), session.keepalive())
1165 };
1166 if let Err(reason) = shutdown_extensions_for_reload(shutdown_snapshot, shutdown_keepalive).await
1167 {
1168 return ReloadOutcome {
1169 summary: "Reload failed during extension shutdown".into(),
1170 had_warnings: true,
1171 details: vec![reason],
1172 had_errors: true,
1173 };
1174 }
1175
1176 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1183 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1184 Some(b) => b.clone_host(),
1185 None => {
1186 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1189 ctx.catalog.clone(),
1190 ctx.cwd.clone(),
1191 ctx.runtime.clone(),
1192 ctx.args.unknown_flags.clone(),
1193 ctx.tool_context.clone(),
1194 );
1195 crate::extensions_actions::HarnessActionHost::set_harness(
1196 &_cell,
1197 Arc::new(harness.clone()),
1198 );
1199 Arc::new(action_host)
1200 }
1201 };
1202
1203 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1204 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1205 ctx.runtime.clone(),
1206 host,
1207 reload_cb,
1208 ctx.ui_dialog.clone(),
1209 ctx.ext_status.clone(),
1210 ctx.editor_text.clone(),
1211 );
1212
1213 let extension_session = if effective_args.no_extensions {
1214 rpi_extensions::ExtensionSession::none()
1215 } else {
1216 load_extensions_from_dirs(
1217 &effective_args,
1218 &extension_dirs,
1219 Some(Arc::clone(&fresh_bridge)),
1220 )
1221 };
1222 if extension_session.is_empty() && !effective_args.no_extensions {
1223 }
1226 if effective_args.verbose {
1227 if let Some(s) = extension_session.summary() {
1228 eprintln!("reload: {s}");
1229 }
1230 report_deferred_renderers(&extension_session);
1231 }
1232
1233 {
1245 let mut session_guard = ctx.extension_session.lock().unwrap();
1246 let old_session = std::mem::replace(&mut *session_guard, extension_session.clone());
1247 if let Some(old_snap) = old_session.snapshot_arc() {
1248 old_snap.active_flag().store(false, Ordering::SeqCst);
1252 }
1253 }
1256 if let Some(old_b) = old_bridge {
1257 old_b.invalidate();
1258 }
1259
1260 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1262
1263 let discovered = extension_session
1265 .snapshot_arc()
1266 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1267 .unwrap_or_default();
1268
1269 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1271 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1272
1273 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1274 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1275 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1276 if !effective_args.no_skills {
1277 let js_paths: &[PathBuf] = &[];
1281 let package_paths: &[PathBuf] = &[];
1282 let dirs = append_reload_resource_paths(
1283 skill_base_dirs,
1284 &effective_args.skill,
1285 &discovered.skill_paths,
1286 js_paths,
1287 &package_paths,
1288 );
1289 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1290 skills = result.skills;
1291 skill_diags = result.diagnostics;
1292 resource_diags.extend(result.resource_diagnostics);
1293 }
1294
1295 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1296 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1297 if !effective_args.no_prompt_templates {
1298 let js_paths: &[PathBuf] = &[];
1299 let package_paths: &[PathBuf] = &[];
1300 let dirs = append_reload_resource_paths(
1301 prompt_base_dirs,
1302 &effective_args.prompt_template,
1303 &discovered.prompt_paths,
1304 js_paths,
1305 &package_paths,
1306 );
1307 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1308 prompt_templates = result.prompt_templates;
1309 prompt_diags = result.diagnostics;
1310 resource_diags.extend(result.resource_diagnostics);
1311 }
1312
1313 let context_files = if effective_args.no_context_files || !project_trusted {
1314 Vec::new()
1315 } else {
1316 let agent_dir = crate::config::agent_dir().ok();
1317 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1318 load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await
1319 };
1320 let context_block = format_project_context(&context_files);
1321
1322 let mut details: Vec<String> = Vec::new();
1323 if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
1324 warnings = true;
1325 for d in &skill_diags {
1330 details.push(format!(
1331 "warning: skill {} ({}): {}",
1332 d.path,
1333 d.code.as_str(),
1334 d.message
1335 ));
1336 }
1337 for d in &prompt_diags {
1338 details.push(format!(
1339 "warning: prompt template {} ({}): {}",
1340 d.path,
1341 d.code.as_str(),
1342 d.message
1343 ));
1344 }
1345 for d in &resource_diags {
1347 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1348 details.push(format!(
1349 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1350 c.resource_type.as_str(),
1351 c.name,
1352 c.winner_path,
1353 c.loser_path
1354 ));
1355 }
1356 }
1357 }
1358
1359 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1364 let mut context_paths = Vec::new();
1365 let base_prompt = match effective_args.system_prompt.as_deref() {
1366 Some(explicit) => explicit.to_string(),
1367 None if project_trusted => match discover_system_prompt_file(&ctx.cwd) {
1368 Some(path) => read_prompt_source(&path, &mut context_paths)
1369 .unwrap_or_else(|| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
1370 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1371 },
1372 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1373 };
1374 let mut append_texts: Vec<String> = Vec::new();
1375 for extra in &effective_args.append_system_prompt {
1376 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
1377 append_texts.push(text);
1378 }
1379 if effective_args.append_system_prompt.is_empty() {
1380 if let Some(path) = project_trusted
1381 .then(|| discover_append_system_prompt_file(&ctx.cwd))
1382 .flatten()
1383 {
1384 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
1385 append_texts.push(text);
1386 }
1387 }
1388 }
1389 context_paths.extend(
1390 context_files
1391 .iter()
1392 .map(|file| file.path.to_string_lossy().into_owned()),
1393 );
1394 let append_join = if append_texts.is_empty() {
1395 None
1396 } else {
1397 Some(append_texts.join("\n\n"))
1398 };
1399 let system_prompt = compose_system_prompt(
1400 Some(&base_prompt),
1401 &[],
1402 if context_block.is_empty() {
1403 None
1404 } else {
1405 Some(&context_block)
1406 },
1407 append_join.as_deref(),
1408 );
1409
1410 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1412 Some(snapshot) => {
1413 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1414 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1415 }
1416 None => ctx.broadcast.clone(),
1417 };
1418
1419 let resources = AgentHarnessResources {
1421 context_files: context_paths,
1422 skills: if skills.is_empty() {
1423 None
1424 } else {
1425 Some(skills.clone())
1426 },
1427 prompt_templates: if prompt_templates.is_empty() {
1428 None
1429 } else {
1430 Some(prompt_templates.clone())
1431 },
1432 };
1433 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1434 let _ = harness.set_resources(resources).await;
1435 let _ = harness.set_agent_emitter(Some(emitter)).await;
1436 let _ = harness
1437 .set_models(build_models_with_extensions_for_reload(
1438 &ctx.providers,
1439 &extension_session,
1440 ctx.runtime.clone(),
1441 ))
1442 .await;
1443 let _ = harness
1444 .set_provider_hooks(
1445 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1446 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1447 )
1448 .await;
1449
1450 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1454 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1455 let mut tools = build_tools(&tool_ctx, &effective_args);
1456 merge_extension_tools(
1457 &mut tools,
1458 &extension_session,
1459 &effective_args,
1460 &ctx.tool_context,
1461 );
1462 let mut active = active_tool_names(&tools, &effective_args);
1463 active = filter_active_tool_names(active, &effective_args);
1464 let _ = harness.set_tools(tools, Some(active)).await;
1465
1466 let summary = format!(
1467 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1468 extension_session.loaded_paths().len(),
1469 skills.len(),
1470 prompt_templates.len(),
1471 );
1472 ReloadOutcome {
1473 summary,
1474 had_warnings: warnings,
1475 details,
1476 had_errors: false,
1479 }
1480}
1481
1482#[derive(Clone, Debug, PartialEq, Eq)]
1483struct ReloadSettingsFields {
1484 skill_dirs: Option<Vec<String>>,
1485 prompt_dirs: Option<Vec<String>>,
1486 extension_dirs: Option<Vec<String>>,
1487}
1488
1489impl From<crate::settings::Settings> for ReloadSettingsFields {
1490 fn from(settings: crate::settings::Settings) -> Self {
1491 Self {
1492 skill_dirs: settings.skill_dirs,
1493 prompt_dirs: settings.prompt_dirs,
1494 extension_dirs: settings.extension_dirs,
1495 }
1496 }
1497}
1498
1499#[derive(Clone, Debug, PartialEq, Eq)]
1500struct ReloadSettingsSnapshot {
1501 global: ReloadSettingsFields,
1502 project: Option<ReloadSettingsFields>,
1503}
1504
1505fn reload_reads_settings(args: &Args) -> bool {
1506 !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
1507}
1508
1509fn load_reload_settings_snapshot(
1513 args: &Args,
1514 cwd: &Path,
1515 project_trusted: bool,
1516) -> Result<Option<ReloadSettingsSnapshot>, String> {
1517 if !reload_reads_settings(args) {
1518 return Ok(None);
1519 }
1520 let global = crate::settings::load_settings()
1521 .map_err(|error| format!("could not load global settings: {error}"))?
1522 .into();
1523 let project = if project_trusted {
1524 crate::settings::load_active_project_settings(cwd)
1525 .map_err(|error| format!("could not load project settings: {error}"))?
1526 .map(|(_, settings)| settings.into())
1527 } else {
1528 None
1529 };
1530 Ok(Some(ReloadSettingsSnapshot { global, project }))
1531}
1532
1533#[cfg(test)]
1536fn validate_settings_for_reload(
1537 args: &Args,
1538 cwd: &Path,
1539 project_trusted: bool,
1540) -> Result<(), String> {
1541 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1542}
1543
1544fn prepare_reload_inputs(
1545 args: &Args,
1546 cwd: &Path,
1547 project_trusted: bool,
1548) -> Result<PreparedReloadInputs, String> {
1549 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1550}
1551
1552fn prepare_reload_inputs_inner<F>(
1553 args: &Args,
1554 cwd: &Path,
1555 project_trusted: bool,
1556 before_verify: F,
1557) -> Result<PreparedReloadInputs, String>
1558where
1559 F: FnOnce(),
1560{
1561 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1562
1563 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1564 Vec::new()
1565 } else if args.project_only {
1566 project_extension_dirs(cwd)
1567 } else if project_trusted {
1568 extension_dirs(cwd)
1569 } else {
1570 global_extension_dirs()
1571 };
1572 if !args.no_extensions {
1573 extension_dirs.extend(args.extensions_dir.iter().cloned());
1574 }
1575
1576 let skill_base_dirs = if args.no_skills {
1577 Vec::new()
1578 } else if args.dev_local_only || args.project_only {
1579 project_skill_dirs(cwd)
1580 } else if project_trusted {
1581 skill_dirs(cwd)
1582 } else {
1583 global_skill_dirs()
1584 };
1585
1586 let prompt_base_dirs = if args.no_prompt_templates {
1587 Vec::new()
1588 } else if args.dev_local_only || args.project_only {
1589 project_prompt_template_dirs(cwd)
1590 } else if project_trusted {
1591 prompt_template_dirs(cwd)
1592 } else {
1593 global_prompt_template_dirs()
1594 };
1595
1596 before_verify();
1597 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1598 if settings_before != settings_after {
1599 return Err(
1600 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1601 );
1602 }
1603
1604 Ok(PreparedReloadInputs {
1605 extension_dirs,
1606 skill_base_dirs,
1607 prompt_base_dirs,
1608 })
1609}
1610
1611fn build_models_with_extensions_for_reload(
1616 providers: &[Arc<dyn Provider>],
1617 extension_session: &ExtensionSession,
1618 runtime: tokio::runtime::Handle,
1619) -> Vec<Arc<dyn Provider>> {
1620 let mut models: Vec<Arc<dyn Provider>> = providers.to_vec();
1621 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1622 models.extend(pluggable);
1623 models
1624}
1625
1626fn report_deferred_renderers(session: &ExtensionSession) {
1630 let Some(snap) = session.snapshot_arc() else {
1631 return;
1632 };
1633 let all = snap.renderers();
1634 let markdown = all
1635 .iter()
1636 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1637 .count();
1638 let message = all
1639 .iter()
1640 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1641 .count();
1642 let entry = all
1643 .iter()
1644 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1645 .count();
1646 if markdown + message + entry == 0 {
1647 return;
1648 }
1649 eprintln!(
1650 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1651 markdown, message, entry
1652 );
1653}
1654
1655#[derive(Debug, thiserror::Error)]
1657pub enum BuildError {
1658 #[error("Could not create the session directory: {0}")]
1659 SessionDir(String),
1660 #[error("Could not open the saved session: {0}")]
1661 SessionOpen(String),
1662 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1663 SessionNotFound { requested: String, dir: String },
1664 #[error("Could not build the harness: {0}")]
1665 HarnessCreate(String),
1666}
1667
1668fn build_models_with_extensions(
1678 resolved: &ResolvedModel,
1679 extension_session: &ExtensionSession,
1680 runtime: tokio::runtime::Handle,
1681) -> Vec<Arc<dyn Provider>> {
1682 let mut models: Vec<Arc<dyn Provider>> = resolved.providers.clone();
1683 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1684 models.extend(pluggable);
1685 models
1686}
1687
1688pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1691 if let Some(override_value) = args.trust_override {
1692 return override_value;
1693 }
1694 crate::config::project_trust_decision(cwd)
1695 .ok()
1696 .flatten()
1697 .unwrap_or(true)
1698}
1699
1700fn load_extensions(
1708 args: &Args,
1709 cwd: &Path,
1710 project_trusted: bool,
1711 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1712) -> ExtensionSession {
1713 let mut dirs = if args.dev_local_only {
1714 Vec::new()
1715 } else if args.project_only {
1716 project_extension_dirs(cwd)
1717 } else if project_trusted {
1718 extension_dirs(cwd)
1719 } else {
1720 global_extension_dirs()
1721 };
1722 dirs.extend(args.extensions_dir.iter().cloned());
1723 load_extensions_from_dirs(args, &dirs, action_bridge)
1724}
1725
1726fn load_extensions_from_dirs(
1727 args: &Args,
1728 dirs: &[PathBuf],
1729 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1730) -> ExtensionSession {
1731 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1732 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1739}
1740
1741fn merge_extension_tools(
1747 tools: &mut Vec<HarnessTool>,
1748 session: &ExtensionSession,
1749 args: &Args,
1750 tool_context: &rpi_extensions::ToolCallContext,
1751) {
1752 let Some(snapshot) = session.snapshot() else {
1753 return;
1754 };
1755 for et in snapshot.tools() {
1756 let name = &et.tool.name;
1757 if !tool_name_allowed(name, args) {
1758 continue;
1759 }
1760 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
1761 .with_context(tool_context.clone());
1762 let harness_tool = HarnessTool::new(Arc::new(adapter));
1763 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1764 Some(slot) => *slot = harness_tool,
1765 None => tools.push(harness_tool),
1766 }
1767 }
1768}
1769
1770pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1777 use rpi_tools::tools::bash::BashToolOptions;
1778 let default = std::env::var("RPI_BASH_TIMEOUT")
1779 .ok()
1780 .and_then(|v| v.parse::<f64>().ok())
1781 .unwrap_or(120.0);
1782 let settings = crate::settings::load_settings().unwrap_or_default();
1788 BashToolOptions {
1789 command_prefix: settings.shell_command_prefix.clone(),
1790 default_timeout: Some(default),
1791 prepare: None,
1792 }
1793}
1794
1795fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1796 if args.no_tools {
1797 return Vec::new();
1798 }
1799 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1808 (
1809 "read",
1810 HarnessTool::new(create_read_tool(
1811 ctx,
1812 Some(rpi_tools::tools::read::ReadToolOptions {
1813 image_processor: Some(Arc::new(
1814 rpi_tools::image_processing::InlineImageProcessor,
1815 )),
1816 ..Default::default()
1817 }),
1818 ))
1819 .with_replay(ToolReplay::Safe),
1820 ),
1821 (
1822 "bash",
1823 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1824 ),
1825 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1826 ("write", HarnessTool::new(create_write_tool(ctx))),
1827 (
1828 "docs",
1829 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
1830 ),
1831 ];
1832
1833 #[cfg(target_os = "windows")]
1835 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
1836
1837 if args.tools.is_none() {
1839 if let Ok(settings) = crate::settings::load_settings() {
1840 if let Some(default_tools) = &settings.default_tools {
1841 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
1842 }
1843 }
1844 }
1845
1846 if args.no_builtin_tools {
1850 all.clear();
1851 }
1852
1853 if let Some(allow) = &args.tools {
1855 all.retain(|(name, _)| allow.iter().any(|a| a == name));
1856 }
1857 if let Some(deny) = &args.exclude_tools {
1859 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1860 }
1861
1862 all.into_iter().map(|(_, t)| t).collect()
1863}
1864
1865fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1869 filter_active_tool_names(
1870 tools.iter().map(|tool| tool.tool.schema().name.clone()),
1871 args,
1872 )
1873}
1874
1875pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
1878 if args.no_tools {
1879 return false;
1880 }
1881 if args
1882 .tools
1883 .as_ref()
1884 .is_some_and(|allow| !allow.iter().any(|value| value == name))
1885 {
1886 return false;
1887 }
1888 if args
1889 .exclude_tools
1890 .as_ref()
1891 .is_some_and(|deny| deny.iter().any(|value| value == name))
1892 {
1893 return false;
1894 }
1895 true
1896}
1897
1898pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
1899where
1900 I: IntoIterator<Item = String>,
1901{
1902 names
1903 .into_iter()
1904 .filter(|name| tool_name_allowed(name, args))
1905 .collect()
1906}
1907
1908async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1910 match selection {
1911 SessionSelection::Ephemeral => Ok(ephemeral_session()),
1912 SessionSelection::New { dir, .. } => {
1913 std::fs::create_dir_all(dir)
1916 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1917 let session = create_jsonl_session(dir, cwd)
1918 .await
1919 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1920 Ok(session)
1921 }
1922 SessionSelection::Latest { .. }
1923 | SessionSelection::ById { .. }
1924 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
1925 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
1926 }
1927}
1928
1929async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1935 match selection {
1939 SessionSelection::Latest { dir } => {
1940 let metas = list_session_metadata_in_dir(cwd, dir).await?;
1941 let Some(meta) = first_resumable_session(&metas) else {
1942 return Err(BuildError::SessionNotFound {
1943 requested: "the most recent session".to_string(),
1944 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1945 });
1946 };
1947 open_session(meta, cwd).await
1948 }
1949 SessionSelection::ById { id, dir } => open_session_by_id_in_dir(id, cwd, dir)
1950 .await
1951 .map_err(|e| match e {
1952 OpenError::NotFound { requested } => BuildError::SessionNotFound {
1953 requested,
1954 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1955 },
1956 OpenError::Other(msg) => BuildError::SessionOpen(msg),
1957 }),
1958 SessionSelection::ByExactId { id, dir } => {
1959 let metas = list_session_metadata_in_dir(cwd, dir).await?;
1962 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
1963 return open_session(meta, cwd).await;
1964 }
1965 std::fs::create_dir_all(dir)
1966 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1967 create_jsonl_session_with_id(dir, cwd, Some(id.clone()))
1968 .await
1969 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
1970 }
1971 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
1972 }
1973}
1974
1975async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
1978 use rpi_harness::session::jsonl::{
1979 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1980 };
1981 use rpi_harness::session::types::{ForkOptions, SessionStorage};
1982 use rpi_tools::FileSystem;
1983
1984 let dir = default_session_dir(Path::new(cwd));
1985 std::fs::create_dir_all(&dir)
1986 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1987 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1988 let fs: Arc<dyn FileSystem> = env.clone();
1989 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1990 fs: fs.clone(),
1991 sessions_root: dir.to_string_lossy().into_owned(),
1992 clock: Arc::new(SystemClock),
1993 ids: Arc::new(DefaultIdGenerator::new()),
1994 });
1995 let metas = repo
1996 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
1997 .await
1998 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
1999 let source_meta = metas
2000 .iter()
2001 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2002 .ok_or_else(|| BuildError::SessionNotFound {
2003 requested: format!("--fork {source}"),
2004 dir: dir.display().to_string(),
2005 })?;
2006 let fork_storage = repo
2007 .fork_typed(
2008 source_meta,
2009 &JsonlSessionCreateOptions {
2010 id: None,
2011 parent_session_id: Some(source_meta.id.clone()),
2012 cwd: cwd.to_string(),
2013 metadata: None,
2014 },
2015 &ForkOptions::default(),
2016 )
2017 .await
2018 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2019 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
2020 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2021 Ok(Session::new(storage_arc, None))
2022}
2023
2024pub enum OpenError {
2027 NotFound { requested: String },
2029 Other(String),
2031}
2032
2033impl std::fmt::Display for OpenError {
2034 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2035 match self {
2036 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2037 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
2042 }
2043 }
2044}
2045
2046pub async fn list_session_metadata(
2049 cwd: &str,
2050) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2051 let dir = default_session_dir(Path::new(cwd));
2052 list_session_metadata_in_dir(cwd, &dir).await
2053}
2054
2055async fn list_session_metadata_in_dir(
2056 cwd: &str,
2057 dir: &Path,
2058) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2059 use rpi_harness::session::jsonl::{
2060 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2061 };
2062 use rpi_tools::FileSystem;
2063
2064 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2065 let fs: Arc<dyn FileSystem> = env.clone();
2066 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2067 fs: fs.clone(),
2068 sessions_root: dir.to_string_lossy().into_owned(),
2069 clock: Arc::new(SystemClock),
2070 ids: Arc::new(DefaultIdGenerator::new()),
2071 });
2072 repo.list_typed(&JsonlSessionListOptions::default())
2073 .await
2074 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2075}
2076
2077#[derive(Debug, Clone, Default, PartialEq, Eq)]
2093pub struct SessionSummary {
2094 pub name: Option<String>,
2096 pub preview: Option<String>,
2098 pub empty: bool,
2100 pub bytes: u64,
2102}
2103
2104const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
2106const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
2108const SUMMARY_PREVIEW_CHARS: usize = 80;
2110
2111pub fn summarize_session_file(path: &Path) -> SessionSummary {
2115 use std::io::{Read, Seek, SeekFrom};
2116
2117 let Ok(mut file) = std::fs::File::open(path) else {
2118 return SessionSummary::default();
2119 };
2120 let Ok(size) = file.metadata().map(|m| m.len()) else {
2121 return SessionSummary::default();
2122 };
2123 if size == 0 {
2124 return SessionSummary {
2125 name: None,
2126 preview: None,
2127 empty: true,
2128 bytes: 0,
2129 };
2130 }
2131
2132 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2135 let head_len = file.read(&mut head).unwrap_or(0);
2136 head.truncate(head_len);
2137 let head = String::from_utf8_lossy(&head);
2138 let mut lines: Vec<&str> = head.lines().collect();
2139 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2140 let empty = (size as usize) <= header_len + 1;
2142 if (head_len as u64) < size && !lines.is_empty() {
2145 lines.pop();
2146 }
2147
2148 let mut summary = SessionSummary {
2149 name: None,
2150 preview: None,
2151 empty,
2152 bytes: size,
2153 };
2154 for line in lines {
2155 apply_summary_line(line, &mut summary);
2156 }
2157
2158 if size > SUMMARY_HEAD_BYTES
2162 && file
2163 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2164 .is_ok()
2165 {
2166 let mut tail = Vec::new();
2167 if file.read_to_end(&mut tail).is_ok() {
2168 let tail = String::from_utf8_lossy(&tail);
2169 let mut tail_lines = tail.lines();
2170 tail_lines.next();
2172 for line in tail_lines {
2173 if let Some(name) = name_from_line(line) {
2174 summary.name = name;
2175 }
2176 }
2177 }
2178 }
2179 summary
2180}
2181
2182fn name_from_line(line: &str) -> Option<Option<String>> {
2185 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2186 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2187 return None;
2188 }
2189 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2190 return None;
2191 }
2192 Some(match value.get("name") {
2193 Some(serde_json::Value::String(name)) => Some(name.clone()),
2194 _ => None,
2195 })
2196}
2197
2198fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2201 if let Some(name) = name_from_line(line) {
2202 summary.name = name;
2203 return;
2204 }
2205 if summary.preview.is_some() {
2206 return;
2207 }
2208 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2209 return;
2210 };
2211 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2212 return;
2213 }
2214 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2215 return;
2216 }
2217 let Some(message) = value.get("message") else {
2218 return;
2219 };
2220 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2221 return;
2222 }
2223 let text = match message.get("content") {
2224 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2225 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2226 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2227 .then(|| block.get("text").and_then(|v| v.as_str()))
2228 .flatten()
2229 }),
2230 _ => None,
2231 };
2232 let Some(text) = text else { return };
2233 let collapsed = text
2234 .lines()
2235 .map(str::trim)
2236 .filter(|line| !line.is_empty())
2237 .collect::<Vec<_>>()
2238 .join(" ");
2239 if collapsed.is_empty() {
2240 return;
2241 }
2242 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2243}
2244
2245pub fn session_display_label(summary: &SessionSummary) -> String {
2249 if let Some(name) = summary.name.as_deref().map(str::trim) {
2250 if !name.is_empty() {
2251 return name.to_string();
2252 }
2253 }
2254 match summary.preview.as_deref().map(str::trim) {
2255 Some(preview) if !preview.is_empty() => preview.to_string(),
2256 _ => "(no messages)".to_string(),
2257 }
2258}
2259
2260pub fn format_session_bytes(bytes: u64) -> String {
2263 if bytes >= 1_048_576 {
2264 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2265 } else if bytes >= 1024 {
2266 format!("{}KB", bytes / 1024)
2267 } else {
2268 format!("{bytes}B")
2269 }
2270}
2271
2272pub fn short_session_id(id: &str) -> &str {
2275 &id[..id.len().min(8)]
2276}
2277
2278pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2287 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2288 let mut visit = |path: PathBuf| {
2289 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2290 return;
2291 }
2292 let modified = std::fs::metadata(&path)
2293 .and_then(|m| m.modified())
2294 .unwrap_or(std::time::UNIX_EPOCH);
2295 files.push((path, modified));
2296 };
2297 if let Ok(entries) = std::fs::read_dir(dir) {
2298 for entry in entries.flatten() {
2299 let path = entry.path();
2300 if path.is_dir() {
2301 if let Ok(inner) = std::fs::read_dir(&path) {
2302 for child in inner.flatten() {
2303 visit(child.path());
2304 }
2305 }
2306 } else {
2307 visit(path);
2308 }
2309 }
2310 }
2311 files.sort_by(|a, b| b.1.cmp(&a.1));
2312 files.into_iter().map(|(path, _)| path).collect()
2313}
2314
2315fn first_resumable_session<'a>(
2320 metas: &'a [rpi_harness::session::jsonl::JsonlSessionMetadata],
2321) -> Option<&'a rpi_harness::session::jsonl::JsonlSessionMetadata> {
2322 metas
2323 .iter()
2324 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2325 .or_else(|| metas.first())
2326}
2327
2328pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2332 let dir = default_session_dir(Path::new(cwd));
2333 open_session_by_id_in_dir(id, cwd, &dir).await
2334}
2335
2336async fn open_session_by_id_in_dir(id: &str, cwd: &str, dir: &Path) -> Result<Session, OpenError> {
2337 let metas = list_session_metadata_in_dir(cwd, dir)
2338 .await
2339 .map_err(|e| OpenError::Other(e.to_string()))?;
2340 let Some(meta) = metas
2341 .iter()
2342 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2343 else {
2344 return Err(OpenError::NotFound {
2345 requested: format!("session {id}"),
2346 });
2347 };
2348 open_session(meta, cwd).await.map_err(|e| {
2349 OpenError::Other(match e {
2350 BuildError::SessionOpen(reason) => reason,
2354 other => other.to_string(),
2355 })
2356 })
2357}
2358
2359fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
2384 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
2385 tool_context.set_session_id(id);
2386}
2387
2388pub(crate) async fn set_active_session(
2399 harness: &AgentHarness,
2400 session: Session,
2401 tool_context: Option<&rpi_extensions::ToolCallContext>,
2402) -> Result<(), String> {
2403 let metadata = session.get_metadata().await.ok();
2404 harness
2405 .set_session(session)
2406 .await
2407 .map_err(|e| e.to_string())?;
2408 if let Some(metadata) = metadata {
2409 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2410 if let Some(context) = tool_context {
2411 context.set_session_id(metadata.id);
2412 }
2413 }
2414 Ok(())
2415}
2416
2417pub(crate) async fn fork_session_storage(
2424 harness: &AgentHarness,
2425 cwd: &str,
2426) -> Result<Session, String> {
2427 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2428 use rpi_tools::FileSystem;
2429
2430 let dir = default_session_dir(Path::new(cwd));
2431 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2432 let fs: Arc<dyn FileSystem> = env.clone();
2433 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2434 fs,
2435 sessions_root: dir.to_string_lossy().into_owned(),
2436 clock: Arc::new(SystemClock),
2437 ids: Arc::new(DefaultIdGenerator::new()),
2438 });
2439 let id = harness.session().storage().metadata().id.clone();
2442 let metas = list_session_metadata(cwd)
2443 .await
2444 .map_err(|e| e.to_string())?;
2445 let Some(source) = metas.iter().find(|m| m.id == id) else {
2446 return Err(format!("current session {id} not found on disk"));
2447 };
2448 let fork_storage = repo
2449 .fork_typed(
2450 source,
2451 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2452 id: None,
2453 parent_session_id: Some(source.id.clone()),
2454 cwd: cwd.to_string(),
2455 metadata: None,
2456 },
2457 &rpi_harness::session::types::ForkOptions::default(),
2458 )
2459 .await
2460 .map_err(|e| e.to_string())?;
2461 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2462 Ok(Session::new(Arc::new(fork_storage), None))
2463}
2464
2465static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2467static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2470
2471fn claim_session_lock(session_path: &str) -> Result<(), String> {
2480 let lock_path = PathBuf::from(format!("{session_path}.lock"));
2481 if SESSION_LOCK_PATHS
2482 .get_or_init(|| Mutex::new(HashSet::new()))
2483 .lock()
2484 .unwrap()
2485 .contains(&lock_path)
2486 {
2487 return Ok(());
2488 }
2489 let file = std::fs::OpenOptions::new()
2490 .create(true)
2491 .write(true)
2492 .open(&lock_path)
2493 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2494 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2495 return Err(format!(
2496 "session {session_path} is already open in another rpi process"
2497 ));
2498 }
2499 SESSION_LOCKS
2500 .get_or_init(|| Mutex::new(Vec::new()))
2501 .lock()
2502 .unwrap()
2503 .push(file);
2504 SESSION_LOCK_PATHS
2505 .get_or_init(|| Mutex::new(HashSet::new()))
2506 .lock()
2507 .unwrap()
2508 .insert(lock_path);
2509 Ok(())
2510}
2511
2512async fn open_session(
2515 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2516 cwd: &str,
2517) -> Result<Session, BuildError> {
2518 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2519 use rpi_harness::session::types::SessionStorage;
2520 use rpi_tools::FileSystem;
2521
2522 let dir = default_session_dir(Path::new(cwd));
2523 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2524 let fs: Arc<dyn FileSystem> = env.clone();
2525 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2526 fs: fs.clone(),
2527 sessions_root: dir.to_string_lossy().into_owned(),
2528 clock: Arc::new(SystemClock),
2529 ids: Arc::new(DefaultIdGenerator::new()),
2530 });
2531 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2532 let storage = repo
2533 .open_by_jsonl_metadata(meta)
2534 .await
2535 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2536 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2540 Some(Path::new(&meta.path)),
2541 Path::new(&meta.cwd),
2542 Path::new(cwd),
2543 ) {
2544 eprintln!(
2545 "warning: {}",
2546 crate::session_cwd::format_missing_session_cwd_error(&issue)
2547 );
2548 }
2549 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2550 Ok(Session::new(storage_arc, None))
2551}
2552
2553fn ephemeral_session() -> Session {
2555 let storage = Arc::new(InMemorySessionStorage::new(
2556 SessionMetadata {
2557 id: "ephemeral".into(),
2558 created_at: 0,
2559 parent_session_id: None,
2560 },
2561 Arc::new(SystemClock),
2562 Arc::new(DefaultIdGenerator::new()),
2563 ));
2564 Session::new(storage, None)
2565}
2566
2567pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2578 create_jsonl_session_with_id(dir, cwd, None).await
2579}
2580
2581pub(crate) async fn create_jsonl_session_with_id(
2585 dir: &Path,
2586 cwd: &str,
2587 id: Option<String>,
2588) -> Result<Session, String> {
2589 use rpi_harness::session::jsonl::{
2590 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2591 };
2592 use rpi_tools::FileSystem;
2593
2594 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2597 let fs: Arc<dyn FileSystem> = env.clone();
2598
2599 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2600 fs: fs.clone(),
2601 sessions_root: dir.to_string_lossy().into_owned(),
2602 clock: Arc::new(SystemClock),
2603 ids: Arc::new(DefaultIdGenerator::new()),
2604 });
2605
2606 let opts = JsonlSessionCreateOptions {
2607 id, parent_session_id: None,
2609 cwd: cwd.to_string(),
2610 metadata: None,
2611 };
2612 let storage = repo
2613 .create_typed(&opts)
2614 .await
2615 .map_err(|e| format!("create session: {e}"))?;
2616 claim_session_lock(&storage.jsonl_metadata().path)
2617 .map_err(|e| format!("create session: {e}"))?;
2618 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2620 Ok(Session::new(storage_arc, None))
2621}
2622
2623fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
2626 let path = Path::new(target);
2627 if path.is_file() {
2628 read_prompt_source(path, sources)
2629 } else {
2630 None
2631 }
2632}
2633
2634fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
2637 let text = std::fs::read_to_string(path).ok()?;
2638 let absolute = if path.is_absolute() {
2639 path.to_path_buf()
2640 } else {
2641 std::env::current_dir()
2642 .map(|cwd| cwd.join(path))
2643 .unwrap_or_else(|_| path.to_path_buf())
2644 };
2645 let source = absolute.to_string_lossy().into_owned();
2646 if !sources.contains(&source) {
2647 sources.push(source);
2648 }
2649 Some(text)
2650}
2651
2652#[cfg(test)]
2653mod tests {
2654 use super::*;
2655 use crate::args::Args;
2656
2657 #[test]
2658 fn loaded_context_paths_track_precedence_and_reload() {
2659 const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
2660 if std::env::var_os(CHILD).is_none() {
2661 let dir = tempfile::tempdir().unwrap();
2662 let output = std::process::Command::new(std::env::current_exe().unwrap())
2663 .args([
2664 "--exact",
2665 "session::tests::loaded_context_paths_track_precedence_and_reload",
2666 "--nocapture",
2667 ])
2668 .env(CHILD, "1")
2669 .env(crate::config::CONFIG_DIR_ENV, dir.path())
2670 .output()
2671 .unwrap();
2672 assert!(
2673 output.status.success(),
2674 "{}\n{}",
2675 String::from_utf8_lossy(&output.stdout),
2676 String::from_utf8_lossy(&output.stderr)
2677 );
2678 return;
2679 }
2680 tokio::runtime::Builder::new_current_thread()
2681 .enable_all()
2682 .build()
2683 .unwrap()
2684 .block_on(async {
2685 let agent = crate::config::agent_dir().unwrap();
2686 let cwd = agent.join("project");
2687 let project_config = cwd.join(".rpi");
2688 std::fs::create_dir_all(&project_config).unwrap();
2689 for (path, content) in [
2690 (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
2691 (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
2692 (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
2693 (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
2694 (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
2695 (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
2696 (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
2697 ] {
2698 std::fs::write(path, content).unwrap();
2699 }
2700 let resolved = crate::provider::resolve(
2701 Some("anthropic"),
2702 Some(crate::provider::DEFAULT_MODEL_ID),
2703 None,
2704 Some("test-key"),
2705 None,
2706 )
2707 .unwrap();
2708 let args = Args {
2709 no_session: true,
2710 no_extensions: true,
2711 no_skills: true,
2712 no_prompt_templates: true,
2713 trust_override: Some(true),
2714 ..Default::default()
2715 };
2716 let (harness, _events, mut context) =
2717 build(&resolved, &args, &cwd, true).await.unwrap();
2718 let resources = harness.get_resources().await.unwrap();
2719 let path = |p: PathBuf| p.to_string_lossy().into_owned();
2720 assert_eq!(
2721 &resources.context_files[..2],
2722 &[
2723 path(project_config.join("SYSTEM.md")),
2724 path(project_config.join("APPEND_SYSTEM.md")),
2725 ]
2726 );
2727 assert!(resources
2728 .context_files
2729 .contains(&path(agent.join("AGENTS.md"))));
2730 assert!(resources
2731 .context_files
2732 .contains(&path(cwd.join("AGENTS.md"))));
2733 assert!(!resources
2734 .context_files
2735 .contains(&path(cwd.join("CLAUDE.md"))));
2736 assert!(!resources
2737 .context_files
2738 .contains(&path(agent.join("SYSTEM.md"))));
2739 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
2740 assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
2741 assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
2742
2743 std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
2746 std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
2747 std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
2748 assert!(
2749 !reload_extension_resources(&harness, &context)
2750 .await
2751 .had_errors
2752 );
2753 let resources = harness.get_resources().await.unwrap();
2754 assert_eq!(
2755 &resources.context_files[..2],
2756 &[
2757 path(agent.join("SYSTEM.md")),
2758 path(agent.join("APPEND_SYSTEM.md")),
2759 ]
2760 );
2761 assert!(resources
2762 .context_files
2763 .contains(&path(cwd.join("CLAUDE.md"))));
2764 assert!(!resources
2765 .context_files
2766 .contains(&path(project_config.join("SYSTEM.md"))));
2767
2768 let explicit = agent.join("custom-append.md");
2771 std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
2772 context.args.system_prompt = Some("EXPLICIT_BASE".into());
2773 context.args.append_system_prompt =
2774 vec![path(explicit.clone()), "literal append".into()];
2775 context.args.no_context_files = true;
2776 assert!(
2777 !reload_extension_resources(&harness, &context)
2778 .await
2779 .had_errors
2780 );
2781 assert_eq!(
2782 harness.get_resources().await.unwrap().context_files,
2783 vec![path(explicit)]
2784 );
2785
2786 context.args.system_prompt = None;
2788 context.args.append_system_prompt.clear();
2789 context.args.no_context_files = false;
2790 context.args.trust_override = Some(false);
2791 assert!(
2792 !reload_extension_resources(&harness, &context)
2793 .await
2794 .had_errors
2795 );
2796 assert!(harness
2797 .get_resources()
2798 .await
2799 .unwrap()
2800 .context_files
2801 .is_empty());
2802 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
2803 assert!(
2804 !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
2805 );
2806 });
2807 }
2808
2809 #[test]
2810 fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
2811 let dir = tempfile::tempdir().unwrap();
2812 let file = dir.path().join("SYSTEM.md");
2813 let mut sources = Vec::new();
2814 assert!(read_prompt_source(&file, &mut sources).is_none());
2815 std::fs::write(&file, [0xff, 0xfe]).unwrap();
2816 assert!(read_prompt_source(&file, &mut sources).is_none());
2817 assert!(read_append_target("literal instructions", &mut sources).is_none());
2818 assert!(sources.is_empty());
2819 std::fs::write(&file, "loaded instructions").unwrap();
2820 assert_eq!(
2821 read_prompt_source(&file, &mut sources).as_deref(),
2822 Some("loaded instructions")
2823 );
2824 assert!(read_prompt_source(&file, &mut sources).is_some());
2825 assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
2826 }
2827
2828 extern "C" fn deny_test_tool_call(
2829 _event: rpi_plugin_sdk::StablePluginEvent,
2830 _data: *mut std::ffi::c_void,
2831 ) -> i32 {
2832 rpi_plugin_sdk::EVENT_HANDLER_ABORT
2833 }
2834
2835 async fn invoke_test_tool_hook(
2836 hook: &rpi_agent::BeforeToolCall,
2837 ) -> Option<rpi_agent::BeforeToolCallResult> {
2838 let message = rpi_ai::providers::faux::faux_assistant_message(
2839 "approval",
2840 rpi_ai::types::StopReason::ToolUse,
2841 );
2842 let rpi_ai::types::Content::ToolCall(call) =
2843 rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
2844 else {
2845 unreachable!()
2846 };
2847 hook(
2848 rpi_agent::BeforeToolCallContext {
2849 assistant_message: &message,
2850 tool_call: &call,
2851 args: &call.arguments,
2852 context: &rpi_agent::AgentContext::default(),
2853 },
2854 tokio_util::sync::CancellationToken::new(),
2855 )
2856 .await
2857 }
2858
2859 #[tokio::test]
2860 async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
2861 let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
2862 let hook =
2863 extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
2864 assert!(invoke_test_tool_hook(&hook).await.is_none());
2865 for name in ["first-gate", "reloaded-gate"] {
2866 let mut registry = rpi_extensions::ExtensionRegistry::new();
2867 registry.register_event_handler(
2868 name.into(),
2869 rpi_plugin_sdk::EventTag::ToolCall,
2870 deny_test_tool_call,
2871 std::ptr::null_mut(),
2872 );
2873 let fresh = ExtensionSession::from_parts(
2874 Arc::new(registry.snapshot()),
2875 rpi_extensions::PluginKeepalive::empty(),
2876 Vec::new(),
2877 None,
2878 );
2879 {
2880 let mut current = sessions.lock().unwrap();
2881 if let Some(snapshot) = current.snapshot() {
2882 snapshot.active_flag().store(false, Ordering::SeqCst);
2883 }
2884 *current = fresh;
2885 }
2886 let result = invoke_test_tool_hook(&hook).await.unwrap();
2887 assert!(result.block);
2888 assert!(result.reason.unwrap().contains(name));
2889 }
2890 *sessions.lock().unwrap() = ExtensionSession::none();
2891 assert!(invoke_test_tool_hook(&hook).await.is_none());
2892 }
2893
2894 fn scratch(name: &str) -> PathBuf {
2896 let nanos = std::time::SystemTime::now()
2897 .duration_since(std::time::UNIX_EPOCH)
2898 .unwrap()
2899 .as_nanos();
2900 let path = std::env::temp_dir().join(format!(
2901 "rpi-summary-{}-{nanos}-{name}.jsonl",
2902 std::process::id()
2903 ));
2904 path
2905 }
2906
2907 #[test]
2908 fn summary_marks_a_header_only_session_empty() {
2909 let path = scratch("empty");
2910 std::fs::write(
2911 &path,
2912 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2913 )
2914 .unwrap();
2915 let summary = summarize_session_file(&path);
2916 assert!(summary.empty, "a header-only file has nothing to resume");
2917 assert!(summary.preview.is_none());
2918 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
2919 std::fs::remove_file(&path).ok();
2920 }
2921
2922 #[test]
2923 fn summary_reads_the_first_user_prompt_and_name() {
2924 let path = scratch("content");
2925 let body = concat!(
2926 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2927 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
2928 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
2929 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
2930 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
2931 );
2932 std::fs::write(&path, body).unwrap();
2933 let summary = summarize_session_file(&path);
2934 assert!(!summary.empty);
2935 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
2936 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
2937 std::fs::remove_file(&path).ok();
2938 }
2939
2940 #[test]
2941 fn summary_extracts_a_block_style_user_prompt() {
2942 let path = scratch("blocks");
2943 let body = concat!(
2944 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2945 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
2946 );
2947 std::fs::write(&path, body).unwrap();
2948 let summary = summarize_session_file(&path);
2949 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
2950 assert_eq!(session_display_label(&summary), "line one line two");
2951 std::fs::remove_file(&path).ok();
2952 }
2953
2954 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
2955 rpi_harness::session::jsonl::JsonlSessionMetadata {
2956 id: id.to_string(),
2957 created_at: 0,
2958 cwd: ".".to_string(),
2959 path: path.to_string_lossy().into_owned(),
2960 modified_at: 0,
2961 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
2962 parent_session_id: None,
2963 legacy_parent_session_path: None,
2964 metadata: None,
2965 }
2966 }
2967
2968 #[test]
2969 fn continue_skips_newer_empty_sessions() {
2970 let empty = scratch("newest-empty");
2971 std::fs::write(
2972 &empty,
2973 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2974 )
2975 .unwrap();
2976 let content = scratch("older-content");
2977 std::fs::write(
2978 &content,
2979 concat!(
2980 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2981 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
2982 ),
2983 )
2984 .unwrap();
2985
2986 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
2987 let picked = first_resumable_session(&metas).expect("a resumable session");
2988 assert_eq!(picked.id, "old");
2989
2990 let all_empty = vec![meta_for(&empty, "empty")];
2993 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
2994
2995 std::fs::remove_file(&empty).ok();
2996 std::fs::remove_file(&content).ok();
2997 }
2998
2999 #[test]
3000 fn list_session_files_sync_walks_the_cwd_subdirectory() {
3001 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
3002 let nested = root.join("--proj--");
3003 std::fs::create_dir_all(&nested).unwrap();
3004 let file = nested.join("2026-01-01_a.jsonl");
3005 std::fs::write(&file, "{}\n").unwrap();
3006 let found = list_session_files_sync(&root);
3007 assert!(found.iter().any(|p| p == &file));
3008 std::fs::remove_dir_all(&root).ok();
3009 }
3010
3011 #[test]
3012 fn default_prompt_mentions_cwd_and_tools() {
3013 let p = default_system_prompt("/tmp/proj", false);
3014 assert!(p.contains("/tmp/proj"));
3015 assert!(p.contains("read"));
3016 assert!(p.contains("bash"));
3017 assert!(p.contains("edit"));
3018 assert!(p.contains("write"));
3019 assert!(p.contains("docs"));
3020 assert!(!p.contains("- grep"));
3021 assert!(!p.contains("- find"));
3022 assert!(!p.contains("- ls"));
3023 assert!(!p.contains("- powershell"));
3024 assert!(p.contains("Bash/POSIX syntax, including on Windows"));
3025 assert!(p.contains("do not pass PowerShell cmdlets"));
3026 }
3027
3028 #[test]
3029 fn default_prompt_instructs_language_matching() {
3030 let p = default_system_prompt("/tmp/proj", false);
3034 let lower = p.to_lowercase();
3035 assert!(
3036 lower.contains("same language") && lower.contains("detect"),
3037 "the prompt must tell the model to detect and mirror the user's language: {p}"
3038 );
3039 }
3040
3041 #[test]
3042 fn default_prompt_keeps_working_state_in_the_visible_channel() {
3043 let p = default_system_prompt("/tmp/proj", false);
3051 let lower = p.to_lowercase();
3052 assert!(
3054 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
3055 "the prompt must explain that reasoning does not survive the turn: {p}"
3056 );
3057 assert!(
3059 lower.contains("visible") && lower.contains("todo tool"),
3060 "the prompt must point at visible text and the todo tool: {p}"
3061 );
3062 assert!(
3064 lower.contains("re-stating the same plan"),
3065 "the prompt must call out plan re-statement as a bug: {p}"
3066 );
3067 assert!(
3069 lower.contains("already shown earlier"),
3070 "the prompt must discourage re-fetching known content: {p}"
3071 );
3072 }
3073
3074 #[test]
3075 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
3076 let p = default_system_prompt("/tmp/proj", true);
3083 let lower = p.to_lowercase();
3084 assert!(
3085 lower.contains("carried back"),
3086 "a replaying model must be told its reasoning survives: {p}"
3087 );
3088 assert!(
3089 !lower.contains("reasoning is not"),
3090 "the replayed variant must not claim reasoning is dropped: {p}"
3091 );
3092 assert!(
3093 lower.contains("do not re-derive"),
3094 "the replayed variant must forbid re-deriving the plan: {p}"
3095 );
3096 assert!(
3097 lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
3098 "the anti-repetition rule must survive the flip: {p}"
3099 );
3100 assert!(
3101 lower.contains("already shown earlier"),
3102 "the re-read rule must survive the flip: {p}"
3103 );
3104 }
3105
3106 #[test]
3114 fn only_read_only_tools_are_replayable() {
3115 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3116 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3117 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3118 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3119 let tools = build_tools(&context, &Args::default());
3120
3121 let replay_of = |name: &str| {
3122 tools
3123 .iter()
3124 .find(|tool| tool.tool.schema().name == name)
3125 .map(|tool| tool.replay)
3126 };
3127
3128 for read_only in ["read", "docs"] {
3129 assert_eq!(
3130 replay_of(read_only),
3131 Some(ToolReplay::Safe),
3132 "{read_only} is side-effect free, so recovery may re-run it"
3133 );
3134 }
3135 for mutating in ["bash", "edit", "write", "powershell"] {
3136 if let Some(replay) = replay_of(mutating) {
3137 assert_eq!(
3138 replay,
3139 ToolReplay::Never,
3140 "{mutating} must never be replayed: re-running it would duplicate its effect"
3141 );
3142 }
3143 }
3144 }
3145
3146 #[test]
3147 fn default_tools_include_docs_lookup() {
3148 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3149 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3150 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3151 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3152 let names: Vec<String> = build_tools(&context, &Args::default())
3153 .iter()
3154 .map(|tool| tool.tool.schema().name.clone())
3155 .collect();
3156
3157 assert!(names.contains(&"docs".to_string()));
3162 for legacy in ["grep", "find", "ls"] {
3163 assert!(
3164 !names.contains(&legacy.to_string()),
3165 "legacy tool {legacy} should not be registered by default"
3166 );
3167 }
3168 }
3169
3170 #[test]
3171 fn reload_settings_preflight_respects_trust() {
3172 struct RestoreConfigDir(Option<std::ffi::OsString>);
3173
3174 impl Drop for RestoreConfigDir {
3175 fn drop(&mut self) {
3176 match self.0.take() {
3177 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3178 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3179 }
3180 }
3181 }
3182
3183 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3184 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3185 let tmp = tempfile::tempdir().unwrap();
3186 let agent = tmp.path().join("agent");
3187 let cwd = tmp.path().join("project");
3188 std::fs::create_dir_all(&agent).unwrap();
3189 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3190 std::fs::write(agent.join("settings.json"), "{}").unwrap();
3191 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
3192 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3193 let args = Args::default();
3194
3195 let trusted = validate_settings_for_reload(&args, &cwd, true);
3196 let untrusted = validate_settings_for_reload(&args, &cwd, false);
3197 assert!(trusted
3198 .unwrap_err()
3199 .contains("could not load project settings"));
3200 assert!(untrusted.is_ok());
3201
3202 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3203 assert!(validate_settings_for_reload(&args, &cwd, false)
3204 .unwrap_err()
3205 .contains("could not load global settings"));
3206
3207 let local_only = Args {
3208 dev_local_only: true,
3209 ..Args::default()
3210 };
3211 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
3212 }
3213
3214 #[tokio::test(flavor = "current_thread")]
3215 async fn reload_preserves_live_resources_when_settings_break() {
3216 struct RestoreConfigDir(Option<std::ffi::OsString>);
3217
3218 impl Drop for RestoreConfigDir {
3219 fn drop(&mut self) {
3220 match self.0.take() {
3221 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3222 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3223 }
3224 }
3225 }
3226
3227 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3228 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3229 let tmp = tempfile::tempdir().unwrap();
3230 let agent = tmp.path().join("agent");
3231 let cwd = tmp.path().join("project");
3232 let skill_dir = tmp.path().join("configured-skills");
3233 std::fs::create_dir_all(&agent).unwrap();
3234 std::fs::create_dir_all(&cwd).unwrap();
3235 std::fs::create_dir_all(&skill_dir).unwrap();
3236 std::fs::write(
3237 skill_dir.join("SKILL.md"),
3238 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
3239 )
3240 .unwrap();
3241 std::fs::write(
3242 agent.join("settings.json"),
3243 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3244 )
3245 .unwrap();
3246 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3247
3248 let resolved = crate::provider::resolve(
3249 Some("anthropic"),
3250 Some(crate::provider::DEFAULT_MODEL_ID),
3251 None,
3252 Some("test-key"),
3253 None,
3254 )
3255 .unwrap();
3256 let args = Args {
3257 trust_override: Some(false),
3258 no_session: true,
3259 no_extensions: true,
3260 no_prompt_templates: true,
3261 no_context_files: true,
3262 system_prompt: Some("stable system prompt".into()),
3263 ..Args::default()
3264 };
3265 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3266 let before_resources = harness.get_resources().await.unwrap();
3267 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
3268 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
3269 let before_prompt = harness.get_system_prompt().await.unwrap();
3270 let before_tools: Vec<String> = harness
3271 .get_tools()
3272 .await
3273 .unwrap()
3274 .iter()
3275 .map(|tool| tool.tool.schema().name.clone())
3276 .collect();
3277 let before_bridge = context
3278 .action_bridge
3279 .lock()
3280 .unwrap()
3281 .as_ref()
3282 .unwrap()
3283 .clone();
3284
3285 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3286 let outcome = reload_extension_resources(&harness, &context).await;
3287
3288 assert!(outcome.had_warnings);
3289 assert!(outcome.summary.contains("Settings reload failed"));
3290 assert!(outcome.summary.contains("could not load global settings"));
3291 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
3292 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
3293 let after_tools: Vec<String> = harness
3294 .get_tools()
3295 .await
3296 .unwrap()
3297 .iter()
3298 .map(|tool| tool.tool.schema().name.clone())
3299 .collect();
3300 assert_eq!(after_tools, before_tools);
3301 let after_bridge = context
3302 .action_bridge
3303 .lock()
3304 .unwrap()
3305 .as_ref()
3306 .unwrap()
3307 .clone();
3308 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
3309 }
3310
3311 #[test]
3312 fn reload_preparation_rejects_settings_changed_during_derivation() {
3313 struct RestoreConfigDir(Option<std::ffi::OsString>);
3314
3315 impl Drop for RestoreConfigDir {
3316 fn drop(&mut self) {
3317 match self.0.take() {
3318 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3319 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3320 }
3321 }
3322 }
3323
3324 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3325 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3326 let tmp = tempfile::tempdir().unwrap();
3327 let agent = tmp.path().join("agent");
3328 let cwd = tmp.path().join("project");
3329 let first_skill_dir = tmp.path().join("first-skills");
3330 let second_skill_dir = tmp.path().join("second-skills");
3331 std::fs::create_dir_all(&agent).unwrap();
3332 std::fs::create_dir_all(&cwd).unwrap();
3333 std::fs::write(
3334 agent.join("settings.json"),
3335 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3336 )
3337 .unwrap();
3338 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3339 let args = Args {
3340 trust_override: Some(false),
3341 no_extensions: true,
3342 no_prompt_templates: true,
3343 ..Args::default()
3344 };
3345
3346 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3347 std::fs::write(
3348 agent.join("settings.json"),
3349 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3350 )
3351 .unwrap();
3352 });
3353
3354 let error = result
3355 .err()
3356 .expect("settings mutation must fail preparation");
3357 assert!(error.contains("settings changed while reload inputs were being prepared"));
3358 }
3359
3360 #[tokio::test(flavor = "current_thread")]
3361 async fn reload_uses_frozen_settings_inputs_after_preparation() {
3362 struct RestoreConfigDir(Option<std::ffi::OsString>);
3363
3364 impl Drop for RestoreConfigDir {
3365 fn drop(&mut self) {
3366 match self.0.take() {
3367 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3368 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3369 }
3370 }
3371 }
3372
3373 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3374 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3375 let tmp = tempfile::tempdir().unwrap();
3376 let agent = tmp.path().join("agent");
3377 let cwd = tmp.path().join("project");
3378 let skill_dir = tmp.path().join("configured-skills");
3379 std::fs::create_dir_all(&agent).unwrap();
3380 std::fs::create_dir_all(&cwd).unwrap();
3381 std::fs::create_dir_all(&skill_dir).unwrap();
3382 std::fs::write(
3383 skill_dir.join("SKILL.md"),
3384 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3385 )
3386 .unwrap();
3387 std::fs::write(
3388 agent.join("settings.json"),
3389 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3390 )
3391 .unwrap();
3392 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3393
3394 let resolved = crate::provider::resolve(
3395 Some("anthropic"),
3396 Some(crate::provider::DEFAULT_MODEL_ID),
3397 None,
3398 Some("test-key"),
3399 None,
3400 )
3401 .unwrap();
3402 let args = Args {
3403 trust_override: Some(false),
3404 no_session: true,
3405 no_extensions: true,
3406 no_prompt_templates: true,
3407 no_context_files: true,
3408 system_prompt: Some("stable system prompt".into()),
3409 ..Args::default()
3410 };
3411 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3412
3413 let outcome = reload_extension_resources_inner(&harness, &context, || {
3414 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3415 })
3416 .await;
3417
3418 assert!(!outcome.summary.contains("Settings reload failed"));
3419 assert_eq!(
3420 outcome.summary,
3421 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3422 );
3423 let resources = harness.get_resources().await.unwrap();
3424 let skills = resources.skills.as_ref().unwrap();
3425 assert_eq!(skills.len(), 1);
3426 assert_eq!(skills[0].name, "frozen-skill");
3427 }
3428
3429 #[tokio::test]
3430 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3431 let tmp = tempfile::tempdir().unwrap();
3432 let skill_path = tmp.path().join("explicit-skill.md");
3433 std::fs::write(
3434 &skill_path,
3435 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3436 )
3437 .unwrap();
3438 let prompt_path = tmp.path().join("explicit-prompt.md");
3439 std::fs::write(
3440 &prompt_path,
3441 "---\ndescription: Explicit prompt\n---\nPrompt body",
3442 )
3443 .unwrap();
3444
3445 let args = Args {
3446 skill: vec![skill_path.clone()],
3447 prompt_template: vec![prompt_path.clone()],
3448 ..Args::default()
3449 };
3450 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3451 let prompt_paths =
3452 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3453 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3454 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3455
3456 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3457 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3458 assert_eq!(skills.skills[0].name, "explicit-skill");
3459
3460 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3461 assert_eq!(
3462 prompts.prompt_templates.len(),
3463 1,
3464 "{:?}",
3465 prompts.diagnostics
3466 );
3467 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3468 }
3469
3470 #[test]
3471 fn tool_policy_applies_to_rust_and_js_active_names() {
3472 let names = vec![
3473 "read".to_string(),
3474 "ask_user_question".to_string(),
3475 "write".to_string(),
3476 ];
3477
3478 let args = Args {
3479 tools: Some(vec!["read".into(), "ask_user_question".into()]),
3480 ..Args::default()
3481 };
3482 assert_eq!(
3483 filter_active_tool_names(names.clone(), &args),
3484 vec!["read", "ask_user_question"]
3485 );
3486
3487 let args = Args {
3488 exclude_tools: Some(vec!["ask_user_question".into()]),
3489 ..Args::default()
3490 };
3491 assert_eq!(
3492 filter_active_tool_names(names.clone(), &args),
3493 vec!["read", "write"]
3494 );
3495
3496 let args = Args {
3497 no_tools: true,
3498 ..Args::default()
3499 };
3500 assert!(filter_active_tool_names(names, &args).is_empty());
3501 }
3502
3503 #[test]
3504 fn select_ephemeral_when_no_session() {
3505 let args = Args {
3506 no_session: true,
3507 ..Args::default()
3508 };
3509 let cwd = Path::new("/tmp");
3510 assert!(matches!(
3511 select_session(&args, cwd),
3512 SessionSelection::Ephemeral
3513 ));
3514 }
3515
3516 #[test]
3517 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3518 let defaults = Args::default();
3519 assert!(resolve_project_trust(
3520 &defaults,
3521 Path::new("C:/definitely-not-a-project")
3522 ));
3523
3524 let denied = Args {
3525 trust_override: Some(false),
3526 ..Args::default()
3527 };
3528 assert!(!resolve_project_trust(
3529 &denied,
3530 Path::new("C:/definitely-not-a-project")
3531 ));
3532 }
3533
3534 #[tokio::test(flavor = "current_thread")]
3535 async fn build_preserves_the_supplied_startup_project_snapshot() {
3536 struct RestoreConfigDir(Option<std::ffi::OsString>);
3537
3538 impl Drop for RestoreConfigDir {
3539 fn drop(&mut self) {
3540 match self.0.take() {
3541 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3542 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3543 }
3544 }
3545 }
3546
3547 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3548 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3549 let tmp = tempfile::tempdir().unwrap();
3550 let agent = tmp.path().join("agent");
3551 let cwd = tmp.path().join("project");
3552 std::fs::create_dir_all(&agent).unwrap();
3553 std::fs::create_dir_all(&cwd).unwrap();
3554 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3555
3556 let resolved = crate::provider::resolve(
3557 Some("anthropic"),
3558 Some(crate::provider::DEFAULT_MODEL_ID),
3559 None,
3560 Some("test-key"),
3561 None,
3562 )
3563 .unwrap();
3564 let args = Args {
3565 trust_override: Some(false),
3566 no_session: true,
3567 no_tools: true,
3568 no_extensions: true,
3569 no_skills: true,
3570 no_prompt_templates: true,
3571 no_context_files: true,
3572 system_prompt: Some("test prompt".into()),
3573 ..Args::default()
3574 };
3575
3576 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3577
3578 assert_eq!(context.cwd, cwd);
3579 assert!(context.project_trusted);
3580 }
3581
3582 #[tokio::test(flavor = "current_thread")]
3583 async fn build_applies_the_name_flag_to_the_session() {
3584 struct RestoreConfigDir(Option<std::ffi::OsString>);
3585
3586 impl Drop for RestoreConfigDir {
3587 fn drop(&mut self) {
3588 match self.0.take() {
3589 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3590 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3591 }
3592 }
3593 }
3594
3595 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3596 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3597 let tmp = tempfile::tempdir().unwrap();
3598 let agent = tmp.path().join("agent");
3599 let cwd = tmp.path().join("project");
3600 std::fs::create_dir_all(&agent).unwrap();
3601 std::fs::create_dir_all(&cwd).unwrap();
3602 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3603
3604 let resolved = crate::provider::resolve(
3605 Some("anthropic"),
3606 Some(crate::provider::DEFAULT_MODEL_ID),
3607 None,
3608 Some("test-key"),
3609 None,
3610 )
3611 .unwrap();
3612 let args = Args {
3613 trust_override: Some(false),
3614 no_tools: true,
3615 no_extensions: true,
3616 no_skills: true,
3617 no_prompt_templates: true,
3618 no_context_files: true,
3619 system_prompt: Some("test prompt".into()),
3620 name: Some("smoke name".into()),
3621 session_dir: Some(cwd.join("sessions")),
3622 ..Args::default()
3623 };
3624
3625 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3626 assert_eq!(
3627 harness.get_name().await.unwrap().as_deref(),
3628 Some("smoke name")
3629 );
3630 }
3631
3632 #[test]
3633 fn select_latest_for_continue_and_resume() {
3634 let args = Args {
3635 continue_session: true,
3636 ..Args::default()
3637 };
3638 let cwd = Path::new("/tmp");
3639 assert!(matches!(
3640 select_session(&args, cwd),
3641 SessionSelection::Latest { .. }
3642 ));
3643
3644 let args = Args {
3645 resume: true,
3646 ..Args::default()
3647 };
3648 assert!(matches!(
3649 select_session(&args, cwd),
3650 SessionSelection::Latest { .. }
3651 ));
3652 }
3653
3654 #[test]
3655 fn select_by_id_for_session_flag() {
3656 let args = Args {
3657 session: Some("01a02ece".into()),
3658 ..Args::default()
3659 };
3660 let cwd = Path::new("/tmp");
3661 assert!(matches!(
3662 select_session(&args, cwd),
3663 SessionSelection::ById { id, .. } if id == "01a02ece"
3664 ));
3665 }
3666
3667 #[test]
3668 fn select_new_with_custom_dir() {
3669 let args = Args {
3670 session_dir: Some(PathBuf::from("/tmp/sess")),
3671 ..Args::default()
3672 };
3673 let cwd = Path::new("/tmp");
3674 match select_session(&args, cwd) {
3675 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
3676 other => panic!("expected New, got {other:?}"),
3677 }
3678 }
3679
3680 #[test]
3681 fn select_new_default_dir() {
3682 let args = Args::default();
3683 let cwd = Path::new("/proj");
3684 match select_session(&args, cwd) {
3685 SessionSelection::New { dir, .. } => {
3686 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
3687 }
3688 other => panic!("expected New, got {other:?}"),
3689 }
3690 }
3691
3692 #[test]
3693 fn default_session_dir_only_uses_rpi() {
3694 let tmp = tempfile::tempdir().unwrap();
3695 let cwd = tmp.path();
3696 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3697 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3698 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3699 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3700 }
3701
3702 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3703 async fn ephemeral_session_builds_roundtrips() {
3704 let s = ephemeral_session();
3708 let leaf = s.get_leaf_id().await;
3709 assert!(leaf.is_ok());
3710 }
3711
3712 #[test]
3730 fn host_private_env_matches_the_declared_contract() {
3731 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
3733 assert_eq!(
3734 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
3735 [
3736 "LANGFUSE_PI_PARENT_TRACE_ID",
3737 "LANGFUSE_PI_PARENT_SPAN_ID",
3738 "LANGFUSE_PI_PARENT_SESSION_ID",
3739 "LANGFUSE_PI_PARENT_DEPTH",
3740 ],
3741 "these strings are what rpi-langfuse (a different repo) matches; a \
3742 change here is a breaking cross-repo change, not a refactor"
3743 );
3744
3745 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
3748 .iter()
3749 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
3750 {
3751 assert!(
3752 rpi_tools::is_host_private_env(name),
3753 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
3754 rpi_tools::HOST_PRIVATE_ENV
3755 );
3756 }
3757
3758 for visible in [
3761 crate::args::RPI_OFFLINE_ENV,
3762 crate::config::CONFIG_DIR_ENV,
3763 "PATH",
3764 ] {
3765 assert!(
3766 !rpi_tools::is_host_private_env(visible),
3767 "{visible} describes the user's configuration and must still reach the shell"
3768 );
3769 }
3770 }
3771 #[tokio::test]
3772 async fn reload_shutdown_joins_all_handlers_even_after_veto() {
3773 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
3774 use std::sync::atomic::AtomicUsize;
3775 static CONTEXTS: rpi_plugin_sdk::handles::ToolHandles<Arc<AtomicUsize>> =
3776 rpi_plugin_sdk::handles::ToolHandles::new();
3777 struct ContextOwner(usize);
3778 impl Drop for ContextOwner {
3779 fn drop(&mut self) {
3780 drop(CONTEXTS.remove(self.0 as rpi_plugin_sdk::StepHandle));
3781 }
3782 }
3783 extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
3784 let context = CONTEXTS.get(data).expect("live test context");
3785 let calls = context.lock().clone();
3786 std::thread::sleep(std::time::Duration::from_millis(20));
3787 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
3788 rpi_plugin_sdk::EVENT_HANDLER_ABORT
3789 } else {
3790 0
3791 }
3792 }
3793 let calls = Arc::new(AtomicUsize::new(0));
3794 let context = CONTEXTS.insert(calls.clone());
3795 let _owner = ContextOwner(context as usize);
3796 let mut registry = rpi_extensions::ExtensionRegistry::new();
3797 for name in ["first", "second"] {
3798 registry.register_event_handler(name.into(), EventTag::SessionShutdown, stop, context);
3799 }
3800 super::shutdown_extensions_for_reload(
3801 Some(Arc::new(registry.snapshot())),
3802 rpi_extensions::PluginKeepalive::empty(),
3803 )
3804 .await
3805 .unwrap();
3806 assert_eq!(calls.load(Ordering::SeqCst), 2);
3807 }
3808}