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, dir: PathBuf },
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 {
176 source: s.clone(),
177 dir,
178 };
179 }
180 if let Some(s) = &args.session_id {
181 return SessionSelection::ByExactId { id: s.clone(), dir };
182 }
183 if let Some(s) = &args.session {
184 return SessionSelection::ById { id: s.clone(), dir };
185 }
186 SessionSelection::New {
187 dir,
188 name: args.name.clone(),
189 }
190}
191
192pub fn default_session_dir(cwd: &Path) -> PathBuf {
194 cwd.join(".rpi").join("sessions")
195}
196
197pub struct PreparedExtensions {
200 pub extension_session: ExtensionSession,
201 runtime: tokio::runtime::Handle,
202 tool_context: rpi_extensions::ToolCallContext,
203 action_host: Arc<crate::extensions_actions::HarnessActionHost>,
204 harness_cell: Arc<std::sync::OnceLock<rpi_harness::agent_harness::WeakAgentHarness>>,
205 reload_mailbox: rpi_extensions::ReloadMailbox,
206 ui_dialog_mailbox: rpi_extensions::UiDialogMailbox,
207 ext_status_mailbox: rpi_extensions::ExtensionStatusMailbox,
208 editor_text_mailbox: rpi_extensions::EditorTextMailbox,
209 action_bridge: Arc<rpi_extensions::ActionBridge>,
210}
211
212pub fn prepare_extensions(
213 args: &Args,
214 cwd: &Path,
215 project_trusted: bool,
216) -> Result<PreparedExtensions, BuildError> {
217 let cwd_str = cwd.to_string_lossy().to_string();
218 let tool_context = rpi_extensions::ToolCallContext::new(cwd_str.clone());
219 if !project_trusted && args.verbose {
220 eprintln!(
221 "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)"
222 );
223 }
224 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
243 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
244 })?;
245 let catalog = Vec::new();
246 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
247 catalog.clone(),
248 cwd.to_path_buf(),
249 runtime.clone(),
250 args.unknown_flags.clone(),
251 tool_context.clone(),
252 );
253 let action_host = Arc::new(action_host);
254 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = action_host.clone();
255 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
265 let ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
271 let ext_status_mailbox = rpi_extensions::ExtensionStatusMailbox::new();
275 let editor_text_mailbox = rpi_extensions::EditorTextMailbox::new();
280 let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
281 runtime.clone(),
282 host_arc,
283 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
284 ui_dialog_mailbox.clone(),
285 ext_status_mailbox.clone(),
286 editor_text_mailbox.clone(),
287 );
288
289 let extension_session = if args.no_extensions {
290 ExtensionSession::none()
291 } else {
292 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
293 };
294 if args.verbose {
295 if let Some(s) = extension_session.summary() {
296 eprintln!("extensions: {s}");
297 }
298 report_deferred_renderers(&extension_session);
299 }
300 Ok(PreparedExtensions {
301 runtime,
302 tool_context,
303 action_host,
304 harness_cell,
305 reload_mailbox,
306 ui_dialog_mailbox,
307 ext_status_mailbox,
308 editor_text_mailbox,
309 action_bridge,
310 extension_session,
311 })
312}
313
314pub async fn build(
332 resolved: &ResolvedModel,
333 args: &Args,
334 cwd: &Path,
335 project_trusted: bool,
336) -> Result<
337 (
338 AgentHarness,
339 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
340 ReloadContext,
341 ),
342 BuildError,
343> {
344 let prepared = prepare_extensions(args, cwd, project_trusted)?;
345 build_prepared(resolved, args, cwd, project_trusted, prepared).await
346}
347
348pub async fn build_prepared(
349 resolved: &ResolvedModel,
350 args: &Args,
351 cwd: &Path,
352 project_trusted: bool,
353 prepared: PreparedExtensions,
354) -> Result<
355 (
356 AgentHarness,
357 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
358 ReloadContext,
359 ),
360 BuildError,
361> {
362 let cwd_str = cwd.to_string_lossy().to_string();
363 let PreparedExtensions {
368 runtime,
369 tool_context,
370 action_host,
371 harness_cell,
372 reload_mailbox,
373 ui_dialog_mailbox,
374 ext_status_mailbox,
375 editor_text_mailbox,
376 action_bridge,
377 extension_session,
378 } = prepared;
379 let catalog = crate::provider::authenticated_catalog(resolved);
380 action_host.set_catalog(catalog.clone());
381
382 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
384 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
385 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
386 let _registry = Arc::new(MutationQueueRegistry::new());
387 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
390
391 let mut tools = build_tools(&ctx, args);
392
393 merge_extension_tools(&mut tools, &extension_session, args, &tool_context);
401 let active = filter_active_tool_names(active_tool_names(&tools, args), args);
402
403 let selection = select_session(args, cwd);
405 let session = build_session(&selection, &cwd_str).await?;
406 if let Ok(metadata) = session.get_metadata().await {
410 publish_session_identity(&tool_context, &metadata.id);
411 }
412 if let Some(requested) = args.name.as_deref() {
418 let name = requested.trim();
419 let name = if name.is_empty() { None } else { Some(name) };
420 if let Err(error) = session.set_name(name).await {
421 if args.verbose {
422 eprintln!("warning: could not set session name: {error}");
423 }
424 }
425 }
426 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
436 let mut context_paths = Vec::new();
437 let base_prompt = match args.system_prompt.as_deref() {
438 Some(explicit) => explicit.to_string(),
439 None if project_trusted => match discover_system_prompt_file(cwd) {
440 Some(path) => read_prompt_source(&path, &mut context_paths)
441 .unwrap_or_else(|| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
442 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
443 },
444 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
445 };
446
447 let mut append_texts: Vec<String> = Vec::new();
454 for extra in &args.append_system_prompt {
455 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
456 append_texts.push(text);
457 }
458 if args.append_system_prompt.is_empty() {
459 if let Some(path) = project_trusted
460 .then(|| discover_append_system_prompt_file(cwd))
461 .flatten()
462 {
463 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
464 append_texts.push(text);
465 }
466 }
467 }
468 let append_join = if append_texts.is_empty() {
469 None
470 } else {
471 Some(append_texts.join("\n\n"))
472 };
473
474 let agent_dir = crate::config::agent_dir().ok();
486
487 let discovered = extension_session
503 .snapshot_arc()
504 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
505 .unwrap_or_default();
506
507 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
508 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
509 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
510 if !args.no_skills {
511 let mut dirs = if args.dev_local_only || args.project_only {
512 project_skill_dirs(cwd)
513 } else if project_trusted {
514 skill_dirs(cwd)
515 } else {
516 global_skill_dirs()
517 };
518 dirs.extend(args.skill.iter().cloned());
519 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
520 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
521 skills = result.skills;
522 skill_diags = result.diagnostics;
523 resource_diags.extend(result.resource_diagnostics);
524 }
525
526 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
527 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
528 if !args.no_prompt_templates {
529 let mut dirs = if args.dev_local_only || args.project_only {
530 project_prompt_template_dirs(cwd)
531 } else if project_trusted {
532 prompt_template_dirs(cwd)
533 } else {
534 global_prompt_template_dirs()
535 };
536 dirs.extend(args.prompt_template.iter().cloned());
537 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
538 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
539 prompt_templates = result.prompt_templates;
540 prompt_diags = result.diagnostics;
541 resource_diags.extend(result.resource_diagnostics);
542 }
543
544 let context_block = if args.no_context_files || !project_trusted {
545 String::new()
546 } else {
547 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
552 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
553 context_paths.extend(
554 files
555 .iter()
556 .map(|file| file.path.to_string_lossy().into_owned()),
557 );
558 format_project_context(&files)
559 };
560
561 if args.verbose {
563 for d in &skill_diags {
564 eprintln!(
565 "warning: skill {} ({}): {}",
566 d.path,
567 d.code.as_str(),
568 d.message
569 );
570 }
571 for d in &prompt_diags {
572 eprintln!(
573 "warning: prompt template {} ({}): {}",
574 d.path,
575 d.code.as_str(),
576 d.message
577 );
578 }
579 for d in &resource_diags {
581 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
582 eprintln!(
583 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
584 c.resource_type.as_str(),
585 c.name,
586 c.winner_path,
587 c.loser_path
588 );
589 }
590 }
591 }
592
593 let system_prompt = compose_system_prompt(
602 Some(&base_prompt),
603 &[], if context_block.is_empty() {
605 None
606 } else {
607 Some(&context_block)
608 },
609 append_join.as_deref(),
610 );
611
612 if args.debug_system_prompt {
621 eprintln!("=== --debug-system-prompt ===");
622 let base_src = if args.system_prompt.is_some() {
623 "--system-prompt"
624 } else if discover_system_prompt_file(cwd).is_some() {
625 "SYSTEM.md"
626 } else {
627 "default"
628 };
629 eprintln!("[base source: {base_src}]");
630 eprintln!("--- base ---\n{base_prompt}");
631 if let Some(append) = append_join.as_deref() {
632 eprintln!("--- append ---\n{append}");
633 } else {
634 eprintln!("--- append: (none) ---");
635 }
636 if context_block.is_empty() {
637 eprintln!("--- context: (none) ---");
638 } else {
639 eprintln!("--- context ---{context_block}");
640 }
641 let visible_skills = skills
642 .iter()
643 .filter(|s| s.disable_model_invocation != Some(true))
644 .count();
645 eprintln!(
646 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
647 skills.len(),
648 visible_skills,
649 skills.len() - visible_skills
650 );
651 for s in &skills {
652 let hidden = if s.disable_model_invocation == Some(true) {
653 " [hidden]"
654 } else {
655 ""
656 };
657 eprintln!(" {}{hidden} — {}", s.name, s.description);
658 }
659 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
660 for t in &prompt_templates {
661 eprintln!(" /{}", t.name);
662 }
663 eprintln!(
667 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
668 discovered.skill_paths.len(),
669 discovered.prompt_paths.len(),
670 discovered.theme_paths.len(),
671 );
672 for p in &discovered.skill_paths {
673 eprintln!(" skill: {p}");
674 }
675 for p in &discovered.prompt_paths {
676 eprintln!(" prompt: {p}");
677 }
678 eprintln!(
679 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
680 );
681 eprintln!("=== end --debug-system-prompt ===");
682 }
683
684 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
689 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
690 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
694
695 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
704 Some(snapshot) => {
705 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
706 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
707 }
708 None => broadcast_emitter,
709 };
710
711 let saved_settings = crate::settings::load_settings().unwrap_or_default();
715
716 if let Some(proxy) = &saved_settings.http_proxy {
721 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
722 if std::env::var_os(key).is_none() {
723 std::env::set_var(key, proxy);
724 }
725 }
726 }
727
728 let extension_session_cell = Arc::new(Mutex::new(extension_session.clone()));
733 let before_tool_call = extension_session
738 .snapshot()
739 .filter(|snapshot| {
740 !snapshot
741 .handlers_for(rpi_plugin_sdk::EventTag::ToolCall)
742 .is_empty()
743 })
744 .map(|_| {
745 extension_tool_call_hook(
746 Arc::clone(&extension_session_cell),
747 ui_dialog_mailbox.clone(),
748 )
749 });
750
751 let options = AgentHarnessOptions {
752 model: resolved.model.clone(),
753 thinking_level: resolved.thinking_level,
754 active_tool_names: active,
755 tools,
756 system_prompt: Some(system_prompt),
757 resources: AgentHarnessResources {
758 context_files: context_paths,
759 skills: if skills.is_empty() {
760 None
761 } else {
762 Some(skills)
763 },
764 prompt_templates: if prompt_templates.is_empty() {
765 None
766 } else {
767 Some(prompt_templates)
768 },
769 },
770 allow_existing_session: matches!(
773 selection,
774 SessionSelection::Latest { .. }
775 | SessionSelection::ById { .. }
776 | SessionSelection::ByExactId { .. }
777 | SessionSelection::Fork { .. }
778 ),
779 stream_options: AgentHarnessStreamOptions {
780 timeout: args.timeout,
781 ..Default::default()
782 },
783 retry: saved_settings.retry_policy(),
788 compaction: saved_settings.compaction_settings(),
789 steering_mode: saved_settings.steering_mode(),
790 follow_up_mode: saved_settings.follow_up_mode(),
791 tool_execution: HarnessToolExecution::default(),
792 drive: DrivingMode::default(),
793 session,
794 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
802 to_provider_messages: None,
803 entry_projectors: Default::default(),
804 agent_emitter: Some(emitter),
805 before_tool_call,
810 after_tool_call: None,
811 transform_context: None,
812 entry_transforms: Vec::new(),
813 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
819 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
820 };
821
822 let harness = match AgentHarness::create(options).await {
823 Ok(h) => {
824 crate::extensions_actions::HarnessActionHost::set_harness(
827 &harness_cell,
828 Arc::new(h.clone()),
829 );
830 h
831 }
832 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
833 };
834
835 {
843 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
844 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
845 Ok(reports) => {
846 for (lane, report) in reports {
847 if report.is_clean() {
848 continue;
849 }
850 if report.is_corrupt() {
851 eprintln!(
852 "warning: session lane '{lane}' is corrupt ({} open operations); \
853 refusing to auto-repair — inspect the session file",
854 report.open_operations.len()
855 );
856 continue;
857 }
858 for decision in runtime.reconcile(&report) {
859 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
860 if args.verbose {
861 eprintln!(
862 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
863 );
864 }
865 }
866 }
867 runtime.emit_recovery_events(harness.events(), &report);
868 }
869 }
870 Err(e) => {
871 if args.verbose {
872 eprintln!("warning: session recovery sweep failed: {e}");
873 }
874 }
875 }
876 }
877
878 let reload_context = ReloadContext {
885 extension_session: extension_session_cell,
886 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
887 tool_context,
888 catalog: Arc::new(std::sync::RwLock::new(catalog)),
889 providers: Arc::new(std::sync::RwLock::new(resolved.providers.clone())),
890 runtime: runtime.clone(),
891 cwd: cwd.to_path_buf(),
892 project_trusted,
893 args: args.clone(),
894 resolved_model: resolved.model.clone(),
895 broadcast: broadcast_for_context,
896 mailbox: reload_mailbox,
897 ui_dialog: ui_dialog_mailbox,
898 ext_status: ext_status_mailbox,
899 editor_text: editor_text_mailbox,
900 dev_extension: None,
901 };
902
903 Ok((harness, event_rx, reload_context))
904}
905
906fn extension_tool_call_hook(
909 sessions: ExtensionSessionCell,
910 ui_dialog: rpi_extensions::UiDialogMailbox,
911) -> rpi_agent::BeforeToolCall {
912 Arc::new(move |ctx: rpi_agent::BeforeToolCallContext<'_>, signal| {
913 let session = sessions
914 .lock()
915 .map(|session| session.clone())
916 .map_err(|_| ());
917 let ui_dialog = ui_dialog.clone();
918 let tool_call_id = ctx.tool_call.id.clone();
919 let tool_name = ctx.tool_call.name.clone();
920 let params = ctx.args.clone();
921 Box::pin(async move {
922 let reason = match session {
923 Ok(session) => {
924 rpi_extensions::dispatch_tool_call_event(
925 &session,
926 &tool_call_id,
927 &tool_name,
928 ¶ms,
929 &signal,
930 &ui_dialog,
931 )
932 .await
933 }
934 Err(_) => Some("extension approval session is unavailable".to_string()),
935 };
936 reason.map(|reason| rpi_agent::BeforeToolCallResult {
937 block: true,
938 reason: Some(reason),
939 terminate: false,
940 args: None,
941 })
942 })
943 })
944}
945
946pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
991
992pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
997
998#[derive(Clone)]
1006pub struct ReloadContext {
1007 pub extension_session: ExtensionSessionCell,
1009 pub tool_context: rpi_extensions::ToolCallContext,
1014 pub action_bridge: ActionBridgeCell,
1016 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
1019 pub editor_text: rpi_extensions::EditorTextMailbox,
1026 pub catalog: Arc<std::sync::RwLock<Vec<rpi_ai::Model>>>,
1029 pub providers: Arc<std::sync::RwLock<Vec<Arc<dyn Provider>>>>,
1032 pub runtime: tokio::runtime::Handle,
1036 pub cwd: PathBuf,
1038 pub project_trusted: bool,
1042 pub args: Args,
1048 pub resolved_model: rpi_ai::Model,
1051 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
1057 pub mailbox: rpi_extensions::ReloadMailbox,
1064 pub ui_dialog: rpi_extensions::UiDialogMailbox,
1070 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
1074}
1075
1076pub async fn dispatch_session_event_async(
1091 reload_context: &ReloadContext,
1092 tag: rpi_plugin_sdk::EventTag,
1093) -> Option<String> {
1094 let (snapshot, keepalive) = {
1100 let session = reload_context.extension_session.lock().unwrap();
1101 (session.snapshot_arc(), session.keepalive())
1102 };
1103 match snapshot {
1104 Some(snapshot) => rpi_extensions::dispatch_lifecycle_event(&snapshot, tag, keepalive).await,
1105 None => None,
1106 }
1107}
1108
1109async fn shutdown_extensions_for_reload(
1112 snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
1113 keepalive: Arc<rpi_extensions::PluginKeepalive>,
1114) -> Result<(), String> {
1115 if let Some(snapshot) = snapshot {
1116 tokio::task::spawn_blocking(move || {
1117 let _keepalive = keepalive;
1118 rpi_extensions::dispatch_empty_event(
1119 &snapshot,
1120 rpi_plugin_sdk::EventTag::SessionShutdown,
1121 );
1122 })
1123 .await
1124 .map_err(|error| error.to_string())?;
1125 }
1126 Ok(())
1127}
1128
1129pub struct ReloadOutcome {
1132 pub summary: String,
1135 pub had_warnings: bool,
1137 pub details: Vec<String>,
1142 pub had_errors: bool,
1146}
1147
1148struct PreparedReloadInputs {
1149 extension_dirs: Vec<PathBuf>,
1150 skill_base_dirs: Vec<PathBuf>,
1151 prompt_base_dirs: Vec<PathBuf>,
1152}
1153
1154fn append_reload_resource_paths(
1159 mut paths: Vec<PathBuf>,
1160 explicit: &[PathBuf],
1161 discovered: &[String],
1162 js_paths: &[PathBuf],
1163 package_paths: &[PathBuf],
1164) -> Vec<PathBuf> {
1165 paths.extend(explicit.iter().cloned());
1166 paths.extend(discovered.iter().map(PathBuf::from));
1167 paths.extend(js_paths.iter().cloned());
1168 paths.extend(package_paths.iter().cloned());
1169 paths
1170}
1171
1172pub async fn reload_extension_resources(
1184 harness: &AgentHarness,
1185 ctx: &ReloadContext,
1186) -> ReloadOutcome {
1187 reload_extension_resources_inner(harness, ctx, || {}).await
1188}
1189
1190async fn reload_extension_resources_inner<F>(
1191 harness: &AgentHarness,
1192 ctx: &ReloadContext,
1193 after_prepare: F,
1194) -> ReloadOutcome
1195where
1196 F: FnOnce() + Send,
1197{
1198 let mut effective_args = ctx.args.clone();
1203 if let Some(dev) = &ctx.dev_extension {
1204 if let Err(error) = dev
1205 .rebuild()
1206 .and_then(|_| dev.apply_to_args(&mut effective_args))
1207 {
1208 return ReloadOutcome {
1209 summary: format!(
1210 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1211 dev.package_name()
1212 ),
1213 had_warnings: true,
1214 details: Vec::new(),
1215 had_errors: true,
1216 };
1217 }
1218 }
1219 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1220 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1221 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1222 Ok(prepared) => prepared,
1223 Err(error) => {
1224 return ReloadOutcome {
1225 summary: format!(
1226 "Settings reload failed: {error}. Keeping the currently loaded resources."
1227 ),
1228 had_warnings: true,
1229 details: Vec::new(),
1230 had_errors: true,
1231 };
1232 }
1233 };
1234 after_prepare();
1235 let PreparedReloadInputs {
1236 extension_dirs,
1237 skill_base_dirs,
1238 prompt_base_dirs,
1239 } = prepared;
1240 let mut warnings = false;
1241
1242 let (shutdown_snapshot, shutdown_keepalive) = {
1246 let session = ctx.extension_session.lock().unwrap();
1247 (session.snapshot_arc(), session.keepalive())
1248 };
1249 if let Err(reason) = shutdown_extensions_for_reload(shutdown_snapshot, shutdown_keepalive).await
1250 {
1251 return ReloadOutcome {
1252 summary: "Reload failed during extension shutdown".into(),
1253 had_warnings: true,
1254 details: vec![reason],
1255 had_errors: true,
1256 };
1257 }
1258
1259 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1266 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1267 Some(b) => b.clone_host(),
1268 None => {
1269 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1272 ctx.catalog
1273 .read()
1274 .unwrap_or_else(|error| error.into_inner())
1275 .clone(),
1276 ctx.cwd.clone(),
1277 ctx.runtime.clone(),
1278 ctx.args.unknown_flags.clone(),
1279 ctx.tool_context.clone(),
1280 );
1281 crate::extensions_actions::HarnessActionHost::set_harness(
1282 &_cell,
1283 Arc::new(harness.clone()),
1284 );
1285 Arc::new(action_host)
1286 }
1287 };
1288
1289 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1290 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1291 ctx.runtime.clone(),
1292 host,
1293 reload_cb,
1294 ctx.ui_dialog.clone(),
1295 ctx.ext_status.clone(),
1296 ctx.editor_text.clone(),
1297 );
1298
1299 let extension_session = if effective_args.no_extensions {
1300 rpi_extensions::ExtensionSession::none()
1301 } else {
1302 load_extensions_from_dirs(
1303 &effective_args,
1304 &extension_dirs,
1305 Some(Arc::clone(&fresh_bridge)),
1306 )
1307 };
1308 if extension_session.is_empty() && !effective_args.no_extensions {
1309 }
1312 if effective_args.verbose {
1313 if let Some(s) = extension_session.summary() {
1314 eprintln!("reload: {s}");
1315 }
1316 report_deferred_renderers(&extension_session);
1317 }
1318
1319 let has_package_providers = |session: &ExtensionSession| {
1320 session.snapshot().is_some_and(|snapshot| {
1321 snapshot
1322 .providers()
1323 .iter()
1324 .any(|provider| snapshot.provider_manifest(&provider.provider_id).is_some())
1325 })
1326 };
1327 let packaged = has_package_providers(&extension_session)
1328 || has_package_providers(&ctx.extension_session.lock().unwrap());
1329 let refreshed = if packaged {
1330 let mut args = effective_args.clone();
1331 if let Ok(current) = harness.lane("main").get_model().await {
1332 let was_package = ctx
1333 .extension_session
1334 .lock()
1335 .unwrap()
1336 .snapshot()
1337 .is_some_and(|snapshot| snapshot.provider_manifest(¤t.provider).is_some());
1338 let has_replacement = extension_session.snapshot().is_some_and(|snapshot| {
1339 snapshot
1340 .providers()
1341 .iter()
1342 .any(|provider| provider.provider_id == current.provider)
1343 });
1344 if was_package && !has_replacement {
1345 return ReloadOutcome {
1346 summary: "Reload failed while resolving provider packages".into(),
1347 had_warnings: true,
1348 details: vec![format!(
1349 "active provider package '{}' is no longer loaded",
1350 current.provider
1351 )],
1352 had_errors: true,
1353 };
1354 }
1355 args.provider = Some(current.provider.clone());
1356 args.model = Some(current.id);
1357 if current.provider != ctx.resolved_model.provider {
1358 args.api_key = None;
1359 args.base_url = None;
1360 }
1361 }
1362 match crate::provider::resolve_with_extensions(
1363 &args,
1364 &ctx.cwd,
1365 ctx.project_trusted,
1366 &extension_session,
1367 ) {
1368 Ok(resolved) => Some(resolved),
1369 Err(error) => {
1370 return ReloadOutcome {
1371 summary: "Reload failed while resolving provider packages".into(),
1372 had_warnings: true,
1373 details: vec![error.to_string()],
1374 had_errors: true,
1375 }
1376 }
1377 }
1378 } else {
1379 None
1380 };
1381
1382 {
1394 let mut session_guard = ctx.extension_session.lock().unwrap();
1395 let old_session = std::mem::replace(&mut *session_guard, extension_session.clone());
1396 if let Some(old_snap) = old_session.snapshot_arc() {
1397 old_snap.active_flag().store(false, Ordering::SeqCst);
1401 }
1402 }
1405 if let Some(old_b) = old_bridge {
1406 old_b.invalidate();
1407 }
1408
1409 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1411
1412 let discovered = extension_session
1414 .snapshot_arc()
1415 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1416 .unwrap_or_default();
1417
1418 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1420 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1421
1422 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1423 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1424 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1425 if !effective_args.no_skills {
1426 let js_paths: &[PathBuf] = &[];
1430 let package_paths: &[PathBuf] = &[];
1431 let dirs = append_reload_resource_paths(
1432 skill_base_dirs,
1433 &effective_args.skill,
1434 &discovered.skill_paths,
1435 js_paths,
1436 &package_paths,
1437 );
1438 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1439 skills = result.skills;
1440 skill_diags = result.diagnostics;
1441 resource_diags.extend(result.resource_diagnostics);
1442 }
1443
1444 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1445 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1446 if !effective_args.no_prompt_templates {
1447 let js_paths: &[PathBuf] = &[];
1448 let package_paths: &[PathBuf] = &[];
1449 let dirs = append_reload_resource_paths(
1450 prompt_base_dirs,
1451 &effective_args.prompt_template,
1452 &discovered.prompt_paths,
1453 js_paths,
1454 &package_paths,
1455 );
1456 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1457 prompt_templates = result.prompt_templates;
1458 prompt_diags = result.diagnostics;
1459 resource_diags.extend(result.resource_diagnostics);
1460 }
1461
1462 let context_files = if effective_args.no_context_files || !project_trusted {
1463 Vec::new()
1464 } else {
1465 let agent_dir = crate::config::agent_dir().ok();
1466 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1467 load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await
1468 };
1469 let context_block = format_project_context(&context_files);
1470
1471 let mut details: Vec<String> = Vec::new();
1472 if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
1473 warnings = true;
1474 for d in &skill_diags {
1479 details.push(format!(
1480 "warning: skill {} ({}): {}",
1481 d.path,
1482 d.code.as_str(),
1483 d.message
1484 ));
1485 }
1486 for d in &prompt_diags {
1487 details.push(format!(
1488 "warning: prompt template {} ({}): {}",
1489 d.path,
1490 d.code.as_str(),
1491 d.message
1492 ));
1493 }
1494 for d in &resource_diags {
1496 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1497 details.push(format!(
1498 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1499 c.resource_type.as_str(),
1500 c.name,
1501 c.winner_path,
1502 c.loser_path
1503 ));
1504 }
1505 }
1506 }
1507
1508 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1513 let mut context_paths = Vec::new();
1514 let base_prompt = match effective_args.system_prompt.as_deref() {
1515 Some(explicit) => explicit.to_string(),
1516 None if project_trusted => match discover_system_prompt_file(&ctx.cwd) {
1517 Some(path) => read_prompt_source(&path, &mut context_paths)
1518 .unwrap_or_else(|| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
1519 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1520 },
1521 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1522 };
1523 let mut append_texts: Vec<String> = Vec::new();
1524 for extra in &effective_args.append_system_prompt {
1525 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
1526 append_texts.push(text);
1527 }
1528 if effective_args.append_system_prompt.is_empty() {
1529 if let Some(path) = project_trusted
1530 .then(|| discover_append_system_prompt_file(&ctx.cwd))
1531 .flatten()
1532 {
1533 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
1534 append_texts.push(text);
1535 }
1536 }
1537 }
1538 context_paths.extend(
1539 context_files
1540 .iter()
1541 .map(|file| file.path.to_string_lossy().into_owned()),
1542 );
1543 let append_join = if append_texts.is_empty() {
1544 None
1545 } else {
1546 Some(append_texts.join("\n\n"))
1547 };
1548 let system_prompt = compose_system_prompt(
1549 Some(&base_prompt),
1550 &[],
1551 if context_block.is_empty() {
1552 None
1553 } else {
1554 Some(&context_block)
1555 },
1556 append_join.as_deref(),
1557 );
1558
1559 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1561 Some(snapshot) => {
1562 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1563 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1564 }
1565 None => ctx.broadcast.clone(),
1566 };
1567
1568 let resources = AgentHarnessResources {
1570 context_files: context_paths,
1571 skills: if skills.is_empty() {
1572 None
1573 } else {
1574 Some(skills.clone())
1575 },
1576 prompt_templates: if prompt_templates.is_empty() {
1577 None
1578 } else {
1579 Some(prompt_templates.clone())
1580 },
1581 };
1582 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1583 let _ = harness.set_resources(resources).await;
1584 let _ = harness.set_agent_emitter(Some(emitter)).await;
1585 let _ = harness
1586 .set_models(build_models_with_extensions_for_reload(
1587 &refreshed
1588 .as_ref()
1589 .map(|resolved| resolved.providers.clone())
1590 .unwrap_or_else(|| {
1591 ctx.providers
1592 .read()
1593 .unwrap_or_else(|error| error.into_inner())
1594 .clone()
1595 }),
1596 &extension_session,
1597 ctx.runtime.clone(),
1598 ))
1599 .await;
1600 let _ = harness
1601 .set_provider_hooks(
1602 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1603 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1604 )
1605 .await;
1606
1607 if let Some(resolved) = &refreshed {
1608 *ctx.providers
1609 .write()
1610 .unwrap_or_else(|error| error.into_inner()) = resolved.providers.clone();
1611 let catalog = crate::provider::authenticated_catalog(resolved);
1612 fresh_bridge
1613 .clone_host()
1614 .replace_model_catalog(catalog.clone());
1615 *ctx.catalog
1616 .write()
1617 .unwrap_or_else(|error| error.into_inner()) = catalog;
1618 let _ = harness.lane("main").set_model(resolved.model.clone()).await;
1621 }
1622
1623 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1627 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1628 let mut tools = build_tools(&tool_ctx, &effective_args);
1629 merge_extension_tools(
1630 &mut tools,
1631 &extension_session,
1632 &effective_args,
1633 &ctx.tool_context,
1634 );
1635 let mut active = active_tool_names(&tools, &effective_args);
1636 active = filter_active_tool_names(active, &effective_args);
1637 let _ = harness.set_tools(tools, Some(active)).await;
1638
1639 let summary = format!(
1640 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1641 extension_session.loaded_paths().len(),
1642 skills.len(),
1643 prompt_templates.len(),
1644 );
1645 ReloadOutcome {
1646 summary,
1647 had_warnings: warnings,
1648 details,
1649 had_errors: false,
1652 }
1653}
1654
1655#[derive(Clone, Debug, PartialEq, Eq)]
1656struct ReloadSettingsFields {
1657 skill_dirs: Option<Vec<String>>,
1658 prompt_dirs: Option<Vec<String>>,
1659 extension_dirs: Option<Vec<String>>,
1660}
1661
1662impl From<crate::settings::Settings> for ReloadSettingsFields {
1663 fn from(settings: crate::settings::Settings) -> Self {
1664 Self {
1665 skill_dirs: settings.skill_dirs,
1666 prompt_dirs: settings.prompt_dirs,
1667 extension_dirs: settings.extension_dirs,
1668 }
1669 }
1670}
1671
1672#[derive(Clone, Debug, PartialEq, Eq)]
1673struct ReloadSettingsSnapshot {
1674 global: ReloadSettingsFields,
1675 project: Option<ReloadSettingsFields>,
1676}
1677
1678fn reload_reads_settings(args: &Args) -> bool {
1679 !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
1680}
1681
1682fn load_reload_settings_snapshot(
1686 args: &Args,
1687 cwd: &Path,
1688 project_trusted: bool,
1689) -> Result<Option<ReloadSettingsSnapshot>, String> {
1690 if !reload_reads_settings(args) {
1691 return Ok(None);
1692 }
1693 let global = crate::settings::load_settings()
1694 .map_err(|error| format!("could not load global settings: {error}"))?
1695 .into();
1696 let project = if project_trusted {
1697 crate::settings::load_active_project_settings(cwd)
1698 .map_err(|error| format!("could not load project settings: {error}"))?
1699 .map(|(_, settings)| settings.into())
1700 } else {
1701 None
1702 };
1703 Ok(Some(ReloadSettingsSnapshot { global, project }))
1704}
1705
1706#[cfg(test)]
1709fn validate_settings_for_reload(
1710 args: &Args,
1711 cwd: &Path,
1712 project_trusted: bool,
1713) -> Result<(), String> {
1714 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1715}
1716
1717fn prepare_reload_inputs(
1718 args: &Args,
1719 cwd: &Path,
1720 project_trusted: bool,
1721) -> Result<PreparedReloadInputs, String> {
1722 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1723}
1724
1725fn prepare_reload_inputs_inner<F>(
1726 args: &Args,
1727 cwd: &Path,
1728 project_trusted: bool,
1729 before_verify: F,
1730) -> Result<PreparedReloadInputs, String>
1731where
1732 F: FnOnce(),
1733{
1734 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1735
1736 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1737 Vec::new()
1738 } else if args.project_only {
1739 project_extension_dirs(cwd)
1740 } else if project_trusted {
1741 extension_dirs(cwd)
1742 } else {
1743 global_extension_dirs()
1744 };
1745 if !args.no_extensions {
1746 extension_dirs.extend(args.extensions_dir.iter().cloned());
1747 }
1748
1749 let skill_base_dirs = if args.no_skills {
1750 Vec::new()
1751 } else if args.dev_local_only || args.project_only {
1752 project_skill_dirs(cwd)
1753 } else if project_trusted {
1754 skill_dirs(cwd)
1755 } else {
1756 global_skill_dirs()
1757 };
1758
1759 let prompt_base_dirs = if args.no_prompt_templates {
1760 Vec::new()
1761 } else if args.dev_local_only || args.project_only {
1762 project_prompt_template_dirs(cwd)
1763 } else if project_trusted {
1764 prompt_template_dirs(cwd)
1765 } else {
1766 global_prompt_template_dirs()
1767 };
1768
1769 before_verify();
1770 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1771 if settings_before != settings_after {
1772 return Err(
1773 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1774 );
1775 }
1776
1777 Ok(PreparedReloadInputs {
1778 extension_dirs,
1779 skill_base_dirs,
1780 prompt_base_dirs,
1781 })
1782}
1783
1784fn build_models_with_extensions_for_reload(
1789 providers: &[Arc<dyn Provider>],
1790 extension_session: &ExtensionSession,
1791 runtime: tokio::runtime::Handle,
1792) -> Vec<Arc<dyn Provider>> {
1793 let mut models: Vec<Arc<dyn Provider>> = providers.to_vec();
1794 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1795 for provider in pluggable {
1796 if extension_session
1800 .snapshot()
1801 .is_some_and(|snapshot| snapshot.provider_manifest(provider.id()).is_some())
1802 {
1803 continue;
1804 }
1805 if !models.iter().any(|known| known.id() == provider.id()) {
1806 models.push(provider);
1807 }
1808 }
1809 models
1810}
1811
1812fn report_deferred_renderers(session: &ExtensionSession) {
1816 let Some(snap) = session.snapshot_arc() else {
1817 return;
1818 };
1819 let all = snap.renderers();
1820 let markdown = all
1821 .iter()
1822 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1823 .count();
1824 let message = all
1825 .iter()
1826 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1827 .count();
1828 let entry = all
1829 .iter()
1830 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1831 .count();
1832 if markdown + message + entry == 0 {
1833 return;
1834 }
1835 eprintln!(
1836 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1837 markdown, message, entry
1838 );
1839}
1840
1841#[derive(Debug, thiserror::Error)]
1843pub enum BuildError {
1844 #[error("Could not create the session directory: {0}")]
1845 SessionDir(String),
1846 #[error("Could not open the saved session: {0}")]
1847 SessionOpen(String),
1848 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1849 SessionNotFound { requested: String, dir: String },
1850 #[error("Could not build the harness: {0}")]
1851 HarnessCreate(String),
1852}
1853
1854fn build_models_with_extensions(
1857 resolved: &ResolvedModel,
1858 extension_session: &ExtensionSession,
1859 runtime: tokio::runtime::Handle,
1860) -> Vec<Arc<dyn Provider>> {
1861 build_models_with_extensions_for_reload(&resolved.providers, extension_session, runtime)
1862}
1863
1864pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1867 if let Some(override_value) = args.trust_override {
1868 return override_value;
1869 }
1870 crate::config::project_trust_decision(cwd)
1871 .ok()
1872 .flatten()
1873 .unwrap_or(true)
1874}
1875
1876pub(crate) fn load_extensions(
1884 args: &Args,
1885 cwd: &Path,
1886 project_trusted: bool,
1887 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1888) -> ExtensionSession {
1889 if args.no_extensions {
1890 return ExtensionSession::none();
1891 }
1892 let mut dirs = if args.dev_local_only {
1893 Vec::new()
1894 } else if args.project_only {
1895 project_extension_dirs(cwd)
1896 } else if project_trusted {
1897 extension_dirs(cwd)
1898 } else {
1899 global_extension_dirs()
1900 };
1901 dirs.extend(args.extensions_dir.iter().cloned());
1902 load_extensions_from_dirs(args, &dirs, action_bridge)
1903}
1904
1905fn load_extensions_from_dirs(
1906 args: &Args,
1907 dirs: &[PathBuf],
1908 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1909) -> ExtensionSession {
1910 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1911 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1918}
1919
1920fn merge_extension_tools(
1926 tools: &mut Vec<HarnessTool>,
1927 session: &ExtensionSession,
1928 args: &Args,
1929 tool_context: &rpi_extensions::ToolCallContext,
1930) {
1931 let Some(snapshot) = session.snapshot() else {
1932 return;
1933 };
1934 for et in snapshot.tools() {
1935 let name = &et.tool.name;
1936 if !tool_name_allowed(name, args) {
1937 continue;
1938 }
1939 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
1940 .with_context(tool_context.clone());
1941 let harness_tool = HarnessTool::new(Arc::new(adapter));
1942 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1943 Some(slot) => *slot = harness_tool,
1944 None => tools.push(harness_tool),
1945 }
1946 }
1947}
1948
1949pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1956 use rpi_tools::tools::bash::BashToolOptions;
1957 let default = std::env::var("RPI_BASH_TIMEOUT")
1958 .ok()
1959 .and_then(|v| v.parse::<f64>().ok())
1960 .unwrap_or(120.0);
1961 let settings = crate::settings::load_settings().unwrap_or_default();
1967 BashToolOptions {
1968 command_prefix: settings.shell_command_prefix.clone(),
1969 default_timeout: Some(default),
1970 prepare: None,
1971 }
1972}
1973
1974fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1975 if args.no_tools {
1976 return Vec::new();
1977 }
1978 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1987 (
1988 "read",
1989 HarnessTool::new(create_read_tool(
1990 ctx,
1991 Some(rpi_tools::tools::read::ReadToolOptions {
1992 image_processor: Some(Arc::new(
1993 rpi_tools::image_processing::InlineImageProcessor,
1994 )),
1995 ..Default::default()
1996 }),
1997 ))
1998 .with_replay(ToolReplay::Safe),
1999 ),
2000 (
2001 "bash",
2002 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
2003 ),
2004 ("edit", HarnessTool::new(create_edit_tool(ctx))),
2005 ("write", HarnessTool::new(create_write_tool(ctx))),
2006 (
2007 "docs",
2008 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
2009 ),
2010 ];
2011
2012 #[cfg(target_os = "windows")]
2014 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
2015
2016 if args.tools.is_none() {
2018 if let Ok(settings) = crate::settings::load_settings() {
2019 if let Some(default_tools) = &settings.default_tools {
2020 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
2021 }
2022 }
2023 }
2024
2025 if args.no_builtin_tools {
2029 all.clear();
2030 }
2031
2032 if let Some(allow) = &args.tools {
2034 all.retain(|(name, _)| allow.iter().any(|a| a == name));
2035 }
2036 if let Some(deny) = &args.exclude_tools {
2038 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
2039 }
2040
2041 all.into_iter().map(|(_, t)| t).collect()
2042}
2043
2044fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
2048 filter_active_tool_names(
2049 tools.iter().map(|tool| tool.tool.schema().name.clone()),
2050 args,
2051 )
2052}
2053
2054pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
2057 if args.no_tools {
2058 return false;
2059 }
2060 if args
2061 .tools
2062 .as_ref()
2063 .is_some_and(|allow| !allow.iter().any(|value| value == name))
2064 {
2065 return false;
2066 }
2067 if args
2068 .exclude_tools
2069 .as_ref()
2070 .is_some_and(|deny| deny.iter().any(|value| value == name))
2071 {
2072 return false;
2073 }
2074 true
2075}
2076
2077pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
2078where
2079 I: IntoIterator<Item = String>,
2080{
2081 names
2082 .into_iter()
2083 .filter(|name| tool_name_allowed(name, args))
2084 .collect()
2085}
2086
2087async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2089 match selection {
2090 SessionSelection::Ephemeral => Ok(ephemeral_session()),
2091 SessionSelection::New { dir, .. } => {
2092 std::fs::create_dir_all(dir)
2095 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2096 let session = create_jsonl_session(dir, cwd)
2097 .await
2098 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2099 Ok(session)
2100 }
2101 SessionSelection::Latest { .. }
2102 | SessionSelection::ById { .. }
2103 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2104 SessionSelection::Fork { source, dir } => fork_session_at_launch(source, dir, cwd).await,
2105 }
2106}
2107
2108async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2114 match selection {
2118 SessionSelection::Latest { dir } => {
2119 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2120 let Some(meta) = first_resumable_session(&metas) else {
2121 return Err(BuildError::SessionNotFound {
2122 requested: "the most recent session".to_string(),
2123 dir: default_session_dir(Path::new(cwd)).display().to_string(),
2124 });
2125 };
2126 open_session(meta, cwd).await
2127 }
2128 SessionSelection::ById { id, dir } => open_session_by_id_in_dir(id, cwd, dir)
2129 .await
2130 .map_err(|e| match e {
2131 OpenError::NotFound { requested } => BuildError::SessionNotFound {
2132 requested,
2133 dir: default_session_dir(Path::new(cwd)).display().to_string(),
2134 },
2135 OpenError::Other(msg) => BuildError::SessionOpen(msg),
2136 }),
2137 SessionSelection::ByExactId { id, dir } => {
2138 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2141 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
2142 return open_session(meta, cwd).await;
2143 }
2144 std::fs::create_dir_all(dir)
2145 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2146 create_jsonl_session_with_id(dir, cwd, Some(id.clone()))
2147 .await
2148 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2149 }
2150 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
2151 }
2152}
2153
2154async fn fork_session_at_launch(
2157 source: &str,
2158 dir: &Path,
2159 cwd: &str,
2160) -> Result<Session, BuildError> {
2161 use rpi_harness::session::jsonl::{
2162 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2163 };
2164 use rpi_harness::session::types::{ForkOptions, SessionStorage};
2165 use rpi_tools::FileSystem;
2166
2167 let dir = dir.to_path_buf();
2168 std::fs::create_dir_all(&dir)
2169 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2170 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2171 let fs: Arc<dyn FileSystem> = env.clone();
2172 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2173 fs: fs.clone(),
2174 sessions_root: dir.to_string_lossy().into_owned(),
2175 clock: Arc::new(SystemClock),
2176 ids: Arc::new(DefaultIdGenerator::new()),
2177 });
2178 let metas = repo
2179 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2180 .await
2181 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2182 let source_meta = metas
2183 .iter()
2184 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2185 .ok_or_else(|| BuildError::SessionNotFound {
2186 requested: format!("--fork {source}"),
2187 dir: dir.display().to_string(),
2188 })?;
2189 let fork_storage = repo
2190 .fork_typed(
2191 source_meta,
2192 &JsonlSessionCreateOptions {
2193 id: None,
2194 parent_session_id: Some(source_meta.id.clone()),
2195 cwd: cwd.to_string(),
2196 metadata: None,
2197 },
2198 &ForkOptions::default(),
2199 )
2200 .await
2201 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2202 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
2203 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2204 Ok(Session::new(storage_arc, None))
2205}
2206
2207pub enum OpenError {
2210 NotFound { requested: String },
2212 Other(String),
2214}
2215
2216impl std::fmt::Display for OpenError {
2217 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2218 match self {
2219 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2220 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
2225 }
2226 }
2227}
2228
2229pub async fn list_session_metadata(
2232 cwd: &str,
2233) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2234 let dir = default_session_dir(Path::new(cwd));
2235 list_session_metadata_in_dir(cwd, &dir).await
2236}
2237
2238async fn list_session_metadata_in_dir(
2239 cwd: &str,
2240 dir: &Path,
2241) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2242 use rpi_harness::session::jsonl::{
2243 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2244 };
2245 use rpi_tools::FileSystem;
2246
2247 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2248 let fs: Arc<dyn FileSystem> = env.clone();
2249 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2250 fs: fs.clone(),
2251 sessions_root: dir.to_string_lossy().into_owned(),
2252 clock: Arc::new(SystemClock),
2253 ids: Arc::new(DefaultIdGenerator::new()),
2254 });
2255 repo.list_typed(&JsonlSessionListOptions::default())
2256 .await
2257 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2258}
2259
2260#[derive(Debug, Clone, Default, PartialEq, Eq)]
2276pub struct SessionSummary {
2277 pub name: Option<String>,
2279 pub preview: Option<String>,
2281 pub empty: bool,
2283 pub bytes: u64,
2285}
2286
2287const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
2289const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
2291const SUMMARY_PREVIEW_CHARS: usize = 80;
2293
2294pub fn summarize_session_file(path: &Path) -> SessionSummary {
2298 use std::io::{Read, Seek, SeekFrom};
2299
2300 let Ok(mut file) = std::fs::File::open(path) else {
2301 return SessionSummary::default();
2302 };
2303 let Ok(size) = file.metadata().map(|m| m.len()) else {
2304 return SessionSummary::default();
2305 };
2306 if size == 0 {
2307 return SessionSummary {
2308 name: None,
2309 preview: None,
2310 empty: true,
2311 bytes: 0,
2312 };
2313 }
2314
2315 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2318 let head_len = file.read(&mut head).unwrap_or(0);
2319 head.truncate(head_len);
2320 let head = String::from_utf8_lossy(&head);
2321 let mut lines: Vec<&str> = head.lines().collect();
2322 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2323 let empty = (size as usize) <= header_len + 1;
2325 if (head_len as u64) < size && !lines.is_empty() {
2328 lines.pop();
2329 }
2330
2331 let mut summary = SessionSummary {
2332 name: None,
2333 preview: None,
2334 empty,
2335 bytes: size,
2336 };
2337 for line in lines {
2338 apply_summary_line(line, &mut summary);
2339 }
2340
2341 if size > SUMMARY_HEAD_BYTES
2345 && file
2346 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2347 .is_ok()
2348 {
2349 let mut tail = Vec::new();
2350 if file.read_to_end(&mut tail).is_ok() {
2351 let tail = String::from_utf8_lossy(&tail);
2352 let mut tail_lines = tail.lines();
2353 tail_lines.next();
2355 for line in tail_lines {
2356 if let Some(name) = name_from_line(line) {
2357 summary.name = name;
2358 }
2359 }
2360 }
2361 }
2362 summary
2363}
2364
2365fn name_from_line(line: &str) -> Option<Option<String>> {
2368 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2369 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2370 return None;
2371 }
2372 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2373 return None;
2374 }
2375 Some(match value.get("name") {
2376 Some(serde_json::Value::String(name)) => Some(name.clone()),
2377 _ => None,
2378 })
2379}
2380
2381fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2384 if let Some(name) = name_from_line(line) {
2385 summary.name = name;
2386 return;
2387 }
2388 if summary.preview.is_some() {
2389 return;
2390 }
2391 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2392 return;
2393 };
2394 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2395 return;
2396 }
2397 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2398 return;
2399 }
2400 let Some(message) = value.get("message") else {
2401 return;
2402 };
2403 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2404 return;
2405 }
2406 let text = match message.get("content") {
2407 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2408 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2409 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2410 .then(|| block.get("text").and_then(|v| v.as_str()))
2411 .flatten()
2412 }),
2413 _ => None,
2414 };
2415 let Some(text) = text else { return };
2416 let collapsed = text
2417 .lines()
2418 .map(str::trim)
2419 .filter(|line| !line.is_empty())
2420 .collect::<Vec<_>>()
2421 .join(" ");
2422 if collapsed.is_empty() {
2423 return;
2424 }
2425 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2426}
2427
2428pub fn session_display_label(summary: &SessionSummary) -> String {
2432 if let Some(name) = summary.name.as_deref().map(str::trim) {
2433 if !name.is_empty() {
2434 return name.to_string();
2435 }
2436 }
2437 match summary.preview.as_deref().map(str::trim) {
2438 Some(preview) if !preview.is_empty() => preview.to_string(),
2439 _ => "(no messages)".to_string(),
2440 }
2441}
2442
2443pub fn format_session_bytes(bytes: u64) -> String {
2446 if bytes >= 1_048_576 {
2447 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2448 } else if bytes >= 1024 {
2449 format!("{}KB", bytes / 1024)
2450 } else {
2451 format!("{bytes}B")
2452 }
2453}
2454
2455pub fn short_session_id(id: &str) -> &str {
2458 &id[..id.len().min(8)]
2459}
2460
2461pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2470 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2471 let mut visit = |path: PathBuf| {
2472 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2473 return;
2474 }
2475 let modified = std::fs::metadata(&path)
2476 .and_then(|m| m.modified())
2477 .unwrap_or(std::time::UNIX_EPOCH);
2478 files.push((path, modified));
2479 };
2480 if let Ok(entries) = std::fs::read_dir(dir) {
2481 for entry in entries.flatten() {
2482 let path = entry.path();
2483 if path.is_dir() {
2484 if let Ok(inner) = std::fs::read_dir(&path) {
2485 for child in inner.flatten() {
2486 visit(child.path());
2487 }
2488 }
2489 } else {
2490 visit(path);
2491 }
2492 }
2493 }
2494 files.sort_by(|a, b| b.1.cmp(&a.1));
2495 files.into_iter().map(|(path, _)| path).collect()
2496}
2497
2498fn first_resumable_session<'a>(
2503 metas: &'a [rpi_harness::session::jsonl::JsonlSessionMetadata],
2504) -> Option<&'a rpi_harness::session::jsonl::JsonlSessionMetadata> {
2505 metas
2506 .iter()
2507 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2508 .or_else(|| metas.first())
2509}
2510
2511pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2515 let dir = default_session_dir(Path::new(cwd));
2516 open_session_by_id_in_dir(id, cwd, &dir).await
2517}
2518
2519async fn open_session_by_id_in_dir(id: &str, cwd: &str, dir: &Path) -> Result<Session, OpenError> {
2520 let metas = list_session_metadata_in_dir(cwd, dir)
2521 .await
2522 .map_err(|e| OpenError::Other(e.to_string()))?;
2523 let Some(meta) = metas
2524 .iter()
2525 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2526 else {
2527 return Err(OpenError::NotFound {
2528 requested: format!("session {id}"),
2529 });
2530 };
2531 open_session(meta, cwd).await.map_err(|e| {
2532 OpenError::Other(match e {
2533 BuildError::SessionOpen(reason) => reason,
2537 other => other.to_string(),
2538 })
2539 })
2540}
2541
2542fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
2567 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
2568 tool_context.set_session_id(id);
2569}
2570
2571pub(crate) async fn set_active_session(
2582 harness: &AgentHarness,
2583 session: Session,
2584 tool_context: Option<&rpi_extensions::ToolCallContext>,
2585) -> Result<(), String> {
2586 let metadata = session.get_metadata().await.ok();
2587 harness
2588 .set_session(session)
2589 .await
2590 .map_err(|e| e.to_string())?;
2591 if let Some(metadata) = metadata {
2592 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2593 if let Some(context) = tool_context {
2594 context.set_session_id(metadata.id);
2595 }
2596 }
2597 Ok(())
2598}
2599
2600pub(crate) async fn fork_session_storage(
2607 harness: &AgentHarness,
2608 cwd: &str,
2609) -> Result<Session, String> {
2610 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2611 use rpi_tools::FileSystem;
2612
2613 let dir = default_session_dir(Path::new(cwd));
2614 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2615 let fs: Arc<dyn FileSystem> = env.clone();
2616 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2617 fs,
2618 sessions_root: dir.to_string_lossy().into_owned(),
2619 clock: Arc::new(SystemClock),
2620 ids: Arc::new(DefaultIdGenerator::new()),
2621 });
2622 let id = harness.session().storage().metadata().id.clone();
2625 let metas = list_session_metadata(cwd)
2626 .await
2627 .map_err(|e| e.to_string())?;
2628 let Some(source) = metas.iter().find(|m| m.id == id) else {
2629 return Err(format!("current session {id} not found on disk"));
2630 };
2631 let fork_storage = repo
2632 .fork_typed(
2633 source,
2634 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2635 id: None,
2636 parent_session_id: Some(source.id.clone()),
2637 cwd: cwd.to_string(),
2638 metadata: None,
2639 },
2640 &rpi_harness::session::types::ForkOptions::default(),
2641 )
2642 .await
2643 .map_err(|e| e.to_string())?;
2644 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2645 Ok(Session::new(Arc::new(fork_storage), None))
2646}
2647
2648static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2650static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2653
2654fn claim_session_lock(session_path: &str) -> Result<(), String> {
2663 let lock_path = PathBuf::from(format!("{session_path}.lock"));
2664 if SESSION_LOCK_PATHS
2665 .get_or_init(|| Mutex::new(HashSet::new()))
2666 .lock()
2667 .unwrap()
2668 .contains(&lock_path)
2669 {
2670 return Ok(());
2671 }
2672 let file = std::fs::OpenOptions::new()
2673 .create(true)
2674 .write(true)
2675 .open(&lock_path)
2676 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2677 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2678 return Err(format!(
2679 "session {session_path} is already open in another rpi process"
2680 ));
2681 }
2682 SESSION_LOCKS
2683 .get_or_init(|| Mutex::new(Vec::new()))
2684 .lock()
2685 .unwrap()
2686 .push(file);
2687 SESSION_LOCK_PATHS
2688 .get_or_init(|| Mutex::new(HashSet::new()))
2689 .lock()
2690 .unwrap()
2691 .insert(lock_path);
2692 Ok(())
2693}
2694
2695async fn open_session(
2698 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2699 cwd: &str,
2700) -> Result<Session, BuildError> {
2701 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2702 use rpi_harness::session::types::SessionStorage;
2703 use rpi_tools::FileSystem;
2704
2705 let dir = default_session_dir(Path::new(cwd));
2706 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2707 let fs: Arc<dyn FileSystem> = env.clone();
2708 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2709 fs: fs.clone(),
2710 sessions_root: dir.to_string_lossy().into_owned(),
2711 clock: Arc::new(SystemClock),
2712 ids: Arc::new(DefaultIdGenerator::new()),
2713 });
2714 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2715 let storage = repo
2716 .open_by_jsonl_metadata(meta)
2717 .await
2718 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2719 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2723 Some(Path::new(&meta.path)),
2724 Path::new(&meta.cwd),
2725 Path::new(cwd),
2726 ) {
2727 eprintln!(
2728 "warning: {}",
2729 crate::session_cwd::format_missing_session_cwd_error(&issue)
2730 );
2731 }
2732 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2733 Ok(Session::new(storage_arc, None))
2734}
2735
2736fn ephemeral_session() -> Session {
2738 let storage = Arc::new(InMemorySessionStorage::new(
2739 SessionMetadata {
2740 id: "ephemeral".into(),
2741 created_at: 0,
2742 parent_session_id: None,
2743 },
2744 Arc::new(SystemClock),
2745 Arc::new(DefaultIdGenerator::new()),
2746 ));
2747 Session::new(storage, None)
2748}
2749
2750pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2761 create_jsonl_session_with_id(dir, cwd, None).await
2762}
2763
2764pub(crate) async fn create_jsonl_session_with_id(
2768 dir: &Path,
2769 cwd: &str,
2770 id: Option<String>,
2771) -> Result<Session, String> {
2772 use rpi_harness::session::jsonl::{
2773 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2774 };
2775 use rpi_tools::FileSystem;
2776
2777 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2780 let fs: Arc<dyn FileSystem> = env.clone();
2781
2782 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2783 fs: fs.clone(),
2784 sessions_root: dir.to_string_lossy().into_owned(),
2785 clock: Arc::new(SystemClock),
2786 ids: Arc::new(DefaultIdGenerator::new()),
2787 });
2788
2789 let opts = JsonlSessionCreateOptions {
2790 id, parent_session_id: None,
2792 cwd: cwd.to_string(),
2793 metadata: None,
2794 };
2795 let storage = repo
2796 .create_typed(&opts)
2797 .await
2798 .map_err(|e| format!("create session: {e}"))?;
2799 claim_session_lock(&storage.jsonl_metadata().path)
2800 .map_err(|e| format!("create session: {e}"))?;
2801 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2803 Ok(Session::new(storage_arc, None))
2804}
2805
2806fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
2809 let path = Path::new(target);
2810 if path.is_file() {
2811 read_prompt_source(path, sources)
2812 } else {
2813 None
2814 }
2815}
2816
2817fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
2820 let text = std::fs::read_to_string(path).ok()?;
2821 let absolute = if path.is_absolute() {
2822 path.to_path_buf()
2823 } else {
2824 std::env::current_dir()
2825 .map(|cwd| cwd.join(path))
2826 .unwrap_or_else(|_| path.to_path_buf())
2827 };
2828 let source = absolute.to_string_lossy().into_owned();
2829 if !sources.contains(&source) {
2830 sources.push(source);
2831 }
2832 Some(text)
2833}
2834
2835#[cfg(test)]
2836mod tests {
2837 use super::*;
2838
2839 #[tokio::test]
2840 async fn unauthenticated_packages_are_not_reintroduced_as_legacy_adapters() {
2841 extern "C" fn request(
2842 _: rpi_plugin_sdk::StbStringRef,
2843 _: *mut rpi_plugin_sdk::StbString,
2844 _: *mut std::ffi::c_void,
2845 ) -> i32 {
2846 1
2847 }
2848 let mut registry = rpi_extensions::ExtensionRegistry::new();
2849 for id in ["package", "legacy"] {
2850 registry.register_provider(rpi_extensions::RegisteredProvider {
2851 provider_id: id.into(),
2852 base_url: "http://localhost".into(),
2853 api_style: "openai-completions".into(),
2854 request_fn: request,
2855 stream_fn: None,
2856 plugin_free_string: rpi_plugin_sdk::callbacks::free_string,
2857 user_data: std::ptr::null_mut(),
2858 });
2859 }
2860 assert!(registry.apply_declaration_json(&serde_json::json!({"providers":[{
2861 "id":"package", "requiresAuth":true,
2862 "models":[rpi_ai::Model::new("model", "Model", rpi_ai::Api::OpenaiCompletions, "package", "http://localhost")]
2863 }]}).to_string()));
2864 let session = ExtensionSession::from_parts(
2865 Arc::new(registry.snapshot()),
2866 rpi_extensions::PluginKeepalive::empty(),
2867 vec![],
2868 None,
2869 );
2870 let providers = build_models_with_extensions_for_reload(
2871 &[],
2872 &session,
2873 tokio::runtime::Handle::current(),
2874 );
2875 assert_eq!(
2876 providers
2877 .iter()
2878 .map(|provider| provider.id())
2879 .collect::<Vec<_>>(),
2880 ["legacy"]
2881 );
2882 }
2883 use crate::args::Args;
2884
2885 #[test]
2886 fn loaded_context_paths_track_precedence_and_reload() {
2887 const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
2888 if std::env::var_os(CHILD).is_none() {
2889 let dir = tempfile::tempdir().unwrap();
2890 let output = std::process::Command::new(std::env::current_exe().unwrap())
2891 .args([
2892 "--exact",
2893 "session::tests::loaded_context_paths_track_precedence_and_reload",
2894 "--nocapture",
2895 ])
2896 .env(CHILD, "1")
2897 .env(crate::config::CONFIG_DIR_ENV, dir.path())
2898 .output()
2899 .unwrap();
2900 assert!(
2901 output.status.success(),
2902 "{}\n{}",
2903 String::from_utf8_lossy(&output.stdout),
2904 String::from_utf8_lossy(&output.stderr)
2905 );
2906 return;
2907 }
2908 tokio::runtime::Builder::new_current_thread()
2909 .enable_all()
2910 .build()
2911 .unwrap()
2912 .block_on(async {
2913 let agent = crate::config::agent_dir().unwrap();
2914 let cwd = agent.join("project");
2915 let project_config = cwd.join(".rpi");
2916 std::fs::create_dir_all(&project_config).unwrap();
2917 for (path, content) in [
2918 (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
2919 (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
2920 (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
2921 (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
2922 (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
2923 (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
2924 (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
2925 ] {
2926 std::fs::write(path, content).unwrap();
2927 }
2928 let resolved = crate::provider::resolve(
2929 Some("anthropic"),
2930 Some(crate::provider::DEFAULT_MODEL_ID),
2931 None,
2932 Some("test-key"),
2933 None,
2934 )
2935 .unwrap();
2936 let args = Args {
2937 no_session: true,
2938 no_extensions: true,
2939 no_skills: true,
2940 no_prompt_templates: true,
2941 trust_override: Some(true),
2942 ..Default::default()
2943 };
2944 let (harness, _events, mut context) =
2945 build(&resolved, &args, &cwd, true).await.unwrap();
2946 let resources = harness.get_resources().await.unwrap();
2947 let path = |p: PathBuf| p.to_string_lossy().into_owned();
2948 assert_eq!(
2949 &resources.context_files[..2],
2950 &[
2951 path(project_config.join("SYSTEM.md")),
2952 path(project_config.join("APPEND_SYSTEM.md")),
2953 ]
2954 );
2955 assert!(resources
2956 .context_files
2957 .contains(&path(agent.join("AGENTS.md"))));
2958 assert!(resources
2959 .context_files
2960 .contains(&path(cwd.join("AGENTS.md"))));
2961 assert!(!resources
2962 .context_files
2963 .contains(&path(cwd.join("CLAUDE.md"))));
2964 assert!(!resources
2965 .context_files
2966 .contains(&path(agent.join("SYSTEM.md"))));
2967 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
2968 assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
2969 assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
2970
2971 std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
2974 std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
2975 std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
2976 assert!(
2977 !reload_extension_resources(&harness, &context)
2978 .await
2979 .had_errors
2980 );
2981 let resources = harness.get_resources().await.unwrap();
2982 assert_eq!(
2983 &resources.context_files[..2],
2984 &[
2985 path(agent.join("SYSTEM.md")),
2986 path(agent.join("APPEND_SYSTEM.md")),
2987 ]
2988 );
2989 assert!(resources
2990 .context_files
2991 .contains(&path(cwd.join("CLAUDE.md"))));
2992 assert!(!resources
2993 .context_files
2994 .contains(&path(project_config.join("SYSTEM.md"))));
2995
2996 let explicit = agent.join("custom-append.md");
2999 std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
3000 context.args.system_prompt = Some("EXPLICIT_BASE".into());
3001 context.args.append_system_prompt =
3002 vec![path(explicit.clone()), "literal append".into()];
3003 context.args.no_context_files = true;
3004 assert!(
3005 !reload_extension_resources(&harness, &context)
3006 .await
3007 .had_errors
3008 );
3009 assert_eq!(
3010 harness.get_resources().await.unwrap().context_files,
3011 vec![path(explicit)]
3012 );
3013
3014 context.args.system_prompt = None;
3016 context.args.append_system_prompt.clear();
3017 context.args.no_context_files = false;
3018 context.args.trust_override = Some(false);
3019 assert!(
3020 !reload_extension_resources(&harness, &context)
3021 .await
3022 .had_errors
3023 );
3024 assert!(harness
3025 .get_resources()
3026 .await
3027 .unwrap()
3028 .context_files
3029 .is_empty());
3030 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3031 assert!(
3032 !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
3033 );
3034 });
3035 }
3036
3037 #[test]
3038 fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
3039 let dir = tempfile::tempdir().unwrap();
3040 let file = dir.path().join("SYSTEM.md");
3041 let mut sources = Vec::new();
3042 assert!(read_prompt_source(&file, &mut sources).is_none());
3043 std::fs::write(&file, [0xff, 0xfe]).unwrap();
3044 assert!(read_prompt_source(&file, &mut sources).is_none());
3045 assert!(read_append_target("literal instructions", &mut sources).is_none());
3046 assert!(sources.is_empty());
3047 std::fs::write(&file, "loaded instructions").unwrap();
3048 assert_eq!(
3049 read_prompt_source(&file, &mut sources).as_deref(),
3050 Some("loaded instructions")
3051 );
3052 assert!(read_prompt_source(&file, &mut sources).is_some());
3053 assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
3054 }
3055
3056 extern "C" fn deny_test_tool_call(
3057 _event: rpi_plugin_sdk::StablePluginEvent,
3058 _data: *mut std::ffi::c_void,
3059 ) -> i32 {
3060 rpi_plugin_sdk::EVENT_HANDLER_ABORT
3061 }
3062
3063 async fn invoke_test_tool_hook(
3064 hook: &rpi_agent::BeforeToolCall,
3065 ) -> Option<rpi_agent::BeforeToolCallResult> {
3066 let message = rpi_ai::providers::faux::faux_assistant_message(
3067 "approval",
3068 rpi_ai::types::StopReason::ToolUse,
3069 );
3070 let rpi_ai::types::Content::ToolCall(call) =
3071 rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
3072 else {
3073 unreachable!()
3074 };
3075 hook(
3076 rpi_agent::BeforeToolCallContext {
3077 assistant_message: &message,
3078 tool_call: &call,
3079 args: &call.arguments,
3080 context: &rpi_agent::AgentContext::default(),
3081 },
3082 tokio_util::sync::CancellationToken::new(),
3083 )
3084 .await
3085 }
3086
3087 #[tokio::test]
3088 async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
3089 let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
3090 let hook =
3091 extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
3092 assert!(invoke_test_tool_hook(&hook).await.is_none());
3093 for name in ["first-gate", "reloaded-gate"] {
3094 let mut registry = rpi_extensions::ExtensionRegistry::new();
3095 registry.register_event_handler(
3096 name.into(),
3097 rpi_plugin_sdk::EventTag::ToolCall,
3098 deny_test_tool_call,
3099 std::ptr::null_mut(),
3100 );
3101 let fresh = ExtensionSession::from_parts(
3102 Arc::new(registry.snapshot()),
3103 rpi_extensions::PluginKeepalive::empty(),
3104 Vec::new(),
3105 None,
3106 );
3107 {
3108 let mut current = sessions.lock().unwrap();
3109 if let Some(snapshot) = current.snapshot() {
3110 snapshot.active_flag().store(false, Ordering::SeqCst);
3111 }
3112 *current = fresh;
3113 }
3114 let result = invoke_test_tool_hook(&hook).await.unwrap();
3115 assert!(result.block);
3116 assert!(result.reason.unwrap().contains(name));
3117 }
3118 *sessions.lock().unwrap() = ExtensionSession::none();
3119 assert!(invoke_test_tool_hook(&hook).await.is_none());
3120 }
3121
3122 fn scratch(name: &str) -> PathBuf {
3124 let nanos = std::time::SystemTime::now()
3125 .duration_since(std::time::UNIX_EPOCH)
3126 .unwrap()
3127 .as_nanos();
3128 let path = std::env::temp_dir().join(format!(
3129 "rpi-summary-{}-{nanos}-{name}.jsonl",
3130 std::process::id()
3131 ));
3132 path
3133 }
3134
3135 #[test]
3136 fn summary_marks_a_header_only_session_empty() {
3137 let path = scratch("empty");
3138 std::fs::write(
3139 &path,
3140 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3141 )
3142 .unwrap();
3143 let summary = summarize_session_file(&path);
3144 assert!(summary.empty, "a header-only file has nothing to resume");
3145 assert!(summary.preview.is_none());
3146 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
3147 std::fs::remove_file(&path).ok();
3148 }
3149
3150 #[test]
3151 fn summary_reads_the_first_user_prompt_and_name() {
3152 let path = scratch("content");
3153 let body = concat!(
3154 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3155 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
3156 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
3157 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
3158 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
3159 );
3160 std::fs::write(&path, body).unwrap();
3161 let summary = summarize_session_file(&path);
3162 assert!(!summary.empty);
3163 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
3164 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
3165 std::fs::remove_file(&path).ok();
3166 }
3167
3168 #[test]
3169 fn summary_extracts_a_block_style_user_prompt() {
3170 let path = scratch("blocks");
3171 let body = concat!(
3172 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3173 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
3174 );
3175 std::fs::write(&path, body).unwrap();
3176 let summary = summarize_session_file(&path);
3177 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
3178 assert_eq!(session_display_label(&summary), "line one line two");
3179 std::fs::remove_file(&path).ok();
3180 }
3181
3182 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
3183 rpi_harness::session::jsonl::JsonlSessionMetadata {
3184 id: id.to_string(),
3185 created_at: 0,
3186 cwd: ".".to_string(),
3187 path: path.to_string_lossy().into_owned(),
3188 modified_at: 0,
3189 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
3190 parent_session_id: None,
3191 legacy_parent_session_path: None,
3192 metadata: None,
3193 }
3194 }
3195
3196 #[test]
3197 fn continue_skips_newer_empty_sessions() {
3198 let empty = scratch("newest-empty");
3199 std::fs::write(
3200 &empty,
3201 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3202 )
3203 .unwrap();
3204 let content = scratch("older-content");
3205 std::fs::write(
3206 &content,
3207 concat!(
3208 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3209 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
3210 ),
3211 )
3212 .unwrap();
3213
3214 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
3215 let picked = first_resumable_session(&metas).expect("a resumable session");
3216 assert_eq!(picked.id, "old");
3217
3218 let all_empty = vec![meta_for(&empty, "empty")];
3221 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
3222
3223 std::fs::remove_file(&empty).ok();
3224 std::fs::remove_file(&content).ok();
3225 }
3226
3227 #[test]
3228 fn list_session_files_sync_walks_the_cwd_subdirectory() {
3229 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
3230 let nested = root.join("--proj--");
3231 std::fs::create_dir_all(&nested).unwrap();
3232 let file = nested.join("2026-01-01_a.jsonl");
3233 std::fs::write(&file, "{}\n").unwrap();
3234 let found = list_session_files_sync(&root);
3235 assert!(found.iter().any(|p| p == &file));
3236 std::fs::remove_dir_all(&root).ok();
3237 }
3238
3239 #[test]
3240 fn default_prompt_mentions_cwd_and_tools() {
3241 let p = default_system_prompt("/tmp/proj", false);
3242 assert!(p.contains("/tmp/proj"));
3243 assert!(p.contains("read"));
3244 assert!(p.contains("bash"));
3245 assert!(p.contains("edit"));
3246 assert!(p.contains("write"));
3247 assert!(p.contains("docs"));
3248 assert!(!p.contains("- grep"));
3249 assert!(!p.contains("- find"));
3250 assert!(!p.contains("- ls"));
3251 assert!(!p.contains("- powershell"));
3252 assert!(p.contains("Bash/POSIX syntax, including on Windows"));
3253 assert!(p.contains("do not pass PowerShell cmdlets"));
3254 }
3255
3256 #[test]
3257 fn default_prompt_instructs_language_matching() {
3258 let p = default_system_prompt("/tmp/proj", false);
3262 let lower = p.to_lowercase();
3263 assert!(
3264 lower.contains("same language") && lower.contains("detect"),
3265 "the prompt must tell the model to detect and mirror the user's language: {p}"
3266 );
3267 }
3268
3269 #[test]
3270 fn default_prompt_keeps_working_state_in_the_visible_channel() {
3271 let p = default_system_prompt("/tmp/proj", false);
3279 let lower = p.to_lowercase();
3280 assert!(
3282 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
3283 "the prompt must explain that reasoning does not survive the turn: {p}"
3284 );
3285 assert!(
3287 lower.contains("visible") && lower.contains("todo tool"),
3288 "the prompt must point at visible text and the todo tool: {p}"
3289 );
3290 assert!(
3292 lower.contains("re-stating the same plan"),
3293 "the prompt must call out plan re-statement as a bug: {p}"
3294 );
3295 assert!(
3297 lower.contains("already shown earlier"),
3298 "the prompt must discourage re-fetching known content: {p}"
3299 );
3300 }
3301
3302 #[test]
3303 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
3304 let p = default_system_prompt("/tmp/proj", true);
3311 let lower = p.to_lowercase();
3312 assert!(
3313 lower.contains("carried back"),
3314 "a replaying model must be told its reasoning survives: {p}"
3315 );
3316 assert!(
3317 !lower.contains("reasoning is not"),
3318 "the replayed variant must not claim reasoning is dropped: {p}"
3319 );
3320 assert!(
3321 lower.contains("do not re-derive"),
3322 "the replayed variant must forbid re-deriving the plan: {p}"
3323 );
3324 assert!(
3325 lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
3326 "the anti-repetition rule must survive the flip: {p}"
3327 );
3328 assert!(
3329 lower.contains("already shown earlier"),
3330 "the re-read rule must survive the flip: {p}"
3331 );
3332 }
3333
3334 #[test]
3342 fn only_read_only_tools_are_replayable() {
3343 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3344 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3345 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3346 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3347 let tools = build_tools(&context, &Args::default());
3348
3349 let replay_of = |name: &str| {
3350 tools
3351 .iter()
3352 .find(|tool| tool.tool.schema().name == name)
3353 .map(|tool| tool.replay)
3354 };
3355
3356 for read_only in ["read", "docs"] {
3357 assert_eq!(
3358 replay_of(read_only),
3359 Some(ToolReplay::Safe),
3360 "{read_only} is side-effect free, so recovery may re-run it"
3361 );
3362 }
3363 for mutating in ["bash", "edit", "write", "powershell"] {
3364 if let Some(replay) = replay_of(mutating) {
3365 assert_eq!(
3366 replay,
3367 ToolReplay::Never,
3368 "{mutating} must never be replayed: re-running it would duplicate its effect"
3369 );
3370 }
3371 }
3372 }
3373
3374 #[test]
3375 fn default_tools_include_docs_lookup() {
3376 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3377 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3378 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3379 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3380 let names: Vec<String> = build_tools(&context, &Args::default())
3381 .iter()
3382 .map(|tool| tool.tool.schema().name.clone())
3383 .collect();
3384
3385 assert!(names.contains(&"docs".to_string()));
3390 for legacy in ["grep", "find", "ls"] {
3391 assert!(
3392 !names.contains(&legacy.to_string()),
3393 "legacy tool {legacy} should not be registered by default"
3394 );
3395 }
3396 }
3397
3398 #[test]
3399 fn reload_settings_preflight_respects_trust() {
3400 struct RestoreConfigDir(Option<std::ffi::OsString>);
3401
3402 impl Drop for RestoreConfigDir {
3403 fn drop(&mut self) {
3404 match self.0.take() {
3405 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3406 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3407 }
3408 }
3409 }
3410
3411 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3412 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3413 let tmp = tempfile::tempdir().unwrap();
3414 let agent = tmp.path().join("agent");
3415 let cwd = tmp.path().join("project");
3416 std::fs::create_dir_all(&agent).unwrap();
3417 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3418 std::fs::write(agent.join("settings.json"), "{}").unwrap();
3419 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
3420 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3421 let args = Args::default();
3422
3423 let trusted = validate_settings_for_reload(&args, &cwd, true);
3424 let untrusted = validate_settings_for_reload(&args, &cwd, false);
3425 assert!(trusted
3426 .unwrap_err()
3427 .contains("could not load project settings"));
3428 assert!(untrusted.is_ok());
3429
3430 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3431 assert!(validate_settings_for_reload(&args, &cwd, false)
3432 .unwrap_err()
3433 .contains("could not load global settings"));
3434
3435 let local_only = Args {
3436 dev_local_only: true,
3437 ..Args::default()
3438 };
3439 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
3440 }
3441
3442 #[tokio::test(flavor = "current_thread")]
3443 async fn reload_preserves_live_resources_when_settings_break() {
3444 struct RestoreConfigDir(Option<std::ffi::OsString>);
3445
3446 impl Drop for RestoreConfigDir {
3447 fn drop(&mut self) {
3448 match self.0.take() {
3449 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3450 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3451 }
3452 }
3453 }
3454
3455 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3456 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3457 let tmp = tempfile::tempdir().unwrap();
3458 let agent = tmp.path().join("agent");
3459 let cwd = tmp.path().join("project");
3460 let skill_dir = tmp.path().join("configured-skills");
3461 std::fs::create_dir_all(&agent).unwrap();
3462 std::fs::create_dir_all(&cwd).unwrap();
3463 std::fs::create_dir_all(&skill_dir).unwrap();
3464 std::fs::write(
3465 skill_dir.join("SKILL.md"),
3466 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
3467 )
3468 .unwrap();
3469 std::fs::write(
3470 agent.join("settings.json"),
3471 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3472 )
3473 .unwrap();
3474 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3475
3476 let resolved = crate::provider::resolve(
3477 Some("anthropic"),
3478 Some(crate::provider::DEFAULT_MODEL_ID),
3479 None,
3480 Some("test-key"),
3481 None,
3482 )
3483 .unwrap();
3484 let args = Args {
3485 trust_override: Some(false),
3486 no_session: true,
3487 no_extensions: true,
3488 no_prompt_templates: true,
3489 no_context_files: true,
3490 system_prompt: Some("stable system prompt".into()),
3491 ..Args::default()
3492 };
3493 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3494 let before_resources = harness.get_resources().await.unwrap();
3495 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
3496 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
3497 let before_prompt = harness.get_system_prompt().await.unwrap();
3498 let before_tools: Vec<String> = harness
3499 .get_tools()
3500 .await
3501 .unwrap()
3502 .iter()
3503 .map(|tool| tool.tool.schema().name.clone())
3504 .collect();
3505 let before_bridge = context
3506 .action_bridge
3507 .lock()
3508 .unwrap()
3509 .as_ref()
3510 .unwrap()
3511 .clone();
3512
3513 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3514 let outcome = reload_extension_resources(&harness, &context).await;
3515
3516 assert!(outcome.had_warnings);
3517 assert!(outcome.summary.contains("Settings reload failed"));
3518 assert!(outcome.summary.contains("could not load global settings"));
3519 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
3520 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
3521 let after_tools: Vec<String> = harness
3522 .get_tools()
3523 .await
3524 .unwrap()
3525 .iter()
3526 .map(|tool| tool.tool.schema().name.clone())
3527 .collect();
3528 assert_eq!(after_tools, before_tools);
3529 let after_bridge = context
3530 .action_bridge
3531 .lock()
3532 .unwrap()
3533 .as_ref()
3534 .unwrap()
3535 .clone();
3536 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
3537 }
3538
3539 #[test]
3540 fn reload_preparation_rejects_settings_changed_during_derivation() {
3541 struct RestoreConfigDir(Option<std::ffi::OsString>);
3542
3543 impl Drop for RestoreConfigDir {
3544 fn drop(&mut self) {
3545 match self.0.take() {
3546 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3547 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3548 }
3549 }
3550 }
3551
3552 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3553 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3554 let tmp = tempfile::tempdir().unwrap();
3555 let agent = tmp.path().join("agent");
3556 let cwd = tmp.path().join("project");
3557 let first_skill_dir = tmp.path().join("first-skills");
3558 let second_skill_dir = tmp.path().join("second-skills");
3559 std::fs::create_dir_all(&agent).unwrap();
3560 std::fs::create_dir_all(&cwd).unwrap();
3561 std::fs::write(
3562 agent.join("settings.json"),
3563 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3564 )
3565 .unwrap();
3566 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3567 let args = Args {
3568 trust_override: Some(false),
3569 no_extensions: true,
3570 no_prompt_templates: true,
3571 ..Args::default()
3572 };
3573
3574 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3575 std::fs::write(
3576 agent.join("settings.json"),
3577 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3578 )
3579 .unwrap();
3580 });
3581
3582 let error = result
3583 .err()
3584 .expect("settings mutation must fail preparation");
3585 assert!(error.contains("settings changed while reload inputs were being prepared"));
3586 }
3587
3588 #[tokio::test(flavor = "current_thread")]
3589 async fn reload_uses_frozen_settings_inputs_after_preparation() {
3590 struct RestoreConfigDir(Option<std::ffi::OsString>);
3591
3592 impl Drop for RestoreConfigDir {
3593 fn drop(&mut self) {
3594 match self.0.take() {
3595 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3596 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3597 }
3598 }
3599 }
3600
3601 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3602 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3603 let tmp = tempfile::tempdir().unwrap();
3604 let agent = tmp.path().join("agent");
3605 let cwd = tmp.path().join("project");
3606 let skill_dir = tmp.path().join("configured-skills");
3607 std::fs::create_dir_all(&agent).unwrap();
3608 std::fs::create_dir_all(&cwd).unwrap();
3609 std::fs::create_dir_all(&skill_dir).unwrap();
3610 std::fs::write(
3611 skill_dir.join("SKILL.md"),
3612 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3613 )
3614 .unwrap();
3615 std::fs::write(
3616 agent.join("settings.json"),
3617 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3618 )
3619 .unwrap();
3620 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3621
3622 let resolved = crate::provider::resolve(
3623 Some("anthropic"),
3624 Some(crate::provider::DEFAULT_MODEL_ID),
3625 None,
3626 Some("test-key"),
3627 None,
3628 )
3629 .unwrap();
3630 let args = Args {
3631 trust_override: Some(false),
3632 no_session: true,
3633 no_extensions: true,
3634 no_prompt_templates: true,
3635 no_context_files: true,
3636 system_prompt: Some("stable system prompt".into()),
3637 ..Args::default()
3638 };
3639 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3640
3641 let outcome = reload_extension_resources_inner(&harness, &context, || {
3642 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3643 })
3644 .await;
3645
3646 assert!(!outcome.summary.contains("Settings reload failed"));
3647 assert_eq!(
3648 outcome.summary,
3649 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3650 );
3651 let resources = harness.get_resources().await.unwrap();
3652 let skills = resources.skills.as_ref().unwrap();
3653 assert_eq!(skills.len(), 1);
3654 assert_eq!(skills[0].name, "frozen-skill");
3655 }
3656
3657 #[tokio::test]
3658 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3659 let tmp = tempfile::tempdir().unwrap();
3660 let skill_path = tmp.path().join("explicit-skill.md");
3661 std::fs::write(
3662 &skill_path,
3663 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3664 )
3665 .unwrap();
3666 let prompt_path = tmp.path().join("explicit-prompt.md");
3667 std::fs::write(
3668 &prompt_path,
3669 "---\ndescription: Explicit prompt\n---\nPrompt body",
3670 )
3671 .unwrap();
3672
3673 let args = Args {
3674 skill: vec![skill_path.clone()],
3675 prompt_template: vec![prompt_path.clone()],
3676 ..Args::default()
3677 };
3678 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3679 let prompt_paths =
3680 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3681 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3682 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3683
3684 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3685 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3686 assert_eq!(skills.skills[0].name, "explicit-skill");
3687
3688 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3689 assert_eq!(
3690 prompts.prompt_templates.len(),
3691 1,
3692 "{:?}",
3693 prompts.diagnostics
3694 );
3695 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3696 }
3697
3698 #[test]
3699 fn tool_policy_applies_to_rust_and_js_active_names() {
3700 let names = vec![
3701 "read".to_string(),
3702 "ask_user_question".to_string(),
3703 "write".to_string(),
3704 ];
3705
3706 let args = Args {
3707 tools: Some(vec!["read".into(), "ask_user_question".into()]),
3708 ..Args::default()
3709 };
3710 assert_eq!(
3711 filter_active_tool_names(names.clone(), &args),
3712 vec!["read", "ask_user_question"]
3713 );
3714
3715 let args = Args {
3716 exclude_tools: Some(vec!["ask_user_question".into()]),
3717 ..Args::default()
3718 };
3719 assert_eq!(
3720 filter_active_tool_names(names.clone(), &args),
3721 vec!["read", "write"]
3722 );
3723
3724 let args = Args {
3725 no_tools: true,
3726 ..Args::default()
3727 };
3728 assert!(filter_active_tool_names(names, &args).is_empty());
3729 }
3730
3731 #[test]
3732 fn select_ephemeral_when_no_session() {
3733 let args = Args {
3734 no_session: true,
3735 ..Args::default()
3736 };
3737 let cwd = Path::new("/tmp");
3738 assert!(matches!(
3739 select_session(&args, cwd),
3740 SessionSelection::Ephemeral
3741 ));
3742 }
3743
3744 #[test]
3745 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3746 let defaults = Args::default();
3747 assert!(resolve_project_trust(
3748 &defaults,
3749 Path::new("C:/definitely-not-a-project")
3750 ));
3751
3752 let denied = Args {
3753 trust_override: Some(false),
3754 ..Args::default()
3755 };
3756 assert!(!resolve_project_trust(
3757 &denied,
3758 Path::new("C:/definitely-not-a-project")
3759 ));
3760 }
3761
3762 #[tokio::test(flavor = "current_thread")]
3763 async fn build_preserves_the_supplied_startup_project_snapshot() {
3764 struct RestoreConfigDir(Option<std::ffi::OsString>);
3765
3766 impl Drop for RestoreConfigDir {
3767 fn drop(&mut self) {
3768 match self.0.take() {
3769 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3770 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3771 }
3772 }
3773 }
3774
3775 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3776 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3777 let tmp = tempfile::tempdir().unwrap();
3778 let agent = tmp.path().join("agent");
3779 let cwd = tmp.path().join("project");
3780 std::fs::create_dir_all(&agent).unwrap();
3781 std::fs::create_dir_all(&cwd).unwrap();
3782 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3783
3784 let resolved = crate::provider::resolve(
3785 Some("anthropic"),
3786 Some(crate::provider::DEFAULT_MODEL_ID),
3787 None,
3788 Some("test-key"),
3789 None,
3790 )
3791 .unwrap();
3792 let args = Args {
3793 trust_override: Some(false),
3794 no_session: true,
3795 no_tools: true,
3796 no_extensions: true,
3797 no_skills: true,
3798 no_prompt_templates: true,
3799 no_context_files: true,
3800 system_prompt: Some("test prompt".into()),
3801 ..Args::default()
3802 };
3803
3804 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3805
3806 assert_eq!(context.cwd, cwd);
3807 assert!(context.project_trusted);
3808 }
3809
3810 #[tokio::test(flavor = "current_thread")]
3811 async fn build_applies_the_name_flag_to_the_session() {
3812 struct RestoreConfigDir(Option<std::ffi::OsString>);
3813
3814 impl Drop for RestoreConfigDir {
3815 fn drop(&mut self) {
3816 match self.0.take() {
3817 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3818 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3819 }
3820 }
3821 }
3822
3823 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3824 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3825 let tmp = tempfile::tempdir().unwrap();
3826 let agent = tmp.path().join("agent");
3827 let cwd = tmp.path().join("project");
3828 std::fs::create_dir_all(&agent).unwrap();
3829 std::fs::create_dir_all(&cwd).unwrap();
3830 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3831
3832 let resolved = crate::provider::resolve(
3833 Some("anthropic"),
3834 Some(crate::provider::DEFAULT_MODEL_ID),
3835 None,
3836 Some("test-key"),
3837 None,
3838 )
3839 .unwrap();
3840 let args = Args {
3841 trust_override: Some(false),
3842 no_tools: true,
3843 no_extensions: true,
3844 no_skills: true,
3845 no_prompt_templates: true,
3846 no_context_files: true,
3847 system_prompt: Some("test prompt".into()),
3848 name: Some("smoke name".into()),
3849 session_dir: Some(cwd.join("sessions")),
3850 ..Args::default()
3851 };
3852
3853 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3854 assert_eq!(
3855 harness.get_name().await.unwrap().as_deref(),
3856 Some("smoke name")
3857 );
3858 }
3859
3860 #[test]
3861 fn select_latest_for_continue_and_resume() {
3862 let args = Args {
3863 continue_session: true,
3864 ..Args::default()
3865 };
3866 let cwd = Path::new("/tmp");
3867 assert!(matches!(
3868 select_session(&args, cwd),
3869 SessionSelection::Latest { .. }
3870 ));
3871
3872 let args = Args {
3873 resume: true,
3874 ..Args::default()
3875 };
3876 assert!(matches!(
3877 select_session(&args, cwd),
3878 SessionSelection::Latest { .. }
3879 ));
3880 }
3881
3882 #[test]
3883 fn select_by_id_for_session_flag() {
3884 let args = Args {
3885 session: Some("01a02ece".into()),
3886 ..Args::default()
3887 };
3888 let cwd = Path::new("/tmp");
3889 assert!(matches!(
3890 select_session(&args, cwd),
3891 SessionSelection::ById { id, .. } if id == "01a02ece"
3892 ));
3893 }
3894
3895 #[test]
3896 fn select_new_with_custom_dir() {
3897 let args = Args {
3898 session_dir: Some(PathBuf::from("/tmp/sess")),
3899 ..Args::default()
3900 };
3901 let cwd = Path::new("/tmp");
3902 match select_session(&args, cwd) {
3903 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
3904 other => panic!("expected New, got {other:?}"),
3905 }
3906 }
3907
3908 #[test]
3909 fn select_new_default_dir() {
3910 let args = Args::default();
3911 let cwd = Path::new("/proj");
3912 match select_session(&args, cwd) {
3913 SessionSelection::New { dir, .. } => {
3914 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
3915 }
3916 other => panic!("expected New, got {other:?}"),
3917 }
3918 }
3919
3920 #[test]
3921 fn default_session_dir_only_uses_rpi() {
3922 let tmp = tempfile::tempdir().unwrap();
3923 let cwd = tmp.path();
3924 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3925 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3926 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3927 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3928 }
3929
3930 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3931 async fn ephemeral_session_builds_roundtrips() {
3932 let s = ephemeral_session();
3936 let leaf = s.get_leaf_id().await;
3937 assert!(leaf.is_ok());
3938 }
3939
3940 #[test]
3958 fn host_private_env_matches_the_declared_contract() {
3959 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
3961 assert_eq!(
3962 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
3963 [
3964 "LANGFUSE_PI_PARENT_TRACE_ID",
3965 "LANGFUSE_PI_PARENT_SPAN_ID",
3966 "LANGFUSE_PI_PARENT_SESSION_ID",
3967 "LANGFUSE_PI_PARENT_DEPTH",
3968 ],
3969 "these strings are what rpi-langfuse (a different repo) matches; a \
3970 change here is a breaking cross-repo change, not a refactor"
3971 );
3972
3973 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
3976 .iter()
3977 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
3978 {
3979 assert!(
3980 rpi_tools::is_host_private_env(name),
3981 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
3982 rpi_tools::HOST_PRIVATE_ENV
3983 );
3984 }
3985
3986 for visible in [
3989 crate::args::RPI_OFFLINE_ENV,
3990 crate::config::CONFIG_DIR_ENV,
3991 "PATH",
3992 ] {
3993 assert!(
3994 !rpi_tools::is_host_private_env(visible),
3995 "{visible} describes the user's configuration and must still reach the shell"
3996 );
3997 }
3998 }
3999 #[tokio::test]
4000 async fn reload_shutdown_joins_all_handlers_even_after_veto() {
4001 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
4002 use std::sync::atomic::AtomicUsize;
4003 static CONTEXTS: rpi_plugin_sdk::handles::ToolHandles<Arc<AtomicUsize>> =
4004 rpi_plugin_sdk::handles::ToolHandles::new();
4005 struct ContextOwner(usize);
4006 impl Drop for ContextOwner {
4007 fn drop(&mut self) {
4008 drop(CONTEXTS.remove(self.0 as rpi_plugin_sdk::StepHandle));
4009 }
4010 }
4011 extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
4012 let context = CONTEXTS.get(data).expect("live test context");
4013 let calls = context.lock().clone();
4014 std::thread::sleep(std::time::Duration::from_millis(20));
4015 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
4016 rpi_plugin_sdk::EVENT_HANDLER_ABORT
4017 } else {
4018 0
4019 }
4020 }
4021 let calls = Arc::new(AtomicUsize::new(0));
4022 let context = CONTEXTS.insert(calls.clone());
4023 let _owner = ContextOwner(context as usize);
4024 let mut registry = rpi_extensions::ExtensionRegistry::new();
4025 for name in ["first", "second"] {
4026 registry.register_event_handler(name.into(), EventTag::SessionShutdown, stop, context);
4027 }
4028 super::shutdown_extensions_for_reload(
4029 Some(Arc::new(registry.snapshot())),
4030 rpi_extensions::PluginKeepalive::empty(),
4031 )
4032 .await
4033 .unwrap();
4034 assert_eq!(calls.load(Ordering::SeqCst), 2);
4035 }
4036}