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 prepare: None,
1689 }
1690}
1691
1692fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1693 if args.no_tools {
1694 return Vec::new();
1695 }
1696 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1705 (
1706 "read",
1707 HarnessTool::new(create_read_tool(ctx, None)).with_replay(ToolReplay::Safe),
1708 ),
1709 (
1710 "bash",
1711 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1712 ),
1713 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1714 ("write", HarnessTool::new(create_write_tool(ctx))),
1715 (
1716 "docs",
1717 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
1718 ),
1719 ];
1720
1721 #[cfg(target_os = "windows")]
1723 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
1724
1725 if args.tools.is_none() {
1727 if let Ok(settings) = crate::settings::load_settings() {
1728 if let Some(default_tools) = &settings.default_tools {
1729 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
1730 }
1731 }
1732 }
1733
1734 if args.no_builtin_tools {
1738 all.clear();
1739 }
1740
1741 if let Some(allow) = &args.tools {
1743 all.retain(|(name, _)| allow.iter().any(|a| a == name));
1744 }
1745 if let Some(deny) = &args.exclude_tools {
1747 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1748 }
1749
1750 all.into_iter().map(|(_, t)| t).collect()
1751}
1752
1753fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1757 filter_active_tool_names(
1758 tools.iter().map(|tool| tool.tool.schema().name.clone()),
1759 args,
1760 )
1761}
1762
1763pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
1766 if args.no_tools {
1767 return false;
1768 }
1769 if args
1770 .tools
1771 .as_ref()
1772 .is_some_and(|allow| !allow.iter().any(|value| value == name))
1773 {
1774 return false;
1775 }
1776 if args
1777 .exclude_tools
1778 .as_ref()
1779 .is_some_and(|deny| deny.iter().any(|value| value == name))
1780 {
1781 return false;
1782 }
1783 true
1784}
1785
1786pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
1787where
1788 I: IntoIterator<Item = String>,
1789{
1790 names
1791 .into_iter()
1792 .filter(|name| tool_name_allowed(name, args))
1793 .collect()
1794}
1795
1796async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1798 match selection {
1799 SessionSelection::Ephemeral => Ok(ephemeral_session()),
1800 SessionSelection::New { dir, .. } => {
1801 std::fs::create_dir_all(dir)
1804 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1805 let session = create_jsonl_session(dir, cwd)
1806 .await
1807 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1808 Ok(session)
1809 }
1810 SessionSelection::Latest
1811 | SessionSelection::ById { .. }
1812 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
1813 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
1814 }
1815}
1816
1817async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1823 match selection {
1827 SessionSelection::Latest => {
1828 let metas = list_session_metadata(cwd).await?;
1829 let Some(meta) = first_resumable_session(&metas) else {
1830 return Err(BuildError::SessionNotFound {
1831 requested: "the most recent session".to_string(),
1832 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1833 });
1834 };
1835 open_session(meta, cwd).await
1836 }
1837 SessionSelection::ById { id } => open_session_by_id(id, cwd).await.map_err(|e| match e {
1838 OpenError::NotFound { requested } => BuildError::SessionNotFound {
1839 requested,
1840 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1841 },
1842 OpenError::Other(msg) => BuildError::SessionOpen(msg),
1843 }),
1844 SessionSelection::ByExactId { id } => {
1845 let metas = list_session_metadata(cwd).await?;
1848 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
1849 return open_session(meta, cwd).await;
1850 }
1851 let dir = default_session_dir(Path::new(cwd));
1852 std::fs::create_dir_all(&dir)
1853 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1854 create_jsonl_session_with_id(&dir, cwd, Some(id.clone()))
1855 .await
1856 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
1857 }
1858 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
1859 }
1860}
1861
1862async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
1865 use rpi_harness::session::jsonl::{
1866 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1867 };
1868 use rpi_harness::session::types::{ForkOptions, SessionStorage};
1869 use rpi_tools::FileSystem;
1870
1871 let dir = default_session_dir(Path::new(cwd));
1872 std::fs::create_dir_all(&dir)
1873 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1874 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1875 let fs: Arc<dyn FileSystem> = env.clone();
1876 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1877 fs: fs.clone(),
1878 sessions_root: dir.to_string_lossy().into_owned(),
1879 clock: Arc::new(SystemClock),
1880 ids: Arc::new(DefaultIdGenerator::new()),
1881 });
1882 let metas = repo
1883 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
1884 .await
1885 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
1886 let source_meta = metas
1887 .iter()
1888 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
1889 .ok_or_else(|| BuildError::SessionNotFound {
1890 requested: format!("--fork {source}"),
1891 dir: dir.display().to_string(),
1892 })?;
1893 let fork_storage = repo
1894 .fork_typed(
1895 source_meta,
1896 &JsonlSessionCreateOptions {
1897 id: None,
1898 parent_session_id: Some(source_meta.id.clone()),
1899 cwd: cwd.to_string(),
1900 metadata: None,
1901 },
1902 &ForkOptions::default(),
1903 )
1904 .await
1905 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
1906 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
1907 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
1908 Ok(Session::new(storage_arc, None))
1909}
1910
1911pub enum OpenError {
1914 NotFound { requested: String },
1916 Other(String),
1918}
1919
1920impl std::fmt::Display for OpenError {
1921 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1922 match self {
1923 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
1924 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
1929 }
1930 }
1931}
1932
1933pub async fn list_session_metadata(
1936 cwd: &str,
1937) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
1938 use rpi_harness::session::jsonl::{
1939 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1940 };
1941 use rpi_tools::FileSystem;
1942
1943 let dir = default_session_dir(Path::new(cwd));
1944 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1945 let fs: Arc<dyn FileSystem> = env.clone();
1946 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1947 fs: fs.clone(),
1948 sessions_root: dir.to_string_lossy().into_owned(),
1949 clock: Arc::new(SystemClock),
1950 ids: Arc::new(DefaultIdGenerator::new()),
1951 });
1952 repo.list_typed(&JsonlSessionListOptions::default())
1953 .await
1954 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
1955}
1956
1957#[derive(Debug, Clone, Default, PartialEq, Eq)]
1973pub struct SessionSummary {
1974 pub name: Option<String>,
1976 pub preview: Option<String>,
1978 pub empty: bool,
1980 pub bytes: u64,
1982}
1983
1984const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
1986const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
1988const SUMMARY_PREVIEW_CHARS: usize = 80;
1990
1991pub fn summarize_session_file(path: &Path) -> SessionSummary {
1995 use std::io::{Read, Seek, SeekFrom};
1996
1997 let Ok(mut file) = std::fs::File::open(path) else {
1998 return SessionSummary::default();
1999 };
2000 let Ok(size) = file.metadata().map(|m| m.len()) else {
2001 return SessionSummary::default();
2002 };
2003 if size == 0 {
2004 return SessionSummary {
2005 name: None,
2006 preview: None,
2007 empty: true,
2008 bytes: 0,
2009 };
2010 }
2011
2012 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2015 let head_len = file.read(&mut head).unwrap_or(0);
2016 head.truncate(head_len);
2017 let head = String::from_utf8_lossy(&head);
2018 let mut lines: Vec<&str> = head.lines().collect();
2019 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2020 let empty = (size as usize) <= header_len + 1;
2022 if (head_len as u64) < size && !lines.is_empty() {
2025 lines.pop();
2026 }
2027
2028 let mut summary = SessionSummary {
2029 name: None,
2030 preview: None,
2031 empty,
2032 bytes: size,
2033 };
2034 for line in lines {
2035 apply_summary_line(line, &mut summary);
2036 }
2037
2038 if size > SUMMARY_HEAD_BYTES
2042 && file
2043 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2044 .is_ok()
2045 {
2046 let mut tail = Vec::new();
2047 if file.read_to_end(&mut tail).is_ok() {
2048 let tail = String::from_utf8_lossy(&tail);
2049 let mut tail_lines = tail.lines();
2050 tail_lines.next();
2052 for line in tail_lines {
2053 if let Some(name) = name_from_line(line) {
2054 summary.name = name;
2055 }
2056 }
2057 }
2058 }
2059 summary
2060}
2061
2062fn name_from_line(line: &str) -> Option<Option<String>> {
2065 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2066 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2067 return None;
2068 }
2069 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2070 return None;
2071 }
2072 Some(match value.get("name") {
2073 Some(serde_json::Value::String(name)) => Some(name.clone()),
2074 _ => None,
2075 })
2076}
2077
2078fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2081 if let Some(name) = name_from_line(line) {
2082 summary.name = name;
2083 return;
2084 }
2085 if summary.preview.is_some() {
2086 return;
2087 }
2088 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2089 return;
2090 };
2091 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2092 return;
2093 }
2094 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2095 return;
2096 }
2097 let Some(message) = value.get("message") else {
2098 return;
2099 };
2100 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2101 return;
2102 }
2103 let text = match message.get("content") {
2104 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2105 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2106 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2107 .then(|| block.get("text").and_then(|v| v.as_str()))
2108 .flatten()
2109 }),
2110 _ => None,
2111 };
2112 let Some(text) = text else { return };
2113 let collapsed = text
2114 .lines()
2115 .map(str::trim)
2116 .filter(|line| !line.is_empty())
2117 .collect::<Vec<_>>()
2118 .join(" ");
2119 if collapsed.is_empty() {
2120 return;
2121 }
2122 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2123}
2124
2125pub fn session_display_label(summary: &SessionSummary) -> String {
2129 if let Some(name) = summary.name.as_deref().map(str::trim) {
2130 if !name.is_empty() {
2131 return name.to_string();
2132 }
2133 }
2134 match summary.preview.as_deref().map(str::trim) {
2135 Some(preview) if !preview.is_empty() => preview.to_string(),
2136 _ => "(no messages)".to_string(),
2137 }
2138}
2139
2140pub fn format_session_bytes(bytes: u64) -> String {
2143 if bytes >= 1_048_576 {
2144 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2145 } else if bytes >= 1024 {
2146 format!("{}KB", bytes / 1024)
2147 } else {
2148 format!("{bytes}B")
2149 }
2150}
2151
2152pub fn short_session_id(id: &str) -> &str {
2155 &id[..id.len().min(8)]
2156}
2157
2158pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2167 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2168 let mut visit = |path: PathBuf| {
2169 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2170 return;
2171 }
2172 let modified = std::fs::metadata(&path)
2173 .and_then(|m| m.modified())
2174 .unwrap_or(std::time::UNIX_EPOCH);
2175 files.push((path, modified));
2176 };
2177 if let Ok(entries) = std::fs::read_dir(dir) {
2178 for entry in entries.flatten() {
2179 let path = entry.path();
2180 if path.is_dir() {
2181 if let Ok(inner) = std::fs::read_dir(&path) {
2182 for child in inner.flatten() {
2183 visit(child.path());
2184 }
2185 }
2186 } else {
2187 visit(path);
2188 }
2189 }
2190 }
2191 files.sort_by(|a, b| b.1.cmp(&a.1));
2192 files.into_iter().map(|(path, _)| path).collect()
2193}
2194
2195fn first_resumable_session<'a>(
2200 metas: &'a [rpi_harness::session::jsonl::JsonlSessionMetadata],
2201) -> Option<&'a rpi_harness::session::jsonl::JsonlSessionMetadata> {
2202 metas
2203 .iter()
2204 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2205 .or_else(|| metas.first())
2206}
2207
2208pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2212 let metas = list_session_metadata(cwd)
2213 .await
2214 .map_err(|e| OpenError::Other(e.to_string()))?;
2215 let Some(meta) = metas
2216 .iter()
2217 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2218 else {
2219 return Err(OpenError::NotFound {
2220 requested: format!("session {id}"),
2221 });
2222 };
2223 open_session(meta, cwd).await.map_err(|e| {
2224 OpenError::Other(match e {
2225 BuildError::SessionOpen(reason) => reason,
2229 other => other.to_string(),
2230 })
2231 })
2232}
2233
2234pub(crate) async fn fork_session_storage(
2241 harness: &AgentHarness,
2242 cwd: &str,
2243) -> Result<Session, String> {
2244 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2245 use rpi_tools::FileSystem;
2246
2247 let dir = default_session_dir(Path::new(cwd));
2248 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2249 let fs: Arc<dyn FileSystem> = env.clone();
2250 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2251 fs,
2252 sessions_root: dir.to_string_lossy().into_owned(),
2253 clock: Arc::new(SystemClock),
2254 ids: Arc::new(DefaultIdGenerator::new()),
2255 });
2256 let id = harness.session().storage().metadata().id.clone();
2259 let metas = list_session_metadata(cwd)
2260 .await
2261 .map_err(|e| e.to_string())?;
2262 let Some(source) = metas.iter().find(|m| m.id == id) else {
2263 return Err(format!("current session {id} not found on disk"));
2264 };
2265 let fork_storage = repo
2266 .fork_typed(
2267 source,
2268 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2269 id: None,
2270 parent_session_id: Some(source.id.clone()),
2271 cwd: cwd.to_string(),
2272 metadata: None,
2273 },
2274 &rpi_harness::session::types::ForkOptions::default(),
2275 )
2276 .await
2277 .map_err(|e| e.to_string())?;
2278 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2279 Ok(Session::new(Arc::new(fork_storage), None))
2280}
2281
2282static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2284static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2287
2288fn claim_session_lock(session_path: &str) -> Result<(), String> {
2297 let lock_path = PathBuf::from(format!("{session_path}.lock"));
2298 if SESSION_LOCK_PATHS
2299 .get_or_init(|| Mutex::new(HashSet::new()))
2300 .lock()
2301 .unwrap()
2302 .contains(&lock_path)
2303 {
2304 return Ok(());
2305 }
2306 let file = std::fs::OpenOptions::new()
2307 .create(true)
2308 .write(true)
2309 .open(&lock_path)
2310 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2311 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2312 return Err(format!(
2313 "session {session_path} is already open in another rpi process"
2314 ));
2315 }
2316 SESSION_LOCKS
2317 .get_or_init(|| Mutex::new(Vec::new()))
2318 .lock()
2319 .unwrap()
2320 .push(file);
2321 SESSION_LOCK_PATHS
2322 .get_or_init(|| Mutex::new(HashSet::new()))
2323 .lock()
2324 .unwrap()
2325 .insert(lock_path);
2326 Ok(())
2327}
2328
2329async fn open_session(
2332 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2333 cwd: &str,
2334) -> Result<Session, BuildError> {
2335 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2336 use rpi_harness::session::types::SessionStorage;
2337 use rpi_tools::FileSystem;
2338
2339 let dir = default_session_dir(Path::new(cwd));
2340 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2341 let fs: Arc<dyn FileSystem> = env.clone();
2342 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2343 fs: fs.clone(),
2344 sessions_root: dir.to_string_lossy().into_owned(),
2345 clock: Arc::new(SystemClock),
2346 ids: Arc::new(DefaultIdGenerator::new()),
2347 });
2348 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2349 let storage = repo
2350 .open_by_jsonl_metadata(meta)
2351 .await
2352 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2353 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2357 Some(Path::new(&meta.path)),
2358 Path::new(&meta.cwd),
2359 Path::new(cwd),
2360 ) {
2361 eprintln!(
2362 "warning: {}",
2363 crate::session_cwd::format_missing_session_cwd_error(&issue)
2364 );
2365 }
2366 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2367 Ok(Session::new(storage_arc, None))
2368}
2369
2370fn ephemeral_session() -> Session {
2372 let storage = Arc::new(InMemorySessionStorage::new(
2373 SessionMetadata {
2374 id: "ephemeral".into(),
2375 created_at: 0,
2376 parent_session_id: None,
2377 },
2378 Arc::new(SystemClock),
2379 Arc::new(DefaultIdGenerator::new()),
2380 ));
2381 Session::new(storage, None)
2382}
2383
2384pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2395 create_jsonl_session_with_id(dir, cwd, None).await
2396}
2397
2398pub(crate) async fn create_jsonl_session_with_id(
2402 dir: &Path,
2403 cwd: &str,
2404 id: Option<String>,
2405) -> Result<Session, String> {
2406 use rpi_harness::session::jsonl::{
2407 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2408 };
2409 use rpi_tools::FileSystem;
2410
2411 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2414 let fs: Arc<dyn FileSystem> = env.clone();
2415
2416 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2417 fs: fs.clone(),
2418 sessions_root: dir.to_string_lossy().into_owned(),
2419 clock: Arc::new(SystemClock),
2420 ids: Arc::new(DefaultIdGenerator::new()),
2421 });
2422
2423 let opts = JsonlSessionCreateOptions {
2424 id, parent_session_id: None,
2426 cwd: cwd.to_string(),
2427 metadata: None,
2428 };
2429 let storage = repo
2430 .create_typed(&opts)
2431 .await
2432 .map_err(|e| format!("create session: {e}"))?;
2433 claim_session_lock(&storage.jsonl_metadata().path)
2434 .map_err(|e| format!("create session: {e}"))?;
2435 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2437 Ok(Session::new(storage_arc, None))
2438}
2439
2440fn read_append_target(target: &str) -> Option<String> {
2443 let path = Path::new(target);
2444 if path.is_file() {
2445 std::fs::read_to_string(path).ok()
2446 } else {
2447 None
2448 }
2449}
2450
2451#[cfg(test)]
2452mod tests {
2453 use super::*;
2454 use crate::args::Args;
2455
2456 fn scratch(name: &str) -> PathBuf {
2458 let nanos = std::time::SystemTime::now()
2459 .duration_since(std::time::UNIX_EPOCH)
2460 .unwrap()
2461 .as_nanos();
2462 let path = std::env::temp_dir().join(format!(
2463 "rpi-summary-{}-{nanos}-{name}.jsonl",
2464 std::process::id()
2465 ));
2466 path
2467 }
2468
2469 #[test]
2470 fn summary_marks_a_header_only_session_empty() {
2471 let path = scratch("empty");
2472 std::fs::write(
2473 &path,
2474 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2475 )
2476 .unwrap();
2477 let summary = summarize_session_file(&path);
2478 assert!(summary.empty, "a header-only file has nothing to resume");
2479 assert!(summary.preview.is_none());
2480 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
2481 std::fs::remove_file(&path).ok();
2482 }
2483
2484 #[test]
2485 fn summary_reads_the_first_user_prompt_and_name() {
2486 let path = scratch("content");
2487 let body = concat!(
2488 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2489 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
2490 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
2491 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
2492 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
2493 );
2494 std::fs::write(&path, body).unwrap();
2495 let summary = summarize_session_file(&path);
2496 assert!(!summary.empty);
2497 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
2498 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
2499 std::fs::remove_file(&path).ok();
2500 }
2501
2502 #[test]
2503 fn summary_extracts_a_block_style_user_prompt() {
2504 let path = scratch("blocks");
2505 let body = concat!(
2506 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2507 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
2508 );
2509 std::fs::write(&path, body).unwrap();
2510 let summary = summarize_session_file(&path);
2511 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
2512 assert_eq!(session_display_label(&summary), "line one line two");
2513 std::fs::remove_file(&path).ok();
2514 }
2515
2516 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
2517 rpi_harness::session::jsonl::JsonlSessionMetadata {
2518 id: id.to_string(),
2519 created_at: 0,
2520 cwd: ".".to_string(),
2521 path: path.to_string_lossy().into_owned(),
2522 modified_at: 0,
2523 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
2524 parent_session_id: None,
2525 legacy_parent_session_path: None,
2526 metadata: None,
2527 }
2528 }
2529
2530 #[test]
2531 fn continue_skips_newer_empty_sessions() {
2532 let empty = scratch("newest-empty");
2533 std::fs::write(
2534 &empty,
2535 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2536 )
2537 .unwrap();
2538 let content = scratch("older-content");
2539 std::fs::write(
2540 &content,
2541 concat!(
2542 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2543 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
2544 ),
2545 )
2546 .unwrap();
2547
2548 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
2549 let picked = first_resumable_session(&metas).expect("a resumable session");
2550 assert_eq!(picked.id, "old");
2551
2552 let all_empty = vec![meta_for(&empty, "empty")];
2555 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
2556
2557 std::fs::remove_file(&empty).ok();
2558 std::fs::remove_file(&content).ok();
2559 }
2560
2561 #[test]
2562 fn list_session_files_sync_walks_the_cwd_subdirectory() {
2563 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
2564 let nested = root.join("--proj--");
2565 std::fs::create_dir_all(&nested).unwrap();
2566 let file = nested.join("2026-01-01_a.jsonl");
2567 std::fs::write(&file, "{}\n").unwrap();
2568 let found = list_session_files_sync(&root);
2569 assert!(found.iter().any(|p| p == &file));
2570 std::fs::remove_dir_all(&root).ok();
2571 }
2572
2573 #[test]
2574 fn default_prompt_mentions_cwd_and_tools() {
2575 let p = default_system_prompt("/tmp/proj", false);
2576 assert!(p.contains("/tmp/proj"));
2577 assert!(p.contains("read"));
2578 assert!(p.contains("bash"));
2579 assert!(p.contains("edit"));
2580 assert!(p.contains("write"));
2581 assert!(p.contains("docs"));
2582 assert!(!p.contains("- grep"));
2583 assert!(!p.contains("- find"));
2584 assert!(!p.contains("- ls"));
2585 assert!(!p.contains("powershell"));
2586 }
2587
2588 #[test]
2589 fn default_prompt_instructs_language_matching() {
2590 let p = default_system_prompt("/tmp/proj", false);
2594 let lower = p.to_lowercase();
2595 assert!(
2596 lower.contains("same language") && lower.contains("detect"),
2597 "the prompt must tell the model to detect and mirror the user's language: {p}"
2598 );
2599 }
2600
2601 #[test]
2602 fn default_prompt_keeps_working_state_in_the_visible_channel() {
2603 let p = default_system_prompt("/tmp/proj", false);
2611 let lower = p.to_lowercase();
2612 assert!(
2614 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
2615 "the prompt must explain that reasoning does not survive the turn: {p}"
2616 );
2617 assert!(
2619 lower.contains("visible") && lower.contains("todo tool"),
2620 "the prompt must point at visible text and the todo tool: {p}"
2621 );
2622 assert!(
2624 lower.contains("re-stating the same plan"),
2625 "the prompt must call out plan re-statement as a bug: {p}"
2626 );
2627 assert!(
2629 lower.contains("already shown earlier"),
2630 "the prompt must discourage re-fetching known content: {p}"
2631 );
2632 }
2633
2634 #[test]
2635 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
2636 let p = default_system_prompt("/tmp/proj", true);
2643 let lower = p.to_lowercase();
2644 assert!(
2645 lower.contains("carried back"),
2646 "a replaying model must be told its reasoning survives: {p}"
2647 );
2648 assert!(
2649 !lower.contains("reasoning is not"),
2650 "the replayed variant must not claim reasoning is dropped: {p}"
2651 );
2652 assert!(
2653 lower.contains("do not re-derive"),
2654 "the replayed variant must forbid re-deriving the plan: {p}"
2655 );
2656 assert!(
2657 lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
2658 "the anti-repetition rule must survive the flip: {p}"
2659 );
2660 assert!(
2661 lower.contains("already shown earlier"),
2662 "the re-read rule must survive the flip: {p}"
2663 );
2664 }
2665
2666 #[test]
2674 fn only_read_only_tools_are_replayable() {
2675 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2676 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2677 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2678 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2679 let tools = build_tools(&context, &Args::default());
2680
2681 let replay_of = |name: &str| {
2682 tools
2683 .iter()
2684 .find(|tool| tool.tool.schema().name == name)
2685 .map(|tool| tool.replay)
2686 };
2687
2688 for read_only in ["read", "docs"] {
2689 assert_eq!(
2690 replay_of(read_only),
2691 Some(ToolReplay::Safe),
2692 "{read_only} is side-effect free, so recovery may re-run it"
2693 );
2694 }
2695 for mutating in ["bash", "edit", "write", "powershell"] {
2696 if let Some(replay) = replay_of(mutating) {
2697 assert_eq!(
2698 replay,
2699 ToolReplay::Never,
2700 "{mutating} must never be replayed: re-running it would duplicate its effect"
2701 );
2702 }
2703 }
2704 }
2705
2706 #[test]
2707 fn default_tools_include_docs_lookup() {
2708 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2709 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2710 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2711 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2712 let names: Vec<String> = build_tools(&context, &Args::default())
2713 .iter()
2714 .map(|tool| tool.tool.schema().name.clone())
2715 .collect();
2716
2717 assert!(names.contains(&"docs".to_string()));
2722 for legacy in ["grep", "find", "ls"] {
2723 assert!(
2724 !names.contains(&legacy.to_string()),
2725 "legacy tool {legacy} should not be registered by default"
2726 );
2727 }
2728 }
2729
2730 #[test]
2731 fn reload_settings_preflight_respects_trust() {
2732 struct RestoreConfigDir(Option<std::ffi::OsString>);
2733
2734 impl Drop for RestoreConfigDir {
2735 fn drop(&mut self) {
2736 match self.0.take() {
2737 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2738 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2739 }
2740 }
2741 }
2742
2743 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2744 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2745 let tmp = tempfile::tempdir().unwrap();
2746 let agent = tmp.path().join("agent");
2747 let cwd = tmp.path().join("project");
2748 std::fs::create_dir_all(&agent).unwrap();
2749 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2750 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2751 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
2752 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2753 let args = Args::default();
2754
2755 let trusted = validate_settings_for_reload(&args, &cwd, true);
2756 let untrusted = validate_settings_for_reload(&args, &cwd, false);
2757 assert!(trusted
2758 .unwrap_err()
2759 .contains("could not load project settings"));
2760 assert!(untrusted.is_ok());
2761
2762 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2763 assert!(validate_settings_for_reload(&args, &cwd, false)
2764 .unwrap_err()
2765 .contains("could not load global settings"));
2766
2767 let local_only = Args {
2768 dev_local_only: true,
2769 ..Args::default()
2770 };
2771 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
2772 }
2773
2774 #[tokio::test(flavor = "current_thread")]
2775 async fn reload_preserves_live_resources_when_settings_break() {
2776 struct RestoreConfigDir(Option<std::ffi::OsString>);
2777
2778 impl Drop for RestoreConfigDir {
2779 fn drop(&mut self) {
2780 match self.0.take() {
2781 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2782 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2783 }
2784 }
2785 }
2786
2787 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2788 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2789 let tmp = tempfile::tempdir().unwrap();
2790 let agent = tmp.path().join("agent");
2791 let cwd = tmp.path().join("project");
2792 let skill_dir = tmp.path().join("configured-skills");
2793 std::fs::create_dir_all(&agent).unwrap();
2794 std::fs::create_dir_all(&cwd).unwrap();
2795 std::fs::create_dir_all(&skill_dir).unwrap();
2796 std::fs::write(
2797 skill_dir.join("SKILL.md"),
2798 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
2799 )
2800 .unwrap();
2801 std::fs::write(
2802 agent.join("settings.json"),
2803 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2804 )
2805 .unwrap();
2806 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2807
2808 let resolved = crate::provider::resolve(
2809 Some("anthropic"),
2810 Some(crate::provider::DEFAULT_MODEL_ID),
2811 None,
2812 Some("test-key"),
2813 None,
2814 )
2815 .unwrap();
2816 let args = Args {
2817 trust_override: Some(false),
2818 no_session: true,
2819 no_extensions: true,
2820 no_prompt_templates: true,
2821 no_context_files: true,
2822 system_prompt: Some("stable system prompt".into()),
2823 ..Args::default()
2824 };
2825 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2826 let before_resources = harness.get_resources().await.unwrap();
2827 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
2828 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
2829 let before_prompt = harness.get_system_prompt().await.unwrap();
2830 let before_tools: Vec<String> = harness
2831 .get_tools()
2832 .await
2833 .unwrap()
2834 .iter()
2835 .map(|tool| tool.tool.schema().name.clone())
2836 .collect();
2837 let before_bridge = context
2838 .action_bridge
2839 .lock()
2840 .unwrap()
2841 .as_ref()
2842 .unwrap()
2843 .clone();
2844
2845 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2846 let outcome = reload_extension_resources(&harness, &context).await;
2847
2848 assert!(outcome.had_warnings);
2849 assert!(outcome.summary.contains("Settings reload failed"));
2850 assert!(outcome.summary.contains("could not load global settings"));
2851 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
2852 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
2853 let after_tools: Vec<String> = harness
2854 .get_tools()
2855 .await
2856 .unwrap()
2857 .iter()
2858 .map(|tool| tool.tool.schema().name.clone())
2859 .collect();
2860 assert_eq!(after_tools, before_tools);
2861 let after_bridge = context
2862 .action_bridge
2863 .lock()
2864 .unwrap()
2865 .as_ref()
2866 .unwrap()
2867 .clone();
2868 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
2869 }
2870
2871 #[test]
2872 fn reload_preparation_rejects_settings_changed_during_derivation() {
2873 struct RestoreConfigDir(Option<std::ffi::OsString>);
2874
2875 impl Drop for RestoreConfigDir {
2876 fn drop(&mut self) {
2877 match self.0.take() {
2878 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2879 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2880 }
2881 }
2882 }
2883
2884 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2885 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2886 let tmp = tempfile::tempdir().unwrap();
2887 let agent = tmp.path().join("agent");
2888 let cwd = tmp.path().join("project");
2889 let first_skill_dir = tmp.path().join("first-skills");
2890 let second_skill_dir = tmp.path().join("second-skills");
2891 std::fs::create_dir_all(&agent).unwrap();
2892 std::fs::create_dir_all(&cwd).unwrap();
2893 std::fs::write(
2894 agent.join("settings.json"),
2895 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
2896 )
2897 .unwrap();
2898 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2899 let args = Args {
2900 trust_override: Some(false),
2901 no_extensions: true,
2902 no_prompt_templates: true,
2903 ..Args::default()
2904 };
2905
2906 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
2907 std::fs::write(
2908 agent.join("settings.json"),
2909 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
2910 )
2911 .unwrap();
2912 });
2913
2914 let error = result
2915 .err()
2916 .expect("settings mutation must fail preparation");
2917 assert!(error.contains("settings changed while reload inputs were being prepared"));
2918 }
2919
2920 #[tokio::test(flavor = "current_thread")]
2921 async fn reload_uses_frozen_settings_inputs_after_preparation() {
2922 struct RestoreConfigDir(Option<std::ffi::OsString>);
2923
2924 impl Drop for RestoreConfigDir {
2925 fn drop(&mut self) {
2926 match self.0.take() {
2927 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2928 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2929 }
2930 }
2931 }
2932
2933 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2934 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2935 let tmp = tempfile::tempdir().unwrap();
2936 let agent = tmp.path().join("agent");
2937 let cwd = tmp.path().join("project");
2938 let skill_dir = tmp.path().join("configured-skills");
2939 std::fs::create_dir_all(&agent).unwrap();
2940 std::fs::create_dir_all(&cwd).unwrap();
2941 std::fs::create_dir_all(&skill_dir).unwrap();
2942 std::fs::write(
2943 skill_dir.join("SKILL.md"),
2944 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
2945 )
2946 .unwrap();
2947 std::fs::write(
2948 agent.join("settings.json"),
2949 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2950 )
2951 .unwrap();
2952 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2953
2954 let resolved = crate::provider::resolve(
2955 Some("anthropic"),
2956 Some(crate::provider::DEFAULT_MODEL_ID),
2957 None,
2958 Some("test-key"),
2959 None,
2960 )
2961 .unwrap();
2962 let args = Args {
2963 trust_override: Some(false),
2964 no_session: true,
2965 no_extensions: true,
2966 no_prompt_templates: true,
2967 no_context_files: true,
2968 system_prompt: Some("stable system prompt".into()),
2969 ..Args::default()
2970 };
2971 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2972
2973 let outcome = reload_extension_resources_inner(&harness, &context, || {
2974 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2975 })
2976 .await;
2977
2978 assert!(!outcome.summary.contains("Settings reload failed"));
2979 assert_eq!(
2980 outcome.summary,
2981 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
2982 );
2983 let resources = harness.get_resources().await.unwrap();
2984 let skills = resources.skills.as_ref().unwrap();
2985 assert_eq!(skills.len(), 1);
2986 assert_eq!(skills[0].name, "frozen-skill");
2987 }
2988
2989 #[tokio::test]
2990 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
2991 let tmp = tempfile::tempdir().unwrap();
2992 let skill_path = tmp.path().join("explicit-skill.md");
2993 std::fs::write(
2994 &skill_path,
2995 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
2996 )
2997 .unwrap();
2998 let prompt_path = tmp.path().join("explicit-prompt.md");
2999 std::fs::write(
3000 &prompt_path,
3001 "---\ndescription: Explicit prompt\n---\nPrompt body",
3002 )
3003 .unwrap();
3004
3005 let args = Args {
3006 skill: vec![skill_path.clone()],
3007 prompt_template: vec![prompt_path.clone()],
3008 ..Args::default()
3009 };
3010 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3011 let prompt_paths =
3012 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3013 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3014 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3015
3016 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3017 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3018 assert_eq!(skills.skills[0].name, "explicit-skill");
3019
3020 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3021 assert_eq!(
3022 prompts.prompt_templates.len(),
3023 1,
3024 "{:?}",
3025 prompts.diagnostics
3026 );
3027 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3028 }
3029
3030 #[test]
3031 fn tool_policy_applies_to_rust_and_js_active_names() {
3032 let names = vec![
3033 "read".to_string(),
3034 "ask_user_question".to_string(),
3035 "write".to_string(),
3036 ];
3037
3038 let args = Args {
3039 tools: Some(vec!["read".into(), "ask_user_question".into()]),
3040 ..Args::default()
3041 };
3042 assert_eq!(
3043 filter_active_tool_names(names.clone(), &args),
3044 vec!["read", "ask_user_question"]
3045 );
3046
3047 let args = Args {
3048 exclude_tools: Some(vec!["ask_user_question".into()]),
3049 ..Args::default()
3050 };
3051 assert_eq!(
3052 filter_active_tool_names(names.clone(), &args),
3053 vec!["read", "write"]
3054 );
3055
3056 let args = Args {
3057 no_tools: true,
3058 ..Args::default()
3059 };
3060 assert!(filter_active_tool_names(names, &args).is_empty());
3061 }
3062
3063 #[test]
3064 fn select_ephemeral_when_no_session() {
3065 let args = Args {
3066 no_session: true,
3067 ..Args::default()
3068 };
3069 let cwd = Path::new("/tmp");
3070 assert!(matches!(
3071 select_session(&args, cwd),
3072 SessionSelection::Ephemeral
3073 ));
3074 }
3075
3076 #[test]
3077 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3078 let defaults = Args::default();
3079 assert!(resolve_project_trust(
3080 &defaults,
3081 Path::new("C:/definitely-not-a-project")
3082 ));
3083
3084 let denied = Args {
3085 trust_override: Some(false),
3086 ..Args::default()
3087 };
3088 assert!(!resolve_project_trust(
3089 &denied,
3090 Path::new("C:/definitely-not-a-project")
3091 ));
3092 }
3093
3094 #[tokio::test(flavor = "current_thread")]
3095 async fn build_preserves_the_supplied_startup_project_snapshot() {
3096 struct RestoreConfigDir(Option<std::ffi::OsString>);
3097
3098 impl Drop for RestoreConfigDir {
3099 fn drop(&mut self) {
3100 match self.0.take() {
3101 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3102 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3103 }
3104 }
3105 }
3106
3107 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3108 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3109 let tmp = tempfile::tempdir().unwrap();
3110 let agent = tmp.path().join("agent");
3111 let cwd = tmp.path().join("project");
3112 std::fs::create_dir_all(&agent).unwrap();
3113 std::fs::create_dir_all(&cwd).unwrap();
3114 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3115
3116 let resolved = crate::provider::resolve(
3117 Some("anthropic"),
3118 Some(crate::provider::DEFAULT_MODEL_ID),
3119 None,
3120 Some("test-key"),
3121 None,
3122 )
3123 .unwrap();
3124 let args = Args {
3125 trust_override: Some(false),
3126 no_session: true,
3127 no_tools: true,
3128 no_extensions: true,
3129 no_skills: true,
3130 no_prompt_templates: true,
3131 no_context_files: true,
3132 system_prompt: Some("test prompt".into()),
3133 ..Args::default()
3134 };
3135
3136 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3137
3138 assert_eq!(context.cwd, cwd);
3139 assert!(context.project_trusted);
3140 }
3141
3142 #[tokio::test(flavor = "current_thread")]
3143 async fn build_applies_the_name_flag_to_the_session() {
3144 struct RestoreConfigDir(Option<std::ffi::OsString>);
3145
3146 impl Drop for RestoreConfigDir {
3147 fn drop(&mut self) {
3148 match self.0.take() {
3149 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3150 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3151 }
3152 }
3153 }
3154
3155 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3156 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3157 let tmp = tempfile::tempdir().unwrap();
3158 let agent = tmp.path().join("agent");
3159 let cwd = tmp.path().join("project");
3160 std::fs::create_dir_all(&agent).unwrap();
3161 std::fs::create_dir_all(&cwd).unwrap();
3162 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3163
3164 let resolved = crate::provider::resolve(
3165 Some("anthropic"),
3166 Some(crate::provider::DEFAULT_MODEL_ID),
3167 None,
3168 Some("test-key"),
3169 None,
3170 )
3171 .unwrap();
3172 let args = Args {
3173 trust_override: Some(false),
3174 no_tools: true,
3175 no_extensions: true,
3176 no_skills: true,
3177 no_prompt_templates: true,
3178 no_context_files: true,
3179 system_prompt: Some("test prompt".into()),
3180 name: Some("smoke name".into()),
3181 session_dir: Some(cwd.join("sessions")),
3182 ..Args::default()
3183 };
3184
3185 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3186 assert_eq!(
3187 harness.get_name().await.unwrap().as_deref(),
3188 Some("smoke name")
3189 );
3190 }
3191
3192 #[test]
3193 fn select_latest_for_continue_and_resume() {
3194 let args = Args {
3195 continue_session: true,
3196 ..Args::default()
3197 };
3198 let cwd = Path::new("/tmp");
3199 assert!(matches!(
3200 select_session(&args, cwd),
3201 SessionSelection::Latest
3202 ));
3203
3204 let args = Args {
3205 resume: true,
3206 ..Args::default()
3207 };
3208 assert!(matches!(
3209 select_session(&args, cwd),
3210 SessionSelection::Latest
3211 ));
3212 }
3213
3214 #[test]
3215 fn select_by_id_for_session_flag() {
3216 let args = Args {
3217 session: Some("01a02ece".into()),
3218 ..Args::default()
3219 };
3220 let cwd = Path::new("/tmp");
3221 assert!(matches!(
3222 select_session(&args, cwd),
3223 SessionSelection::ById { id } if id == "01a02ece"
3224 ));
3225 }
3226
3227 #[test]
3228 fn select_new_with_custom_dir() {
3229 let args = Args {
3230 session_dir: Some(PathBuf::from("/tmp/sess")),
3231 ..Args::default()
3232 };
3233 let cwd = Path::new("/tmp");
3234 match select_session(&args, cwd) {
3235 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
3236 other => panic!("expected New, got {other:?}"),
3237 }
3238 }
3239
3240 #[test]
3241 fn select_new_default_dir() {
3242 let args = Args::default();
3243 let cwd = Path::new("/proj");
3244 match select_session(&args, cwd) {
3245 SessionSelection::New { dir, .. } => {
3246 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
3247 }
3248 other => panic!("expected New, got {other:?}"),
3249 }
3250 }
3251
3252 #[test]
3253 fn default_session_dir_only_uses_rpi() {
3254 let tmp = tempfile::tempdir().unwrap();
3255 let cwd = tmp.path();
3256 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3257 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3258 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3259 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3260 }
3261
3262 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3263 async fn ephemeral_session_builds_roundtrips() {
3264 let s = ephemeral_session();
3268 let leaf = s.get_leaf_id().await;
3269 assert!(leaf.is_ok());
3270 }
3271
3272 }