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