1use std::collections::HashSet;
34use std::path::{Path, PathBuf};
35use std::sync::atomic::Ordering;
36use std::sync::{Arc, Mutex, OnceLock};
37
38use rpi_ai::Provider;
39use rpi_harness::agent_harness::AgentHarness;
40use rpi_harness::context_files::{format_project_context, load_project_context_files};
41use rpi_harness::session::memory::{InMemorySessionStorage, SystemClock};
42use rpi_harness::session::session::DefaultIdGenerator;
43use rpi_harness::session::types::SessionMetadata;
44use rpi_harness::session::Session;
45use rpi_harness::system_prompt::compose_system_prompt;
46use rpi_harness::types::{
47 AgentHarnessOptions, AgentHarnessResources, AgentHarnessStreamOptions, DrivingMode,
48 HarnessTool, HarnessToolExecution, ToolReplay,
49};
50#[cfg(windows)]
51use rpi_tools::create_powershell_tool;
52use rpi_tools::{
53 create_bash_tool, create_edit_tool, create_read_tool, create_write_tool, ExecutionToolContext,
54 MutationQueueRegistry, OsExecutionEnv,
55};
56
57use crate::args::Args;
58use crate::docs_tool::create_docs_tool;
59use crate::provider::ResolvedModel;
60use crate::resource_dirs::{
61 discover_append_system_prompt_file, discover_system_prompt_file, extension_dirs,
62 global_extension_dirs, global_prompt_template_dirs, global_skill_dirs,
63 load_prompt_templates_with_precedence, load_skills_with_precedence, project_extension_dirs,
64 project_prompt_template_dirs, project_skill_dirs, prompt_template_dirs, skill_dirs,
65};
66use rpi_extensions::{
67 emit_resources_discover, ExtensionEmitter, ExtensionSession, NullDiagnostics,
68 PluginDiagnostics, PluginToolAdapter, TeeEmitter,
69};
70
71pub const BUILTIN_TOOL_NAMES: &[&str] = &["read", "bash", "edit", "write", "docs"];
73
74pub fn default_system_prompt(cwd: &str, prior_reasoning_replayed: bool) -> String {
77 let working_state_rule = if prior_reasoning_replayed {
83 "- Keep a SHORT working plan visible (a few bullets, or the todo tool) and update it as you go.
84 Your own reasoning is carried back to you on the next turn, so do not re-derive it or restate
85 the whole plan: continue from the last unfinished step, and when a step lands, say which one.
86 Re-listing the same plan without acting on it is a bug, not progress."
87 } else {
88 "- Keep your working state in your VISIBLE replies, not only in reasoning. Reasoning is not
89 carried into your next turn: only the text you write and the tool output you produce come
90 back. Before each batch of tool calls, write one short line naming the task you are on and
91 what remains. When you finish a step, say which one is done. If you keep the plan only in
92 your head you will re-derive it from scratch every turn."
93 };
94
95 format!(
96 "You are an expert coding assistant operating inside rpi, a coding agent harness. \
97You help users by reading files, executing commands, editing code, and writing new files.
98
99Available tools:
100- read — Read file contents
101- bash — Execute Bash commands using Bash/POSIX syntax, including on Windows
102- edit — Find/replace edits to existing files
103- write — Create or overwrite files
104- docs — Look up rpi usage, extension, package, and compatibility documentation
105
106Guidelines:
107- Reply in the SAME language the user is writing in. Detect the language of the user's
108 latest message (and, when it is too short to be sure, the working language of the
109 conversation so far) and use it for your prose replies. Mirror it unless the user
110 explicitly asks for a different language; only fall back to English when the
111 user's language cannot be determined.
112- Be concise in your responses
113- Show file paths clearly when working with files
114- Prefer the smallest change that solves the problem
115- The bash tool runs Bash, even on Windows. Use ls, find, rg, and POSIX pipelines;
116 do not pass PowerShell cmdlets such as Get-ChildItem, Select-Object, or Select-String.
117 If a powershell tool is available, use it for PowerShell commands.
118{working_state_rule}
119- Track multi-step work with the todo tool instead of re-listing the plan in prose: add the
120 steps once, then mark them done as you go. Re-stating the same plan without acting on it is
121 a bug, not progress.
122- Read only what you need. If a file or search result was already shown earlier in this
123 conversation, use it instead of fetching it again.
124- When unsure about rpi commands, extensions, Pi package compatibility, or .rpi configuration, consult the project documentation before guessing
125
126Current working directory: {cwd}"
127 )
128}
129
130#[derive(Debug, Clone)]
137pub enum SessionSelection {
138 Ephemeral,
140 New { dir: PathBuf, name: Option<String> },
142 Latest { dir: PathBuf },
144 ById { id: String, dir: PathBuf },
147 ByExactId { id: String, dir: PathBuf },
149 Fork { source: String, dir: PathBuf },
152}
153
154pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
156 if args.no_session {
157 return SessionSelection::Ephemeral;
158 }
159 let dir = args
163 .session_dir
164 .clone()
165 .or_else(|| {
166 crate::settings::load_settings()
167 .ok()
168 .and_then(|settings| settings.session_dir)
169 })
170 .unwrap_or_else(|| default_session_dir(cwd));
171 if args.continue_session || args.resume {
172 return SessionSelection::Latest { dir };
173 }
174 if let Some(s) = &args.fork {
175 return SessionSelection::Fork {
176 source: s.clone(),
177 dir,
178 };
179 }
180 if let Some(s) = &args.session_id {
181 return SessionSelection::ByExactId { id: s.clone(), dir };
182 }
183 if let Some(s) = &args.session {
184 return SessionSelection::ById { id: s.clone(), dir };
185 }
186 SessionSelection::New {
187 dir,
188 name: args.name.clone(),
189 }
190}
191
192pub fn default_session_dir(cwd: &Path) -> PathBuf {
195 std::env::var("RPI_CODING_AGENT_SESSION_DIR")
196 .ok()
197 .map(PathBuf::from)
198 .unwrap_or_else(|| cwd.join(".rpi").join("sessions"))
199}
200
201pub struct PreparedExtensions {
204 pub extension_session: ExtensionSession,
205 runtime: tokio::runtime::Handle,
206 tool_context: rpi_extensions::ToolCallContext,
207 action_host: Arc<crate::extensions_actions::HarnessActionHost>,
208 harness_cell: Arc<std::sync::OnceLock<rpi_harness::agent_harness::WeakAgentHarness>>,
209 reload_mailbox: rpi_extensions::ReloadMailbox,
210 ui_dialog_mailbox: rpi_extensions::UiDialogMailbox,
211 ext_status_mailbox: rpi_extensions::ExtensionStatusMailbox,
212 editor_text_mailbox: rpi_extensions::EditorTextMailbox,
213 action_bridge: Arc<rpi_extensions::ActionBridge>,
214}
215
216pub fn prepare_extensions(
217 args: &Args,
218 cwd: &Path,
219 project_trusted: bool,
220) -> Result<PreparedExtensions, BuildError> {
221 let cwd_str = cwd.to_string_lossy().to_string();
222 let tool_context = rpi_extensions::ToolCallContext::new(cwd_str.clone());
223 if !project_trusted && args.verbose {
224 eprintln!(
225 "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)"
226 );
227 }
228 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
247 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
248 })?;
249 let catalog = Vec::new();
250 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
251 catalog.clone(),
252 cwd.to_path_buf(),
253 runtime.clone(),
254 args.unknown_flags.clone(),
255 tool_context.clone(),
256 );
257 let action_host = Arc::new(action_host);
258 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = action_host.clone();
259 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
269 let ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
275 let ext_status_mailbox = rpi_extensions::ExtensionStatusMailbox::new();
279 let editor_text_mailbox = rpi_extensions::EditorTextMailbox::new();
284 let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
285 runtime.clone(),
286 host_arc,
287 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
288 ui_dialog_mailbox.clone(),
289 ext_status_mailbox.clone(),
290 editor_text_mailbox.clone(),
291 );
292
293 let extension_session = if args.no_extensions {
294 ExtensionSession::none()
295 } else {
296 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
297 };
298 if args.verbose {
299 if let Some(s) = extension_session.summary() {
300 eprintln!("extensions: {s}");
301 }
302 report_deferred_renderers(&extension_session);
303 }
304 Ok(PreparedExtensions {
305 runtime,
306 tool_context,
307 action_host,
308 harness_cell,
309 reload_mailbox,
310 ui_dialog_mailbox,
311 ext_status_mailbox,
312 editor_text_mailbox,
313 action_bridge,
314 extension_session,
315 })
316}
317
318pub async fn build(
336 resolved: &ResolvedModel,
337 args: &Args,
338 cwd: &Path,
339 project_trusted: bool,
340) -> Result<
341 (
342 AgentHarness,
343 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
344 ReloadContext,
345 ),
346 BuildError,
347> {
348 let prepared = prepare_extensions(args, cwd, project_trusted)?;
349 build_prepared(resolved, args, cwd, project_trusted, prepared).await
350}
351
352pub async fn build_prepared(
353 resolved: &ResolvedModel,
354 args: &Args,
355 cwd: &Path,
356 project_trusted: bool,
357 prepared: PreparedExtensions,
358) -> Result<
359 (
360 AgentHarness,
361 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
362 ReloadContext,
363 ),
364 BuildError,
365> {
366 let cwd_str = cwd.to_string_lossy().to_string();
367 let PreparedExtensions {
372 runtime,
373 tool_context,
374 action_host,
375 harness_cell,
376 reload_mailbox,
377 ui_dialog_mailbox,
378 ext_status_mailbox,
379 editor_text_mailbox,
380 action_bridge,
381 extension_session,
382 } = prepared;
383 let catalog = crate::provider::authenticated_catalog(resolved);
384 action_host.set_catalog(catalog.clone());
385
386 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
388 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
389 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
390 let _registry = Arc::new(MutationQueueRegistry::new());
391 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
394
395 let mut tools = build_tools(&ctx, args);
396
397 merge_extension_tools(&mut tools, &extension_session, args, &tool_context);
405 let active = filter_active_tool_names(active_tool_names(&tools, args), args);
406
407 let selection = select_session(args, cwd);
409 let session = build_session(&selection, &cwd_str).await?;
410 if let Ok(metadata) = session.get_metadata().await {
414 publish_session_identity(&tool_context, &metadata.id);
415 }
416 if let Some(requested) = args.name.as_deref() {
422 let name = requested.trim();
423 let name = if name.is_empty() { None } else { Some(name) };
424 if let Err(error) = session.set_name(name).await {
425 if args.verbose {
426 eprintln!("warning: could not set session name: {error}");
427 }
428 }
429 }
430 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
440 let mut context_paths = Vec::new();
441 let base_prompt = match args.system_prompt.as_deref() {
442 Some(explicit) => explicit.to_string(),
443 None if project_trusted => match discover_system_prompt_file(cwd) {
444 Some(path) => read_prompt_source(&path, &mut context_paths)
445 .unwrap_or_else(|| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
446 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
447 },
448 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
449 };
450
451 let mut append_texts: Vec<String> = Vec::new();
458 for extra in &args.append_system_prompt {
459 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
460 append_texts.push(text);
461 }
462 if args.append_system_prompt.is_empty() {
463 if let Some(path) = project_trusted
464 .then(|| discover_append_system_prompt_file(cwd))
465 .flatten()
466 {
467 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
468 append_texts.push(text);
469 }
470 }
471 }
472 let append_join = if append_texts.is_empty() {
473 None
474 } else {
475 Some(append_texts.join("\n\n"))
476 };
477
478 let agent_dir = crate::config::agent_dir().ok();
490
491 let discovered = extension_session
507 .snapshot_arc()
508 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
509 .unwrap_or_default();
510
511 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
512 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
513 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
514 if !args.no_skills {
515 let mut dirs = if args.dev_local_only || args.project_only {
516 project_skill_dirs(cwd)
517 } else if project_trusted {
518 skill_dirs(cwd)
519 } else {
520 global_skill_dirs()
521 };
522 dirs.extend(args.skill.iter().cloned());
523 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
524 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
525 skills = result.skills;
526 skill_diags = result.diagnostics;
527 resource_diags.extend(result.resource_diagnostics);
528 }
529
530 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
531 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
532 if !args.no_prompt_templates {
533 let mut dirs = if args.dev_local_only || args.project_only {
534 project_prompt_template_dirs(cwd)
535 } else if project_trusted {
536 prompt_template_dirs(cwd)
537 } else {
538 global_prompt_template_dirs()
539 };
540 dirs.extend(args.prompt_template.iter().cloned());
541 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
542 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
543 prompt_templates = result.prompt_templates;
544 prompt_diags = result.diagnostics;
545 resource_diags.extend(result.resource_diagnostics);
546 }
547
548 let context_block = if args.no_context_files || !project_trusted {
549 String::new()
550 } else {
551 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
556 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
557 context_paths.extend(
558 files
559 .iter()
560 .map(|file| file.path.to_string_lossy().into_owned()),
561 );
562 format_project_context(&files)
563 };
564
565 if args.verbose {
567 for d in &skill_diags {
568 eprintln!(
569 "warning: skill {} ({}): {}",
570 d.path,
571 d.code.as_str(),
572 d.message
573 );
574 }
575 for d in &prompt_diags {
576 eprintln!(
577 "warning: prompt template {} ({}): {}",
578 d.path,
579 d.code.as_str(),
580 d.message
581 );
582 }
583 for d in &resource_diags {
585 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
586 eprintln!(
587 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
588 c.resource_type.as_str(),
589 c.name,
590 c.winner_path,
591 c.loser_path
592 );
593 }
594 }
595 }
596
597 let system_prompt = compose_system_prompt(
606 Some(&base_prompt),
607 &[], if context_block.is_empty() {
609 None
610 } else {
611 Some(&context_block)
612 },
613 append_join.as_deref(),
614 );
615
616 if args.debug_system_prompt {
625 eprintln!("=== --debug-system-prompt ===");
626 let base_src = if args.system_prompt.is_some() {
627 "--system-prompt"
628 } else if discover_system_prompt_file(cwd).is_some() {
629 "SYSTEM.md"
630 } else {
631 "default"
632 };
633 eprintln!("[base source: {base_src}]");
634 eprintln!("--- base ---\n{base_prompt}");
635 if let Some(append) = append_join.as_deref() {
636 eprintln!("--- append ---\n{append}");
637 } else {
638 eprintln!("--- append: (none) ---");
639 }
640 if context_block.is_empty() {
641 eprintln!("--- context: (none) ---");
642 } else {
643 eprintln!("--- context ---{context_block}");
644 }
645 let visible_skills = skills
646 .iter()
647 .filter(|s| s.disable_model_invocation != Some(true))
648 .count();
649 eprintln!(
650 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
651 skills.len(),
652 visible_skills,
653 skills.len() - visible_skills
654 );
655 for s in &skills {
656 let hidden = if s.disable_model_invocation == Some(true) {
657 " [hidden]"
658 } else {
659 ""
660 };
661 eprintln!(" {}{hidden} — {}", s.name, s.description);
662 }
663 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
664 for t in &prompt_templates {
665 eprintln!(" /{}", t.name);
666 }
667 eprintln!(
671 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
672 discovered.skill_paths.len(),
673 discovered.prompt_paths.len(),
674 discovered.theme_paths.len(),
675 );
676 for p in &discovered.skill_paths {
677 eprintln!(" skill: {p}");
678 }
679 for p in &discovered.prompt_paths {
680 eprintln!(" prompt: {p}");
681 }
682 eprintln!(
683 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
684 );
685 eprintln!("=== end --debug-system-prompt ===");
686 }
687
688 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
693 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
694 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
698
699 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
708 Some(snapshot) => {
709 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
710 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
711 }
712 None => broadcast_emitter,
713 };
714
715 let saved_settings = crate::settings::load_settings().unwrap_or_default();
719
720 if let Some(proxy) = &saved_settings.http_proxy {
725 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
726 if std::env::var_os(key).is_none() {
727 std::env::set_var(key, proxy);
728 }
729 }
730 }
731
732 let extension_session_cell = Arc::new(Mutex::new(extension_session.clone()));
737 let before_tool_call = extension_session
742 .snapshot()
743 .filter(|snapshot| {
744 !snapshot
745 .handlers_for(rpi_plugin_sdk::EventTag::ToolCall)
746 .is_empty()
747 })
748 .map(|_| {
749 extension_tool_call_hook(
750 Arc::clone(&extension_session_cell),
751 ui_dialog_mailbox.clone(),
752 )
753 });
754
755 let options = AgentHarnessOptions {
756 model: resolved.model.clone(),
757 thinking_level: resolved.thinking_level,
758 active_tool_names: active,
759 tools,
760 system_prompt: Some(system_prompt),
761 resources: AgentHarnessResources {
762 context_files: context_paths,
763 skills: if skills.is_empty() {
764 None
765 } else {
766 Some(skills)
767 },
768 prompt_templates: if prompt_templates.is_empty() {
769 None
770 } else {
771 Some(prompt_templates)
772 },
773 },
774 allow_existing_session: matches!(
777 selection,
778 SessionSelection::Latest { .. }
779 | SessionSelection::ById { .. }
780 | SessionSelection::ByExactId { .. }
781 | SessionSelection::Fork { .. }
782 ),
783 stream_options: AgentHarnessStreamOptions {
784 timeout: args.timeout,
785 ..Default::default()
786 },
787 retry: saved_settings.retry_policy(),
792 compaction: saved_settings.compaction_settings(),
793 steering_mode: saved_settings.steering_mode(),
794 follow_up_mode: saved_settings.follow_up_mode(),
795 tool_execution: HarnessToolExecution::default(),
796 drive: DrivingMode::default(),
797 session,
798 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
806 to_provider_messages: None,
807 entry_projectors: Default::default(),
808 agent_emitter: Some(emitter),
809 before_tool_call,
814 after_tool_call: None,
815 transform_context: None,
816 entry_transforms: Vec::new(),
817 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
823 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
824 };
825
826 let harness = match AgentHarness::create(options).await {
827 Ok(h) => {
828 crate::extensions_actions::HarnessActionHost::set_harness(
831 &harness_cell,
832 Arc::new(h.clone()),
833 );
834 h
835 }
836 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
837 };
838
839 {
847 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
848 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
849 Ok(reports) => {
850 for (lane, report) in reports {
851 if report.is_clean() {
852 continue;
853 }
854 if report.is_corrupt() {
855 eprintln!(
856 "warning: session lane '{lane}' is corrupt ({} open operations); \
857 refusing to auto-repair — inspect the session file",
858 report.open_operations.len()
859 );
860 continue;
861 }
862 for decision in runtime.reconcile(&report) {
863 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
864 if args.verbose {
865 eprintln!(
866 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
867 );
868 }
869 }
870 }
871 runtime.emit_recovery_events(harness.events(), &report);
872 }
873 }
874 Err(e) => {
875 if args.verbose {
876 eprintln!("warning: session recovery sweep failed: {e}");
877 }
878 }
879 }
880 }
881
882 let reload_context = ReloadContext {
889 extension_session: extension_session_cell,
890 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
891 tool_context,
892 catalog: Arc::new(std::sync::RwLock::new(catalog)),
893 providers: Arc::new(std::sync::RwLock::new(resolved.providers.clone())),
894 runtime: runtime.clone(),
895 cwd: cwd.to_path_buf(),
896 project_trusted,
897 args: args.clone(),
898 resolved_model: resolved.model.clone(),
899 broadcast: broadcast_for_context,
900 mailbox: reload_mailbox,
901 ui_dialog: ui_dialog_mailbox,
902 ext_status: ext_status_mailbox,
903 editor_text: editor_text_mailbox,
904 dev_extension: None,
905 };
906
907 Ok((harness, event_rx, reload_context))
908}
909
910fn extension_tool_call_hook(
913 sessions: ExtensionSessionCell,
914 ui_dialog: rpi_extensions::UiDialogMailbox,
915) -> rpi_agent::BeforeToolCall {
916 Arc::new(move |ctx: rpi_agent::BeforeToolCallContext<'_>, signal| {
917 let session = sessions
918 .lock()
919 .map(|session| session.clone())
920 .map_err(|_| ());
921 let ui_dialog = ui_dialog.clone();
922 let tool_call_id = ctx.tool_call.id.clone();
923 let tool_name = ctx.tool_call.name.clone();
924 let params = ctx.args.clone();
925 Box::pin(async move {
926 let reason = match session {
927 Ok(session) => {
928 rpi_extensions::dispatch_tool_call_event(
929 &session,
930 &tool_call_id,
931 &tool_name,
932 ¶ms,
933 &signal,
934 &ui_dialog,
935 )
936 .await
937 }
938 Err(_) => Some("extension approval session is unavailable".to_string()),
939 };
940 reason.map(|reason| rpi_agent::BeforeToolCallResult {
941 block: true,
942 reason: Some(reason),
943 terminate: false,
944 args: None,
945 })
946 })
947 })
948}
949
950pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
995
996pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
1001
1002#[derive(Clone)]
1010pub struct ReloadContext {
1011 pub extension_session: ExtensionSessionCell,
1013 pub tool_context: rpi_extensions::ToolCallContext,
1018 pub action_bridge: ActionBridgeCell,
1020 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
1023 pub editor_text: rpi_extensions::EditorTextMailbox,
1030 pub catalog: Arc<std::sync::RwLock<Vec<rpi_ai::Model>>>,
1033 pub providers: Arc<std::sync::RwLock<Vec<Arc<dyn Provider>>>>,
1036 pub runtime: tokio::runtime::Handle,
1040 pub cwd: PathBuf,
1042 pub project_trusted: bool,
1046 pub args: Args,
1052 pub resolved_model: rpi_ai::Model,
1055 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
1061 pub mailbox: rpi_extensions::ReloadMailbox,
1068 pub ui_dialog: rpi_extensions::UiDialogMailbox,
1074 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
1078}
1079
1080pub async fn dispatch_session_event_async(
1095 reload_context: &ReloadContext,
1096 tag: rpi_plugin_sdk::EventTag,
1097) -> Option<String> {
1098 let (snapshot, keepalive) = {
1104 let session = reload_context.extension_session.lock().unwrap();
1105 (session.snapshot_arc(), session.keepalive())
1106 };
1107 match snapshot {
1108 Some(snapshot) => rpi_extensions::dispatch_lifecycle_event(&snapshot, tag, keepalive).await,
1109 None => None,
1110 }
1111}
1112
1113async fn shutdown_extensions_for_reload(
1116 snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
1117 keepalive: Arc<rpi_extensions::PluginKeepalive>,
1118) -> Result<(), String> {
1119 if let Some(snapshot) = snapshot {
1120 tokio::task::spawn_blocking(move || {
1121 let _keepalive = keepalive;
1122 rpi_extensions::dispatch_empty_event(
1123 &snapshot,
1124 rpi_plugin_sdk::EventTag::SessionShutdown,
1125 );
1126 })
1127 .await
1128 .map_err(|error| error.to_string())?;
1129 }
1130 Ok(())
1131}
1132
1133pub struct ReloadOutcome {
1136 pub summary: String,
1139 pub had_warnings: bool,
1141 pub details: Vec<String>,
1146 pub had_errors: bool,
1150}
1151
1152struct PreparedReloadInputs {
1153 extension_dirs: Vec<PathBuf>,
1154 skill_base_dirs: Vec<PathBuf>,
1155 prompt_base_dirs: Vec<PathBuf>,
1156}
1157
1158fn append_reload_resource_paths(
1163 mut paths: Vec<PathBuf>,
1164 explicit: &[PathBuf],
1165 discovered: &[String],
1166 js_paths: &[PathBuf],
1167 package_paths: &[PathBuf],
1168) -> Vec<PathBuf> {
1169 paths.extend(explicit.iter().cloned());
1170 paths.extend(discovered.iter().map(PathBuf::from));
1171 paths.extend(js_paths.iter().cloned());
1172 paths.extend(package_paths.iter().cloned());
1173 paths
1174}
1175
1176pub async fn reload_extension_resources(
1188 harness: &AgentHarness,
1189 ctx: &ReloadContext,
1190) -> ReloadOutcome {
1191 reload_extension_resources_inner(harness, ctx, || {}).await
1192}
1193
1194async fn reload_extension_resources_inner<F>(
1195 harness: &AgentHarness,
1196 ctx: &ReloadContext,
1197 after_prepare: F,
1198) -> ReloadOutcome
1199where
1200 F: FnOnce() + Send,
1201{
1202 let mut effective_args = ctx.args.clone();
1207 if let Some(dev) = &ctx.dev_extension {
1208 if let Err(error) = dev
1209 .rebuild()
1210 .and_then(|_| dev.apply_to_args(&mut effective_args))
1211 {
1212 return ReloadOutcome {
1213 summary: format!(
1214 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1215 dev.package_name()
1216 ),
1217 had_warnings: true,
1218 details: Vec::new(),
1219 had_errors: true,
1220 };
1221 }
1222 }
1223 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1224 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1225 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1226 Ok(prepared) => prepared,
1227 Err(error) => {
1228 return ReloadOutcome {
1229 summary: format!(
1230 "Settings reload failed: {error}. Keeping the currently loaded resources."
1231 ),
1232 had_warnings: true,
1233 details: Vec::new(),
1234 had_errors: true,
1235 };
1236 }
1237 };
1238 after_prepare();
1239 let PreparedReloadInputs {
1240 extension_dirs,
1241 skill_base_dirs,
1242 prompt_base_dirs,
1243 } = prepared;
1244 let mut warnings = false;
1245
1246 let (shutdown_snapshot, shutdown_keepalive) = {
1250 let session = ctx.extension_session.lock().unwrap();
1251 (session.snapshot_arc(), session.keepalive())
1252 };
1253 if let Err(reason) = shutdown_extensions_for_reload(shutdown_snapshot, shutdown_keepalive).await
1254 {
1255 return ReloadOutcome {
1256 summary: "Reload failed during extension shutdown".into(),
1257 had_warnings: true,
1258 details: vec![reason],
1259 had_errors: true,
1260 };
1261 }
1262
1263 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1270 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1271 Some(b) => b.clone_host(),
1272 None => {
1273 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1276 ctx.catalog
1277 .read()
1278 .unwrap_or_else(|error| error.into_inner())
1279 .clone(),
1280 ctx.cwd.clone(),
1281 ctx.runtime.clone(),
1282 ctx.args.unknown_flags.clone(),
1283 ctx.tool_context.clone(),
1284 );
1285 crate::extensions_actions::HarnessActionHost::set_harness(
1286 &_cell,
1287 Arc::new(harness.clone()),
1288 );
1289 Arc::new(action_host)
1290 }
1291 };
1292
1293 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1294 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1295 ctx.runtime.clone(),
1296 host,
1297 reload_cb,
1298 ctx.ui_dialog.clone(),
1299 ctx.ext_status.clone(),
1300 ctx.editor_text.clone(),
1301 );
1302
1303 let extension_session = if effective_args.no_extensions {
1304 rpi_extensions::ExtensionSession::none()
1305 } else {
1306 load_extensions_from_dirs(
1307 &effective_args,
1308 &extension_dirs,
1309 Some(Arc::clone(&fresh_bridge)),
1310 )
1311 };
1312 if extension_session.is_empty() && !effective_args.no_extensions {
1313 }
1316 if effective_args.verbose {
1317 if let Some(s) = extension_session.summary() {
1318 eprintln!("reload: {s}");
1319 }
1320 report_deferred_renderers(&extension_session);
1321 }
1322
1323 let has_package_providers = |session: &ExtensionSession| {
1324 session.snapshot().is_some_and(|snapshot| {
1325 snapshot
1326 .providers()
1327 .iter()
1328 .any(|provider| snapshot.provider_manifest(&provider.provider_id).is_some())
1329 })
1330 };
1331 let packaged = has_package_providers(&extension_session)
1332 || has_package_providers(&ctx.extension_session.lock().unwrap());
1333 let refreshed = if packaged {
1334 let mut args = effective_args.clone();
1335 if let Ok(current) = harness.lane("main").get_model().await {
1336 let was_package = ctx
1337 .extension_session
1338 .lock()
1339 .unwrap()
1340 .snapshot()
1341 .is_some_and(|snapshot| snapshot.provider_manifest(¤t.provider).is_some());
1342 let has_replacement = extension_session.snapshot().is_some_and(|snapshot| {
1343 snapshot
1344 .providers()
1345 .iter()
1346 .any(|provider| provider.provider_id == current.provider)
1347 });
1348 if was_package && !has_replacement {
1349 return ReloadOutcome {
1350 summary: "Reload failed while resolving provider packages".into(),
1351 had_warnings: true,
1352 details: vec![format!(
1353 "active provider package '{}' is no longer loaded",
1354 current.provider
1355 )],
1356 had_errors: true,
1357 };
1358 }
1359 args.provider = Some(current.provider.clone());
1360 args.model = Some(current.id);
1361 if current.provider != ctx.resolved_model.provider {
1362 args.api_key = None;
1363 args.base_url = None;
1364 }
1365 }
1366 match crate::provider::resolve_with_extensions(
1367 &args,
1368 &ctx.cwd,
1369 ctx.project_trusted,
1370 &extension_session,
1371 ) {
1372 Ok(resolved) => Some(resolved),
1373 Err(error) => {
1374 return ReloadOutcome {
1375 summary: "Reload failed while resolving provider packages".into(),
1376 had_warnings: true,
1377 details: vec![error.to_string()],
1378 had_errors: true,
1379 }
1380 }
1381 }
1382 } else {
1383 None
1384 };
1385
1386 {
1398 let mut session_guard = ctx.extension_session.lock().unwrap();
1399 let old_session = std::mem::replace(&mut *session_guard, extension_session.clone());
1400 if let Some(old_snap) = old_session.snapshot_arc() {
1401 old_snap.active_flag().store(false, Ordering::SeqCst);
1405 }
1406 }
1409 if let Some(old_b) = old_bridge {
1410 old_b.invalidate();
1411 }
1412
1413 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1415
1416 let discovered = extension_session
1418 .snapshot_arc()
1419 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1420 .unwrap_or_default();
1421
1422 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1424 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1425
1426 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1427 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1428 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1429 if !effective_args.no_skills {
1430 let js_paths: &[PathBuf] = &[];
1434 let package_paths: &[PathBuf] = &[];
1435 let dirs = append_reload_resource_paths(
1436 skill_base_dirs,
1437 &effective_args.skill,
1438 &discovered.skill_paths,
1439 js_paths,
1440 package_paths,
1441 );
1442 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1443 skills = result.skills;
1444 skill_diags = result.diagnostics;
1445 resource_diags.extend(result.resource_diagnostics);
1446 }
1447
1448 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1449 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1450 if !effective_args.no_prompt_templates {
1451 let js_paths: &[PathBuf] = &[];
1452 let package_paths: &[PathBuf] = &[];
1453 let dirs = append_reload_resource_paths(
1454 prompt_base_dirs,
1455 &effective_args.prompt_template,
1456 &discovered.prompt_paths,
1457 js_paths,
1458 package_paths,
1459 );
1460 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1461 prompt_templates = result.prompt_templates;
1462 prompt_diags = result.diagnostics;
1463 resource_diags.extend(result.resource_diagnostics);
1464 }
1465
1466 let context_files = if effective_args.no_context_files || !project_trusted {
1467 Vec::new()
1468 } else {
1469 let agent_dir = crate::config::agent_dir().ok();
1470 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1471 load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await
1472 };
1473 let context_block = format_project_context(&context_files);
1474
1475 let mut details: Vec<String> = Vec::new();
1476 if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
1477 warnings = true;
1478 for d in &skill_diags {
1483 details.push(format!(
1484 "warning: skill {} ({}): {}",
1485 d.path,
1486 d.code.as_str(),
1487 d.message
1488 ));
1489 }
1490 for d in &prompt_diags {
1491 details.push(format!(
1492 "warning: prompt template {} ({}): {}",
1493 d.path,
1494 d.code.as_str(),
1495 d.message
1496 ));
1497 }
1498 for d in &resource_diags {
1500 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1501 details.push(format!(
1502 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1503 c.resource_type.as_str(),
1504 c.name,
1505 c.winner_path,
1506 c.loser_path
1507 ));
1508 }
1509 }
1510 }
1511
1512 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1517 let mut context_paths = Vec::new();
1518 let base_prompt = match effective_args.system_prompt.as_deref() {
1519 Some(explicit) => explicit.to_string(),
1520 None if project_trusted => match discover_system_prompt_file(&ctx.cwd) {
1521 Some(path) => read_prompt_source(&path, &mut context_paths)
1522 .unwrap_or_else(|| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
1523 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1524 },
1525 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1526 };
1527 let mut append_texts: Vec<String> = Vec::new();
1528 for extra in &effective_args.append_system_prompt {
1529 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
1530 append_texts.push(text);
1531 }
1532 if effective_args.append_system_prompt.is_empty() {
1533 if let Some(path) = project_trusted
1534 .then(|| discover_append_system_prompt_file(&ctx.cwd))
1535 .flatten()
1536 {
1537 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
1538 append_texts.push(text);
1539 }
1540 }
1541 }
1542 context_paths.extend(
1543 context_files
1544 .iter()
1545 .map(|file| file.path.to_string_lossy().into_owned()),
1546 );
1547 let append_join = if append_texts.is_empty() {
1548 None
1549 } else {
1550 Some(append_texts.join("\n\n"))
1551 };
1552 let system_prompt = compose_system_prompt(
1553 Some(&base_prompt),
1554 &[],
1555 if context_block.is_empty() {
1556 None
1557 } else {
1558 Some(&context_block)
1559 },
1560 append_join.as_deref(),
1561 );
1562
1563 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1565 Some(snapshot) => {
1566 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1567 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1568 }
1569 None => ctx.broadcast.clone(),
1570 };
1571
1572 let resources = AgentHarnessResources {
1574 context_files: context_paths,
1575 skills: if skills.is_empty() {
1576 None
1577 } else {
1578 Some(skills.clone())
1579 },
1580 prompt_templates: if prompt_templates.is_empty() {
1581 None
1582 } else {
1583 Some(prompt_templates.clone())
1584 },
1585 };
1586 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1587 let _ = harness.set_resources(resources).await;
1588 let _ = harness.set_agent_emitter(Some(emitter)).await;
1589 let _ = harness
1590 .set_models(build_models_with_extensions_for_reload(
1591 &refreshed
1592 .as_ref()
1593 .map(|resolved| resolved.providers.clone())
1594 .unwrap_or_else(|| {
1595 ctx.providers
1596 .read()
1597 .unwrap_or_else(|error| error.into_inner())
1598 .clone()
1599 }),
1600 &extension_session,
1601 ctx.runtime.clone(),
1602 ))
1603 .await;
1604 let _ = harness
1605 .set_provider_hooks(
1606 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1607 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1608 )
1609 .await;
1610
1611 if let Some(resolved) = &refreshed {
1612 *ctx.providers
1613 .write()
1614 .unwrap_or_else(|error| error.into_inner()) = resolved.providers.clone();
1615 let catalog = crate::provider::authenticated_catalog(resolved);
1616 fresh_bridge
1617 .clone_host()
1618 .replace_model_catalog(catalog.clone());
1619 *ctx.catalog
1620 .write()
1621 .unwrap_or_else(|error| error.into_inner()) = catalog;
1622 let _ = harness.lane("main").set_model(resolved.model.clone()).await;
1625 }
1626
1627 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1631 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1632 let mut tools = build_tools(&tool_ctx, &effective_args);
1633 merge_extension_tools(
1634 &mut tools,
1635 &extension_session,
1636 &effective_args,
1637 &ctx.tool_context,
1638 );
1639 let mut active = active_tool_names(&tools, &effective_args);
1640 active = filter_active_tool_names(active, &effective_args);
1641 let _ = harness.set_tools(tools, Some(active)).await;
1642
1643 let summary = format!(
1644 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1645 extension_session.loaded_paths().len(),
1646 skills.len(),
1647 prompt_templates.len(),
1648 );
1649 ReloadOutcome {
1650 summary,
1651 had_warnings: warnings,
1652 details,
1653 had_errors: false,
1656 }
1657}
1658
1659#[derive(Clone, Debug, PartialEq, Eq)]
1660struct ReloadSettingsFields {
1661 skill_dirs: Option<Vec<String>>,
1662 prompt_dirs: Option<Vec<String>>,
1663 extension_dirs: Option<Vec<String>>,
1664}
1665
1666impl From<crate::settings::Settings> for ReloadSettingsFields {
1667 fn from(settings: crate::settings::Settings) -> Self {
1668 Self {
1669 skill_dirs: settings.skill_dirs,
1670 prompt_dirs: settings.prompt_dirs,
1671 extension_dirs: settings.extension_dirs,
1672 }
1673 }
1674}
1675
1676#[derive(Clone, Debug, PartialEq, Eq)]
1677struct ReloadSettingsSnapshot {
1678 global: ReloadSettingsFields,
1679 project: Option<ReloadSettingsFields>,
1680}
1681
1682fn reload_reads_settings(args: &Args) -> bool {
1683 !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
1684}
1685
1686fn load_reload_settings_snapshot(
1690 args: &Args,
1691 cwd: &Path,
1692 project_trusted: bool,
1693) -> Result<Option<ReloadSettingsSnapshot>, String> {
1694 if !reload_reads_settings(args) {
1695 return Ok(None);
1696 }
1697 let global = crate::settings::load_settings()
1698 .map_err(|error| format!("could not load global settings: {error}"))?
1699 .into();
1700 let project = if project_trusted {
1701 crate::settings::load_active_project_settings(cwd)
1702 .map_err(|error| format!("could not load project settings: {error}"))?
1703 .map(|(_, settings)| settings.into())
1704 } else {
1705 None
1706 };
1707 Ok(Some(ReloadSettingsSnapshot { global, project }))
1708}
1709
1710#[cfg(test)]
1713fn validate_settings_for_reload(
1714 args: &Args,
1715 cwd: &Path,
1716 project_trusted: bool,
1717) -> Result<(), String> {
1718 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1719}
1720
1721fn prepare_reload_inputs(
1722 args: &Args,
1723 cwd: &Path,
1724 project_trusted: bool,
1725) -> Result<PreparedReloadInputs, String> {
1726 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1727}
1728
1729fn prepare_reload_inputs_inner<F>(
1730 args: &Args,
1731 cwd: &Path,
1732 project_trusted: bool,
1733 before_verify: F,
1734) -> Result<PreparedReloadInputs, String>
1735where
1736 F: FnOnce(),
1737{
1738 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1739
1740 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1741 Vec::new()
1742 } else if args.project_only {
1743 project_extension_dirs(cwd)
1744 } else if project_trusted {
1745 extension_dirs(cwd)
1746 } else {
1747 global_extension_dirs()
1748 };
1749 if !args.no_extensions {
1750 extension_dirs.extend(args.extensions_dir.iter().cloned());
1751 }
1752
1753 let skill_base_dirs = if args.no_skills {
1754 Vec::new()
1755 } else if args.dev_local_only || args.project_only {
1756 project_skill_dirs(cwd)
1757 } else if project_trusted {
1758 skill_dirs(cwd)
1759 } else {
1760 global_skill_dirs()
1761 };
1762
1763 let prompt_base_dirs = if args.no_prompt_templates {
1764 Vec::new()
1765 } else if args.dev_local_only || args.project_only {
1766 project_prompt_template_dirs(cwd)
1767 } else if project_trusted {
1768 prompt_template_dirs(cwd)
1769 } else {
1770 global_prompt_template_dirs()
1771 };
1772
1773 before_verify();
1774 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1775 if settings_before != settings_after {
1776 return Err(
1777 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1778 );
1779 }
1780
1781 Ok(PreparedReloadInputs {
1782 extension_dirs,
1783 skill_base_dirs,
1784 prompt_base_dirs,
1785 })
1786}
1787
1788fn build_models_with_extensions_for_reload(
1793 providers: &[Arc<dyn Provider>],
1794 extension_session: &ExtensionSession,
1795 runtime: tokio::runtime::Handle,
1796) -> Vec<Arc<dyn Provider>> {
1797 let mut models: Vec<Arc<dyn Provider>> = providers.to_vec();
1798 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1799 for provider in pluggable {
1800 if extension_session
1804 .snapshot()
1805 .is_some_and(|snapshot| snapshot.provider_manifest(provider.id()).is_some())
1806 {
1807 continue;
1808 }
1809 if !models.iter().any(|known| known.id() == provider.id()) {
1810 models.push(provider);
1811 }
1812 }
1813 models
1814}
1815
1816fn report_deferred_renderers(session: &ExtensionSession) {
1820 let Some(snap) = session.snapshot_arc() else {
1821 return;
1822 };
1823 let all = snap.renderers();
1824 let markdown = all
1825 .iter()
1826 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1827 .count();
1828 let message = all
1829 .iter()
1830 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1831 .count();
1832 let entry = all
1833 .iter()
1834 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1835 .count();
1836 if markdown + message + entry == 0 {
1837 return;
1838 }
1839 eprintln!(
1840 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1841 markdown, message, entry
1842 );
1843}
1844
1845#[derive(Debug, thiserror::Error)]
1847pub enum BuildError {
1848 #[error("Could not create the session directory: {0}")]
1849 SessionDir(String),
1850 #[error("Could not open the saved session: {0}")]
1851 SessionOpen(String),
1852 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1853 SessionNotFound { requested: String, dir: String },
1854 #[error("Could not build the harness: {0}")]
1855 HarnessCreate(String),
1856}
1857
1858fn build_models_with_extensions(
1861 resolved: &ResolvedModel,
1862 extension_session: &ExtensionSession,
1863 runtime: tokio::runtime::Handle,
1864) -> Vec<Arc<dyn Provider>> {
1865 build_models_with_extensions_for_reload(&resolved.providers, extension_session, runtime)
1866}
1867
1868pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1871 if let Some(override_value) = args.trust_override {
1872 return override_value;
1873 }
1874 crate::config::project_trust_decision(cwd)
1875 .ok()
1876 .flatten()
1877 .unwrap_or(true)
1878}
1879
1880pub(crate) fn load_extensions(
1888 args: &Args,
1889 cwd: &Path,
1890 project_trusted: bool,
1891 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1892) -> ExtensionSession {
1893 if args.no_extensions {
1894 return ExtensionSession::none();
1895 }
1896 let mut dirs = if args.dev_local_only {
1897 Vec::new()
1898 } else if args.project_only {
1899 project_extension_dirs(cwd)
1900 } else if project_trusted {
1901 extension_dirs(cwd)
1902 } else {
1903 global_extension_dirs()
1904 };
1905 dirs.extend(args.extensions_dir.iter().cloned());
1906 load_extensions_from_dirs(args, &dirs, action_bridge)
1907}
1908
1909fn load_extensions_from_dirs(
1910 args: &Args,
1911 dirs: &[PathBuf],
1912 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1913) -> ExtensionSession {
1914 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1915 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1922}
1923
1924fn merge_extension_tools(
1930 tools: &mut Vec<HarnessTool>,
1931 session: &ExtensionSession,
1932 args: &Args,
1933 tool_context: &rpi_extensions::ToolCallContext,
1934) {
1935 let Some(snapshot) = session.snapshot() else {
1936 return;
1937 };
1938 for et in snapshot.tools() {
1939 let name = &et.tool.name;
1940 if !tool_name_allowed(name, args) {
1941 continue;
1942 }
1943 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
1944 .with_context(tool_context.clone());
1945 let harness_tool = HarnessTool::new(Arc::new(adapter));
1946 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1947 Some(slot) => *slot = harness_tool,
1948 None => tools.push(harness_tool),
1949 }
1950 }
1951}
1952
1953pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1960 use rpi_tools::tools::bash::BashToolOptions;
1961 let default = std::env::var("RPI_BASH_TIMEOUT")
1962 .ok()
1963 .and_then(|v| v.parse::<f64>().ok())
1964 .unwrap_or(120.0);
1965 let settings = crate::settings::load_settings().unwrap_or_default();
1971 BashToolOptions {
1972 command_prefix: settings.shell_command_prefix.clone(),
1973 default_timeout: Some(default),
1974 prepare: None,
1975 }
1976}
1977
1978fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1979 if args.no_tools {
1980 return Vec::new();
1981 }
1982 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1991 (
1992 "read",
1993 HarnessTool::new(create_read_tool(
1994 ctx,
1995 Some(rpi_tools::tools::read::ReadToolOptions {
1996 image_processor: Some(Arc::new(
1997 rpi_tools::image_processing::InlineImageProcessor,
1998 )),
1999 ..Default::default()
2000 }),
2001 ))
2002 .with_replay(ToolReplay::Safe),
2003 ),
2004 (
2005 "bash",
2006 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
2007 ),
2008 ("edit", HarnessTool::new(create_edit_tool(ctx))),
2009 ("write", HarnessTool::new(create_write_tool(ctx))),
2010 (
2011 "docs",
2012 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
2013 ),
2014 ];
2015
2016 #[cfg(target_os = "windows")]
2018 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
2019
2020 if args.tools.is_none() {
2022 if let Ok(settings) = crate::settings::load_settings() {
2023 if let Some(default_tools) = &settings.default_tools {
2024 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
2025 }
2026 }
2027 }
2028
2029 if args.no_builtin_tools {
2033 all.clear();
2034 }
2035
2036 if let Some(allow) = &args.tools {
2038 all.retain(|(name, _)| allow.iter().any(|a| a == name));
2039 }
2040 if let Some(deny) = &args.exclude_tools {
2042 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
2043 }
2044
2045 all.into_iter().map(|(_, t)| t).collect()
2046}
2047
2048fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
2052 filter_active_tool_names(
2053 tools.iter().map(|tool| tool.tool.schema().name.clone()),
2054 args,
2055 )
2056}
2057
2058pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
2061 if args.no_tools {
2062 return false;
2063 }
2064 if args
2065 .tools
2066 .as_ref()
2067 .is_some_and(|allow| !allow.iter().any(|value| value == name))
2068 {
2069 return false;
2070 }
2071 if args
2072 .exclude_tools
2073 .as_ref()
2074 .is_some_and(|deny| deny.iter().any(|value| value == name))
2075 {
2076 return false;
2077 }
2078 true
2079}
2080
2081pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
2082where
2083 I: IntoIterator<Item = String>,
2084{
2085 names
2086 .into_iter()
2087 .filter(|name| tool_name_allowed(name, args))
2088 .collect()
2089}
2090
2091async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2093 match selection {
2094 SessionSelection::Ephemeral => Ok(ephemeral_session()),
2095 SessionSelection::New { dir, .. } => {
2096 std::fs::create_dir_all(dir)
2099 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2100 let session = create_jsonl_session(dir, cwd)
2101 .await
2102 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2103 Ok(session)
2104 }
2105 SessionSelection::Latest { .. }
2106 | SessionSelection::ById { .. }
2107 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2108 SessionSelection::Fork { source, dir } => fork_session_at_launch(source, dir, cwd).await,
2109 }
2110}
2111
2112async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2118 match selection {
2122 SessionSelection::Latest { dir } => {
2123 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2124 let Some(meta) = first_resumable_session(&metas) else {
2125 return Err(BuildError::SessionNotFound {
2126 requested: "the most recent session".to_string(),
2127 dir: default_session_dir(Path::new(cwd)).display().to_string(),
2128 });
2129 };
2130 open_session(meta, cwd).await
2131 }
2132 SessionSelection::ById { id, dir } => open_session_by_id_in_dir(id, cwd, dir)
2133 .await
2134 .map_err(|e| match e {
2135 OpenError::NotFound { requested } => BuildError::SessionNotFound {
2136 requested,
2137 dir: default_session_dir(Path::new(cwd)).display().to_string(),
2138 },
2139 OpenError::Other(msg) => BuildError::SessionOpen(msg),
2140 }),
2141 SessionSelection::ByExactId { id, dir } => {
2142 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2145 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
2146 return open_session(meta, cwd).await;
2147 }
2148 std::fs::create_dir_all(dir)
2149 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2150 create_jsonl_session_with_id(dir, cwd, Some(id.clone()))
2151 .await
2152 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2153 }
2154 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
2155 }
2156}
2157
2158async fn fork_session_at_launch(
2161 source: &str,
2162 dir: &Path,
2163 cwd: &str,
2164) -> Result<Session, BuildError> {
2165 use rpi_harness::session::jsonl::{
2166 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2167 };
2168 use rpi_harness::session::types::{ForkOptions, SessionStorage};
2169 use rpi_tools::FileSystem;
2170
2171 let dir = dir.to_path_buf();
2172 std::fs::create_dir_all(&dir)
2173 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2174 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2175 let fs: Arc<dyn FileSystem> = env.clone();
2176 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2177 fs: fs.clone(),
2178 sessions_root: dir.to_string_lossy().into_owned(),
2179 clock: Arc::new(SystemClock),
2180 ids: Arc::new(DefaultIdGenerator::new()),
2181 });
2182 let metas = repo
2183 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2184 .await
2185 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2186 let source_meta = metas
2187 .iter()
2188 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2189 .ok_or_else(|| BuildError::SessionNotFound {
2190 requested: format!("--fork {source}"),
2191 dir: dir.display().to_string(),
2192 })?;
2193 let fork_storage = repo
2194 .fork_typed(
2195 source_meta,
2196 &JsonlSessionCreateOptions {
2197 id: None,
2198 parent_session_id: Some(source_meta.id.clone()),
2199 cwd: cwd.to_string(),
2200 metadata: None,
2201 },
2202 &ForkOptions::default(),
2203 )
2204 .await
2205 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2206 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
2207 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2208 Ok(Session::new(storage_arc, None))
2209}
2210
2211pub enum OpenError {
2214 NotFound { requested: String },
2216 Other(String),
2218}
2219
2220impl std::fmt::Display for OpenError {
2221 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2222 match self {
2223 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2224 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
2229 }
2230 }
2231}
2232
2233pub async fn list_session_metadata(
2236 cwd: &str,
2237) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2238 let dir = default_session_dir(Path::new(cwd));
2239 list_session_metadata_in_dir(cwd, &dir).await
2240}
2241
2242async fn list_session_metadata_in_dir(
2243 cwd: &str,
2244 dir: &Path,
2245) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2246 use rpi_harness::session::jsonl::{
2247 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2248 };
2249 use rpi_tools::FileSystem;
2250
2251 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2252 let fs: Arc<dyn FileSystem> = env.clone();
2253 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2254 fs: fs.clone(),
2255 sessions_root: dir.to_string_lossy().into_owned(),
2256 clock: Arc::new(SystemClock),
2257 ids: Arc::new(DefaultIdGenerator::new()),
2258 });
2259 repo.list_typed(&JsonlSessionListOptions::default())
2260 .await
2261 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2262}
2263
2264#[derive(Debug, Clone, Default, PartialEq, Eq)]
2280pub struct SessionSummary {
2281 pub name: Option<String>,
2283 pub preview: Option<String>,
2285 pub empty: bool,
2287 pub bytes: u64,
2289}
2290
2291const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
2293const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
2295const SUMMARY_PREVIEW_CHARS: usize = 80;
2297
2298pub fn summarize_session_file(path: &Path) -> SessionSummary {
2302 use std::io::{Read, Seek, SeekFrom};
2303
2304 let Ok(mut file) = std::fs::File::open(path) else {
2305 return SessionSummary::default();
2306 };
2307 let Ok(size) = file.metadata().map(|m| m.len()) else {
2308 return SessionSummary::default();
2309 };
2310 if size == 0 {
2311 return SessionSummary {
2312 name: None,
2313 preview: None,
2314 empty: true,
2315 bytes: 0,
2316 };
2317 }
2318
2319 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2322 let head_len = file.read(&mut head).unwrap_or(0);
2323 head.truncate(head_len);
2324 let head = String::from_utf8_lossy(&head);
2325 let mut lines: Vec<&str> = head.lines().collect();
2326 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2327 let empty = (size as usize) <= header_len + 1;
2329 if (head_len as u64) < size && !lines.is_empty() {
2332 lines.pop();
2333 }
2334
2335 let mut summary = SessionSummary {
2336 name: None,
2337 preview: None,
2338 empty,
2339 bytes: size,
2340 };
2341 for line in lines {
2342 apply_summary_line(line, &mut summary);
2343 }
2344
2345 if size > SUMMARY_HEAD_BYTES
2349 && file
2350 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2351 .is_ok()
2352 {
2353 let mut tail = Vec::new();
2354 if file.read_to_end(&mut tail).is_ok() {
2355 let tail = String::from_utf8_lossy(&tail);
2356 let mut tail_lines = tail.lines();
2357 tail_lines.next();
2359 for line in tail_lines {
2360 if let Some(name) = name_from_line(line) {
2361 summary.name = name;
2362 }
2363 }
2364 }
2365 }
2366 summary
2367}
2368
2369fn name_from_line(line: &str) -> Option<Option<String>> {
2372 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2373 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2374 return None;
2375 }
2376 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2377 return None;
2378 }
2379 Some(match value.get("name") {
2380 Some(serde_json::Value::String(name)) => Some(name.clone()),
2381 _ => None,
2382 })
2383}
2384
2385fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2388 if let Some(name) = name_from_line(line) {
2389 summary.name = name;
2390 return;
2391 }
2392 if summary.preview.is_some() {
2393 return;
2394 }
2395 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2396 return;
2397 };
2398 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2399 return;
2400 }
2401 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2402 return;
2403 }
2404 let Some(message) = value.get("message") else {
2405 return;
2406 };
2407 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2408 return;
2409 }
2410 let text = match message.get("content") {
2411 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2412 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2413 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2414 .then(|| block.get("text").and_then(|v| v.as_str()))
2415 .flatten()
2416 }),
2417 _ => None,
2418 };
2419 let Some(text) = text else { return };
2420 let collapsed = text
2421 .lines()
2422 .map(str::trim)
2423 .filter(|line| !line.is_empty())
2424 .collect::<Vec<_>>()
2425 .join(" ");
2426 if collapsed.is_empty() {
2427 return;
2428 }
2429 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2430}
2431
2432pub fn session_display_label(summary: &SessionSummary) -> String {
2436 if let Some(name) = summary.name.as_deref().map(str::trim) {
2437 if !name.is_empty() {
2438 return name.to_string();
2439 }
2440 }
2441 match summary.preview.as_deref().map(str::trim) {
2442 Some(preview) if !preview.is_empty() => preview.to_string(),
2443 _ => "(no messages)".to_string(),
2444 }
2445}
2446
2447pub fn format_session_bytes(bytes: u64) -> String {
2450 if bytes >= 1_048_576 {
2451 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2452 } else if bytes >= 1024 {
2453 format!("{}KB", bytes / 1024)
2454 } else {
2455 format!("{bytes}B")
2456 }
2457}
2458
2459pub fn short_session_id(id: &str) -> &str {
2462 &id[..id.len().min(8)]
2463}
2464
2465pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2474 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2475 let mut visit = |path: PathBuf| {
2476 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2477 return;
2478 }
2479 let modified = std::fs::metadata(&path)
2480 .and_then(|m| m.modified())
2481 .unwrap_or(std::time::UNIX_EPOCH);
2482 files.push((path, modified));
2483 };
2484 if let Ok(entries) = std::fs::read_dir(dir) {
2485 for entry in entries.flatten() {
2486 let path = entry.path();
2487 if path.is_dir() {
2488 if let Ok(inner) = std::fs::read_dir(&path) {
2489 for child in inner.flatten() {
2490 visit(child.path());
2491 }
2492 }
2493 } else {
2494 visit(path);
2495 }
2496 }
2497 }
2498 files.sort_by(|a, b| b.1.cmp(&a.1));
2499 files.into_iter().map(|(path, _)| path).collect()
2500}
2501
2502fn first_resumable_session(
2507 metas: &[rpi_harness::session::jsonl::JsonlSessionMetadata],
2508) -> Option<&rpi_harness::session::jsonl::JsonlSessionMetadata> {
2509 metas
2510 .iter()
2511 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2512 .or_else(|| metas.first())
2513}
2514
2515pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2519 let dir = default_session_dir(Path::new(cwd));
2520 open_session_by_id_in_dir(id, cwd, &dir).await
2521}
2522
2523async fn open_session_by_id_in_dir(id: &str, cwd: &str, dir: &Path) -> Result<Session, OpenError> {
2524 let metas = list_session_metadata_in_dir(cwd, dir)
2525 .await
2526 .map_err(|e| OpenError::Other(e.to_string()))?;
2527 let Some(meta) = metas
2528 .iter()
2529 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2530 else {
2531 return Err(OpenError::NotFound {
2532 requested: format!("session {id}"),
2533 });
2534 };
2535 open_session(meta, cwd).await.map_err(|e| {
2536 OpenError::Other(match e {
2537 BuildError::SessionOpen(reason) => reason,
2541 other => other.to_string(),
2542 })
2543 })
2544}
2545
2546fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
2571 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
2572 tool_context.set_session_id(id);
2573}
2574
2575pub(crate) async fn set_active_session(
2586 harness: &AgentHarness,
2587 session: Session,
2588 tool_context: Option<&rpi_extensions::ToolCallContext>,
2589) -> Result<(), String> {
2590 let metadata = session.get_metadata().await.ok();
2591 harness
2592 .set_session(session)
2593 .await
2594 .map_err(|e| e.to_string())?;
2595 if let Some(metadata) = metadata {
2596 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2597 if let Some(context) = tool_context {
2598 context.set_session_id(metadata.id);
2599 }
2600 }
2601 Ok(())
2602}
2603
2604pub(crate) async fn fork_session_storage(
2611 harness: &AgentHarness,
2612 cwd: &str,
2613) -> Result<Session, String> {
2614 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2615 use rpi_tools::FileSystem;
2616
2617 let dir = default_session_dir(Path::new(cwd));
2618 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2619 let fs: Arc<dyn FileSystem> = env.clone();
2620 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2621 fs,
2622 sessions_root: dir.to_string_lossy().into_owned(),
2623 clock: Arc::new(SystemClock),
2624 ids: Arc::new(DefaultIdGenerator::new()),
2625 });
2626 let id = harness.session().storage().metadata().id.clone();
2629 let metas = list_session_metadata(cwd)
2630 .await
2631 .map_err(|e| e.to_string())?;
2632 let Some(source) = metas.iter().find(|m| m.id == id) else {
2633 return Err(format!("current session {id} not found on disk"));
2634 };
2635 let fork_storage = repo
2636 .fork_typed(
2637 source,
2638 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2639 id: None,
2640 parent_session_id: Some(source.id.clone()),
2641 cwd: cwd.to_string(),
2642 metadata: None,
2643 },
2644 &rpi_harness::session::types::ForkOptions::default(),
2645 )
2646 .await
2647 .map_err(|e| e.to_string())?;
2648 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2649 Ok(Session::new(Arc::new(fork_storage), None))
2650}
2651
2652static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2654static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2657
2658fn claim_session_lock(session_path: &str) -> Result<(), String> {
2667 let lock_path = PathBuf::from(format!("{session_path}.lock"));
2668 if SESSION_LOCK_PATHS
2669 .get_or_init(|| Mutex::new(HashSet::new()))
2670 .lock()
2671 .unwrap()
2672 .contains(&lock_path)
2673 {
2674 return Ok(());
2675 }
2676 let file = std::fs::OpenOptions::new()
2677 .create(true)
2678 .write(true)
2679 .open(&lock_path)
2680 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2681 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2682 return Err(format!(
2683 "session {session_path} is already open in another rpi process"
2684 ));
2685 }
2686 SESSION_LOCKS
2687 .get_or_init(|| Mutex::new(Vec::new()))
2688 .lock()
2689 .unwrap()
2690 .push(file);
2691 SESSION_LOCK_PATHS
2692 .get_or_init(|| Mutex::new(HashSet::new()))
2693 .lock()
2694 .unwrap()
2695 .insert(lock_path);
2696 Ok(())
2697}
2698
2699async fn open_session(
2702 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2703 cwd: &str,
2704) -> Result<Session, BuildError> {
2705 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2706 use rpi_harness::session::types::SessionStorage;
2707 use rpi_tools::FileSystem;
2708
2709 let dir = default_session_dir(Path::new(cwd));
2710 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2711 let fs: Arc<dyn FileSystem> = env.clone();
2712 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2713 fs: fs.clone(),
2714 sessions_root: dir.to_string_lossy().into_owned(),
2715 clock: Arc::new(SystemClock),
2716 ids: Arc::new(DefaultIdGenerator::new()),
2717 });
2718 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2719 let storage = repo
2720 .open_by_jsonl_metadata(meta)
2721 .await
2722 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2723 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2727 Some(Path::new(&meta.path)),
2728 Path::new(&meta.cwd),
2729 Path::new(cwd),
2730 ) {
2731 eprintln!(
2732 "warning: {}",
2733 crate::session_cwd::format_missing_session_cwd_error(&issue)
2734 );
2735 }
2736 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2737 Ok(Session::new(storage_arc, None))
2738}
2739
2740fn ephemeral_session() -> Session {
2742 let storage = Arc::new(InMemorySessionStorage::new(
2743 SessionMetadata {
2744 id: "ephemeral".into(),
2745 created_at: 0,
2746 parent_session_id: None,
2747 },
2748 Arc::new(SystemClock),
2749 Arc::new(DefaultIdGenerator::new()),
2750 ));
2751 Session::new(storage, None)
2752}
2753
2754pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2765 create_jsonl_session_with_id(dir, cwd, None).await
2766}
2767
2768pub(crate) async fn create_jsonl_session_with_id(
2772 dir: &Path,
2773 cwd: &str,
2774 id: Option<String>,
2775) -> Result<Session, String> {
2776 use rpi_harness::session::jsonl::{
2777 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2778 };
2779 use rpi_tools::FileSystem;
2780
2781 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2784 let fs: Arc<dyn FileSystem> = env.clone();
2785
2786 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2787 fs: fs.clone(),
2788 sessions_root: dir.to_string_lossy().into_owned(),
2789 clock: Arc::new(SystemClock),
2790 ids: Arc::new(DefaultIdGenerator::new()),
2791 });
2792
2793 let opts = JsonlSessionCreateOptions {
2794 id, parent_session_id: None,
2796 cwd: cwd.to_string(),
2797 metadata: None,
2798 };
2799 let storage = repo
2800 .create_typed(&opts)
2801 .await
2802 .map_err(|e| format!("create session: {e}"))?;
2803 claim_session_lock(&storage.jsonl_metadata().path)
2804 .map_err(|e| format!("create session: {e}"))?;
2805 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2807 Ok(Session::new(storage_arc, None))
2808}
2809
2810fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
2813 let path = Path::new(target);
2814 if path.is_file() {
2815 read_prompt_source(path, sources)
2816 } else {
2817 None
2818 }
2819}
2820
2821fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
2824 let text = std::fs::read_to_string(path).ok()?;
2825 let absolute = if path.is_absolute() {
2826 path.to_path_buf()
2827 } else {
2828 std::env::current_dir()
2829 .map(|cwd| cwd.join(path))
2830 .unwrap_or_else(|_| path.to_path_buf())
2831 };
2832 let source = absolute.to_string_lossy().into_owned();
2833 if !sources.contains(&source) {
2834 sources.push(source);
2835 }
2836 Some(text)
2837}
2838
2839#[cfg(test)]
2840mod tests {
2841 use super::*;
2842
2843 #[tokio::test]
2844 async fn unauthenticated_packages_are_not_reintroduced_as_legacy_adapters() {
2845 extern "C" fn request(
2846 _: rpi_plugin_sdk::StbStringRef,
2847 _: *mut rpi_plugin_sdk::StbString,
2848 _: *mut std::ffi::c_void,
2849 ) -> i32 {
2850 1
2851 }
2852 let mut registry = rpi_extensions::ExtensionRegistry::new();
2853 for id in ["package", "legacy"] {
2854 registry.register_provider(rpi_extensions::RegisteredProvider {
2855 provider_id: id.into(),
2856 base_url: "http://localhost".into(),
2857 api_style: "openai-completions".into(),
2858 request_fn: request,
2859 stream_fn: None,
2860 plugin_free_string: rpi_plugin_sdk::callbacks::free_string,
2861 user_data: std::ptr::null_mut(),
2862 });
2863 }
2864 assert!(registry.apply_declaration_json(&serde_json::json!({"providers":[{
2865 "id":"package", "requiresAuth":true,
2866 "models":[rpi_ai::Model::new("model", "Model", rpi_ai::Api::OpenaiCompletions, "package", "http://localhost")]
2867 }]}).to_string()));
2868 let session = ExtensionSession::from_parts(
2869 Arc::new(registry.snapshot()),
2870 rpi_extensions::PluginKeepalive::empty(),
2871 vec![],
2872 None,
2873 );
2874 let providers = build_models_with_extensions_for_reload(
2875 &[],
2876 &session,
2877 tokio::runtime::Handle::current(),
2878 );
2879 assert_eq!(
2880 providers
2881 .iter()
2882 .map(|provider| provider.id())
2883 .collect::<Vec<_>>(),
2884 ["legacy"]
2885 );
2886 }
2887 use crate::args::Args;
2888
2889 #[test]
2890 fn loaded_context_paths_track_precedence_and_reload() {
2891 const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
2892 if std::env::var_os(CHILD).is_none() {
2893 let dir = tempfile::tempdir().unwrap();
2894 let output = std::process::Command::new(std::env::current_exe().unwrap())
2895 .args([
2896 "--exact",
2897 "session::tests::loaded_context_paths_track_precedence_and_reload",
2898 "--nocapture",
2899 ])
2900 .env(CHILD, "1")
2901 .env(crate::config::CONFIG_DIR_ENV, dir.path())
2902 .output()
2903 .unwrap();
2904 assert!(
2905 output.status.success(),
2906 "{}\n{}",
2907 String::from_utf8_lossy(&output.stdout),
2908 String::from_utf8_lossy(&output.stderr)
2909 );
2910 return;
2911 }
2912 tokio::runtime::Builder::new_current_thread()
2913 .enable_all()
2914 .build()
2915 .unwrap()
2916 .block_on(async {
2917 let agent = crate::config::agent_dir().unwrap();
2918 let cwd = agent.join("project");
2919 let project_config = cwd.join(".rpi");
2920 std::fs::create_dir_all(&project_config).unwrap();
2921 for (path, content) in [
2922 (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
2923 (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
2924 (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
2925 (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
2926 (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
2927 (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
2928 (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
2929 ] {
2930 std::fs::write(path, content).unwrap();
2931 }
2932 let resolved = crate::provider::resolve(
2933 Some("anthropic"),
2934 Some(crate::provider::DEFAULT_MODEL_ID),
2935 None,
2936 Some("test-key"),
2937 None,
2938 )
2939 .unwrap();
2940 let args = Args {
2941 no_session: true,
2942 no_extensions: true,
2943 no_skills: true,
2944 no_prompt_templates: true,
2945 trust_override: Some(true),
2946 ..Default::default()
2947 };
2948 let (harness, _events, mut context) =
2949 build(&resolved, &args, &cwd, true).await.unwrap();
2950 let resources = harness.get_resources().await.unwrap();
2951 let path = |p: PathBuf| p.to_string_lossy().into_owned();
2952 assert_eq!(
2953 &resources.context_files[..2],
2954 &[
2955 path(project_config.join("SYSTEM.md")),
2956 path(project_config.join("APPEND_SYSTEM.md")),
2957 ]
2958 );
2959 assert!(resources
2960 .context_files
2961 .contains(&path(agent.join("AGENTS.md"))));
2962 assert!(resources
2963 .context_files
2964 .contains(&path(cwd.join("AGENTS.md"))));
2965 assert!(!resources
2966 .context_files
2967 .contains(&path(cwd.join("CLAUDE.md"))));
2968 assert!(!resources
2969 .context_files
2970 .contains(&path(agent.join("SYSTEM.md"))));
2971 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
2972 assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
2973 assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
2974
2975 std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
2978 std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
2979 std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
2980 assert!(
2981 !reload_extension_resources(&harness, &context)
2982 .await
2983 .had_errors
2984 );
2985 let resources = harness.get_resources().await.unwrap();
2986 assert_eq!(
2987 &resources.context_files[..2],
2988 &[
2989 path(agent.join("SYSTEM.md")),
2990 path(agent.join("APPEND_SYSTEM.md")),
2991 ]
2992 );
2993 assert!(resources
2994 .context_files
2995 .contains(&path(cwd.join("CLAUDE.md"))));
2996 assert!(!resources
2997 .context_files
2998 .contains(&path(project_config.join("SYSTEM.md"))));
2999
3000 let explicit = agent.join("custom-append.md");
3003 std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
3004 context.args.system_prompt = Some("EXPLICIT_BASE".into());
3005 context.args.append_system_prompt =
3006 vec![path(explicit.clone()), "literal append".into()];
3007 context.args.no_context_files = true;
3008 assert!(
3009 !reload_extension_resources(&harness, &context)
3010 .await
3011 .had_errors
3012 );
3013 assert_eq!(
3014 harness.get_resources().await.unwrap().context_files,
3015 vec![path(explicit)]
3016 );
3017
3018 context.args.system_prompt = None;
3020 context.args.append_system_prompt.clear();
3021 context.args.no_context_files = false;
3022 context.args.trust_override = Some(false);
3023 assert!(
3024 !reload_extension_resources(&harness, &context)
3025 .await
3026 .had_errors
3027 );
3028 assert!(harness
3029 .get_resources()
3030 .await
3031 .unwrap()
3032 .context_files
3033 .is_empty());
3034 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3035 assert!(
3036 !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
3037 );
3038 });
3039 }
3040
3041 #[test]
3042 fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
3043 let dir = tempfile::tempdir().unwrap();
3044 let file = dir.path().join("SYSTEM.md");
3045 let mut sources = Vec::new();
3046 assert!(read_prompt_source(&file, &mut sources).is_none());
3047 std::fs::write(&file, [0xff, 0xfe]).unwrap();
3048 assert!(read_prompt_source(&file, &mut sources).is_none());
3049 assert!(read_append_target("literal instructions", &mut sources).is_none());
3050 assert!(sources.is_empty());
3051 std::fs::write(&file, "loaded instructions").unwrap();
3052 assert_eq!(
3053 read_prompt_source(&file, &mut sources).as_deref(),
3054 Some("loaded instructions")
3055 );
3056 assert!(read_prompt_source(&file, &mut sources).is_some());
3057 assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
3058 }
3059
3060 extern "C" fn deny_test_tool_call(
3061 _event: rpi_plugin_sdk::StablePluginEvent,
3062 _data: *mut std::ffi::c_void,
3063 ) -> i32 {
3064 rpi_plugin_sdk::EVENT_HANDLER_ABORT
3065 }
3066
3067 async fn invoke_test_tool_hook(
3068 hook: &rpi_agent::BeforeToolCall,
3069 ) -> Option<rpi_agent::BeforeToolCallResult> {
3070 let message = rpi_ai::providers::faux::faux_assistant_message(
3071 "approval",
3072 rpi_ai::types::StopReason::ToolUse,
3073 );
3074 let rpi_ai::types::Content::ToolCall(call) =
3075 rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
3076 else {
3077 unreachable!()
3078 };
3079 hook(
3080 rpi_agent::BeforeToolCallContext {
3081 assistant_message: &message,
3082 tool_call: &call,
3083 args: &call.arguments,
3084 context: &rpi_agent::AgentContext::default(),
3085 },
3086 tokio_util::sync::CancellationToken::new(),
3087 )
3088 .await
3089 }
3090
3091 #[tokio::test]
3092 async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
3093 let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
3094 let hook =
3095 extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
3096 assert!(invoke_test_tool_hook(&hook).await.is_none());
3097 for name in ["first-gate", "reloaded-gate"] {
3098 let mut registry = rpi_extensions::ExtensionRegistry::new();
3099 registry.register_event_handler(
3100 name.into(),
3101 rpi_plugin_sdk::EventTag::ToolCall,
3102 deny_test_tool_call,
3103 std::ptr::null_mut(),
3104 );
3105 let fresh = ExtensionSession::from_parts(
3106 Arc::new(registry.snapshot()),
3107 rpi_extensions::PluginKeepalive::empty(),
3108 Vec::new(),
3109 None,
3110 );
3111 {
3112 let mut current = sessions.lock().unwrap();
3113 if let Some(snapshot) = current.snapshot() {
3114 snapshot.active_flag().store(false, Ordering::SeqCst);
3115 }
3116 *current = fresh;
3117 }
3118 let result = invoke_test_tool_hook(&hook).await.unwrap();
3119 assert!(result.block);
3120 assert!(result.reason.unwrap().contains(name));
3121 }
3122 *sessions.lock().unwrap() = ExtensionSession::none();
3123 assert!(invoke_test_tool_hook(&hook).await.is_none());
3124 }
3125
3126 fn scratch(name: &str) -> PathBuf {
3128 let nanos = std::time::SystemTime::now()
3129 .duration_since(std::time::UNIX_EPOCH)
3130 .unwrap()
3131 .as_nanos();
3132 let path = std::env::temp_dir().join(format!(
3133 "rpi-summary-{}-{nanos}-{name}.jsonl",
3134 std::process::id()
3135 ));
3136 path
3137 }
3138
3139 #[test]
3140 fn summary_marks_a_header_only_session_empty() {
3141 let path = scratch("empty");
3142 std::fs::write(
3143 &path,
3144 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3145 )
3146 .unwrap();
3147 let summary = summarize_session_file(&path);
3148 assert!(summary.empty, "a header-only file has nothing to resume");
3149 assert!(summary.preview.is_none());
3150 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
3151 std::fs::remove_file(&path).ok();
3152 }
3153
3154 #[test]
3155 fn summary_reads_the_first_user_prompt_and_name() {
3156 let path = scratch("content");
3157 let body = concat!(
3158 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3159 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
3160 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
3161 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
3162 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
3163 );
3164 std::fs::write(&path, body).unwrap();
3165 let summary = summarize_session_file(&path);
3166 assert!(!summary.empty);
3167 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
3168 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
3169 std::fs::remove_file(&path).ok();
3170 }
3171
3172 #[test]
3173 fn summary_extracts_a_block_style_user_prompt() {
3174 let path = scratch("blocks");
3175 let body = concat!(
3176 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3177 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
3178 );
3179 std::fs::write(&path, body).unwrap();
3180 let summary = summarize_session_file(&path);
3181 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
3182 assert_eq!(session_display_label(&summary), "line one line two");
3183 std::fs::remove_file(&path).ok();
3184 }
3185
3186 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
3187 rpi_harness::session::jsonl::JsonlSessionMetadata {
3188 id: id.to_string(),
3189 created_at: 0,
3190 cwd: ".".to_string(),
3191 path: path.to_string_lossy().into_owned(),
3192 modified_at: 0,
3193 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
3194 parent_session_id: None,
3195 legacy_parent_session_path: None,
3196 metadata: None,
3197 }
3198 }
3199
3200 #[test]
3201 fn continue_skips_newer_empty_sessions() {
3202 let empty = scratch("newest-empty");
3203 std::fs::write(
3204 &empty,
3205 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3206 )
3207 .unwrap();
3208 let content = scratch("older-content");
3209 std::fs::write(
3210 &content,
3211 concat!(
3212 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3213 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
3214 ),
3215 )
3216 .unwrap();
3217
3218 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
3219 let picked = first_resumable_session(&metas).expect("a resumable session");
3220 assert_eq!(picked.id, "old");
3221
3222 let all_empty = vec![meta_for(&empty, "empty")];
3225 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
3226
3227 std::fs::remove_file(&empty).ok();
3228 std::fs::remove_file(&content).ok();
3229 }
3230
3231 #[test]
3232 fn list_session_files_sync_walks_the_cwd_subdirectory() {
3233 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
3234 let nested = root.join("--proj--");
3235 std::fs::create_dir_all(&nested).unwrap();
3236 let file = nested.join("2026-01-01_a.jsonl");
3237 std::fs::write(&file, "{}\n").unwrap();
3238 let found = list_session_files_sync(&root);
3239 assert!(found.iter().any(|p| p == &file));
3240 std::fs::remove_dir_all(&root).ok();
3241 }
3242
3243 #[test]
3244 fn default_prompt_mentions_cwd_and_tools() {
3245 let p = default_system_prompt("/tmp/proj", false);
3246 assert!(p.contains("/tmp/proj"));
3247 assert!(p.contains("read"));
3248 assert!(p.contains("bash"));
3249 assert!(p.contains("edit"));
3250 assert!(p.contains("write"));
3251 assert!(p.contains("docs"));
3252 assert!(!p.contains("- grep"));
3253 assert!(!p.contains("- find"));
3254 assert!(!p.contains("- ls"));
3255 assert!(!p.contains("- powershell"));
3256 assert!(p.contains("Bash/POSIX syntax, including on Windows"));
3257 assert!(p.contains("do not pass PowerShell cmdlets"));
3258 }
3259
3260 #[test]
3261 fn default_prompt_instructs_language_matching() {
3262 let p = default_system_prompt("/tmp/proj", false);
3266 let lower = p.to_lowercase();
3267 assert!(
3268 lower.contains("same language") && lower.contains("detect"),
3269 "the prompt must tell the model to detect and mirror the user's language: {p}"
3270 );
3271 }
3272
3273 #[test]
3274 fn default_prompt_keeps_working_state_in_the_visible_channel() {
3275 let p = default_system_prompt("/tmp/proj", false);
3283 let lower = p.to_lowercase();
3284 assert!(
3286 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
3287 "the prompt must explain that reasoning does not survive the turn: {p}"
3288 );
3289 assert!(
3291 lower.contains("visible") && lower.contains("todo tool"),
3292 "the prompt must point at visible text and the todo tool: {p}"
3293 );
3294 assert!(
3296 lower.contains("re-stating the same plan"),
3297 "the prompt must call out plan re-statement as a bug: {p}"
3298 );
3299 assert!(
3301 lower.contains("already shown earlier"),
3302 "the prompt must discourage re-fetching known content: {p}"
3303 );
3304 }
3305
3306 #[test]
3307 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
3308 let p = default_system_prompt("/tmp/proj", true);
3315 let lower = p.to_lowercase();
3316 assert!(
3317 lower.contains("carried back"),
3318 "a replaying model must be told its reasoning survives: {p}"
3319 );
3320 assert!(
3321 !lower.contains("reasoning is not"),
3322 "the replayed variant must not claim reasoning is dropped: {p}"
3323 );
3324 assert!(
3325 lower.contains("do not re-derive"),
3326 "the replayed variant must forbid re-deriving the plan: {p}"
3327 );
3328 assert!(
3329 lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
3330 "the anti-repetition rule must survive the flip: {p}"
3331 );
3332 assert!(
3333 lower.contains("already shown earlier"),
3334 "the re-read rule must survive the flip: {p}"
3335 );
3336 }
3337
3338 #[test]
3346 fn only_read_only_tools_are_replayable() {
3347 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3348 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3349 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3350 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3351 let tools = build_tools(&context, &Args::default());
3352
3353 let replay_of = |name: &str| {
3354 tools
3355 .iter()
3356 .find(|tool| tool.tool.schema().name == name)
3357 .map(|tool| tool.replay)
3358 };
3359
3360 for read_only in ["read", "docs"] {
3361 assert_eq!(
3362 replay_of(read_only),
3363 Some(ToolReplay::Safe),
3364 "{read_only} is side-effect free, so recovery may re-run it"
3365 );
3366 }
3367 for mutating in ["bash", "edit", "write", "powershell"] {
3368 if let Some(replay) = replay_of(mutating) {
3369 assert_eq!(
3370 replay,
3371 ToolReplay::Never,
3372 "{mutating} must never be replayed: re-running it would duplicate its effect"
3373 );
3374 }
3375 }
3376 }
3377
3378 #[test]
3379 fn default_tools_include_docs_lookup() {
3380 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3381 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3382 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3383 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3384 let names: Vec<String> = build_tools(&context, &Args::default())
3385 .iter()
3386 .map(|tool| tool.tool.schema().name.clone())
3387 .collect();
3388
3389 assert!(names.contains(&"docs".to_string()));
3394 for legacy in ["grep", "find", "ls"] {
3395 assert!(
3396 !names.contains(&legacy.to_string()),
3397 "legacy tool {legacy} should not be registered by default"
3398 );
3399 }
3400 }
3401
3402 #[test]
3403 fn reload_settings_preflight_respects_trust() {
3404 struct RestoreConfigDir(Option<std::ffi::OsString>);
3405
3406 impl Drop for RestoreConfigDir {
3407 fn drop(&mut self) {
3408 match self.0.take() {
3409 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3410 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3411 }
3412 }
3413 }
3414
3415 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3416 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3417 let tmp = tempfile::tempdir().unwrap();
3418 let agent = tmp.path().join("agent");
3419 let cwd = tmp.path().join("project");
3420 std::fs::create_dir_all(&agent).unwrap();
3421 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3422 std::fs::write(agent.join("settings.json"), "{}").unwrap();
3423 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
3424 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3425 let args = Args::default();
3426
3427 let trusted = validate_settings_for_reload(&args, &cwd, true);
3428 let untrusted = validate_settings_for_reload(&args, &cwd, false);
3429 assert!(trusted
3430 .unwrap_err()
3431 .contains("could not load project settings"));
3432 assert!(untrusted.is_ok());
3433
3434 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3435 assert!(validate_settings_for_reload(&args, &cwd, false)
3436 .unwrap_err()
3437 .contains("could not load global settings"));
3438
3439 let local_only = Args {
3440 dev_local_only: true,
3441 ..Args::default()
3442 };
3443 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
3444 }
3445
3446 #[tokio::test(flavor = "current_thread")]
3447 async fn reload_preserves_live_resources_when_settings_break() {
3448 struct RestoreConfigDir(Option<std::ffi::OsString>);
3449
3450 impl Drop for RestoreConfigDir {
3451 fn drop(&mut self) {
3452 match self.0.take() {
3453 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3454 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3455 }
3456 }
3457 }
3458
3459 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3460 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3461 let tmp = tempfile::tempdir().unwrap();
3462 let agent = tmp.path().join("agent");
3463 let cwd = tmp.path().join("project");
3464 let skill_dir = tmp.path().join("configured-skills");
3465 std::fs::create_dir_all(&agent).unwrap();
3466 std::fs::create_dir_all(&cwd).unwrap();
3467 std::fs::create_dir_all(&skill_dir).unwrap();
3468 std::fs::write(
3469 skill_dir.join("SKILL.md"),
3470 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
3471 )
3472 .unwrap();
3473 std::fs::write(
3474 agent.join("settings.json"),
3475 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3476 )
3477 .unwrap();
3478 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3479
3480 let resolved = crate::provider::resolve(
3481 Some("anthropic"),
3482 Some(crate::provider::DEFAULT_MODEL_ID),
3483 None,
3484 Some("test-key"),
3485 None,
3486 )
3487 .unwrap();
3488 let args = Args {
3489 trust_override: Some(false),
3490 no_session: true,
3491 no_extensions: true,
3492 no_prompt_templates: true,
3493 no_context_files: true,
3494 system_prompt: Some("stable system prompt".into()),
3495 ..Args::default()
3496 };
3497 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3498 let before_resources = harness.get_resources().await.unwrap();
3499 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
3500 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
3501 let before_prompt = harness.get_system_prompt().await.unwrap();
3502 let before_tools: Vec<String> = harness
3503 .get_tools()
3504 .await
3505 .unwrap()
3506 .iter()
3507 .map(|tool| tool.tool.schema().name.clone())
3508 .collect();
3509 let before_bridge = context
3510 .action_bridge
3511 .lock()
3512 .unwrap()
3513 .as_ref()
3514 .unwrap()
3515 .clone();
3516
3517 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3518 let outcome = reload_extension_resources(&harness, &context).await;
3519
3520 assert!(outcome.had_warnings);
3521 assert!(outcome.summary.contains("Settings reload failed"));
3522 assert!(outcome.summary.contains("could not load global settings"));
3523 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
3524 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
3525 let after_tools: Vec<String> = harness
3526 .get_tools()
3527 .await
3528 .unwrap()
3529 .iter()
3530 .map(|tool| tool.tool.schema().name.clone())
3531 .collect();
3532 assert_eq!(after_tools, before_tools);
3533 let after_bridge = context
3534 .action_bridge
3535 .lock()
3536 .unwrap()
3537 .as_ref()
3538 .unwrap()
3539 .clone();
3540 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
3541 }
3542
3543 #[test]
3544 fn reload_preparation_rejects_settings_changed_during_derivation() {
3545 struct RestoreConfigDir(Option<std::ffi::OsString>);
3546
3547 impl Drop for RestoreConfigDir {
3548 fn drop(&mut self) {
3549 match self.0.take() {
3550 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3551 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3552 }
3553 }
3554 }
3555
3556 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3557 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3558 let tmp = tempfile::tempdir().unwrap();
3559 let agent = tmp.path().join("agent");
3560 let cwd = tmp.path().join("project");
3561 let first_skill_dir = tmp.path().join("first-skills");
3562 let second_skill_dir = tmp.path().join("second-skills");
3563 std::fs::create_dir_all(&agent).unwrap();
3564 std::fs::create_dir_all(&cwd).unwrap();
3565 std::fs::write(
3566 agent.join("settings.json"),
3567 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3568 )
3569 .unwrap();
3570 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3571 let args = Args {
3572 trust_override: Some(false),
3573 no_extensions: true,
3574 no_prompt_templates: true,
3575 ..Args::default()
3576 };
3577
3578 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3579 std::fs::write(
3580 agent.join("settings.json"),
3581 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3582 )
3583 .unwrap();
3584 });
3585
3586 let error = result
3587 .err()
3588 .expect("settings mutation must fail preparation");
3589 assert!(error.contains("settings changed while reload inputs were being prepared"));
3590 }
3591
3592 #[tokio::test(flavor = "current_thread")]
3593 async fn reload_uses_frozen_settings_inputs_after_preparation() {
3594 struct RestoreConfigDir(Option<std::ffi::OsString>);
3595
3596 impl Drop for RestoreConfigDir {
3597 fn drop(&mut self) {
3598 match self.0.take() {
3599 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3600 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3601 }
3602 }
3603 }
3604
3605 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3606 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3607 let tmp = tempfile::tempdir().unwrap();
3608 let agent = tmp.path().join("agent");
3609 let cwd = tmp.path().join("project");
3610 let skill_dir = tmp.path().join("configured-skills");
3611 std::fs::create_dir_all(&agent).unwrap();
3612 std::fs::create_dir_all(&cwd).unwrap();
3613 std::fs::create_dir_all(&skill_dir).unwrap();
3614 std::fs::write(
3615 skill_dir.join("SKILL.md"),
3616 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3617 )
3618 .unwrap();
3619 std::fs::write(
3620 agent.join("settings.json"),
3621 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3622 )
3623 .unwrap();
3624 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3625
3626 let resolved = crate::provider::resolve(
3627 Some("anthropic"),
3628 Some(crate::provider::DEFAULT_MODEL_ID),
3629 None,
3630 Some("test-key"),
3631 None,
3632 )
3633 .unwrap();
3634 let args = Args {
3635 trust_override: Some(false),
3636 no_session: true,
3637 no_extensions: true,
3638 no_prompt_templates: true,
3639 no_context_files: true,
3640 system_prompt: Some("stable system prompt".into()),
3641 ..Args::default()
3642 };
3643 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3644
3645 let outcome = reload_extension_resources_inner(&harness, &context, || {
3646 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3647 })
3648 .await;
3649
3650 assert!(!outcome.summary.contains("Settings reload failed"));
3651 assert_eq!(
3652 outcome.summary,
3653 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3654 );
3655 let resources = harness.get_resources().await.unwrap();
3656 let skills = resources.skills.as_ref().unwrap();
3657 assert_eq!(skills.len(), 1);
3658 assert_eq!(skills[0].name, "frozen-skill");
3659 }
3660
3661 #[tokio::test]
3662 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3663 let tmp = tempfile::tempdir().unwrap();
3664 let skill_path = tmp.path().join("explicit-skill.md");
3665 std::fs::write(
3666 &skill_path,
3667 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3668 )
3669 .unwrap();
3670 let prompt_path = tmp.path().join("explicit-prompt.md");
3671 std::fs::write(
3672 &prompt_path,
3673 "---\ndescription: Explicit prompt\n---\nPrompt body",
3674 )
3675 .unwrap();
3676
3677 let args = Args {
3678 skill: vec![skill_path.clone()],
3679 prompt_template: vec![prompt_path.clone()],
3680 ..Args::default()
3681 };
3682 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3683 let prompt_paths =
3684 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3685 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3686 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3687
3688 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3689 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3690 assert_eq!(skills.skills[0].name, "explicit-skill");
3691
3692 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3693 assert_eq!(
3694 prompts.prompt_templates.len(),
3695 1,
3696 "{:?}",
3697 prompts.diagnostics
3698 );
3699 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3700 }
3701
3702 #[test]
3703 fn tool_policy_applies_to_rust_and_js_active_names() {
3704 let names = vec![
3705 "read".to_string(),
3706 "ask_user_question".to_string(),
3707 "write".to_string(),
3708 ];
3709
3710 let args = Args {
3711 tools: Some(vec!["read".into(), "ask_user_question".into()]),
3712 ..Args::default()
3713 };
3714 assert_eq!(
3715 filter_active_tool_names(names.clone(), &args),
3716 vec!["read", "ask_user_question"]
3717 );
3718
3719 let args = Args {
3720 exclude_tools: Some(vec!["ask_user_question".into()]),
3721 ..Args::default()
3722 };
3723 assert_eq!(
3724 filter_active_tool_names(names.clone(), &args),
3725 vec!["read", "write"]
3726 );
3727
3728 let args = Args {
3729 no_tools: true,
3730 ..Args::default()
3731 };
3732 assert!(filter_active_tool_names(names, &args).is_empty());
3733 }
3734
3735 #[test]
3736 fn select_ephemeral_when_no_session() {
3737 let args = Args {
3738 no_session: true,
3739 ..Args::default()
3740 };
3741 let cwd = Path::new("/tmp");
3742 assert!(matches!(
3743 select_session(&args, cwd),
3744 SessionSelection::Ephemeral
3745 ));
3746 }
3747
3748 #[test]
3749 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3750 let defaults = Args::default();
3751 assert!(resolve_project_trust(
3752 &defaults,
3753 Path::new("C:/definitely-not-a-project")
3754 ));
3755
3756 let denied = Args {
3757 trust_override: Some(false),
3758 ..Args::default()
3759 };
3760 assert!(!resolve_project_trust(
3761 &denied,
3762 Path::new("C:/definitely-not-a-project")
3763 ));
3764 }
3765
3766 #[tokio::test(flavor = "current_thread")]
3767 async fn build_preserves_the_supplied_startup_project_snapshot() {
3768 struct RestoreConfigDir(Option<std::ffi::OsString>);
3769
3770 impl Drop for RestoreConfigDir {
3771 fn drop(&mut self) {
3772 match self.0.take() {
3773 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3774 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3775 }
3776 }
3777 }
3778
3779 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3780 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3781 let tmp = tempfile::tempdir().unwrap();
3782 let agent = tmp.path().join("agent");
3783 let cwd = tmp.path().join("project");
3784 std::fs::create_dir_all(&agent).unwrap();
3785 std::fs::create_dir_all(&cwd).unwrap();
3786 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3787
3788 let resolved = crate::provider::resolve(
3789 Some("anthropic"),
3790 Some(crate::provider::DEFAULT_MODEL_ID),
3791 None,
3792 Some("test-key"),
3793 None,
3794 )
3795 .unwrap();
3796 let args = Args {
3797 trust_override: Some(false),
3798 no_session: true,
3799 no_tools: true,
3800 no_extensions: true,
3801 no_skills: true,
3802 no_prompt_templates: true,
3803 no_context_files: true,
3804 system_prompt: Some("test prompt".into()),
3805 ..Args::default()
3806 };
3807
3808 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3809
3810 assert_eq!(context.cwd, cwd);
3811 assert!(context.project_trusted);
3812 }
3813
3814 #[tokio::test(flavor = "current_thread")]
3815 async fn build_applies_the_name_flag_to_the_session() {
3816 struct RestoreConfigDir(Option<std::ffi::OsString>);
3817
3818 impl Drop for RestoreConfigDir {
3819 fn drop(&mut self) {
3820 match self.0.take() {
3821 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3822 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3823 }
3824 }
3825 }
3826
3827 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3828 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3829 let tmp = tempfile::tempdir().unwrap();
3830 let agent = tmp.path().join("agent");
3831 let cwd = tmp.path().join("project");
3832 std::fs::create_dir_all(&agent).unwrap();
3833 std::fs::create_dir_all(&cwd).unwrap();
3834 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3835
3836 let resolved = crate::provider::resolve(
3837 Some("anthropic"),
3838 Some(crate::provider::DEFAULT_MODEL_ID),
3839 None,
3840 Some("test-key"),
3841 None,
3842 )
3843 .unwrap();
3844 let args = Args {
3845 trust_override: Some(false),
3846 no_tools: true,
3847 no_extensions: true,
3848 no_skills: true,
3849 no_prompt_templates: true,
3850 no_context_files: true,
3851 system_prompt: Some("test prompt".into()),
3852 name: Some("smoke name".into()),
3853 session_dir: Some(cwd.join("sessions")),
3854 ..Args::default()
3855 };
3856
3857 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3858 assert_eq!(
3859 harness.get_name().await.unwrap().as_deref(),
3860 Some("smoke name")
3861 );
3862 }
3863
3864 #[test]
3865 fn select_latest_for_continue_and_resume() {
3866 let args = Args {
3867 continue_session: true,
3868 ..Args::default()
3869 };
3870 let cwd = Path::new("/tmp");
3871 assert!(matches!(
3872 select_session(&args, cwd),
3873 SessionSelection::Latest { .. }
3874 ));
3875
3876 let args = Args {
3877 resume: true,
3878 ..Args::default()
3879 };
3880 assert!(matches!(
3881 select_session(&args, cwd),
3882 SessionSelection::Latest { .. }
3883 ));
3884 }
3885
3886 #[test]
3887 fn select_by_id_for_session_flag() {
3888 let args = Args {
3889 session: Some("01a02ece".into()),
3890 ..Args::default()
3891 };
3892 let cwd = Path::new("/tmp");
3893 assert!(matches!(
3894 select_session(&args, cwd),
3895 SessionSelection::ById { id, .. } if id == "01a02ece"
3896 ));
3897 }
3898
3899 #[test]
3900 fn select_new_with_custom_dir() {
3901 let args = Args {
3902 session_dir: Some(PathBuf::from("/tmp/sess")),
3903 ..Args::default()
3904 };
3905 let cwd = Path::new("/tmp");
3906 match select_session(&args, cwd) {
3907 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
3908 other => panic!("expected New, got {other:?}"),
3909 }
3910 }
3911
3912 #[test]
3913 fn select_new_default_dir() {
3914 let args = Args::default();
3915 let cwd = Path::new("/proj");
3916 match select_session(&args, cwd) {
3917 SessionSelection::New { dir, .. } => {
3918 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
3919 }
3920 other => panic!("expected New, got {other:?}"),
3921 }
3922 }
3923
3924 #[test]
3925 fn default_session_dir_only_uses_rpi() {
3926 let tmp = tempfile::tempdir().unwrap();
3927 let cwd = tmp.path();
3928 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3929 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3930 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3931 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3932 }
3933
3934 #[test]
3935 fn default_session_dir_uses_env_var() {
3936 let tmp = tempfile::tempdir().unwrap();
3937 let cwd = tmp.path();
3938 let custom = tmp.path().join("custom_sessions");
3939 std::env::set_var("RPI_CODING_AGENT_SESSION_DIR", custom.to_str().unwrap());
3940 assert_eq!(default_session_dir(cwd), custom);
3941 std::env::remove_var("RPI_CODING_AGENT_SESSION_DIR");
3942 }
3943
3944 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3945 async fn ephemeral_session_builds_roundtrips() {
3946 let s = ephemeral_session();
3950 let leaf = s.get_leaf_id().await;
3951 assert!(leaf.is_ok());
3952 }
3953
3954 #[test]
3972 fn host_private_env_matches_the_declared_contract() {
3973 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
3975 assert_eq!(
3976 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
3977 [
3978 "LANGFUSE_PI_PARENT_TRACE_ID",
3979 "LANGFUSE_PI_PARENT_SPAN_ID",
3980 "LANGFUSE_PI_PARENT_SESSION_ID",
3981 "LANGFUSE_PI_PARENT_DEPTH",
3982 ],
3983 "these strings are what rpi-langfuse (a different repo) matches; a \
3984 change here is a breaking cross-repo change, not a refactor"
3985 );
3986
3987 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
3990 .iter()
3991 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
3992 {
3993 assert!(
3994 rpi_tools::is_host_private_env(name),
3995 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
3996 rpi_tools::HOST_PRIVATE_ENV
3997 );
3998 }
3999
4000 for visible in [
4003 crate::args::RPI_OFFLINE_ENV,
4004 crate::config::CONFIG_DIR_ENV,
4005 "PATH",
4006 ] {
4007 assert!(
4008 !rpi_tools::is_host_private_env(visible),
4009 "{visible} describes the user's configuration and must still reach the shell"
4010 );
4011 }
4012 }
4013 #[tokio::test]
4014 async fn reload_shutdown_joins_all_handlers_even_after_veto() {
4015 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
4016 use std::sync::atomic::AtomicUsize;
4017 static CONTEXTS: rpi_plugin_sdk::handles::ToolHandles<Arc<AtomicUsize>> =
4018 rpi_plugin_sdk::handles::ToolHandles::new();
4019 struct ContextOwner(usize);
4020 impl Drop for ContextOwner {
4021 fn drop(&mut self) {
4022 drop(CONTEXTS.remove(self.0 as rpi_plugin_sdk::StepHandle));
4023 }
4024 }
4025 extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
4026 let context = CONTEXTS.get(data).expect("live test context");
4027 let calls = context.lock().clone();
4028 std::thread::sleep(std::time::Duration::from_millis(20));
4029 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
4030 rpi_plugin_sdk::EVENT_HANDLER_ABORT
4031 } else {
4032 0
4033 }
4034 }
4035 let calls = Arc::new(AtomicUsize::new(0));
4036 let context = CONTEXTS.insert(calls.clone());
4037 let _owner = ContextOwner(context as usize);
4038 let mut registry = rpi_extensions::ExtensionRegistry::new();
4039 for name in ["first", "second"] {
4040 registry.register_event_handler(name.into(), EventTag::SessionShutdown, stop, context);
4041 }
4042 super::shutdown_extensions_for_reload(
4043 Some(Arc::new(registry.snapshot())),
4044 rpi_extensions::PluginKeepalive::empty(),
4045 )
4046 .await
4047 .unwrap();
4048 assert_eq!(calls.load(Ordering::SeqCst), 2);
4049 }
4050}