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 if let Ok(metadata) = session.get_metadata().await {
2404 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2405 if let Some(context) = tool_context {
2406 context.set_session_id(metadata.id);
2407 }
2408 }
2409 harness
2410 .set_session(session)
2411 .await
2412 .map_err(|e| e.to_string())
2413}
2414
2415pub(crate) async fn fork_session_storage(
2422 harness: &AgentHarness,
2423 cwd: &str,
2424) -> Result<Session, String> {
2425 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2426 use rpi_tools::FileSystem;
2427
2428 let dir = default_session_dir(Path::new(cwd));
2429 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2430 let fs: Arc<dyn FileSystem> = env.clone();
2431 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2432 fs,
2433 sessions_root: dir.to_string_lossy().into_owned(),
2434 clock: Arc::new(SystemClock),
2435 ids: Arc::new(DefaultIdGenerator::new()),
2436 });
2437 let id = harness.session().storage().metadata().id.clone();
2440 let metas = list_session_metadata(cwd)
2441 .await
2442 .map_err(|e| e.to_string())?;
2443 let Some(source) = metas.iter().find(|m| m.id == id) else {
2444 return Err(format!("current session {id} not found on disk"));
2445 };
2446 let fork_storage = repo
2447 .fork_typed(
2448 source,
2449 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2450 id: None,
2451 parent_session_id: Some(source.id.clone()),
2452 cwd: cwd.to_string(),
2453 metadata: None,
2454 },
2455 &rpi_harness::session::types::ForkOptions::default(),
2456 )
2457 .await
2458 .map_err(|e| e.to_string())?;
2459 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2460 Ok(Session::new(Arc::new(fork_storage), None))
2461}
2462
2463static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2465static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2468
2469fn claim_session_lock(session_path: &str) -> Result<(), String> {
2478 let lock_path = PathBuf::from(format!("{session_path}.lock"));
2479 if SESSION_LOCK_PATHS
2480 .get_or_init(|| Mutex::new(HashSet::new()))
2481 .lock()
2482 .unwrap()
2483 .contains(&lock_path)
2484 {
2485 return Ok(());
2486 }
2487 let file = std::fs::OpenOptions::new()
2488 .create(true)
2489 .write(true)
2490 .open(&lock_path)
2491 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2492 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2493 return Err(format!(
2494 "session {session_path} is already open in another rpi process"
2495 ));
2496 }
2497 SESSION_LOCKS
2498 .get_or_init(|| Mutex::new(Vec::new()))
2499 .lock()
2500 .unwrap()
2501 .push(file);
2502 SESSION_LOCK_PATHS
2503 .get_or_init(|| Mutex::new(HashSet::new()))
2504 .lock()
2505 .unwrap()
2506 .insert(lock_path);
2507 Ok(())
2508}
2509
2510async fn open_session(
2513 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2514 cwd: &str,
2515) -> Result<Session, BuildError> {
2516 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2517 use rpi_harness::session::types::SessionStorage;
2518 use rpi_tools::FileSystem;
2519
2520 let dir = default_session_dir(Path::new(cwd));
2521 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2522 let fs: Arc<dyn FileSystem> = env.clone();
2523 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2524 fs: fs.clone(),
2525 sessions_root: dir.to_string_lossy().into_owned(),
2526 clock: Arc::new(SystemClock),
2527 ids: Arc::new(DefaultIdGenerator::new()),
2528 });
2529 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2530 let storage = repo
2531 .open_by_jsonl_metadata(meta)
2532 .await
2533 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2534 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2538 Some(Path::new(&meta.path)),
2539 Path::new(&meta.cwd),
2540 Path::new(cwd),
2541 ) {
2542 eprintln!(
2543 "warning: {}",
2544 crate::session_cwd::format_missing_session_cwd_error(&issue)
2545 );
2546 }
2547 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2548 Ok(Session::new(storage_arc, None))
2549}
2550
2551fn ephemeral_session() -> Session {
2553 let storage = Arc::new(InMemorySessionStorage::new(
2554 SessionMetadata {
2555 id: "ephemeral".into(),
2556 created_at: 0,
2557 parent_session_id: None,
2558 },
2559 Arc::new(SystemClock),
2560 Arc::new(DefaultIdGenerator::new()),
2561 ));
2562 Session::new(storage, None)
2563}
2564
2565pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2576 create_jsonl_session_with_id(dir, cwd, None).await
2577}
2578
2579pub(crate) async fn create_jsonl_session_with_id(
2583 dir: &Path,
2584 cwd: &str,
2585 id: Option<String>,
2586) -> Result<Session, String> {
2587 use rpi_harness::session::jsonl::{
2588 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2589 };
2590 use rpi_tools::FileSystem;
2591
2592 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2595 let fs: Arc<dyn FileSystem> = env.clone();
2596
2597 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2598 fs: fs.clone(),
2599 sessions_root: dir.to_string_lossy().into_owned(),
2600 clock: Arc::new(SystemClock),
2601 ids: Arc::new(DefaultIdGenerator::new()),
2602 });
2603
2604 let opts = JsonlSessionCreateOptions {
2605 id, parent_session_id: None,
2607 cwd: cwd.to_string(),
2608 metadata: None,
2609 };
2610 let storage = repo
2611 .create_typed(&opts)
2612 .await
2613 .map_err(|e| format!("create session: {e}"))?;
2614 claim_session_lock(&storage.jsonl_metadata().path)
2615 .map_err(|e| format!("create session: {e}"))?;
2616 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2618 Ok(Session::new(storage_arc, None))
2619}
2620
2621fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
2624 let path = Path::new(target);
2625 if path.is_file() {
2626 read_prompt_source(path, sources)
2627 } else {
2628 None
2629 }
2630}
2631
2632fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
2635 let text = std::fs::read_to_string(path).ok()?;
2636 let absolute = if path.is_absolute() {
2637 path.to_path_buf()
2638 } else {
2639 std::env::current_dir()
2640 .map(|cwd| cwd.join(path))
2641 .unwrap_or_else(|_| path.to_path_buf())
2642 };
2643 let source = absolute.to_string_lossy().into_owned();
2644 if !sources.contains(&source) {
2645 sources.push(source);
2646 }
2647 Some(text)
2648}
2649
2650#[cfg(test)]
2651mod tests {
2652 use super::*;
2653 use crate::args::Args;
2654
2655 #[test]
2656 fn loaded_context_paths_track_precedence_and_reload() {
2657 const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
2658 if std::env::var_os(CHILD).is_none() {
2659 let dir = tempfile::tempdir().unwrap();
2660 let output = std::process::Command::new(std::env::current_exe().unwrap())
2661 .args([
2662 "--exact",
2663 "session::tests::loaded_context_paths_track_precedence_and_reload",
2664 "--nocapture",
2665 ])
2666 .env(CHILD, "1")
2667 .env(crate::config::CONFIG_DIR_ENV, dir.path())
2668 .output()
2669 .unwrap();
2670 assert!(
2671 output.status.success(),
2672 "{}\n{}",
2673 String::from_utf8_lossy(&output.stdout),
2674 String::from_utf8_lossy(&output.stderr)
2675 );
2676 return;
2677 }
2678 tokio::runtime::Builder::new_current_thread()
2679 .enable_all()
2680 .build()
2681 .unwrap()
2682 .block_on(async {
2683 let agent = crate::config::agent_dir().unwrap();
2684 let cwd = agent.join("project");
2685 let project_config = cwd.join(".rpi");
2686 std::fs::create_dir_all(&project_config).unwrap();
2687 for (path, content) in [
2688 (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
2689 (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
2690 (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
2691 (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
2692 (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
2693 (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
2694 (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
2695 ] {
2696 std::fs::write(path, content).unwrap();
2697 }
2698 let resolved = crate::provider::resolve(
2699 Some("anthropic"),
2700 Some(crate::provider::DEFAULT_MODEL_ID),
2701 None,
2702 Some("test-key"),
2703 None,
2704 )
2705 .unwrap();
2706 let args = Args {
2707 no_session: true,
2708 no_extensions: true,
2709 no_skills: true,
2710 no_prompt_templates: true,
2711 trust_override: Some(true),
2712 ..Default::default()
2713 };
2714 let (harness, _events, mut context) =
2715 build(&resolved, &args, &cwd, true).await.unwrap();
2716 let resources = harness.get_resources().await.unwrap();
2717 let path = |p: PathBuf| p.to_string_lossy().into_owned();
2718 assert_eq!(
2719 &resources.context_files[..2],
2720 &[
2721 path(project_config.join("SYSTEM.md")),
2722 path(project_config.join("APPEND_SYSTEM.md")),
2723 ]
2724 );
2725 assert!(resources
2726 .context_files
2727 .contains(&path(agent.join("AGENTS.md"))));
2728 assert!(resources
2729 .context_files
2730 .contains(&path(cwd.join("AGENTS.md"))));
2731 assert!(!resources
2732 .context_files
2733 .contains(&path(cwd.join("CLAUDE.md"))));
2734 assert!(!resources
2735 .context_files
2736 .contains(&path(agent.join("SYSTEM.md"))));
2737 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
2738 assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
2739 assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
2740
2741 std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
2744 std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
2745 std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
2746 assert!(
2747 !reload_extension_resources(&harness, &context)
2748 .await
2749 .had_errors
2750 );
2751 let resources = harness.get_resources().await.unwrap();
2752 assert_eq!(
2753 &resources.context_files[..2],
2754 &[
2755 path(agent.join("SYSTEM.md")),
2756 path(agent.join("APPEND_SYSTEM.md")),
2757 ]
2758 );
2759 assert!(resources
2760 .context_files
2761 .contains(&path(cwd.join("CLAUDE.md"))));
2762 assert!(!resources
2763 .context_files
2764 .contains(&path(project_config.join("SYSTEM.md"))));
2765
2766 let explicit = agent.join("custom-append.md");
2769 std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
2770 context.args.system_prompt = Some("EXPLICIT_BASE".into());
2771 context.args.append_system_prompt =
2772 vec![path(explicit.clone()), "literal append".into()];
2773 context.args.no_context_files = true;
2774 assert!(
2775 !reload_extension_resources(&harness, &context)
2776 .await
2777 .had_errors
2778 );
2779 assert_eq!(
2780 harness.get_resources().await.unwrap().context_files,
2781 vec![path(explicit)]
2782 );
2783
2784 context.args.system_prompt = None;
2786 context.args.append_system_prompt.clear();
2787 context.args.no_context_files = false;
2788 context.args.trust_override = Some(false);
2789 assert!(
2790 !reload_extension_resources(&harness, &context)
2791 .await
2792 .had_errors
2793 );
2794 assert!(harness
2795 .get_resources()
2796 .await
2797 .unwrap()
2798 .context_files
2799 .is_empty());
2800 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
2801 assert!(
2802 !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
2803 );
2804 });
2805 }
2806
2807 #[test]
2808 fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
2809 let dir = tempfile::tempdir().unwrap();
2810 let file = dir.path().join("SYSTEM.md");
2811 let mut sources = Vec::new();
2812 assert!(read_prompt_source(&file, &mut sources).is_none());
2813 std::fs::write(&file, [0xff, 0xfe]).unwrap();
2814 assert!(read_prompt_source(&file, &mut sources).is_none());
2815 assert!(read_append_target("literal instructions", &mut sources).is_none());
2816 assert!(sources.is_empty());
2817 std::fs::write(&file, "loaded instructions").unwrap();
2818 assert_eq!(
2819 read_prompt_source(&file, &mut sources).as_deref(),
2820 Some("loaded instructions")
2821 );
2822 assert!(read_prompt_source(&file, &mut sources).is_some());
2823 assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
2824 }
2825
2826 extern "C" fn deny_test_tool_call(
2827 _event: rpi_plugin_sdk::StablePluginEvent,
2828 _data: *mut std::ffi::c_void,
2829 ) -> i32 {
2830 rpi_plugin_sdk::EVENT_HANDLER_ABORT
2831 }
2832
2833 async fn invoke_test_tool_hook(
2834 hook: &rpi_agent::BeforeToolCall,
2835 ) -> Option<rpi_agent::BeforeToolCallResult> {
2836 let message = rpi_ai::providers::faux::faux_assistant_message(
2837 "approval",
2838 rpi_ai::types::StopReason::ToolUse,
2839 );
2840 let rpi_ai::types::Content::ToolCall(call) =
2841 rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
2842 else {
2843 unreachable!()
2844 };
2845 hook(
2846 rpi_agent::BeforeToolCallContext {
2847 assistant_message: &message,
2848 tool_call: &call,
2849 args: &call.arguments,
2850 context: &rpi_agent::AgentContext::default(),
2851 },
2852 tokio_util::sync::CancellationToken::new(),
2853 )
2854 .await
2855 }
2856
2857 #[tokio::test]
2858 async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
2859 let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
2860 let hook =
2861 extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
2862 assert!(invoke_test_tool_hook(&hook).await.is_none());
2863 for name in ["first-gate", "reloaded-gate"] {
2864 let mut registry = rpi_extensions::ExtensionRegistry::new();
2865 registry.register_event_handler(
2866 name.into(),
2867 rpi_plugin_sdk::EventTag::ToolCall,
2868 deny_test_tool_call,
2869 std::ptr::null_mut(),
2870 );
2871 let fresh = ExtensionSession::from_parts(
2872 Arc::new(registry.snapshot()),
2873 rpi_extensions::PluginKeepalive::empty(),
2874 Vec::new(),
2875 None,
2876 );
2877 {
2878 let mut current = sessions.lock().unwrap();
2879 if let Some(snapshot) = current.snapshot() {
2880 snapshot.active_flag().store(false, Ordering::SeqCst);
2881 }
2882 *current = fresh;
2883 }
2884 let result = invoke_test_tool_hook(&hook).await.unwrap();
2885 assert!(result.block);
2886 assert!(result.reason.unwrap().contains(name));
2887 }
2888 *sessions.lock().unwrap() = ExtensionSession::none();
2889 assert!(invoke_test_tool_hook(&hook).await.is_none());
2890 }
2891
2892 fn scratch(name: &str) -> PathBuf {
2894 let nanos = std::time::SystemTime::now()
2895 .duration_since(std::time::UNIX_EPOCH)
2896 .unwrap()
2897 .as_nanos();
2898 let path = std::env::temp_dir().join(format!(
2899 "rpi-summary-{}-{nanos}-{name}.jsonl",
2900 std::process::id()
2901 ));
2902 path
2903 }
2904
2905 #[test]
2906 fn summary_marks_a_header_only_session_empty() {
2907 let path = scratch("empty");
2908 std::fs::write(
2909 &path,
2910 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2911 )
2912 .unwrap();
2913 let summary = summarize_session_file(&path);
2914 assert!(summary.empty, "a header-only file has nothing to resume");
2915 assert!(summary.preview.is_none());
2916 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
2917 std::fs::remove_file(&path).ok();
2918 }
2919
2920 #[test]
2921 fn summary_reads_the_first_user_prompt_and_name() {
2922 let path = scratch("content");
2923 let body = concat!(
2924 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2925 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
2926 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
2927 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
2928 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
2929 );
2930 std::fs::write(&path, body).unwrap();
2931 let summary = summarize_session_file(&path);
2932 assert!(!summary.empty);
2933 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
2934 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
2935 std::fs::remove_file(&path).ok();
2936 }
2937
2938 #[test]
2939 fn summary_extracts_a_block_style_user_prompt() {
2940 let path = scratch("blocks");
2941 let body = concat!(
2942 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2943 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
2944 );
2945 std::fs::write(&path, body).unwrap();
2946 let summary = summarize_session_file(&path);
2947 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
2948 assert_eq!(session_display_label(&summary), "line one line two");
2949 std::fs::remove_file(&path).ok();
2950 }
2951
2952 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
2953 rpi_harness::session::jsonl::JsonlSessionMetadata {
2954 id: id.to_string(),
2955 created_at: 0,
2956 cwd: ".".to_string(),
2957 path: path.to_string_lossy().into_owned(),
2958 modified_at: 0,
2959 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
2960 parent_session_id: None,
2961 legacy_parent_session_path: None,
2962 metadata: None,
2963 }
2964 }
2965
2966 #[test]
2967 fn continue_skips_newer_empty_sessions() {
2968 let empty = scratch("newest-empty");
2969 std::fs::write(
2970 &empty,
2971 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2972 )
2973 .unwrap();
2974 let content = scratch("older-content");
2975 std::fs::write(
2976 &content,
2977 concat!(
2978 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2979 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
2980 ),
2981 )
2982 .unwrap();
2983
2984 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
2985 let picked = first_resumable_session(&metas).expect("a resumable session");
2986 assert_eq!(picked.id, "old");
2987
2988 let all_empty = vec![meta_for(&empty, "empty")];
2991 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
2992
2993 std::fs::remove_file(&empty).ok();
2994 std::fs::remove_file(&content).ok();
2995 }
2996
2997 #[test]
2998 fn list_session_files_sync_walks_the_cwd_subdirectory() {
2999 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
3000 let nested = root.join("--proj--");
3001 std::fs::create_dir_all(&nested).unwrap();
3002 let file = nested.join("2026-01-01_a.jsonl");
3003 std::fs::write(&file, "{}\n").unwrap();
3004 let found = list_session_files_sync(&root);
3005 assert!(found.iter().any(|p| p == &file));
3006 std::fs::remove_dir_all(&root).ok();
3007 }
3008
3009 #[test]
3010 fn default_prompt_mentions_cwd_and_tools() {
3011 let p = default_system_prompt("/tmp/proj", false);
3012 assert!(p.contains("/tmp/proj"));
3013 assert!(p.contains("read"));
3014 assert!(p.contains("bash"));
3015 assert!(p.contains("edit"));
3016 assert!(p.contains("write"));
3017 assert!(p.contains("docs"));
3018 assert!(!p.contains("- grep"));
3019 assert!(!p.contains("- find"));
3020 assert!(!p.contains("- ls"));
3021 assert!(!p.contains("- powershell"));
3022 assert!(p.contains("Bash/POSIX syntax, including on Windows"));
3023 assert!(p.contains("do not pass PowerShell cmdlets"));
3024 }
3025
3026 #[test]
3027 fn default_prompt_instructs_language_matching() {
3028 let p = default_system_prompt("/tmp/proj", false);
3032 let lower = p.to_lowercase();
3033 assert!(
3034 lower.contains("same language") && lower.contains("detect"),
3035 "the prompt must tell the model to detect and mirror the user's language: {p}"
3036 );
3037 }
3038
3039 #[test]
3040 fn default_prompt_keeps_working_state_in_the_visible_channel() {
3041 let p = default_system_prompt("/tmp/proj", false);
3049 let lower = p.to_lowercase();
3050 assert!(
3052 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
3053 "the prompt must explain that reasoning does not survive the turn: {p}"
3054 );
3055 assert!(
3057 lower.contains("visible") && lower.contains("todo tool"),
3058 "the prompt must point at visible text and the todo tool: {p}"
3059 );
3060 assert!(
3062 lower.contains("re-stating the same plan"),
3063 "the prompt must call out plan re-statement as a bug: {p}"
3064 );
3065 assert!(
3067 lower.contains("already shown earlier"),
3068 "the prompt must discourage re-fetching known content: {p}"
3069 );
3070 }
3071
3072 #[test]
3073 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
3074 let p = default_system_prompt("/tmp/proj", true);
3081 let lower = p.to_lowercase();
3082 assert!(
3083 lower.contains("carried back"),
3084 "a replaying model must be told its reasoning survives: {p}"
3085 );
3086 assert!(
3087 !lower.contains("reasoning is not"),
3088 "the replayed variant must not claim reasoning is dropped: {p}"
3089 );
3090 assert!(
3091 lower.contains("do not re-derive"),
3092 "the replayed variant must forbid re-deriving the plan: {p}"
3093 );
3094 assert!(
3095 lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
3096 "the anti-repetition rule must survive the flip: {p}"
3097 );
3098 assert!(
3099 lower.contains("already shown earlier"),
3100 "the re-read rule must survive the flip: {p}"
3101 );
3102 }
3103
3104 #[test]
3112 fn only_read_only_tools_are_replayable() {
3113 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3114 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3115 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3116 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3117 let tools = build_tools(&context, &Args::default());
3118
3119 let replay_of = |name: &str| {
3120 tools
3121 .iter()
3122 .find(|tool| tool.tool.schema().name == name)
3123 .map(|tool| tool.replay)
3124 };
3125
3126 for read_only in ["read", "docs"] {
3127 assert_eq!(
3128 replay_of(read_only),
3129 Some(ToolReplay::Safe),
3130 "{read_only} is side-effect free, so recovery may re-run it"
3131 );
3132 }
3133 for mutating in ["bash", "edit", "write", "powershell"] {
3134 if let Some(replay) = replay_of(mutating) {
3135 assert_eq!(
3136 replay,
3137 ToolReplay::Never,
3138 "{mutating} must never be replayed: re-running it would duplicate its effect"
3139 );
3140 }
3141 }
3142 }
3143
3144 #[test]
3145 fn default_tools_include_docs_lookup() {
3146 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3147 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3148 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3149 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3150 let names: Vec<String> = build_tools(&context, &Args::default())
3151 .iter()
3152 .map(|tool| tool.tool.schema().name.clone())
3153 .collect();
3154
3155 assert!(names.contains(&"docs".to_string()));
3160 for legacy in ["grep", "find", "ls"] {
3161 assert!(
3162 !names.contains(&legacy.to_string()),
3163 "legacy tool {legacy} should not be registered by default"
3164 );
3165 }
3166 }
3167
3168 #[test]
3169 fn reload_settings_preflight_respects_trust() {
3170 struct RestoreConfigDir(Option<std::ffi::OsString>);
3171
3172 impl Drop for RestoreConfigDir {
3173 fn drop(&mut self) {
3174 match self.0.take() {
3175 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3176 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3177 }
3178 }
3179 }
3180
3181 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3182 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3183 let tmp = tempfile::tempdir().unwrap();
3184 let agent = tmp.path().join("agent");
3185 let cwd = tmp.path().join("project");
3186 std::fs::create_dir_all(&agent).unwrap();
3187 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3188 std::fs::write(agent.join("settings.json"), "{}").unwrap();
3189 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
3190 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3191 let args = Args::default();
3192
3193 let trusted = validate_settings_for_reload(&args, &cwd, true);
3194 let untrusted = validate_settings_for_reload(&args, &cwd, false);
3195 assert!(trusted
3196 .unwrap_err()
3197 .contains("could not load project settings"));
3198 assert!(untrusted.is_ok());
3199
3200 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3201 assert!(validate_settings_for_reload(&args, &cwd, false)
3202 .unwrap_err()
3203 .contains("could not load global settings"));
3204
3205 let local_only = Args {
3206 dev_local_only: true,
3207 ..Args::default()
3208 };
3209 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
3210 }
3211
3212 #[tokio::test(flavor = "current_thread")]
3213 async fn reload_preserves_live_resources_when_settings_break() {
3214 struct RestoreConfigDir(Option<std::ffi::OsString>);
3215
3216 impl Drop for RestoreConfigDir {
3217 fn drop(&mut self) {
3218 match self.0.take() {
3219 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3220 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3221 }
3222 }
3223 }
3224
3225 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3226 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3227 let tmp = tempfile::tempdir().unwrap();
3228 let agent = tmp.path().join("agent");
3229 let cwd = tmp.path().join("project");
3230 let skill_dir = tmp.path().join("configured-skills");
3231 std::fs::create_dir_all(&agent).unwrap();
3232 std::fs::create_dir_all(&cwd).unwrap();
3233 std::fs::create_dir_all(&skill_dir).unwrap();
3234 std::fs::write(
3235 skill_dir.join("SKILL.md"),
3236 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
3237 )
3238 .unwrap();
3239 std::fs::write(
3240 agent.join("settings.json"),
3241 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3242 )
3243 .unwrap();
3244 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3245
3246 let resolved = crate::provider::resolve(
3247 Some("anthropic"),
3248 Some(crate::provider::DEFAULT_MODEL_ID),
3249 None,
3250 Some("test-key"),
3251 None,
3252 )
3253 .unwrap();
3254 let args = Args {
3255 trust_override: Some(false),
3256 no_session: true,
3257 no_extensions: true,
3258 no_prompt_templates: true,
3259 no_context_files: true,
3260 system_prompt: Some("stable system prompt".into()),
3261 ..Args::default()
3262 };
3263 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3264 let before_resources = harness.get_resources().await.unwrap();
3265 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
3266 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
3267 let before_prompt = harness.get_system_prompt().await.unwrap();
3268 let before_tools: Vec<String> = harness
3269 .get_tools()
3270 .await
3271 .unwrap()
3272 .iter()
3273 .map(|tool| tool.tool.schema().name.clone())
3274 .collect();
3275 let before_bridge = context
3276 .action_bridge
3277 .lock()
3278 .unwrap()
3279 .as_ref()
3280 .unwrap()
3281 .clone();
3282
3283 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3284 let outcome = reload_extension_resources(&harness, &context).await;
3285
3286 assert!(outcome.had_warnings);
3287 assert!(outcome.summary.contains("Settings reload failed"));
3288 assert!(outcome.summary.contains("could not load global settings"));
3289 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
3290 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
3291 let after_tools: Vec<String> = harness
3292 .get_tools()
3293 .await
3294 .unwrap()
3295 .iter()
3296 .map(|tool| tool.tool.schema().name.clone())
3297 .collect();
3298 assert_eq!(after_tools, before_tools);
3299 let after_bridge = context
3300 .action_bridge
3301 .lock()
3302 .unwrap()
3303 .as_ref()
3304 .unwrap()
3305 .clone();
3306 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
3307 }
3308
3309 #[test]
3310 fn reload_preparation_rejects_settings_changed_during_derivation() {
3311 struct RestoreConfigDir(Option<std::ffi::OsString>);
3312
3313 impl Drop for RestoreConfigDir {
3314 fn drop(&mut self) {
3315 match self.0.take() {
3316 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3317 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3318 }
3319 }
3320 }
3321
3322 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3323 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3324 let tmp = tempfile::tempdir().unwrap();
3325 let agent = tmp.path().join("agent");
3326 let cwd = tmp.path().join("project");
3327 let first_skill_dir = tmp.path().join("first-skills");
3328 let second_skill_dir = tmp.path().join("second-skills");
3329 std::fs::create_dir_all(&agent).unwrap();
3330 std::fs::create_dir_all(&cwd).unwrap();
3331 std::fs::write(
3332 agent.join("settings.json"),
3333 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3334 )
3335 .unwrap();
3336 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3337 let args = Args {
3338 trust_override: Some(false),
3339 no_extensions: true,
3340 no_prompt_templates: true,
3341 ..Args::default()
3342 };
3343
3344 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3345 std::fs::write(
3346 agent.join("settings.json"),
3347 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3348 )
3349 .unwrap();
3350 });
3351
3352 let error = result
3353 .err()
3354 .expect("settings mutation must fail preparation");
3355 assert!(error.contains("settings changed while reload inputs were being prepared"));
3356 }
3357
3358 #[tokio::test(flavor = "current_thread")]
3359 async fn reload_uses_frozen_settings_inputs_after_preparation() {
3360 struct RestoreConfigDir(Option<std::ffi::OsString>);
3361
3362 impl Drop for RestoreConfigDir {
3363 fn drop(&mut self) {
3364 match self.0.take() {
3365 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3366 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3367 }
3368 }
3369 }
3370
3371 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3372 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3373 let tmp = tempfile::tempdir().unwrap();
3374 let agent = tmp.path().join("agent");
3375 let cwd = tmp.path().join("project");
3376 let skill_dir = tmp.path().join("configured-skills");
3377 std::fs::create_dir_all(&agent).unwrap();
3378 std::fs::create_dir_all(&cwd).unwrap();
3379 std::fs::create_dir_all(&skill_dir).unwrap();
3380 std::fs::write(
3381 skill_dir.join("SKILL.md"),
3382 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3383 )
3384 .unwrap();
3385 std::fs::write(
3386 agent.join("settings.json"),
3387 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3388 )
3389 .unwrap();
3390 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3391
3392 let resolved = crate::provider::resolve(
3393 Some("anthropic"),
3394 Some(crate::provider::DEFAULT_MODEL_ID),
3395 None,
3396 Some("test-key"),
3397 None,
3398 )
3399 .unwrap();
3400 let args = Args {
3401 trust_override: Some(false),
3402 no_session: true,
3403 no_extensions: true,
3404 no_prompt_templates: true,
3405 no_context_files: true,
3406 system_prompt: Some("stable system prompt".into()),
3407 ..Args::default()
3408 };
3409 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3410
3411 let outcome = reload_extension_resources_inner(&harness, &context, || {
3412 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3413 })
3414 .await;
3415
3416 assert!(!outcome.summary.contains("Settings reload failed"));
3417 assert_eq!(
3418 outcome.summary,
3419 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3420 );
3421 let resources = harness.get_resources().await.unwrap();
3422 let skills = resources.skills.as_ref().unwrap();
3423 assert_eq!(skills.len(), 1);
3424 assert_eq!(skills[0].name, "frozen-skill");
3425 }
3426
3427 #[tokio::test]
3428 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3429 let tmp = tempfile::tempdir().unwrap();
3430 let skill_path = tmp.path().join("explicit-skill.md");
3431 std::fs::write(
3432 &skill_path,
3433 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3434 )
3435 .unwrap();
3436 let prompt_path = tmp.path().join("explicit-prompt.md");
3437 std::fs::write(
3438 &prompt_path,
3439 "---\ndescription: Explicit prompt\n---\nPrompt body",
3440 )
3441 .unwrap();
3442
3443 let args = Args {
3444 skill: vec![skill_path.clone()],
3445 prompt_template: vec![prompt_path.clone()],
3446 ..Args::default()
3447 };
3448 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3449 let prompt_paths =
3450 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3451 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3452 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3453
3454 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3455 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3456 assert_eq!(skills.skills[0].name, "explicit-skill");
3457
3458 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3459 assert_eq!(
3460 prompts.prompt_templates.len(),
3461 1,
3462 "{:?}",
3463 prompts.diagnostics
3464 );
3465 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3466 }
3467
3468 #[test]
3469 fn tool_policy_applies_to_rust_and_js_active_names() {
3470 let names = vec![
3471 "read".to_string(),
3472 "ask_user_question".to_string(),
3473 "write".to_string(),
3474 ];
3475
3476 let args = Args {
3477 tools: Some(vec!["read".into(), "ask_user_question".into()]),
3478 ..Args::default()
3479 };
3480 assert_eq!(
3481 filter_active_tool_names(names.clone(), &args),
3482 vec!["read", "ask_user_question"]
3483 );
3484
3485 let args = Args {
3486 exclude_tools: Some(vec!["ask_user_question".into()]),
3487 ..Args::default()
3488 };
3489 assert_eq!(
3490 filter_active_tool_names(names.clone(), &args),
3491 vec!["read", "write"]
3492 );
3493
3494 let args = Args {
3495 no_tools: true,
3496 ..Args::default()
3497 };
3498 assert!(filter_active_tool_names(names, &args).is_empty());
3499 }
3500
3501 #[test]
3502 fn select_ephemeral_when_no_session() {
3503 let args = Args {
3504 no_session: true,
3505 ..Args::default()
3506 };
3507 let cwd = Path::new("/tmp");
3508 assert!(matches!(
3509 select_session(&args, cwd),
3510 SessionSelection::Ephemeral
3511 ));
3512 }
3513
3514 #[test]
3515 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3516 let defaults = Args::default();
3517 assert!(resolve_project_trust(
3518 &defaults,
3519 Path::new("C:/definitely-not-a-project")
3520 ));
3521
3522 let denied = Args {
3523 trust_override: Some(false),
3524 ..Args::default()
3525 };
3526 assert!(!resolve_project_trust(
3527 &denied,
3528 Path::new("C:/definitely-not-a-project")
3529 ));
3530 }
3531
3532 #[tokio::test(flavor = "current_thread")]
3533 async fn build_preserves_the_supplied_startup_project_snapshot() {
3534 struct RestoreConfigDir(Option<std::ffi::OsString>);
3535
3536 impl Drop for RestoreConfigDir {
3537 fn drop(&mut self) {
3538 match self.0.take() {
3539 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3540 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3541 }
3542 }
3543 }
3544
3545 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3546 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3547 let tmp = tempfile::tempdir().unwrap();
3548 let agent = tmp.path().join("agent");
3549 let cwd = tmp.path().join("project");
3550 std::fs::create_dir_all(&agent).unwrap();
3551 std::fs::create_dir_all(&cwd).unwrap();
3552 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3553
3554 let resolved = crate::provider::resolve(
3555 Some("anthropic"),
3556 Some(crate::provider::DEFAULT_MODEL_ID),
3557 None,
3558 Some("test-key"),
3559 None,
3560 )
3561 .unwrap();
3562 let args = Args {
3563 trust_override: Some(false),
3564 no_session: true,
3565 no_tools: true,
3566 no_extensions: true,
3567 no_skills: true,
3568 no_prompt_templates: true,
3569 no_context_files: true,
3570 system_prompt: Some("test prompt".into()),
3571 ..Args::default()
3572 };
3573
3574 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3575
3576 assert_eq!(context.cwd, cwd);
3577 assert!(context.project_trusted);
3578 }
3579
3580 #[tokio::test(flavor = "current_thread")]
3581 async fn build_applies_the_name_flag_to_the_session() {
3582 struct RestoreConfigDir(Option<std::ffi::OsString>);
3583
3584 impl Drop for RestoreConfigDir {
3585 fn drop(&mut self) {
3586 match self.0.take() {
3587 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3588 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3589 }
3590 }
3591 }
3592
3593 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3594 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3595 let tmp = tempfile::tempdir().unwrap();
3596 let agent = tmp.path().join("agent");
3597 let cwd = tmp.path().join("project");
3598 std::fs::create_dir_all(&agent).unwrap();
3599 std::fs::create_dir_all(&cwd).unwrap();
3600 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3601
3602 let resolved = crate::provider::resolve(
3603 Some("anthropic"),
3604 Some(crate::provider::DEFAULT_MODEL_ID),
3605 None,
3606 Some("test-key"),
3607 None,
3608 )
3609 .unwrap();
3610 let args = Args {
3611 trust_override: Some(false),
3612 no_tools: true,
3613 no_extensions: true,
3614 no_skills: true,
3615 no_prompt_templates: true,
3616 no_context_files: true,
3617 system_prompt: Some("test prompt".into()),
3618 name: Some("smoke name".into()),
3619 session_dir: Some(cwd.join("sessions")),
3620 ..Args::default()
3621 };
3622
3623 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3624 assert_eq!(
3625 harness.get_name().await.unwrap().as_deref(),
3626 Some("smoke name")
3627 );
3628 }
3629
3630 #[test]
3631 fn select_latest_for_continue_and_resume() {
3632 let args = Args {
3633 continue_session: true,
3634 ..Args::default()
3635 };
3636 let cwd = Path::new("/tmp");
3637 assert!(matches!(
3638 select_session(&args, cwd),
3639 SessionSelection::Latest { .. }
3640 ));
3641
3642 let args = Args {
3643 resume: true,
3644 ..Args::default()
3645 };
3646 assert!(matches!(
3647 select_session(&args, cwd),
3648 SessionSelection::Latest { .. }
3649 ));
3650 }
3651
3652 #[test]
3653 fn select_by_id_for_session_flag() {
3654 let args = Args {
3655 session: Some("01a02ece".into()),
3656 ..Args::default()
3657 };
3658 let cwd = Path::new("/tmp");
3659 assert!(matches!(
3660 select_session(&args, cwd),
3661 SessionSelection::ById { id, .. } if id == "01a02ece"
3662 ));
3663 }
3664
3665 #[test]
3666 fn select_new_with_custom_dir() {
3667 let args = Args {
3668 session_dir: Some(PathBuf::from("/tmp/sess")),
3669 ..Args::default()
3670 };
3671 let cwd = Path::new("/tmp");
3672 match select_session(&args, cwd) {
3673 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
3674 other => panic!("expected New, got {other:?}"),
3675 }
3676 }
3677
3678 #[test]
3679 fn select_new_default_dir() {
3680 let args = Args::default();
3681 let cwd = Path::new("/proj");
3682 match select_session(&args, cwd) {
3683 SessionSelection::New { dir, .. } => {
3684 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
3685 }
3686 other => panic!("expected New, got {other:?}"),
3687 }
3688 }
3689
3690 #[test]
3691 fn default_session_dir_only_uses_rpi() {
3692 let tmp = tempfile::tempdir().unwrap();
3693 let cwd = tmp.path();
3694 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3695 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3696 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3697 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3698 }
3699
3700 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3701 async fn ephemeral_session_builds_roundtrips() {
3702 let s = ephemeral_session();
3706 let leaf = s.get_leaf_id().await;
3707 assert!(leaf.is_ok());
3708 }
3709
3710 #[test]
3728 fn host_private_env_matches_the_declared_contract() {
3729 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
3731 assert_eq!(
3732 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
3733 [
3734 "LANGFUSE_PI_PARENT_TRACE_ID",
3735 "LANGFUSE_PI_PARENT_SPAN_ID",
3736 "LANGFUSE_PI_PARENT_SESSION_ID",
3737 "LANGFUSE_PI_PARENT_DEPTH",
3738 ],
3739 "these strings are what rpi-langfuse (a different repo) matches; a \
3740 change here is a breaking cross-repo change, not a refactor"
3741 );
3742
3743 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
3746 .iter()
3747 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
3748 {
3749 assert!(
3750 rpi_tools::is_host_private_env(name),
3751 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
3752 rpi_tools::HOST_PRIVATE_ENV
3753 );
3754 }
3755
3756 for visible in [
3759 crate::args::RPI_OFFLINE_ENV,
3760 crate::config::CONFIG_DIR_ENV,
3761 "PATH",
3762 ] {
3763 assert!(
3764 !rpi_tools::is_host_private_env(visible),
3765 "{visible} describes the user's configuration and must still reach the shell"
3766 );
3767 }
3768 }
3769 #[tokio::test]
3770 async fn reload_shutdown_joins_all_handlers_even_after_veto() {
3771 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
3772 use std::sync::atomic::AtomicUsize;
3773 extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
3774 let calls = unsafe { &*(data as *const AtomicUsize) };
3775 std::thread::sleep(std::time::Duration::from_millis(20));
3776 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
3777 rpi_plugin_sdk::EVENT_HANDLER_ABORT
3778 } else {
3779 0
3780 }
3781 }
3782 let calls = AtomicUsize::new(0);
3783 let mut registry = rpi_extensions::ExtensionRegistry::new();
3784 for name in ["first", "second"] {
3785 registry.register_event_handler(
3786 name.into(),
3787 EventTag::SessionShutdown,
3788 stop,
3789 &calls as *const _ as *mut std::ffi::c_void,
3790 );
3791 }
3792 super::shutdown_extensions_for_reload(
3793 Some(Arc::new(registry.snapshot())),
3794 rpi_extensions::PluginKeepalive::empty(),
3795 )
3796 .await
3797 .unwrap();
3798 assert_eq!(calls.load(Ordering::SeqCst), 2);
3799 }
3800}