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};
50#[cfg(windows)]
51use rpi_tools::create_powershell_tool;
52use rpi_tools::{
53 create_bash_tool, create_edit_tool, create_read_tool, create_write_tool, ExecutionToolContext,
54 MutationQueueRegistry, OsExecutionEnv,
55};
56
57use crate::args::Args;
58use crate::docs_tool::create_docs_tool;
59use crate::provider::ResolvedModel;
60use crate::resource_dirs::{
61 discover_append_system_prompt_file, discover_system_prompt_file, extension_dirs,
62 global_extension_dirs, global_prompt_template_dirs, global_skill_dirs,
63 load_prompt_templates_with_precedence, load_skills_with_precedence, project_extension_dirs,
64 project_prompt_template_dirs, project_skill_dirs, prompt_template_dirs, skill_dirs,
65};
66use rpi_extensions::{
67 emit_resources_discover, ExtensionEmitter, ExtensionSession, NullDiagnostics,
68 PluginDiagnostics, PluginToolAdapter, TeeEmitter,
69};
70
71pub const BUILTIN_TOOL_NAMES: &[&str] = &["read", "bash", "edit", "write", "docs"];
73
74pub fn default_system_prompt(cwd: &str, prior_reasoning_replayed: bool) -> String {
77 let working_state_rule = if prior_reasoning_replayed {
83 "- Keep a SHORT working plan visible (a few bullets, or the todo tool) and update it as you go.
84 Your own reasoning is carried back to you on the next turn, so do not re-derive it or restate
85 the whole plan: continue from the last unfinished step, and when a step lands, say which one.
86 Re-listing the same plan without acting on it is a bug, not progress."
87 } else {
88 "- Keep your working state in your VISIBLE replies, not only in reasoning. Reasoning is not
89 carried into your next turn: only the text you write and the tool output you produce come
90 back. Before each batch of tool calls, write one short line naming the task you are on and
91 what remains. When you finish a step, say which one is done. If you keep the plan only in
92 your head you will re-derive it from scratch every turn."
93 };
94
95 format!(
96 "You are an expert coding assistant operating inside rpi, a coding agent harness. \
97You help users by reading files, executing commands, editing code, and writing new files.
98
99Available tools:
100- read — Read file contents
101- bash — Execute Bash commands using Bash/POSIX syntax, including on Windows
102- edit — Find/replace edits to existing files
103- write — Create or overwrite files
104- docs — Look up rpi usage, extension, package, and compatibility documentation
105
106Guidelines:
107- Reply in the SAME language the user is writing in. Detect the language of the user's
108 latest message (and, when it is too short to be sure, the working language of the
109 conversation so far) and use it for your prose replies. Mirror it unless the user
110 explicitly asks for a different language; only fall back to English when the
111 user's language cannot be determined.
112- Be concise in your responses
113- Show file paths clearly when working with files
114- Prefer the smallest change that solves the problem
115- The bash tool runs Bash, even on Windows. Use ls, find, rg, and POSIX pipelines;
116 do not pass PowerShell cmdlets such as Get-ChildItem, Select-Object, or Select-String.
117 If a powershell tool is available, use it for PowerShell commands.
118{working_state_rule}
119- Track multi-step work with the todo tool instead of re-listing the plan in prose: add the
120 steps once, then mark them done as you go. Re-stating the same plan without acting on it is
121 a bug, not progress.
122- Read only what you need. If a file or search result was already shown earlier in this
123 conversation, use it instead of fetching it again.
124- When unsure about rpi commands, extensions, Pi package compatibility, or .rpi configuration, consult the project documentation before guessing
125
126Current working directory: {cwd}"
127 )
128}
129
130#[derive(Debug, Clone)]
137pub enum SessionSelection {
138 Ephemeral,
140 New { dir: PathBuf, name: Option<String> },
142 Latest { dir: PathBuf },
144 ById { id: String, dir: PathBuf },
147 ByExactId { id: String, dir: PathBuf },
149 Fork { source: String },
152}
153
154pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
156 if args.no_session {
157 return SessionSelection::Ephemeral;
158 }
159 let dir = args
163 .session_dir
164 .clone()
165 .or_else(|| {
166 crate::settings::load_settings()
167 .ok()
168 .and_then(|settings| settings.session_dir)
169 })
170 .unwrap_or_else(|| default_session_dir(cwd));
171 if args.continue_session || args.resume {
172 return SessionSelection::Latest { dir };
173 }
174 if let Some(s) = &args.fork {
175 return SessionSelection::Fork { source: s.clone() };
176 }
177 if let Some(s) = &args.session_id {
178 return SessionSelection::ByExactId { id: s.clone(), dir };
179 }
180 if let Some(s) = &args.session {
181 return SessionSelection::ById { id: s.clone(), dir };
182 }
183 SessionSelection::New {
184 dir,
185 name: args.name.clone(),
186 }
187}
188
189pub fn default_session_dir(cwd: &Path) -> PathBuf {
191 cwd.join(".rpi").join("sessions")
192}
193
194pub struct PreparedExtensions {
197 pub extension_session: ExtensionSession,
198 runtime: tokio::runtime::Handle,
199 tool_context: rpi_extensions::ToolCallContext,
200 action_host: Arc<crate::extensions_actions::HarnessActionHost>,
201 harness_cell: Arc<std::sync::OnceLock<rpi_harness::agent_harness::WeakAgentHarness>>,
202 reload_mailbox: rpi_extensions::ReloadMailbox,
203 ui_dialog_mailbox: rpi_extensions::UiDialogMailbox,
204 ext_status_mailbox: rpi_extensions::ExtensionStatusMailbox,
205 editor_text_mailbox: rpi_extensions::EditorTextMailbox,
206 action_bridge: Arc<rpi_extensions::ActionBridge>,
207}
208
209pub fn prepare_extensions(
210 args: &Args,
211 cwd: &Path,
212 project_trusted: bool,
213) -> Result<PreparedExtensions, BuildError> {
214 let cwd_str = cwd.to_string_lossy().to_string();
215 let tool_context = rpi_extensions::ToolCallContext::new(cwd_str.clone());
216 if !project_trusted && args.verbose {
217 eprintln!(
218 "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)"
219 );
220 }
221 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
240 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
241 })?;
242 let catalog = Vec::new();
243 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
244 catalog.clone(),
245 cwd.to_path_buf(),
246 runtime.clone(),
247 args.unknown_flags.clone(),
248 tool_context.clone(),
249 );
250 let action_host = Arc::new(action_host);
251 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = action_host.clone();
252 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
262 let ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
268 let ext_status_mailbox = rpi_extensions::ExtensionStatusMailbox::new();
272 let editor_text_mailbox = rpi_extensions::EditorTextMailbox::new();
277 let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
278 runtime.clone(),
279 host_arc,
280 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
281 ui_dialog_mailbox.clone(),
282 ext_status_mailbox.clone(),
283 editor_text_mailbox.clone(),
284 );
285
286 let extension_session = if args.no_extensions {
287 ExtensionSession::none()
288 } else {
289 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
290 };
291 if args.verbose {
292 if let Some(s) = extension_session.summary() {
293 eprintln!("extensions: {s}");
294 }
295 report_deferred_renderers(&extension_session);
296 }
297 Ok(PreparedExtensions {
298 runtime,
299 tool_context,
300 action_host,
301 harness_cell,
302 reload_mailbox,
303 ui_dialog_mailbox,
304 ext_status_mailbox,
305 editor_text_mailbox,
306 action_bridge,
307 extension_session,
308 })
309}
310
311pub async fn build(
329 resolved: &ResolvedModel,
330 args: &Args,
331 cwd: &Path,
332 project_trusted: bool,
333) -> Result<
334 (
335 AgentHarness,
336 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
337 ReloadContext,
338 ),
339 BuildError,
340> {
341 let prepared = prepare_extensions(args, cwd, project_trusted)?;
342 build_prepared(resolved, args, cwd, project_trusted, prepared).await
343}
344
345pub async fn build_prepared(
346 resolved: &ResolvedModel,
347 args: &Args,
348 cwd: &Path,
349 project_trusted: bool,
350 prepared: PreparedExtensions,
351) -> Result<
352 (
353 AgentHarness,
354 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
355 ReloadContext,
356 ),
357 BuildError,
358> {
359 let cwd_str = cwd.to_string_lossy().to_string();
360 let PreparedExtensions {
365 runtime,
366 tool_context,
367 action_host,
368 harness_cell,
369 reload_mailbox,
370 ui_dialog_mailbox,
371 ext_status_mailbox,
372 editor_text_mailbox,
373 action_bridge,
374 extension_session,
375 } = prepared;
376 let catalog = crate::provider::authenticated_catalog(resolved);
377 action_host.set_catalog(catalog.clone());
378
379 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
381 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
382 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
383 let _registry = Arc::new(MutationQueueRegistry::new());
384 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
387
388 let mut tools = build_tools(&ctx, args);
389
390 merge_extension_tools(&mut tools, &extension_session, args, &tool_context);
398 let active = filter_active_tool_names(active_tool_names(&tools, args), args);
399
400 let selection = select_session(args, cwd);
402 let session = build_session(&selection, &cwd_str).await?;
403 if let Ok(metadata) = session.get_metadata().await {
407 publish_session_identity(&tool_context, &metadata.id);
408 }
409 if let Some(requested) = args.name.as_deref() {
415 let name = requested.trim();
416 let name = if name.is_empty() { None } else { Some(name) };
417 if let Err(error) = session.set_name(name).await {
418 if args.verbose {
419 eprintln!("warning: could not set session name: {error}");
420 }
421 }
422 }
423 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
433 let mut context_paths = Vec::new();
434 let base_prompt = match args.system_prompt.as_deref() {
435 Some(explicit) => explicit.to_string(),
436 None if project_trusted => match discover_system_prompt_file(cwd) {
437 Some(path) => read_prompt_source(&path, &mut context_paths)
438 .unwrap_or_else(|| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
439 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
440 },
441 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
442 };
443
444 let mut append_texts: Vec<String> = Vec::new();
451 for extra in &args.append_system_prompt {
452 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
453 append_texts.push(text);
454 }
455 if args.append_system_prompt.is_empty() {
456 if let Some(path) = project_trusted
457 .then(|| discover_append_system_prompt_file(cwd))
458 .flatten()
459 {
460 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
461 append_texts.push(text);
462 }
463 }
464 }
465 let append_join = if append_texts.is_empty() {
466 None
467 } else {
468 Some(append_texts.join("\n\n"))
469 };
470
471 let agent_dir = crate::config::agent_dir().ok();
483
484 let discovered = extension_session
500 .snapshot_arc()
501 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
502 .unwrap_or_default();
503
504 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
505 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
506 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
507 if !args.no_skills {
508 let mut dirs = if args.dev_local_only || args.project_only {
509 project_skill_dirs(cwd)
510 } else if project_trusted {
511 skill_dirs(cwd)
512 } else {
513 global_skill_dirs()
514 };
515 dirs.extend(args.skill.iter().cloned());
516 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
517 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
518 skills = result.skills;
519 skill_diags = result.diagnostics;
520 resource_diags.extend(result.resource_diagnostics);
521 }
522
523 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
524 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
525 if !args.no_prompt_templates {
526 let mut dirs = if args.dev_local_only || args.project_only {
527 project_prompt_template_dirs(cwd)
528 } else if project_trusted {
529 prompt_template_dirs(cwd)
530 } else {
531 global_prompt_template_dirs()
532 };
533 dirs.extend(args.prompt_template.iter().cloned());
534 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
535 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
536 prompt_templates = result.prompt_templates;
537 prompt_diags = result.diagnostics;
538 resource_diags.extend(result.resource_diagnostics);
539 }
540
541 let context_block = if args.no_context_files || !project_trusted {
542 String::new()
543 } else {
544 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
549 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
550 context_paths.extend(
551 files
552 .iter()
553 .map(|file| file.path.to_string_lossy().into_owned()),
554 );
555 format_project_context(&files)
556 };
557
558 if args.verbose {
560 for d in &skill_diags {
561 eprintln!(
562 "warning: skill {} ({}): {}",
563 d.path,
564 d.code.as_str(),
565 d.message
566 );
567 }
568 for d in &prompt_diags {
569 eprintln!(
570 "warning: prompt template {} ({}): {}",
571 d.path,
572 d.code.as_str(),
573 d.message
574 );
575 }
576 for d in &resource_diags {
578 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
579 eprintln!(
580 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
581 c.resource_type.as_str(),
582 c.name,
583 c.winner_path,
584 c.loser_path
585 );
586 }
587 }
588 }
589
590 let system_prompt = compose_system_prompt(
599 Some(&base_prompt),
600 &[], if context_block.is_empty() {
602 None
603 } else {
604 Some(&context_block)
605 },
606 append_join.as_deref(),
607 );
608
609 if args.debug_system_prompt {
618 eprintln!("=== --debug-system-prompt ===");
619 let base_src = if args.system_prompt.is_some() {
620 "--system-prompt"
621 } else if discover_system_prompt_file(cwd).is_some() {
622 "SYSTEM.md"
623 } else {
624 "default"
625 };
626 eprintln!("[base source: {base_src}]");
627 eprintln!("--- base ---\n{base_prompt}");
628 if let Some(append) = append_join.as_deref() {
629 eprintln!("--- append ---\n{append}");
630 } else {
631 eprintln!("--- append: (none) ---");
632 }
633 if context_block.is_empty() {
634 eprintln!("--- context: (none) ---");
635 } else {
636 eprintln!("--- context ---{context_block}");
637 }
638 let visible_skills = skills
639 .iter()
640 .filter(|s| s.disable_model_invocation != Some(true))
641 .count();
642 eprintln!(
643 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
644 skills.len(),
645 visible_skills,
646 skills.len() - visible_skills
647 );
648 for s in &skills {
649 let hidden = if s.disable_model_invocation == Some(true) {
650 " [hidden]"
651 } else {
652 ""
653 };
654 eprintln!(" {}{hidden} — {}", s.name, s.description);
655 }
656 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
657 for t in &prompt_templates {
658 eprintln!(" /{}", t.name);
659 }
660 eprintln!(
664 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
665 discovered.skill_paths.len(),
666 discovered.prompt_paths.len(),
667 discovered.theme_paths.len(),
668 );
669 for p in &discovered.skill_paths {
670 eprintln!(" skill: {p}");
671 }
672 for p in &discovered.prompt_paths {
673 eprintln!(" prompt: {p}");
674 }
675 eprintln!(
676 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
677 );
678 eprintln!("=== end --debug-system-prompt ===");
679 }
680
681 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
686 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
687 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
691
692 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
701 Some(snapshot) => {
702 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
703 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
704 }
705 None => broadcast_emitter,
706 };
707
708 let saved_settings = crate::settings::load_settings().unwrap_or_default();
712
713 if let Some(proxy) = &saved_settings.http_proxy {
718 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
719 if std::env::var_os(key).is_none() {
720 std::env::set_var(key, proxy);
721 }
722 }
723 }
724
725 let extension_session_cell = Arc::new(Mutex::new(extension_session.clone()));
729 let before_tool_call = Some(extension_tool_call_hook(
730 Arc::clone(&extension_session_cell),
731 ui_dialog_mailbox.clone(),
732 ));
733
734 let options = AgentHarnessOptions {
735 model: resolved.model.clone(),
736 thinking_level: resolved.thinking_level,
737 active_tool_names: active,
738 tools,
739 system_prompt: Some(system_prompt),
740 resources: AgentHarnessResources {
741 context_files: context_paths,
742 skills: if skills.is_empty() {
743 None
744 } else {
745 Some(skills)
746 },
747 prompt_templates: if prompt_templates.is_empty() {
748 None
749 } else {
750 Some(prompt_templates)
751 },
752 },
753 allow_existing_session: matches!(
756 selection,
757 SessionSelection::Latest { .. }
758 | SessionSelection::ById { .. }
759 | SessionSelection::ByExactId { .. }
760 | SessionSelection::Fork { .. }
761 ),
762 stream_options: AgentHarnessStreamOptions {
763 timeout: args.timeout,
764 ..Default::default()
765 },
766 retry: saved_settings.retry_policy(),
771 compaction: saved_settings.compaction_settings(),
772 steering_mode: saved_settings.steering_mode(),
773 follow_up_mode: saved_settings.follow_up_mode(),
774 tool_execution: HarnessToolExecution::default(),
775 drive: DrivingMode::default(),
776 session,
777 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
785 to_provider_messages: None,
786 entry_projectors: Default::default(),
787 agent_emitter: Some(emitter),
788 before_tool_call,
793 after_tool_call: None,
794 transform_context: None,
795 entry_transforms: Vec::new(),
796 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
802 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
803 };
804
805 let harness = match AgentHarness::create(options).await {
806 Ok(h) => {
807 crate::extensions_actions::HarnessActionHost::set_harness(
810 &harness_cell,
811 Arc::new(h.clone()),
812 );
813 h
814 }
815 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
816 };
817
818 {
826 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
827 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
828 Ok(reports) => {
829 for (lane, report) in reports {
830 if report.is_clean() {
831 continue;
832 }
833 if report.is_corrupt() {
834 eprintln!(
835 "warning: session lane '{lane}' is corrupt ({} open operations); \
836 refusing to auto-repair — inspect the session file",
837 report.open_operations.len()
838 );
839 continue;
840 }
841 for decision in runtime.reconcile(&report) {
842 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
843 if args.verbose {
844 eprintln!(
845 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
846 );
847 }
848 }
849 }
850 runtime.emit_recovery_events(harness.events(), &report);
851 }
852 }
853 Err(e) => {
854 if args.verbose {
855 eprintln!("warning: session recovery sweep failed: {e}");
856 }
857 }
858 }
859 }
860
861 let reload_context = ReloadContext {
868 extension_session: extension_session_cell,
869 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
870 tool_context,
871 catalog: Arc::new(std::sync::RwLock::new(catalog)),
872 providers: Arc::new(std::sync::RwLock::new(resolved.providers.clone())),
873 runtime: runtime.clone(),
874 cwd: cwd.to_path_buf(),
875 project_trusted,
876 args: args.clone(),
877 resolved_model: resolved.model.clone(),
878 broadcast: broadcast_for_context,
879 mailbox: reload_mailbox,
880 ui_dialog: ui_dialog_mailbox,
881 ext_status: ext_status_mailbox,
882 editor_text: editor_text_mailbox,
883 dev_extension: None,
884 };
885
886 Ok((harness, event_rx, reload_context))
887}
888
889fn extension_tool_call_hook(
892 sessions: ExtensionSessionCell,
893 ui_dialog: rpi_extensions::UiDialogMailbox,
894) -> rpi_agent::BeforeToolCall {
895 Arc::new(move |ctx: rpi_agent::BeforeToolCallContext<'_>, signal| {
896 let session = sessions
897 .lock()
898 .map(|session| session.clone())
899 .map_err(|_| ());
900 let ui_dialog = ui_dialog.clone();
901 let tool_call_id = ctx.tool_call.id.clone();
902 let tool_name = ctx.tool_call.name.clone();
903 let params = ctx.args.clone();
904 Box::pin(async move {
905 let reason = match session {
906 Ok(session) => {
907 rpi_extensions::dispatch_tool_call_event(
908 &session,
909 &tool_call_id,
910 &tool_name,
911 ¶ms,
912 &signal,
913 &ui_dialog,
914 )
915 .await
916 }
917 Err(_) => Some("extension approval session is unavailable".to_string()),
918 };
919 reason.map(|reason| rpi_agent::BeforeToolCallResult {
920 block: true,
921 reason: Some(reason),
922 terminate: false,
923 args: None,
924 })
925 })
926 })
927}
928
929pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
974
975pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
980
981#[derive(Clone)]
989pub struct ReloadContext {
990 pub extension_session: ExtensionSessionCell,
992 pub tool_context: rpi_extensions::ToolCallContext,
997 pub action_bridge: ActionBridgeCell,
999 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
1002 pub editor_text: rpi_extensions::EditorTextMailbox,
1009 pub catalog: Arc<std::sync::RwLock<Vec<rpi_ai::Model>>>,
1012 pub providers: Arc<std::sync::RwLock<Vec<Arc<dyn Provider>>>>,
1015 pub runtime: tokio::runtime::Handle,
1019 pub cwd: PathBuf,
1021 pub project_trusted: bool,
1025 pub args: Args,
1031 pub resolved_model: rpi_ai::Model,
1034 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
1040 pub mailbox: rpi_extensions::ReloadMailbox,
1047 pub ui_dialog: rpi_extensions::UiDialogMailbox,
1053 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
1057}
1058
1059pub async fn dispatch_session_event_async(
1074 reload_context: &ReloadContext,
1075 tag: rpi_plugin_sdk::EventTag,
1076) -> Option<String> {
1077 let (snapshot, keepalive) = {
1083 let session = reload_context.extension_session.lock().unwrap();
1084 (session.snapshot_arc(), session.keepalive())
1085 };
1086 match snapshot {
1087 Some(snapshot) => rpi_extensions::dispatch_lifecycle_event(&snapshot, tag, keepalive).await,
1088 None => None,
1089 }
1090}
1091
1092async fn shutdown_extensions_for_reload(
1095 snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
1096 keepalive: Arc<rpi_extensions::PluginKeepalive>,
1097) -> Result<(), String> {
1098 if let Some(snapshot) = snapshot {
1099 tokio::task::spawn_blocking(move || {
1100 let _keepalive = keepalive;
1101 rpi_extensions::dispatch_empty_event(
1102 &snapshot,
1103 rpi_plugin_sdk::EventTag::SessionShutdown,
1104 );
1105 })
1106 .await
1107 .map_err(|error| error.to_string())?;
1108 }
1109 Ok(())
1110}
1111
1112pub struct ReloadOutcome {
1115 pub summary: String,
1118 pub had_warnings: bool,
1120 pub details: Vec<String>,
1125 pub had_errors: bool,
1129}
1130
1131struct PreparedReloadInputs {
1132 extension_dirs: Vec<PathBuf>,
1133 skill_base_dirs: Vec<PathBuf>,
1134 prompt_base_dirs: Vec<PathBuf>,
1135}
1136
1137fn append_reload_resource_paths(
1142 mut paths: Vec<PathBuf>,
1143 explicit: &[PathBuf],
1144 discovered: &[String],
1145 js_paths: &[PathBuf],
1146 package_paths: &[PathBuf],
1147) -> Vec<PathBuf> {
1148 paths.extend(explicit.iter().cloned());
1149 paths.extend(discovered.iter().map(PathBuf::from));
1150 paths.extend(js_paths.iter().cloned());
1151 paths.extend(package_paths.iter().cloned());
1152 paths
1153}
1154
1155pub async fn reload_extension_resources(
1167 harness: &AgentHarness,
1168 ctx: &ReloadContext,
1169) -> ReloadOutcome {
1170 reload_extension_resources_inner(harness, ctx, || {}).await
1171}
1172
1173async fn reload_extension_resources_inner<F>(
1174 harness: &AgentHarness,
1175 ctx: &ReloadContext,
1176 after_prepare: F,
1177) -> ReloadOutcome
1178where
1179 F: FnOnce() + Send,
1180{
1181 let mut effective_args = ctx.args.clone();
1186 if let Some(dev) = &ctx.dev_extension {
1187 if let Err(error) = dev
1188 .rebuild()
1189 .and_then(|_| dev.apply_to_args(&mut effective_args))
1190 {
1191 return ReloadOutcome {
1192 summary: format!(
1193 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1194 dev.package_name()
1195 ),
1196 had_warnings: true,
1197 details: Vec::new(),
1198 had_errors: true,
1199 };
1200 }
1201 }
1202 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1203 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1204 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1205 Ok(prepared) => prepared,
1206 Err(error) => {
1207 return ReloadOutcome {
1208 summary: format!(
1209 "Settings reload failed: {error}. Keeping the currently loaded resources."
1210 ),
1211 had_warnings: true,
1212 details: Vec::new(),
1213 had_errors: true,
1214 };
1215 }
1216 };
1217 after_prepare();
1218 let PreparedReloadInputs {
1219 extension_dirs,
1220 skill_base_dirs,
1221 prompt_base_dirs,
1222 } = prepared;
1223 let mut warnings = false;
1224
1225 let (shutdown_snapshot, shutdown_keepalive) = {
1229 let session = ctx.extension_session.lock().unwrap();
1230 (session.snapshot_arc(), session.keepalive())
1231 };
1232 if let Err(reason) = shutdown_extensions_for_reload(shutdown_snapshot, shutdown_keepalive).await
1233 {
1234 return ReloadOutcome {
1235 summary: "Reload failed during extension shutdown".into(),
1236 had_warnings: true,
1237 details: vec![reason],
1238 had_errors: true,
1239 };
1240 }
1241
1242 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1249 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1250 Some(b) => b.clone_host(),
1251 None => {
1252 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1255 ctx.catalog
1256 .read()
1257 .unwrap_or_else(|error| error.into_inner())
1258 .clone(),
1259 ctx.cwd.clone(),
1260 ctx.runtime.clone(),
1261 ctx.args.unknown_flags.clone(),
1262 ctx.tool_context.clone(),
1263 );
1264 crate::extensions_actions::HarnessActionHost::set_harness(
1265 &_cell,
1266 Arc::new(harness.clone()),
1267 );
1268 Arc::new(action_host)
1269 }
1270 };
1271
1272 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1273 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1274 ctx.runtime.clone(),
1275 host,
1276 reload_cb,
1277 ctx.ui_dialog.clone(),
1278 ctx.ext_status.clone(),
1279 ctx.editor_text.clone(),
1280 );
1281
1282 let extension_session = if effective_args.no_extensions {
1283 rpi_extensions::ExtensionSession::none()
1284 } else {
1285 load_extensions_from_dirs(
1286 &effective_args,
1287 &extension_dirs,
1288 Some(Arc::clone(&fresh_bridge)),
1289 )
1290 };
1291 if extension_session.is_empty() && !effective_args.no_extensions {
1292 }
1295 if effective_args.verbose {
1296 if let Some(s) = extension_session.summary() {
1297 eprintln!("reload: {s}");
1298 }
1299 report_deferred_renderers(&extension_session);
1300 }
1301
1302 let has_package_providers = |session: &ExtensionSession| {
1303 session.snapshot().is_some_and(|snapshot| {
1304 snapshot
1305 .providers()
1306 .iter()
1307 .any(|provider| snapshot.provider_manifest(&provider.provider_id).is_some())
1308 })
1309 };
1310 let packaged = has_package_providers(&extension_session)
1311 || has_package_providers(&ctx.extension_session.lock().unwrap());
1312 let refreshed = if packaged {
1313 let mut args = effective_args.clone();
1314 if let Ok(current) = harness.lane("main").get_model().await {
1315 let was_package = ctx
1316 .extension_session
1317 .lock()
1318 .unwrap()
1319 .snapshot()
1320 .is_some_and(|snapshot| snapshot.provider_manifest(¤t.provider).is_some());
1321 let has_replacement = extension_session.snapshot().is_some_and(|snapshot| {
1322 snapshot
1323 .providers()
1324 .iter()
1325 .any(|provider| provider.provider_id == current.provider)
1326 });
1327 if was_package && !has_replacement {
1328 return ReloadOutcome {
1329 summary: "Reload failed while resolving provider packages".into(),
1330 had_warnings: true,
1331 details: vec![format!(
1332 "active provider package '{}' is no longer loaded",
1333 current.provider
1334 )],
1335 had_errors: true,
1336 };
1337 }
1338 args.provider = Some(current.provider.clone());
1339 args.model = Some(current.id);
1340 if current.provider != ctx.resolved_model.provider {
1341 args.api_key = None;
1342 args.base_url = None;
1343 }
1344 }
1345 match crate::provider::resolve_with_extensions(
1346 &args,
1347 &ctx.cwd,
1348 ctx.project_trusted,
1349 &extension_session,
1350 ) {
1351 Ok(resolved) => Some(resolved),
1352 Err(error) => {
1353 return ReloadOutcome {
1354 summary: "Reload failed while resolving provider packages".into(),
1355 had_warnings: true,
1356 details: vec![error.to_string()],
1357 had_errors: true,
1358 }
1359 }
1360 }
1361 } else {
1362 None
1363 };
1364
1365 {
1377 let mut session_guard = ctx.extension_session.lock().unwrap();
1378 let old_session = std::mem::replace(&mut *session_guard, extension_session.clone());
1379 if let Some(old_snap) = old_session.snapshot_arc() {
1380 old_snap.active_flag().store(false, Ordering::SeqCst);
1384 }
1385 }
1388 if let Some(old_b) = old_bridge {
1389 old_b.invalidate();
1390 }
1391
1392 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1394
1395 let discovered = extension_session
1397 .snapshot_arc()
1398 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1399 .unwrap_or_default();
1400
1401 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1403 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1404
1405 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1406 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1407 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1408 if !effective_args.no_skills {
1409 let js_paths: &[PathBuf] = &[];
1413 let package_paths: &[PathBuf] = &[];
1414 let dirs = append_reload_resource_paths(
1415 skill_base_dirs,
1416 &effective_args.skill,
1417 &discovered.skill_paths,
1418 js_paths,
1419 &package_paths,
1420 );
1421 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1422 skills = result.skills;
1423 skill_diags = result.diagnostics;
1424 resource_diags.extend(result.resource_diagnostics);
1425 }
1426
1427 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1428 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1429 if !effective_args.no_prompt_templates {
1430 let js_paths: &[PathBuf] = &[];
1431 let package_paths: &[PathBuf] = &[];
1432 let dirs = append_reload_resource_paths(
1433 prompt_base_dirs,
1434 &effective_args.prompt_template,
1435 &discovered.prompt_paths,
1436 js_paths,
1437 &package_paths,
1438 );
1439 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1440 prompt_templates = result.prompt_templates;
1441 prompt_diags = result.diagnostics;
1442 resource_diags.extend(result.resource_diagnostics);
1443 }
1444
1445 let context_files = if effective_args.no_context_files || !project_trusted {
1446 Vec::new()
1447 } else {
1448 let agent_dir = crate::config::agent_dir().ok();
1449 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1450 load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await
1451 };
1452 let context_block = format_project_context(&context_files);
1453
1454 let mut details: Vec<String> = Vec::new();
1455 if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
1456 warnings = true;
1457 for d in &skill_diags {
1462 details.push(format!(
1463 "warning: skill {} ({}): {}",
1464 d.path,
1465 d.code.as_str(),
1466 d.message
1467 ));
1468 }
1469 for d in &prompt_diags {
1470 details.push(format!(
1471 "warning: prompt template {} ({}): {}",
1472 d.path,
1473 d.code.as_str(),
1474 d.message
1475 ));
1476 }
1477 for d in &resource_diags {
1479 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1480 details.push(format!(
1481 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1482 c.resource_type.as_str(),
1483 c.name,
1484 c.winner_path,
1485 c.loser_path
1486 ));
1487 }
1488 }
1489 }
1490
1491 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1496 let mut context_paths = Vec::new();
1497 let base_prompt = match effective_args.system_prompt.as_deref() {
1498 Some(explicit) => explicit.to_string(),
1499 None if project_trusted => match discover_system_prompt_file(&ctx.cwd) {
1500 Some(path) => read_prompt_source(&path, &mut context_paths)
1501 .unwrap_or_else(|| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
1502 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1503 },
1504 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1505 };
1506 let mut append_texts: Vec<String> = Vec::new();
1507 for extra in &effective_args.append_system_prompt {
1508 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
1509 append_texts.push(text);
1510 }
1511 if effective_args.append_system_prompt.is_empty() {
1512 if let Some(path) = project_trusted
1513 .then(|| discover_append_system_prompt_file(&ctx.cwd))
1514 .flatten()
1515 {
1516 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
1517 append_texts.push(text);
1518 }
1519 }
1520 }
1521 context_paths.extend(
1522 context_files
1523 .iter()
1524 .map(|file| file.path.to_string_lossy().into_owned()),
1525 );
1526 let append_join = if append_texts.is_empty() {
1527 None
1528 } else {
1529 Some(append_texts.join("\n\n"))
1530 };
1531 let system_prompt = compose_system_prompt(
1532 Some(&base_prompt),
1533 &[],
1534 if context_block.is_empty() {
1535 None
1536 } else {
1537 Some(&context_block)
1538 },
1539 append_join.as_deref(),
1540 );
1541
1542 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1544 Some(snapshot) => {
1545 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1546 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1547 }
1548 None => ctx.broadcast.clone(),
1549 };
1550
1551 let resources = AgentHarnessResources {
1553 context_files: context_paths,
1554 skills: if skills.is_empty() {
1555 None
1556 } else {
1557 Some(skills.clone())
1558 },
1559 prompt_templates: if prompt_templates.is_empty() {
1560 None
1561 } else {
1562 Some(prompt_templates.clone())
1563 },
1564 };
1565 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1566 let _ = harness.set_resources(resources).await;
1567 let _ = harness.set_agent_emitter(Some(emitter)).await;
1568 let _ = harness
1569 .set_models(build_models_with_extensions_for_reload(
1570 &refreshed
1571 .as_ref()
1572 .map(|resolved| resolved.providers.clone())
1573 .unwrap_or_else(|| {
1574 ctx.providers
1575 .read()
1576 .unwrap_or_else(|error| error.into_inner())
1577 .clone()
1578 }),
1579 &extension_session,
1580 ctx.runtime.clone(),
1581 ))
1582 .await;
1583 let _ = harness
1584 .set_provider_hooks(
1585 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1586 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1587 )
1588 .await;
1589
1590 if let Some(resolved) = &refreshed {
1591 *ctx.providers
1592 .write()
1593 .unwrap_or_else(|error| error.into_inner()) = resolved.providers.clone();
1594 let catalog = crate::provider::authenticated_catalog(resolved);
1595 fresh_bridge
1596 .clone_host()
1597 .replace_model_catalog(catalog.clone());
1598 *ctx.catalog
1599 .write()
1600 .unwrap_or_else(|error| error.into_inner()) = catalog;
1601 let _ = harness.lane("main").set_model(resolved.model.clone()).await;
1604 }
1605
1606 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1610 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1611 let mut tools = build_tools(&tool_ctx, &effective_args);
1612 merge_extension_tools(
1613 &mut tools,
1614 &extension_session,
1615 &effective_args,
1616 &ctx.tool_context,
1617 );
1618 let mut active = active_tool_names(&tools, &effective_args);
1619 active = filter_active_tool_names(active, &effective_args);
1620 let _ = harness.set_tools(tools, Some(active)).await;
1621
1622 let summary = format!(
1623 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1624 extension_session.loaded_paths().len(),
1625 skills.len(),
1626 prompt_templates.len(),
1627 );
1628 ReloadOutcome {
1629 summary,
1630 had_warnings: warnings,
1631 details,
1632 had_errors: false,
1635 }
1636}
1637
1638#[derive(Clone, Debug, PartialEq, Eq)]
1639struct ReloadSettingsFields {
1640 skill_dirs: Option<Vec<String>>,
1641 prompt_dirs: Option<Vec<String>>,
1642 extension_dirs: Option<Vec<String>>,
1643}
1644
1645impl From<crate::settings::Settings> for ReloadSettingsFields {
1646 fn from(settings: crate::settings::Settings) -> Self {
1647 Self {
1648 skill_dirs: settings.skill_dirs,
1649 prompt_dirs: settings.prompt_dirs,
1650 extension_dirs: settings.extension_dirs,
1651 }
1652 }
1653}
1654
1655#[derive(Clone, Debug, PartialEq, Eq)]
1656struct ReloadSettingsSnapshot {
1657 global: ReloadSettingsFields,
1658 project: Option<ReloadSettingsFields>,
1659}
1660
1661fn reload_reads_settings(args: &Args) -> bool {
1662 !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
1663}
1664
1665fn load_reload_settings_snapshot(
1669 args: &Args,
1670 cwd: &Path,
1671 project_trusted: bool,
1672) -> Result<Option<ReloadSettingsSnapshot>, String> {
1673 if !reload_reads_settings(args) {
1674 return Ok(None);
1675 }
1676 let global = crate::settings::load_settings()
1677 .map_err(|error| format!("could not load global settings: {error}"))?
1678 .into();
1679 let project = if project_trusted {
1680 crate::settings::load_active_project_settings(cwd)
1681 .map_err(|error| format!("could not load project settings: {error}"))?
1682 .map(|(_, settings)| settings.into())
1683 } else {
1684 None
1685 };
1686 Ok(Some(ReloadSettingsSnapshot { global, project }))
1687}
1688
1689#[cfg(test)]
1692fn validate_settings_for_reload(
1693 args: &Args,
1694 cwd: &Path,
1695 project_trusted: bool,
1696) -> Result<(), String> {
1697 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1698}
1699
1700fn prepare_reload_inputs(
1701 args: &Args,
1702 cwd: &Path,
1703 project_trusted: bool,
1704) -> Result<PreparedReloadInputs, String> {
1705 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1706}
1707
1708fn prepare_reload_inputs_inner<F>(
1709 args: &Args,
1710 cwd: &Path,
1711 project_trusted: bool,
1712 before_verify: F,
1713) -> Result<PreparedReloadInputs, String>
1714where
1715 F: FnOnce(),
1716{
1717 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1718
1719 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1720 Vec::new()
1721 } else if args.project_only {
1722 project_extension_dirs(cwd)
1723 } else if project_trusted {
1724 extension_dirs(cwd)
1725 } else {
1726 global_extension_dirs()
1727 };
1728 if !args.no_extensions {
1729 extension_dirs.extend(args.extensions_dir.iter().cloned());
1730 }
1731
1732 let skill_base_dirs = if args.no_skills {
1733 Vec::new()
1734 } else if args.dev_local_only || args.project_only {
1735 project_skill_dirs(cwd)
1736 } else if project_trusted {
1737 skill_dirs(cwd)
1738 } else {
1739 global_skill_dirs()
1740 };
1741
1742 let prompt_base_dirs = if args.no_prompt_templates {
1743 Vec::new()
1744 } else if args.dev_local_only || args.project_only {
1745 project_prompt_template_dirs(cwd)
1746 } else if project_trusted {
1747 prompt_template_dirs(cwd)
1748 } else {
1749 global_prompt_template_dirs()
1750 };
1751
1752 before_verify();
1753 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1754 if settings_before != settings_after {
1755 return Err(
1756 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1757 );
1758 }
1759
1760 Ok(PreparedReloadInputs {
1761 extension_dirs,
1762 skill_base_dirs,
1763 prompt_base_dirs,
1764 })
1765}
1766
1767fn build_models_with_extensions_for_reload(
1772 providers: &[Arc<dyn Provider>],
1773 extension_session: &ExtensionSession,
1774 runtime: tokio::runtime::Handle,
1775) -> Vec<Arc<dyn Provider>> {
1776 let mut models: Vec<Arc<dyn Provider>> = providers.to_vec();
1777 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1778 for provider in pluggable {
1779 if extension_session
1783 .snapshot()
1784 .is_some_and(|snapshot| snapshot.provider_manifest(provider.id()).is_some())
1785 {
1786 continue;
1787 }
1788 if !models.iter().any(|known| known.id() == provider.id()) {
1789 models.push(provider);
1790 }
1791 }
1792 models
1793}
1794
1795fn report_deferred_renderers(session: &ExtensionSession) {
1799 let Some(snap) = session.snapshot_arc() else {
1800 return;
1801 };
1802 let all = snap.renderers();
1803 let markdown = all
1804 .iter()
1805 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1806 .count();
1807 let message = all
1808 .iter()
1809 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1810 .count();
1811 let entry = all
1812 .iter()
1813 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1814 .count();
1815 if markdown + message + entry == 0 {
1816 return;
1817 }
1818 eprintln!(
1819 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1820 markdown, message, entry
1821 );
1822}
1823
1824#[derive(Debug, thiserror::Error)]
1826pub enum BuildError {
1827 #[error("Could not create the session directory: {0}")]
1828 SessionDir(String),
1829 #[error("Could not open the saved session: {0}")]
1830 SessionOpen(String),
1831 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1832 SessionNotFound { requested: String, dir: String },
1833 #[error("Could not build the harness: {0}")]
1834 HarnessCreate(String),
1835}
1836
1837fn build_models_with_extensions(
1840 resolved: &ResolvedModel,
1841 extension_session: &ExtensionSession,
1842 runtime: tokio::runtime::Handle,
1843) -> Vec<Arc<dyn Provider>> {
1844 build_models_with_extensions_for_reload(&resolved.providers, extension_session, runtime)
1845}
1846
1847pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1850 if let Some(override_value) = args.trust_override {
1851 return override_value;
1852 }
1853 crate::config::project_trust_decision(cwd)
1854 .ok()
1855 .flatten()
1856 .unwrap_or(true)
1857}
1858
1859pub(crate) fn load_extensions(
1867 args: &Args,
1868 cwd: &Path,
1869 project_trusted: bool,
1870 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1871) -> ExtensionSession {
1872 if args.no_extensions {
1873 return ExtensionSession::none();
1874 }
1875 let mut dirs = if args.dev_local_only {
1876 Vec::new()
1877 } else if args.project_only {
1878 project_extension_dirs(cwd)
1879 } else if project_trusted {
1880 extension_dirs(cwd)
1881 } else {
1882 global_extension_dirs()
1883 };
1884 dirs.extend(args.extensions_dir.iter().cloned());
1885 load_extensions_from_dirs(args, &dirs, action_bridge)
1886}
1887
1888fn load_extensions_from_dirs(
1889 args: &Args,
1890 dirs: &[PathBuf],
1891 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1892) -> ExtensionSession {
1893 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1894 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1901}
1902
1903fn merge_extension_tools(
1909 tools: &mut Vec<HarnessTool>,
1910 session: &ExtensionSession,
1911 args: &Args,
1912 tool_context: &rpi_extensions::ToolCallContext,
1913) {
1914 let Some(snapshot) = session.snapshot() else {
1915 return;
1916 };
1917 for et in snapshot.tools() {
1918 let name = &et.tool.name;
1919 if !tool_name_allowed(name, args) {
1920 continue;
1921 }
1922 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
1923 .with_context(tool_context.clone());
1924 let harness_tool = HarnessTool::new(Arc::new(adapter));
1925 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1926 Some(slot) => *slot = harness_tool,
1927 None => tools.push(harness_tool),
1928 }
1929 }
1930}
1931
1932pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1939 use rpi_tools::tools::bash::BashToolOptions;
1940 let default = std::env::var("RPI_BASH_TIMEOUT")
1941 .ok()
1942 .and_then(|v| v.parse::<f64>().ok())
1943 .unwrap_or(120.0);
1944 let settings = crate::settings::load_settings().unwrap_or_default();
1950 BashToolOptions {
1951 command_prefix: settings.shell_command_prefix.clone(),
1952 default_timeout: Some(default),
1953 prepare: None,
1954 }
1955}
1956
1957fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1958 if args.no_tools {
1959 return Vec::new();
1960 }
1961 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1970 (
1971 "read",
1972 HarnessTool::new(create_read_tool(
1973 ctx,
1974 Some(rpi_tools::tools::read::ReadToolOptions {
1975 image_processor: Some(Arc::new(
1976 rpi_tools::image_processing::InlineImageProcessor,
1977 )),
1978 ..Default::default()
1979 }),
1980 ))
1981 .with_replay(ToolReplay::Safe),
1982 ),
1983 (
1984 "bash",
1985 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1986 ),
1987 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1988 ("write", HarnessTool::new(create_write_tool(ctx))),
1989 (
1990 "docs",
1991 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
1992 ),
1993 ];
1994
1995 #[cfg(target_os = "windows")]
1997 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
1998
1999 if args.tools.is_none() {
2001 if let Ok(settings) = crate::settings::load_settings() {
2002 if let Some(default_tools) = &settings.default_tools {
2003 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
2004 }
2005 }
2006 }
2007
2008 if args.no_builtin_tools {
2012 all.clear();
2013 }
2014
2015 if let Some(allow) = &args.tools {
2017 all.retain(|(name, _)| allow.iter().any(|a| a == name));
2018 }
2019 if let Some(deny) = &args.exclude_tools {
2021 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
2022 }
2023
2024 all.into_iter().map(|(_, t)| t).collect()
2025}
2026
2027fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
2031 filter_active_tool_names(
2032 tools.iter().map(|tool| tool.tool.schema().name.clone()),
2033 args,
2034 )
2035}
2036
2037pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
2040 if args.no_tools {
2041 return false;
2042 }
2043 if args
2044 .tools
2045 .as_ref()
2046 .is_some_and(|allow| !allow.iter().any(|value| value == name))
2047 {
2048 return false;
2049 }
2050 if args
2051 .exclude_tools
2052 .as_ref()
2053 .is_some_and(|deny| deny.iter().any(|value| value == name))
2054 {
2055 return false;
2056 }
2057 true
2058}
2059
2060pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
2061where
2062 I: IntoIterator<Item = String>,
2063{
2064 names
2065 .into_iter()
2066 .filter(|name| tool_name_allowed(name, args))
2067 .collect()
2068}
2069
2070async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2072 match selection {
2073 SessionSelection::Ephemeral => Ok(ephemeral_session()),
2074 SessionSelection::New { dir, .. } => {
2075 std::fs::create_dir_all(dir)
2078 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2079 let session = create_jsonl_session(dir, cwd)
2080 .await
2081 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2082 Ok(session)
2083 }
2084 SessionSelection::Latest { .. }
2085 | SessionSelection::ById { .. }
2086 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2087 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
2088 }
2089}
2090
2091async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2097 match selection {
2101 SessionSelection::Latest { dir } => {
2102 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2103 let Some(meta) = first_resumable_session(&metas) else {
2104 return Err(BuildError::SessionNotFound {
2105 requested: "the most recent session".to_string(),
2106 dir: default_session_dir(Path::new(cwd)).display().to_string(),
2107 });
2108 };
2109 open_session(meta, cwd).await
2110 }
2111 SessionSelection::ById { id, dir } => open_session_by_id_in_dir(id, cwd, dir)
2112 .await
2113 .map_err(|e| match e {
2114 OpenError::NotFound { requested } => BuildError::SessionNotFound {
2115 requested,
2116 dir: default_session_dir(Path::new(cwd)).display().to_string(),
2117 },
2118 OpenError::Other(msg) => BuildError::SessionOpen(msg),
2119 }),
2120 SessionSelection::ByExactId { id, dir } => {
2121 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2124 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
2125 return open_session(meta, cwd).await;
2126 }
2127 std::fs::create_dir_all(dir)
2128 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2129 create_jsonl_session_with_id(dir, cwd, Some(id.clone()))
2130 .await
2131 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2132 }
2133 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
2134 }
2135}
2136
2137async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
2140 use rpi_harness::session::jsonl::{
2141 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2142 };
2143 use rpi_harness::session::types::{ForkOptions, SessionStorage};
2144 use rpi_tools::FileSystem;
2145
2146 let dir = default_session_dir(Path::new(cwd));
2147 std::fs::create_dir_all(&dir)
2148 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2149 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2150 let fs: Arc<dyn FileSystem> = env.clone();
2151 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2152 fs: fs.clone(),
2153 sessions_root: dir.to_string_lossy().into_owned(),
2154 clock: Arc::new(SystemClock),
2155 ids: Arc::new(DefaultIdGenerator::new()),
2156 });
2157 let metas = repo
2158 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2159 .await
2160 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2161 let source_meta = metas
2162 .iter()
2163 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2164 .ok_or_else(|| BuildError::SessionNotFound {
2165 requested: format!("--fork {source}"),
2166 dir: dir.display().to_string(),
2167 })?;
2168 let fork_storage = repo
2169 .fork_typed(
2170 source_meta,
2171 &JsonlSessionCreateOptions {
2172 id: None,
2173 parent_session_id: Some(source_meta.id.clone()),
2174 cwd: cwd.to_string(),
2175 metadata: None,
2176 },
2177 &ForkOptions::default(),
2178 )
2179 .await
2180 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2181 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
2182 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2183 Ok(Session::new(storage_arc, None))
2184}
2185
2186pub enum OpenError {
2189 NotFound { requested: String },
2191 Other(String),
2193}
2194
2195impl std::fmt::Display for OpenError {
2196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2197 match self {
2198 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2199 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
2204 }
2205 }
2206}
2207
2208pub async fn list_session_metadata(
2211 cwd: &str,
2212) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2213 let dir = default_session_dir(Path::new(cwd));
2214 list_session_metadata_in_dir(cwd, &dir).await
2215}
2216
2217async fn list_session_metadata_in_dir(
2218 cwd: &str,
2219 dir: &Path,
2220) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2221 use rpi_harness::session::jsonl::{
2222 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2223 };
2224 use rpi_tools::FileSystem;
2225
2226 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2227 let fs: Arc<dyn FileSystem> = env.clone();
2228 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2229 fs: fs.clone(),
2230 sessions_root: dir.to_string_lossy().into_owned(),
2231 clock: Arc::new(SystemClock),
2232 ids: Arc::new(DefaultIdGenerator::new()),
2233 });
2234 repo.list_typed(&JsonlSessionListOptions::default())
2235 .await
2236 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2237}
2238
2239#[derive(Debug, Clone, Default, PartialEq, Eq)]
2255pub struct SessionSummary {
2256 pub name: Option<String>,
2258 pub preview: Option<String>,
2260 pub empty: bool,
2262 pub bytes: u64,
2264}
2265
2266const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
2268const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
2270const SUMMARY_PREVIEW_CHARS: usize = 80;
2272
2273pub fn summarize_session_file(path: &Path) -> SessionSummary {
2277 use std::io::{Read, Seek, SeekFrom};
2278
2279 let Ok(mut file) = std::fs::File::open(path) else {
2280 return SessionSummary::default();
2281 };
2282 let Ok(size) = file.metadata().map(|m| m.len()) else {
2283 return SessionSummary::default();
2284 };
2285 if size == 0 {
2286 return SessionSummary {
2287 name: None,
2288 preview: None,
2289 empty: true,
2290 bytes: 0,
2291 };
2292 }
2293
2294 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2297 let head_len = file.read(&mut head).unwrap_or(0);
2298 head.truncate(head_len);
2299 let head = String::from_utf8_lossy(&head);
2300 let mut lines: Vec<&str> = head.lines().collect();
2301 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2302 let empty = (size as usize) <= header_len + 1;
2304 if (head_len as u64) < size && !lines.is_empty() {
2307 lines.pop();
2308 }
2309
2310 let mut summary = SessionSummary {
2311 name: None,
2312 preview: None,
2313 empty,
2314 bytes: size,
2315 };
2316 for line in lines {
2317 apply_summary_line(line, &mut summary);
2318 }
2319
2320 if size > SUMMARY_HEAD_BYTES
2324 && file
2325 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2326 .is_ok()
2327 {
2328 let mut tail = Vec::new();
2329 if file.read_to_end(&mut tail).is_ok() {
2330 let tail = String::from_utf8_lossy(&tail);
2331 let mut tail_lines = tail.lines();
2332 tail_lines.next();
2334 for line in tail_lines {
2335 if let Some(name) = name_from_line(line) {
2336 summary.name = name;
2337 }
2338 }
2339 }
2340 }
2341 summary
2342}
2343
2344fn name_from_line(line: &str) -> Option<Option<String>> {
2347 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2348 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2349 return None;
2350 }
2351 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2352 return None;
2353 }
2354 Some(match value.get("name") {
2355 Some(serde_json::Value::String(name)) => Some(name.clone()),
2356 _ => None,
2357 })
2358}
2359
2360fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2363 if let Some(name) = name_from_line(line) {
2364 summary.name = name;
2365 return;
2366 }
2367 if summary.preview.is_some() {
2368 return;
2369 }
2370 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2371 return;
2372 };
2373 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2374 return;
2375 }
2376 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2377 return;
2378 }
2379 let Some(message) = value.get("message") else {
2380 return;
2381 };
2382 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2383 return;
2384 }
2385 let text = match message.get("content") {
2386 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2387 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2388 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2389 .then(|| block.get("text").and_then(|v| v.as_str()))
2390 .flatten()
2391 }),
2392 _ => None,
2393 };
2394 let Some(text) = text else { return };
2395 let collapsed = text
2396 .lines()
2397 .map(str::trim)
2398 .filter(|line| !line.is_empty())
2399 .collect::<Vec<_>>()
2400 .join(" ");
2401 if collapsed.is_empty() {
2402 return;
2403 }
2404 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2405}
2406
2407pub fn session_display_label(summary: &SessionSummary) -> String {
2411 if let Some(name) = summary.name.as_deref().map(str::trim) {
2412 if !name.is_empty() {
2413 return name.to_string();
2414 }
2415 }
2416 match summary.preview.as_deref().map(str::trim) {
2417 Some(preview) if !preview.is_empty() => preview.to_string(),
2418 _ => "(no messages)".to_string(),
2419 }
2420}
2421
2422pub fn format_session_bytes(bytes: u64) -> String {
2425 if bytes >= 1_048_576 {
2426 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2427 } else if bytes >= 1024 {
2428 format!("{}KB", bytes / 1024)
2429 } else {
2430 format!("{bytes}B")
2431 }
2432}
2433
2434pub fn short_session_id(id: &str) -> &str {
2437 &id[..id.len().min(8)]
2438}
2439
2440pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2449 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2450 let mut visit = |path: PathBuf| {
2451 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2452 return;
2453 }
2454 let modified = std::fs::metadata(&path)
2455 .and_then(|m| m.modified())
2456 .unwrap_or(std::time::UNIX_EPOCH);
2457 files.push((path, modified));
2458 };
2459 if let Ok(entries) = std::fs::read_dir(dir) {
2460 for entry in entries.flatten() {
2461 let path = entry.path();
2462 if path.is_dir() {
2463 if let Ok(inner) = std::fs::read_dir(&path) {
2464 for child in inner.flatten() {
2465 visit(child.path());
2466 }
2467 }
2468 } else {
2469 visit(path);
2470 }
2471 }
2472 }
2473 files.sort_by(|a, b| b.1.cmp(&a.1));
2474 files.into_iter().map(|(path, _)| path).collect()
2475}
2476
2477fn first_resumable_session<'a>(
2482 metas: &'a [rpi_harness::session::jsonl::JsonlSessionMetadata],
2483) -> Option<&'a rpi_harness::session::jsonl::JsonlSessionMetadata> {
2484 metas
2485 .iter()
2486 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2487 .or_else(|| metas.first())
2488}
2489
2490pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2494 let dir = default_session_dir(Path::new(cwd));
2495 open_session_by_id_in_dir(id, cwd, &dir).await
2496}
2497
2498async fn open_session_by_id_in_dir(id: &str, cwd: &str, dir: &Path) -> Result<Session, OpenError> {
2499 let metas = list_session_metadata_in_dir(cwd, dir)
2500 .await
2501 .map_err(|e| OpenError::Other(e.to_string()))?;
2502 let Some(meta) = metas
2503 .iter()
2504 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2505 else {
2506 return Err(OpenError::NotFound {
2507 requested: format!("session {id}"),
2508 });
2509 };
2510 open_session(meta, cwd).await.map_err(|e| {
2511 OpenError::Other(match e {
2512 BuildError::SessionOpen(reason) => reason,
2516 other => other.to_string(),
2517 })
2518 })
2519}
2520
2521fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
2546 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
2547 tool_context.set_session_id(id);
2548}
2549
2550pub(crate) async fn set_active_session(
2561 harness: &AgentHarness,
2562 session: Session,
2563 tool_context: Option<&rpi_extensions::ToolCallContext>,
2564) -> Result<(), String> {
2565 let metadata = session.get_metadata().await.ok();
2566 harness
2567 .set_session(session)
2568 .await
2569 .map_err(|e| e.to_string())?;
2570 if let Some(metadata) = metadata {
2571 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2572 if let Some(context) = tool_context {
2573 context.set_session_id(metadata.id);
2574 }
2575 }
2576 Ok(())
2577}
2578
2579pub(crate) async fn fork_session_storage(
2586 harness: &AgentHarness,
2587 cwd: &str,
2588) -> Result<Session, String> {
2589 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2590 use rpi_tools::FileSystem;
2591
2592 let dir = default_session_dir(Path::new(cwd));
2593 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2594 let fs: Arc<dyn FileSystem> = env.clone();
2595 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2596 fs,
2597 sessions_root: dir.to_string_lossy().into_owned(),
2598 clock: Arc::new(SystemClock),
2599 ids: Arc::new(DefaultIdGenerator::new()),
2600 });
2601 let id = harness.session().storage().metadata().id.clone();
2604 let metas = list_session_metadata(cwd)
2605 .await
2606 .map_err(|e| e.to_string())?;
2607 let Some(source) = metas.iter().find(|m| m.id == id) else {
2608 return Err(format!("current session {id} not found on disk"));
2609 };
2610 let fork_storage = repo
2611 .fork_typed(
2612 source,
2613 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2614 id: None,
2615 parent_session_id: Some(source.id.clone()),
2616 cwd: cwd.to_string(),
2617 metadata: None,
2618 },
2619 &rpi_harness::session::types::ForkOptions::default(),
2620 )
2621 .await
2622 .map_err(|e| e.to_string())?;
2623 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2624 Ok(Session::new(Arc::new(fork_storage), None))
2625}
2626
2627static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2629static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2632
2633fn claim_session_lock(session_path: &str) -> Result<(), String> {
2642 let lock_path = PathBuf::from(format!("{session_path}.lock"));
2643 if SESSION_LOCK_PATHS
2644 .get_or_init(|| Mutex::new(HashSet::new()))
2645 .lock()
2646 .unwrap()
2647 .contains(&lock_path)
2648 {
2649 return Ok(());
2650 }
2651 let file = std::fs::OpenOptions::new()
2652 .create(true)
2653 .write(true)
2654 .open(&lock_path)
2655 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2656 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2657 return Err(format!(
2658 "session {session_path} is already open in another rpi process"
2659 ));
2660 }
2661 SESSION_LOCKS
2662 .get_or_init(|| Mutex::new(Vec::new()))
2663 .lock()
2664 .unwrap()
2665 .push(file);
2666 SESSION_LOCK_PATHS
2667 .get_or_init(|| Mutex::new(HashSet::new()))
2668 .lock()
2669 .unwrap()
2670 .insert(lock_path);
2671 Ok(())
2672}
2673
2674async fn open_session(
2677 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2678 cwd: &str,
2679) -> Result<Session, BuildError> {
2680 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2681 use rpi_harness::session::types::SessionStorage;
2682 use rpi_tools::FileSystem;
2683
2684 let dir = default_session_dir(Path::new(cwd));
2685 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2686 let fs: Arc<dyn FileSystem> = env.clone();
2687 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2688 fs: fs.clone(),
2689 sessions_root: dir.to_string_lossy().into_owned(),
2690 clock: Arc::new(SystemClock),
2691 ids: Arc::new(DefaultIdGenerator::new()),
2692 });
2693 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2694 let storage = repo
2695 .open_by_jsonl_metadata(meta)
2696 .await
2697 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2698 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2702 Some(Path::new(&meta.path)),
2703 Path::new(&meta.cwd),
2704 Path::new(cwd),
2705 ) {
2706 eprintln!(
2707 "warning: {}",
2708 crate::session_cwd::format_missing_session_cwd_error(&issue)
2709 );
2710 }
2711 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2712 Ok(Session::new(storage_arc, None))
2713}
2714
2715fn ephemeral_session() -> Session {
2717 let storage = Arc::new(InMemorySessionStorage::new(
2718 SessionMetadata {
2719 id: "ephemeral".into(),
2720 created_at: 0,
2721 parent_session_id: None,
2722 },
2723 Arc::new(SystemClock),
2724 Arc::new(DefaultIdGenerator::new()),
2725 ));
2726 Session::new(storage, None)
2727}
2728
2729pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2740 create_jsonl_session_with_id(dir, cwd, None).await
2741}
2742
2743pub(crate) async fn create_jsonl_session_with_id(
2747 dir: &Path,
2748 cwd: &str,
2749 id: Option<String>,
2750) -> Result<Session, String> {
2751 use rpi_harness::session::jsonl::{
2752 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2753 };
2754 use rpi_tools::FileSystem;
2755
2756 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2759 let fs: Arc<dyn FileSystem> = env.clone();
2760
2761 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2762 fs: fs.clone(),
2763 sessions_root: dir.to_string_lossy().into_owned(),
2764 clock: Arc::new(SystemClock),
2765 ids: Arc::new(DefaultIdGenerator::new()),
2766 });
2767
2768 let opts = JsonlSessionCreateOptions {
2769 id, parent_session_id: None,
2771 cwd: cwd.to_string(),
2772 metadata: None,
2773 };
2774 let storage = repo
2775 .create_typed(&opts)
2776 .await
2777 .map_err(|e| format!("create session: {e}"))?;
2778 claim_session_lock(&storage.jsonl_metadata().path)
2779 .map_err(|e| format!("create session: {e}"))?;
2780 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2782 Ok(Session::new(storage_arc, None))
2783}
2784
2785fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
2788 let path = Path::new(target);
2789 if path.is_file() {
2790 read_prompt_source(path, sources)
2791 } else {
2792 None
2793 }
2794}
2795
2796fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
2799 let text = std::fs::read_to_string(path).ok()?;
2800 let absolute = if path.is_absolute() {
2801 path.to_path_buf()
2802 } else {
2803 std::env::current_dir()
2804 .map(|cwd| cwd.join(path))
2805 .unwrap_or_else(|_| path.to_path_buf())
2806 };
2807 let source = absolute.to_string_lossy().into_owned();
2808 if !sources.contains(&source) {
2809 sources.push(source);
2810 }
2811 Some(text)
2812}
2813
2814#[cfg(test)]
2815mod tests {
2816 use super::*;
2817
2818 #[tokio::test]
2819 async fn unauthenticated_packages_are_not_reintroduced_as_legacy_adapters() {
2820 extern "C" fn request(
2821 _: rpi_plugin_sdk::StbStringRef,
2822 _: *mut rpi_plugin_sdk::StbString,
2823 _: *mut std::ffi::c_void,
2824 ) -> i32 {
2825 1
2826 }
2827 let mut registry = rpi_extensions::ExtensionRegistry::new();
2828 for id in ["package", "legacy"] {
2829 registry.register_provider(rpi_extensions::RegisteredProvider {
2830 provider_id: id.into(),
2831 base_url: "http://localhost".into(),
2832 api_style: "openai-completions".into(),
2833 request_fn: request,
2834 stream_fn: None,
2835 plugin_free_string: rpi_plugin_sdk::callbacks::free_string,
2836 user_data: std::ptr::null_mut(),
2837 });
2838 }
2839 assert!(registry.apply_declaration_json(&serde_json::json!({"providers":[{
2840 "id":"package", "requiresAuth":true,
2841 "models":[rpi_ai::Model::new("model", "Model", rpi_ai::Api::OpenaiCompletions, "package", "http://localhost")]
2842 }]}).to_string()));
2843 let session = ExtensionSession::from_parts(
2844 Arc::new(registry.snapshot()),
2845 rpi_extensions::PluginKeepalive::empty(),
2846 vec![],
2847 None,
2848 );
2849 let providers = build_models_with_extensions_for_reload(
2850 &[],
2851 &session,
2852 tokio::runtime::Handle::current(),
2853 );
2854 assert_eq!(
2855 providers
2856 .iter()
2857 .map(|provider| provider.id())
2858 .collect::<Vec<_>>(),
2859 ["legacy"]
2860 );
2861 }
2862 use crate::args::Args;
2863
2864 #[test]
2865 fn loaded_context_paths_track_precedence_and_reload() {
2866 const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
2867 if std::env::var_os(CHILD).is_none() {
2868 let dir = tempfile::tempdir().unwrap();
2869 let output = std::process::Command::new(std::env::current_exe().unwrap())
2870 .args([
2871 "--exact",
2872 "session::tests::loaded_context_paths_track_precedence_and_reload",
2873 "--nocapture",
2874 ])
2875 .env(CHILD, "1")
2876 .env(crate::config::CONFIG_DIR_ENV, dir.path())
2877 .output()
2878 .unwrap();
2879 assert!(
2880 output.status.success(),
2881 "{}\n{}",
2882 String::from_utf8_lossy(&output.stdout),
2883 String::from_utf8_lossy(&output.stderr)
2884 );
2885 return;
2886 }
2887 tokio::runtime::Builder::new_current_thread()
2888 .enable_all()
2889 .build()
2890 .unwrap()
2891 .block_on(async {
2892 let agent = crate::config::agent_dir().unwrap();
2893 let cwd = agent.join("project");
2894 let project_config = cwd.join(".rpi");
2895 std::fs::create_dir_all(&project_config).unwrap();
2896 for (path, content) in [
2897 (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
2898 (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
2899 (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
2900 (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
2901 (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
2902 (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
2903 (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
2904 ] {
2905 std::fs::write(path, content).unwrap();
2906 }
2907 let resolved = crate::provider::resolve(
2908 Some("anthropic"),
2909 Some(crate::provider::DEFAULT_MODEL_ID),
2910 None,
2911 Some("test-key"),
2912 None,
2913 )
2914 .unwrap();
2915 let args = Args {
2916 no_session: true,
2917 no_extensions: true,
2918 no_skills: true,
2919 no_prompt_templates: true,
2920 trust_override: Some(true),
2921 ..Default::default()
2922 };
2923 let (harness, _events, mut context) =
2924 build(&resolved, &args, &cwd, true).await.unwrap();
2925 let resources = harness.get_resources().await.unwrap();
2926 let path = |p: PathBuf| p.to_string_lossy().into_owned();
2927 assert_eq!(
2928 &resources.context_files[..2],
2929 &[
2930 path(project_config.join("SYSTEM.md")),
2931 path(project_config.join("APPEND_SYSTEM.md")),
2932 ]
2933 );
2934 assert!(resources
2935 .context_files
2936 .contains(&path(agent.join("AGENTS.md"))));
2937 assert!(resources
2938 .context_files
2939 .contains(&path(cwd.join("AGENTS.md"))));
2940 assert!(!resources
2941 .context_files
2942 .contains(&path(cwd.join("CLAUDE.md"))));
2943 assert!(!resources
2944 .context_files
2945 .contains(&path(agent.join("SYSTEM.md"))));
2946 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
2947 assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
2948 assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
2949
2950 std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
2953 std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
2954 std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
2955 assert!(
2956 !reload_extension_resources(&harness, &context)
2957 .await
2958 .had_errors
2959 );
2960 let resources = harness.get_resources().await.unwrap();
2961 assert_eq!(
2962 &resources.context_files[..2],
2963 &[
2964 path(agent.join("SYSTEM.md")),
2965 path(agent.join("APPEND_SYSTEM.md")),
2966 ]
2967 );
2968 assert!(resources
2969 .context_files
2970 .contains(&path(cwd.join("CLAUDE.md"))));
2971 assert!(!resources
2972 .context_files
2973 .contains(&path(project_config.join("SYSTEM.md"))));
2974
2975 let explicit = agent.join("custom-append.md");
2978 std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
2979 context.args.system_prompt = Some("EXPLICIT_BASE".into());
2980 context.args.append_system_prompt =
2981 vec![path(explicit.clone()), "literal append".into()];
2982 context.args.no_context_files = true;
2983 assert!(
2984 !reload_extension_resources(&harness, &context)
2985 .await
2986 .had_errors
2987 );
2988 assert_eq!(
2989 harness.get_resources().await.unwrap().context_files,
2990 vec![path(explicit)]
2991 );
2992
2993 context.args.system_prompt = None;
2995 context.args.append_system_prompt.clear();
2996 context.args.no_context_files = false;
2997 context.args.trust_override = Some(false);
2998 assert!(
2999 !reload_extension_resources(&harness, &context)
3000 .await
3001 .had_errors
3002 );
3003 assert!(harness
3004 .get_resources()
3005 .await
3006 .unwrap()
3007 .context_files
3008 .is_empty());
3009 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3010 assert!(
3011 !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
3012 );
3013 });
3014 }
3015
3016 #[test]
3017 fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
3018 let dir = tempfile::tempdir().unwrap();
3019 let file = dir.path().join("SYSTEM.md");
3020 let mut sources = Vec::new();
3021 assert!(read_prompt_source(&file, &mut sources).is_none());
3022 std::fs::write(&file, [0xff, 0xfe]).unwrap();
3023 assert!(read_prompt_source(&file, &mut sources).is_none());
3024 assert!(read_append_target("literal instructions", &mut sources).is_none());
3025 assert!(sources.is_empty());
3026 std::fs::write(&file, "loaded instructions").unwrap();
3027 assert_eq!(
3028 read_prompt_source(&file, &mut sources).as_deref(),
3029 Some("loaded instructions")
3030 );
3031 assert!(read_prompt_source(&file, &mut sources).is_some());
3032 assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
3033 }
3034
3035 extern "C" fn deny_test_tool_call(
3036 _event: rpi_plugin_sdk::StablePluginEvent,
3037 _data: *mut std::ffi::c_void,
3038 ) -> i32 {
3039 rpi_plugin_sdk::EVENT_HANDLER_ABORT
3040 }
3041
3042 async fn invoke_test_tool_hook(
3043 hook: &rpi_agent::BeforeToolCall,
3044 ) -> Option<rpi_agent::BeforeToolCallResult> {
3045 let message = rpi_ai::providers::faux::faux_assistant_message(
3046 "approval",
3047 rpi_ai::types::StopReason::ToolUse,
3048 );
3049 let rpi_ai::types::Content::ToolCall(call) =
3050 rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
3051 else {
3052 unreachable!()
3053 };
3054 hook(
3055 rpi_agent::BeforeToolCallContext {
3056 assistant_message: &message,
3057 tool_call: &call,
3058 args: &call.arguments,
3059 context: &rpi_agent::AgentContext::default(),
3060 },
3061 tokio_util::sync::CancellationToken::new(),
3062 )
3063 .await
3064 }
3065
3066 #[tokio::test]
3067 async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
3068 let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
3069 let hook =
3070 extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
3071 assert!(invoke_test_tool_hook(&hook).await.is_none());
3072 for name in ["first-gate", "reloaded-gate"] {
3073 let mut registry = rpi_extensions::ExtensionRegistry::new();
3074 registry.register_event_handler(
3075 name.into(),
3076 rpi_plugin_sdk::EventTag::ToolCall,
3077 deny_test_tool_call,
3078 std::ptr::null_mut(),
3079 );
3080 let fresh = ExtensionSession::from_parts(
3081 Arc::new(registry.snapshot()),
3082 rpi_extensions::PluginKeepalive::empty(),
3083 Vec::new(),
3084 None,
3085 );
3086 {
3087 let mut current = sessions.lock().unwrap();
3088 if let Some(snapshot) = current.snapshot() {
3089 snapshot.active_flag().store(false, Ordering::SeqCst);
3090 }
3091 *current = fresh;
3092 }
3093 let result = invoke_test_tool_hook(&hook).await.unwrap();
3094 assert!(result.block);
3095 assert!(result.reason.unwrap().contains(name));
3096 }
3097 *sessions.lock().unwrap() = ExtensionSession::none();
3098 assert!(invoke_test_tool_hook(&hook).await.is_none());
3099 }
3100
3101 fn scratch(name: &str) -> PathBuf {
3103 let nanos = std::time::SystemTime::now()
3104 .duration_since(std::time::UNIX_EPOCH)
3105 .unwrap()
3106 .as_nanos();
3107 let path = std::env::temp_dir().join(format!(
3108 "rpi-summary-{}-{nanos}-{name}.jsonl",
3109 std::process::id()
3110 ));
3111 path
3112 }
3113
3114 #[test]
3115 fn summary_marks_a_header_only_session_empty() {
3116 let path = scratch("empty");
3117 std::fs::write(
3118 &path,
3119 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3120 )
3121 .unwrap();
3122 let summary = summarize_session_file(&path);
3123 assert!(summary.empty, "a header-only file has nothing to resume");
3124 assert!(summary.preview.is_none());
3125 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
3126 std::fs::remove_file(&path).ok();
3127 }
3128
3129 #[test]
3130 fn summary_reads_the_first_user_prompt_and_name() {
3131 let path = scratch("content");
3132 let body = concat!(
3133 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3134 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
3135 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
3136 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
3137 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
3138 );
3139 std::fs::write(&path, body).unwrap();
3140 let summary = summarize_session_file(&path);
3141 assert!(!summary.empty);
3142 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
3143 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
3144 std::fs::remove_file(&path).ok();
3145 }
3146
3147 #[test]
3148 fn summary_extracts_a_block_style_user_prompt() {
3149 let path = scratch("blocks");
3150 let body = concat!(
3151 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3152 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
3153 );
3154 std::fs::write(&path, body).unwrap();
3155 let summary = summarize_session_file(&path);
3156 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
3157 assert_eq!(session_display_label(&summary), "line one line two");
3158 std::fs::remove_file(&path).ok();
3159 }
3160
3161 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
3162 rpi_harness::session::jsonl::JsonlSessionMetadata {
3163 id: id.to_string(),
3164 created_at: 0,
3165 cwd: ".".to_string(),
3166 path: path.to_string_lossy().into_owned(),
3167 modified_at: 0,
3168 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
3169 parent_session_id: None,
3170 legacy_parent_session_path: None,
3171 metadata: None,
3172 }
3173 }
3174
3175 #[test]
3176 fn continue_skips_newer_empty_sessions() {
3177 let empty = scratch("newest-empty");
3178 std::fs::write(
3179 &empty,
3180 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3181 )
3182 .unwrap();
3183 let content = scratch("older-content");
3184 std::fs::write(
3185 &content,
3186 concat!(
3187 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3188 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
3189 ),
3190 )
3191 .unwrap();
3192
3193 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
3194 let picked = first_resumable_session(&metas).expect("a resumable session");
3195 assert_eq!(picked.id, "old");
3196
3197 let all_empty = vec![meta_for(&empty, "empty")];
3200 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
3201
3202 std::fs::remove_file(&empty).ok();
3203 std::fs::remove_file(&content).ok();
3204 }
3205
3206 #[test]
3207 fn list_session_files_sync_walks_the_cwd_subdirectory() {
3208 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
3209 let nested = root.join("--proj--");
3210 std::fs::create_dir_all(&nested).unwrap();
3211 let file = nested.join("2026-01-01_a.jsonl");
3212 std::fs::write(&file, "{}\n").unwrap();
3213 let found = list_session_files_sync(&root);
3214 assert!(found.iter().any(|p| p == &file));
3215 std::fs::remove_dir_all(&root).ok();
3216 }
3217
3218 #[test]
3219 fn default_prompt_mentions_cwd_and_tools() {
3220 let p = default_system_prompt("/tmp/proj", false);
3221 assert!(p.contains("/tmp/proj"));
3222 assert!(p.contains("read"));
3223 assert!(p.contains("bash"));
3224 assert!(p.contains("edit"));
3225 assert!(p.contains("write"));
3226 assert!(p.contains("docs"));
3227 assert!(!p.contains("- grep"));
3228 assert!(!p.contains("- find"));
3229 assert!(!p.contains("- ls"));
3230 assert!(!p.contains("- powershell"));
3231 assert!(p.contains("Bash/POSIX syntax, including on Windows"));
3232 assert!(p.contains("do not pass PowerShell cmdlets"));
3233 }
3234
3235 #[test]
3236 fn default_prompt_instructs_language_matching() {
3237 let p = default_system_prompt("/tmp/proj", false);
3241 let lower = p.to_lowercase();
3242 assert!(
3243 lower.contains("same language") && lower.contains("detect"),
3244 "the prompt must tell the model to detect and mirror the user's language: {p}"
3245 );
3246 }
3247
3248 #[test]
3249 fn default_prompt_keeps_working_state_in_the_visible_channel() {
3250 let p = default_system_prompt("/tmp/proj", false);
3258 let lower = p.to_lowercase();
3259 assert!(
3261 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
3262 "the prompt must explain that reasoning does not survive the turn: {p}"
3263 );
3264 assert!(
3266 lower.contains("visible") && lower.contains("todo tool"),
3267 "the prompt must point at visible text and the todo tool: {p}"
3268 );
3269 assert!(
3271 lower.contains("re-stating the same plan"),
3272 "the prompt must call out plan re-statement as a bug: {p}"
3273 );
3274 assert!(
3276 lower.contains("already shown earlier"),
3277 "the prompt must discourage re-fetching known content: {p}"
3278 );
3279 }
3280
3281 #[test]
3282 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
3283 let p = default_system_prompt("/tmp/proj", true);
3290 let lower = p.to_lowercase();
3291 assert!(
3292 lower.contains("carried back"),
3293 "a replaying model must be told its reasoning survives: {p}"
3294 );
3295 assert!(
3296 !lower.contains("reasoning is not"),
3297 "the replayed variant must not claim reasoning is dropped: {p}"
3298 );
3299 assert!(
3300 lower.contains("do not re-derive"),
3301 "the replayed variant must forbid re-deriving the plan: {p}"
3302 );
3303 assert!(
3304 lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
3305 "the anti-repetition rule must survive the flip: {p}"
3306 );
3307 assert!(
3308 lower.contains("already shown earlier"),
3309 "the re-read rule must survive the flip: {p}"
3310 );
3311 }
3312
3313 #[test]
3321 fn only_read_only_tools_are_replayable() {
3322 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3323 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3324 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3325 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3326 let tools = build_tools(&context, &Args::default());
3327
3328 let replay_of = |name: &str| {
3329 tools
3330 .iter()
3331 .find(|tool| tool.tool.schema().name == name)
3332 .map(|tool| tool.replay)
3333 };
3334
3335 for read_only in ["read", "docs"] {
3336 assert_eq!(
3337 replay_of(read_only),
3338 Some(ToolReplay::Safe),
3339 "{read_only} is side-effect free, so recovery may re-run it"
3340 );
3341 }
3342 for mutating in ["bash", "edit", "write", "powershell"] {
3343 if let Some(replay) = replay_of(mutating) {
3344 assert_eq!(
3345 replay,
3346 ToolReplay::Never,
3347 "{mutating} must never be replayed: re-running it would duplicate its effect"
3348 );
3349 }
3350 }
3351 }
3352
3353 #[test]
3354 fn default_tools_include_docs_lookup() {
3355 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3356 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3357 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3358 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3359 let names: Vec<String> = build_tools(&context, &Args::default())
3360 .iter()
3361 .map(|tool| tool.tool.schema().name.clone())
3362 .collect();
3363
3364 assert!(names.contains(&"docs".to_string()));
3369 for legacy in ["grep", "find", "ls"] {
3370 assert!(
3371 !names.contains(&legacy.to_string()),
3372 "legacy tool {legacy} should not be registered by default"
3373 );
3374 }
3375 }
3376
3377 #[test]
3378 fn reload_settings_preflight_respects_trust() {
3379 struct RestoreConfigDir(Option<std::ffi::OsString>);
3380
3381 impl Drop for RestoreConfigDir {
3382 fn drop(&mut self) {
3383 match self.0.take() {
3384 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3385 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3386 }
3387 }
3388 }
3389
3390 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3391 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3392 let tmp = tempfile::tempdir().unwrap();
3393 let agent = tmp.path().join("agent");
3394 let cwd = tmp.path().join("project");
3395 std::fs::create_dir_all(&agent).unwrap();
3396 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3397 std::fs::write(agent.join("settings.json"), "{}").unwrap();
3398 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
3399 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3400 let args = Args::default();
3401
3402 let trusted = validate_settings_for_reload(&args, &cwd, true);
3403 let untrusted = validate_settings_for_reload(&args, &cwd, false);
3404 assert!(trusted
3405 .unwrap_err()
3406 .contains("could not load project settings"));
3407 assert!(untrusted.is_ok());
3408
3409 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3410 assert!(validate_settings_for_reload(&args, &cwd, false)
3411 .unwrap_err()
3412 .contains("could not load global settings"));
3413
3414 let local_only = Args {
3415 dev_local_only: true,
3416 ..Args::default()
3417 };
3418 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
3419 }
3420
3421 #[tokio::test(flavor = "current_thread")]
3422 async fn reload_preserves_live_resources_when_settings_break() {
3423 struct RestoreConfigDir(Option<std::ffi::OsString>);
3424
3425 impl Drop for RestoreConfigDir {
3426 fn drop(&mut self) {
3427 match self.0.take() {
3428 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3429 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3430 }
3431 }
3432 }
3433
3434 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3435 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3436 let tmp = tempfile::tempdir().unwrap();
3437 let agent = tmp.path().join("agent");
3438 let cwd = tmp.path().join("project");
3439 let skill_dir = tmp.path().join("configured-skills");
3440 std::fs::create_dir_all(&agent).unwrap();
3441 std::fs::create_dir_all(&cwd).unwrap();
3442 std::fs::create_dir_all(&skill_dir).unwrap();
3443 std::fs::write(
3444 skill_dir.join("SKILL.md"),
3445 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
3446 )
3447 .unwrap();
3448 std::fs::write(
3449 agent.join("settings.json"),
3450 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3451 )
3452 .unwrap();
3453 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3454
3455 let resolved = crate::provider::resolve(
3456 Some("anthropic"),
3457 Some(crate::provider::DEFAULT_MODEL_ID),
3458 None,
3459 Some("test-key"),
3460 None,
3461 )
3462 .unwrap();
3463 let args = Args {
3464 trust_override: Some(false),
3465 no_session: true,
3466 no_extensions: true,
3467 no_prompt_templates: true,
3468 no_context_files: true,
3469 system_prompt: Some("stable system prompt".into()),
3470 ..Args::default()
3471 };
3472 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3473 let before_resources = harness.get_resources().await.unwrap();
3474 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
3475 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
3476 let before_prompt = harness.get_system_prompt().await.unwrap();
3477 let before_tools: Vec<String> = harness
3478 .get_tools()
3479 .await
3480 .unwrap()
3481 .iter()
3482 .map(|tool| tool.tool.schema().name.clone())
3483 .collect();
3484 let before_bridge = context
3485 .action_bridge
3486 .lock()
3487 .unwrap()
3488 .as_ref()
3489 .unwrap()
3490 .clone();
3491
3492 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3493 let outcome = reload_extension_resources(&harness, &context).await;
3494
3495 assert!(outcome.had_warnings);
3496 assert!(outcome.summary.contains("Settings reload failed"));
3497 assert!(outcome.summary.contains("could not load global settings"));
3498 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
3499 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
3500 let after_tools: Vec<String> = harness
3501 .get_tools()
3502 .await
3503 .unwrap()
3504 .iter()
3505 .map(|tool| tool.tool.schema().name.clone())
3506 .collect();
3507 assert_eq!(after_tools, before_tools);
3508 let after_bridge = context
3509 .action_bridge
3510 .lock()
3511 .unwrap()
3512 .as_ref()
3513 .unwrap()
3514 .clone();
3515 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
3516 }
3517
3518 #[test]
3519 fn reload_preparation_rejects_settings_changed_during_derivation() {
3520 struct RestoreConfigDir(Option<std::ffi::OsString>);
3521
3522 impl Drop for RestoreConfigDir {
3523 fn drop(&mut self) {
3524 match self.0.take() {
3525 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3526 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3527 }
3528 }
3529 }
3530
3531 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3532 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3533 let tmp = tempfile::tempdir().unwrap();
3534 let agent = tmp.path().join("agent");
3535 let cwd = tmp.path().join("project");
3536 let first_skill_dir = tmp.path().join("first-skills");
3537 let second_skill_dir = tmp.path().join("second-skills");
3538 std::fs::create_dir_all(&agent).unwrap();
3539 std::fs::create_dir_all(&cwd).unwrap();
3540 std::fs::write(
3541 agent.join("settings.json"),
3542 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3543 )
3544 .unwrap();
3545 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3546 let args = Args {
3547 trust_override: Some(false),
3548 no_extensions: true,
3549 no_prompt_templates: true,
3550 ..Args::default()
3551 };
3552
3553 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3554 std::fs::write(
3555 agent.join("settings.json"),
3556 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3557 )
3558 .unwrap();
3559 });
3560
3561 let error = result
3562 .err()
3563 .expect("settings mutation must fail preparation");
3564 assert!(error.contains("settings changed while reload inputs were being prepared"));
3565 }
3566
3567 #[tokio::test(flavor = "current_thread")]
3568 async fn reload_uses_frozen_settings_inputs_after_preparation() {
3569 struct RestoreConfigDir(Option<std::ffi::OsString>);
3570
3571 impl Drop for RestoreConfigDir {
3572 fn drop(&mut self) {
3573 match self.0.take() {
3574 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3575 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3576 }
3577 }
3578 }
3579
3580 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3581 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3582 let tmp = tempfile::tempdir().unwrap();
3583 let agent = tmp.path().join("agent");
3584 let cwd = tmp.path().join("project");
3585 let skill_dir = tmp.path().join("configured-skills");
3586 std::fs::create_dir_all(&agent).unwrap();
3587 std::fs::create_dir_all(&cwd).unwrap();
3588 std::fs::create_dir_all(&skill_dir).unwrap();
3589 std::fs::write(
3590 skill_dir.join("SKILL.md"),
3591 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3592 )
3593 .unwrap();
3594 std::fs::write(
3595 agent.join("settings.json"),
3596 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3597 )
3598 .unwrap();
3599 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3600
3601 let resolved = crate::provider::resolve(
3602 Some("anthropic"),
3603 Some(crate::provider::DEFAULT_MODEL_ID),
3604 None,
3605 Some("test-key"),
3606 None,
3607 )
3608 .unwrap();
3609 let args = Args {
3610 trust_override: Some(false),
3611 no_session: true,
3612 no_extensions: true,
3613 no_prompt_templates: true,
3614 no_context_files: true,
3615 system_prompt: Some("stable system prompt".into()),
3616 ..Args::default()
3617 };
3618 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3619
3620 let outcome = reload_extension_resources_inner(&harness, &context, || {
3621 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3622 })
3623 .await;
3624
3625 assert!(!outcome.summary.contains("Settings reload failed"));
3626 assert_eq!(
3627 outcome.summary,
3628 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3629 );
3630 let resources = harness.get_resources().await.unwrap();
3631 let skills = resources.skills.as_ref().unwrap();
3632 assert_eq!(skills.len(), 1);
3633 assert_eq!(skills[0].name, "frozen-skill");
3634 }
3635
3636 #[tokio::test]
3637 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3638 let tmp = tempfile::tempdir().unwrap();
3639 let skill_path = tmp.path().join("explicit-skill.md");
3640 std::fs::write(
3641 &skill_path,
3642 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3643 )
3644 .unwrap();
3645 let prompt_path = tmp.path().join("explicit-prompt.md");
3646 std::fs::write(
3647 &prompt_path,
3648 "---\ndescription: Explicit prompt\n---\nPrompt body",
3649 )
3650 .unwrap();
3651
3652 let args = Args {
3653 skill: vec![skill_path.clone()],
3654 prompt_template: vec![prompt_path.clone()],
3655 ..Args::default()
3656 };
3657 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3658 let prompt_paths =
3659 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3660 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3661 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3662
3663 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3664 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3665 assert_eq!(skills.skills[0].name, "explicit-skill");
3666
3667 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3668 assert_eq!(
3669 prompts.prompt_templates.len(),
3670 1,
3671 "{:?}",
3672 prompts.diagnostics
3673 );
3674 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3675 }
3676
3677 #[test]
3678 fn tool_policy_applies_to_rust_and_js_active_names() {
3679 let names = vec![
3680 "read".to_string(),
3681 "ask_user_question".to_string(),
3682 "write".to_string(),
3683 ];
3684
3685 let args = Args {
3686 tools: Some(vec!["read".into(), "ask_user_question".into()]),
3687 ..Args::default()
3688 };
3689 assert_eq!(
3690 filter_active_tool_names(names.clone(), &args),
3691 vec!["read", "ask_user_question"]
3692 );
3693
3694 let args = Args {
3695 exclude_tools: Some(vec!["ask_user_question".into()]),
3696 ..Args::default()
3697 };
3698 assert_eq!(
3699 filter_active_tool_names(names.clone(), &args),
3700 vec!["read", "write"]
3701 );
3702
3703 let args = Args {
3704 no_tools: true,
3705 ..Args::default()
3706 };
3707 assert!(filter_active_tool_names(names, &args).is_empty());
3708 }
3709
3710 #[test]
3711 fn select_ephemeral_when_no_session() {
3712 let args = Args {
3713 no_session: true,
3714 ..Args::default()
3715 };
3716 let cwd = Path::new("/tmp");
3717 assert!(matches!(
3718 select_session(&args, cwd),
3719 SessionSelection::Ephemeral
3720 ));
3721 }
3722
3723 #[test]
3724 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3725 let defaults = Args::default();
3726 assert!(resolve_project_trust(
3727 &defaults,
3728 Path::new("C:/definitely-not-a-project")
3729 ));
3730
3731 let denied = Args {
3732 trust_override: Some(false),
3733 ..Args::default()
3734 };
3735 assert!(!resolve_project_trust(
3736 &denied,
3737 Path::new("C:/definitely-not-a-project")
3738 ));
3739 }
3740
3741 #[tokio::test(flavor = "current_thread")]
3742 async fn build_preserves_the_supplied_startup_project_snapshot() {
3743 struct RestoreConfigDir(Option<std::ffi::OsString>);
3744
3745 impl Drop for RestoreConfigDir {
3746 fn drop(&mut self) {
3747 match self.0.take() {
3748 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3749 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3750 }
3751 }
3752 }
3753
3754 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3755 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3756 let tmp = tempfile::tempdir().unwrap();
3757 let agent = tmp.path().join("agent");
3758 let cwd = tmp.path().join("project");
3759 std::fs::create_dir_all(&agent).unwrap();
3760 std::fs::create_dir_all(&cwd).unwrap();
3761 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3762
3763 let resolved = crate::provider::resolve(
3764 Some("anthropic"),
3765 Some(crate::provider::DEFAULT_MODEL_ID),
3766 None,
3767 Some("test-key"),
3768 None,
3769 )
3770 .unwrap();
3771 let args = Args {
3772 trust_override: Some(false),
3773 no_session: true,
3774 no_tools: true,
3775 no_extensions: true,
3776 no_skills: true,
3777 no_prompt_templates: true,
3778 no_context_files: true,
3779 system_prompt: Some("test prompt".into()),
3780 ..Args::default()
3781 };
3782
3783 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3784
3785 assert_eq!(context.cwd, cwd);
3786 assert!(context.project_trusted);
3787 }
3788
3789 #[tokio::test(flavor = "current_thread")]
3790 async fn build_applies_the_name_flag_to_the_session() {
3791 struct RestoreConfigDir(Option<std::ffi::OsString>);
3792
3793 impl Drop for RestoreConfigDir {
3794 fn drop(&mut self) {
3795 match self.0.take() {
3796 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3797 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3798 }
3799 }
3800 }
3801
3802 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3803 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3804 let tmp = tempfile::tempdir().unwrap();
3805 let agent = tmp.path().join("agent");
3806 let cwd = tmp.path().join("project");
3807 std::fs::create_dir_all(&agent).unwrap();
3808 std::fs::create_dir_all(&cwd).unwrap();
3809 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3810
3811 let resolved = crate::provider::resolve(
3812 Some("anthropic"),
3813 Some(crate::provider::DEFAULT_MODEL_ID),
3814 None,
3815 Some("test-key"),
3816 None,
3817 )
3818 .unwrap();
3819 let args = Args {
3820 trust_override: Some(false),
3821 no_tools: true,
3822 no_extensions: true,
3823 no_skills: true,
3824 no_prompt_templates: true,
3825 no_context_files: true,
3826 system_prompt: Some("test prompt".into()),
3827 name: Some("smoke name".into()),
3828 session_dir: Some(cwd.join("sessions")),
3829 ..Args::default()
3830 };
3831
3832 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3833 assert_eq!(
3834 harness.get_name().await.unwrap().as_deref(),
3835 Some("smoke name")
3836 );
3837 }
3838
3839 #[test]
3840 fn select_latest_for_continue_and_resume() {
3841 let args = Args {
3842 continue_session: true,
3843 ..Args::default()
3844 };
3845 let cwd = Path::new("/tmp");
3846 assert!(matches!(
3847 select_session(&args, cwd),
3848 SessionSelection::Latest { .. }
3849 ));
3850
3851 let args = Args {
3852 resume: true,
3853 ..Args::default()
3854 };
3855 assert!(matches!(
3856 select_session(&args, cwd),
3857 SessionSelection::Latest { .. }
3858 ));
3859 }
3860
3861 #[test]
3862 fn select_by_id_for_session_flag() {
3863 let args = Args {
3864 session: Some("01a02ece".into()),
3865 ..Args::default()
3866 };
3867 let cwd = Path::new("/tmp");
3868 assert!(matches!(
3869 select_session(&args, cwd),
3870 SessionSelection::ById { id, .. } if id == "01a02ece"
3871 ));
3872 }
3873
3874 #[test]
3875 fn select_new_with_custom_dir() {
3876 let args = Args {
3877 session_dir: Some(PathBuf::from("/tmp/sess")),
3878 ..Args::default()
3879 };
3880 let cwd = Path::new("/tmp");
3881 match select_session(&args, cwd) {
3882 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
3883 other => panic!("expected New, got {other:?}"),
3884 }
3885 }
3886
3887 #[test]
3888 fn select_new_default_dir() {
3889 let args = Args::default();
3890 let cwd = Path::new("/proj");
3891 match select_session(&args, cwd) {
3892 SessionSelection::New { dir, .. } => {
3893 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
3894 }
3895 other => panic!("expected New, got {other:?}"),
3896 }
3897 }
3898
3899 #[test]
3900 fn default_session_dir_only_uses_rpi() {
3901 let tmp = tempfile::tempdir().unwrap();
3902 let cwd = tmp.path();
3903 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3904 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3905 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3906 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3907 }
3908
3909 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3910 async fn ephemeral_session_builds_roundtrips() {
3911 let s = ephemeral_session();
3915 let leaf = s.get_leaf_id().await;
3916 assert!(leaf.is_ok());
3917 }
3918
3919 #[test]
3937 fn host_private_env_matches_the_declared_contract() {
3938 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
3940 assert_eq!(
3941 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
3942 [
3943 "LANGFUSE_PI_PARENT_TRACE_ID",
3944 "LANGFUSE_PI_PARENT_SPAN_ID",
3945 "LANGFUSE_PI_PARENT_SESSION_ID",
3946 "LANGFUSE_PI_PARENT_DEPTH",
3947 ],
3948 "these strings are what rpi-langfuse (a different repo) matches; a \
3949 change here is a breaking cross-repo change, not a refactor"
3950 );
3951
3952 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
3955 .iter()
3956 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
3957 {
3958 assert!(
3959 rpi_tools::is_host_private_env(name),
3960 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
3961 rpi_tools::HOST_PRIVATE_ENV
3962 );
3963 }
3964
3965 for visible in [
3968 crate::args::RPI_OFFLINE_ENV,
3969 crate::config::CONFIG_DIR_ENV,
3970 "PATH",
3971 ] {
3972 assert!(
3973 !rpi_tools::is_host_private_env(visible),
3974 "{visible} describes the user's configuration and must still reach the shell"
3975 );
3976 }
3977 }
3978 #[tokio::test]
3979 async fn reload_shutdown_joins_all_handlers_even_after_veto() {
3980 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
3981 use std::sync::atomic::AtomicUsize;
3982 static CONTEXTS: rpi_plugin_sdk::handles::ToolHandles<Arc<AtomicUsize>> =
3983 rpi_plugin_sdk::handles::ToolHandles::new();
3984 struct ContextOwner(usize);
3985 impl Drop for ContextOwner {
3986 fn drop(&mut self) {
3987 drop(CONTEXTS.remove(self.0 as rpi_plugin_sdk::StepHandle));
3988 }
3989 }
3990 extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
3991 let context = CONTEXTS.get(data).expect("live test context");
3992 let calls = context.lock().clone();
3993 std::thread::sleep(std::time::Duration::from_millis(20));
3994 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
3995 rpi_plugin_sdk::EVENT_HANDLER_ABORT
3996 } else {
3997 0
3998 }
3999 }
4000 let calls = Arc::new(AtomicUsize::new(0));
4001 let context = CONTEXTS.insert(calls.clone());
4002 let _owner = ContextOwner(context as usize);
4003 let mut registry = rpi_extensions::ExtensionRegistry::new();
4004 for name in ["first", "second"] {
4005 registry.register_event_handler(name.into(), EventTag::SessionShutdown, stop, context);
4006 }
4007 super::shutdown_extensions_for_reload(
4008 Some(Arc::new(registry.snapshot())),
4009 rpi_extensions::PluginKeepalive::empty(),
4010 )
4011 .await
4012 .unwrap();
4013 assert_eq!(calls.load(Ordering::SeqCst), 2);
4014 }
4015}