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