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 select_session_in_dir(args, configured_session_dir(args, cwd))
157}
158
159pub(crate) fn configured_session_dir(args: &Args, cwd: &Path) -> PathBuf {
161 let dir = args
162 .session_dir
163 .clone()
164 .or_else(|| {
165 crate::settings::load_settings()
166 .ok()
167 .and_then(|settings| settings.session_dir)
168 })
169 .unwrap_or_else(|| default_session_dir(cwd));
170 if dir.is_relative() && !dir.has_root() {
173 cwd.join(dir)
174 } else {
175 dir
176 }
177}
178
179fn select_session_in_dir(args: &Args, dir: PathBuf) -> SessionSelection {
180 if args.no_session {
181 return SessionSelection::Ephemeral;
182 }
183 if args.continue_session || args.resume {
187 return SessionSelection::Latest { dir };
188 }
189 if let Some(s) = &args.fork {
190 return SessionSelection::Fork {
191 source: s.clone(),
192 dir,
193 };
194 }
195 if let Some(s) = &args.session_id {
196 return SessionSelection::ByExactId { id: s.clone(), dir };
197 }
198 if let Some(s) = &args.session {
199 return SessionSelection::ById { id: s.clone(), dir };
200 }
201 SessionSelection::New {
202 dir,
203 name: args.name.clone(),
204 }
205}
206
207pub fn default_session_dir(cwd: &Path) -> PathBuf {
210 std::env::var("RPI_CODING_AGENT_SESSION_DIR")
211 .ok()
212 .map(PathBuf::from)
213 .unwrap_or_else(|| cwd.join(".rpi").join("sessions"))
214}
215
216pub struct PreparedExtensions {
219 pub extension_session: ExtensionSession,
220 session_dir: PathBuf,
221 runtime: tokio::runtime::Handle,
222 tool_context: rpi_extensions::ToolCallContext,
223 action_host: Arc<crate::extensions_actions::HarnessActionHost>,
224 harness_cell: Arc<std::sync::OnceLock<rpi_harness::agent_harness::WeakAgentHarness>>,
225 reload_mailbox: rpi_extensions::ReloadMailbox,
226 ui_dialog_mailbox: rpi_extensions::UiDialogMailbox,
227 ext_status_mailbox: rpi_extensions::ExtensionStatusMailbox,
228 editor_text_mailbox: rpi_extensions::EditorTextMailbox,
229 action_bridge: Arc<rpi_extensions::ActionBridge>,
230}
231
232pub fn prepare_extensions(
233 args: &Args,
234 cwd: &Path,
235 project_trusted: bool,
236) -> Result<PreparedExtensions, BuildError> {
237 let session_dir = configured_session_dir(args, cwd);
238 let cwd_str = cwd.to_string_lossy().to_string();
239 let tool_context = rpi_extensions::ToolCallContext::new(cwd_str.clone());
240 if !project_trusted && args.verbose {
241 eprintln!(
242 "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)"
243 );
244 }
245 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
264 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
265 })?;
266 let catalog = Vec::new();
267 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
268 catalog.clone(),
269 cwd.to_path_buf(),
270 runtime.clone(),
271 args.unknown_flags.clone(),
272 tool_context.clone(),
273 );
274 let action_host = Arc::new(action_host.with_session_dir(session_dir.clone()));
275 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = action_host.clone();
276 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
286 let ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
292 let ext_status_mailbox = rpi_extensions::ExtensionStatusMailbox::new();
296 let editor_text_mailbox = rpi_extensions::EditorTextMailbox::new();
301 let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
302 runtime.clone(),
303 host_arc,
304 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
305 ui_dialog_mailbox.clone(),
306 ext_status_mailbox.clone(),
307 editor_text_mailbox.clone(),
308 );
309
310 let extension_session = if args.no_extensions {
311 ExtensionSession::none()
312 } else {
313 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
314 };
315 if args.verbose {
316 if let Some(s) = extension_session.summary() {
317 eprintln!("extensions: {s}");
318 }
319 report_deferred_renderers(&extension_session);
320 }
321 Ok(PreparedExtensions {
322 session_dir,
323 runtime,
324 tool_context,
325 action_host,
326 harness_cell,
327 reload_mailbox,
328 ui_dialog_mailbox,
329 ext_status_mailbox,
330 editor_text_mailbox,
331 action_bridge,
332 extension_session,
333 })
334}
335
336pub async fn build(
354 resolved: &ResolvedModel,
355 args: &Args,
356 cwd: &Path,
357 project_trusted: bool,
358) -> Result<
359 (
360 AgentHarness,
361 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
362 ReloadContext,
363 ),
364 BuildError,
365> {
366 let prepared = prepare_extensions(args, cwd, project_trusted)?;
367 build_prepared(resolved, args, cwd, project_trusted, prepared).await
368}
369
370pub async fn build_prepared(
371 resolved: &ResolvedModel,
372 args: &Args,
373 cwd: &Path,
374 project_trusted: bool,
375 prepared: PreparedExtensions,
376) -> Result<
377 (
378 AgentHarness,
379 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
380 ReloadContext,
381 ),
382 BuildError,
383> {
384 let cwd_str = cwd.to_string_lossy().to_string();
385 let PreparedExtensions {
390 session_dir,
391 runtime,
392 tool_context,
393 action_host,
394 harness_cell,
395 reload_mailbox,
396 ui_dialog_mailbox,
397 ext_status_mailbox,
398 editor_text_mailbox,
399 action_bridge,
400 extension_session,
401 } = prepared;
402 let catalog = crate::provider::authenticated_catalog(resolved);
403 action_host.set_catalog(catalog.clone());
404
405 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
407 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
408 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
409 let _registry = Arc::new(MutationQueueRegistry::new());
410 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
413
414 let mut tools = build_tools(&ctx, args);
415
416 merge_extension_tools(&mut tools, &extension_session, args, &tool_context);
424 let active = filter_active_tool_names(active_tool_names(&tools, args), args);
425
426 let selection = select_session_in_dir(args, session_dir.clone());
428 let session = build_session(&selection, &cwd_str).await?;
429 if let Ok(metadata) = session.get_metadata().await {
433 publish_session_identity(&tool_context, &metadata.id);
434 }
435 if let Some(requested) = args.name.as_deref() {
441 let name = requested.trim();
442 let name = if name.is_empty() { None } else { Some(name) };
443 if let Err(error) = session.set_name(name).await {
444 if args.verbose {
445 eprintln!("warning: could not set session name: {error}");
446 }
447 }
448 }
449 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
459 let mut context_paths = Vec::new();
460 let base_prompt = match args.system_prompt.as_deref() {
461 Some(explicit) => explicit.to_string(),
462 None if project_trusted => match discover_system_prompt_file(cwd) {
463 Some(path) => read_prompt_source(&path, &mut context_paths)
464 .unwrap_or_else(|| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
465 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
466 },
467 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
468 };
469
470 let mut append_texts: Vec<String> = Vec::new();
477 for extra in &args.append_system_prompt {
478 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
479 append_texts.push(text);
480 }
481 if args.append_system_prompt.is_empty() {
482 if let Some(path) = project_trusted
483 .then(|| discover_append_system_prompt_file(cwd))
484 .flatten()
485 {
486 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
487 append_texts.push(text);
488 }
489 }
490 }
491 let append_join = if append_texts.is_empty() {
492 None
493 } else {
494 Some(append_texts.join("\n\n"))
495 };
496
497 let agent_dir = crate::config::agent_dir().ok();
509
510 let discovered = extension_session
526 .snapshot_arc()
527 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
528 .unwrap_or_default();
529
530 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
531 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
532 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
533 if !args.no_skills {
534 let mut dirs = if args.dev_local_only || args.project_only {
535 project_skill_dirs(cwd)
536 } else if project_trusted {
537 skill_dirs(cwd)
538 } else {
539 global_skill_dirs()
540 };
541 dirs.extend(args.skill.iter().cloned());
542 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
543 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
544 skills = result.skills;
545 skill_diags = result.diagnostics;
546 resource_diags.extend(result.resource_diagnostics);
547 }
548
549 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
550 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
551 if !args.no_prompt_templates {
552 let mut dirs = if args.dev_local_only || args.project_only {
553 project_prompt_template_dirs(cwd)
554 } else if project_trusted {
555 prompt_template_dirs(cwd)
556 } else {
557 global_prompt_template_dirs()
558 };
559 dirs.extend(args.prompt_template.iter().cloned());
560 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
561 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
562 prompt_templates = result.prompt_templates;
563 prompt_diags = result.diagnostics;
564 resource_diags.extend(result.resource_diagnostics);
565 }
566
567 let context_block = if args.no_context_files || !project_trusted {
568 String::new()
569 } else {
570 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
575 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
576 context_paths.extend(
577 files
578 .iter()
579 .map(|file| file.path.to_string_lossy().into_owned()),
580 );
581 format_project_context(&files)
582 };
583
584 if args.verbose {
586 for d in &skill_diags {
587 eprintln!(
588 "warning: skill {} ({}): {}",
589 d.path,
590 d.code.as_str(),
591 d.message
592 );
593 }
594 for d in &prompt_diags {
595 eprintln!(
596 "warning: prompt template {} ({}): {}",
597 d.path,
598 d.code.as_str(),
599 d.message
600 );
601 }
602 for d in &resource_diags {
604 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
605 eprintln!(
606 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
607 c.resource_type.as_str(),
608 c.name,
609 c.winner_path,
610 c.loser_path
611 );
612 }
613 }
614 }
615
616 let system_prompt = compose_system_prompt(
625 Some(&base_prompt),
626 &[], if context_block.is_empty() {
628 None
629 } else {
630 Some(&context_block)
631 },
632 append_join.as_deref(),
633 );
634
635 if args.debug_system_prompt {
644 eprintln!("=== --debug-system-prompt ===");
645 let base_src = if args.system_prompt.is_some() {
646 "--system-prompt"
647 } else if discover_system_prompt_file(cwd).is_some() {
648 "SYSTEM.md"
649 } else {
650 "default"
651 };
652 eprintln!("[base source: {base_src}]");
653 eprintln!("--- base ---\n{base_prompt}");
654 if let Some(append) = append_join.as_deref() {
655 eprintln!("--- append ---\n{append}");
656 } else {
657 eprintln!("--- append: (none) ---");
658 }
659 if context_block.is_empty() {
660 eprintln!("--- context: (none) ---");
661 } else {
662 eprintln!("--- context ---{context_block}");
663 }
664 let visible_skills = skills
665 .iter()
666 .filter(|s| s.disable_model_invocation != Some(true))
667 .count();
668 eprintln!(
669 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
670 skills.len(),
671 visible_skills,
672 skills.len() - visible_skills
673 );
674 for s in &skills {
675 let hidden = if s.disable_model_invocation == Some(true) {
676 " [hidden]"
677 } else {
678 ""
679 };
680 eprintln!(" {}{hidden} — {}", s.name, s.description);
681 }
682 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
683 for t in &prompt_templates {
684 eprintln!(" /{}", t.name);
685 }
686 eprintln!(
690 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
691 discovered.skill_paths.len(),
692 discovered.prompt_paths.len(),
693 discovered.theme_paths.len(),
694 );
695 for p in &discovered.skill_paths {
696 eprintln!(" skill: {p}");
697 }
698 for p in &discovered.prompt_paths {
699 eprintln!(" prompt: {p}");
700 }
701 eprintln!(
702 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
703 );
704 eprintln!("=== end --debug-system-prompt ===");
705 }
706
707 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
712 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
713 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
717
718 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
727 Some(snapshot) => {
728 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
729 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
730 }
731 None => broadcast_emitter,
732 };
733
734 let saved_settings = crate::settings::load_settings().unwrap_or_default();
738
739 if let Some(proxy) = &saved_settings.http_proxy {
744 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
745 if std::env::var_os(key).is_none() {
746 std::env::set_var(key, proxy);
747 }
748 }
749 }
750
751 let extension_session_cell = Arc::new(Mutex::new(extension_session.clone()));
756 let before_tool_call = extension_session
761 .snapshot()
762 .filter(|snapshot| {
763 !snapshot
764 .handlers_for(rpi_plugin_sdk::EventTag::ToolCall)
765 .is_empty()
766 })
767 .map(|_| {
768 extension_tool_call_hook(
769 Arc::clone(&extension_session_cell),
770 ui_dialog_mailbox.clone(),
771 )
772 });
773
774 let options = AgentHarnessOptions {
775 model: resolved.model.clone(),
776 thinking_level: resolved.thinking_level,
777 active_tool_names: active,
778 tools,
779 system_prompt: Some(system_prompt),
780 resources: AgentHarnessResources {
781 context_files: context_paths,
782 skills: if skills.is_empty() {
783 None
784 } else {
785 Some(skills)
786 },
787 prompt_templates: if prompt_templates.is_empty() {
788 None
789 } else {
790 Some(prompt_templates)
791 },
792 },
793 allow_existing_session: matches!(
796 selection,
797 SessionSelection::Latest { .. }
798 | SessionSelection::ById { .. }
799 | SessionSelection::ByExactId { .. }
800 | SessionSelection::Fork { .. }
801 ),
802 stream_options: AgentHarnessStreamOptions {
803 timeout: args.timeout,
804 stream_idle_timeout: args.stream_idle_timeout.or_else(|| {
805 saved_settings
806 .stream_idle_timeout
807 .map(std::time::Duration::from_secs)
808 }),
809 ..Default::default()
810 },
811 retry: saved_settings.retry_policy(),
816 compaction: saved_settings.compaction_settings(),
817 steering_mode: saved_settings.steering_mode(),
818 follow_up_mode: saved_settings.follow_up_mode(),
819 tool_execution: HarnessToolExecution::default(),
820 drive: DrivingMode::default(),
821 session,
822 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
830 to_provider_messages: None,
831 entry_projectors: Default::default(),
832 agent_emitter: Some(emitter),
833 before_tool_call,
838 after_tool_call: None,
839 transform_context: None,
840 entry_transforms: Vec::new(),
841 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
847 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
848 };
849
850 let harness = match AgentHarness::create(options).await {
851 Ok(h) => {
852 crate::extensions_actions::HarnessActionHost::set_harness(
855 &harness_cell,
856 Arc::new(h.clone()),
857 );
858 h
859 }
860 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
861 };
862
863 {
871 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
872 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
873 Ok(reports) => {
874 for (lane, report) in reports {
875 if report.is_clean() {
876 continue;
877 }
878 if report.is_corrupt() {
879 eprintln!(
880 "warning: session lane '{lane}' is corrupt ({} open operations); \
881 refusing to auto-repair — inspect the session file",
882 report.open_operations.len()
883 );
884 continue;
885 }
886 for decision in runtime.reconcile(&report) {
887 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
888 if args.verbose {
889 eprintln!(
890 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
891 );
892 }
893 }
894 }
895 runtime.emit_recovery_events(harness.events(), &report);
896 }
897 }
898 Err(e) => {
899 if args.verbose {
900 eprintln!("warning: session recovery sweep failed: {e}");
901 }
902 }
903 }
904 }
905
906 let mut reload_args = args.clone();
915 reload_args.session_dir = Some(session_dir);
916 let reload_context = ReloadContext {
917 extension_session: extension_session_cell,
918 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
919 tool_context,
920 catalog: Arc::new(std::sync::RwLock::new(catalog)),
921 providers: Arc::new(std::sync::RwLock::new(resolved.providers.clone())),
922 runtime: runtime.clone(),
923 cwd: cwd.to_path_buf(),
924 project_trusted,
925 args: reload_args,
926 resolved_model: resolved.model.clone(),
927 broadcast: broadcast_for_context,
928 mailbox: reload_mailbox,
929 ui_dialog: ui_dialog_mailbox,
930 ext_status: ext_status_mailbox,
931 editor_text: editor_text_mailbox,
932 dev_extension: None,
933 };
934
935 Ok((harness, event_rx, reload_context))
936}
937
938fn extension_tool_call_hook(
941 sessions: ExtensionSessionCell,
942 ui_dialog: rpi_extensions::UiDialogMailbox,
943) -> rpi_agent::BeforeToolCall {
944 Arc::new(move |ctx: rpi_agent::BeforeToolCallContext<'_>, signal| {
945 let session = sessions
946 .lock()
947 .map(|session| session.clone())
948 .map_err(|_| ());
949 let ui_dialog = ui_dialog.clone();
950 let tool_call_id = ctx.tool_call.id.clone();
951 let tool_name = ctx.tool_call.name.clone();
952 let params = ctx.args.clone();
953 Box::pin(async move {
954 let reason = match session {
955 Ok(session) => {
956 rpi_extensions::dispatch_tool_call_event(
957 &session,
958 &tool_call_id,
959 &tool_name,
960 ¶ms,
961 &signal,
962 &ui_dialog,
963 )
964 .await
965 }
966 Err(_) => Some("extension approval session is unavailable".to_string()),
967 };
968 reason.map(|reason| rpi_agent::BeforeToolCallResult {
969 block: true,
970 reason: Some(reason),
971 terminate: false,
972 args: None,
973 })
974 })
975 })
976}
977
978pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
1023
1024pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
1029
1030#[derive(Clone)]
1038pub struct ReloadContext {
1039 pub extension_session: ExtensionSessionCell,
1041 pub tool_context: rpi_extensions::ToolCallContext,
1046 pub action_bridge: ActionBridgeCell,
1048 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
1051 pub editor_text: rpi_extensions::EditorTextMailbox,
1058 pub catalog: Arc<std::sync::RwLock<Vec<rpi_ai::Model>>>,
1061 pub providers: Arc<std::sync::RwLock<Vec<Arc<dyn Provider>>>>,
1064 pub runtime: tokio::runtime::Handle,
1068 pub cwd: PathBuf,
1070 pub project_trusted: bool,
1074 pub args: Args,
1081 pub resolved_model: rpi_ai::Model,
1084 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
1090 pub mailbox: rpi_extensions::ReloadMailbox,
1097 pub ui_dialog: rpi_extensions::UiDialogMailbox,
1103 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
1107}
1108
1109impl ReloadContext {
1110 pub(crate) fn session_dir(&self) -> PathBuf {
1111 self.args
1113 .session_dir
1114 .clone()
1115 .unwrap_or_else(|| configured_session_dir(&self.args, &self.cwd))
1116 }
1117}
1118
1119pub async fn dispatch_session_event_async(
1134 reload_context: &ReloadContext,
1135 tag: rpi_plugin_sdk::EventTag,
1136) -> Option<String> {
1137 let (snapshot, keepalive) = {
1143 let session = reload_context.extension_session.lock().unwrap();
1144 (session.snapshot_arc(), session.keepalive())
1145 };
1146 match snapshot {
1147 Some(snapshot) => rpi_extensions::dispatch_lifecycle_event(&snapshot, tag, keepalive).await,
1148 None => None,
1149 }
1150}
1151
1152async fn shutdown_extensions_for_reload(
1155 snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
1156 keepalive: Arc<rpi_extensions::PluginKeepalive>,
1157) -> Result<(), String> {
1158 if let Some(snapshot) = snapshot {
1159 tokio::task::spawn_blocking(move || {
1160 let _keepalive = keepalive;
1161 rpi_extensions::dispatch_empty_event(
1162 &snapshot,
1163 rpi_plugin_sdk::EventTag::SessionShutdown,
1164 );
1165 })
1166 .await
1167 .map_err(|error| error.to_string())?;
1168 }
1169 Ok(())
1170}
1171
1172pub struct ReloadOutcome {
1175 pub summary: String,
1178 pub had_warnings: bool,
1180 pub details: Vec<String>,
1185 pub had_errors: bool,
1189}
1190
1191struct PreparedReloadInputs {
1192 extension_dirs: Vec<PathBuf>,
1193 skill_base_dirs: Vec<PathBuf>,
1194 prompt_base_dirs: Vec<PathBuf>,
1195}
1196
1197fn append_reload_resource_paths(
1202 mut paths: Vec<PathBuf>,
1203 explicit: &[PathBuf],
1204 discovered: &[String],
1205 js_paths: &[PathBuf],
1206 package_paths: &[PathBuf],
1207) -> Vec<PathBuf> {
1208 paths.extend(explicit.iter().cloned());
1209 paths.extend(discovered.iter().map(PathBuf::from));
1210 paths.extend(js_paths.iter().cloned());
1211 paths.extend(package_paths.iter().cloned());
1212 paths
1213}
1214
1215pub async fn reload_extension_resources(
1227 harness: &AgentHarness,
1228 ctx: &ReloadContext,
1229) -> ReloadOutcome {
1230 reload_extension_resources_inner(harness, ctx, || {}).await
1231}
1232
1233async fn reload_extension_resources_inner<F>(
1234 harness: &AgentHarness,
1235 ctx: &ReloadContext,
1236 after_prepare: F,
1237) -> ReloadOutcome
1238where
1239 F: FnOnce() + Send,
1240{
1241 let mut effective_args = ctx.args.clone();
1246 if let Some(dev) = &ctx.dev_extension {
1247 if let Err(error) = dev
1248 .rebuild()
1249 .and_then(|_| dev.apply_to_args(&mut effective_args))
1250 {
1251 return ReloadOutcome {
1252 summary: format!(
1253 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1254 dev.package_name()
1255 ),
1256 had_warnings: true,
1257 details: Vec::new(),
1258 had_errors: true,
1259 };
1260 }
1261 }
1262 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1263 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1264 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1265 Ok(prepared) => prepared,
1266 Err(error) => {
1267 return ReloadOutcome {
1268 summary: format!(
1269 "Settings reload failed: {error}. Keeping the currently loaded resources."
1270 ),
1271 had_warnings: true,
1272 details: Vec::new(),
1273 had_errors: true,
1274 };
1275 }
1276 };
1277 after_prepare();
1278 let PreparedReloadInputs {
1279 extension_dirs,
1280 skill_base_dirs,
1281 prompt_base_dirs,
1282 } = prepared;
1283 let mut warnings = false;
1284
1285 let (shutdown_snapshot, shutdown_keepalive) = {
1289 let session = ctx.extension_session.lock().unwrap();
1290 (session.snapshot_arc(), session.keepalive())
1291 };
1292 if let Err(reason) = shutdown_extensions_for_reload(shutdown_snapshot, shutdown_keepalive).await
1293 {
1294 return ReloadOutcome {
1295 summary: "Reload failed during extension shutdown".into(),
1296 had_warnings: true,
1297 details: vec![reason],
1298 had_errors: true,
1299 };
1300 }
1301
1302 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1309 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1310 Some(b) => b.clone_host(),
1311 None => {
1312 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1315 ctx.catalog
1316 .read()
1317 .unwrap_or_else(|error| error.into_inner())
1318 .clone(),
1319 ctx.cwd.clone(),
1320 ctx.runtime.clone(),
1321 ctx.args.unknown_flags.clone(),
1322 ctx.tool_context.clone(),
1323 );
1324 crate::extensions_actions::HarnessActionHost::set_harness(
1325 &_cell,
1326 Arc::new(harness.clone()),
1327 );
1328 Arc::new(action_host.with_session_dir(ctx.session_dir()))
1329 }
1330 };
1331
1332 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1333 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1334 ctx.runtime.clone(),
1335 host,
1336 reload_cb,
1337 ctx.ui_dialog.clone(),
1338 ctx.ext_status.clone(),
1339 ctx.editor_text.clone(),
1340 );
1341
1342 let extension_session = if effective_args.no_extensions {
1343 rpi_extensions::ExtensionSession::none()
1344 } else {
1345 load_extensions_from_dirs(
1346 &effective_args,
1347 &extension_dirs,
1348 Some(Arc::clone(&fresh_bridge)),
1349 )
1350 };
1351 if extension_session.is_empty() && !effective_args.no_extensions {
1352 }
1355 if effective_args.verbose {
1356 if let Some(s) = extension_session.summary() {
1357 eprintln!("reload: {s}");
1358 }
1359 report_deferred_renderers(&extension_session);
1360 }
1361
1362 let has_package_providers = |session: &ExtensionSession| {
1363 session.snapshot().is_some_and(|snapshot| {
1364 snapshot
1365 .providers()
1366 .iter()
1367 .any(|provider| snapshot.provider_manifest(&provider.provider_id).is_some())
1368 })
1369 };
1370 let packaged = has_package_providers(&extension_session)
1371 || has_package_providers(&ctx.extension_session.lock().unwrap());
1372 let refreshed = if packaged {
1373 let mut args = effective_args.clone();
1374 if let Ok(current) = harness.lane("main").get_model().await {
1375 let was_package = ctx
1376 .extension_session
1377 .lock()
1378 .unwrap()
1379 .snapshot()
1380 .is_some_and(|snapshot| snapshot.provider_manifest(¤t.provider).is_some());
1381 let has_replacement = extension_session.snapshot().is_some_and(|snapshot| {
1382 snapshot
1383 .providers()
1384 .iter()
1385 .any(|provider| provider.provider_id == current.provider)
1386 });
1387 if was_package && !has_replacement {
1388 return ReloadOutcome {
1389 summary: "Reload failed while resolving provider packages".into(),
1390 had_warnings: true,
1391 details: vec![format!(
1392 "active provider package '{}' is no longer loaded",
1393 current.provider
1394 )],
1395 had_errors: true,
1396 };
1397 }
1398 args.provider = Some(current.provider.clone());
1399 args.model = Some(current.id);
1400 if current.provider != ctx.resolved_model.provider {
1401 args.api_key = None;
1402 args.base_url = None;
1403 }
1404 }
1405 match crate::provider::resolve_with_extensions(
1406 &args,
1407 &ctx.cwd,
1408 ctx.project_trusted,
1409 &extension_session,
1410 ) {
1411 Ok(resolved) => Some(resolved),
1412 Err(error) => {
1413 return ReloadOutcome {
1414 summary: "Reload failed while resolving provider packages".into(),
1415 had_warnings: true,
1416 details: vec![error.to_string()],
1417 had_errors: true,
1418 }
1419 }
1420 }
1421 } else {
1422 None
1423 };
1424
1425 let old_session = {
1437 let mut session_guard = ctx.extension_session.lock().unwrap();
1438 let old_session = std::mem::replace(&mut *session_guard, extension_session.clone());
1439 if let Some(old_snap) = old_session.snapshot_arc() {
1440 old_snap.active_flag().store(false, Ordering::SeqCst);
1444 }
1445 old_session
1446 };
1447 if let Some(old_b) = old_bridge {
1448 old_b.invalidate();
1449 }
1450 old_session.begin_shutdown();
1452
1453 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1455
1456 let discovered = extension_session
1458 .snapshot_arc()
1459 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1460 .unwrap_or_default();
1461
1462 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1464 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1465
1466 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1467 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1468 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1469 if !effective_args.no_skills {
1470 let js_paths: &[PathBuf] = &[];
1474 let package_paths: &[PathBuf] = &[];
1475 let dirs = append_reload_resource_paths(
1476 skill_base_dirs,
1477 &effective_args.skill,
1478 &discovered.skill_paths,
1479 js_paths,
1480 package_paths,
1481 );
1482 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1483 skills = result.skills;
1484 skill_diags = result.diagnostics;
1485 resource_diags.extend(result.resource_diagnostics);
1486 }
1487
1488 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1489 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1490 if !effective_args.no_prompt_templates {
1491 let js_paths: &[PathBuf] = &[];
1492 let package_paths: &[PathBuf] = &[];
1493 let dirs = append_reload_resource_paths(
1494 prompt_base_dirs,
1495 &effective_args.prompt_template,
1496 &discovered.prompt_paths,
1497 js_paths,
1498 package_paths,
1499 );
1500 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1501 prompt_templates = result.prompt_templates;
1502 prompt_diags = result.diagnostics;
1503 resource_diags.extend(result.resource_diagnostics);
1504 }
1505
1506 let context_files = if effective_args.no_context_files || !project_trusted {
1507 Vec::new()
1508 } else {
1509 let agent_dir = crate::config::agent_dir().ok();
1510 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1511 load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await
1512 };
1513 let context_block = format_project_context(&context_files);
1514
1515 let mut details: Vec<String> = Vec::new();
1516 if let Some(resolved) = &refreshed {
1517 warnings |= !resolved.compat_warnings.is_empty();
1518 details.extend(
1519 resolved
1520 .compat_warnings
1521 .iter()
1522 .map(|warning| format!("warning: models.json: {warning}")),
1523 );
1524 }
1525 if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
1526 warnings = true;
1527 for d in &skill_diags {
1532 details.push(format!(
1533 "warning: skill {} ({}): {}",
1534 d.path,
1535 d.code.as_str(),
1536 d.message
1537 ));
1538 }
1539 for d in &prompt_diags {
1540 details.push(format!(
1541 "warning: prompt template {} ({}): {}",
1542 d.path,
1543 d.code.as_str(),
1544 d.message
1545 ));
1546 }
1547 for d in &resource_diags {
1549 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1550 details.push(format!(
1551 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1552 c.resource_type.as_str(),
1553 c.name,
1554 c.winner_path,
1555 c.loser_path
1556 ));
1557 }
1558 }
1559 }
1560
1561 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1566 let mut context_paths = Vec::new();
1567 let base_prompt = match effective_args.system_prompt.as_deref() {
1568 Some(explicit) => explicit.to_string(),
1569 None if project_trusted => match discover_system_prompt_file(&ctx.cwd) {
1570 Some(path) => read_prompt_source(&path, &mut context_paths)
1571 .unwrap_or_else(|| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
1572 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1573 },
1574 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1575 };
1576 let mut append_texts: Vec<String> = Vec::new();
1577 for extra in &effective_args.append_system_prompt {
1578 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
1579 append_texts.push(text);
1580 }
1581 if effective_args.append_system_prompt.is_empty() {
1582 if let Some(path) = project_trusted
1583 .then(|| discover_append_system_prompt_file(&ctx.cwd))
1584 .flatten()
1585 {
1586 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
1587 append_texts.push(text);
1588 }
1589 }
1590 }
1591 context_paths.extend(
1592 context_files
1593 .iter()
1594 .map(|file| file.path.to_string_lossy().into_owned()),
1595 );
1596 let append_join = if append_texts.is_empty() {
1597 None
1598 } else {
1599 Some(append_texts.join("\n\n"))
1600 };
1601 let system_prompt = compose_system_prompt(
1602 Some(&base_prompt),
1603 &[],
1604 if context_block.is_empty() {
1605 None
1606 } else {
1607 Some(&context_block)
1608 },
1609 append_join.as_deref(),
1610 );
1611
1612 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1614 Some(snapshot) => {
1615 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1616 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1617 }
1618 None => ctx.broadcast.clone(),
1619 };
1620
1621 let resources = AgentHarnessResources {
1623 context_files: context_paths,
1624 skills: if skills.is_empty() {
1625 None
1626 } else {
1627 Some(skills.clone())
1628 },
1629 prompt_templates: if prompt_templates.is_empty() {
1630 None
1631 } else {
1632 Some(prompt_templates.clone())
1633 },
1634 };
1635 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1636 let _ = harness.set_resources(resources).await;
1637 let _ = harness.set_agent_emitter(Some(emitter)).await;
1638 let _ = harness
1639 .set_models(build_models_with_extensions_for_reload(
1640 &refreshed
1641 .as_ref()
1642 .map(|resolved| resolved.providers.clone())
1643 .unwrap_or_else(|| {
1644 ctx.providers
1645 .read()
1646 .unwrap_or_else(|error| error.into_inner())
1647 .clone()
1648 }),
1649 &extension_session,
1650 ctx.runtime.clone(),
1651 ))
1652 .await;
1653 let _ = harness
1654 .set_provider_hooks(
1655 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1656 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1657 )
1658 .await;
1659
1660 if let Some(resolved) = &refreshed {
1661 *ctx.providers
1662 .write()
1663 .unwrap_or_else(|error| error.into_inner()) = resolved.providers.clone();
1664 let catalog = crate::provider::authenticated_catalog(resolved);
1665 fresh_bridge
1666 .clone_host()
1667 .replace_model_catalog(catalog.clone());
1668 *ctx.catalog
1669 .write()
1670 .unwrap_or_else(|error| error.into_inner()) = catalog;
1671 let _ = harness.lane("main").set_model(resolved.model.clone()).await;
1674 }
1675
1676 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1680 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1681 let mut tools = build_tools(&tool_ctx, &effective_args);
1682 merge_extension_tools(
1683 &mut tools,
1684 &extension_session,
1685 &effective_args,
1686 &ctx.tool_context,
1687 );
1688 let mut active = active_tool_names(&tools, &effective_args);
1689 active = filter_active_tool_names(active, &effective_args);
1690 let _ = harness.set_tools(tools, Some(active)).await;
1691
1692 let summary = format!(
1693 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1694 extension_session.loaded_paths().len(),
1695 skills.len(),
1696 prompt_templates.len(),
1697 );
1698 ReloadOutcome {
1699 summary,
1700 had_warnings: warnings,
1701 details,
1702 had_errors: false,
1705 }
1706}
1707
1708#[derive(Clone, Debug, PartialEq, Eq)]
1709struct ReloadSettingsFields {
1710 skill_dirs: Option<Vec<String>>,
1711 prompt_dirs: Option<Vec<String>>,
1712 extension_dirs: Option<Vec<String>>,
1713}
1714
1715impl From<crate::settings::Settings> for ReloadSettingsFields {
1716 fn from(settings: crate::settings::Settings) -> Self {
1717 Self {
1718 skill_dirs: settings.skill_dirs,
1719 prompt_dirs: settings.prompt_dirs,
1720 extension_dirs: settings.extension_dirs,
1721 }
1722 }
1723}
1724
1725#[derive(Clone, Debug, PartialEq, Eq)]
1726struct ReloadSettingsSnapshot {
1727 global: ReloadSettingsFields,
1728 project: Option<ReloadSettingsFields>,
1729}
1730
1731fn reload_reads_settings(args: &Args) -> bool {
1732 !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
1733}
1734
1735fn load_reload_settings_snapshot(
1739 args: &Args,
1740 cwd: &Path,
1741 project_trusted: bool,
1742) -> Result<Option<ReloadSettingsSnapshot>, String> {
1743 if !reload_reads_settings(args) {
1744 return Ok(None);
1745 }
1746 let global = crate::settings::load_settings()
1747 .map_err(|error| format!("could not load global settings: {error}"))?
1748 .into();
1749 let project = if project_trusted {
1750 crate::settings::load_active_project_settings(cwd)
1751 .map_err(|error| format!("could not load project settings: {error}"))?
1752 .map(|(_, settings)| settings.into())
1753 } else {
1754 None
1755 };
1756 Ok(Some(ReloadSettingsSnapshot { global, project }))
1757}
1758
1759#[cfg(test)]
1762fn validate_settings_for_reload(
1763 args: &Args,
1764 cwd: &Path,
1765 project_trusted: bool,
1766) -> Result<(), String> {
1767 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1768}
1769
1770fn prepare_reload_inputs(
1771 args: &Args,
1772 cwd: &Path,
1773 project_trusted: bool,
1774) -> Result<PreparedReloadInputs, String> {
1775 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1776}
1777
1778fn prepare_reload_inputs_inner<F>(
1779 args: &Args,
1780 cwd: &Path,
1781 project_trusted: bool,
1782 before_verify: F,
1783) -> Result<PreparedReloadInputs, String>
1784where
1785 F: FnOnce(),
1786{
1787 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1788
1789 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1790 Vec::new()
1791 } else if args.project_only {
1792 project_extension_dirs(cwd)
1793 } else if project_trusted {
1794 extension_dirs(cwd)
1795 } else {
1796 global_extension_dirs()
1797 };
1798 if !args.no_extensions {
1799 extension_dirs.extend(args.extensions_dir.iter().cloned());
1800 }
1801
1802 let skill_base_dirs = if args.no_skills {
1803 Vec::new()
1804 } else if args.dev_local_only || args.project_only {
1805 project_skill_dirs(cwd)
1806 } else if project_trusted {
1807 skill_dirs(cwd)
1808 } else {
1809 global_skill_dirs()
1810 };
1811
1812 let prompt_base_dirs = if args.no_prompt_templates {
1813 Vec::new()
1814 } else if args.dev_local_only || args.project_only {
1815 project_prompt_template_dirs(cwd)
1816 } else if project_trusted {
1817 prompt_template_dirs(cwd)
1818 } else {
1819 global_prompt_template_dirs()
1820 };
1821
1822 before_verify();
1823 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1824 if settings_before != settings_after {
1825 return Err(
1826 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1827 );
1828 }
1829
1830 Ok(PreparedReloadInputs {
1831 extension_dirs,
1832 skill_base_dirs,
1833 prompt_base_dirs,
1834 })
1835}
1836
1837fn build_models_with_extensions_for_reload(
1842 providers: &[Arc<dyn Provider>],
1843 extension_session: &ExtensionSession,
1844 runtime: tokio::runtime::Handle,
1845) -> Vec<Arc<dyn Provider>> {
1846 let mut models: Vec<Arc<dyn Provider>> = providers.to_vec();
1847 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1848 for provider in pluggable {
1849 if extension_session
1853 .snapshot()
1854 .is_some_and(|snapshot| snapshot.provider_manifest(provider.id()).is_some())
1855 {
1856 continue;
1857 }
1858 if !models.iter().any(|known| known.id() == provider.id()) {
1859 models.push(provider);
1860 }
1861 }
1862 models
1863}
1864
1865fn report_deferred_renderers(session: &ExtensionSession) {
1869 let Some(snap) = session.snapshot_arc() else {
1870 return;
1871 };
1872 let all = snap.renderers();
1873 let markdown = all
1874 .iter()
1875 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1876 .count();
1877 let message = all
1878 .iter()
1879 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1880 .count();
1881 let entry = all
1882 .iter()
1883 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1884 .count();
1885 if markdown + message + entry == 0 {
1886 return;
1887 }
1888 eprintln!(
1889 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1890 markdown, message, entry
1891 );
1892}
1893
1894#[derive(Debug, thiserror::Error)]
1896pub enum BuildError {
1897 #[error("Could not create the session directory: {0}")]
1898 SessionDir(String),
1899 #[error("Could not open the saved session: {0}")]
1900 SessionOpen(String),
1901 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1902 SessionNotFound { requested: String, dir: String },
1903 #[error("Could not build the harness: {0}")]
1904 HarnessCreate(String),
1905}
1906
1907fn build_models_with_extensions(
1910 resolved: &ResolvedModel,
1911 extension_session: &ExtensionSession,
1912 runtime: tokio::runtime::Handle,
1913) -> Vec<Arc<dyn Provider>> {
1914 build_models_with_extensions_for_reload(&resolved.providers, extension_session, runtime)
1915}
1916
1917pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1920 if let Some(override_value) = args.trust_override {
1921 return override_value;
1922 }
1923 crate::config::project_trust_decision(cwd)
1924 .ok()
1925 .flatten()
1926 .unwrap_or(true)
1927}
1928
1929pub(crate) fn load_extensions(
1937 args: &Args,
1938 cwd: &Path,
1939 project_trusted: bool,
1940 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1941) -> ExtensionSession {
1942 if args.no_extensions {
1943 return ExtensionSession::none();
1944 }
1945 let mut dirs = if args.dev_local_only {
1946 Vec::new()
1947 } else if args.project_only {
1948 project_extension_dirs(cwd)
1949 } else if project_trusted {
1950 extension_dirs(cwd)
1951 } else {
1952 global_extension_dirs()
1953 };
1954 dirs.extend(args.extensions_dir.iter().cloned());
1955 load_extensions_from_dirs(args, &dirs, action_bridge)
1956}
1957
1958fn load_extensions_from_dirs(
1959 args: &Args,
1960 dirs: &[PathBuf],
1961 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1962) -> ExtensionSession {
1963 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1964 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1971}
1972
1973fn merge_extension_tools(
1979 tools: &mut Vec<HarnessTool>,
1980 session: &ExtensionSession,
1981 args: &Args,
1982 tool_context: &rpi_extensions::ToolCallContext,
1983) {
1984 let Some(snapshot) = session.snapshot() else {
1985 return;
1986 };
1987 for et in snapshot.tools() {
1988 let name = &et.tool.name;
1989 if !tool_name_allowed(name, args) {
1990 continue;
1991 }
1992 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
1993 .with_context(tool_context.clone());
1994 let harness_tool = HarnessTool::new(Arc::new(adapter));
1995 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1996 Some(slot) => *slot = harness_tool,
1997 None => tools.push(harness_tool),
1998 }
1999 }
2000}
2001
2002pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
2009 use rpi_tools::tools::bash::BashToolOptions;
2010 let default = std::env::var("RPI_BASH_TIMEOUT")
2011 .ok()
2012 .and_then(|v| v.parse::<f64>().ok())
2013 .unwrap_or(120.0);
2014 let settings = crate::settings::load_settings().unwrap_or_default();
2020 BashToolOptions {
2021 command_prefix: settings.shell_command_prefix.clone(),
2022 default_timeout: Some(default),
2023 prepare: None,
2024 }
2025}
2026
2027fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
2028 if args.no_tools {
2029 return Vec::new();
2030 }
2031 let mut all: Vec<(&'static str, HarnessTool)> = vec![
2040 (
2041 "read",
2042 HarnessTool::new(create_read_tool(
2043 ctx,
2044 Some(rpi_tools::tools::read::ReadToolOptions {
2045 image_processor: Some(Arc::new(
2046 rpi_tools::image_processing::InlineImageProcessor,
2047 )),
2048 ..Default::default()
2049 }),
2050 ))
2051 .with_replay(ToolReplay::Safe),
2052 ),
2053 (
2054 "bash",
2055 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
2056 ),
2057 ("edit", HarnessTool::new(create_edit_tool(ctx))),
2058 ("write", HarnessTool::new(create_write_tool(ctx))),
2059 (
2060 "docs",
2061 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
2062 ),
2063 ];
2064
2065 #[cfg(target_os = "windows")]
2067 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
2068
2069 if args.tools.is_none() {
2071 if let Ok(settings) = crate::settings::load_settings() {
2072 if let Some(default_tools) = &settings.default_tools {
2073 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
2074 }
2075 }
2076 }
2077
2078 if args.no_builtin_tools {
2082 all.clear();
2083 }
2084
2085 if let Some(allow) = &args.tools {
2087 all.retain(|(name, _)| allow.iter().any(|a| a == name));
2088 }
2089 if let Some(deny) = &args.exclude_tools {
2091 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
2092 }
2093
2094 all.into_iter().map(|(_, t)| t).collect()
2095}
2096
2097fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
2101 filter_active_tool_names(
2102 tools.iter().map(|tool| tool.tool.schema().name.clone()),
2103 args,
2104 )
2105}
2106
2107pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
2110 if args.no_tools {
2111 return false;
2112 }
2113 if args
2114 .tools
2115 .as_ref()
2116 .is_some_and(|allow| !allow.iter().any(|value| value == name))
2117 {
2118 return false;
2119 }
2120 if args
2121 .exclude_tools
2122 .as_ref()
2123 .is_some_and(|deny| deny.iter().any(|value| value == name))
2124 {
2125 return false;
2126 }
2127 true
2128}
2129
2130pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
2131where
2132 I: IntoIterator<Item = String>,
2133{
2134 names
2135 .into_iter()
2136 .filter(|name| tool_name_allowed(name, args))
2137 .collect()
2138}
2139
2140async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2142 match selection {
2143 SessionSelection::Ephemeral => Ok(ephemeral_session()),
2144 SessionSelection::New { dir, .. } => {
2145 std::fs::create_dir_all(dir)
2148 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2149 let session = create_jsonl_session(dir, cwd)
2150 .await
2151 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2152 Ok(session)
2153 }
2154 SessionSelection::Latest { .. }
2155 | SessionSelection::ById { .. }
2156 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2157 SessionSelection::Fork { source, dir } => fork_session_at_launch(source, dir, cwd).await,
2158 }
2159}
2160
2161async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2167 match selection {
2171 SessionSelection::Latest { dir } => {
2172 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2173 let Some(meta) = first_resumable_session(&metas) else {
2174 return Err(BuildError::SessionNotFound {
2175 requested: "the most recent session".to_string(),
2176 dir: dir.display().to_string(),
2177 });
2178 };
2179 open_session(meta, cwd).await
2180 }
2181 SessionSelection::ById { id, dir } => open_session_by_id_in_dir(id, cwd, dir)
2182 .await
2183 .map_err(|e| match e {
2184 OpenError::NotFound { requested } => BuildError::SessionNotFound {
2185 requested,
2186 dir: dir.display().to_string(),
2187 },
2188 OpenError::Other(msg) => BuildError::SessionOpen(msg),
2189 }),
2190 SessionSelection::ByExactId { id, dir } => {
2191 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2194 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
2195 return open_session(meta, cwd).await;
2196 }
2197 std::fs::create_dir_all(dir)
2198 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2199 create_jsonl_session_with_id(dir, cwd, Some(id.clone()))
2200 .await
2201 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2202 }
2203 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
2204 }
2205}
2206
2207async fn fork_session_at_launch(
2210 source: &str,
2211 dir: &Path,
2212 cwd: &str,
2213) -> Result<Session, BuildError> {
2214 use rpi_harness::session::jsonl::{
2215 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2216 };
2217 use rpi_harness::session::types::{ForkOptions, SessionStorage};
2218 use rpi_tools::FileSystem;
2219
2220 let dir = dir.to_path_buf();
2221 std::fs::create_dir_all(&dir)
2222 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2223 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2224 let fs: Arc<dyn FileSystem> = env.clone();
2225 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2226 fs: fs.clone(),
2227 sessions_root: dir.to_string_lossy().into_owned(),
2228 clock: Arc::new(SystemClock),
2229 ids: Arc::new(DefaultIdGenerator::new()),
2230 });
2231 let metas = repo
2232 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2233 .await
2234 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2235 let source_meta = metas
2236 .iter()
2237 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2238 .ok_or_else(|| BuildError::SessionNotFound {
2239 requested: format!("--fork {source}"),
2240 dir: dir.display().to_string(),
2241 })?;
2242 let fork_storage = repo
2243 .fork_typed(
2244 source_meta,
2245 &JsonlSessionCreateOptions {
2246 id: None,
2247 parent_session_id: Some(source_meta.id.clone()),
2248 cwd: cwd.to_string(),
2249 metadata: None,
2250 },
2251 &ForkOptions::default(),
2252 )
2253 .await
2254 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2255 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
2256 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2257 Ok(Session::new(storage_arc, None))
2258}
2259
2260pub enum OpenError {
2263 NotFound { requested: String },
2265 Other(String),
2267}
2268
2269impl std::fmt::Display for OpenError {
2270 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2271 match self {
2272 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2273 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
2278 }
2279 }
2280}
2281
2282pub async fn list_session_metadata(
2285 cwd: &str,
2286) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2287 let dir = default_session_dir(Path::new(cwd));
2288 list_session_metadata_in_dir(cwd, &dir).await
2289}
2290
2291pub(crate) async fn list_session_metadata_in_dir(
2292 cwd: &str,
2293 dir: &Path,
2294) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2295 use rpi_harness::session::jsonl::{
2296 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2297 };
2298 use rpi_tools::FileSystem;
2299
2300 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2301 let fs: Arc<dyn FileSystem> = env.clone();
2302 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2303 fs: fs.clone(),
2304 sessions_root: dir.to_string_lossy().into_owned(),
2305 clock: Arc::new(SystemClock),
2306 ids: Arc::new(DefaultIdGenerator::new()),
2307 });
2308 repo.list_typed(&JsonlSessionListOptions::default())
2309 .await
2310 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2311}
2312
2313#[derive(Debug, Clone, Default, PartialEq, Eq)]
2329pub struct SessionSummary {
2330 pub name: Option<String>,
2332 pub preview: Option<String>,
2334 pub empty: bool,
2336 pub bytes: u64,
2338}
2339
2340const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
2342const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
2344const SUMMARY_PREVIEW_CHARS: usize = 80;
2346
2347pub fn summarize_session_file(path: &Path) -> SessionSummary {
2351 use std::io::{Read, Seek, SeekFrom};
2352
2353 let Ok(mut file) = std::fs::File::open(path) else {
2354 return SessionSummary::default();
2355 };
2356 let Ok(size) = file.metadata().map(|m| m.len()) else {
2357 return SessionSummary::default();
2358 };
2359 if size == 0 {
2360 return SessionSummary {
2361 name: None,
2362 preview: None,
2363 empty: true,
2364 bytes: 0,
2365 };
2366 }
2367
2368 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2371 let head_len = file.read(&mut head).unwrap_or(0);
2372 head.truncate(head_len);
2373 let head = String::from_utf8_lossy(&head);
2374 let mut lines: Vec<&str> = head.lines().collect();
2375 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2376 let empty = (size as usize) <= header_len + 1;
2378 if (head_len as u64) < size && !lines.is_empty() {
2381 lines.pop();
2382 }
2383
2384 let mut summary = SessionSummary {
2385 name: None,
2386 preview: None,
2387 empty,
2388 bytes: size,
2389 };
2390 for line in lines {
2391 apply_summary_line(line, &mut summary);
2392 }
2393
2394 if size > SUMMARY_HEAD_BYTES
2398 && file
2399 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2400 .is_ok()
2401 {
2402 let mut tail = Vec::new();
2403 if file.read_to_end(&mut tail).is_ok() {
2404 let tail = String::from_utf8_lossy(&tail);
2405 let mut tail_lines = tail.lines();
2406 tail_lines.next();
2408 for line in tail_lines {
2409 if let Some(name) = name_from_line(line) {
2410 summary.name = name;
2411 }
2412 }
2413 }
2414 }
2415 summary
2416}
2417
2418fn name_from_line(line: &str) -> Option<Option<String>> {
2421 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2422 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2423 return None;
2424 }
2425 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2426 return None;
2427 }
2428 Some(match value.get("name") {
2429 Some(serde_json::Value::String(name)) => Some(name.clone()),
2430 _ => None,
2431 })
2432}
2433
2434fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2437 if let Some(name) = name_from_line(line) {
2438 summary.name = name;
2439 return;
2440 }
2441 if summary.preview.is_some() {
2442 return;
2443 }
2444 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2445 return;
2446 };
2447 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2448 return;
2449 }
2450 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2451 return;
2452 }
2453 let Some(message) = value.get("message") else {
2454 return;
2455 };
2456 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2457 return;
2458 }
2459 let text = match message.get("content") {
2460 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2461 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2462 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2463 .then(|| block.get("text").and_then(|v| v.as_str()))
2464 .flatten()
2465 }),
2466 _ => None,
2467 };
2468 let Some(text) = text else { return };
2469 let collapsed = text
2470 .lines()
2471 .map(str::trim)
2472 .filter(|line| !line.is_empty())
2473 .collect::<Vec<_>>()
2474 .join(" ");
2475 if collapsed.is_empty() {
2476 return;
2477 }
2478 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2479}
2480
2481pub fn session_display_label(summary: &SessionSummary) -> String {
2485 if let Some(name) = summary.name.as_deref().map(str::trim) {
2486 if !name.is_empty() {
2487 return name.to_string();
2488 }
2489 }
2490 match summary.preview.as_deref().map(str::trim) {
2491 Some(preview) if !preview.is_empty() => preview.to_string(),
2492 _ => "(no messages)".to_string(),
2493 }
2494}
2495
2496pub fn format_session_bytes(bytes: u64) -> String {
2499 if bytes >= 1_048_576 {
2500 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2501 } else if bytes >= 1024 {
2502 format!("{}KB", bytes / 1024)
2503 } else {
2504 format!("{bytes}B")
2505 }
2506}
2507
2508pub fn short_session_id(id: &str) -> &str {
2511 &id[..id.len().min(8)]
2512}
2513
2514pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2523 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2524 let mut visit = |path: PathBuf| {
2525 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2526 return;
2527 }
2528 let modified = std::fs::metadata(&path)
2529 .and_then(|m| m.modified())
2530 .unwrap_or(std::time::UNIX_EPOCH);
2531 files.push((path, modified));
2532 };
2533 if let Ok(entries) = std::fs::read_dir(dir) {
2534 for entry in entries.flatten() {
2535 let path = entry.path();
2536 if path.is_dir() {
2537 if let Ok(inner) = std::fs::read_dir(&path) {
2538 for child in inner.flatten() {
2539 visit(child.path());
2540 }
2541 }
2542 } else {
2543 visit(path);
2544 }
2545 }
2546 }
2547 files.sort_by(|a, b| b.1.cmp(&a.1));
2548 files.into_iter().map(|(path, _)| path).collect()
2549}
2550
2551fn first_resumable_session(
2556 metas: &[rpi_harness::session::jsonl::JsonlSessionMetadata],
2557) -> Option<&rpi_harness::session::jsonl::JsonlSessionMetadata> {
2558 metas
2559 .iter()
2560 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2561 .or_else(|| metas.first())
2562}
2563
2564pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2568 let dir = default_session_dir(Path::new(cwd));
2569 open_session_by_id_in_dir(id, cwd, &dir).await
2570}
2571
2572pub(crate) async fn open_session_by_id_in_dir(
2573 id: &str,
2574 cwd: &str,
2575 dir: &Path,
2576) -> Result<Session, OpenError> {
2577 let metas = list_session_metadata_in_dir(cwd, dir)
2578 .await
2579 .map_err(|e| OpenError::Other(e.to_string()))?;
2580 let Some(meta) = metas
2581 .iter()
2582 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2583 else {
2584 return Err(OpenError::NotFound {
2585 requested: format!("session {id}"),
2586 });
2587 };
2588 open_session(meta, cwd).await.map_err(|e| {
2589 OpenError::Other(match e {
2590 BuildError::SessionOpen(reason) => reason,
2594 other => other.to_string(),
2595 })
2596 })
2597}
2598
2599fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
2624 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
2625 tool_context.set_session_id(id);
2626}
2627
2628pub(crate) async fn set_active_session(
2639 harness: &AgentHarness,
2640 session: Session,
2641 tool_context: Option<&rpi_extensions::ToolCallContext>,
2642) -> Result<(), String> {
2643 let metadata = session.get_metadata().await.ok();
2644 harness
2645 .set_session(session)
2646 .await
2647 .map_err(|e| e.to_string())?;
2648 if let Some(metadata) = metadata {
2649 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2650 if let Some(context) = tool_context {
2651 context.set_session_id(metadata.id);
2652 }
2653 }
2654 Ok(())
2655}
2656
2657pub(crate) async fn fork_session_storage(
2664 harness: &AgentHarness,
2665 cwd: &str,
2666 dir: &Path,
2667) -> Result<Session, String> {
2668 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2669 use rpi_tools::FileSystem;
2670
2671 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2672 let fs: Arc<dyn FileSystem> = env.clone();
2673 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2674 fs,
2675 sessions_root: dir.to_string_lossy().into_owned(),
2676 clock: Arc::new(SystemClock),
2677 ids: Arc::new(DefaultIdGenerator::new()),
2678 });
2679 let id = harness.session().storage().metadata().id.clone();
2682 let metas = list_session_metadata_in_dir(cwd, dir)
2683 .await
2684 .map_err(|e| e.to_string())?;
2685 let Some(source) = metas.iter().find(|m| m.id == id) else {
2686 return Err(format!("current session {id} not found on disk"));
2687 };
2688 let fork_storage = repo
2689 .fork_typed(
2690 source,
2691 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2692 id: None,
2693 parent_session_id: Some(source.id.clone()),
2694 cwd: cwd.to_string(),
2695 metadata: None,
2696 },
2697 &rpi_harness::session::types::ForkOptions::default(),
2698 )
2699 .await
2700 .map_err(|e| e.to_string())?;
2701 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2702 Ok(Session::new(Arc::new(fork_storage), None))
2703}
2704
2705pub(crate) async fn import_session_file(
2709 source: &Path,
2710 cwd: &str,
2711 dir: &Path,
2712) -> Result<Session, String> {
2713 use rpi_harness::session::jsonl::{
2714 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions, JsonlSessionStorage,
2715 };
2716 use rpi_tools::FileSystem;
2717
2718 let dir = if dir.is_absolute() {
2719 dir.to_path_buf()
2720 } else {
2721 Path::new(cwd).join(dir)
2722 };
2723 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
2724 let staged = tempfile::NamedTempFile::new_in(&dir)
2725 .map_err(|e| e.to_string())?
2726 .into_temp_path();
2727 std::fs::copy(source, &staged).map_err(|e| format!("copy import: {e}"))?;
2728 let fs: Arc<dyn FileSystem> = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2729 let validated = JsonlSessionStorage::load(
2730 fs.clone(),
2731 &staged.to_string_lossy(),
2732 Arc::new(SystemClock),
2733 Arc::new(DefaultIdGenerator::new()),
2734 )
2735 .await
2736 .map_err(|e| format!("invalid session import: {e}"))?;
2737 let mut metadata = validated.jsonl_metadata().clone();
2738 drop(validated);
2739 if list_session_metadata_in_dir(cwd, &dir)
2740 .await
2741 .map_err(|e| e.to_string())?
2742 .iter()
2743 .any(|saved| saved.id == metadata.id)
2744 {
2745 return Err(format!(
2746 "session {} already exists in {}",
2747 metadata.id,
2748 dir.display()
2749 ));
2750 }
2751 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2752 fs,
2753 sessions_root: dir.to_string_lossy().into_owned(),
2754 clock: Arc::new(SystemClock),
2755 ids: Arc::new(DefaultIdGenerator::new()),
2756 });
2757 let destination = repo
2759 .create_typed(&JsonlSessionCreateOptions {
2760 id: Some(metadata.id.clone()),
2761 parent_session_id: metadata.parent_session_id.clone(),
2762 cwd: cwd.to_string(),
2763 metadata: metadata.metadata.clone(),
2764 })
2765 .await
2766 .map_err(|e| e.to_string())?;
2767 let path = destination.jsonl_metadata().path.clone();
2768 drop(destination);
2769 if let Err(error) = claim_session_lock(&path) {
2770 return Err(error);
2772 }
2773 if let Err(error) = staged.persist(&path) {
2774 let _ = std::fs::remove_file(&path);
2775 return Err(format!("publish import: {error}"));
2776 }
2777 metadata.path = path;
2778 let storage = repo
2779 .open_by_jsonl_metadata(&metadata)
2780 .await
2781 .map_err(|e| e.to_string())?;
2782 Ok(Session::new(Arc::new(storage), None))
2783}
2784
2785static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2787static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2790
2791fn claim_session_lock(session_path: &str) -> Result<(), String> {
2800 let lock_path = PathBuf::from(format!("{session_path}.lock"));
2801 if SESSION_LOCK_PATHS
2802 .get_or_init(|| Mutex::new(HashSet::new()))
2803 .lock()
2804 .unwrap()
2805 .contains(&lock_path)
2806 {
2807 return Ok(());
2808 }
2809 let file = std::fs::OpenOptions::new()
2810 .create(true)
2811 .write(true)
2812 .open(&lock_path)
2813 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2814 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2815 return Err(format!(
2816 "session {session_path} is already open in another rpi process"
2817 ));
2818 }
2819 SESSION_LOCKS
2820 .get_or_init(|| Mutex::new(Vec::new()))
2821 .lock()
2822 .unwrap()
2823 .push(file);
2824 SESSION_LOCK_PATHS
2825 .get_or_init(|| Mutex::new(HashSet::new()))
2826 .lock()
2827 .unwrap()
2828 .insert(lock_path);
2829 Ok(())
2830}
2831
2832async fn open_session(
2835 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2836 cwd: &str,
2837) -> Result<Session, BuildError> {
2838 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2839 use rpi_harness::session::types::SessionStorage;
2840 use rpi_tools::FileSystem;
2841
2842 let dir = default_session_dir(Path::new(cwd));
2843 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2844 let fs: Arc<dyn FileSystem> = env.clone();
2845 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2846 fs: fs.clone(),
2847 sessions_root: dir.to_string_lossy().into_owned(),
2848 clock: Arc::new(SystemClock),
2849 ids: Arc::new(DefaultIdGenerator::new()),
2850 });
2851 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2852 let storage = repo
2853 .open_by_jsonl_metadata(meta)
2854 .await
2855 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2856 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2860 Some(Path::new(&meta.path)),
2861 Path::new(&meta.cwd),
2862 Path::new(cwd),
2863 ) {
2864 eprintln!(
2865 "warning: {}",
2866 crate::session_cwd::format_missing_session_cwd_error(&issue)
2867 );
2868 }
2869 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2870 Ok(Session::new(storage_arc, None))
2871}
2872
2873fn ephemeral_session() -> Session {
2875 let storage = Arc::new(InMemorySessionStorage::new(
2876 SessionMetadata {
2877 id: "ephemeral".into(),
2878 created_at: 0,
2879 parent_session_id: None,
2880 },
2881 Arc::new(SystemClock),
2882 Arc::new(DefaultIdGenerator::new()),
2883 ));
2884 Session::new(storage, None)
2885}
2886
2887pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2898 create_jsonl_session_with_id(dir, cwd, None).await
2899}
2900
2901pub(crate) async fn create_jsonl_session_with_id(
2905 dir: &Path,
2906 cwd: &str,
2907 id: Option<String>,
2908) -> Result<Session, String> {
2909 use rpi_harness::session::jsonl::{
2910 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2911 };
2912 use rpi_tools::FileSystem;
2913
2914 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2917 let fs: Arc<dyn FileSystem> = env.clone();
2918
2919 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2920 fs: fs.clone(),
2921 sessions_root: dir.to_string_lossy().into_owned(),
2922 clock: Arc::new(SystemClock),
2923 ids: Arc::new(DefaultIdGenerator::new()),
2924 });
2925
2926 let opts = JsonlSessionCreateOptions {
2927 id, parent_session_id: None,
2929 cwd: cwd.to_string(),
2930 metadata: None,
2931 };
2932 let storage = repo
2933 .create_typed(&opts)
2934 .await
2935 .map_err(|e| format!("create session: {e}"))?;
2936 claim_session_lock(&storage.jsonl_metadata().path)
2937 .map_err(|e| format!("create session: {e}"))?;
2938 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2940 Ok(Session::new(storage_arc, None))
2941}
2942
2943fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
2946 let path = Path::new(target);
2947 if path.is_file() {
2948 read_prompt_source(path, sources)
2949 } else {
2950 None
2951 }
2952}
2953
2954fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
2957 let text = std::fs::read_to_string(path).ok()?;
2958 let absolute = if path.is_absolute() {
2959 path.to_path_buf()
2960 } else {
2961 std::env::current_dir()
2962 .map(|cwd| cwd.join(path))
2963 .unwrap_or_else(|_| path.to_path_buf())
2964 };
2965 let source = absolute.to_string_lossy().into_owned();
2966 if !sources.contains(&source) {
2967 sources.push(source);
2968 }
2969 Some(text)
2970}
2971
2972#[cfg(test)]
2973mod runtime_session_tests;
2974
2975#[cfg(test)]
2976mod tests {
2977 use super::*;
2978
2979 #[tokio::test]
2980 async fn unauthenticated_packages_are_not_reintroduced_as_legacy_adapters() {
2981 extern "C" fn request(
2982 _: rpi_plugin_sdk::StbStringRef,
2983 _: *mut rpi_plugin_sdk::StbString,
2984 _: *mut std::ffi::c_void,
2985 ) -> i32 {
2986 1
2987 }
2988 let mut registry = rpi_extensions::ExtensionRegistry::new();
2989 for id in ["package", "legacy"] {
2990 registry.register_provider(rpi_extensions::RegisteredProvider {
2991 provider_id: id.into(),
2992 base_url: "http://localhost".into(),
2993 api_style: "openai-completions".into(),
2994 request_fn: request,
2995 stream_fn: None,
2996 plugin_free_string: rpi_plugin_sdk::callbacks::free_string,
2997 user_data: std::ptr::null_mut(),
2998 });
2999 }
3000 assert!(registry.apply_declaration_json(&serde_json::json!({"providers":[{
3001 "id":"package", "requiresAuth":true,
3002 "models":[rpi_ai::Model::new("model", "Model", rpi_ai::Api::OpenaiCompletions, "package", "http://localhost")]
3003 }]}).to_string()));
3004 let session = ExtensionSession::from_parts(
3005 Arc::new(registry.snapshot()),
3006 rpi_extensions::PluginKeepalive::empty(),
3007 vec![],
3008 None,
3009 );
3010 let providers = build_models_with_extensions_for_reload(
3011 &[],
3012 &session,
3013 tokio::runtime::Handle::current(),
3014 );
3015 assert_eq!(
3016 providers
3017 .iter()
3018 .map(|provider| provider.id())
3019 .collect::<Vec<_>>(),
3020 ["legacy"]
3021 );
3022 }
3023 use crate::args::Args;
3024
3025 #[test]
3026 fn loaded_context_paths_track_precedence_and_reload() {
3027 const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
3028 if std::env::var_os(CHILD).is_none() {
3029 let dir = tempfile::tempdir().unwrap();
3030 let output = std::process::Command::new(std::env::current_exe().unwrap())
3031 .args([
3032 "--exact",
3033 "session::tests::loaded_context_paths_track_precedence_and_reload",
3034 "--nocapture",
3035 ])
3036 .env(CHILD, "1")
3037 .env(crate::config::CONFIG_DIR_ENV, dir.path())
3038 .output()
3039 .unwrap();
3040 assert!(
3041 output.status.success(),
3042 "{}\n{}",
3043 String::from_utf8_lossy(&output.stdout),
3044 String::from_utf8_lossy(&output.stderr)
3045 );
3046 return;
3047 }
3048 tokio::runtime::Builder::new_current_thread()
3049 .enable_all()
3050 .build()
3051 .unwrap()
3052 .block_on(async {
3053 let agent = crate::config::agent_dir().unwrap();
3054 let cwd = agent.join("project");
3055 let project_config = cwd.join(".rpi");
3056 std::fs::create_dir_all(&project_config).unwrap();
3057 for (path, content) in [
3058 (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
3059 (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
3060 (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
3061 (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
3062 (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
3063 (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
3064 (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
3065 ] {
3066 std::fs::write(path, content).unwrap();
3067 }
3068 let resolved = crate::provider::resolve(
3069 Some("anthropic"),
3070 Some(crate::provider::DEFAULT_MODEL_ID),
3071 None,
3072 Some("test-key"),
3073 None,
3074 )
3075 .unwrap();
3076 let args = Args {
3077 no_session: true,
3078 no_extensions: true,
3079 no_skills: true,
3080 no_prompt_templates: true,
3081 trust_override: Some(true),
3082 ..Default::default()
3083 };
3084 let (harness, _events, mut context) =
3085 build(&resolved, &args, &cwd, true).await.unwrap();
3086 let resources = harness.get_resources().await.unwrap();
3087 let path = |p: PathBuf| p.to_string_lossy().into_owned();
3088 assert_eq!(
3089 &resources.context_files[..2],
3090 &[
3091 path(project_config.join("SYSTEM.md")),
3092 path(project_config.join("APPEND_SYSTEM.md")),
3093 ]
3094 );
3095 assert!(resources
3096 .context_files
3097 .contains(&path(agent.join("AGENTS.md"))));
3098 assert!(resources
3099 .context_files
3100 .contains(&path(cwd.join("AGENTS.md"))));
3101 assert!(!resources
3102 .context_files
3103 .contains(&path(cwd.join("CLAUDE.md"))));
3104 assert!(!resources
3105 .context_files
3106 .contains(&path(agent.join("SYSTEM.md"))));
3107 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3108 assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
3109 assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
3110
3111 std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
3114 std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
3115 std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
3116 assert!(
3117 !reload_extension_resources(&harness, &context)
3118 .await
3119 .had_errors
3120 );
3121 let resources = harness.get_resources().await.unwrap();
3122 assert_eq!(
3123 &resources.context_files[..2],
3124 &[
3125 path(agent.join("SYSTEM.md")),
3126 path(agent.join("APPEND_SYSTEM.md")),
3127 ]
3128 );
3129 assert!(resources
3130 .context_files
3131 .contains(&path(cwd.join("CLAUDE.md"))));
3132 assert!(!resources
3133 .context_files
3134 .contains(&path(project_config.join("SYSTEM.md"))));
3135
3136 let explicit = agent.join("custom-append.md");
3139 std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
3140 context.args.system_prompt = Some("EXPLICIT_BASE".into());
3141 context.args.append_system_prompt =
3142 vec![path(explicit.clone()), "literal append".into()];
3143 context.args.no_context_files = true;
3144 assert!(
3145 !reload_extension_resources(&harness, &context)
3146 .await
3147 .had_errors
3148 );
3149 assert_eq!(
3150 harness.get_resources().await.unwrap().context_files,
3151 vec![path(explicit)]
3152 );
3153
3154 context.args.system_prompt = None;
3156 context.args.append_system_prompt.clear();
3157 context.args.no_context_files = false;
3158 context.args.trust_override = Some(false);
3159 assert!(
3160 !reload_extension_resources(&harness, &context)
3161 .await
3162 .had_errors
3163 );
3164 assert!(harness
3165 .get_resources()
3166 .await
3167 .unwrap()
3168 .context_files
3169 .is_empty());
3170 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3171 assert!(
3172 !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
3173 );
3174 });
3175 }
3176
3177 #[test]
3178 fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
3179 let dir = tempfile::tempdir().unwrap();
3180 let file = dir.path().join("SYSTEM.md");
3181 let mut sources = Vec::new();
3182 assert!(read_prompt_source(&file, &mut sources).is_none());
3183 std::fs::write(&file, [0xff, 0xfe]).unwrap();
3184 assert!(read_prompt_source(&file, &mut sources).is_none());
3185 assert!(read_append_target("literal instructions", &mut sources).is_none());
3186 assert!(sources.is_empty());
3187 std::fs::write(&file, "loaded instructions").unwrap();
3188 assert_eq!(
3189 read_prompt_source(&file, &mut sources).as_deref(),
3190 Some("loaded instructions")
3191 );
3192 assert!(read_prompt_source(&file, &mut sources).is_some());
3193 assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
3194 }
3195
3196 extern "C" fn deny_test_tool_call(
3197 _event: rpi_plugin_sdk::StablePluginEvent,
3198 _data: *mut std::ffi::c_void,
3199 ) -> i32 {
3200 rpi_plugin_sdk::EVENT_HANDLER_ABORT
3201 }
3202
3203 async fn invoke_test_tool_hook(
3204 hook: &rpi_agent::BeforeToolCall,
3205 ) -> Option<rpi_agent::BeforeToolCallResult> {
3206 let message = rpi_ai::providers::faux::faux_assistant_message(
3207 "approval",
3208 rpi_ai::types::StopReason::ToolUse,
3209 );
3210 let rpi_ai::types::Content::ToolCall(call) =
3211 rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
3212 else {
3213 unreachable!()
3214 };
3215 hook(
3216 rpi_agent::BeforeToolCallContext {
3217 assistant_message: &message,
3218 tool_call: &call,
3219 args: &call.arguments,
3220 context: &rpi_agent::AgentContext::default(),
3221 },
3222 tokio_util::sync::CancellationToken::new(),
3223 )
3224 .await
3225 }
3226
3227 #[tokio::test]
3228 async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
3229 let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
3230 let hook =
3231 extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
3232 assert!(invoke_test_tool_hook(&hook).await.is_none());
3233 for name in ["first-gate", "reloaded-gate"] {
3234 let mut registry = rpi_extensions::ExtensionRegistry::new();
3235 registry.register_event_handler(
3236 name.into(),
3237 rpi_plugin_sdk::EventTag::ToolCall,
3238 deny_test_tool_call,
3239 std::ptr::null_mut(),
3240 );
3241 let fresh = ExtensionSession::from_parts(
3242 Arc::new(registry.snapshot()),
3243 rpi_extensions::PluginKeepalive::empty(),
3244 Vec::new(),
3245 None,
3246 );
3247 {
3248 let mut current = sessions.lock().unwrap();
3249 if let Some(snapshot) = current.snapshot() {
3250 snapshot.active_flag().store(false, Ordering::SeqCst);
3251 }
3252 *current = fresh;
3253 }
3254 let result = invoke_test_tool_hook(&hook).await.unwrap();
3255 assert!(result.block);
3256 assert!(result.reason.unwrap().contains(name));
3257 }
3258 *sessions.lock().unwrap() = ExtensionSession::none();
3259 assert!(invoke_test_tool_hook(&hook).await.is_none());
3260 }
3261
3262 fn scratch(name: &str) -> PathBuf {
3264 let nanos = std::time::SystemTime::now()
3265 .duration_since(std::time::UNIX_EPOCH)
3266 .unwrap()
3267 .as_nanos();
3268 let path = std::env::temp_dir().join(format!(
3269 "rpi-summary-{}-{nanos}-{name}.jsonl",
3270 std::process::id()
3271 ));
3272 path
3273 }
3274
3275 #[test]
3276 fn summary_marks_a_header_only_session_empty() {
3277 let path = scratch("empty");
3278 std::fs::write(
3279 &path,
3280 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3281 )
3282 .unwrap();
3283 let summary = summarize_session_file(&path);
3284 assert!(summary.empty, "a header-only file has nothing to resume");
3285 assert!(summary.preview.is_none());
3286 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
3287 std::fs::remove_file(&path).ok();
3288 }
3289
3290 #[test]
3291 fn summary_reads_the_first_user_prompt_and_name() {
3292 let path = scratch("content");
3293 let body = concat!(
3294 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3295 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
3296 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
3297 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
3298 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
3299 );
3300 std::fs::write(&path, body).unwrap();
3301 let summary = summarize_session_file(&path);
3302 assert!(!summary.empty);
3303 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
3304 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
3305 std::fs::remove_file(&path).ok();
3306 }
3307
3308 #[test]
3309 fn summary_extracts_a_block_style_user_prompt() {
3310 let path = scratch("blocks");
3311 let body = concat!(
3312 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3313 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
3314 );
3315 std::fs::write(&path, body).unwrap();
3316 let summary = summarize_session_file(&path);
3317 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
3318 assert_eq!(session_display_label(&summary), "line one line two");
3319 std::fs::remove_file(&path).ok();
3320 }
3321
3322 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
3323 rpi_harness::session::jsonl::JsonlSessionMetadata {
3324 id: id.to_string(),
3325 created_at: 0,
3326 cwd: ".".to_string(),
3327 path: path.to_string_lossy().into_owned(),
3328 modified_at: 0,
3329 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
3330 parent_session_id: None,
3331 legacy_parent_session_path: None,
3332 metadata: None,
3333 }
3334 }
3335
3336 #[test]
3337 fn continue_skips_newer_empty_sessions() {
3338 let empty = scratch("newest-empty");
3339 std::fs::write(
3340 &empty,
3341 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3342 )
3343 .unwrap();
3344 let content = scratch("older-content");
3345 std::fs::write(
3346 &content,
3347 concat!(
3348 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3349 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
3350 ),
3351 )
3352 .unwrap();
3353
3354 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
3355 let picked = first_resumable_session(&metas).expect("a resumable session");
3356 assert_eq!(picked.id, "old");
3357
3358 let all_empty = vec![meta_for(&empty, "empty")];
3361 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
3362
3363 std::fs::remove_file(&empty).ok();
3364 std::fs::remove_file(&content).ok();
3365 }
3366
3367 #[test]
3368 fn list_session_files_sync_walks_the_cwd_subdirectory() {
3369 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
3370 let nested = root.join("--proj--");
3371 std::fs::create_dir_all(&nested).unwrap();
3372 let file = nested.join("2026-01-01_a.jsonl");
3373 std::fs::write(&file, "{}\n").unwrap();
3374 let found = list_session_files_sync(&root);
3375 assert!(found.iter().any(|p| p == &file));
3376 std::fs::remove_dir_all(&root).ok();
3377 }
3378
3379 #[test]
3380 fn default_prompt_mentions_cwd_and_tools() {
3381 let p = default_system_prompt("/tmp/proj", false);
3382 assert!(p.contains("/tmp/proj"));
3383 assert!(p.contains("read"));
3384 assert!(p.contains("bash"));
3385 assert!(p.contains("edit"));
3386 assert!(p.contains("write"));
3387 assert!(p.contains("docs"));
3388 assert!(!p.contains("- grep"));
3389 assert!(!p.contains("- find"));
3390 assert!(!p.contains("- ls"));
3391 assert!(!p.contains("- powershell"));
3392 assert!(p.contains("Bash/POSIX syntax, including on Windows"));
3393 assert!(p.contains("do not pass PowerShell cmdlets"));
3394 }
3395
3396 #[test]
3397 fn default_prompt_instructs_language_matching() {
3398 let p = default_system_prompt("/tmp/proj", false);
3402 let lower = p.to_lowercase();
3403 assert!(
3404 lower.contains("same language") && lower.contains("detect"),
3405 "the prompt must tell the model to detect and mirror the user's language: {p}"
3406 );
3407 }
3408
3409 #[test]
3410 fn default_prompt_keeps_working_state_in_the_visible_channel() {
3411 let p = default_system_prompt("/tmp/proj", false);
3419 let lower = p.to_lowercase();
3420 assert!(
3422 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
3423 "the prompt must explain that reasoning does not survive the turn: {p}"
3424 );
3425 assert!(
3427 lower.contains("visible") && lower.contains("todo tool"),
3428 "the prompt must point at visible text and the todo tool: {p}"
3429 );
3430 assert!(
3432 lower.contains("re-stating the same plan"),
3433 "the prompt must call out plan re-statement as a bug: {p}"
3434 );
3435 assert!(
3437 lower.contains("already shown earlier"),
3438 "the prompt must discourage re-fetching known content: {p}"
3439 );
3440 }
3441
3442 #[test]
3443 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
3444 let p = default_system_prompt("/tmp/proj", true);
3451 let lower = p.to_lowercase();
3452 assert!(
3453 lower.contains("carried back"),
3454 "a replaying model must be told its reasoning survives: {p}"
3455 );
3456 assert!(
3457 !lower.contains("reasoning is not"),
3458 "the replayed variant must not claim reasoning is dropped: {p}"
3459 );
3460 assert!(
3461 lower.contains("do not re-derive"),
3462 "the replayed variant must forbid re-deriving the plan: {p}"
3463 );
3464 assert!(
3465 lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
3466 "the anti-repetition rule must survive the flip: {p}"
3467 );
3468 assert!(
3469 lower.contains("already shown earlier"),
3470 "the re-read rule must survive the flip: {p}"
3471 );
3472 }
3473
3474 #[test]
3482 fn only_read_only_tools_are_replayable() {
3483 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3484 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3485 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3486 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3487 let tools = build_tools(&context, &Args::default());
3488
3489 let replay_of = |name: &str| {
3490 tools
3491 .iter()
3492 .find(|tool| tool.tool.schema().name == name)
3493 .map(|tool| tool.replay)
3494 };
3495
3496 for read_only in ["read", "docs"] {
3497 assert_eq!(
3498 replay_of(read_only),
3499 Some(ToolReplay::Safe),
3500 "{read_only} is side-effect free, so recovery may re-run it"
3501 );
3502 }
3503 for mutating in ["bash", "edit", "write", "powershell"] {
3504 if let Some(replay) = replay_of(mutating) {
3505 assert_eq!(
3506 replay,
3507 ToolReplay::Never,
3508 "{mutating} must never be replayed: re-running it would duplicate its effect"
3509 );
3510 }
3511 }
3512 }
3513
3514 #[test]
3515 fn default_tools_include_docs_lookup() {
3516 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3517 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3518 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3519 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3520 let names: Vec<String> = build_tools(&context, &Args::default())
3521 .iter()
3522 .map(|tool| tool.tool.schema().name.clone())
3523 .collect();
3524
3525 assert!(names.contains(&"docs".to_string()));
3530 for legacy in ["grep", "find", "ls"] {
3531 assert!(
3532 !names.contains(&legacy.to_string()),
3533 "legacy tool {legacy} should not be registered by default"
3534 );
3535 }
3536 }
3537
3538 #[test]
3539 fn reload_settings_preflight_respects_trust() {
3540 struct RestoreConfigDir(Option<std::ffi::OsString>);
3541
3542 impl Drop for RestoreConfigDir {
3543 fn drop(&mut self) {
3544 match self.0.take() {
3545 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3546 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3547 }
3548 }
3549 }
3550
3551 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3552 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3553 let tmp = tempfile::tempdir().unwrap();
3554 let agent = tmp.path().join("agent");
3555 let cwd = tmp.path().join("project");
3556 std::fs::create_dir_all(&agent).unwrap();
3557 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3558 std::fs::write(agent.join("settings.json"), "{}").unwrap();
3559 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
3560 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3561 let args = Args::default();
3562
3563 let trusted = validate_settings_for_reload(&args, &cwd, true);
3564 let untrusted = validate_settings_for_reload(&args, &cwd, false);
3565 assert!(trusted
3566 .unwrap_err()
3567 .contains("could not load project settings"));
3568 assert!(untrusted.is_ok());
3569
3570 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3571 assert!(validate_settings_for_reload(&args, &cwd, false)
3572 .unwrap_err()
3573 .contains("could not load global settings"));
3574
3575 let local_only = Args {
3576 dev_local_only: true,
3577 ..Args::default()
3578 };
3579 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
3580 }
3581
3582 #[tokio::test(flavor = "current_thread")]
3583 async fn reload_preserves_live_resources_when_settings_break() {
3584 struct RestoreConfigDir(Option<std::ffi::OsString>);
3585
3586 impl Drop for RestoreConfigDir {
3587 fn drop(&mut self) {
3588 match self.0.take() {
3589 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3590 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3591 }
3592 }
3593 }
3594
3595 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3596 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3597 let tmp = tempfile::tempdir().unwrap();
3598 let agent = tmp.path().join("agent");
3599 let cwd = tmp.path().join("project");
3600 let skill_dir = tmp.path().join("configured-skills");
3601 std::fs::create_dir_all(&agent).unwrap();
3602 std::fs::create_dir_all(&cwd).unwrap();
3603 std::fs::create_dir_all(&skill_dir).unwrap();
3604 std::fs::write(
3605 skill_dir.join("SKILL.md"),
3606 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
3607 )
3608 .unwrap();
3609 std::fs::write(
3610 agent.join("settings.json"),
3611 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3612 )
3613 .unwrap();
3614 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3615
3616 let resolved = crate::provider::resolve(
3617 Some("anthropic"),
3618 Some(crate::provider::DEFAULT_MODEL_ID),
3619 None,
3620 Some("test-key"),
3621 None,
3622 )
3623 .unwrap();
3624 let args = Args {
3625 trust_override: Some(false),
3626 no_session: true,
3627 no_extensions: true,
3628 no_prompt_templates: true,
3629 no_context_files: true,
3630 system_prompt: Some("stable system prompt".into()),
3631 ..Args::default()
3632 };
3633 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3634 let before_resources = harness.get_resources().await.unwrap();
3635 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
3636 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
3637 let before_prompt = harness.get_system_prompt().await.unwrap();
3638 let before_tools: Vec<String> = harness
3639 .get_tools()
3640 .await
3641 .unwrap()
3642 .iter()
3643 .map(|tool| tool.tool.schema().name.clone())
3644 .collect();
3645 let before_bridge = context
3646 .action_bridge
3647 .lock()
3648 .unwrap()
3649 .as_ref()
3650 .unwrap()
3651 .clone();
3652
3653 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3654 let outcome = reload_extension_resources(&harness, &context).await;
3655
3656 assert!(outcome.had_warnings);
3657 assert!(outcome.summary.contains("Settings reload failed"));
3658 assert!(outcome.summary.contains("could not load global settings"));
3659 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
3660 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
3661 let after_tools: Vec<String> = harness
3662 .get_tools()
3663 .await
3664 .unwrap()
3665 .iter()
3666 .map(|tool| tool.tool.schema().name.clone())
3667 .collect();
3668 assert_eq!(after_tools, before_tools);
3669 let after_bridge = context
3670 .action_bridge
3671 .lock()
3672 .unwrap()
3673 .as_ref()
3674 .unwrap()
3675 .clone();
3676 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
3677 }
3678
3679 #[test]
3680 fn reload_preparation_rejects_settings_changed_during_derivation() {
3681 struct RestoreConfigDir(Option<std::ffi::OsString>);
3682
3683 impl Drop for RestoreConfigDir {
3684 fn drop(&mut self) {
3685 match self.0.take() {
3686 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3687 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3688 }
3689 }
3690 }
3691
3692 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3693 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3694 let tmp = tempfile::tempdir().unwrap();
3695 let agent = tmp.path().join("agent");
3696 let cwd = tmp.path().join("project");
3697 let first_skill_dir = tmp.path().join("first-skills");
3698 let second_skill_dir = tmp.path().join("second-skills");
3699 std::fs::create_dir_all(&agent).unwrap();
3700 std::fs::create_dir_all(&cwd).unwrap();
3701 std::fs::write(
3702 agent.join("settings.json"),
3703 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3704 )
3705 .unwrap();
3706 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3707 let args = Args {
3708 trust_override: Some(false),
3709 no_extensions: true,
3710 no_prompt_templates: true,
3711 ..Args::default()
3712 };
3713
3714 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3715 std::fs::write(
3716 agent.join("settings.json"),
3717 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3718 )
3719 .unwrap();
3720 });
3721
3722 let error = result
3723 .err()
3724 .expect("settings mutation must fail preparation");
3725 assert!(error.contains("settings changed while reload inputs were being prepared"));
3726 }
3727
3728 #[tokio::test(flavor = "current_thread")]
3729 async fn reload_uses_frozen_settings_inputs_after_preparation() {
3730 struct RestoreConfigDir(Option<std::ffi::OsString>);
3731
3732 impl Drop for RestoreConfigDir {
3733 fn drop(&mut self) {
3734 match self.0.take() {
3735 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3736 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3737 }
3738 }
3739 }
3740
3741 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3742 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3743 let tmp = tempfile::tempdir().unwrap();
3744 let agent = tmp.path().join("agent");
3745 let cwd = tmp.path().join("project");
3746 let skill_dir = tmp.path().join("configured-skills");
3747 std::fs::create_dir_all(&agent).unwrap();
3748 std::fs::create_dir_all(&cwd).unwrap();
3749 std::fs::create_dir_all(&skill_dir).unwrap();
3750 std::fs::write(
3751 skill_dir.join("SKILL.md"),
3752 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3753 )
3754 .unwrap();
3755 std::fs::write(
3756 agent.join("settings.json"),
3757 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3758 )
3759 .unwrap();
3760 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3761
3762 let resolved = crate::provider::resolve(
3763 Some("anthropic"),
3764 Some(crate::provider::DEFAULT_MODEL_ID),
3765 None,
3766 Some("test-key"),
3767 None,
3768 )
3769 .unwrap();
3770 let args = Args {
3771 trust_override: Some(false),
3772 no_session: true,
3773 no_extensions: true,
3774 no_prompt_templates: true,
3775 no_context_files: true,
3776 system_prompt: Some("stable system prompt".into()),
3777 ..Args::default()
3778 };
3779 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3780
3781 let outcome = reload_extension_resources_inner(&harness, &context, || {
3782 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3783 })
3784 .await;
3785
3786 assert!(!outcome.summary.contains("Settings reload failed"));
3787 assert_eq!(
3788 outcome.summary,
3789 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3790 );
3791 let resources = harness.get_resources().await.unwrap();
3792 let skills = resources.skills.as_ref().unwrap();
3793 assert_eq!(skills.len(), 1);
3794 assert_eq!(skills[0].name, "frozen-skill");
3795 }
3796
3797 #[tokio::test]
3798 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3799 let tmp = tempfile::tempdir().unwrap();
3800 let skill_path = tmp.path().join("explicit-skill.md");
3801 std::fs::write(
3802 &skill_path,
3803 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3804 )
3805 .unwrap();
3806 let prompt_path = tmp.path().join("explicit-prompt.md");
3807 std::fs::write(
3808 &prompt_path,
3809 "---\ndescription: Explicit prompt\n---\nPrompt body",
3810 )
3811 .unwrap();
3812
3813 let args = Args {
3814 skill: vec![skill_path.clone()],
3815 prompt_template: vec![prompt_path.clone()],
3816 ..Args::default()
3817 };
3818 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3819 let prompt_paths =
3820 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3821 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3822 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3823
3824 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3825 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3826 assert_eq!(skills.skills[0].name, "explicit-skill");
3827
3828 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3829 assert_eq!(
3830 prompts.prompt_templates.len(),
3831 1,
3832 "{:?}",
3833 prompts.diagnostics
3834 );
3835 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3836 }
3837
3838 #[test]
3839 fn tool_policy_applies_to_rust_and_js_active_names() {
3840 let names = vec![
3841 "read".to_string(),
3842 "ask_user_question".to_string(),
3843 "write".to_string(),
3844 ];
3845
3846 let args = Args {
3847 tools: Some(vec!["read".into(), "ask_user_question".into()]),
3848 ..Args::default()
3849 };
3850 assert_eq!(
3851 filter_active_tool_names(names.clone(), &args),
3852 vec!["read", "ask_user_question"]
3853 );
3854
3855 let args = Args {
3856 exclude_tools: Some(vec!["ask_user_question".into()]),
3857 ..Args::default()
3858 };
3859 assert_eq!(
3860 filter_active_tool_names(names.clone(), &args),
3861 vec!["read", "write"]
3862 );
3863
3864 let args = Args {
3865 no_tools: true,
3866 ..Args::default()
3867 };
3868 assert!(filter_active_tool_names(names, &args).is_empty());
3869 }
3870
3871 #[test]
3872 fn select_ephemeral_when_no_session() {
3873 let args = Args {
3874 no_session: true,
3875 ..Args::default()
3876 };
3877 let cwd = Path::new("/tmp");
3878 assert!(matches!(
3879 select_session(&args, cwd),
3880 SessionSelection::Ephemeral
3881 ));
3882 }
3883
3884 #[test]
3885 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3886 let defaults = Args::default();
3887 assert!(resolve_project_trust(
3888 &defaults,
3889 Path::new("C:/definitely-not-a-project")
3890 ));
3891
3892 let denied = Args {
3893 trust_override: Some(false),
3894 ..Args::default()
3895 };
3896 assert!(!resolve_project_trust(
3897 &denied,
3898 Path::new("C:/definitely-not-a-project")
3899 ));
3900 }
3901
3902 #[tokio::test(flavor = "current_thread")]
3903 async fn build_preserves_the_supplied_startup_project_snapshot() {
3904 struct RestoreConfigDir(Option<std::ffi::OsString>);
3905
3906 impl Drop for RestoreConfigDir {
3907 fn drop(&mut self) {
3908 match self.0.take() {
3909 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3910 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3911 }
3912 }
3913 }
3914
3915 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3916 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3917 let tmp = tempfile::tempdir().unwrap();
3918 let agent = tmp.path().join("agent");
3919 let cwd = tmp.path().join("project");
3920 std::fs::create_dir_all(&agent).unwrap();
3921 std::fs::create_dir_all(&cwd).unwrap();
3922 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3923
3924 let resolved = crate::provider::resolve(
3925 Some("anthropic"),
3926 Some(crate::provider::DEFAULT_MODEL_ID),
3927 None,
3928 Some("test-key"),
3929 None,
3930 )
3931 .unwrap();
3932 let args = Args {
3933 trust_override: Some(false),
3934 no_session: true,
3935 no_tools: true,
3936 no_extensions: true,
3937 no_skills: true,
3938 no_prompt_templates: true,
3939 no_context_files: true,
3940 system_prompt: Some("test prompt".into()),
3941 ..Args::default()
3942 };
3943
3944 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3945
3946 assert_eq!(context.cwd, cwd);
3947 assert!(context.project_trusted);
3948 }
3949
3950 #[tokio::test(flavor = "current_thread")]
3951 async fn build_applies_the_name_flag_to_the_session() {
3952 struct RestoreConfigDir(Option<std::ffi::OsString>);
3953
3954 impl Drop for RestoreConfigDir {
3955 fn drop(&mut self) {
3956 match self.0.take() {
3957 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3958 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3959 }
3960 }
3961 }
3962
3963 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3964 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3965 let tmp = tempfile::tempdir().unwrap();
3966 let agent = tmp.path().join("agent");
3967 let cwd = tmp.path().join("project");
3968 std::fs::create_dir_all(&agent).unwrap();
3969 std::fs::create_dir_all(&cwd).unwrap();
3970 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3971
3972 let resolved = crate::provider::resolve(
3973 Some("anthropic"),
3974 Some(crate::provider::DEFAULT_MODEL_ID),
3975 None,
3976 Some("test-key"),
3977 None,
3978 )
3979 .unwrap();
3980 let args = Args {
3981 trust_override: Some(false),
3982 no_tools: true,
3983 no_extensions: true,
3984 no_skills: true,
3985 no_prompt_templates: true,
3986 no_context_files: true,
3987 system_prompt: Some("test prompt".into()),
3988 name: Some("smoke name".into()),
3989 session_dir: Some(cwd.join("sessions")),
3990 ..Args::default()
3991 };
3992
3993 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3994 assert_eq!(
3995 harness.get_name().await.unwrap().as_deref(),
3996 Some("smoke name")
3997 );
3998 }
3999
4000 #[test]
4001 fn select_latest_for_continue_and_resume() {
4002 let _env = crate::config::test_support::SessionDirEnv::new(None);
4003 let args = Args {
4004 continue_session: true,
4005 ..Args::default()
4006 };
4007 let cwd = Path::new("/tmp");
4008 assert!(matches!(
4009 select_session(&args, cwd),
4010 SessionSelection::Latest { .. }
4011 ));
4012
4013 let args = Args {
4014 resume: true,
4015 ..Args::default()
4016 };
4017 assert!(matches!(
4018 select_session(&args, cwd),
4019 SessionSelection::Latest { .. }
4020 ));
4021 }
4022
4023 #[test]
4024 fn select_by_id_for_session_flag() {
4025 let _env = crate::config::test_support::SessionDirEnv::new(None);
4026 let args = Args {
4027 session: Some("01a02ece".into()),
4028 ..Args::default()
4029 };
4030 let cwd = Path::new("/tmp");
4031 assert!(matches!(
4032 select_session(&args, cwd),
4033 SessionSelection::ById { id, .. } if id == "01a02ece"
4034 ));
4035 }
4036
4037 #[test]
4038 fn select_new_with_custom_dir() {
4039 let _env = crate::config::test_support::SessionDirEnv::new(None);
4040 let args = Args {
4041 session_dir: Some(PathBuf::from("/tmp/sess")),
4042 ..Args::default()
4043 };
4044 let cwd = Path::new("/tmp");
4045 match select_session(&args, cwd) {
4046 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
4047 other => panic!("expected New, got {other:?}"),
4048 }
4049 }
4050
4051 #[test]
4052 fn select_new_default_dir() {
4053 let _env = crate::config::test_support::SessionDirEnv::new(None);
4054 let args = Args::default();
4055 let cwd = Path::new("/proj");
4056 match select_session(&args, cwd) {
4057 SessionSelection::New { dir, .. } => {
4058 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
4059 }
4060 other => panic!("expected New, got {other:?}"),
4061 }
4062 }
4063
4064 #[test]
4065 fn default_session_dir_only_uses_rpi() {
4066 let _env = crate::config::test_support::SessionDirEnv::new(None);
4067 let tmp = tempfile::tempdir().unwrap();
4068 let cwd = tmp.path();
4069 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
4070 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
4071 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
4072 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
4073 }
4074
4075 #[test]
4076 fn default_session_dir_uses_env_var() {
4077 let tmp = tempfile::tempdir().unwrap();
4078 let cwd = tmp.path();
4079 let custom = tmp.path().join("custom_sessions");
4080 let _env = crate::config::test_support::SessionDirEnv::new(Some(&custom));
4081 assert_eq!(default_session_dir(cwd), custom);
4082 }
4083
4084 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4085 async fn ephemeral_session_builds_roundtrips() {
4086 let s = ephemeral_session();
4090 let leaf = s.get_leaf_id().await;
4091 assert!(leaf.is_ok());
4092 }
4093
4094 #[test]
4112 fn host_private_env_matches_the_declared_contract() {
4113 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
4115 assert_eq!(
4116 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
4117 [
4118 "LANGFUSE_PI_PARENT_TRACE_ID",
4119 "LANGFUSE_PI_PARENT_SPAN_ID",
4120 "LANGFUSE_PI_PARENT_SESSION_ID",
4121 "LANGFUSE_PI_PARENT_DEPTH",
4122 ],
4123 "these strings are what rpi-langfuse (a different repo) matches; a \
4124 change here is a breaking cross-repo change, not a refactor"
4125 );
4126
4127 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
4130 .iter()
4131 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
4132 {
4133 assert!(
4134 rpi_tools::is_host_private_env(name),
4135 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
4136 rpi_tools::HOST_PRIVATE_ENV
4137 );
4138 }
4139
4140 for visible in [
4143 crate::args::RPI_OFFLINE_ENV,
4144 crate::config::CONFIG_DIR_ENV,
4145 "PATH",
4146 ] {
4147 assert!(
4148 !rpi_tools::is_host_private_env(visible),
4149 "{visible} describes the user's configuration and must still reach the shell"
4150 );
4151 }
4152 }
4153 #[tokio::test]
4154 async fn reload_shutdown_joins_all_handlers_even_after_veto() {
4155 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
4156 use std::sync::atomic::AtomicUsize;
4157 static CONTEXTS: rpi_plugin_sdk::handles::ToolHandles<Arc<AtomicUsize>> =
4158 rpi_plugin_sdk::handles::ToolHandles::new();
4159 struct ContextOwner(usize);
4160 impl Drop for ContextOwner {
4161 fn drop(&mut self) {
4162 drop(CONTEXTS.remove(self.0 as rpi_plugin_sdk::StepHandle));
4163 }
4164 }
4165 extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
4166 let context = CONTEXTS.get(data).expect("live test context");
4167 let calls = context.lock().clone();
4168 std::thread::sleep(std::time::Duration::from_millis(20));
4169 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
4170 rpi_plugin_sdk::EVENT_HANDLER_ABORT
4171 } else {
4172 0
4173 }
4174 }
4175 let calls = Arc::new(AtomicUsize::new(0));
4176 let context = CONTEXTS.insert(calls.clone());
4177 let _owner = ContextOwner(context as usize);
4178 let mut registry = rpi_extensions::ExtensionRegistry::new();
4179 for name in ["first", "second"] {
4180 registry.register_event_handler(name.into(), EventTag::SessionShutdown, stop, context);
4181 }
4182 super::shutdown_extensions_for_reload(
4183 Some(Arc::new(registry.snapshot())),
4184 rpi_extensions::PluginKeepalive::empty(),
4185 )
4186 .await
4187 .unwrap();
4188 assert_eq!(calls.load(Ordering::SeqCst), 2);
4189 }
4190}