1use std::collections::HashSet;
34use std::path::{Path, PathBuf};
35use std::sync::atomic::Ordering;
36use std::sync::{Arc, Mutex, OnceLock};
37
38use rpi_ai::Provider;
39use rpi_harness::agent_harness::AgentHarness;
40use rpi_harness::context_files::{format_project_context, load_project_context_files};
41use rpi_harness::session::memory::{InMemorySessionStorage, SystemClock};
42use rpi_harness::session::session::DefaultIdGenerator;
43use rpi_harness::session::types::SessionMetadata;
44use rpi_harness::session::Session;
45use rpi_harness::system_prompt::compose_system_prompt;
46use rpi_harness::types::{
47 AgentHarnessOptions, AgentHarnessResources, AgentHarnessStreamOptions, DrivingMode,
48 HarnessTool, HarnessToolExecution, ToolReplay,
49};
50use rpi_tools::{
51 create_bash_tool, create_edit_tool, create_powershell_tool, create_read_tool,
52 create_write_tool, ExecutionToolContext, MutationQueueRegistry, OsExecutionEnv,
53};
54
55use crate::args::Args;
56use crate::docs_tool::create_docs_tool;
57use crate::provider::ResolvedModel;
58use crate::resource_dirs::{
59 discover_append_system_prompt_file, discover_system_prompt_file, extension_dirs,
60 global_extension_dirs, global_prompt_template_dirs, global_skill_dirs,
61 load_prompt_templates_with_precedence, load_skills_with_precedence,
62 project_prompt_template_dirs, project_skill_dirs, prompt_template_dirs, skill_dirs,
63};
64use rpi_extensions::{
65 emit_resources_discover, ExtensionEmitter, ExtensionSession, NullDiagnostics,
66 PluginDiagnostics, PluginToolAdapter, TeeEmitter,
67};
68
69pub const BUILTIN_TOOL_NAMES: &[&str] = &["read", "bash", "edit", "write", "docs"];
71
72pub fn default_system_prompt(cwd: &str, prior_reasoning_replayed: bool) -> String {
75 let working_state_rule = if prior_reasoning_replayed {
81 "- Keep a SHORT working plan visible (a few bullets, or the todo tool) and update it as you go.
82 Your own reasoning is carried back to you on the next turn, so do not re-derive it or restate
83 the whole plan: continue from the last unfinished step, and when a step lands, say which one.
84 Re-listing the same plan without acting on it is a bug, not progress."
85 } else {
86 "- Keep your working state in your VISIBLE replies, not only in reasoning. Reasoning is not
87 carried into your next turn: only the text you write and the tool output you produce come
88 back. Before each batch of tool calls, write one short line naming the task you are on and
89 what remains. When you finish a step, say which one is done. If you keep the plan only in
90 your head you will re-derive it from scratch every turn."
91 };
92
93 format!(
94 "You are an expert coding assistant operating inside rpi, a coding agent harness. \
95You help users by reading files, executing commands, editing code, and writing new files.
96
97Available tools:
98- read — Read file contents
99- bash — Execute shell commands
100- edit — Find/replace edits to existing files
101- write — Create or overwrite files
102- docs — Look up rpi usage, extension, package, and compatibility documentation
103
104Guidelines:
105- Reply in the SAME language the user is writing in. Detect the language of the user's
106 latest message (and, when it is too short to be sure, the working language of the
107 conversation so far) and use it for your prose replies. Mirror it unless the user
108 explicitly asks for a different language; only fall back to English when the
109 user's language cannot be determined.
110- Be concise in your responses
111- Show file paths clearly when working with files
112- Prefer the smallest change that solves the problem
113{working_state_rule}
114- Track multi-step work with the todo tool instead of re-listing the plan in prose: add the
115 steps once, then mark them done as you go. Re-stating the same plan without acting on it is
116 a bug, not progress.
117- Read only what you need. If a file or search result was already shown earlier in this
118 conversation, use it instead of fetching it again.
119- When unsure about rpi commands, extensions, Pi package compatibility, or .rpi configuration, consult the project documentation before guessing
120
121Current working directory: {cwd}"
122 )
123}
124
125#[derive(Debug, Clone)]
132pub enum SessionSelection {
133 Ephemeral,
135 New { dir: PathBuf, name: Option<String> },
137 Latest,
139 ById { id: String },
142 ByExactId { id: String },
144 Fork { source: String },
147}
148
149pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
151 if args.no_session {
152 return SessionSelection::Ephemeral;
153 }
154 if args.continue_session || args.resume {
155 return SessionSelection::Latest;
157 }
158 if let Some(s) = &args.fork {
159 return SessionSelection::Fork { source: s.clone() };
160 }
161 if let Some(s) = &args.session_id {
162 return SessionSelection::ByExactId { id: s.clone() };
163 }
164 if let Some(s) = &args.session {
165 return SessionSelection::ById { id: s.clone() };
166 }
167 let dir = args
170 .session_dir
171 .clone()
172 .or_else(|| {
173 crate::settings::load_settings()
174 .ok()
175 .and_then(|settings| settings.session_dir)
176 })
177 .unwrap_or_else(|| default_session_dir(cwd));
178 SessionSelection::New {
179 dir,
180 name: args.name.clone(),
181 }
182}
183
184pub fn default_session_dir(cwd: &Path) -> PathBuf {
186 cwd.join(".rpi").join("sessions")
187}
188
189pub async fn build(
207 resolved: &ResolvedModel,
208 args: &Args,
209 cwd: &Path,
210 project_trusted: bool,
211) -> Result<
212 (
213 AgentHarness,
214 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
215 ReloadContext,
216 ),
217 BuildError,
218> {
219 let cwd_str = cwd.to_string_lossy().to_string();
220 let tool_context = rpi_extensions::ToolCallContext::new(cwd_str.clone());
225 if !project_trusted && args.verbose {
226 eprintln!(
227 "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)"
228 );
229 }
230 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
249 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
250 })?;
251 let catalog = crate::provider::available_catalog(resolved);
252 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
253 catalog.clone(),
254 cwd.to_path_buf(),
255 runtime.clone(),
256 args.unknown_flags.clone(),
257 );
258 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = Arc::new(action_host);
259 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
269 let ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
275 let ext_status_mailbox = rpi_extensions::ExtensionStatusMailbox::new();
279 let editor_text_mailbox = rpi_extensions::EditorTextMailbox::new();
284 let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
285 runtime.clone(),
286 host_arc,
287 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
288 ui_dialog_mailbox.clone(),
289 ext_status_mailbox.clone(),
290 editor_text_mailbox.clone(),
291 );
292
293 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
295 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
296 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
297 let _registry = Arc::new(MutationQueueRegistry::new());
298 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
301
302 let tools = build_tools(&ctx, args);
303 let mut tools = tools;
304
305 let extension_session = if args.no_extensions {
313 ExtensionSession::none()
314 } else {
315 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
316 };
317 if args.verbose {
318 if let Some(s) = extension_session.summary() {
319 eprintln!("extensions: {s}");
320 }
321 report_deferred_renderers(&extension_session);
322 }
323 merge_extension_tools(&mut tools, &extension_session, args, &tool_context);
324 let active = filter_active_tool_names(active_tool_names(&tools, args), args);
325
326 let selection = select_session(args, cwd);
328 let session = build_session(&selection, &cwd_str).await?;
329 if let Ok(metadata) = session.get_metadata().await {
333 tool_context.set_session_id(metadata.id);
334 }
335 if let Some(requested) = args.name.as_deref() {
341 let name = requested.trim();
342 let name = if name.is_empty() { None } else { Some(name) };
343 if let Err(error) = session.set_name(name).await {
344 if args.verbose {
345 eprintln!("warning: could not set session name: {error}");
346 }
347 }
348 }
349 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
359 let base_prompt = match args.system_prompt.as_deref() {
360 Some(explicit) => explicit.to_string(),
361 None if project_trusted => match discover_system_prompt_file(cwd) {
362 Some(path) => std::fs::read_to_string(&path)
363 .unwrap_or_else(|_| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
364 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
365 },
366 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
367 };
368
369 let mut append_texts: Vec<String> = Vec::new();
376 for extra in &args.append_system_prompt {
377 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
378 append_texts.push(text);
379 }
380 if args.append_system_prompt.is_empty() {
381 if let Some(path) = project_trusted
382 .then(|| discover_append_system_prompt_file(cwd))
383 .flatten()
384 {
385 if let Ok(text) = std::fs::read_to_string(&path) {
386 append_texts.push(text);
387 }
388 }
389 }
390 let append_join = if append_texts.is_empty() {
391 None
392 } else {
393 Some(append_texts.join("\n\n"))
394 };
395
396 let agent_dir = crate::config::agent_dir().ok();
408
409 let discovered = extension_session
425 .snapshot_arc()
426 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
427 .unwrap_or_default();
428
429 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
430 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
431 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
432 if !args.no_skills {
433 let mut dirs = if args.dev_local_only {
434 project_skill_dirs(cwd)
435 } else if project_trusted {
436 skill_dirs(cwd)
437 } else {
438 global_skill_dirs()
439 };
440 dirs.extend(args.skill.iter().cloned());
441 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
442 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
443 skills = result.skills;
444 skill_diags = result.diagnostics;
445 resource_diags.extend(result.resource_diagnostics);
446 }
447
448 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
449 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
450 if !args.no_prompt_templates {
451 let mut dirs = if args.dev_local_only {
452 project_prompt_template_dirs(cwd)
453 } else if project_trusted {
454 prompt_template_dirs(cwd)
455 } else {
456 global_prompt_template_dirs()
457 };
458 dirs.extend(args.prompt_template.iter().cloned());
459 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
460 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
461 prompt_templates = result.prompt_templates;
462 prompt_diags = result.diagnostics;
463 resource_diags.extend(result.resource_diagnostics);
464 }
465
466 let context_block = if args.no_context_files || !project_trusted {
467 String::new()
468 } else {
469 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
474 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
475 format_project_context(&files)
476 };
477
478 if args.verbose {
480 for d in &skill_diags {
481 eprintln!(
482 "warning: skill {} ({}): {}",
483 d.path,
484 d.code.as_str(),
485 d.message
486 );
487 }
488 for d in &prompt_diags {
489 eprintln!(
490 "warning: prompt template {} ({}): {}",
491 d.path,
492 d.code.as_str(),
493 d.message
494 );
495 }
496 for d in &resource_diags {
498 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
499 eprintln!(
500 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
501 c.resource_type.as_str(),
502 c.name,
503 c.winner_path,
504 c.loser_path
505 );
506 }
507 }
508 }
509
510 let system_prompt = compose_system_prompt(
519 Some(&base_prompt),
520 &[], if context_block.is_empty() {
522 None
523 } else {
524 Some(&context_block)
525 },
526 append_join.as_deref(),
527 );
528
529 if args.debug_system_prompt {
538 eprintln!("=== --debug-system-prompt ===");
539 let base_src = if args.system_prompt.is_some() {
540 "--system-prompt"
541 } else if discover_system_prompt_file(cwd).is_some() {
542 "SYSTEM.md"
543 } else {
544 "default"
545 };
546 eprintln!("[base source: {base_src}]");
547 eprintln!("--- base ---\n{base_prompt}");
548 if let Some(append) = append_join.as_deref() {
549 eprintln!("--- append ---\n{append}");
550 } else {
551 eprintln!("--- append: (none) ---");
552 }
553 if context_block.is_empty() {
554 eprintln!("--- context: (none) ---");
555 } else {
556 eprintln!("--- context ---{context_block}");
557 }
558 let visible_skills = skills
559 .iter()
560 .filter(|s| s.disable_model_invocation != Some(true))
561 .count();
562 eprintln!(
563 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
564 skills.len(),
565 visible_skills,
566 skills.len() - visible_skills
567 );
568 for s in &skills {
569 let hidden = if s.disable_model_invocation == Some(true) {
570 " [hidden]"
571 } else {
572 ""
573 };
574 eprintln!(" {}{hidden} — {}", s.name, s.description);
575 }
576 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
577 for t in &prompt_templates {
578 eprintln!(" /{}", t.name);
579 }
580 eprintln!(
584 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
585 discovered.skill_paths.len(),
586 discovered.prompt_paths.len(),
587 discovered.theme_paths.len(),
588 );
589 for p in &discovered.skill_paths {
590 eprintln!(" skill: {p}");
591 }
592 for p in &discovered.prompt_paths {
593 eprintln!(" prompt: {p}");
594 }
595 eprintln!(
596 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
597 );
598 eprintln!("=== end --debug-system-prompt ===");
599 }
600
601 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
606 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
607 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
611
612 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
621 Some(snapshot) => {
622 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
623 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
624 }
625 None => broadcast_emitter,
626 };
627
628 let saved_settings = crate::settings::load_settings().unwrap_or_default();
632
633 if let Some(proxy) = &saved_settings.http_proxy {
638 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
639 if std::env::var_os(key).is_none() {
640 std::env::set_var(key, proxy);
641 }
642 }
643 }
644
645 let options = AgentHarnessOptions {
646 model: resolved.model.clone(),
647 thinking_level: resolved.thinking_level,
648 active_tool_names: active,
649 tools,
650 system_prompt: Some(system_prompt),
651 resources: AgentHarnessResources {
652 skills: if skills.is_empty() {
653 None
654 } else {
655 Some(skills)
656 },
657 prompt_templates: if prompt_templates.is_empty() {
658 None
659 } else {
660 Some(prompt_templates)
661 },
662 },
663 allow_existing_session: matches!(
666 selection,
667 SessionSelection::Latest
668 | SessionSelection::ById { .. }
669 | SessionSelection::ByExactId { .. }
670 | SessionSelection::Fork { .. }
671 ),
672 stream_options: AgentHarnessStreamOptions {
673 timeout: args.timeout,
674 ..Default::default()
675 },
676 retry: saved_settings.retry_policy(),
681 compaction: saved_settings.compaction_settings(),
682 steering_mode: saved_settings.steering_mode(),
683 follow_up_mode: saved_settings.follow_up_mode(),
684 tool_execution: HarnessToolExecution::default(),
685 drive: DrivingMode::default(),
686 session,
687 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
695 to_provider_messages: None,
696 entry_projectors: Default::default(),
697 agent_emitter: Some(emitter),
698 before_tool_call: None,
704 after_tool_call: None,
705 transform_context: None,
706 entry_transforms: Vec::new(),
707 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
713 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
714 };
715
716 let harness = match AgentHarness::create(options).await {
717 Ok(h) => {
718 crate::extensions_actions::HarnessActionHost::set_harness(
721 &harness_cell,
722 Arc::new(h.clone()),
723 );
724 h
725 }
726 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
727 };
728
729 {
737 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
738 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
739 Ok(reports) => {
740 for (lane, report) in reports {
741 if report.is_clean() {
742 continue;
743 }
744 if report.is_corrupt() {
745 eprintln!(
746 "warning: session lane '{lane}' is corrupt ({} open operations); \
747 refusing to auto-repair — inspect the session file",
748 report.open_operations.len()
749 );
750 continue;
751 }
752 for decision in runtime.reconcile(&report) {
753 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
754 if args.verbose {
755 eprintln!(
756 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
757 );
758 }
759 }
760 }
761 runtime.emit_recovery_events(harness.events(), &report);
762 }
763 }
764 Err(e) => {
765 if args.verbose {
766 eprintln!("warning: session recovery sweep failed: {e}");
767 }
768 }
769 }
770 }
771
772 let reload_context = ReloadContext {
779 extension_session: Arc::new(Mutex::new(extension_session)),
780 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
781 tool_context,
782 catalog,
783 gateway: resolved.provider.clone(),
784 runtime: runtime.clone(),
785 cwd: cwd.to_path_buf(),
786 project_trusted,
787 args: args.clone(),
788 resolved_model: resolved.model.clone(),
789 broadcast: broadcast_for_context,
790 mailbox: reload_mailbox,
791 ui_dialog: ui_dialog_mailbox,
792 ext_status: ext_status_mailbox,
793 editor_text: editor_text_mailbox,
794 dev_extension: None,
795 };
796
797 Ok((harness, event_rx, reload_context))
798}
799
800pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
845
846pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
851
852#[derive(Clone)]
860pub struct ReloadContext {
861 pub extension_session: ExtensionSessionCell,
863 pub tool_context: rpi_extensions::ToolCallContext,
868 pub action_bridge: ActionBridgeCell,
870 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
873 pub editor_text: rpi_extensions::EditorTextMailbox,
880 pub catalog: Vec<rpi_ai::Model>,
885 pub gateway: Arc<dyn Provider>,
888 pub runtime: tokio::runtime::Handle,
892 pub cwd: PathBuf,
894 pub project_trusted: bool,
898 pub args: Args,
904 pub resolved_model: rpi_ai::Model,
907 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
913 pub mailbox: rpi_extensions::ReloadMailbox,
920 pub ui_dialog: rpi_extensions::UiDialogMailbox,
926 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
930}
931
932pub async fn dispatch_session_event_async(
947 reload_context: &ReloadContext,
948 tag: rpi_plugin_sdk::EventTag,
949) -> Option<String> {
950 let (snapshot, keepalive) = {
956 let session = reload_context.extension_session.lock().unwrap();
957 (session.snapshot_arc(), session.keepalive())
958 };
959 match snapshot {
960 Some(snapshot) => {
961 let _keepalive = keepalive;
962 rpi_extensions::dispatch_lifecycle_event(&snapshot, tag).await
963 }
964 None => None,
965 }
966}
967
968pub struct ReloadOutcome {
971 pub summary: String,
974 pub had_warnings: bool,
976 pub details: Vec<String>,
981 pub had_errors: bool,
985}
986
987struct PreparedReloadInputs {
988 extension_dirs: Vec<PathBuf>,
989 skill_base_dirs: Vec<PathBuf>,
990 prompt_base_dirs: Vec<PathBuf>,
991}
992
993fn append_reload_resource_paths(
998 mut paths: Vec<PathBuf>,
999 explicit: &[PathBuf],
1000 discovered: &[String],
1001 js_paths: &[PathBuf],
1002 package_paths: &[PathBuf],
1003) -> Vec<PathBuf> {
1004 paths.extend(explicit.iter().cloned());
1005 paths.extend(discovered.iter().map(PathBuf::from));
1006 paths.extend(js_paths.iter().cloned());
1007 paths.extend(package_paths.iter().cloned());
1008 paths
1009}
1010
1011pub async fn reload_extension_resources(
1023 harness: &AgentHarness,
1024 ctx: &ReloadContext,
1025) -> ReloadOutcome {
1026 reload_extension_resources_inner(harness, ctx, || {}).await
1027}
1028
1029async fn reload_extension_resources_inner<F>(
1030 harness: &AgentHarness,
1031 ctx: &ReloadContext,
1032 after_prepare: F,
1033) -> ReloadOutcome
1034where
1035 F: FnOnce() + Send,
1036{
1037 let mut effective_args = ctx.args.clone();
1042 if let Some(dev) = &ctx.dev_extension {
1043 if let Err(error) = dev
1044 .rebuild()
1045 .and_then(|_| dev.apply_to_args(&mut effective_args))
1046 {
1047 return ReloadOutcome {
1048 summary: format!(
1049 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1050 dev.package_name()
1051 ),
1052 had_warnings: true,
1053 details: Vec::new(),
1054 had_errors: true,
1055 };
1056 }
1057 }
1058 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1059 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1060 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1061 Ok(prepared) => prepared,
1062 Err(error) => {
1063 return ReloadOutcome {
1064 summary: format!(
1065 "Settings reload failed: {error}. Keeping the currently loaded resources."
1066 ),
1067 had_warnings: true,
1068 details: Vec::new(),
1069 had_errors: true,
1070 };
1071 }
1072 };
1073 after_prepare();
1074 let PreparedReloadInputs {
1075 extension_dirs,
1076 skill_base_dirs,
1077 prompt_base_dirs,
1078 } = prepared;
1079 let mut warnings = false;
1080
1081 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1088 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1089 Some(b) => b.clone_host(),
1090 None => {
1091 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1094 ctx.catalog.clone(),
1095 ctx.cwd.clone(),
1096 ctx.runtime.clone(),
1097 ctx.args.unknown_flags.clone(),
1098 );
1099 crate::extensions_actions::HarnessActionHost::set_harness(
1100 &_cell,
1101 Arc::new(harness.clone()),
1102 );
1103 Arc::new(action_host)
1104 }
1105 };
1106
1107 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1108 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1109 ctx.runtime.clone(),
1110 host,
1111 reload_cb,
1112 ctx.ui_dialog.clone(),
1113 ctx.ext_status.clone(),
1114 ctx.editor_text.clone(),
1115 );
1116
1117 let extension_session = if effective_args.no_extensions {
1118 rpi_extensions::ExtensionSession::none()
1119 } else {
1120 load_extensions_from_dirs(
1121 &effective_args,
1122 &extension_dirs,
1123 Some(Arc::clone(&fresh_bridge)),
1124 )
1125 };
1126 if extension_session.is_empty() && !effective_args.no_extensions {
1127 }
1130 if effective_args.verbose {
1131 if let Some(s) = extension_session.summary() {
1132 eprintln!("reload: {s}");
1133 }
1134 report_deferred_renderers(&extension_session);
1135 }
1136
1137 {
1151 let mut session_guard = ctx.extension_session.lock().unwrap();
1152 let old_session = std::mem::replace(
1153 &mut *session_guard,
1154 rpi_extensions::ExtensionSession::none(),
1155 );
1156 if let Some(old_snap) = old_session.snapshot_arc() {
1157 old_snap.active_flag().store(false, Ordering::SeqCst);
1161 }
1162 }
1166 if let Some(old_b) = old_bridge {
1167 old_b.invalidate();
1168 }
1169
1170 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1173 *ctx.extension_session.lock().unwrap() = extension_session.clone();
1174
1175 let discovered = extension_session
1177 .snapshot_arc()
1178 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1179 .unwrap_or_default();
1180
1181 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1183 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1184
1185 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1186 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1187 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1188 if !effective_args.no_skills {
1189 let js_paths: &[PathBuf] = &[];
1193 let package_paths: &[PathBuf] = &[];
1194 let dirs = append_reload_resource_paths(
1195 skill_base_dirs,
1196 &effective_args.skill,
1197 &discovered.skill_paths,
1198 js_paths,
1199 &package_paths,
1200 );
1201 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1202 skills = result.skills;
1203 skill_diags = result.diagnostics;
1204 resource_diags.extend(result.resource_diagnostics);
1205 }
1206
1207 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1208 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1209 if !effective_args.no_prompt_templates {
1210 let js_paths: &[PathBuf] = &[];
1211 let package_paths: &[PathBuf] = &[];
1212 let dirs = append_reload_resource_paths(
1213 prompt_base_dirs,
1214 &effective_args.prompt_template,
1215 &discovered.prompt_paths,
1216 js_paths,
1217 &package_paths,
1218 );
1219 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1220 prompt_templates = result.prompt_templates;
1221 prompt_diags = result.diagnostics;
1222 resource_diags.extend(result.resource_diagnostics);
1223 }
1224
1225 let context_block = if effective_args.no_context_files {
1226 String::new()
1227 } else {
1228 let agent_dir = crate::config::agent_dir().ok();
1229 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1230 let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
1231 format_project_context(&files)
1232 };
1233
1234 let mut details: Vec<String> = Vec::new();
1235 if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
1236 warnings = true;
1237 for d in &skill_diags {
1242 details.push(format!(
1243 "warning: skill {} ({}): {}",
1244 d.path,
1245 d.code.as_str(),
1246 d.message
1247 ));
1248 }
1249 for d in &prompt_diags {
1250 details.push(format!(
1251 "warning: prompt template {} ({}): {}",
1252 d.path,
1253 d.code.as_str(),
1254 d.message
1255 ));
1256 }
1257 for d in &resource_diags {
1259 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1260 details.push(format!(
1261 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1262 c.resource_type.as_str(),
1263 c.name,
1264 c.winner_path,
1265 c.loser_path
1266 ));
1267 }
1268 }
1269 }
1270
1271 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1276 let base_prompt = match effective_args.system_prompt.as_deref() {
1277 Some(explicit) => explicit.to_string(),
1278 None => match discover_system_prompt_file(&ctx.cwd) {
1279 Some(path) => std::fs::read_to_string(&path)
1280 .unwrap_or_else(|_| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
1281 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1282 },
1283 };
1284 let mut append_texts: Vec<String> = Vec::new();
1285 for extra in &effective_args.append_system_prompt {
1286 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
1287 append_texts.push(text);
1288 }
1289 if effective_args.append_system_prompt.is_empty() {
1290 if let Some(path) = discover_append_system_prompt_file(&ctx.cwd) {
1291 if let Ok(text) = std::fs::read_to_string(&path) {
1292 append_texts.push(text);
1293 }
1294 }
1295 }
1296 let append_join = if append_texts.is_empty() {
1297 None
1298 } else {
1299 Some(append_texts.join("\n\n"))
1300 };
1301 let system_prompt = compose_system_prompt(
1302 Some(&base_prompt),
1303 &[],
1304 if context_block.is_empty() {
1305 None
1306 } else {
1307 Some(&context_block)
1308 },
1309 append_join.as_deref(),
1310 );
1311
1312 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1314 Some(snapshot) => {
1315 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1316 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1317 }
1318 None => ctx.broadcast.clone(),
1319 };
1320
1321 let resources = AgentHarnessResources {
1323 skills: if skills.is_empty() {
1324 None
1325 } else {
1326 Some(skills.clone())
1327 },
1328 prompt_templates: if prompt_templates.is_empty() {
1329 None
1330 } else {
1331 Some(prompt_templates.clone())
1332 },
1333 };
1334 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1335 let _ = harness.set_resources(resources).await;
1336 let _ = harness.set_agent_emitter(Some(emitter)).await;
1337 let _ = harness
1338 .set_models(build_models_with_extensions_for_reload(
1339 &ctx.gateway,
1340 &extension_session,
1341 ctx.runtime.clone(),
1342 ))
1343 .await;
1344 let _ = harness
1345 .set_provider_hooks(
1346 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1347 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1348 )
1349 .await;
1350
1351 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1355 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1356 let mut tools = build_tools(&tool_ctx, &effective_args);
1357 merge_extension_tools(
1358 &mut tools,
1359 &extension_session,
1360 &effective_args,
1361 &ctx.tool_context,
1362 );
1363 let mut active = active_tool_names(&tools, &effective_args);
1364 active = filter_active_tool_names(active, &effective_args);
1365 let _ = harness.set_tools(tools, Some(active)).await;
1366
1367 let summary = format!(
1368 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1369 extension_session.loaded_paths().len(),
1370 skills.len(),
1371 prompt_templates.len(),
1372 );
1373 ReloadOutcome {
1374 summary,
1375 had_warnings: warnings,
1376 details,
1377 had_errors: false,
1380 }
1381}
1382
1383#[derive(Clone, Debug, PartialEq, Eq)]
1384struct ReloadSettingsFields {
1385 skill_dirs: Option<Vec<String>>,
1386 prompt_dirs: Option<Vec<String>>,
1387 extension_dirs: Option<Vec<String>>,
1388}
1389
1390impl From<crate::settings::Settings> for ReloadSettingsFields {
1391 fn from(settings: crate::settings::Settings) -> Self {
1392 Self {
1393 skill_dirs: settings.skill_dirs,
1394 prompt_dirs: settings.prompt_dirs,
1395 extension_dirs: settings.extension_dirs,
1396 }
1397 }
1398}
1399
1400#[derive(Clone, Debug, PartialEq, Eq)]
1401struct ReloadSettingsSnapshot {
1402 global: ReloadSettingsFields,
1403 project: Option<ReloadSettingsFields>,
1404}
1405
1406fn reload_reads_settings(args: &Args) -> bool {
1407 !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
1408}
1409
1410fn load_reload_settings_snapshot(
1414 args: &Args,
1415 cwd: &Path,
1416 project_trusted: bool,
1417) -> Result<Option<ReloadSettingsSnapshot>, String> {
1418 if !reload_reads_settings(args) {
1419 return Ok(None);
1420 }
1421 let global = crate::settings::load_settings()
1422 .map_err(|error| format!("could not load global settings: {error}"))?
1423 .into();
1424 let project = if project_trusted {
1425 crate::settings::load_active_project_settings(cwd)
1426 .map_err(|error| format!("could not load project settings: {error}"))?
1427 .map(|(_, settings)| settings.into())
1428 } else {
1429 None
1430 };
1431 Ok(Some(ReloadSettingsSnapshot { global, project }))
1432}
1433
1434#[cfg(test)]
1437fn validate_settings_for_reload(
1438 args: &Args,
1439 cwd: &Path,
1440 project_trusted: bool,
1441) -> Result<(), String> {
1442 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1443}
1444
1445fn prepare_reload_inputs(
1446 args: &Args,
1447 cwd: &Path,
1448 project_trusted: bool,
1449) -> Result<PreparedReloadInputs, String> {
1450 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1451}
1452
1453fn prepare_reload_inputs_inner<F>(
1454 args: &Args,
1455 cwd: &Path,
1456 project_trusted: bool,
1457 before_verify: F,
1458) -> Result<PreparedReloadInputs, String>
1459where
1460 F: FnOnce(),
1461{
1462 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1463
1464 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1465 Vec::new()
1466 } else if project_trusted {
1467 extension_dirs(cwd)
1468 } else {
1469 global_extension_dirs()
1470 };
1471 if !args.no_extensions {
1472 extension_dirs.extend(args.extensions_dir.iter().cloned());
1473 }
1474
1475 let skill_base_dirs = if args.no_skills {
1476 Vec::new()
1477 } else if args.dev_local_only {
1478 project_skill_dirs(cwd)
1479 } else if project_trusted {
1480 skill_dirs(cwd)
1481 } else {
1482 global_skill_dirs()
1483 };
1484
1485 let prompt_base_dirs = if args.no_prompt_templates {
1486 Vec::new()
1487 } else if args.dev_local_only {
1488 project_prompt_template_dirs(cwd)
1489 } else if project_trusted {
1490 prompt_template_dirs(cwd)
1491 } else {
1492 global_prompt_template_dirs()
1493 };
1494
1495 before_verify();
1496 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1497 if settings_before != settings_after {
1498 return Err(
1499 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1500 );
1501 }
1502
1503 Ok(PreparedReloadInputs {
1504 extension_dirs,
1505 skill_base_dirs,
1506 prompt_base_dirs,
1507 })
1508}
1509
1510fn build_models_with_extensions_for_reload(
1515 gateway: &Arc<dyn Provider>,
1516 extension_session: &ExtensionSession,
1517 runtime: tokio::runtime::Handle,
1518) -> Vec<Arc<dyn Provider>> {
1519 let mut models: Vec<Arc<dyn Provider>> = vec![gateway.clone()];
1520 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1521 models.extend(pluggable);
1522 models
1523}
1524
1525fn report_deferred_renderers(session: &ExtensionSession) {
1529 let Some(snap) = session.snapshot_arc() else {
1530 return;
1531 };
1532 let all = snap.renderers();
1533 let markdown = all
1534 .iter()
1535 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1536 .count();
1537 let message = all
1538 .iter()
1539 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1540 .count();
1541 let entry = all
1542 .iter()
1543 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1544 .count();
1545 if markdown + message + entry == 0 {
1546 return;
1547 }
1548 eprintln!(
1549 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1550 markdown, message, entry
1551 );
1552}
1553
1554#[derive(Debug, thiserror::Error)]
1556pub enum BuildError {
1557 #[error("Could not create the session directory: {0}")]
1558 SessionDir(String),
1559 #[error("Could not open the saved session: {0}")]
1560 SessionOpen(String),
1561 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1562 SessionNotFound { requested: String, dir: String },
1563 #[error("Could not build the harness: {0}")]
1564 HarnessCreate(String),
1565}
1566
1567fn build_models_with_extensions(
1577 resolved: &ResolvedModel,
1578 extension_session: &ExtensionSession,
1579 runtime: tokio::runtime::Handle,
1580) -> Vec<Arc<dyn Provider>> {
1581 let mut models: Vec<Arc<dyn Provider>> = vec![resolved.provider.clone() as Arc<dyn Provider>];
1582 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1583 models.extend(pluggable);
1584 models
1585}
1586
1587pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1590 if let Some(override_value) = args.trust_override {
1591 return override_value;
1592 }
1593 crate::config::project_trust_decision(cwd)
1594 .ok()
1595 .flatten()
1596 .unwrap_or(true)
1597}
1598
1599fn load_extensions(
1607 args: &Args,
1608 cwd: &Path,
1609 project_trusted: bool,
1610 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1611) -> ExtensionSession {
1612 let mut dirs = if args.dev_local_only {
1613 Vec::new()
1614 } else if project_trusted {
1615 extension_dirs(cwd)
1616 } else {
1617 global_extension_dirs()
1618 };
1619 dirs.extend(args.extensions_dir.iter().cloned());
1620 load_extensions_from_dirs(args, &dirs, action_bridge)
1621}
1622
1623fn load_extensions_from_dirs(
1624 args: &Args,
1625 dirs: &[PathBuf],
1626 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1627) -> ExtensionSession {
1628 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1629 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1636}
1637
1638fn merge_extension_tools(
1644 tools: &mut Vec<HarnessTool>,
1645 session: &ExtensionSession,
1646 args: &Args,
1647 tool_context: &rpi_extensions::ToolCallContext,
1648) {
1649 let Some(snapshot) = session.snapshot() else {
1650 return;
1651 };
1652 for et in snapshot.tools() {
1653 let name = &et.tool.name;
1654 if !tool_name_allowed(name, args) {
1655 continue;
1656 }
1657 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
1658 .with_context(tool_context.clone());
1659 let harness_tool = HarnessTool::new(Arc::new(adapter));
1660 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1661 Some(slot) => *slot = harness_tool,
1662 None => tools.push(harness_tool),
1663 }
1664 }
1665}
1666
1667pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1674 use rpi_tools::tools::bash::BashToolOptions;
1675 let default = std::env::var("RPI_BASH_TIMEOUT")
1676 .ok()
1677 .and_then(|v| v.parse::<f64>().ok())
1678 .unwrap_or(120.0);
1679 let settings = crate::settings::load_settings().unwrap_or_default();
1685 BashToolOptions {
1686 command_prefix: settings.shell_command_prefix.clone(),
1687 default_timeout: Some(default),
1688 }
1689}
1690
1691fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1692 if args.no_tools {
1693 return Vec::new();
1694 }
1695 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1704 (
1705 "read",
1706 HarnessTool::new(create_read_tool(ctx, None)).with_replay(ToolReplay::Safe),
1707 ),
1708 (
1709 "bash",
1710 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1711 ),
1712 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1713 ("write", HarnessTool::new(create_write_tool(ctx))),
1714 (
1715 "docs",
1716 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
1717 ),
1718 ];
1719
1720 #[cfg(target_os = "windows")]
1722 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
1723
1724 if args.tools.is_none() {
1726 if let Ok(settings) = crate::settings::load_settings() {
1727 if let Some(default_tools) = &settings.default_tools {
1728 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
1729 }
1730 }
1731 }
1732
1733 if args.no_builtin_tools {
1737 all.clear();
1738 }
1739
1740 if let Some(allow) = &args.tools {
1742 all.retain(|(name, _)| allow.iter().any(|a| a == name));
1743 }
1744 if let Some(deny) = &args.exclude_tools {
1746 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1747 }
1748
1749 all.into_iter().map(|(_, t)| t).collect()
1750}
1751
1752fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1756 filter_active_tool_names(
1757 tools.iter().map(|tool| tool.tool.schema().name.clone()),
1758 args,
1759 )
1760}
1761
1762pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
1765 if args.no_tools {
1766 return false;
1767 }
1768 if args
1769 .tools
1770 .as_ref()
1771 .is_some_and(|allow| !allow.iter().any(|value| value == name))
1772 {
1773 return false;
1774 }
1775 if args
1776 .exclude_tools
1777 .as_ref()
1778 .is_some_and(|deny| deny.iter().any(|value| value == name))
1779 {
1780 return false;
1781 }
1782 true
1783}
1784
1785pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
1786where
1787 I: IntoIterator<Item = String>,
1788{
1789 names
1790 .into_iter()
1791 .filter(|name| tool_name_allowed(name, args))
1792 .collect()
1793}
1794
1795async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1797 match selection {
1798 SessionSelection::Ephemeral => Ok(ephemeral_session()),
1799 SessionSelection::New { dir, .. } => {
1800 std::fs::create_dir_all(dir)
1803 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1804 let session = create_jsonl_session(dir, cwd)
1805 .await
1806 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1807 Ok(session)
1808 }
1809 SessionSelection::Latest
1810 | SessionSelection::ById { .. }
1811 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
1812 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
1813 }
1814}
1815
1816async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1822 match selection {
1826 SessionSelection::Latest => {
1827 let metas = list_session_metadata(cwd).await?;
1828 let Some(meta) = first_resumable_session(&metas) else {
1829 return Err(BuildError::SessionNotFound {
1830 requested: "the most recent session".to_string(),
1831 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1832 });
1833 };
1834 open_session(meta, cwd).await
1835 }
1836 SessionSelection::ById { id } => open_session_by_id(id, cwd).await.map_err(|e| match e {
1837 OpenError::NotFound { requested } => BuildError::SessionNotFound {
1838 requested,
1839 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1840 },
1841 OpenError::Other(msg) => BuildError::SessionOpen(msg),
1842 }),
1843 SessionSelection::ByExactId { id } => {
1844 let metas = list_session_metadata(cwd).await?;
1847 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
1848 return open_session(meta, cwd).await;
1849 }
1850 let dir = default_session_dir(Path::new(cwd));
1851 std::fs::create_dir_all(&dir)
1852 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1853 create_jsonl_session_with_id(&dir, cwd, Some(id.clone()))
1854 .await
1855 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
1856 }
1857 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
1858 }
1859}
1860
1861async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
1864 use rpi_harness::session::jsonl::{
1865 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1866 };
1867 use rpi_harness::session::types::{ForkOptions, SessionStorage};
1868 use rpi_tools::FileSystem;
1869
1870 let dir = default_session_dir(Path::new(cwd));
1871 std::fs::create_dir_all(&dir)
1872 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1873 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1874 let fs: Arc<dyn FileSystem> = env.clone();
1875 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1876 fs: fs.clone(),
1877 sessions_root: dir.to_string_lossy().into_owned(),
1878 clock: Arc::new(SystemClock),
1879 ids: Arc::new(DefaultIdGenerator::new()),
1880 });
1881 let metas = repo
1882 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
1883 .await
1884 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
1885 let source_meta = metas
1886 .iter()
1887 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
1888 .ok_or_else(|| BuildError::SessionNotFound {
1889 requested: format!("--fork {source}"),
1890 dir: dir.display().to_string(),
1891 })?;
1892 let fork_storage = repo
1893 .fork_typed(
1894 source_meta,
1895 &JsonlSessionCreateOptions {
1896 id: None,
1897 parent_session_id: Some(source_meta.id.clone()),
1898 cwd: cwd.to_string(),
1899 metadata: None,
1900 },
1901 &ForkOptions::default(),
1902 )
1903 .await
1904 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
1905 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
1906 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
1907 Ok(Session::new(storage_arc, None))
1908}
1909
1910pub enum OpenError {
1913 NotFound { requested: String },
1915 Other(String),
1917}
1918
1919impl std::fmt::Display for OpenError {
1920 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1921 match self {
1922 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
1923 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
1928 }
1929 }
1930}
1931
1932pub async fn list_session_metadata(
1935 cwd: &str,
1936) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
1937 use rpi_harness::session::jsonl::{
1938 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1939 };
1940 use rpi_tools::FileSystem;
1941
1942 let dir = default_session_dir(Path::new(cwd));
1943 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1944 let fs: Arc<dyn FileSystem> = env.clone();
1945 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1946 fs: fs.clone(),
1947 sessions_root: dir.to_string_lossy().into_owned(),
1948 clock: Arc::new(SystemClock),
1949 ids: Arc::new(DefaultIdGenerator::new()),
1950 });
1951 repo.list_typed(&JsonlSessionListOptions::default())
1952 .await
1953 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
1954}
1955
1956#[derive(Debug, Clone, Default, PartialEq, Eq)]
1972pub struct SessionSummary {
1973 pub name: Option<String>,
1975 pub preview: Option<String>,
1977 pub empty: bool,
1979 pub bytes: u64,
1981}
1982
1983const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
1985const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
1987const SUMMARY_PREVIEW_CHARS: usize = 80;
1989
1990pub fn summarize_session_file(path: &Path) -> SessionSummary {
1994 use std::io::{Read, Seek, SeekFrom};
1995
1996 let Ok(mut file) = std::fs::File::open(path) else {
1997 return SessionSummary::default();
1998 };
1999 let Ok(size) = file.metadata().map(|m| m.len()) else {
2000 return SessionSummary::default();
2001 };
2002 if size == 0 {
2003 return SessionSummary {
2004 name: None,
2005 preview: None,
2006 empty: true,
2007 bytes: 0,
2008 };
2009 }
2010
2011 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2014 let head_len = file.read(&mut head).unwrap_or(0);
2015 head.truncate(head_len);
2016 let head = String::from_utf8_lossy(&head);
2017 let mut lines: Vec<&str> = head.lines().collect();
2018 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2019 let empty = (size as usize) <= header_len + 1;
2021 if (head_len as u64) < size && !lines.is_empty() {
2024 lines.pop();
2025 }
2026
2027 let mut summary = SessionSummary {
2028 name: None,
2029 preview: None,
2030 empty,
2031 bytes: size,
2032 };
2033 for line in lines {
2034 apply_summary_line(line, &mut summary);
2035 }
2036
2037 if size > SUMMARY_HEAD_BYTES
2041 && file
2042 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2043 .is_ok()
2044 {
2045 let mut tail = Vec::new();
2046 if file.read_to_end(&mut tail).is_ok() {
2047 let tail = String::from_utf8_lossy(&tail);
2048 let mut tail_lines = tail.lines();
2049 tail_lines.next();
2051 for line in tail_lines {
2052 if let Some(name) = name_from_line(line) {
2053 summary.name = name;
2054 }
2055 }
2056 }
2057 }
2058 summary
2059}
2060
2061fn name_from_line(line: &str) -> Option<Option<String>> {
2064 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2065 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2066 return None;
2067 }
2068 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2069 return None;
2070 }
2071 Some(match value.get("name") {
2072 Some(serde_json::Value::String(name)) => Some(name.clone()),
2073 _ => None,
2074 })
2075}
2076
2077fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2080 if let Some(name) = name_from_line(line) {
2081 summary.name = name;
2082 return;
2083 }
2084 if summary.preview.is_some() {
2085 return;
2086 }
2087 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2088 return;
2089 };
2090 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2091 return;
2092 }
2093 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2094 return;
2095 }
2096 let Some(message) = value.get("message") else {
2097 return;
2098 };
2099 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2100 return;
2101 }
2102 let text = match message.get("content") {
2103 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2104 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2105 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2106 .then(|| block.get("text").and_then(|v| v.as_str()))
2107 .flatten()
2108 }),
2109 _ => None,
2110 };
2111 let Some(text) = text else { return };
2112 let collapsed = text
2113 .lines()
2114 .map(str::trim)
2115 .filter(|line| !line.is_empty())
2116 .collect::<Vec<_>>()
2117 .join(" ");
2118 if collapsed.is_empty() {
2119 return;
2120 }
2121 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2122}
2123
2124pub fn session_display_label(summary: &SessionSummary) -> String {
2128 if let Some(name) = summary.name.as_deref().map(str::trim) {
2129 if !name.is_empty() {
2130 return name.to_string();
2131 }
2132 }
2133 match summary.preview.as_deref().map(str::trim) {
2134 Some(preview) if !preview.is_empty() => preview.to_string(),
2135 _ => "(no messages)".to_string(),
2136 }
2137}
2138
2139pub fn format_session_bytes(bytes: u64) -> String {
2142 if bytes >= 1_048_576 {
2143 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2144 } else if bytes >= 1024 {
2145 format!("{}KB", bytes / 1024)
2146 } else {
2147 format!("{bytes}B")
2148 }
2149}
2150
2151pub fn short_session_id(id: &str) -> &str {
2154 &id[..id.len().min(8)]
2155}
2156
2157pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2166 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2167 let mut visit = |path: PathBuf| {
2168 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2169 return;
2170 }
2171 let modified = std::fs::metadata(&path)
2172 .and_then(|m| m.modified())
2173 .unwrap_or(std::time::UNIX_EPOCH);
2174 files.push((path, modified));
2175 };
2176 if let Ok(entries) = std::fs::read_dir(dir) {
2177 for entry in entries.flatten() {
2178 let path = entry.path();
2179 if path.is_dir() {
2180 if let Ok(inner) = std::fs::read_dir(&path) {
2181 for child in inner.flatten() {
2182 visit(child.path());
2183 }
2184 }
2185 } else {
2186 visit(path);
2187 }
2188 }
2189 }
2190 files.sort_by(|a, b| b.1.cmp(&a.1));
2191 files.into_iter().map(|(path, _)| path).collect()
2192}
2193
2194fn first_resumable_session<'a>(
2199 metas: &'a [rpi_harness::session::jsonl::JsonlSessionMetadata],
2200) -> Option<&'a rpi_harness::session::jsonl::JsonlSessionMetadata> {
2201 metas
2202 .iter()
2203 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2204 .or_else(|| metas.first())
2205}
2206
2207pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2211 let metas = list_session_metadata(cwd)
2212 .await
2213 .map_err(|e| OpenError::Other(e.to_string()))?;
2214 let Some(meta) = metas
2215 .iter()
2216 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2217 else {
2218 return Err(OpenError::NotFound {
2219 requested: format!("session {id}"),
2220 });
2221 };
2222 open_session(meta, cwd).await.map_err(|e| {
2223 OpenError::Other(match e {
2224 BuildError::SessionOpen(reason) => reason,
2228 other => other.to_string(),
2229 })
2230 })
2231}
2232
2233pub(crate) async fn fork_session_storage(
2240 harness: &AgentHarness,
2241 cwd: &str,
2242) -> Result<Session, String> {
2243 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2244 use rpi_tools::FileSystem;
2245
2246 let dir = default_session_dir(Path::new(cwd));
2247 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2248 let fs: Arc<dyn FileSystem> = env.clone();
2249 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2250 fs,
2251 sessions_root: dir.to_string_lossy().into_owned(),
2252 clock: Arc::new(SystemClock),
2253 ids: Arc::new(DefaultIdGenerator::new()),
2254 });
2255 let id = harness.session().storage().metadata().id.clone();
2258 let metas = list_session_metadata(cwd)
2259 .await
2260 .map_err(|e| e.to_string())?;
2261 let Some(source) = metas.iter().find(|m| m.id == id) else {
2262 return Err(format!("current session {id} not found on disk"));
2263 };
2264 let fork_storage = repo
2265 .fork_typed(
2266 source,
2267 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2268 id: None,
2269 parent_session_id: Some(source.id.clone()),
2270 cwd: cwd.to_string(),
2271 metadata: None,
2272 },
2273 &rpi_harness::session::types::ForkOptions::default(),
2274 )
2275 .await
2276 .map_err(|e| e.to_string())?;
2277 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2278 Ok(Session::new(Arc::new(fork_storage), None))
2279}
2280
2281static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2283static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2286
2287fn claim_session_lock(session_path: &str) -> Result<(), String> {
2296 let lock_path = PathBuf::from(format!("{session_path}.lock"));
2297 if SESSION_LOCK_PATHS
2298 .get_or_init(|| Mutex::new(HashSet::new()))
2299 .lock()
2300 .unwrap()
2301 .contains(&lock_path)
2302 {
2303 return Ok(());
2304 }
2305 let file = std::fs::OpenOptions::new()
2306 .create(true)
2307 .write(true)
2308 .open(&lock_path)
2309 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2310 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2311 return Err(format!(
2312 "session {session_path} is already open in another rpi process"
2313 ));
2314 }
2315 SESSION_LOCKS
2316 .get_or_init(|| Mutex::new(Vec::new()))
2317 .lock()
2318 .unwrap()
2319 .push(file);
2320 SESSION_LOCK_PATHS
2321 .get_or_init(|| Mutex::new(HashSet::new()))
2322 .lock()
2323 .unwrap()
2324 .insert(lock_path);
2325 Ok(())
2326}
2327
2328async fn open_session(
2331 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2332 cwd: &str,
2333) -> Result<Session, BuildError> {
2334 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2335 use rpi_harness::session::types::SessionStorage;
2336 use rpi_tools::FileSystem;
2337
2338 let dir = default_session_dir(Path::new(cwd));
2339 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2340 let fs: Arc<dyn FileSystem> = env.clone();
2341 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2342 fs: fs.clone(),
2343 sessions_root: dir.to_string_lossy().into_owned(),
2344 clock: Arc::new(SystemClock),
2345 ids: Arc::new(DefaultIdGenerator::new()),
2346 });
2347 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2348 let storage = repo
2349 .open_by_jsonl_metadata(meta)
2350 .await
2351 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2352 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2356 Some(Path::new(&meta.path)),
2357 Path::new(&meta.cwd),
2358 Path::new(cwd),
2359 ) {
2360 eprintln!(
2361 "warning: {}",
2362 crate::session_cwd::format_missing_session_cwd_error(&issue)
2363 );
2364 }
2365 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2366 Ok(Session::new(storage_arc, None))
2367}
2368
2369fn ephemeral_session() -> Session {
2371 let storage = Arc::new(InMemorySessionStorage::new(
2372 SessionMetadata {
2373 id: "ephemeral".into(),
2374 created_at: 0,
2375 parent_session_id: None,
2376 },
2377 Arc::new(SystemClock),
2378 Arc::new(DefaultIdGenerator::new()),
2379 ));
2380 Session::new(storage, None)
2381}
2382
2383pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2394 create_jsonl_session_with_id(dir, cwd, None).await
2395}
2396
2397pub(crate) async fn create_jsonl_session_with_id(
2401 dir: &Path,
2402 cwd: &str,
2403 id: Option<String>,
2404) -> Result<Session, String> {
2405 use rpi_harness::session::jsonl::{
2406 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2407 };
2408 use rpi_tools::FileSystem;
2409
2410 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2413 let fs: Arc<dyn FileSystem> = env.clone();
2414
2415 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2416 fs: fs.clone(),
2417 sessions_root: dir.to_string_lossy().into_owned(),
2418 clock: Arc::new(SystemClock),
2419 ids: Arc::new(DefaultIdGenerator::new()),
2420 });
2421
2422 let opts = JsonlSessionCreateOptions {
2423 id, parent_session_id: None,
2425 cwd: cwd.to_string(),
2426 metadata: None,
2427 };
2428 let storage = repo
2429 .create_typed(&opts)
2430 .await
2431 .map_err(|e| format!("create session: {e}"))?;
2432 claim_session_lock(&storage.jsonl_metadata().path)
2433 .map_err(|e| format!("create session: {e}"))?;
2434 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2436 Ok(Session::new(storage_arc, None))
2437}
2438
2439fn read_append_target(target: &str) -> Option<String> {
2442 let path = Path::new(target);
2443 if path.is_file() {
2444 std::fs::read_to_string(path).ok()
2445 } else {
2446 None
2447 }
2448}
2449
2450#[cfg(test)]
2451mod tests {
2452 use super::*;
2453 use crate::args::Args;
2454
2455 fn scratch(name: &str) -> PathBuf {
2457 let nanos = std::time::SystemTime::now()
2458 .duration_since(std::time::UNIX_EPOCH)
2459 .unwrap()
2460 .as_nanos();
2461 let path = std::env::temp_dir().join(format!(
2462 "rpi-summary-{}-{nanos}-{name}.jsonl",
2463 std::process::id()
2464 ));
2465 path
2466 }
2467
2468 #[test]
2469 fn summary_marks_a_header_only_session_empty() {
2470 let path = scratch("empty");
2471 std::fs::write(
2472 &path,
2473 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2474 )
2475 .unwrap();
2476 let summary = summarize_session_file(&path);
2477 assert!(summary.empty, "a header-only file has nothing to resume");
2478 assert!(summary.preview.is_none());
2479 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
2480 std::fs::remove_file(&path).ok();
2481 }
2482
2483 #[test]
2484 fn summary_reads_the_first_user_prompt_and_name() {
2485 let path = scratch("content");
2486 let body = concat!(
2487 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2488 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
2489 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
2490 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
2491 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
2492 );
2493 std::fs::write(&path, body).unwrap();
2494 let summary = summarize_session_file(&path);
2495 assert!(!summary.empty);
2496 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
2497 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
2498 std::fs::remove_file(&path).ok();
2499 }
2500
2501 #[test]
2502 fn summary_extracts_a_block_style_user_prompt() {
2503 let path = scratch("blocks");
2504 let body = concat!(
2505 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2506 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
2507 );
2508 std::fs::write(&path, body).unwrap();
2509 let summary = summarize_session_file(&path);
2510 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
2511 assert_eq!(session_display_label(&summary), "line one line two");
2512 std::fs::remove_file(&path).ok();
2513 }
2514
2515 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
2516 rpi_harness::session::jsonl::JsonlSessionMetadata {
2517 id: id.to_string(),
2518 created_at: 0,
2519 cwd: ".".to_string(),
2520 path: path.to_string_lossy().into_owned(),
2521 modified_at: 0,
2522 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
2523 parent_session_id: None,
2524 legacy_parent_session_path: None,
2525 metadata: None,
2526 }
2527 }
2528
2529 #[test]
2530 fn continue_skips_newer_empty_sessions() {
2531 let empty = scratch("newest-empty");
2532 std::fs::write(
2533 &empty,
2534 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2535 )
2536 .unwrap();
2537 let content = scratch("older-content");
2538 std::fs::write(
2539 &content,
2540 concat!(
2541 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2542 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
2543 ),
2544 )
2545 .unwrap();
2546
2547 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
2548 let picked = first_resumable_session(&metas).expect("a resumable session");
2549 assert_eq!(picked.id, "old");
2550
2551 let all_empty = vec![meta_for(&empty, "empty")];
2554 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
2555
2556 std::fs::remove_file(&empty).ok();
2557 std::fs::remove_file(&content).ok();
2558 }
2559
2560 #[test]
2561 fn list_session_files_sync_walks_the_cwd_subdirectory() {
2562 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
2563 let nested = root.join("--proj--");
2564 std::fs::create_dir_all(&nested).unwrap();
2565 let file = nested.join("2026-01-01_a.jsonl");
2566 std::fs::write(&file, "{}\n").unwrap();
2567 let found = list_session_files_sync(&root);
2568 assert!(found.iter().any(|p| p == &file));
2569 std::fs::remove_dir_all(&root).ok();
2570 }
2571
2572 #[test]
2573 fn default_prompt_mentions_cwd_and_tools() {
2574 let p = default_system_prompt("/tmp/proj", false);
2575 assert!(p.contains("/tmp/proj"));
2576 assert!(p.contains("read"));
2577 assert!(p.contains("bash"));
2578 assert!(p.contains("edit"));
2579 assert!(p.contains("write"));
2580 assert!(p.contains("docs"));
2581 assert!(!p.contains("- grep"));
2582 assert!(!p.contains("- find"));
2583 assert!(!p.contains("- ls"));
2584 assert!(!p.contains("powershell"));
2585 }
2586
2587 #[test]
2588 fn default_prompt_instructs_language_matching() {
2589 let p = default_system_prompt("/tmp/proj", false);
2593 let lower = p.to_lowercase();
2594 assert!(
2595 lower.contains("same language") && lower.contains("detect"),
2596 "the prompt must tell the model to detect and mirror the user's language: {p}"
2597 );
2598 }
2599
2600 #[test]
2601 fn default_prompt_keeps_working_state_in_the_visible_channel() {
2602 let p = default_system_prompt("/tmp/proj", false);
2610 let lower = p.to_lowercase();
2611 assert!(
2613 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
2614 "the prompt must explain that reasoning does not survive the turn: {p}"
2615 );
2616 assert!(
2618 lower.contains("visible") && lower.contains("todo tool"),
2619 "the prompt must point at visible text and the todo tool: {p}"
2620 );
2621 assert!(
2623 lower.contains("re-stating the same plan"),
2624 "the prompt must call out plan re-statement as a bug: {p}"
2625 );
2626 assert!(
2628 lower.contains("already shown earlier"),
2629 "the prompt must discourage re-fetching known content: {p}"
2630 );
2631 }
2632
2633 #[test]
2634 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
2635 let p = default_system_prompt("/tmp/proj", true);
2642 let lower = p.to_lowercase();
2643 assert!(
2644 lower.contains("carried back"),
2645 "a replaying model must be told its reasoning survives: {p}"
2646 );
2647 assert!(
2648 !lower.contains("reasoning is not"),
2649 "the replayed variant must not claim reasoning is dropped: {p}"
2650 );
2651 assert!(
2652 lower.contains("do not re-derive"),
2653 "the replayed variant must forbid re-deriving the plan: {p}"
2654 );
2655 assert!(
2656 lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
2657 "the anti-repetition rule must survive the flip: {p}"
2658 );
2659 assert!(
2660 lower.contains("already shown earlier"),
2661 "the re-read rule must survive the flip: {p}"
2662 );
2663 }
2664
2665 #[test]
2673 fn only_read_only_tools_are_replayable() {
2674 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2675 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2676 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2677 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2678 let tools = build_tools(&context, &Args::default());
2679
2680 let replay_of = |name: &str| {
2681 tools
2682 .iter()
2683 .find(|tool| tool.tool.schema().name == name)
2684 .map(|tool| tool.replay)
2685 };
2686
2687 for read_only in ["read", "docs"] {
2688 assert_eq!(
2689 replay_of(read_only),
2690 Some(ToolReplay::Safe),
2691 "{read_only} is side-effect free, so recovery may re-run it"
2692 );
2693 }
2694 for mutating in ["bash", "edit", "write", "powershell"] {
2695 if let Some(replay) = replay_of(mutating) {
2696 assert_eq!(
2697 replay,
2698 ToolReplay::Never,
2699 "{mutating} must never be replayed: re-running it would duplicate its effect"
2700 );
2701 }
2702 }
2703 }
2704
2705 #[test]
2706 fn default_tools_include_docs_lookup() {
2707 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2708 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2709 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2710 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2711 let names: Vec<String> = build_tools(&context, &Args::default())
2712 .iter()
2713 .map(|tool| tool.tool.schema().name.clone())
2714 .collect();
2715
2716 assert!(names.contains(&"docs".to_string()));
2721 for legacy in ["grep", "find", "ls"] {
2722 assert!(
2723 !names.contains(&legacy.to_string()),
2724 "legacy tool {legacy} should not be registered by default"
2725 );
2726 }
2727 }
2728
2729 #[test]
2730 fn reload_settings_preflight_respects_trust() {
2731 struct RestoreConfigDir(Option<std::ffi::OsString>);
2732
2733 impl Drop for RestoreConfigDir {
2734 fn drop(&mut self) {
2735 match self.0.take() {
2736 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2737 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2738 }
2739 }
2740 }
2741
2742 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2743 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2744 let tmp = tempfile::tempdir().unwrap();
2745 let agent = tmp.path().join("agent");
2746 let cwd = tmp.path().join("project");
2747 std::fs::create_dir_all(&agent).unwrap();
2748 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2749 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2750 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
2751 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2752 let args = Args::default();
2753
2754 let trusted = validate_settings_for_reload(&args, &cwd, true);
2755 let untrusted = validate_settings_for_reload(&args, &cwd, false);
2756 assert!(trusted
2757 .unwrap_err()
2758 .contains("could not load project settings"));
2759 assert!(untrusted.is_ok());
2760
2761 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2762 assert!(validate_settings_for_reload(&args, &cwd, false)
2763 .unwrap_err()
2764 .contains("could not load global settings"));
2765
2766 let local_only = Args {
2767 dev_local_only: true,
2768 ..Args::default()
2769 };
2770 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
2771 }
2772
2773 #[tokio::test(flavor = "current_thread")]
2774 async fn reload_preserves_live_resources_when_settings_break() {
2775 struct RestoreConfigDir(Option<std::ffi::OsString>);
2776
2777 impl Drop for RestoreConfigDir {
2778 fn drop(&mut self) {
2779 match self.0.take() {
2780 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2781 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2782 }
2783 }
2784 }
2785
2786 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2787 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2788 let tmp = tempfile::tempdir().unwrap();
2789 let agent = tmp.path().join("agent");
2790 let cwd = tmp.path().join("project");
2791 let skill_dir = tmp.path().join("configured-skills");
2792 std::fs::create_dir_all(&agent).unwrap();
2793 std::fs::create_dir_all(&cwd).unwrap();
2794 std::fs::create_dir_all(&skill_dir).unwrap();
2795 std::fs::write(
2796 skill_dir.join("SKILL.md"),
2797 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
2798 )
2799 .unwrap();
2800 std::fs::write(
2801 agent.join("settings.json"),
2802 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2803 )
2804 .unwrap();
2805 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2806
2807 let resolved = crate::provider::resolve(
2808 Some("anthropic"),
2809 Some(crate::provider::DEFAULT_MODEL_ID),
2810 None,
2811 Some("test-key"),
2812 None,
2813 )
2814 .unwrap();
2815 let args = Args {
2816 trust_override: Some(false),
2817 no_session: true,
2818 no_extensions: true,
2819 no_prompt_templates: true,
2820 no_context_files: true,
2821 system_prompt: Some("stable system prompt".into()),
2822 ..Args::default()
2823 };
2824 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2825 let before_resources = harness.get_resources().await.unwrap();
2826 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
2827 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
2828 let before_prompt = harness.get_system_prompt().await.unwrap();
2829 let before_tools: Vec<String> = harness
2830 .get_tools()
2831 .await
2832 .unwrap()
2833 .iter()
2834 .map(|tool| tool.tool.schema().name.clone())
2835 .collect();
2836 let before_bridge = context
2837 .action_bridge
2838 .lock()
2839 .unwrap()
2840 .as_ref()
2841 .unwrap()
2842 .clone();
2843
2844 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2845 let outcome = reload_extension_resources(&harness, &context).await;
2846
2847 assert!(outcome.had_warnings);
2848 assert!(outcome.summary.contains("Settings reload failed"));
2849 assert!(outcome.summary.contains("could not load global settings"));
2850 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
2851 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
2852 let after_tools: Vec<String> = harness
2853 .get_tools()
2854 .await
2855 .unwrap()
2856 .iter()
2857 .map(|tool| tool.tool.schema().name.clone())
2858 .collect();
2859 assert_eq!(after_tools, before_tools);
2860 let after_bridge = context
2861 .action_bridge
2862 .lock()
2863 .unwrap()
2864 .as_ref()
2865 .unwrap()
2866 .clone();
2867 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
2868 }
2869
2870 #[test]
2871 fn reload_preparation_rejects_settings_changed_during_derivation() {
2872 struct RestoreConfigDir(Option<std::ffi::OsString>);
2873
2874 impl Drop for RestoreConfigDir {
2875 fn drop(&mut self) {
2876 match self.0.take() {
2877 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2878 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2879 }
2880 }
2881 }
2882
2883 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2884 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2885 let tmp = tempfile::tempdir().unwrap();
2886 let agent = tmp.path().join("agent");
2887 let cwd = tmp.path().join("project");
2888 let first_skill_dir = tmp.path().join("first-skills");
2889 let second_skill_dir = tmp.path().join("second-skills");
2890 std::fs::create_dir_all(&agent).unwrap();
2891 std::fs::create_dir_all(&cwd).unwrap();
2892 std::fs::write(
2893 agent.join("settings.json"),
2894 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
2895 )
2896 .unwrap();
2897 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2898 let args = Args {
2899 trust_override: Some(false),
2900 no_extensions: true,
2901 no_prompt_templates: true,
2902 ..Args::default()
2903 };
2904
2905 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
2906 std::fs::write(
2907 agent.join("settings.json"),
2908 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
2909 )
2910 .unwrap();
2911 });
2912
2913 let error = result
2914 .err()
2915 .expect("settings mutation must fail preparation");
2916 assert!(error.contains("settings changed while reload inputs were being prepared"));
2917 }
2918
2919 #[tokio::test(flavor = "current_thread")]
2920 async fn reload_uses_frozen_settings_inputs_after_preparation() {
2921 struct RestoreConfigDir(Option<std::ffi::OsString>);
2922
2923 impl Drop for RestoreConfigDir {
2924 fn drop(&mut self) {
2925 match self.0.take() {
2926 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2927 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2928 }
2929 }
2930 }
2931
2932 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2933 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2934 let tmp = tempfile::tempdir().unwrap();
2935 let agent = tmp.path().join("agent");
2936 let cwd = tmp.path().join("project");
2937 let skill_dir = tmp.path().join("configured-skills");
2938 std::fs::create_dir_all(&agent).unwrap();
2939 std::fs::create_dir_all(&cwd).unwrap();
2940 std::fs::create_dir_all(&skill_dir).unwrap();
2941 std::fs::write(
2942 skill_dir.join("SKILL.md"),
2943 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
2944 )
2945 .unwrap();
2946 std::fs::write(
2947 agent.join("settings.json"),
2948 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2949 )
2950 .unwrap();
2951 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2952
2953 let resolved = crate::provider::resolve(
2954 Some("anthropic"),
2955 Some(crate::provider::DEFAULT_MODEL_ID),
2956 None,
2957 Some("test-key"),
2958 None,
2959 )
2960 .unwrap();
2961 let args = Args {
2962 trust_override: Some(false),
2963 no_session: true,
2964 no_extensions: true,
2965 no_prompt_templates: true,
2966 no_context_files: true,
2967 system_prompt: Some("stable system prompt".into()),
2968 ..Args::default()
2969 };
2970 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2971
2972 let outcome = reload_extension_resources_inner(&harness, &context, || {
2973 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2974 })
2975 .await;
2976
2977 assert!(!outcome.summary.contains("Settings reload failed"));
2978 assert_eq!(
2979 outcome.summary,
2980 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
2981 );
2982 let resources = harness.get_resources().await.unwrap();
2983 let skills = resources.skills.as_ref().unwrap();
2984 assert_eq!(skills.len(), 1);
2985 assert_eq!(skills[0].name, "frozen-skill");
2986 }
2987
2988 #[tokio::test]
2989 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
2990 let tmp = tempfile::tempdir().unwrap();
2991 let skill_path = tmp.path().join("explicit-skill.md");
2992 std::fs::write(
2993 &skill_path,
2994 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
2995 )
2996 .unwrap();
2997 let prompt_path = tmp.path().join("explicit-prompt.md");
2998 std::fs::write(
2999 &prompt_path,
3000 "---\ndescription: Explicit prompt\n---\nPrompt body",
3001 )
3002 .unwrap();
3003
3004 let args = Args {
3005 skill: vec![skill_path.clone()],
3006 prompt_template: vec![prompt_path.clone()],
3007 ..Args::default()
3008 };
3009 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3010 let prompt_paths =
3011 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3012 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3013 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3014
3015 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3016 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3017 assert_eq!(skills.skills[0].name, "explicit-skill");
3018
3019 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3020 assert_eq!(
3021 prompts.prompt_templates.len(),
3022 1,
3023 "{:?}",
3024 prompts.diagnostics
3025 );
3026 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3027 }
3028
3029 #[test]
3030 fn tool_policy_applies_to_rust_and_js_active_names() {
3031 let names = vec![
3032 "read".to_string(),
3033 "ask_user_question".to_string(),
3034 "write".to_string(),
3035 ];
3036
3037 let args = Args {
3038 tools: Some(vec!["read".into(), "ask_user_question".into()]),
3039 ..Args::default()
3040 };
3041 assert_eq!(
3042 filter_active_tool_names(names.clone(), &args),
3043 vec!["read", "ask_user_question"]
3044 );
3045
3046 let args = Args {
3047 exclude_tools: Some(vec!["ask_user_question".into()]),
3048 ..Args::default()
3049 };
3050 assert_eq!(
3051 filter_active_tool_names(names.clone(), &args),
3052 vec!["read", "write"]
3053 );
3054
3055 let args = Args {
3056 no_tools: true,
3057 ..Args::default()
3058 };
3059 assert!(filter_active_tool_names(names, &args).is_empty());
3060 }
3061
3062 #[test]
3063 fn select_ephemeral_when_no_session() {
3064 let args = Args {
3065 no_session: true,
3066 ..Args::default()
3067 };
3068 let cwd = Path::new("/tmp");
3069 assert!(matches!(
3070 select_session(&args, cwd),
3071 SessionSelection::Ephemeral
3072 ));
3073 }
3074
3075 #[test]
3076 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3077 let defaults = Args::default();
3078 assert!(resolve_project_trust(
3079 &defaults,
3080 Path::new("C:/definitely-not-a-project")
3081 ));
3082
3083 let denied = Args {
3084 trust_override: Some(false),
3085 ..Args::default()
3086 };
3087 assert!(!resolve_project_trust(
3088 &denied,
3089 Path::new("C:/definitely-not-a-project")
3090 ));
3091 }
3092
3093 #[tokio::test(flavor = "current_thread")]
3094 async fn build_preserves_the_supplied_startup_project_snapshot() {
3095 struct RestoreConfigDir(Option<std::ffi::OsString>);
3096
3097 impl Drop for RestoreConfigDir {
3098 fn drop(&mut self) {
3099 match self.0.take() {
3100 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3101 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3102 }
3103 }
3104 }
3105
3106 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3107 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3108 let tmp = tempfile::tempdir().unwrap();
3109 let agent = tmp.path().join("agent");
3110 let cwd = tmp.path().join("project");
3111 std::fs::create_dir_all(&agent).unwrap();
3112 std::fs::create_dir_all(&cwd).unwrap();
3113 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3114
3115 let resolved = crate::provider::resolve(
3116 Some("anthropic"),
3117 Some(crate::provider::DEFAULT_MODEL_ID),
3118 None,
3119 Some("test-key"),
3120 None,
3121 )
3122 .unwrap();
3123 let args = Args {
3124 trust_override: Some(false),
3125 no_session: true,
3126 no_tools: true,
3127 no_extensions: true,
3128 no_skills: true,
3129 no_prompt_templates: true,
3130 no_context_files: true,
3131 system_prompt: Some("test prompt".into()),
3132 ..Args::default()
3133 };
3134
3135 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3136
3137 assert_eq!(context.cwd, cwd);
3138 assert!(context.project_trusted);
3139 }
3140
3141 #[tokio::test(flavor = "current_thread")]
3142 async fn build_applies_the_name_flag_to_the_session() {
3143 struct RestoreConfigDir(Option<std::ffi::OsString>);
3144
3145 impl Drop for RestoreConfigDir {
3146 fn drop(&mut self) {
3147 match self.0.take() {
3148 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3149 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3150 }
3151 }
3152 }
3153
3154 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3155 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3156 let tmp = tempfile::tempdir().unwrap();
3157 let agent = tmp.path().join("agent");
3158 let cwd = tmp.path().join("project");
3159 std::fs::create_dir_all(&agent).unwrap();
3160 std::fs::create_dir_all(&cwd).unwrap();
3161 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3162
3163 let resolved = crate::provider::resolve(
3164 Some("anthropic"),
3165 Some(crate::provider::DEFAULT_MODEL_ID),
3166 None,
3167 Some("test-key"),
3168 None,
3169 )
3170 .unwrap();
3171 let args = Args {
3172 trust_override: Some(false),
3173 no_tools: true,
3174 no_extensions: true,
3175 no_skills: true,
3176 no_prompt_templates: true,
3177 no_context_files: true,
3178 system_prompt: Some("test prompt".into()),
3179 name: Some("smoke name".into()),
3180 session_dir: Some(cwd.join("sessions")),
3181 ..Args::default()
3182 };
3183
3184 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3185 assert_eq!(
3186 harness.get_name().await.unwrap().as_deref(),
3187 Some("smoke name")
3188 );
3189 }
3190
3191 #[test]
3192 fn select_latest_for_continue_and_resume() {
3193 let args = Args {
3194 continue_session: true,
3195 ..Args::default()
3196 };
3197 let cwd = Path::new("/tmp");
3198 assert!(matches!(
3199 select_session(&args, cwd),
3200 SessionSelection::Latest
3201 ));
3202
3203 let args = Args {
3204 resume: true,
3205 ..Args::default()
3206 };
3207 assert!(matches!(
3208 select_session(&args, cwd),
3209 SessionSelection::Latest
3210 ));
3211 }
3212
3213 #[test]
3214 fn select_by_id_for_session_flag() {
3215 let args = Args {
3216 session: Some("01a02ece".into()),
3217 ..Args::default()
3218 };
3219 let cwd = Path::new("/tmp");
3220 assert!(matches!(
3221 select_session(&args, cwd),
3222 SessionSelection::ById { id } if id == "01a02ece"
3223 ));
3224 }
3225
3226 #[test]
3227 fn select_new_with_custom_dir() {
3228 let args = Args {
3229 session_dir: Some(PathBuf::from("/tmp/sess")),
3230 ..Args::default()
3231 };
3232 let cwd = Path::new("/tmp");
3233 match select_session(&args, cwd) {
3234 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
3235 other => panic!("expected New, got {other:?}"),
3236 }
3237 }
3238
3239 #[test]
3240 fn select_new_default_dir() {
3241 let args = Args::default();
3242 let cwd = Path::new("/proj");
3243 match select_session(&args, cwd) {
3244 SessionSelection::New { dir, .. } => {
3245 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
3246 }
3247 other => panic!("expected New, got {other:?}"),
3248 }
3249 }
3250
3251 #[test]
3252 fn default_session_dir_only_uses_rpi() {
3253 let tmp = tempfile::tempdir().unwrap();
3254 let cwd = tmp.path();
3255 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3256 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3257 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3258 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3259 }
3260
3261 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3262 async fn ephemeral_session_builds_roundtrips() {
3263 let s = ephemeral_session();
3267 let leaf = s.get_leaf_id().await;
3268 assert!(leaf.is_ok());
3269 }
3270
3271 }