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 {
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 }
1448 if let Some(old_b) = old_bridge {
1449 old_b.invalidate();
1450 }
1451
1452 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1454
1455 let discovered = extension_session
1457 .snapshot_arc()
1458 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1459 .unwrap_or_default();
1460
1461 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1463 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1464
1465 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1466 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1467 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1468 if !effective_args.no_skills {
1469 let js_paths: &[PathBuf] = &[];
1473 let package_paths: &[PathBuf] = &[];
1474 let dirs = append_reload_resource_paths(
1475 skill_base_dirs,
1476 &effective_args.skill,
1477 &discovered.skill_paths,
1478 js_paths,
1479 package_paths,
1480 );
1481 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1482 skills = result.skills;
1483 skill_diags = result.diagnostics;
1484 resource_diags.extend(result.resource_diagnostics);
1485 }
1486
1487 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1488 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1489 if !effective_args.no_prompt_templates {
1490 let js_paths: &[PathBuf] = &[];
1491 let package_paths: &[PathBuf] = &[];
1492 let dirs = append_reload_resource_paths(
1493 prompt_base_dirs,
1494 &effective_args.prompt_template,
1495 &discovered.prompt_paths,
1496 js_paths,
1497 package_paths,
1498 );
1499 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1500 prompt_templates = result.prompt_templates;
1501 prompt_diags = result.diagnostics;
1502 resource_diags.extend(result.resource_diagnostics);
1503 }
1504
1505 let context_files = if effective_args.no_context_files || !project_trusted {
1506 Vec::new()
1507 } else {
1508 let agent_dir = crate::config::agent_dir().ok();
1509 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1510 load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await
1511 };
1512 let context_block = format_project_context(&context_files);
1513
1514 let mut details: Vec<String> = Vec::new();
1515 if let Some(resolved) = &refreshed {
1516 warnings |= !resolved.compat_warnings.is_empty();
1517 details.extend(
1518 resolved
1519 .compat_warnings
1520 .iter()
1521 .map(|warning| format!("warning: models.json: {warning}")),
1522 );
1523 }
1524 if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
1525 warnings = true;
1526 for d in &skill_diags {
1531 details.push(format!(
1532 "warning: skill {} ({}): {}",
1533 d.path,
1534 d.code.as_str(),
1535 d.message
1536 ));
1537 }
1538 for d in &prompt_diags {
1539 details.push(format!(
1540 "warning: prompt template {} ({}): {}",
1541 d.path,
1542 d.code.as_str(),
1543 d.message
1544 ));
1545 }
1546 for d in &resource_diags {
1548 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1549 details.push(format!(
1550 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1551 c.resource_type.as_str(),
1552 c.name,
1553 c.winner_path,
1554 c.loser_path
1555 ));
1556 }
1557 }
1558 }
1559
1560 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1565 let mut context_paths = Vec::new();
1566 let base_prompt = match effective_args.system_prompt.as_deref() {
1567 Some(explicit) => explicit.to_string(),
1568 None if project_trusted => match discover_system_prompt_file(&ctx.cwd) {
1569 Some(path) => read_prompt_source(&path, &mut context_paths)
1570 .unwrap_or_else(|| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
1571 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1572 },
1573 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1574 };
1575 let mut append_texts: Vec<String> = Vec::new();
1576 for extra in &effective_args.append_system_prompt {
1577 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
1578 append_texts.push(text);
1579 }
1580 if effective_args.append_system_prompt.is_empty() {
1581 if let Some(path) = project_trusted
1582 .then(|| discover_append_system_prompt_file(&ctx.cwd))
1583 .flatten()
1584 {
1585 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
1586 append_texts.push(text);
1587 }
1588 }
1589 }
1590 context_paths.extend(
1591 context_files
1592 .iter()
1593 .map(|file| file.path.to_string_lossy().into_owned()),
1594 );
1595 let append_join = if append_texts.is_empty() {
1596 None
1597 } else {
1598 Some(append_texts.join("\n\n"))
1599 };
1600 let system_prompt = compose_system_prompt(
1601 Some(&base_prompt),
1602 &[],
1603 if context_block.is_empty() {
1604 None
1605 } else {
1606 Some(&context_block)
1607 },
1608 append_join.as_deref(),
1609 );
1610
1611 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1613 Some(snapshot) => {
1614 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1615 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1616 }
1617 None => ctx.broadcast.clone(),
1618 };
1619
1620 let resources = AgentHarnessResources {
1622 context_files: context_paths,
1623 skills: if skills.is_empty() {
1624 None
1625 } else {
1626 Some(skills.clone())
1627 },
1628 prompt_templates: if prompt_templates.is_empty() {
1629 None
1630 } else {
1631 Some(prompt_templates.clone())
1632 },
1633 };
1634 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1635 let _ = harness.set_resources(resources).await;
1636 let _ = harness.set_agent_emitter(Some(emitter)).await;
1637 let _ = harness
1638 .set_models(build_models_with_extensions_for_reload(
1639 &refreshed
1640 .as_ref()
1641 .map(|resolved| resolved.providers.clone())
1642 .unwrap_or_else(|| {
1643 ctx.providers
1644 .read()
1645 .unwrap_or_else(|error| error.into_inner())
1646 .clone()
1647 }),
1648 &extension_session,
1649 ctx.runtime.clone(),
1650 ))
1651 .await;
1652 let _ = harness
1653 .set_provider_hooks(
1654 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1655 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1656 )
1657 .await;
1658
1659 if let Some(resolved) = &refreshed {
1660 *ctx.providers
1661 .write()
1662 .unwrap_or_else(|error| error.into_inner()) = resolved.providers.clone();
1663 let catalog = crate::provider::authenticated_catalog(resolved);
1664 fresh_bridge
1665 .clone_host()
1666 .replace_model_catalog(catalog.clone());
1667 *ctx.catalog
1668 .write()
1669 .unwrap_or_else(|error| error.into_inner()) = catalog;
1670 let _ = harness.lane("main").set_model(resolved.model.clone()).await;
1673 }
1674
1675 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1679 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1680 let mut tools = build_tools(&tool_ctx, &effective_args);
1681 merge_extension_tools(
1682 &mut tools,
1683 &extension_session,
1684 &effective_args,
1685 &ctx.tool_context,
1686 );
1687 let mut active = active_tool_names(&tools, &effective_args);
1688 active = filter_active_tool_names(active, &effective_args);
1689 let _ = harness.set_tools(tools, Some(active)).await;
1690
1691 let summary = format!(
1692 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1693 extension_session.loaded_paths().len(),
1694 skills.len(),
1695 prompt_templates.len(),
1696 );
1697 ReloadOutcome {
1698 summary,
1699 had_warnings: warnings,
1700 details,
1701 had_errors: false,
1704 }
1705}
1706
1707#[derive(Clone, Debug, PartialEq, Eq)]
1708struct ReloadSettingsFields {
1709 skill_dirs: Option<Vec<String>>,
1710 prompt_dirs: Option<Vec<String>>,
1711 extension_dirs: Option<Vec<String>>,
1712}
1713
1714impl From<crate::settings::Settings> for ReloadSettingsFields {
1715 fn from(settings: crate::settings::Settings) -> Self {
1716 Self {
1717 skill_dirs: settings.skill_dirs,
1718 prompt_dirs: settings.prompt_dirs,
1719 extension_dirs: settings.extension_dirs,
1720 }
1721 }
1722}
1723
1724#[derive(Clone, Debug, PartialEq, Eq)]
1725struct ReloadSettingsSnapshot {
1726 global: ReloadSettingsFields,
1727 project: Option<ReloadSettingsFields>,
1728}
1729
1730fn reload_reads_settings(args: &Args) -> bool {
1731 !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
1732}
1733
1734fn load_reload_settings_snapshot(
1738 args: &Args,
1739 cwd: &Path,
1740 project_trusted: bool,
1741) -> Result<Option<ReloadSettingsSnapshot>, String> {
1742 if !reload_reads_settings(args) {
1743 return Ok(None);
1744 }
1745 let global = crate::settings::load_settings()
1746 .map_err(|error| format!("could not load global settings: {error}"))?
1747 .into();
1748 let project = if project_trusted {
1749 crate::settings::load_active_project_settings(cwd)
1750 .map_err(|error| format!("could not load project settings: {error}"))?
1751 .map(|(_, settings)| settings.into())
1752 } else {
1753 None
1754 };
1755 Ok(Some(ReloadSettingsSnapshot { global, project }))
1756}
1757
1758#[cfg(test)]
1761fn validate_settings_for_reload(
1762 args: &Args,
1763 cwd: &Path,
1764 project_trusted: bool,
1765) -> Result<(), String> {
1766 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1767}
1768
1769fn prepare_reload_inputs(
1770 args: &Args,
1771 cwd: &Path,
1772 project_trusted: bool,
1773) -> Result<PreparedReloadInputs, String> {
1774 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1775}
1776
1777fn prepare_reload_inputs_inner<F>(
1778 args: &Args,
1779 cwd: &Path,
1780 project_trusted: bool,
1781 before_verify: F,
1782) -> Result<PreparedReloadInputs, String>
1783where
1784 F: FnOnce(),
1785{
1786 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1787
1788 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1789 Vec::new()
1790 } else if args.project_only {
1791 project_extension_dirs(cwd)
1792 } else if project_trusted {
1793 extension_dirs(cwd)
1794 } else {
1795 global_extension_dirs()
1796 };
1797 if !args.no_extensions {
1798 extension_dirs.extend(args.extensions_dir.iter().cloned());
1799 }
1800
1801 let skill_base_dirs = if args.no_skills {
1802 Vec::new()
1803 } else if args.dev_local_only || args.project_only {
1804 project_skill_dirs(cwd)
1805 } else if project_trusted {
1806 skill_dirs(cwd)
1807 } else {
1808 global_skill_dirs()
1809 };
1810
1811 let prompt_base_dirs = if args.no_prompt_templates {
1812 Vec::new()
1813 } else if args.dev_local_only || args.project_only {
1814 project_prompt_template_dirs(cwd)
1815 } else if project_trusted {
1816 prompt_template_dirs(cwd)
1817 } else {
1818 global_prompt_template_dirs()
1819 };
1820
1821 before_verify();
1822 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1823 if settings_before != settings_after {
1824 return Err(
1825 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1826 );
1827 }
1828
1829 Ok(PreparedReloadInputs {
1830 extension_dirs,
1831 skill_base_dirs,
1832 prompt_base_dirs,
1833 })
1834}
1835
1836fn build_models_with_extensions_for_reload(
1841 providers: &[Arc<dyn Provider>],
1842 extension_session: &ExtensionSession,
1843 runtime: tokio::runtime::Handle,
1844) -> Vec<Arc<dyn Provider>> {
1845 let mut models: Vec<Arc<dyn Provider>> = providers.to_vec();
1846 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1847 for provider in pluggable {
1848 if extension_session
1852 .snapshot()
1853 .is_some_and(|snapshot| snapshot.provider_manifest(provider.id()).is_some())
1854 {
1855 continue;
1856 }
1857 if !models.iter().any(|known| known.id() == provider.id()) {
1858 models.push(provider);
1859 }
1860 }
1861 models
1862}
1863
1864fn report_deferred_renderers(session: &ExtensionSession) {
1868 let Some(snap) = session.snapshot_arc() else {
1869 return;
1870 };
1871 let all = snap.renderers();
1872 let markdown = all
1873 .iter()
1874 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1875 .count();
1876 let message = all
1877 .iter()
1878 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1879 .count();
1880 let entry = all
1881 .iter()
1882 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1883 .count();
1884 if markdown + message + entry == 0 {
1885 return;
1886 }
1887 eprintln!(
1888 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1889 markdown, message, entry
1890 );
1891}
1892
1893#[derive(Debug, thiserror::Error)]
1895pub enum BuildError {
1896 #[error("Could not create the session directory: {0}")]
1897 SessionDir(String),
1898 #[error("Could not open the saved session: {0}")]
1899 SessionOpen(String),
1900 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1901 SessionNotFound { requested: String, dir: String },
1902 #[error("Could not build the harness: {0}")]
1903 HarnessCreate(String),
1904}
1905
1906fn build_models_with_extensions(
1909 resolved: &ResolvedModel,
1910 extension_session: &ExtensionSession,
1911 runtime: tokio::runtime::Handle,
1912) -> Vec<Arc<dyn Provider>> {
1913 build_models_with_extensions_for_reload(&resolved.providers, extension_session, runtime)
1914}
1915
1916pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1919 if let Some(override_value) = args.trust_override {
1920 return override_value;
1921 }
1922 crate::config::project_trust_decision(cwd)
1923 .ok()
1924 .flatten()
1925 .unwrap_or(true)
1926}
1927
1928pub(crate) fn load_extensions(
1936 args: &Args,
1937 cwd: &Path,
1938 project_trusted: bool,
1939 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1940) -> ExtensionSession {
1941 if args.no_extensions {
1942 return ExtensionSession::none();
1943 }
1944 let mut dirs = if args.dev_local_only {
1945 Vec::new()
1946 } else if args.project_only {
1947 project_extension_dirs(cwd)
1948 } else if project_trusted {
1949 extension_dirs(cwd)
1950 } else {
1951 global_extension_dirs()
1952 };
1953 dirs.extend(args.extensions_dir.iter().cloned());
1954 load_extensions_from_dirs(args, &dirs, action_bridge)
1955}
1956
1957fn load_extensions_from_dirs(
1958 args: &Args,
1959 dirs: &[PathBuf],
1960 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1961) -> ExtensionSession {
1962 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1963 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1970}
1971
1972fn merge_extension_tools(
1978 tools: &mut Vec<HarnessTool>,
1979 session: &ExtensionSession,
1980 args: &Args,
1981 tool_context: &rpi_extensions::ToolCallContext,
1982) {
1983 let Some(snapshot) = session.snapshot() else {
1984 return;
1985 };
1986 for et in snapshot.tools() {
1987 let name = &et.tool.name;
1988 if !tool_name_allowed(name, args) {
1989 continue;
1990 }
1991 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
1992 .with_context(tool_context.clone());
1993 let harness_tool = HarnessTool::new(Arc::new(adapter));
1994 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1995 Some(slot) => *slot = harness_tool,
1996 None => tools.push(harness_tool),
1997 }
1998 }
1999}
2000
2001pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
2008 use rpi_tools::tools::bash::BashToolOptions;
2009 let default = std::env::var("RPI_BASH_TIMEOUT")
2010 .ok()
2011 .and_then(|v| v.parse::<f64>().ok())
2012 .unwrap_or(120.0);
2013 let settings = crate::settings::load_settings().unwrap_or_default();
2019 BashToolOptions {
2020 command_prefix: settings.shell_command_prefix.clone(),
2021 default_timeout: Some(default),
2022 prepare: None,
2023 }
2024}
2025
2026fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
2027 if args.no_tools {
2028 return Vec::new();
2029 }
2030 let mut all: Vec<(&'static str, HarnessTool)> = vec![
2039 (
2040 "read",
2041 HarnessTool::new(create_read_tool(
2042 ctx,
2043 Some(rpi_tools::tools::read::ReadToolOptions {
2044 image_processor: Some(Arc::new(
2045 rpi_tools::image_processing::InlineImageProcessor,
2046 )),
2047 ..Default::default()
2048 }),
2049 ))
2050 .with_replay(ToolReplay::Safe),
2051 ),
2052 (
2053 "bash",
2054 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
2055 ),
2056 ("edit", HarnessTool::new(create_edit_tool(ctx))),
2057 ("write", HarnessTool::new(create_write_tool(ctx))),
2058 (
2059 "docs",
2060 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
2061 ),
2062 ];
2063
2064 #[cfg(target_os = "windows")]
2066 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
2067
2068 if args.tools.is_none() {
2070 if let Ok(settings) = crate::settings::load_settings() {
2071 if let Some(default_tools) = &settings.default_tools {
2072 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
2073 }
2074 }
2075 }
2076
2077 if args.no_builtin_tools {
2081 all.clear();
2082 }
2083
2084 if let Some(allow) = &args.tools {
2086 all.retain(|(name, _)| allow.iter().any(|a| a == name));
2087 }
2088 if let Some(deny) = &args.exclude_tools {
2090 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
2091 }
2092
2093 all.into_iter().map(|(_, t)| t).collect()
2094}
2095
2096fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
2100 filter_active_tool_names(
2101 tools.iter().map(|tool| tool.tool.schema().name.clone()),
2102 args,
2103 )
2104}
2105
2106pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
2109 if args.no_tools {
2110 return false;
2111 }
2112 if args
2113 .tools
2114 .as_ref()
2115 .is_some_and(|allow| !allow.iter().any(|value| value == name))
2116 {
2117 return false;
2118 }
2119 if args
2120 .exclude_tools
2121 .as_ref()
2122 .is_some_and(|deny| deny.iter().any(|value| value == name))
2123 {
2124 return false;
2125 }
2126 true
2127}
2128
2129pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
2130where
2131 I: IntoIterator<Item = String>,
2132{
2133 names
2134 .into_iter()
2135 .filter(|name| tool_name_allowed(name, args))
2136 .collect()
2137}
2138
2139async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2141 match selection {
2142 SessionSelection::Ephemeral => Ok(ephemeral_session()),
2143 SessionSelection::New { dir, .. } => {
2144 std::fs::create_dir_all(dir)
2147 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2148 let session = create_jsonl_session(dir, cwd)
2149 .await
2150 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2151 Ok(session)
2152 }
2153 SessionSelection::Latest { .. }
2154 | SessionSelection::ById { .. }
2155 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2156 SessionSelection::Fork { source, dir } => fork_session_at_launch(source, dir, cwd).await,
2157 }
2158}
2159
2160async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2166 match selection {
2170 SessionSelection::Latest { dir } => {
2171 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2172 let Some(meta) = first_resumable_session(&metas) else {
2173 return Err(BuildError::SessionNotFound {
2174 requested: "the most recent session".to_string(),
2175 dir: dir.display().to_string(),
2176 });
2177 };
2178 open_session(meta, cwd).await
2179 }
2180 SessionSelection::ById { id, dir } => open_session_by_id_in_dir(id, cwd, dir)
2181 .await
2182 .map_err(|e| match e {
2183 OpenError::NotFound { requested } => BuildError::SessionNotFound {
2184 requested,
2185 dir: dir.display().to_string(),
2186 },
2187 OpenError::Other(msg) => BuildError::SessionOpen(msg),
2188 }),
2189 SessionSelection::ByExactId { id, dir } => {
2190 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2193 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
2194 return open_session(meta, cwd).await;
2195 }
2196 std::fs::create_dir_all(dir)
2197 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2198 create_jsonl_session_with_id(dir, cwd, Some(id.clone()))
2199 .await
2200 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2201 }
2202 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
2203 }
2204}
2205
2206async fn fork_session_at_launch(
2209 source: &str,
2210 dir: &Path,
2211 cwd: &str,
2212) -> Result<Session, BuildError> {
2213 use rpi_harness::session::jsonl::{
2214 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2215 };
2216 use rpi_harness::session::types::{ForkOptions, SessionStorage};
2217 use rpi_tools::FileSystem;
2218
2219 let dir = dir.to_path_buf();
2220 std::fs::create_dir_all(&dir)
2221 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2222 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2223 let fs: Arc<dyn FileSystem> = env.clone();
2224 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2225 fs: fs.clone(),
2226 sessions_root: dir.to_string_lossy().into_owned(),
2227 clock: Arc::new(SystemClock),
2228 ids: Arc::new(DefaultIdGenerator::new()),
2229 });
2230 let metas = repo
2231 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2232 .await
2233 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2234 let source_meta = metas
2235 .iter()
2236 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2237 .ok_or_else(|| BuildError::SessionNotFound {
2238 requested: format!("--fork {source}"),
2239 dir: dir.display().to_string(),
2240 })?;
2241 let fork_storage = repo
2242 .fork_typed(
2243 source_meta,
2244 &JsonlSessionCreateOptions {
2245 id: None,
2246 parent_session_id: Some(source_meta.id.clone()),
2247 cwd: cwd.to_string(),
2248 metadata: None,
2249 },
2250 &ForkOptions::default(),
2251 )
2252 .await
2253 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2254 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
2255 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2256 Ok(Session::new(storage_arc, None))
2257}
2258
2259pub enum OpenError {
2262 NotFound { requested: String },
2264 Other(String),
2266}
2267
2268impl std::fmt::Display for OpenError {
2269 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2270 match self {
2271 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2272 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
2277 }
2278 }
2279}
2280
2281pub async fn list_session_metadata(
2284 cwd: &str,
2285) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2286 let dir = default_session_dir(Path::new(cwd));
2287 list_session_metadata_in_dir(cwd, &dir).await
2288}
2289
2290pub(crate) async fn list_session_metadata_in_dir(
2291 cwd: &str,
2292 dir: &Path,
2293) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2294 use rpi_harness::session::jsonl::{
2295 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2296 };
2297 use rpi_tools::FileSystem;
2298
2299 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2300 let fs: Arc<dyn FileSystem> = env.clone();
2301 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2302 fs: fs.clone(),
2303 sessions_root: dir.to_string_lossy().into_owned(),
2304 clock: Arc::new(SystemClock),
2305 ids: Arc::new(DefaultIdGenerator::new()),
2306 });
2307 repo.list_typed(&JsonlSessionListOptions::default())
2308 .await
2309 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2310}
2311
2312#[derive(Debug, Clone, Default, PartialEq, Eq)]
2328pub struct SessionSummary {
2329 pub name: Option<String>,
2331 pub preview: Option<String>,
2333 pub empty: bool,
2335 pub bytes: u64,
2337}
2338
2339const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
2341const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
2343const SUMMARY_PREVIEW_CHARS: usize = 80;
2345
2346pub fn summarize_session_file(path: &Path) -> SessionSummary {
2350 use std::io::{Read, Seek, SeekFrom};
2351
2352 let Ok(mut file) = std::fs::File::open(path) else {
2353 return SessionSummary::default();
2354 };
2355 let Ok(size) = file.metadata().map(|m| m.len()) else {
2356 return SessionSummary::default();
2357 };
2358 if size == 0 {
2359 return SessionSummary {
2360 name: None,
2361 preview: None,
2362 empty: true,
2363 bytes: 0,
2364 };
2365 }
2366
2367 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2370 let head_len = file.read(&mut head).unwrap_or(0);
2371 head.truncate(head_len);
2372 let head = String::from_utf8_lossy(&head);
2373 let mut lines: Vec<&str> = head.lines().collect();
2374 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2375 let empty = (size as usize) <= header_len + 1;
2377 if (head_len as u64) < size && !lines.is_empty() {
2380 lines.pop();
2381 }
2382
2383 let mut summary = SessionSummary {
2384 name: None,
2385 preview: None,
2386 empty,
2387 bytes: size,
2388 };
2389 for line in lines {
2390 apply_summary_line(line, &mut summary);
2391 }
2392
2393 if size > SUMMARY_HEAD_BYTES
2397 && file
2398 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2399 .is_ok()
2400 {
2401 let mut tail = Vec::new();
2402 if file.read_to_end(&mut tail).is_ok() {
2403 let tail = String::from_utf8_lossy(&tail);
2404 let mut tail_lines = tail.lines();
2405 tail_lines.next();
2407 for line in tail_lines {
2408 if let Some(name) = name_from_line(line) {
2409 summary.name = name;
2410 }
2411 }
2412 }
2413 }
2414 summary
2415}
2416
2417fn name_from_line(line: &str) -> Option<Option<String>> {
2420 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2421 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2422 return None;
2423 }
2424 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2425 return None;
2426 }
2427 Some(match value.get("name") {
2428 Some(serde_json::Value::String(name)) => Some(name.clone()),
2429 _ => None,
2430 })
2431}
2432
2433fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2436 if let Some(name) = name_from_line(line) {
2437 summary.name = name;
2438 return;
2439 }
2440 if summary.preview.is_some() {
2441 return;
2442 }
2443 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2444 return;
2445 };
2446 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2447 return;
2448 }
2449 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2450 return;
2451 }
2452 let Some(message) = value.get("message") else {
2453 return;
2454 };
2455 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2456 return;
2457 }
2458 let text = match message.get("content") {
2459 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2460 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2461 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2462 .then(|| block.get("text").and_then(|v| v.as_str()))
2463 .flatten()
2464 }),
2465 _ => None,
2466 };
2467 let Some(text) = text else { return };
2468 let collapsed = text
2469 .lines()
2470 .map(str::trim)
2471 .filter(|line| !line.is_empty())
2472 .collect::<Vec<_>>()
2473 .join(" ");
2474 if collapsed.is_empty() {
2475 return;
2476 }
2477 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2478}
2479
2480pub fn session_display_label(summary: &SessionSummary) -> String {
2484 if let Some(name) = summary.name.as_deref().map(str::trim) {
2485 if !name.is_empty() {
2486 return name.to_string();
2487 }
2488 }
2489 match summary.preview.as_deref().map(str::trim) {
2490 Some(preview) if !preview.is_empty() => preview.to_string(),
2491 _ => "(no messages)".to_string(),
2492 }
2493}
2494
2495pub fn format_session_bytes(bytes: u64) -> String {
2498 if bytes >= 1_048_576 {
2499 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2500 } else if bytes >= 1024 {
2501 format!("{}KB", bytes / 1024)
2502 } else {
2503 format!("{bytes}B")
2504 }
2505}
2506
2507pub fn short_session_id(id: &str) -> &str {
2510 &id[..id.len().min(8)]
2511}
2512
2513pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2522 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2523 let mut visit = |path: PathBuf| {
2524 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2525 return;
2526 }
2527 let modified = std::fs::metadata(&path)
2528 .and_then(|m| m.modified())
2529 .unwrap_or(std::time::UNIX_EPOCH);
2530 files.push((path, modified));
2531 };
2532 if let Ok(entries) = std::fs::read_dir(dir) {
2533 for entry in entries.flatten() {
2534 let path = entry.path();
2535 if path.is_dir() {
2536 if let Ok(inner) = std::fs::read_dir(&path) {
2537 for child in inner.flatten() {
2538 visit(child.path());
2539 }
2540 }
2541 } else {
2542 visit(path);
2543 }
2544 }
2545 }
2546 files.sort_by(|a, b| b.1.cmp(&a.1));
2547 files.into_iter().map(|(path, _)| path).collect()
2548}
2549
2550fn first_resumable_session(
2555 metas: &[rpi_harness::session::jsonl::JsonlSessionMetadata],
2556) -> Option<&rpi_harness::session::jsonl::JsonlSessionMetadata> {
2557 metas
2558 .iter()
2559 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2560 .or_else(|| metas.first())
2561}
2562
2563pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2567 let dir = default_session_dir(Path::new(cwd));
2568 open_session_by_id_in_dir(id, cwd, &dir).await
2569}
2570
2571pub(crate) async fn open_session_by_id_in_dir(
2572 id: &str,
2573 cwd: &str,
2574 dir: &Path,
2575) -> Result<Session, OpenError> {
2576 let metas = list_session_metadata_in_dir(cwd, dir)
2577 .await
2578 .map_err(|e| OpenError::Other(e.to_string()))?;
2579 let Some(meta) = metas
2580 .iter()
2581 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2582 else {
2583 return Err(OpenError::NotFound {
2584 requested: format!("session {id}"),
2585 });
2586 };
2587 open_session(meta, cwd).await.map_err(|e| {
2588 OpenError::Other(match e {
2589 BuildError::SessionOpen(reason) => reason,
2593 other => other.to_string(),
2594 })
2595 })
2596}
2597
2598fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
2623 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
2624 tool_context.set_session_id(id);
2625}
2626
2627pub(crate) async fn set_active_session(
2638 harness: &AgentHarness,
2639 session: Session,
2640 tool_context: Option<&rpi_extensions::ToolCallContext>,
2641) -> Result<(), String> {
2642 let metadata = session.get_metadata().await.ok();
2643 harness
2644 .set_session(session)
2645 .await
2646 .map_err(|e| e.to_string())?;
2647 if let Some(metadata) = metadata {
2648 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2649 if let Some(context) = tool_context {
2650 context.set_session_id(metadata.id);
2651 }
2652 }
2653 Ok(())
2654}
2655
2656pub(crate) async fn fork_session_storage(
2663 harness: &AgentHarness,
2664 cwd: &str,
2665 dir: &Path,
2666) -> Result<Session, String> {
2667 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2668 use rpi_tools::FileSystem;
2669
2670 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2671 let fs: Arc<dyn FileSystem> = env.clone();
2672 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2673 fs,
2674 sessions_root: dir.to_string_lossy().into_owned(),
2675 clock: Arc::new(SystemClock),
2676 ids: Arc::new(DefaultIdGenerator::new()),
2677 });
2678 let id = harness.session().storage().metadata().id.clone();
2681 let metas = list_session_metadata_in_dir(cwd, dir)
2682 .await
2683 .map_err(|e| e.to_string())?;
2684 let Some(source) = metas.iter().find(|m| m.id == id) else {
2685 return Err(format!("current session {id} not found on disk"));
2686 };
2687 let fork_storage = repo
2688 .fork_typed(
2689 source,
2690 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2691 id: None,
2692 parent_session_id: Some(source.id.clone()),
2693 cwd: cwd.to_string(),
2694 metadata: None,
2695 },
2696 &rpi_harness::session::types::ForkOptions::default(),
2697 )
2698 .await
2699 .map_err(|e| e.to_string())?;
2700 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2701 Ok(Session::new(Arc::new(fork_storage), None))
2702}
2703
2704pub(crate) async fn import_session_file(
2708 source: &Path,
2709 cwd: &str,
2710 dir: &Path,
2711) -> Result<Session, String> {
2712 use rpi_harness::session::jsonl::{
2713 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions, JsonlSessionStorage,
2714 };
2715 use rpi_tools::FileSystem;
2716
2717 let dir = if dir.is_absolute() {
2718 dir.to_path_buf()
2719 } else {
2720 Path::new(cwd).join(dir)
2721 };
2722 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
2723 let staged = tempfile::NamedTempFile::new_in(&dir)
2724 .map_err(|e| e.to_string())?
2725 .into_temp_path();
2726 std::fs::copy(source, &staged).map_err(|e| format!("copy import: {e}"))?;
2727 let fs: Arc<dyn FileSystem> = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2728 let validated = JsonlSessionStorage::load(
2729 fs.clone(),
2730 &staged.to_string_lossy(),
2731 Arc::new(SystemClock),
2732 Arc::new(DefaultIdGenerator::new()),
2733 )
2734 .await
2735 .map_err(|e| format!("invalid session import: {e}"))?;
2736 let mut metadata = validated.jsonl_metadata().clone();
2737 drop(validated);
2738 if list_session_metadata_in_dir(cwd, &dir)
2739 .await
2740 .map_err(|e| e.to_string())?
2741 .iter()
2742 .any(|saved| saved.id == metadata.id)
2743 {
2744 return Err(format!(
2745 "session {} already exists in {}",
2746 metadata.id,
2747 dir.display()
2748 ));
2749 }
2750 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2751 fs,
2752 sessions_root: dir.to_string_lossy().into_owned(),
2753 clock: Arc::new(SystemClock),
2754 ids: Arc::new(DefaultIdGenerator::new()),
2755 });
2756 let destination = repo
2758 .create_typed(&JsonlSessionCreateOptions {
2759 id: Some(metadata.id.clone()),
2760 parent_session_id: metadata.parent_session_id.clone(),
2761 cwd: cwd.to_string(),
2762 metadata: metadata.metadata.clone(),
2763 })
2764 .await
2765 .map_err(|e| e.to_string())?;
2766 let path = destination.jsonl_metadata().path.clone();
2767 drop(destination);
2768 if let Err(error) = claim_session_lock(&path) {
2769 return Err(error);
2771 }
2772 if let Err(error) = staged.persist(&path) {
2773 let _ = std::fs::remove_file(&path);
2774 return Err(format!("publish import: {error}"));
2775 }
2776 metadata.path = path;
2777 let storage = repo
2778 .open_by_jsonl_metadata(&metadata)
2779 .await
2780 .map_err(|e| e.to_string())?;
2781 Ok(Session::new(Arc::new(storage), None))
2782}
2783
2784static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2786static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2789
2790fn claim_session_lock(session_path: &str) -> Result<(), String> {
2799 let lock_path = PathBuf::from(format!("{session_path}.lock"));
2800 if SESSION_LOCK_PATHS
2801 .get_or_init(|| Mutex::new(HashSet::new()))
2802 .lock()
2803 .unwrap()
2804 .contains(&lock_path)
2805 {
2806 return Ok(());
2807 }
2808 let file = std::fs::OpenOptions::new()
2809 .create(true)
2810 .write(true)
2811 .open(&lock_path)
2812 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2813 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2814 return Err(format!(
2815 "session {session_path} is already open in another rpi process"
2816 ));
2817 }
2818 SESSION_LOCKS
2819 .get_or_init(|| Mutex::new(Vec::new()))
2820 .lock()
2821 .unwrap()
2822 .push(file);
2823 SESSION_LOCK_PATHS
2824 .get_or_init(|| Mutex::new(HashSet::new()))
2825 .lock()
2826 .unwrap()
2827 .insert(lock_path);
2828 Ok(())
2829}
2830
2831async fn open_session(
2834 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2835 cwd: &str,
2836) -> Result<Session, BuildError> {
2837 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2838 use rpi_harness::session::types::SessionStorage;
2839 use rpi_tools::FileSystem;
2840
2841 let dir = default_session_dir(Path::new(cwd));
2842 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2843 let fs: Arc<dyn FileSystem> = env.clone();
2844 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2845 fs: fs.clone(),
2846 sessions_root: dir.to_string_lossy().into_owned(),
2847 clock: Arc::new(SystemClock),
2848 ids: Arc::new(DefaultIdGenerator::new()),
2849 });
2850 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2851 let storage = repo
2852 .open_by_jsonl_metadata(meta)
2853 .await
2854 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2855 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2859 Some(Path::new(&meta.path)),
2860 Path::new(&meta.cwd),
2861 Path::new(cwd),
2862 ) {
2863 eprintln!(
2864 "warning: {}",
2865 crate::session_cwd::format_missing_session_cwd_error(&issue)
2866 );
2867 }
2868 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2869 Ok(Session::new(storage_arc, None))
2870}
2871
2872fn ephemeral_session() -> Session {
2874 let storage = Arc::new(InMemorySessionStorage::new(
2875 SessionMetadata {
2876 id: "ephemeral".into(),
2877 created_at: 0,
2878 parent_session_id: None,
2879 },
2880 Arc::new(SystemClock),
2881 Arc::new(DefaultIdGenerator::new()),
2882 ));
2883 Session::new(storage, None)
2884}
2885
2886pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2897 create_jsonl_session_with_id(dir, cwd, None).await
2898}
2899
2900pub(crate) async fn create_jsonl_session_with_id(
2904 dir: &Path,
2905 cwd: &str,
2906 id: Option<String>,
2907) -> Result<Session, String> {
2908 use rpi_harness::session::jsonl::{
2909 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2910 };
2911 use rpi_tools::FileSystem;
2912
2913 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2916 let fs: Arc<dyn FileSystem> = env.clone();
2917
2918 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2919 fs: fs.clone(),
2920 sessions_root: dir.to_string_lossy().into_owned(),
2921 clock: Arc::new(SystemClock),
2922 ids: Arc::new(DefaultIdGenerator::new()),
2923 });
2924
2925 let opts = JsonlSessionCreateOptions {
2926 id, parent_session_id: None,
2928 cwd: cwd.to_string(),
2929 metadata: None,
2930 };
2931 let storage = repo
2932 .create_typed(&opts)
2933 .await
2934 .map_err(|e| format!("create session: {e}"))?;
2935 claim_session_lock(&storage.jsonl_metadata().path)
2936 .map_err(|e| format!("create session: {e}"))?;
2937 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2939 Ok(Session::new(storage_arc, None))
2940}
2941
2942fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
2945 let path = Path::new(target);
2946 if path.is_file() {
2947 read_prompt_source(path, sources)
2948 } else {
2949 None
2950 }
2951}
2952
2953fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
2956 let text = std::fs::read_to_string(path).ok()?;
2957 let absolute = if path.is_absolute() {
2958 path.to_path_buf()
2959 } else {
2960 std::env::current_dir()
2961 .map(|cwd| cwd.join(path))
2962 .unwrap_or_else(|_| path.to_path_buf())
2963 };
2964 let source = absolute.to_string_lossy().into_owned();
2965 if !sources.contains(&source) {
2966 sources.push(source);
2967 }
2968 Some(text)
2969}
2970
2971#[cfg(test)]
2972mod runtime_session_tests;
2973
2974#[cfg(test)]
2975mod tests {
2976 use super::*;
2977
2978 #[tokio::test]
2979 async fn unauthenticated_packages_are_not_reintroduced_as_legacy_adapters() {
2980 extern "C" fn request(
2981 _: rpi_plugin_sdk::StbStringRef,
2982 _: *mut rpi_plugin_sdk::StbString,
2983 _: *mut std::ffi::c_void,
2984 ) -> i32 {
2985 1
2986 }
2987 let mut registry = rpi_extensions::ExtensionRegistry::new();
2988 for id in ["package", "legacy"] {
2989 registry.register_provider(rpi_extensions::RegisteredProvider {
2990 provider_id: id.into(),
2991 base_url: "http://localhost".into(),
2992 api_style: "openai-completions".into(),
2993 request_fn: request,
2994 stream_fn: None,
2995 plugin_free_string: rpi_plugin_sdk::callbacks::free_string,
2996 user_data: std::ptr::null_mut(),
2997 });
2998 }
2999 assert!(registry.apply_declaration_json(&serde_json::json!({"providers":[{
3000 "id":"package", "requiresAuth":true,
3001 "models":[rpi_ai::Model::new("model", "Model", rpi_ai::Api::OpenaiCompletions, "package", "http://localhost")]
3002 }]}).to_string()));
3003 let session = ExtensionSession::from_parts(
3004 Arc::new(registry.snapshot()),
3005 rpi_extensions::PluginKeepalive::empty(),
3006 vec![],
3007 None,
3008 );
3009 let providers = build_models_with_extensions_for_reload(
3010 &[],
3011 &session,
3012 tokio::runtime::Handle::current(),
3013 );
3014 assert_eq!(
3015 providers
3016 .iter()
3017 .map(|provider| provider.id())
3018 .collect::<Vec<_>>(),
3019 ["legacy"]
3020 );
3021 }
3022 use crate::args::Args;
3023
3024 #[test]
3025 fn loaded_context_paths_track_precedence_and_reload() {
3026 const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
3027 if std::env::var_os(CHILD).is_none() {
3028 let dir = tempfile::tempdir().unwrap();
3029 let output = std::process::Command::new(std::env::current_exe().unwrap())
3030 .args([
3031 "--exact",
3032 "session::tests::loaded_context_paths_track_precedence_and_reload",
3033 "--nocapture",
3034 ])
3035 .env(CHILD, "1")
3036 .env(crate::config::CONFIG_DIR_ENV, dir.path())
3037 .output()
3038 .unwrap();
3039 assert!(
3040 output.status.success(),
3041 "{}\n{}",
3042 String::from_utf8_lossy(&output.stdout),
3043 String::from_utf8_lossy(&output.stderr)
3044 );
3045 return;
3046 }
3047 tokio::runtime::Builder::new_current_thread()
3048 .enable_all()
3049 .build()
3050 .unwrap()
3051 .block_on(async {
3052 let agent = crate::config::agent_dir().unwrap();
3053 let cwd = agent.join("project");
3054 let project_config = cwd.join(".rpi");
3055 std::fs::create_dir_all(&project_config).unwrap();
3056 for (path, content) in [
3057 (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
3058 (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
3059 (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
3060 (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
3061 (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
3062 (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
3063 (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
3064 ] {
3065 std::fs::write(path, content).unwrap();
3066 }
3067 let resolved = crate::provider::resolve(
3068 Some("anthropic"),
3069 Some(crate::provider::DEFAULT_MODEL_ID),
3070 None,
3071 Some("test-key"),
3072 None,
3073 )
3074 .unwrap();
3075 let args = Args {
3076 no_session: true,
3077 no_extensions: true,
3078 no_skills: true,
3079 no_prompt_templates: true,
3080 trust_override: Some(true),
3081 ..Default::default()
3082 };
3083 let (harness, _events, mut context) =
3084 build(&resolved, &args, &cwd, true).await.unwrap();
3085 let resources = harness.get_resources().await.unwrap();
3086 let path = |p: PathBuf| p.to_string_lossy().into_owned();
3087 assert_eq!(
3088 &resources.context_files[..2],
3089 &[
3090 path(project_config.join("SYSTEM.md")),
3091 path(project_config.join("APPEND_SYSTEM.md")),
3092 ]
3093 );
3094 assert!(resources
3095 .context_files
3096 .contains(&path(agent.join("AGENTS.md"))));
3097 assert!(resources
3098 .context_files
3099 .contains(&path(cwd.join("AGENTS.md"))));
3100 assert!(!resources
3101 .context_files
3102 .contains(&path(cwd.join("CLAUDE.md"))));
3103 assert!(!resources
3104 .context_files
3105 .contains(&path(agent.join("SYSTEM.md"))));
3106 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3107 assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
3108 assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
3109
3110 std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
3113 std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
3114 std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
3115 assert!(
3116 !reload_extension_resources(&harness, &context)
3117 .await
3118 .had_errors
3119 );
3120 let resources = harness.get_resources().await.unwrap();
3121 assert_eq!(
3122 &resources.context_files[..2],
3123 &[
3124 path(agent.join("SYSTEM.md")),
3125 path(agent.join("APPEND_SYSTEM.md")),
3126 ]
3127 );
3128 assert!(resources
3129 .context_files
3130 .contains(&path(cwd.join("CLAUDE.md"))));
3131 assert!(!resources
3132 .context_files
3133 .contains(&path(project_config.join("SYSTEM.md"))));
3134
3135 let explicit = agent.join("custom-append.md");
3138 std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
3139 context.args.system_prompt = Some("EXPLICIT_BASE".into());
3140 context.args.append_system_prompt =
3141 vec![path(explicit.clone()), "literal append".into()];
3142 context.args.no_context_files = true;
3143 assert!(
3144 !reload_extension_resources(&harness, &context)
3145 .await
3146 .had_errors
3147 );
3148 assert_eq!(
3149 harness.get_resources().await.unwrap().context_files,
3150 vec![path(explicit)]
3151 );
3152
3153 context.args.system_prompt = None;
3155 context.args.append_system_prompt.clear();
3156 context.args.no_context_files = false;
3157 context.args.trust_override = Some(false);
3158 assert!(
3159 !reload_extension_resources(&harness, &context)
3160 .await
3161 .had_errors
3162 );
3163 assert!(harness
3164 .get_resources()
3165 .await
3166 .unwrap()
3167 .context_files
3168 .is_empty());
3169 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3170 assert!(
3171 !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
3172 );
3173 });
3174 }
3175
3176 #[test]
3177 fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
3178 let dir = tempfile::tempdir().unwrap();
3179 let file = dir.path().join("SYSTEM.md");
3180 let mut sources = Vec::new();
3181 assert!(read_prompt_source(&file, &mut sources).is_none());
3182 std::fs::write(&file, [0xff, 0xfe]).unwrap();
3183 assert!(read_prompt_source(&file, &mut sources).is_none());
3184 assert!(read_append_target("literal instructions", &mut sources).is_none());
3185 assert!(sources.is_empty());
3186 std::fs::write(&file, "loaded instructions").unwrap();
3187 assert_eq!(
3188 read_prompt_source(&file, &mut sources).as_deref(),
3189 Some("loaded instructions")
3190 );
3191 assert!(read_prompt_source(&file, &mut sources).is_some());
3192 assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
3193 }
3194
3195 extern "C" fn deny_test_tool_call(
3196 _event: rpi_plugin_sdk::StablePluginEvent,
3197 _data: *mut std::ffi::c_void,
3198 ) -> i32 {
3199 rpi_plugin_sdk::EVENT_HANDLER_ABORT
3200 }
3201
3202 async fn invoke_test_tool_hook(
3203 hook: &rpi_agent::BeforeToolCall,
3204 ) -> Option<rpi_agent::BeforeToolCallResult> {
3205 let message = rpi_ai::providers::faux::faux_assistant_message(
3206 "approval",
3207 rpi_ai::types::StopReason::ToolUse,
3208 );
3209 let rpi_ai::types::Content::ToolCall(call) =
3210 rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
3211 else {
3212 unreachable!()
3213 };
3214 hook(
3215 rpi_agent::BeforeToolCallContext {
3216 assistant_message: &message,
3217 tool_call: &call,
3218 args: &call.arguments,
3219 context: &rpi_agent::AgentContext::default(),
3220 },
3221 tokio_util::sync::CancellationToken::new(),
3222 )
3223 .await
3224 }
3225
3226 #[tokio::test]
3227 async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
3228 let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
3229 let hook =
3230 extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
3231 assert!(invoke_test_tool_hook(&hook).await.is_none());
3232 for name in ["first-gate", "reloaded-gate"] {
3233 let mut registry = rpi_extensions::ExtensionRegistry::new();
3234 registry.register_event_handler(
3235 name.into(),
3236 rpi_plugin_sdk::EventTag::ToolCall,
3237 deny_test_tool_call,
3238 std::ptr::null_mut(),
3239 );
3240 let fresh = ExtensionSession::from_parts(
3241 Arc::new(registry.snapshot()),
3242 rpi_extensions::PluginKeepalive::empty(),
3243 Vec::new(),
3244 None,
3245 );
3246 {
3247 let mut current = sessions.lock().unwrap();
3248 if let Some(snapshot) = current.snapshot() {
3249 snapshot.active_flag().store(false, Ordering::SeqCst);
3250 }
3251 *current = fresh;
3252 }
3253 let result = invoke_test_tool_hook(&hook).await.unwrap();
3254 assert!(result.block);
3255 assert!(result.reason.unwrap().contains(name));
3256 }
3257 *sessions.lock().unwrap() = ExtensionSession::none();
3258 assert!(invoke_test_tool_hook(&hook).await.is_none());
3259 }
3260
3261 fn scratch(name: &str) -> PathBuf {
3263 let nanos = std::time::SystemTime::now()
3264 .duration_since(std::time::UNIX_EPOCH)
3265 .unwrap()
3266 .as_nanos();
3267 let path = std::env::temp_dir().join(format!(
3268 "rpi-summary-{}-{nanos}-{name}.jsonl",
3269 std::process::id()
3270 ));
3271 path
3272 }
3273
3274 #[test]
3275 fn summary_marks_a_header_only_session_empty() {
3276 let path = scratch("empty");
3277 std::fs::write(
3278 &path,
3279 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3280 )
3281 .unwrap();
3282 let summary = summarize_session_file(&path);
3283 assert!(summary.empty, "a header-only file has nothing to resume");
3284 assert!(summary.preview.is_none());
3285 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
3286 std::fs::remove_file(&path).ok();
3287 }
3288
3289 #[test]
3290 fn summary_reads_the_first_user_prompt_and_name() {
3291 let path = scratch("content");
3292 let body = concat!(
3293 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3294 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
3295 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
3296 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
3297 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
3298 );
3299 std::fs::write(&path, body).unwrap();
3300 let summary = summarize_session_file(&path);
3301 assert!(!summary.empty);
3302 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
3303 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
3304 std::fs::remove_file(&path).ok();
3305 }
3306
3307 #[test]
3308 fn summary_extracts_a_block_style_user_prompt() {
3309 let path = scratch("blocks");
3310 let body = concat!(
3311 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3312 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
3313 );
3314 std::fs::write(&path, body).unwrap();
3315 let summary = summarize_session_file(&path);
3316 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
3317 assert_eq!(session_display_label(&summary), "line one line two");
3318 std::fs::remove_file(&path).ok();
3319 }
3320
3321 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
3322 rpi_harness::session::jsonl::JsonlSessionMetadata {
3323 id: id.to_string(),
3324 created_at: 0,
3325 cwd: ".".to_string(),
3326 path: path.to_string_lossy().into_owned(),
3327 modified_at: 0,
3328 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
3329 parent_session_id: None,
3330 legacy_parent_session_path: None,
3331 metadata: None,
3332 }
3333 }
3334
3335 #[test]
3336 fn continue_skips_newer_empty_sessions() {
3337 let empty = scratch("newest-empty");
3338 std::fs::write(
3339 &empty,
3340 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3341 )
3342 .unwrap();
3343 let content = scratch("older-content");
3344 std::fs::write(
3345 &content,
3346 concat!(
3347 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3348 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
3349 ),
3350 )
3351 .unwrap();
3352
3353 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
3354 let picked = first_resumable_session(&metas).expect("a resumable session");
3355 assert_eq!(picked.id, "old");
3356
3357 let all_empty = vec![meta_for(&empty, "empty")];
3360 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
3361
3362 std::fs::remove_file(&empty).ok();
3363 std::fs::remove_file(&content).ok();
3364 }
3365
3366 #[test]
3367 fn list_session_files_sync_walks_the_cwd_subdirectory() {
3368 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
3369 let nested = root.join("--proj--");
3370 std::fs::create_dir_all(&nested).unwrap();
3371 let file = nested.join("2026-01-01_a.jsonl");
3372 std::fs::write(&file, "{}\n").unwrap();
3373 let found = list_session_files_sync(&root);
3374 assert!(found.iter().any(|p| p == &file));
3375 std::fs::remove_dir_all(&root).ok();
3376 }
3377
3378 #[test]
3379 fn default_prompt_mentions_cwd_and_tools() {
3380 let p = default_system_prompt("/tmp/proj", false);
3381 assert!(p.contains("/tmp/proj"));
3382 assert!(p.contains("read"));
3383 assert!(p.contains("bash"));
3384 assert!(p.contains("edit"));
3385 assert!(p.contains("write"));
3386 assert!(p.contains("docs"));
3387 assert!(!p.contains("- grep"));
3388 assert!(!p.contains("- find"));
3389 assert!(!p.contains("- ls"));
3390 assert!(!p.contains("- powershell"));
3391 assert!(p.contains("Bash/POSIX syntax, including on Windows"));
3392 assert!(p.contains("do not pass PowerShell cmdlets"));
3393 }
3394
3395 #[test]
3396 fn default_prompt_instructs_language_matching() {
3397 let p = default_system_prompt("/tmp/proj", false);
3401 let lower = p.to_lowercase();
3402 assert!(
3403 lower.contains("same language") && lower.contains("detect"),
3404 "the prompt must tell the model to detect and mirror the user's language: {p}"
3405 );
3406 }
3407
3408 #[test]
3409 fn default_prompt_keeps_working_state_in_the_visible_channel() {
3410 let p = default_system_prompt("/tmp/proj", false);
3418 let lower = p.to_lowercase();
3419 assert!(
3421 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
3422 "the prompt must explain that reasoning does not survive the turn: {p}"
3423 );
3424 assert!(
3426 lower.contains("visible") && lower.contains("todo tool"),
3427 "the prompt must point at visible text and the todo tool: {p}"
3428 );
3429 assert!(
3431 lower.contains("re-stating the same plan"),
3432 "the prompt must call out plan re-statement as a bug: {p}"
3433 );
3434 assert!(
3436 lower.contains("already shown earlier"),
3437 "the prompt must discourage re-fetching known content: {p}"
3438 );
3439 }
3440
3441 #[test]
3442 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
3443 let p = default_system_prompt("/tmp/proj", true);
3450 let lower = p.to_lowercase();
3451 assert!(
3452 lower.contains("carried back"),
3453 "a replaying model must be told its reasoning survives: {p}"
3454 );
3455 assert!(
3456 !lower.contains("reasoning is not"),
3457 "the replayed variant must not claim reasoning is dropped: {p}"
3458 );
3459 assert!(
3460 lower.contains("do not re-derive"),
3461 "the replayed variant must forbid re-deriving the plan: {p}"
3462 );
3463 assert!(
3464 lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
3465 "the anti-repetition rule must survive the flip: {p}"
3466 );
3467 assert!(
3468 lower.contains("already shown earlier"),
3469 "the re-read rule must survive the flip: {p}"
3470 );
3471 }
3472
3473 #[test]
3481 fn only_read_only_tools_are_replayable() {
3482 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3483 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3484 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3485 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3486 let tools = build_tools(&context, &Args::default());
3487
3488 let replay_of = |name: &str| {
3489 tools
3490 .iter()
3491 .find(|tool| tool.tool.schema().name == name)
3492 .map(|tool| tool.replay)
3493 };
3494
3495 for read_only in ["read", "docs"] {
3496 assert_eq!(
3497 replay_of(read_only),
3498 Some(ToolReplay::Safe),
3499 "{read_only} is side-effect free, so recovery may re-run it"
3500 );
3501 }
3502 for mutating in ["bash", "edit", "write", "powershell"] {
3503 if let Some(replay) = replay_of(mutating) {
3504 assert_eq!(
3505 replay,
3506 ToolReplay::Never,
3507 "{mutating} must never be replayed: re-running it would duplicate its effect"
3508 );
3509 }
3510 }
3511 }
3512
3513 #[test]
3514 fn default_tools_include_docs_lookup() {
3515 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3516 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3517 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3518 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3519 let names: Vec<String> = build_tools(&context, &Args::default())
3520 .iter()
3521 .map(|tool| tool.tool.schema().name.clone())
3522 .collect();
3523
3524 assert!(names.contains(&"docs".to_string()));
3529 for legacy in ["grep", "find", "ls"] {
3530 assert!(
3531 !names.contains(&legacy.to_string()),
3532 "legacy tool {legacy} should not be registered by default"
3533 );
3534 }
3535 }
3536
3537 #[test]
3538 fn reload_settings_preflight_respects_trust() {
3539 struct RestoreConfigDir(Option<std::ffi::OsString>);
3540
3541 impl Drop for RestoreConfigDir {
3542 fn drop(&mut self) {
3543 match self.0.take() {
3544 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3545 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3546 }
3547 }
3548 }
3549
3550 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3551 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3552 let tmp = tempfile::tempdir().unwrap();
3553 let agent = tmp.path().join("agent");
3554 let cwd = tmp.path().join("project");
3555 std::fs::create_dir_all(&agent).unwrap();
3556 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3557 std::fs::write(agent.join("settings.json"), "{}").unwrap();
3558 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
3559 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3560 let args = Args::default();
3561
3562 let trusted = validate_settings_for_reload(&args, &cwd, true);
3563 let untrusted = validate_settings_for_reload(&args, &cwd, false);
3564 assert!(trusted
3565 .unwrap_err()
3566 .contains("could not load project settings"));
3567 assert!(untrusted.is_ok());
3568
3569 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3570 assert!(validate_settings_for_reload(&args, &cwd, false)
3571 .unwrap_err()
3572 .contains("could not load global settings"));
3573
3574 let local_only = Args {
3575 dev_local_only: true,
3576 ..Args::default()
3577 };
3578 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
3579 }
3580
3581 #[tokio::test(flavor = "current_thread")]
3582 async fn reload_preserves_live_resources_when_settings_break() {
3583 struct RestoreConfigDir(Option<std::ffi::OsString>);
3584
3585 impl Drop for RestoreConfigDir {
3586 fn drop(&mut self) {
3587 match self.0.take() {
3588 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3589 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3590 }
3591 }
3592 }
3593
3594 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3595 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3596 let tmp = tempfile::tempdir().unwrap();
3597 let agent = tmp.path().join("agent");
3598 let cwd = tmp.path().join("project");
3599 let skill_dir = tmp.path().join("configured-skills");
3600 std::fs::create_dir_all(&agent).unwrap();
3601 std::fs::create_dir_all(&cwd).unwrap();
3602 std::fs::create_dir_all(&skill_dir).unwrap();
3603 std::fs::write(
3604 skill_dir.join("SKILL.md"),
3605 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
3606 )
3607 .unwrap();
3608 std::fs::write(
3609 agent.join("settings.json"),
3610 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3611 )
3612 .unwrap();
3613 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3614
3615 let resolved = crate::provider::resolve(
3616 Some("anthropic"),
3617 Some(crate::provider::DEFAULT_MODEL_ID),
3618 None,
3619 Some("test-key"),
3620 None,
3621 )
3622 .unwrap();
3623 let args = Args {
3624 trust_override: Some(false),
3625 no_session: true,
3626 no_extensions: true,
3627 no_prompt_templates: true,
3628 no_context_files: true,
3629 system_prompt: Some("stable system prompt".into()),
3630 ..Args::default()
3631 };
3632 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3633 let before_resources = harness.get_resources().await.unwrap();
3634 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
3635 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
3636 let before_prompt = harness.get_system_prompt().await.unwrap();
3637 let before_tools: Vec<String> = harness
3638 .get_tools()
3639 .await
3640 .unwrap()
3641 .iter()
3642 .map(|tool| tool.tool.schema().name.clone())
3643 .collect();
3644 let before_bridge = context
3645 .action_bridge
3646 .lock()
3647 .unwrap()
3648 .as_ref()
3649 .unwrap()
3650 .clone();
3651
3652 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3653 let outcome = reload_extension_resources(&harness, &context).await;
3654
3655 assert!(outcome.had_warnings);
3656 assert!(outcome.summary.contains("Settings reload failed"));
3657 assert!(outcome.summary.contains("could not load global settings"));
3658 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
3659 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
3660 let after_tools: Vec<String> = harness
3661 .get_tools()
3662 .await
3663 .unwrap()
3664 .iter()
3665 .map(|tool| tool.tool.schema().name.clone())
3666 .collect();
3667 assert_eq!(after_tools, before_tools);
3668 let after_bridge = context
3669 .action_bridge
3670 .lock()
3671 .unwrap()
3672 .as_ref()
3673 .unwrap()
3674 .clone();
3675 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
3676 }
3677
3678 #[test]
3679 fn reload_preparation_rejects_settings_changed_during_derivation() {
3680 struct RestoreConfigDir(Option<std::ffi::OsString>);
3681
3682 impl Drop for RestoreConfigDir {
3683 fn drop(&mut self) {
3684 match self.0.take() {
3685 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3686 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3687 }
3688 }
3689 }
3690
3691 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3692 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3693 let tmp = tempfile::tempdir().unwrap();
3694 let agent = tmp.path().join("agent");
3695 let cwd = tmp.path().join("project");
3696 let first_skill_dir = tmp.path().join("first-skills");
3697 let second_skill_dir = tmp.path().join("second-skills");
3698 std::fs::create_dir_all(&agent).unwrap();
3699 std::fs::create_dir_all(&cwd).unwrap();
3700 std::fs::write(
3701 agent.join("settings.json"),
3702 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3703 )
3704 .unwrap();
3705 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3706 let args = Args {
3707 trust_override: Some(false),
3708 no_extensions: true,
3709 no_prompt_templates: true,
3710 ..Args::default()
3711 };
3712
3713 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3714 std::fs::write(
3715 agent.join("settings.json"),
3716 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3717 )
3718 .unwrap();
3719 });
3720
3721 let error = result
3722 .err()
3723 .expect("settings mutation must fail preparation");
3724 assert!(error.contains("settings changed while reload inputs were being prepared"));
3725 }
3726
3727 #[tokio::test(flavor = "current_thread")]
3728 async fn reload_uses_frozen_settings_inputs_after_preparation() {
3729 struct RestoreConfigDir(Option<std::ffi::OsString>);
3730
3731 impl Drop for RestoreConfigDir {
3732 fn drop(&mut self) {
3733 match self.0.take() {
3734 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3735 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3736 }
3737 }
3738 }
3739
3740 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3741 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3742 let tmp = tempfile::tempdir().unwrap();
3743 let agent = tmp.path().join("agent");
3744 let cwd = tmp.path().join("project");
3745 let skill_dir = tmp.path().join("configured-skills");
3746 std::fs::create_dir_all(&agent).unwrap();
3747 std::fs::create_dir_all(&cwd).unwrap();
3748 std::fs::create_dir_all(&skill_dir).unwrap();
3749 std::fs::write(
3750 skill_dir.join("SKILL.md"),
3751 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3752 )
3753 .unwrap();
3754 std::fs::write(
3755 agent.join("settings.json"),
3756 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3757 )
3758 .unwrap();
3759 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3760
3761 let resolved = crate::provider::resolve(
3762 Some("anthropic"),
3763 Some(crate::provider::DEFAULT_MODEL_ID),
3764 None,
3765 Some("test-key"),
3766 None,
3767 )
3768 .unwrap();
3769 let args = Args {
3770 trust_override: Some(false),
3771 no_session: true,
3772 no_extensions: true,
3773 no_prompt_templates: true,
3774 no_context_files: true,
3775 system_prompt: Some("stable system prompt".into()),
3776 ..Args::default()
3777 };
3778 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3779
3780 let outcome = reload_extension_resources_inner(&harness, &context, || {
3781 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3782 })
3783 .await;
3784
3785 assert!(!outcome.summary.contains("Settings reload failed"));
3786 assert_eq!(
3787 outcome.summary,
3788 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3789 );
3790 let resources = harness.get_resources().await.unwrap();
3791 let skills = resources.skills.as_ref().unwrap();
3792 assert_eq!(skills.len(), 1);
3793 assert_eq!(skills[0].name, "frozen-skill");
3794 }
3795
3796 #[tokio::test]
3797 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3798 let tmp = tempfile::tempdir().unwrap();
3799 let skill_path = tmp.path().join("explicit-skill.md");
3800 std::fs::write(
3801 &skill_path,
3802 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3803 )
3804 .unwrap();
3805 let prompt_path = tmp.path().join("explicit-prompt.md");
3806 std::fs::write(
3807 &prompt_path,
3808 "---\ndescription: Explicit prompt\n---\nPrompt body",
3809 )
3810 .unwrap();
3811
3812 let args = Args {
3813 skill: vec![skill_path.clone()],
3814 prompt_template: vec![prompt_path.clone()],
3815 ..Args::default()
3816 };
3817 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3818 let prompt_paths =
3819 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3820 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3821 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3822
3823 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3824 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3825 assert_eq!(skills.skills[0].name, "explicit-skill");
3826
3827 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3828 assert_eq!(
3829 prompts.prompt_templates.len(),
3830 1,
3831 "{:?}",
3832 prompts.diagnostics
3833 );
3834 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3835 }
3836
3837 #[test]
3838 fn tool_policy_applies_to_rust_and_js_active_names() {
3839 let names = vec![
3840 "read".to_string(),
3841 "ask_user_question".to_string(),
3842 "write".to_string(),
3843 ];
3844
3845 let args = Args {
3846 tools: Some(vec!["read".into(), "ask_user_question".into()]),
3847 ..Args::default()
3848 };
3849 assert_eq!(
3850 filter_active_tool_names(names.clone(), &args),
3851 vec!["read", "ask_user_question"]
3852 );
3853
3854 let args = Args {
3855 exclude_tools: Some(vec!["ask_user_question".into()]),
3856 ..Args::default()
3857 };
3858 assert_eq!(
3859 filter_active_tool_names(names.clone(), &args),
3860 vec!["read", "write"]
3861 );
3862
3863 let args = Args {
3864 no_tools: true,
3865 ..Args::default()
3866 };
3867 assert!(filter_active_tool_names(names, &args).is_empty());
3868 }
3869
3870 #[test]
3871 fn select_ephemeral_when_no_session() {
3872 let args = Args {
3873 no_session: true,
3874 ..Args::default()
3875 };
3876 let cwd = Path::new("/tmp");
3877 assert!(matches!(
3878 select_session(&args, cwd),
3879 SessionSelection::Ephemeral
3880 ));
3881 }
3882
3883 #[test]
3884 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3885 let defaults = Args::default();
3886 assert!(resolve_project_trust(
3887 &defaults,
3888 Path::new("C:/definitely-not-a-project")
3889 ));
3890
3891 let denied = Args {
3892 trust_override: Some(false),
3893 ..Args::default()
3894 };
3895 assert!(!resolve_project_trust(
3896 &denied,
3897 Path::new("C:/definitely-not-a-project")
3898 ));
3899 }
3900
3901 #[tokio::test(flavor = "current_thread")]
3902 async fn build_preserves_the_supplied_startup_project_snapshot() {
3903 struct RestoreConfigDir(Option<std::ffi::OsString>);
3904
3905 impl Drop for RestoreConfigDir {
3906 fn drop(&mut self) {
3907 match self.0.take() {
3908 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3909 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3910 }
3911 }
3912 }
3913
3914 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3915 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3916 let tmp = tempfile::tempdir().unwrap();
3917 let agent = tmp.path().join("agent");
3918 let cwd = tmp.path().join("project");
3919 std::fs::create_dir_all(&agent).unwrap();
3920 std::fs::create_dir_all(&cwd).unwrap();
3921 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3922
3923 let resolved = crate::provider::resolve(
3924 Some("anthropic"),
3925 Some(crate::provider::DEFAULT_MODEL_ID),
3926 None,
3927 Some("test-key"),
3928 None,
3929 )
3930 .unwrap();
3931 let args = Args {
3932 trust_override: Some(false),
3933 no_session: true,
3934 no_tools: true,
3935 no_extensions: true,
3936 no_skills: true,
3937 no_prompt_templates: true,
3938 no_context_files: true,
3939 system_prompt: Some("test prompt".into()),
3940 ..Args::default()
3941 };
3942
3943 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3944
3945 assert_eq!(context.cwd, cwd);
3946 assert!(context.project_trusted);
3947 }
3948
3949 #[tokio::test(flavor = "current_thread")]
3950 async fn build_applies_the_name_flag_to_the_session() {
3951 struct RestoreConfigDir(Option<std::ffi::OsString>);
3952
3953 impl Drop for RestoreConfigDir {
3954 fn drop(&mut self) {
3955 match self.0.take() {
3956 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3957 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3958 }
3959 }
3960 }
3961
3962 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3963 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3964 let tmp = tempfile::tempdir().unwrap();
3965 let agent = tmp.path().join("agent");
3966 let cwd = tmp.path().join("project");
3967 std::fs::create_dir_all(&agent).unwrap();
3968 std::fs::create_dir_all(&cwd).unwrap();
3969 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3970
3971 let resolved = crate::provider::resolve(
3972 Some("anthropic"),
3973 Some(crate::provider::DEFAULT_MODEL_ID),
3974 None,
3975 Some("test-key"),
3976 None,
3977 )
3978 .unwrap();
3979 let args = Args {
3980 trust_override: Some(false),
3981 no_tools: true,
3982 no_extensions: true,
3983 no_skills: true,
3984 no_prompt_templates: true,
3985 no_context_files: true,
3986 system_prompt: Some("test prompt".into()),
3987 name: Some("smoke name".into()),
3988 session_dir: Some(cwd.join("sessions")),
3989 ..Args::default()
3990 };
3991
3992 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3993 assert_eq!(
3994 harness.get_name().await.unwrap().as_deref(),
3995 Some("smoke name")
3996 );
3997 }
3998
3999 #[test]
4000 fn select_latest_for_continue_and_resume() {
4001 let _env = crate::config::test_support::SessionDirEnv::new(None);
4002 let args = Args {
4003 continue_session: true,
4004 ..Args::default()
4005 };
4006 let cwd = Path::new("/tmp");
4007 assert!(matches!(
4008 select_session(&args, cwd),
4009 SessionSelection::Latest { .. }
4010 ));
4011
4012 let args = Args {
4013 resume: true,
4014 ..Args::default()
4015 };
4016 assert!(matches!(
4017 select_session(&args, cwd),
4018 SessionSelection::Latest { .. }
4019 ));
4020 }
4021
4022 #[test]
4023 fn select_by_id_for_session_flag() {
4024 let _env = crate::config::test_support::SessionDirEnv::new(None);
4025 let args = Args {
4026 session: Some("01a02ece".into()),
4027 ..Args::default()
4028 };
4029 let cwd = Path::new("/tmp");
4030 assert!(matches!(
4031 select_session(&args, cwd),
4032 SessionSelection::ById { id, .. } if id == "01a02ece"
4033 ));
4034 }
4035
4036 #[test]
4037 fn select_new_with_custom_dir() {
4038 let _env = crate::config::test_support::SessionDirEnv::new(None);
4039 let args = Args {
4040 session_dir: Some(PathBuf::from("/tmp/sess")),
4041 ..Args::default()
4042 };
4043 let cwd = Path::new("/tmp");
4044 match select_session(&args, cwd) {
4045 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
4046 other => panic!("expected New, got {other:?}"),
4047 }
4048 }
4049
4050 #[test]
4051 fn select_new_default_dir() {
4052 let _env = crate::config::test_support::SessionDirEnv::new(None);
4053 let args = Args::default();
4054 let cwd = Path::new("/proj");
4055 match select_session(&args, cwd) {
4056 SessionSelection::New { dir, .. } => {
4057 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
4058 }
4059 other => panic!("expected New, got {other:?}"),
4060 }
4061 }
4062
4063 #[test]
4064 fn default_session_dir_only_uses_rpi() {
4065 let _env = crate::config::test_support::SessionDirEnv::new(None);
4066 let tmp = tempfile::tempdir().unwrap();
4067 let cwd = tmp.path();
4068 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
4069 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
4070 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
4071 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
4072 }
4073
4074 #[test]
4075 fn default_session_dir_uses_env_var() {
4076 let tmp = tempfile::tempdir().unwrap();
4077 let cwd = tmp.path();
4078 let custom = tmp.path().join("custom_sessions");
4079 let _env = crate::config::test_support::SessionDirEnv::new(Some(&custom));
4080 assert_eq!(default_session_dir(cwd), custom);
4081 }
4082
4083 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4084 async fn ephemeral_session_builds_roundtrips() {
4085 let s = ephemeral_session();
4089 let leaf = s.get_leaf_id().await;
4090 assert!(leaf.is_ok());
4091 }
4092
4093 #[test]
4111 fn host_private_env_matches_the_declared_contract() {
4112 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
4114 assert_eq!(
4115 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
4116 [
4117 "LANGFUSE_PI_PARENT_TRACE_ID",
4118 "LANGFUSE_PI_PARENT_SPAN_ID",
4119 "LANGFUSE_PI_PARENT_SESSION_ID",
4120 "LANGFUSE_PI_PARENT_DEPTH",
4121 ],
4122 "these strings are what rpi-langfuse (a different repo) matches; a \
4123 change here is a breaking cross-repo change, not a refactor"
4124 );
4125
4126 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
4129 .iter()
4130 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
4131 {
4132 assert!(
4133 rpi_tools::is_host_private_env(name),
4134 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
4135 rpi_tools::HOST_PRIVATE_ENV
4136 );
4137 }
4138
4139 for visible in [
4142 crate::args::RPI_OFFLINE_ENV,
4143 crate::config::CONFIG_DIR_ENV,
4144 "PATH",
4145 ] {
4146 assert!(
4147 !rpi_tools::is_host_private_env(visible),
4148 "{visible} describes the user's configuration and must still reach the shell"
4149 );
4150 }
4151 }
4152 #[tokio::test]
4153 async fn reload_shutdown_joins_all_handlers_even_after_veto() {
4154 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
4155 use std::sync::atomic::AtomicUsize;
4156 static CONTEXTS: rpi_plugin_sdk::handles::ToolHandles<Arc<AtomicUsize>> =
4157 rpi_plugin_sdk::handles::ToolHandles::new();
4158 struct ContextOwner(usize);
4159 impl Drop for ContextOwner {
4160 fn drop(&mut self) {
4161 drop(CONTEXTS.remove(self.0 as rpi_plugin_sdk::StepHandle));
4162 }
4163 }
4164 extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
4165 let context = CONTEXTS.get(data).expect("live test context");
4166 let calls = context.lock().clone();
4167 std::thread::sleep(std::time::Duration::from_millis(20));
4168 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
4169 rpi_plugin_sdk::EVENT_HANDLER_ABORT
4170 } else {
4171 0
4172 }
4173 }
4174 let calls = Arc::new(AtomicUsize::new(0));
4175 let context = CONTEXTS.insert(calls.clone());
4176 let _owner = ContextOwner(context as usize);
4177 let mut registry = rpi_extensions::ExtensionRegistry::new();
4178 for name in ["first", "second"] {
4179 registry.register_event_handler(name.into(), EventTag::SessionShutdown, stop, context);
4180 }
4181 super::shutdown_extensions_for_reload(
4182 Some(Arc::new(registry.snapshot())),
4183 rpi_extensions::PluginKeepalive::empty(),
4184 )
4185 .await
4186 .unwrap();
4187 assert_eq!(calls.load(Ordering::SeqCst), 2);
4188 }
4189}