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 base_prompt = match args.system_prompt.as_deref() {
363 Some(explicit) => explicit.to_string(),
364 None if project_trusted => match discover_system_prompt_file(cwd) {
365 Some(path) => std::fs::read_to_string(&path)
366 .unwrap_or_else(|_| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
367 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
368 },
369 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
370 };
371
372 let mut append_texts: Vec<String> = Vec::new();
379 for extra in &args.append_system_prompt {
380 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
381 append_texts.push(text);
382 }
383 if args.append_system_prompt.is_empty() {
384 if let Some(path) = project_trusted
385 .then(|| discover_append_system_prompt_file(cwd))
386 .flatten()
387 {
388 if let Ok(text) = std::fs::read_to_string(&path) {
389 append_texts.push(text);
390 }
391 }
392 }
393 let append_join = if append_texts.is_empty() {
394 None
395 } else {
396 Some(append_texts.join("\n\n"))
397 };
398
399 let agent_dir = crate::config::agent_dir().ok();
411
412 let discovered = extension_session
428 .snapshot_arc()
429 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
430 .unwrap_or_default();
431
432 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
433 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
434 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
435 if !args.no_skills {
436 let mut dirs = if args.dev_local_only || args.project_only {
437 project_skill_dirs(cwd)
438 } else if project_trusted {
439 skill_dirs(cwd)
440 } else {
441 global_skill_dirs()
442 };
443 dirs.extend(args.skill.iter().cloned());
444 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
445 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
446 skills = result.skills;
447 skill_diags = result.diagnostics;
448 resource_diags.extend(result.resource_diagnostics);
449 }
450
451 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
452 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
453 if !args.no_prompt_templates {
454 let mut dirs = if args.dev_local_only || args.project_only {
455 project_prompt_template_dirs(cwd)
456 } else if project_trusted {
457 prompt_template_dirs(cwd)
458 } else {
459 global_prompt_template_dirs()
460 };
461 dirs.extend(args.prompt_template.iter().cloned());
462 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
463 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
464 prompt_templates = result.prompt_templates;
465 prompt_diags = result.diagnostics;
466 resource_diags.extend(result.resource_diagnostics);
467 }
468
469 let context_block = if args.no_context_files || !project_trusted {
470 String::new()
471 } else {
472 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
477 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
478 format_project_context(&files)
479 };
480
481 if args.verbose {
483 for d in &skill_diags {
484 eprintln!(
485 "warning: skill {} ({}): {}",
486 d.path,
487 d.code.as_str(),
488 d.message
489 );
490 }
491 for d in &prompt_diags {
492 eprintln!(
493 "warning: prompt template {} ({}): {}",
494 d.path,
495 d.code.as_str(),
496 d.message
497 );
498 }
499 for d in &resource_diags {
501 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
502 eprintln!(
503 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
504 c.resource_type.as_str(),
505 c.name,
506 c.winner_path,
507 c.loser_path
508 );
509 }
510 }
511 }
512
513 let system_prompt = compose_system_prompt(
522 Some(&base_prompt),
523 &[], if context_block.is_empty() {
525 None
526 } else {
527 Some(&context_block)
528 },
529 append_join.as_deref(),
530 );
531
532 if args.debug_system_prompt {
541 eprintln!("=== --debug-system-prompt ===");
542 let base_src = if args.system_prompt.is_some() {
543 "--system-prompt"
544 } else if discover_system_prompt_file(cwd).is_some() {
545 "SYSTEM.md"
546 } else {
547 "default"
548 };
549 eprintln!("[base source: {base_src}]");
550 eprintln!("--- base ---\n{base_prompt}");
551 if let Some(append) = append_join.as_deref() {
552 eprintln!("--- append ---\n{append}");
553 } else {
554 eprintln!("--- append: (none) ---");
555 }
556 if context_block.is_empty() {
557 eprintln!("--- context: (none) ---");
558 } else {
559 eprintln!("--- context ---{context_block}");
560 }
561 let visible_skills = skills
562 .iter()
563 .filter(|s| s.disable_model_invocation != Some(true))
564 .count();
565 eprintln!(
566 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
567 skills.len(),
568 visible_skills,
569 skills.len() - visible_skills
570 );
571 for s in &skills {
572 let hidden = if s.disable_model_invocation == Some(true) {
573 " [hidden]"
574 } else {
575 ""
576 };
577 eprintln!(" {}{hidden} — {}", s.name, s.description);
578 }
579 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
580 for t in &prompt_templates {
581 eprintln!(" /{}", t.name);
582 }
583 eprintln!(
587 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
588 discovered.skill_paths.len(),
589 discovered.prompt_paths.len(),
590 discovered.theme_paths.len(),
591 );
592 for p in &discovered.skill_paths {
593 eprintln!(" skill: {p}");
594 }
595 for p in &discovered.prompt_paths {
596 eprintln!(" prompt: {p}");
597 }
598 eprintln!(
599 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
600 );
601 eprintln!("=== end --debug-system-prompt ===");
602 }
603
604 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
609 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
610 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
614
615 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
624 Some(snapshot) => {
625 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
626 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
627 }
628 None => broadcast_emitter,
629 };
630
631 let saved_settings = crate::settings::load_settings().unwrap_or_default();
635
636 if let Some(proxy) = &saved_settings.http_proxy {
641 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
642 if std::env::var_os(key).is_none() {
643 std::env::set_var(key, proxy);
644 }
645 }
646 }
647
648 let extension_session_cell = Arc::new(Mutex::new(extension_session.clone()));
652 let before_tool_call = Some(extension_tool_call_hook(
653 Arc::clone(&extension_session_cell),
654 ui_dialog_mailbox.clone(),
655 ));
656
657 let options = AgentHarnessOptions {
658 model: resolved.model.clone(),
659 thinking_level: resolved.thinking_level,
660 active_tool_names: active,
661 tools,
662 system_prompt: Some(system_prompt),
663 resources: AgentHarnessResources {
664 skills: if skills.is_empty() {
665 None
666 } else {
667 Some(skills)
668 },
669 prompt_templates: if prompt_templates.is_empty() {
670 None
671 } else {
672 Some(prompt_templates)
673 },
674 },
675 allow_existing_session: matches!(
678 selection,
679 SessionSelection::Latest { .. }
680 | SessionSelection::ById { .. }
681 | SessionSelection::ByExactId { .. }
682 | SessionSelection::Fork { .. }
683 ),
684 stream_options: AgentHarnessStreamOptions {
685 timeout: args.timeout,
686 ..Default::default()
687 },
688 retry: saved_settings.retry_policy(),
693 compaction: saved_settings.compaction_settings(),
694 steering_mode: saved_settings.steering_mode(),
695 follow_up_mode: saved_settings.follow_up_mode(),
696 tool_execution: HarnessToolExecution::default(),
697 drive: DrivingMode::default(),
698 session,
699 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
707 to_provider_messages: None,
708 entry_projectors: Default::default(),
709 agent_emitter: Some(emitter),
710 before_tool_call,
715 after_tool_call: None,
716 transform_context: None,
717 entry_transforms: Vec::new(),
718 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
724 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
725 };
726
727 let harness = match AgentHarness::create(options).await {
728 Ok(h) => {
729 crate::extensions_actions::HarnessActionHost::set_harness(
732 &harness_cell,
733 Arc::new(h.clone()),
734 );
735 h
736 }
737 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
738 };
739
740 {
748 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
749 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
750 Ok(reports) => {
751 for (lane, report) in reports {
752 if report.is_clean() {
753 continue;
754 }
755 if report.is_corrupt() {
756 eprintln!(
757 "warning: session lane '{lane}' is corrupt ({} open operations); \
758 refusing to auto-repair — inspect the session file",
759 report.open_operations.len()
760 );
761 continue;
762 }
763 for decision in runtime.reconcile(&report) {
764 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
765 if args.verbose {
766 eprintln!(
767 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
768 );
769 }
770 }
771 }
772 runtime.emit_recovery_events(harness.events(), &report);
773 }
774 }
775 Err(e) => {
776 if args.verbose {
777 eprintln!("warning: session recovery sweep failed: {e}");
778 }
779 }
780 }
781 }
782
783 let reload_context = ReloadContext {
790 extension_session: extension_session_cell,
791 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
792 tool_context,
793 catalog,
794 providers: resolved.providers.clone(),
795 runtime: runtime.clone(),
796 cwd: cwd.to_path_buf(),
797 project_trusted,
798 args: args.clone(),
799 resolved_model: resolved.model.clone(),
800 broadcast: broadcast_for_context,
801 mailbox: reload_mailbox,
802 ui_dialog: ui_dialog_mailbox,
803 ext_status: ext_status_mailbox,
804 editor_text: editor_text_mailbox,
805 dev_extension: None,
806 };
807
808 Ok((harness, event_rx, reload_context))
809}
810
811fn extension_tool_call_hook(
814 sessions: ExtensionSessionCell,
815 ui_dialog: rpi_extensions::UiDialogMailbox,
816) -> rpi_agent::BeforeToolCall {
817 Arc::new(move |ctx: rpi_agent::BeforeToolCallContext<'_>, signal| {
818 let session = sessions
819 .lock()
820 .map(|session| session.clone())
821 .map_err(|_| ());
822 let ui_dialog = ui_dialog.clone();
823 let tool_call_id = ctx.tool_call.id.clone();
824 let tool_name = ctx.tool_call.name.clone();
825 let params = ctx.args.clone();
826 Box::pin(async move {
827 let reason = match session {
828 Ok(session) => {
829 rpi_extensions::dispatch_tool_call_event(
830 &session,
831 &tool_call_id,
832 &tool_name,
833 ¶ms,
834 &signal,
835 &ui_dialog,
836 )
837 .await
838 }
839 Err(_) => Some("extension approval session is unavailable".to_string()),
840 };
841 reason.map(|reason| rpi_agent::BeforeToolCallResult {
842 block: true,
843 reason: Some(reason),
844 terminate: false,
845 args: None,
846 })
847 })
848 })
849}
850
851pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
896
897pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
902
903#[derive(Clone)]
911pub struct ReloadContext {
912 pub extension_session: ExtensionSessionCell,
914 pub tool_context: rpi_extensions::ToolCallContext,
919 pub action_bridge: ActionBridgeCell,
921 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
924 pub editor_text: rpi_extensions::EditorTextMailbox,
931 pub catalog: Vec<rpi_ai::Model>,
936 pub providers: Vec<Arc<dyn Provider>>,
939 pub runtime: tokio::runtime::Handle,
943 pub cwd: PathBuf,
945 pub project_trusted: bool,
949 pub args: Args,
955 pub resolved_model: rpi_ai::Model,
958 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
964 pub mailbox: rpi_extensions::ReloadMailbox,
971 pub ui_dialog: rpi_extensions::UiDialogMailbox,
977 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
981}
982
983pub async fn dispatch_session_event_async(
998 reload_context: &ReloadContext,
999 tag: rpi_plugin_sdk::EventTag,
1000) -> Option<String> {
1001 let (snapshot, keepalive) = {
1007 let session = reload_context.extension_session.lock().unwrap();
1008 (session.snapshot_arc(), session.keepalive())
1009 };
1010 match snapshot {
1011 Some(snapshot) => {
1012 let _keepalive = keepalive;
1013 rpi_extensions::dispatch_lifecycle_event(&snapshot, tag).await
1014 }
1015 None => None,
1016 }
1017}
1018
1019async fn shutdown_extensions_for_reload(
1022 snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
1023 keepalive: Arc<rpi_extensions::PluginKeepalive>,
1024) -> Result<(), String> {
1025 if let Some(snapshot) = snapshot {
1026 tokio::task::spawn_blocking(move || {
1027 let _keepalive = keepalive;
1028 rpi_extensions::dispatch_empty_event(
1029 &snapshot,
1030 rpi_plugin_sdk::EventTag::SessionShutdown,
1031 );
1032 })
1033 .await
1034 .map_err(|error| error.to_string())?;
1035 }
1036 Ok(())
1037}
1038
1039pub struct ReloadOutcome {
1042 pub summary: String,
1045 pub had_warnings: bool,
1047 pub details: Vec<String>,
1052 pub had_errors: bool,
1056}
1057
1058struct PreparedReloadInputs {
1059 extension_dirs: Vec<PathBuf>,
1060 skill_base_dirs: Vec<PathBuf>,
1061 prompt_base_dirs: Vec<PathBuf>,
1062}
1063
1064fn append_reload_resource_paths(
1069 mut paths: Vec<PathBuf>,
1070 explicit: &[PathBuf],
1071 discovered: &[String],
1072 js_paths: &[PathBuf],
1073 package_paths: &[PathBuf],
1074) -> Vec<PathBuf> {
1075 paths.extend(explicit.iter().cloned());
1076 paths.extend(discovered.iter().map(PathBuf::from));
1077 paths.extend(js_paths.iter().cloned());
1078 paths.extend(package_paths.iter().cloned());
1079 paths
1080}
1081
1082pub async fn reload_extension_resources(
1094 harness: &AgentHarness,
1095 ctx: &ReloadContext,
1096) -> ReloadOutcome {
1097 reload_extension_resources_inner(harness, ctx, || {}).await
1098}
1099
1100async fn reload_extension_resources_inner<F>(
1101 harness: &AgentHarness,
1102 ctx: &ReloadContext,
1103 after_prepare: F,
1104) -> ReloadOutcome
1105where
1106 F: FnOnce() + Send,
1107{
1108 let mut effective_args = ctx.args.clone();
1113 if let Some(dev) = &ctx.dev_extension {
1114 if let Err(error) = dev
1115 .rebuild()
1116 .and_then(|_| dev.apply_to_args(&mut effective_args))
1117 {
1118 return ReloadOutcome {
1119 summary: format!(
1120 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1121 dev.package_name()
1122 ),
1123 had_warnings: true,
1124 details: Vec::new(),
1125 had_errors: true,
1126 };
1127 }
1128 }
1129 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1130 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1131 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1132 Ok(prepared) => prepared,
1133 Err(error) => {
1134 return ReloadOutcome {
1135 summary: format!(
1136 "Settings reload failed: {error}. Keeping the currently loaded resources."
1137 ),
1138 had_warnings: true,
1139 details: Vec::new(),
1140 had_errors: true,
1141 };
1142 }
1143 };
1144 after_prepare();
1145 let PreparedReloadInputs {
1146 extension_dirs,
1147 skill_base_dirs,
1148 prompt_base_dirs,
1149 } = prepared;
1150 let mut warnings = false;
1151
1152 let (shutdown_snapshot, shutdown_keepalive) = {
1156 let session = ctx.extension_session.lock().unwrap();
1157 (session.snapshot_arc(), session.keepalive())
1158 };
1159 if let Err(reason) = shutdown_extensions_for_reload(shutdown_snapshot, shutdown_keepalive).await
1160 {
1161 return ReloadOutcome {
1162 summary: "Reload failed during extension shutdown".into(),
1163 had_warnings: true,
1164 details: vec![reason],
1165 had_errors: true,
1166 };
1167 }
1168
1169 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1176 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1177 Some(b) => b.clone_host(),
1178 None => {
1179 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1182 ctx.catalog.clone(),
1183 ctx.cwd.clone(),
1184 ctx.runtime.clone(),
1185 ctx.args.unknown_flags.clone(),
1186 ctx.tool_context.clone(),
1187 );
1188 crate::extensions_actions::HarnessActionHost::set_harness(
1189 &_cell,
1190 Arc::new(harness.clone()),
1191 );
1192 Arc::new(action_host)
1193 }
1194 };
1195
1196 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1197 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1198 ctx.runtime.clone(),
1199 host,
1200 reload_cb,
1201 ctx.ui_dialog.clone(),
1202 ctx.ext_status.clone(),
1203 ctx.editor_text.clone(),
1204 );
1205
1206 let extension_session = if effective_args.no_extensions {
1207 rpi_extensions::ExtensionSession::none()
1208 } else {
1209 load_extensions_from_dirs(
1210 &effective_args,
1211 &extension_dirs,
1212 Some(Arc::clone(&fresh_bridge)),
1213 )
1214 };
1215 if extension_session.is_empty() && !effective_args.no_extensions {
1216 }
1219 if effective_args.verbose {
1220 if let Some(s) = extension_session.summary() {
1221 eprintln!("reload: {s}");
1222 }
1223 report_deferred_renderers(&extension_session);
1224 }
1225
1226 {
1238 let mut session_guard = ctx.extension_session.lock().unwrap();
1239 let old_session = std::mem::replace(&mut *session_guard, extension_session.clone());
1240 if let Some(old_snap) = old_session.snapshot_arc() {
1241 old_snap.active_flag().store(false, Ordering::SeqCst);
1245 }
1246 }
1249 if let Some(old_b) = old_bridge {
1250 old_b.invalidate();
1251 }
1252
1253 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1255
1256 let discovered = extension_session
1258 .snapshot_arc()
1259 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1260 .unwrap_or_default();
1261
1262 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1264 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1265
1266 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1267 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1268 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1269 if !effective_args.no_skills {
1270 let js_paths: &[PathBuf] = &[];
1274 let package_paths: &[PathBuf] = &[];
1275 let dirs = append_reload_resource_paths(
1276 skill_base_dirs,
1277 &effective_args.skill,
1278 &discovered.skill_paths,
1279 js_paths,
1280 &package_paths,
1281 );
1282 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1283 skills = result.skills;
1284 skill_diags = result.diagnostics;
1285 resource_diags.extend(result.resource_diagnostics);
1286 }
1287
1288 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1289 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1290 if !effective_args.no_prompt_templates {
1291 let js_paths: &[PathBuf] = &[];
1292 let package_paths: &[PathBuf] = &[];
1293 let dirs = append_reload_resource_paths(
1294 prompt_base_dirs,
1295 &effective_args.prompt_template,
1296 &discovered.prompt_paths,
1297 js_paths,
1298 &package_paths,
1299 );
1300 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1301 prompt_templates = result.prompt_templates;
1302 prompt_diags = result.diagnostics;
1303 resource_diags.extend(result.resource_diagnostics);
1304 }
1305
1306 let context_block = if effective_args.no_context_files {
1307 String::new()
1308 } else {
1309 let agent_dir = crate::config::agent_dir().ok();
1310 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1311 let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
1312 format_project_context(&files)
1313 };
1314
1315 let mut details: Vec<String> = Vec::new();
1316 if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
1317 warnings = true;
1318 for d in &skill_diags {
1323 details.push(format!(
1324 "warning: skill {} ({}): {}",
1325 d.path,
1326 d.code.as_str(),
1327 d.message
1328 ));
1329 }
1330 for d in &prompt_diags {
1331 details.push(format!(
1332 "warning: prompt template {} ({}): {}",
1333 d.path,
1334 d.code.as_str(),
1335 d.message
1336 ));
1337 }
1338 for d in &resource_diags {
1340 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1341 details.push(format!(
1342 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1343 c.resource_type.as_str(),
1344 c.name,
1345 c.winner_path,
1346 c.loser_path
1347 ));
1348 }
1349 }
1350 }
1351
1352 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1357 let base_prompt = match effective_args.system_prompt.as_deref() {
1358 Some(explicit) => explicit.to_string(),
1359 None => match discover_system_prompt_file(&ctx.cwd) {
1360 Some(path) => std::fs::read_to_string(&path)
1361 .unwrap_or_else(|_| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
1362 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1363 },
1364 };
1365 let mut append_texts: Vec<String> = Vec::new();
1366 for extra in &effective_args.append_system_prompt {
1367 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
1368 append_texts.push(text);
1369 }
1370 if effective_args.append_system_prompt.is_empty() {
1371 if let Some(path) = discover_append_system_prompt_file(&ctx.cwd) {
1372 if let Ok(text) = std::fs::read_to_string(&path) {
1373 append_texts.push(text);
1374 }
1375 }
1376 }
1377 let append_join = if append_texts.is_empty() {
1378 None
1379 } else {
1380 Some(append_texts.join("\n\n"))
1381 };
1382 let system_prompt = compose_system_prompt(
1383 Some(&base_prompt),
1384 &[],
1385 if context_block.is_empty() {
1386 None
1387 } else {
1388 Some(&context_block)
1389 },
1390 append_join.as_deref(),
1391 );
1392
1393 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1395 Some(snapshot) => {
1396 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1397 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1398 }
1399 None => ctx.broadcast.clone(),
1400 };
1401
1402 let resources = AgentHarnessResources {
1404 skills: if skills.is_empty() {
1405 None
1406 } else {
1407 Some(skills.clone())
1408 },
1409 prompt_templates: if prompt_templates.is_empty() {
1410 None
1411 } else {
1412 Some(prompt_templates.clone())
1413 },
1414 };
1415 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1416 let _ = harness.set_resources(resources).await;
1417 let _ = harness.set_agent_emitter(Some(emitter)).await;
1418 let _ = harness
1419 .set_models(build_models_with_extensions_for_reload(
1420 &ctx.providers,
1421 &extension_session,
1422 ctx.runtime.clone(),
1423 ))
1424 .await;
1425 let _ = harness
1426 .set_provider_hooks(
1427 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1428 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1429 )
1430 .await;
1431
1432 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1436 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1437 let mut tools = build_tools(&tool_ctx, &effective_args);
1438 merge_extension_tools(
1439 &mut tools,
1440 &extension_session,
1441 &effective_args,
1442 &ctx.tool_context,
1443 );
1444 let mut active = active_tool_names(&tools, &effective_args);
1445 active = filter_active_tool_names(active, &effective_args);
1446 let _ = harness.set_tools(tools, Some(active)).await;
1447
1448 let summary = format!(
1449 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1450 extension_session.loaded_paths().len(),
1451 skills.len(),
1452 prompt_templates.len(),
1453 );
1454 ReloadOutcome {
1455 summary,
1456 had_warnings: warnings,
1457 details,
1458 had_errors: false,
1461 }
1462}
1463
1464#[derive(Clone, Debug, PartialEq, Eq)]
1465struct ReloadSettingsFields {
1466 skill_dirs: Option<Vec<String>>,
1467 prompt_dirs: Option<Vec<String>>,
1468 extension_dirs: Option<Vec<String>>,
1469}
1470
1471impl From<crate::settings::Settings> for ReloadSettingsFields {
1472 fn from(settings: crate::settings::Settings) -> Self {
1473 Self {
1474 skill_dirs: settings.skill_dirs,
1475 prompt_dirs: settings.prompt_dirs,
1476 extension_dirs: settings.extension_dirs,
1477 }
1478 }
1479}
1480
1481#[derive(Clone, Debug, PartialEq, Eq)]
1482struct ReloadSettingsSnapshot {
1483 global: ReloadSettingsFields,
1484 project: Option<ReloadSettingsFields>,
1485}
1486
1487fn reload_reads_settings(args: &Args) -> bool {
1488 !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
1489}
1490
1491fn load_reload_settings_snapshot(
1495 args: &Args,
1496 cwd: &Path,
1497 project_trusted: bool,
1498) -> Result<Option<ReloadSettingsSnapshot>, String> {
1499 if !reload_reads_settings(args) {
1500 return Ok(None);
1501 }
1502 let global = crate::settings::load_settings()
1503 .map_err(|error| format!("could not load global settings: {error}"))?
1504 .into();
1505 let project = if project_trusted {
1506 crate::settings::load_active_project_settings(cwd)
1507 .map_err(|error| format!("could not load project settings: {error}"))?
1508 .map(|(_, settings)| settings.into())
1509 } else {
1510 None
1511 };
1512 Ok(Some(ReloadSettingsSnapshot { global, project }))
1513}
1514
1515#[cfg(test)]
1518fn validate_settings_for_reload(
1519 args: &Args,
1520 cwd: &Path,
1521 project_trusted: bool,
1522) -> Result<(), String> {
1523 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1524}
1525
1526fn prepare_reload_inputs(
1527 args: &Args,
1528 cwd: &Path,
1529 project_trusted: bool,
1530) -> Result<PreparedReloadInputs, String> {
1531 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1532}
1533
1534fn prepare_reload_inputs_inner<F>(
1535 args: &Args,
1536 cwd: &Path,
1537 project_trusted: bool,
1538 before_verify: F,
1539) -> Result<PreparedReloadInputs, String>
1540where
1541 F: FnOnce(),
1542{
1543 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1544
1545 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1546 Vec::new()
1547 } else if args.project_only {
1548 project_extension_dirs(cwd)
1549 } else if project_trusted {
1550 extension_dirs(cwd)
1551 } else {
1552 global_extension_dirs()
1553 };
1554 if !args.no_extensions {
1555 extension_dirs.extend(args.extensions_dir.iter().cloned());
1556 }
1557
1558 let skill_base_dirs = if args.no_skills {
1559 Vec::new()
1560 } else if args.dev_local_only || args.project_only {
1561 project_skill_dirs(cwd)
1562 } else if project_trusted {
1563 skill_dirs(cwd)
1564 } else {
1565 global_skill_dirs()
1566 };
1567
1568 let prompt_base_dirs = if args.no_prompt_templates {
1569 Vec::new()
1570 } else if args.dev_local_only || args.project_only {
1571 project_prompt_template_dirs(cwd)
1572 } else if project_trusted {
1573 prompt_template_dirs(cwd)
1574 } else {
1575 global_prompt_template_dirs()
1576 };
1577
1578 before_verify();
1579 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1580 if settings_before != settings_after {
1581 return Err(
1582 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1583 );
1584 }
1585
1586 Ok(PreparedReloadInputs {
1587 extension_dirs,
1588 skill_base_dirs,
1589 prompt_base_dirs,
1590 })
1591}
1592
1593fn build_models_with_extensions_for_reload(
1598 providers: &[Arc<dyn Provider>],
1599 extension_session: &ExtensionSession,
1600 runtime: tokio::runtime::Handle,
1601) -> Vec<Arc<dyn Provider>> {
1602 let mut models: Vec<Arc<dyn Provider>> = providers.to_vec();
1603 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1604 models.extend(pluggable);
1605 models
1606}
1607
1608fn report_deferred_renderers(session: &ExtensionSession) {
1612 let Some(snap) = session.snapshot_arc() else {
1613 return;
1614 };
1615 let all = snap.renderers();
1616 let markdown = all
1617 .iter()
1618 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1619 .count();
1620 let message = all
1621 .iter()
1622 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1623 .count();
1624 let entry = all
1625 .iter()
1626 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1627 .count();
1628 if markdown + message + entry == 0 {
1629 return;
1630 }
1631 eprintln!(
1632 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1633 markdown, message, entry
1634 );
1635}
1636
1637#[derive(Debug, thiserror::Error)]
1639pub enum BuildError {
1640 #[error("Could not create the session directory: {0}")]
1641 SessionDir(String),
1642 #[error("Could not open the saved session: {0}")]
1643 SessionOpen(String),
1644 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1645 SessionNotFound { requested: String, dir: String },
1646 #[error("Could not build the harness: {0}")]
1647 HarnessCreate(String),
1648}
1649
1650fn build_models_with_extensions(
1660 resolved: &ResolvedModel,
1661 extension_session: &ExtensionSession,
1662 runtime: tokio::runtime::Handle,
1663) -> Vec<Arc<dyn Provider>> {
1664 let mut models: Vec<Arc<dyn Provider>> = resolved.providers.clone();
1665 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1666 models.extend(pluggable);
1667 models
1668}
1669
1670pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1673 if let Some(override_value) = args.trust_override {
1674 return override_value;
1675 }
1676 crate::config::project_trust_decision(cwd)
1677 .ok()
1678 .flatten()
1679 .unwrap_or(true)
1680}
1681
1682fn load_extensions(
1690 args: &Args,
1691 cwd: &Path,
1692 project_trusted: bool,
1693 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1694) -> ExtensionSession {
1695 let mut dirs = if args.dev_local_only {
1696 Vec::new()
1697 } else if args.project_only {
1698 project_extension_dirs(cwd)
1699 } else if project_trusted {
1700 extension_dirs(cwd)
1701 } else {
1702 global_extension_dirs()
1703 };
1704 dirs.extend(args.extensions_dir.iter().cloned());
1705 load_extensions_from_dirs(args, &dirs, action_bridge)
1706}
1707
1708fn load_extensions_from_dirs(
1709 args: &Args,
1710 dirs: &[PathBuf],
1711 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1712) -> ExtensionSession {
1713 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1714 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1721}
1722
1723fn merge_extension_tools(
1729 tools: &mut Vec<HarnessTool>,
1730 session: &ExtensionSession,
1731 args: &Args,
1732 tool_context: &rpi_extensions::ToolCallContext,
1733) {
1734 let Some(snapshot) = session.snapshot() else {
1735 return;
1736 };
1737 for et in snapshot.tools() {
1738 let name = &et.tool.name;
1739 if !tool_name_allowed(name, args) {
1740 continue;
1741 }
1742 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
1743 .with_context(tool_context.clone());
1744 let harness_tool = HarnessTool::new(Arc::new(adapter));
1745 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1746 Some(slot) => *slot = harness_tool,
1747 None => tools.push(harness_tool),
1748 }
1749 }
1750}
1751
1752pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1759 use rpi_tools::tools::bash::BashToolOptions;
1760 let default = std::env::var("RPI_BASH_TIMEOUT")
1761 .ok()
1762 .and_then(|v| v.parse::<f64>().ok())
1763 .unwrap_or(120.0);
1764 let settings = crate::settings::load_settings().unwrap_or_default();
1770 BashToolOptions {
1771 command_prefix: settings.shell_command_prefix.clone(),
1772 default_timeout: Some(default),
1773 prepare: None,
1774 }
1775}
1776
1777fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1778 if args.no_tools {
1779 return Vec::new();
1780 }
1781 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1790 (
1791 "read",
1792 HarnessTool::new(create_read_tool(
1793 ctx,
1794 Some(rpi_tools::tools::read::ReadToolOptions {
1795 image_processor: Some(Arc::new(
1796 rpi_tools::image_processing::InlineImageProcessor,
1797 )),
1798 ..Default::default()
1799 }),
1800 ))
1801 .with_replay(ToolReplay::Safe),
1802 ),
1803 (
1804 "bash",
1805 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1806 ),
1807 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1808 ("write", HarnessTool::new(create_write_tool(ctx))),
1809 (
1810 "docs",
1811 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
1812 ),
1813 ];
1814
1815 #[cfg(target_os = "windows")]
1817 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
1818
1819 if args.tools.is_none() {
1821 if let Ok(settings) = crate::settings::load_settings() {
1822 if let Some(default_tools) = &settings.default_tools {
1823 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
1824 }
1825 }
1826 }
1827
1828 if args.no_builtin_tools {
1832 all.clear();
1833 }
1834
1835 if let Some(allow) = &args.tools {
1837 all.retain(|(name, _)| allow.iter().any(|a| a == name));
1838 }
1839 if let Some(deny) = &args.exclude_tools {
1841 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1842 }
1843
1844 all.into_iter().map(|(_, t)| t).collect()
1845}
1846
1847fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1851 filter_active_tool_names(
1852 tools.iter().map(|tool| tool.tool.schema().name.clone()),
1853 args,
1854 )
1855}
1856
1857pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
1860 if args.no_tools {
1861 return false;
1862 }
1863 if args
1864 .tools
1865 .as_ref()
1866 .is_some_and(|allow| !allow.iter().any(|value| value == name))
1867 {
1868 return false;
1869 }
1870 if args
1871 .exclude_tools
1872 .as_ref()
1873 .is_some_and(|deny| deny.iter().any(|value| value == name))
1874 {
1875 return false;
1876 }
1877 true
1878}
1879
1880pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
1881where
1882 I: IntoIterator<Item = String>,
1883{
1884 names
1885 .into_iter()
1886 .filter(|name| tool_name_allowed(name, args))
1887 .collect()
1888}
1889
1890async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1892 match selection {
1893 SessionSelection::Ephemeral => Ok(ephemeral_session()),
1894 SessionSelection::New { dir, .. } => {
1895 std::fs::create_dir_all(dir)
1898 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1899 let session = create_jsonl_session(dir, cwd)
1900 .await
1901 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1902 Ok(session)
1903 }
1904 SessionSelection::Latest { .. }
1905 | SessionSelection::ById { .. }
1906 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
1907 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
1908 }
1909}
1910
1911async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1917 match selection {
1921 SessionSelection::Latest { dir } => {
1922 let metas = list_session_metadata_in_dir(cwd, dir).await?;
1923 let Some(meta) = first_resumable_session(&metas) else {
1924 return Err(BuildError::SessionNotFound {
1925 requested: "the most recent session".to_string(),
1926 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1927 });
1928 };
1929 open_session(meta, cwd).await
1930 }
1931 SessionSelection::ById { id, dir } => open_session_by_id_in_dir(id, cwd, dir)
1932 .await
1933 .map_err(|e| match e {
1934 OpenError::NotFound { requested } => BuildError::SessionNotFound {
1935 requested,
1936 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1937 },
1938 OpenError::Other(msg) => BuildError::SessionOpen(msg),
1939 }),
1940 SessionSelection::ByExactId { id, dir } => {
1941 let metas = list_session_metadata_in_dir(cwd, dir).await?;
1944 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
1945 return open_session(meta, cwd).await;
1946 }
1947 std::fs::create_dir_all(dir)
1948 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1949 create_jsonl_session_with_id(dir, cwd, Some(id.clone()))
1950 .await
1951 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
1952 }
1953 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
1954 }
1955}
1956
1957async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
1960 use rpi_harness::session::jsonl::{
1961 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1962 };
1963 use rpi_harness::session::types::{ForkOptions, SessionStorage};
1964 use rpi_tools::FileSystem;
1965
1966 let dir = default_session_dir(Path::new(cwd));
1967 std::fs::create_dir_all(&dir)
1968 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1969 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1970 let fs: Arc<dyn FileSystem> = env.clone();
1971 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1972 fs: fs.clone(),
1973 sessions_root: dir.to_string_lossy().into_owned(),
1974 clock: Arc::new(SystemClock),
1975 ids: Arc::new(DefaultIdGenerator::new()),
1976 });
1977 let metas = repo
1978 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
1979 .await
1980 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
1981 let source_meta = metas
1982 .iter()
1983 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
1984 .ok_or_else(|| BuildError::SessionNotFound {
1985 requested: format!("--fork {source}"),
1986 dir: dir.display().to_string(),
1987 })?;
1988 let fork_storage = repo
1989 .fork_typed(
1990 source_meta,
1991 &JsonlSessionCreateOptions {
1992 id: None,
1993 parent_session_id: Some(source_meta.id.clone()),
1994 cwd: cwd.to_string(),
1995 metadata: None,
1996 },
1997 &ForkOptions::default(),
1998 )
1999 .await
2000 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2001 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
2002 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2003 Ok(Session::new(storage_arc, None))
2004}
2005
2006pub enum OpenError {
2009 NotFound { requested: String },
2011 Other(String),
2013}
2014
2015impl std::fmt::Display for OpenError {
2016 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2017 match self {
2018 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2019 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
2024 }
2025 }
2026}
2027
2028pub async fn list_session_metadata(
2031 cwd: &str,
2032) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2033 let dir = default_session_dir(Path::new(cwd));
2034 list_session_metadata_in_dir(cwd, &dir).await
2035}
2036
2037async fn list_session_metadata_in_dir(
2038 cwd: &str,
2039 dir: &Path,
2040) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2041 use rpi_harness::session::jsonl::{
2042 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2043 };
2044 use rpi_tools::FileSystem;
2045
2046 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2047 let fs: Arc<dyn FileSystem> = env.clone();
2048 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2049 fs: fs.clone(),
2050 sessions_root: dir.to_string_lossy().into_owned(),
2051 clock: Arc::new(SystemClock),
2052 ids: Arc::new(DefaultIdGenerator::new()),
2053 });
2054 repo.list_typed(&JsonlSessionListOptions::default())
2055 .await
2056 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2057}
2058
2059#[derive(Debug, Clone, Default, PartialEq, Eq)]
2075pub struct SessionSummary {
2076 pub name: Option<String>,
2078 pub preview: Option<String>,
2080 pub empty: bool,
2082 pub bytes: u64,
2084}
2085
2086const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
2088const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
2090const SUMMARY_PREVIEW_CHARS: usize = 80;
2092
2093pub fn summarize_session_file(path: &Path) -> SessionSummary {
2097 use std::io::{Read, Seek, SeekFrom};
2098
2099 let Ok(mut file) = std::fs::File::open(path) else {
2100 return SessionSummary::default();
2101 };
2102 let Ok(size) = file.metadata().map(|m| m.len()) else {
2103 return SessionSummary::default();
2104 };
2105 if size == 0 {
2106 return SessionSummary {
2107 name: None,
2108 preview: None,
2109 empty: true,
2110 bytes: 0,
2111 };
2112 }
2113
2114 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2117 let head_len = file.read(&mut head).unwrap_or(0);
2118 head.truncate(head_len);
2119 let head = String::from_utf8_lossy(&head);
2120 let mut lines: Vec<&str> = head.lines().collect();
2121 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2122 let empty = (size as usize) <= header_len + 1;
2124 if (head_len as u64) < size && !lines.is_empty() {
2127 lines.pop();
2128 }
2129
2130 let mut summary = SessionSummary {
2131 name: None,
2132 preview: None,
2133 empty,
2134 bytes: size,
2135 };
2136 for line in lines {
2137 apply_summary_line(line, &mut summary);
2138 }
2139
2140 if size > SUMMARY_HEAD_BYTES
2144 && file
2145 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2146 .is_ok()
2147 {
2148 let mut tail = Vec::new();
2149 if file.read_to_end(&mut tail).is_ok() {
2150 let tail = String::from_utf8_lossy(&tail);
2151 let mut tail_lines = tail.lines();
2152 tail_lines.next();
2154 for line in tail_lines {
2155 if let Some(name) = name_from_line(line) {
2156 summary.name = name;
2157 }
2158 }
2159 }
2160 }
2161 summary
2162}
2163
2164fn name_from_line(line: &str) -> Option<Option<String>> {
2167 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2168 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2169 return None;
2170 }
2171 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2172 return None;
2173 }
2174 Some(match value.get("name") {
2175 Some(serde_json::Value::String(name)) => Some(name.clone()),
2176 _ => None,
2177 })
2178}
2179
2180fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2183 if let Some(name) = name_from_line(line) {
2184 summary.name = name;
2185 return;
2186 }
2187 if summary.preview.is_some() {
2188 return;
2189 }
2190 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2191 return;
2192 };
2193 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2194 return;
2195 }
2196 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2197 return;
2198 }
2199 let Some(message) = value.get("message") else {
2200 return;
2201 };
2202 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2203 return;
2204 }
2205 let text = match message.get("content") {
2206 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2207 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2208 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2209 .then(|| block.get("text").and_then(|v| v.as_str()))
2210 .flatten()
2211 }),
2212 _ => None,
2213 };
2214 let Some(text) = text else { return };
2215 let collapsed = text
2216 .lines()
2217 .map(str::trim)
2218 .filter(|line| !line.is_empty())
2219 .collect::<Vec<_>>()
2220 .join(" ");
2221 if collapsed.is_empty() {
2222 return;
2223 }
2224 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2225}
2226
2227pub fn session_display_label(summary: &SessionSummary) -> String {
2231 if let Some(name) = summary.name.as_deref().map(str::trim) {
2232 if !name.is_empty() {
2233 return name.to_string();
2234 }
2235 }
2236 match summary.preview.as_deref().map(str::trim) {
2237 Some(preview) if !preview.is_empty() => preview.to_string(),
2238 _ => "(no messages)".to_string(),
2239 }
2240}
2241
2242pub fn format_session_bytes(bytes: u64) -> String {
2245 if bytes >= 1_048_576 {
2246 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2247 } else if bytes >= 1024 {
2248 format!("{}KB", bytes / 1024)
2249 } else {
2250 format!("{bytes}B")
2251 }
2252}
2253
2254pub fn short_session_id(id: &str) -> &str {
2257 &id[..id.len().min(8)]
2258}
2259
2260pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2269 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2270 let mut visit = |path: PathBuf| {
2271 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2272 return;
2273 }
2274 let modified = std::fs::metadata(&path)
2275 .and_then(|m| m.modified())
2276 .unwrap_or(std::time::UNIX_EPOCH);
2277 files.push((path, modified));
2278 };
2279 if let Ok(entries) = std::fs::read_dir(dir) {
2280 for entry in entries.flatten() {
2281 let path = entry.path();
2282 if path.is_dir() {
2283 if let Ok(inner) = std::fs::read_dir(&path) {
2284 for child in inner.flatten() {
2285 visit(child.path());
2286 }
2287 }
2288 } else {
2289 visit(path);
2290 }
2291 }
2292 }
2293 files.sort_by(|a, b| b.1.cmp(&a.1));
2294 files.into_iter().map(|(path, _)| path).collect()
2295}
2296
2297fn first_resumable_session<'a>(
2302 metas: &'a [rpi_harness::session::jsonl::JsonlSessionMetadata],
2303) -> Option<&'a rpi_harness::session::jsonl::JsonlSessionMetadata> {
2304 metas
2305 .iter()
2306 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2307 .or_else(|| metas.first())
2308}
2309
2310pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2314 let dir = default_session_dir(Path::new(cwd));
2315 open_session_by_id_in_dir(id, cwd, &dir).await
2316}
2317
2318async fn open_session_by_id_in_dir(id: &str, cwd: &str, dir: &Path) -> Result<Session, OpenError> {
2319 let metas = list_session_metadata_in_dir(cwd, dir)
2320 .await
2321 .map_err(|e| OpenError::Other(e.to_string()))?;
2322 let Some(meta) = metas
2323 .iter()
2324 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2325 else {
2326 return Err(OpenError::NotFound {
2327 requested: format!("session {id}"),
2328 });
2329 };
2330 open_session(meta, cwd).await.map_err(|e| {
2331 OpenError::Other(match e {
2332 BuildError::SessionOpen(reason) => reason,
2336 other => other.to_string(),
2337 })
2338 })
2339}
2340
2341fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
2366 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
2367 tool_context.set_session_id(id);
2368}
2369
2370pub(crate) async fn set_active_session(
2381 harness: &AgentHarness,
2382 session: Session,
2383 tool_context: Option<&rpi_extensions::ToolCallContext>,
2384) -> Result<(), String> {
2385 if let Ok(metadata) = session.get_metadata().await {
2386 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2387 if let Some(context) = tool_context {
2388 context.set_session_id(metadata.id);
2389 }
2390 }
2391 harness
2392 .set_session(session)
2393 .await
2394 .map_err(|e| e.to_string())
2395}
2396
2397pub(crate) async fn fork_session_storage(
2404 harness: &AgentHarness,
2405 cwd: &str,
2406) -> Result<Session, String> {
2407 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2408 use rpi_tools::FileSystem;
2409
2410 let dir = default_session_dir(Path::new(cwd));
2411 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2412 let fs: Arc<dyn FileSystem> = env.clone();
2413 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2414 fs,
2415 sessions_root: dir.to_string_lossy().into_owned(),
2416 clock: Arc::new(SystemClock),
2417 ids: Arc::new(DefaultIdGenerator::new()),
2418 });
2419 let id = harness.session().storage().metadata().id.clone();
2422 let metas = list_session_metadata(cwd)
2423 .await
2424 .map_err(|e| e.to_string())?;
2425 let Some(source) = metas.iter().find(|m| m.id == id) else {
2426 return Err(format!("current session {id} not found on disk"));
2427 };
2428 let fork_storage = repo
2429 .fork_typed(
2430 source,
2431 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2432 id: None,
2433 parent_session_id: Some(source.id.clone()),
2434 cwd: cwd.to_string(),
2435 metadata: None,
2436 },
2437 &rpi_harness::session::types::ForkOptions::default(),
2438 )
2439 .await
2440 .map_err(|e| e.to_string())?;
2441 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2442 Ok(Session::new(Arc::new(fork_storage), None))
2443}
2444
2445static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2447static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2450
2451fn claim_session_lock(session_path: &str) -> Result<(), String> {
2460 let lock_path = PathBuf::from(format!("{session_path}.lock"));
2461 if SESSION_LOCK_PATHS
2462 .get_or_init(|| Mutex::new(HashSet::new()))
2463 .lock()
2464 .unwrap()
2465 .contains(&lock_path)
2466 {
2467 return Ok(());
2468 }
2469 let file = std::fs::OpenOptions::new()
2470 .create(true)
2471 .write(true)
2472 .open(&lock_path)
2473 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2474 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2475 return Err(format!(
2476 "session {session_path} is already open in another rpi process"
2477 ));
2478 }
2479 SESSION_LOCKS
2480 .get_or_init(|| Mutex::new(Vec::new()))
2481 .lock()
2482 .unwrap()
2483 .push(file);
2484 SESSION_LOCK_PATHS
2485 .get_or_init(|| Mutex::new(HashSet::new()))
2486 .lock()
2487 .unwrap()
2488 .insert(lock_path);
2489 Ok(())
2490}
2491
2492async fn open_session(
2495 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2496 cwd: &str,
2497) -> Result<Session, BuildError> {
2498 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2499 use rpi_harness::session::types::SessionStorage;
2500 use rpi_tools::FileSystem;
2501
2502 let dir = default_session_dir(Path::new(cwd));
2503 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2504 let fs: Arc<dyn FileSystem> = env.clone();
2505 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2506 fs: fs.clone(),
2507 sessions_root: dir.to_string_lossy().into_owned(),
2508 clock: Arc::new(SystemClock),
2509 ids: Arc::new(DefaultIdGenerator::new()),
2510 });
2511 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2512 let storage = repo
2513 .open_by_jsonl_metadata(meta)
2514 .await
2515 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2516 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2520 Some(Path::new(&meta.path)),
2521 Path::new(&meta.cwd),
2522 Path::new(cwd),
2523 ) {
2524 eprintln!(
2525 "warning: {}",
2526 crate::session_cwd::format_missing_session_cwd_error(&issue)
2527 );
2528 }
2529 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2530 Ok(Session::new(storage_arc, None))
2531}
2532
2533fn ephemeral_session() -> Session {
2535 let storage = Arc::new(InMemorySessionStorage::new(
2536 SessionMetadata {
2537 id: "ephemeral".into(),
2538 created_at: 0,
2539 parent_session_id: None,
2540 },
2541 Arc::new(SystemClock),
2542 Arc::new(DefaultIdGenerator::new()),
2543 ));
2544 Session::new(storage, None)
2545}
2546
2547pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2558 create_jsonl_session_with_id(dir, cwd, None).await
2559}
2560
2561pub(crate) async fn create_jsonl_session_with_id(
2565 dir: &Path,
2566 cwd: &str,
2567 id: Option<String>,
2568) -> Result<Session, String> {
2569 use rpi_harness::session::jsonl::{
2570 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2571 };
2572 use rpi_tools::FileSystem;
2573
2574 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2577 let fs: Arc<dyn FileSystem> = env.clone();
2578
2579 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2580 fs: fs.clone(),
2581 sessions_root: dir.to_string_lossy().into_owned(),
2582 clock: Arc::new(SystemClock),
2583 ids: Arc::new(DefaultIdGenerator::new()),
2584 });
2585
2586 let opts = JsonlSessionCreateOptions {
2587 id, parent_session_id: None,
2589 cwd: cwd.to_string(),
2590 metadata: None,
2591 };
2592 let storage = repo
2593 .create_typed(&opts)
2594 .await
2595 .map_err(|e| format!("create session: {e}"))?;
2596 claim_session_lock(&storage.jsonl_metadata().path)
2597 .map_err(|e| format!("create session: {e}"))?;
2598 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2600 Ok(Session::new(storage_arc, None))
2601}
2602
2603fn read_append_target(target: &str) -> Option<String> {
2606 let path = Path::new(target);
2607 if path.is_file() {
2608 std::fs::read_to_string(path).ok()
2609 } else {
2610 None
2611 }
2612}
2613
2614#[cfg(test)]
2615mod tests {
2616 use super::*;
2617 use crate::args::Args;
2618
2619 extern "C" fn deny_test_tool_call(
2620 _event: rpi_plugin_sdk::StablePluginEvent,
2621 _data: *mut std::ffi::c_void,
2622 ) -> i32 {
2623 rpi_plugin_sdk::EVENT_HANDLER_ABORT
2624 }
2625
2626 async fn invoke_test_tool_hook(
2627 hook: &rpi_agent::BeforeToolCall,
2628 ) -> Option<rpi_agent::BeforeToolCallResult> {
2629 let message = rpi_ai::providers::faux::faux_assistant_message(
2630 "approval",
2631 rpi_ai::types::StopReason::ToolUse,
2632 );
2633 let rpi_ai::types::Content::ToolCall(call) =
2634 rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
2635 else {
2636 unreachable!()
2637 };
2638 hook(
2639 rpi_agent::BeforeToolCallContext {
2640 assistant_message: &message,
2641 tool_call: &call,
2642 args: &call.arguments,
2643 context: &rpi_agent::AgentContext::default(),
2644 },
2645 tokio_util::sync::CancellationToken::new(),
2646 )
2647 .await
2648 }
2649
2650 #[tokio::test]
2651 async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
2652 let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
2653 let hook =
2654 extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
2655 assert!(invoke_test_tool_hook(&hook).await.is_none());
2656 for name in ["first-gate", "reloaded-gate"] {
2657 let mut registry = rpi_extensions::ExtensionRegistry::new();
2658 registry.register_event_handler(
2659 name.into(),
2660 rpi_plugin_sdk::EventTag::ToolCall,
2661 deny_test_tool_call,
2662 std::ptr::null_mut(),
2663 );
2664 let fresh = ExtensionSession::from_parts(
2665 Arc::new(registry.snapshot()),
2666 rpi_extensions::PluginKeepalive::empty(),
2667 Vec::new(),
2668 None,
2669 );
2670 {
2671 let mut current = sessions.lock().unwrap();
2672 if let Some(snapshot) = current.snapshot() {
2673 snapshot.active_flag().store(false, Ordering::SeqCst);
2674 }
2675 *current = fresh;
2676 }
2677 let result = invoke_test_tool_hook(&hook).await.unwrap();
2678 assert!(result.block);
2679 assert!(result.reason.unwrap().contains(name));
2680 }
2681 *sessions.lock().unwrap() = ExtensionSession::none();
2682 assert!(invoke_test_tool_hook(&hook).await.is_none());
2683 }
2684
2685 fn scratch(name: &str) -> PathBuf {
2687 let nanos = std::time::SystemTime::now()
2688 .duration_since(std::time::UNIX_EPOCH)
2689 .unwrap()
2690 .as_nanos();
2691 let path = std::env::temp_dir().join(format!(
2692 "rpi-summary-{}-{nanos}-{name}.jsonl",
2693 std::process::id()
2694 ));
2695 path
2696 }
2697
2698 #[test]
2699 fn summary_marks_a_header_only_session_empty() {
2700 let path = scratch("empty");
2701 std::fs::write(
2702 &path,
2703 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2704 )
2705 .unwrap();
2706 let summary = summarize_session_file(&path);
2707 assert!(summary.empty, "a header-only file has nothing to resume");
2708 assert!(summary.preview.is_none());
2709 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
2710 std::fs::remove_file(&path).ok();
2711 }
2712
2713 #[test]
2714 fn summary_reads_the_first_user_prompt_and_name() {
2715 let path = scratch("content");
2716 let body = concat!(
2717 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2718 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
2719 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
2720 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
2721 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
2722 );
2723 std::fs::write(&path, body).unwrap();
2724 let summary = summarize_session_file(&path);
2725 assert!(!summary.empty);
2726 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
2727 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
2728 std::fs::remove_file(&path).ok();
2729 }
2730
2731 #[test]
2732 fn summary_extracts_a_block_style_user_prompt() {
2733 let path = scratch("blocks");
2734 let body = concat!(
2735 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2736 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
2737 );
2738 std::fs::write(&path, body).unwrap();
2739 let summary = summarize_session_file(&path);
2740 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
2741 assert_eq!(session_display_label(&summary), "line one line two");
2742 std::fs::remove_file(&path).ok();
2743 }
2744
2745 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
2746 rpi_harness::session::jsonl::JsonlSessionMetadata {
2747 id: id.to_string(),
2748 created_at: 0,
2749 cwd: ".".to_string(),
2750 path: path.to_string_lossy().into_owned(),
2751 modified_at: 0,
2752 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
2753 parent_session_id: None,
2754 legacy_parent_session_path: None,
2755 metadata: None,
2756 }
2757 }
2758
2759 #[test]
2760 fn continue_skips_newer_empty_sessions() {
2761 let empty = scratch("newest-empty");
2762 std::fs::write(
2763 &empty,
2764 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2765 )
2766 .unwrap();
2767 let content = scratch("older-content");
2768 std::fs::write(
2769 &content,
2770 concat!(
2771 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2772 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
2773 ),
2774 )
2775 .unwrap();
2776
2777 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
2778 let picked = first_resumable_session(&metas).expect("a resumable session");
2779 assert_eq!(picked.id, "old");
2780
2781 let all_empty = vec![meta_for(&empty, "empty")];
2784 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
2785
2786 std::fs::remove_file(&empty).ok();
2787 std::fs::remove_file(&content).ok();
2788 }
2789
2790 #[test]
2791 fn list_session_files_sync_walks_the_cwd_subdirectory() {
2792 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
2793 let nested = root.join("--proj--");
2794 std::fs::create_dir_all(&nested).unwrap();
2795 let file = nested.join("2026-01-01_a.jsonl");
2796 std::fs::write(&file, "{}\n").unwrap();
2797 let found = list_session_files_sync(&root);
2798 assert!(found.iter().any(|p| p == &file));
2799 std::fs::remove_dir_all(&root).ok();
2800 }
2801
2802 #[test]
2803 fn default_prompt_mentions_cwd_and_tools() {
2804 let p = default_system_prompt("/tmp/proj", false);
2805 assert!(p.contains("/tmp/proj"));
2806 assert!(p.contains("read"));
2807 assert!(p.contains("bash"));
2808 assert!(p.contains("edit"));
2809 assert!(p.contains("write"));
2810 assert!(p.contains("docs"));
2811 assert!(!p.contains("- grep"));
2812 assert!(!p.contains("- find"));
2813 assert!(!p.contains("- ls"));
2814 assert!(!p.contains("- powershell"));
2815 assert!(p.contains("Bash/POSIX syntax, including on Windows"));
2816 assert!(p.contains("do not pass PowerShell cmdlets"));
2817 }
2818
2819 #[test]
2820 fn default_prompt_instructs_language_matching() {
2821 let p = default_system_prompt("/tmp/proj", false);
2825 let lower = p.to_lowercase();
2826 assert!(
2827 lower.contains("same language") && lower.contains("detect"),
2828 "the prompt must tell the model to detect and mirror the user's language: {p}"
2829 );
2830 }
2831
2832 #[test]
2833 fn default_prompt_keeps_working_state_in_the_visible_channel() {
2834 let p = default_system_prompt("/tmp/proj", false);
2842 let lower = p.to_lowercase();
2843 assert!(
2845 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
2846 "the prompt must explain that reasoning does not survive the turn: {p}"
2847 );
2848 assert!(
2850 lower.contains("visible") && lower.contains("todo tool"),
2851 "the prompt must point at visible text and the todo tool: {p}"
2852 );
2853 assert!(
2855 lower.contains("re-stating the same plan"),
2856 "the prompt must call out plan re-statement as a bug: {p}"
2857 );
2858 assert!(
2860 lower.contains("already shown earlier"),
2861 "the prompt must discourage re-fetching known content: {p}"
2862 );
2863 }
2864
2865 #[test]
2866 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
2867 let p = default_system_prompt("/tmp/proj", true);
2874 let lower = p.to_lowercase();
2875 assert!(
2876 lower.contains("carried back"),
2877 "a replaying model must be told its reasoning survives: {p}"
2878 );
2879 assert!(
2880 !lower.contains("reasoning is not"),
2881 "the replayed variant must not claim reasoning is dropped: {p}"
2882 );
2883 assert!(
2884 lower.contains("do not re-derive"),
2885 "the replayed variant must forbid re-deriving the plan: {p}"
2886 );
2887 assert!(
2888 lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
2889 "the anti-repetition rule must survive the flip: {p}"
2890 );
2891 assert!(
2892 lower.contains("already shown earlier"),
2893 "the re-read rule must survive the flip: {p}"
2894 );
2895 }
2896
2897 #[test]
2905 fn only_read_only_tools_are_replayable() {
2906 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2907 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2908 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2909 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2910 let tools = build_tools(&context, &Args::default());
2911
2912 let replay_of = |name: &str| {
2913 tools
2914 .iter()
2915 .find(|tool| tool.tool.schema().name == name)
2916 .map(|tool| tool.replay)
2917 };
2918
2919 for read_only in ["read", "docs"] {
2920 assert_eq!(
2921 replay_of(read_only),
2922 Some(ToolReplay::Safe),
2923 "{read_only} is side-effect free, so recovery may re-run it"
2924 );
2925 }
2926 for mutating in ["bash", "edit", "write", "powershell"] {
2927 if let Some(replay) = replay_of(mutating) {
2928 assert_eq!(
2929 replay,
2930 ToolReplay::Never,
2931 "{mutating} must never be replayed: re-running it would duplicate its effect"
2932 );
2933 }
2934 }
2935 }
2936
2937 #[test]
2938 fn default_tools_include_docs_lookup() {
2939 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2940 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2941 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2942 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2943 let names: Vec<String> = build_tools(&context, &Args::default())
2944 .iter()
2945 .map(|tool| tool.tool.schema().name.clone())
2946 .collect();
2947
2948 assert!(names.contains(&"docs".to_string()));
2953 for legacy in ["grep", "find", "ls"] {
2954 assert!(
2955 !names.contains(&legacy.to_string()),
2956 "legacy tool {legacy} should not be registered by default"
2957 );
2958 }
2959 }
2960
2961 #[test]
2962 fn reload_settings_preflight_respects_trust() {
2963 struct RestoreConfigDir(Option<std::ffi::OsString>);
2964
2965 impl Drop for RestoreConfigDir {
2966 fn drop(&mut self) {
2967 match self.0.take() {
2968 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2969 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2970 }
2971 }
2972 }
2973
2974 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2975 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2976 let tmp = tempfile::tempdir().unwrap();
2977 let agent = tmp.path().join("agent");
2978 let cwd = tmp.path().join("project");
2979 std::fs::create_dir_all(&agent).unwrap();
2980 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2981 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2982 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
2983 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2984 let args = Args::default();
2985
2986 let trusted = validate_settings_for_reload(&args, &cwd, true);
2987 let untrusted = validate_settings_for_reload(&args, &cwd, false);
2988 assert!(trusted
2989 .unwrap_err()
2990 .contains("could not load project settings"));
2991 assert!(untrusted.is_ok());
2992
2993 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2994 assert!(validate_settings_for_reload(&args, &cwd, false)
2995 .unwrap_err()
2996 .contains("could not load global settings"));
2997
2998 let local_only = Args {
2999 dev_local_only: true,
3000 ..Args::default()
3001 };
3002 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
3003 }
3004
3005 #[tokio::test(flavor = "current_thread")]
3006 async fn reload_preserves_live_resources_when_settings_break() {
3007 struct RestoreConfigDir(Option<std::ffi::OsString>);
3008
3009 impl Drop for RestoreConfigDir {
3010 fn drop(&mut self) {
3011 match self.0.take() {
3012 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3013 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3014 }
3015 }
3016 }
3017
3018 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3019 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3020 let tmp = tempfile::tempdir().unwrap();
3021 let agent = tmp.path().join("agent");
3022 let cwd = tmp.path().join("project");
3023 let skill_dir = tmp.path().join("configured-skills");
3024 std::fs::create_dir_all(&agent).unwrap();
3025 std::fs::create_dir_all(&cwd).unwrap();
3026 std::fs::create_dir_all(&skill_dir).unwrap();
3027 std::fs::write(
3028 skill_dir.join("SKILL.md"),
3029 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
3030 )
3031 .unwrap();
3032 std::fs::write(
3033 agent.join("settings.json"),
3034 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3035 )
3036 .unwrap();
3037 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3038
3039 let resolved = crate::provider::resolve(
3040 Some("anthropic"),
3041 Some(crate::provider::DEFAULT_MODEL_ID),
3042 None,
3043 Some("test-key"),
3044 None,
3045 )
3046 .unwrap();
3047 let args = Args {
3048 trust_override: Some(false),
3049 no_session: true,
3050 no_extensions: true,
3051 no_prompt_templates: true,
3052 no_context_files: true,
3053 system_prompt: Some("stable system prompt".into()),
3054 ..Args::default()
3055 };
3056 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3057 let before_resources = harness.get_resources().await.unwrap();
3058 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
3059 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
3060 let before_prompt = harness.get_system_prompt().await.unwrap();
3061 let before_tools: Vec<String> = harness
3062 .get_tools()
3063 .await
3064 .unwrap()
3065 .iter()
3066 .map(|tool| tool.tool.schema().name.clone())
3067 .collect();
3068 let before_bridge = context
3069 .action_bridge
3070 .lock()
3071 .unwrap()
3072 .as_ref()
3073 .unwrap()
3074 .clone();
3075
3076 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3077 let outcome = reload_extension_resources(&harness, &context).await;
3078
3079 assert!(outcome.had_warnings);
3080 assert!(outcome.summary.contains("Settings reload failed"));
3081 assert!(outcome.summary.contains("could not load global settings"));
3082 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
3083 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
3084 let after_tools: Vec<String> = harness
3085 .get_tools()
3086 .await
3087 .unwrap()
3088 .iter()
3089 .map(|tool| tool.tool.schema().name.clone())
3090 .collect();
3091 assert_eq!(after_tools, before_tools);
3092 let after_bridge = context
3093 .action_bridge
3094 .lock()
3095 .unwrap()
3096 .as_ref()
3097 .unwrap()
3098 .clone();
3099 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
3100 }
3101
3102 #[test]
3103 fn reload_preparation_rejects_settings_changed_during_derivation() {
3104 struct RestoreConfigDir(Option<std::ffi::OsString>);
3105
3106 impl Drop for RestoreConfigDir {
3107 fn drop(&mut self) {
3108 match self.0.take() {
3109 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3110 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3111 }
3112 }
3113 }
3114
3115 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3116 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3117 let tmp = tempfile::tempdir().unwrap();
3118 let agent = tmp.path().join("agent");
3119 let cwd = tmp.path().join("project");
3120 let first_skill_dir = tmp.path().join("first-skills");
3121 let second_skill_dir = tmp.path().join("second-skills");
3122 std::fs::create_dir_all(&agent).unwrap();
3123 std::fs::create_dir_all(&cwd).unwrap();
3124 std::fs::write(
3125 agent.join("settings.json"),
3126 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3127 )
3128 .unwrap();
3129 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3130 let args = Args {
3131 trust_override: Some(false),
3132 no_extensions: true,
3133 no_prompt_templates: true,
3134 ..Args::default()
3135 };
3136
3137 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3138 std::fs::write(
3139 agent.join("settings.json"),
3140 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3141 )
3142 .unwrap();
3143 });
3144
3145 let error = result
3146 .err()
3147 .expect("settings mutation must fail preparation");
3148 assert!(error.contains("settings changed while reload inputs were being prepared"));
3149 }
3150
3151 #[tokio::test(flavor = "current_thread")]
3152 async fn reload_uses_frozen_settings_inputs_after_preparation() {
3153 struct RestoreConfigDir(Option<std::ffi::OsString>);
3154
3155 impl Drop for RestoreConfigDir {
3156 fn drop(&mut self) {
3157 match self.0.take() {
3158 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3159 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3160 }
3161 }
3162 }
3163
3164 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3165 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3166 let tmp = tempfile::tempdir().unwrap();
3167 let agent = tmp.path().join("agent");
3168 let cwd = tmp.path().join("project");
3169 let skill_dir = tmp.path().join("configured-skills");
3170 std::fs::create_dir_all(&agent).unwrap();
3171 std::fs::create_dir_all(&cwd).unwrap();
3172 std::fs::create_dir_all(&skill_dir).unwrap();
3173 std::fs::write(
3174 skill_dir.join("SKILL.md"),
3175 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3176 )
3177 .unwrap();
3178 std::fs::write(
3179 agent.join("settings.json"),
3180 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3181 )
3182 .unwrap();
3183 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3184
3185 let resolved = crate::provider::resolve(
3186 Some("anthropic"),
3187 Some(crate::provider::DEFAULT_MODEL_ID),
3188 None,
3189 Some("test-key"),
3190 None,
3191 )
3192 .unwrap();
3193 let args = Args {
3194 trust_override: Some(false),
3195 no_session: true,
3196 no_extensions: true,
3197 no_prompt_templates: true,
3198 no_context_files: true,
3199 system_prompt: Some("stable system prompt".into()),
3200 ..Args::default()
3201 };
3202 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3203
3204 let outcome = reload_extension_resources_inner(&harness, &context, || {
3205 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3206 })
3207 .await;
3208
3209 assert!(!outcome.summary.contains("Settings reload failed"));
3210 assert_eq!(
3211 outcome.summary,
3212 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3213 );
3214 let resources = harness.get_resources().await.unwrap();
3215 let skills = resources.skills.as_ref().unwrap();
3216 assert_eq!(skills.len(), 1);
3217 assert_eq!(skills[0].name, "frozen-skill");
3218 }
3219
3220 #[tokio::test]
3221 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3222 let tmp = tempfile::tempdir().unwrap();
3223 let skill_path = tmp.path().join("explicit-skill.md");
3224 std::fs::write(
3225 &skill_path,
3226 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3227 )
3228 .unwrap();
3229 let prompt_path = tmp.path().join("explicit-prompt.md");
3230 std::fs::write(
3231 &prompt_path,
3232 "---\ndescription: Explicit prompt\n---\nPrompt body",
3233 )
3234 .unwrap();
3235
3236 let args = Args {
3237 skill: vec![skill_path.clone()],
3238 prompt_template: vec![prompt_path.clone()],
3239 ..Args::default()
3240 };
3241 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3242 let prompt_paths =
3243 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3244 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3245 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3246
3247 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3248 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3249 assert_eq!(skills.skills[0].name, "explicit-skill");
3250
3251 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3252 assert_eq!(
3253 prompts.prompt_templates.len(),
3254 1,
3255 "{:?}",
3256 prompts.diagnostics
3257 );
3258 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3259 }
3260
3261 #[test]
3262 fn tool_policy_applies_to_rust_and_js_active_names() {
3263 let names = vec![
3264 "read".to_string(),
3265 "ask_user_question".to_string(),
3266 "write".to_string(),
3267 ];
3268
3269 let args = Args {
3270 tools: Some(vec!["read".into(), "ask_user_question".into()]),
3271 ..Args::default()
3272 };
3273 assert_eq!(
3274 filter_active_tool_names(names.clone(), &args),
3275 vec!["read", "ask_user_question"]
3276 );
3277
3278 let args = Args {
3279 exclude_tools: Some(vec!["ask_user_question".into()]),
3280 ..Args::default()
3281 };
3282 assert_eq!(
3283 filter_active_tool_names(names.clone(), &args),
3284 vec!["read", "write"]
3285 );
3286
3287 let args = Args {
3288 no_tools: true,
3289 ..Args::default()
3290 };
3291 assert!(filter_active_tool_names(names, &args).is_empty());
3292 }
3293
3294 #[test]
3295 fn select_ephemeral_when_no_session() {
3296 let args = Args {
3297 no_session: true,
3298 ..Args::default()
3299 };
3300 let cwd = Path::new("/tmp");
3301 assert!(matches!(
3302 select_session(&args, cwd),
3303 SessionSelection::Ephemeral
3304 ));
3305 }
3306
3307 #[test]
3308 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3309 let defaults = Args::default();
3310 assert!(resolve_project_trust(
3311 &defaults,
3312 Path::new("C:/definitely-not-a-project")
3313 ));
3314
3315 let denied = Args {
3316 trust_override: Some(false),
3317 ..Args::default()
3318 };
3319 assert!(!resolve_project_trust(
3320 &denied,
3321 Path::new("C:/definitely-not-a-project")
3322 ));
3323 }
3324
3325 #[tokio::test(flavor = "current_thread")]
3326 async fn build_preserves_the_supplied_startup_project_snapshot() {
3327 struct RestoreConfigDir(Option<std::ffi::OsString>);
3328
3329 impl Drop for RestoreConfigDir {
3330 fn drop(&mut self) {
3331 match self.0.take() {
3332 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3333 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3334 }
3335 }
3336 }
3337
3338 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3339 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3340 let tmp = tempfile::tempdir().unwrap();
3341 let agent = tmp.path().join("agent");
3342 let cwd = tmp.path().join("project");
3343 std::fs::create_dir_all(&agent).unwrap();
3344 std::fs::create_dir_all(&cwd).unwrap();
3345 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3346
3347 let resolved = crate::provider::resolve(
3348 Some("anthropic"),
3349 Some(crate::provider::DEFAULT_MODEL_ID),
3350 None,
3351 Some("test-key"),
3352 None,
3353 )
3354 .unwrap();
3355 let args = Args {
3356 trust_override: Some(false),
3357 no_session: true,
3358 no_tools: true,
3359 no_extensions: true,
3360 no_skills: true,
3361 no_prompt_templates: true,
3362 no_context_files: true,
3363 system_prompt: Some("test prompt".into()),
3364 ..Args::default()
3365 };
3366
3367 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3368
3369 assert_eq!(context.cwd, cwd);
3370 assert!(context.project_trusted);
3371 }
3372
3373 #[tokio::test(flavor = "current_thread")]
3374 async fn build_applies_the_name_flag_to_the_session() {
3375 struct RestoreConfigDir(Option<std::ffi::OsString>);
3376
3377 impl Drop for RestoreConfigDir {
3378 fn drop(&mut self) {
3379 match self.0.take() {
3380 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3381 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3382 }
3383 }
3384 }
3385
3386 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3387 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3388 let tmp = tempfile::tempdir().unwrap();
3389 let agent = tmp.path().join("agent");
3390 let cwd = tmp.path().join("project");
3391 std::fs::create_dir_all(&agent).unwrap();
3392 std::fs::create_dir_all(&cwd).unwrap();
3393 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3394
3395 let resolved = crate::provider::resolve(
3396 Some("anthropic"),
3397 Some(crate::provider::DEFAULT_MODEL_ID),
3398 None,
3399 Some("test-key"),
3400 None,
3401 )
3402 .unwrap();
3403 let args = Args {
3404 trust_override: Some(false),
3405 no_tools: true,
3406 no_extensions: true,
3407 no_skills: true,
3408 no_prompt_templates: true,
3409 no_context_files: true,
3410 system_prompt: Some("test prompt".into()),
3411 name: Some("smoke name".into()),
3412 session_dir: Some(cwd.join("sessions")),
3413 ..Args::default()
3414 };
3415
3416 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3417 assert_eq!(
3418 harness.get_name().await.unwrap().as_deref(),
3419 Some("smoke name")
3420 );
3421 }
3422
3423 #[test]
3424 fn select_latest_for_continue_and_resume() {
3425 let args = Args {
3426 continue_session: true,
3427 ..Args::default()
3428 };
3429 let cwd = Path::new("/tmp");
3430 assert!(matches!(
3431 select_session(&args, cwd),
3432 SessionSelection::Latest { .. }
3433 ));
3434
3435 let args = Args {
3436 resume: true,
3437 ..Args::default()
3438 };
3439 assert!(matches!(
3440 select_session(&args, cwd),
3441 SessionSelection::Latest { .. }
3442 ));
3443 }
3444
3445 #[test]
3446 fn select_by_id_for_session_flag() {
3447 let args = Args {
3448 session: Some("01a02ece".into()),
3449 ..Args::default()
3450 };
3451 let cwd = Path::new("/tmp");
3452 assert!(matches!(
3453 select_session(&args, cwd),
3454 SessionSelection::ById { id, .. } if id == "01a02ece"
3455 ));
3456 }
3457
3458 #[test]
3459 fn select_new_with_custom_dir() {
3460 let args = Args {
3461 session_dir: Some(PathBuf::from("/tmp/sess")),
3462 ..Args::default()
3463 };
3464 let cwd = Path::new("/tmp");
3465 match select_session(&args, cwd) {
3466 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
3467 other => panic!("expected New, got {other:?}"),
3468 }
3469 }
3470
3471 #[test]
3472 fn select_new_default_dir() {
3473 let args = Args::default();
3474 let cwd = Path::new("/proj");
3475 match select_session(&args, cwd) {
3476 SessionSelection::New { dir, .. } => {
3477 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
3478 }
3479 other => panic!("expected New, got {other:?}"),
3480 }
3481 }
3482
3483 #[test]
3484 fn default_session_dir_only_uses_rpi() {
3485 let tmp = tempfile::tempdir().unwrap();
3486 let cwd = tmp.path();
3487 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3488 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3489 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3490 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3491 }
3492
3493 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3494 async fn ephemeral_session_builds_roundtrips() {
3495 let s = ephemeral_session();
3499 let leaf = s.get_leaf_id().await;
3500 assert!(leaf.is_ok());
3501 }
3502
3503 #[test]
3521 fn host_private_env_matches_the_declared_contract() {
3522 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
3524 assert_eq!(
3525 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
3526 [
3527 "LANGFUSE_PI_PARENT_TRACE_ID",
3528 "LANGFUSE_PI_PARENT_SPAN_ID",
3529 "LANGFUSE_PI_PARENT_SESSION_ID",
3530 "LANGFUSE_PI_PARENT_DEPTH",
3531 ],
3532 "these strings are what rpi-langfuse (a different repo) matches; a \
3533 change here is a breaking cross-repo change, not a refactor"
3534 );
3535
3536 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
3539 .iter()
3540 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
3541 {
3542 assert!(
3543 rpi_tools::is_host_private_env(name),
3544 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
3545 rpi_tools::HOST_PRIVATE_ENV
3546 );
3547 }
3548
3549 for visible in [
3552 crate::args::RPI_OFFLINE_ENV,
3553 crate::config::CONFIG_DIR_ENV,
3554 "PATH",
3555 ] {
3556 assert!(
3557 !rpi_tools::is_host_private_env(visible),
3558 "{visible} describes the user's configuration and must still reach the shell"
3559 );
3560 }
3561 }
3562 #[tokio::test]
3563 async fn reload_shutdown_joins_all_handlers_even_after_veto() {
3564 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
3565 use std::sync::atomic::AtomicUsize;
3566 extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
3567 let calls = unsafe { &*(data as *const AtomicUsize) };
3568 std::thread::sleep(std::time::Duration::from_millis(20));
3569 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
3570 rpi_plugin_sdk::EVENT_HANDLER_ABORT
3571 } else {
3572 0
3573 }
3574 }
3575 let calls = AtomicUsize::new(0);
3576 let mut registry = rpi_extensions::ExtensionRegistry::new();
3577 for name in ["first", "second"] {
3578 registry.register_event_handler(
3579 name.into(),
3580 EventTag::SessionShutdown,
3581 stop,
3582 &calls as *const _ as *mut std::ffi::c_void,
3583 );
3584 }
3585 super::shutdown_extensions_for_reload(
3586 Some(Arc::new(registry.snapshot())),
3587 rpi_extensions::PluginKeepalive::empty(),
3588 )
3589 .await
3590 .unwrap();
3591 assert_eq!(calls.load(Ordering::SeqCst), 2);
3592 }
3593}