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 tool_context.clone(),
258 );
259 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = Arc::new(action_host);
260 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
270 let ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
276 let ext_status_mailbox = rpi_extensions::ExtensionStatusMailbox::new();
280 let editor_text_mailbox = rpi_extensions::EditorTextMailbox::new();
285 let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
286 runtime.clone(),
287 host_arc,
288 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
289 ui_dialog_mailbox.clone(),
290 ext_status_mailbox.clone(),
291 editor_text_mailbox.clone(),
292 );
293
294 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
296 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
297 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
298 let _registry = Arc::new(MutationQueueRegistry::new());
299 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
302
303 let tools = build_tools(&ctx, args);
304 let mut tools = tools;
305
306 let extension_session = if args.no_extensions {
314 ExtensionSession::none()
315 } else {
316 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
317 };
318 if args.verbose {
319 if let Some(s) = extension_session.summary() {
320 eprintln!("extensions: {s}");
321 }
322 report_deferred_renderers(&extension_session);
323 }
324 merge_extension_tools(&mut tools, &extension_session, args, &tool_context);
325 let active = filter_active_tool_names(active_tool_names(&tools, args), args);
326
327 let selection = select_session(args, cwd);
329 let session = build_session(&selection, &cwd_str).await?;
330 if let Ok(metadata) = session.get_metadata().await {
334 publish_session_identity(&tool_context, &metadata.id);
335 }
336 if let Some(requested) = args.name.as_deref() {
342 let name = requested.trim();
343 let name = if name.is_empty() { None } else { Some(name) };
344 if let Err(error) = session.set_name(name).await {
345 if args.verbose {
346 eprintln!("warning: could not set session name: {error}");
347 }
348 }
349 }
350 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
360 let base_prompt = match args.system_prompt.as_deref() {
361 Some(explicit) => explicit.to_string(),
362 None if project_trusted => match discover_system_prompt_file(cwd) {
363 Some(path) => std::fs::read_to_string(&path)
364 .unwrap_or_else(|_| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
365 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
366 },
367 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
368 };
369
370 let mut append_texts: Vec<String> = Vec::new();
377 for extra in &args.append_system_prompt {
378 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
379 append_texts.push(text);
380 }
381 if args.append_system_prompt.is_empty() {
382 if let Some(path) = project_trusted
383 .then(|| discover_append_system_prompt_file(cwd))
384 .flatten()
385 {
386 if let Ok(text) = std::fs::read_to_string(&path) {
387 append_texts.push(text);
388 }
389 }
390 }
391 let append_join = if append_texts.is_empty() {
392 None
393 } else {
394 Some(append_texts.join("\n\n"))
395 };
396
397 let agent_dir = crate::config::agent_dir().ok();
409
410 let discovered = extension_session
426 .snapshot_arc()
427 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
428 .unwrap_or_default();
429
430 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
431 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
432 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
433 if !args.no_skills {
434 let mut dirs = if args.dev_local_only {
435 project_skill_dirs(cwd)
436 } else if project_trusted {
437 skill_dirs(cwd)
438 } else {
439 global_skill_dirs()
440 };
441 dirs.extend(args.skill.iter().cloned());
442 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
443 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
444 skills = result.skills;
445 skill_diags = result.diagnostics;
446 resource_diags.extend(result.resource_diagnostics);
447 }
448
449 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
450 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
451 if !args.no_prompt_templates {
452 let mut dirs = if args.dev_local_only {
453 project_prompt_template_dirs(cwd)
454 } else if project_trusted {
455 prompt_template_dirs(cwd)
456 } else {
457 global_prompt_template_dirs()
458 };
459 dirs.extend(args.prompt_template.iter().cloned());
460 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
461 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
462 prompt_templates = result.prompt_templates;
463 prompt_diags = result.diagnostics;
464 resource_diags.extend(result.resource_diagnostics);
465 }
466
467 let context_block = if args.no_context_files || !project_trusted {
468 String::new()
469 } else {
470 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
475 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
476 format_project_context(&files)
477 };
478
479 if args.verbose {
481 for d in &skill_diags {
482 eprintln!(
483 "warning: skill {} ({}): {}",
484 d.path,
485 d.code.as_str(),
486 d.message
487 );
488 }
489 for d in &prompt_diags {
490 eprintln!(
491 "warning: prompt template {} ({}): {}",
492 d.path,
493 d.code.as_str(),
494 d.message
495 );
496 }
497 for d in &resource_diags {
499 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
500 eprintln!(
501 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
502 c.resource_type.as_str(),
503 c.name,
504 c.winner_path,
505 c.loser_path
506 );
507 }
508 }
509 }
510
511 let system_prompt = compose_system_prompt(
520 Some(&base_prompt),
521 &[], if context_block.is_empty() {
523 None
524 } else {
525 Some(&context_block)
526 },
527 append_join.as_deref(),
528 );
529
530 if args.debug_system_prompt {
539 eprintln!("=== --debug-system-prompt ===");
540 let base_src = if args.system_prompt.is_some() {
541 "--system-prompt"
542 } else if discover_system_prompt_file(cwd).is_some() {
543 "SYSTEM.md"
544 } else {
545 "default"
546 };
547 eprintln!("[base source: {base_src}]");
548 eprintln!("--- base ---\n{base_prompt}");
549 if let Some(append) = append_join.as_deref() {
550 eprintln!("--- append ---\n{append}");
551 } else {
552 eprintln!("--- append: (none) ---");
553 }
554 if context_block.is_empty() {
555 eprintln!("--- context: (none) ---");
556 } else {
557 eprintln!("--- context ---{context_block}");
558 }
559 let visible_skills = skills
560 .iter()
561 .filter(|s| s.disable_model_invocation != Some(true))
562 .count();
563 eprintln!(
564 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
565 skills.len(),
566 visible_skills,
567 skills.len() - visible_skills
568 );
569 for s in &skills {
570 let hidden = if s.disable_model_invocation == Some(true) {
571 " [hidden]"
572 } else {
573 ""
574 };
575 eprintln!(" {}{hidden} — {}", s.name, s.description);
576 }
577 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
578 for t in &prompt_templates {
579 eprintln!(" /{}", t.name);
580 }
581 eprintln!(
585 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
586 discovered.skill_paths.len(),
587 discovered.prompt_paths.len(),
588 discovered.theme_paths.len(),
589 );
590 for p in &discovered.skill_paths {
591 eprintln!(" skill: {p}");
592 }
593 for p in &discovered.prompt_paths {
594 eprintln!(" prompt: {p}");
595 }
596 eprintln!(
597 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
598 );
599 eprintln!("=== end --debug-system-prompt ===");
600 }
601
602 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
607 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
608 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
612
613 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
622 Some(snapshot) => {
623 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
624 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
625 }
626 None => broadcast_emitter,
627 };
628
629 let saved_settings = crate::settings::load_settings().unwrap_or_default();
633
634 if let Some(proxy) = &saved_settings.http_proxy {
639 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
640 if std::env::var_os(key).is_none() {
641 std::env::set_var(key, proxy);
642 }
643 }
644 }
645
646 let options = AgentHarnessOptions {
647 model: resolved.model.clone(),
648 thinking_level: resolved.thinking_level,
649 active_tool_names: active,
650 tools,
651 system_prompt: Some(system_prompt),
652 resources: AgentHarnessResources {
653 skills: if skills.is_empty() {
654 None
655 } else {
656 Some(skills)
657 },
658 prompt_templates: if prompt_templates.is_empty() {
659 None
660 } else {
661 Some(prompt_templates)
662 },
663 },
664 allow_existing_session: matches!(
667 selection,
668 SessionSelection::Latest
669 | SessionSelection::ById { .. }
670 | SessionSelection::ByExactId { .. }
671 | SessionSelection::Fork { .. }
672 ),
673 stream_options: AgentHarnessStreamOptions {
674 timeout: args.timeout,
675 ..Default::default()
676 },
677 retry: saved_settings.retry_policy(),
682 compaction: saved_settings.compaction_settings(),
683 steering_mode: saved_settings.steering_mode(),
684 follow_up_mode: saved_settings.follow_up_mode(),
685 tool_execution: HarnessToolExecution::default(),
686 drive: DrivingMode::default(),
687 session,
688 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
696 to_provider_messages: None,
697 entry_projectors: Default::default(),
698 agent_emitter: Some(emitter),
699 before_tool_call: None,
705 after_tool_call: None,
706 transform_context: None,
707 entry_transforms: Vec::new(),
708 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
714 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
715 };
716
717 let harness = match AgentHarness::create(options).await {
718 Ok(h) => {
719 crate::extensions_actions::HarnessActionHost::set_harness(
722 &harness_cell,
723 Arc::new(h.clone()),
724 );
725 h
726 }
727 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
728 };
729
730 {
738 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
739 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
740 Ok(reports) => {
741 for (lane, report) in reports {
742 if report.is_clean() {
743 continue;
744 }
745 if report.is_corrupt() {
746 eprintln!(
747 "warning: session lane '{lane}' is corrupt ({} open operations); \
748 refusing to auto-repair — inspect the session file",
749 report.open_operations.len()
750 );
751 continue;
752 }
753 for decision in runtime.reconcile(&report) {
754 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
755 if args.verbose {
756 eprintln!(
757 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
758 );
759 }
760 }
761 }
762 runtime.emit_recovery_events(harness.events(), &report);
763 }
764 }
765 Err(e) => {
766 if args.verbose {
767 eprintln!("warning: session recovery sweep failed: {e}");
768 }
769 }
770 }
771 }
772
773 let reload_context = ReloadContext {
780 extension_session: Arc::new(Mutex::new(extension_session)),
781 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
782 tool_context,
783 catalog,
784 gateway: resolved.provider.clone(),
785 runtime: runtime.clone(),
786 cwd: cwd.to_path_buf(),
787 project_trusted,
788 args: args.clone(),
789 resolved_model: resolved.model.clone(),
790 broadcast: broadcast_for_context,
791 mailbox: reload_mailbox,
792 ui_dialog: ui_dialog_mailbox,
793 ext_status: ext_status_mailbox,
794 editor_text: editor_text_mailbox,
795 dev_extension: None,
796 };
797
798 Ok((harness, event_rx, reload_context))
799}
800
801pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
846
847pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
852
853#[derive(Clone)]
861pub struct ReloadContext {
862 pub extension_session: ExtensionSessionCell,
864 pub tool_context: rpi_extensions::ToolCallContext,
869 pub action_bridge: ActionBridgeCell,
871 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
874 pub editor_text: rpi_extensions::EditorTextMailbox,
881 pub catalog: Vec<rpi_ai::Model>,
886 pub gateway: Arc<dyn Provider>,
889 pub runtime: tokio::runtime::Handle,
893 pub cwd: PathBuf,
895 pub project_trusted: bool,
899 pub args: Args,
905 pub resolved_model: rpi_ai::Model,
908 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
914 pub mailbox: rpi_extensions::ReloadMailbox,
921 pub ui_dialog: rpi_extensions::UiDialogMailbox,
927 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
931}
932
933pub async fn dispatch_session_event_async(
948 reload_context: &ReloadContext,
949 tag: rpi_plugin_sdk::EventTag,
950) -> Option<String> {
951 let (snapshot, keepalive) = {
957 let session = reload_context.extension_session.lock().unwrap();
958 (session.snapshot_arc(), session.keepalive())
959 };
960 match snapshot {
961 Some(snapshot) => {
962 let _keepalive = keepalive;
963 rpi_extensions::dispatch_lifecycle_event(&snapshot, tag).await
964 }
965 None => None,
966 }
967}
968
969pub struct ReloadOutcome {
972 pub summary: String,
975 pub had_warnings: bool,
977 pub details: Vec<String>,
982 pub had_errors: bool,
986}
987
988struct PreparedReloadInputs {
989 extension_dirs: Vec<PathBuf>,
990 skill_base_dirs: Vec<PathBuf>,
991 prompt_base_dirs: Vec<PathBuf>,
992}
993
994fn append_reload_resource_paths(
999 mut paths: Vec<PathBuf>,
1000 explicit: &[PathBuf],
1001 discovered: &[String],
1002 js_paths: &[PathBuf],
1003 package_paths: &[PathBuf],
1004) -> Vec<PathBuf> {
1005 paths.extend(explicit.iter().cloned());
1006 paths.extend(discovered.iter().map(PathBuf::from));
1007 paths.extend(js_paths.iter().cloned());
1008 paths.extend(package_paths.iter().cloned());
1009 paths
1010}
1011
1012pub async fn reload_extension_resources(
1024 harness: &AgentHarness,
1025 ctx: &ReloadContext,
1026) -> ReloadOutcome {
1027 reload_extension_resources_inner(harness, ctx, || {}).await
1028}
1029
1030async fn reload_extension_resources_inner<F>(
1031 harness: &AgentHarness,
1032 ctx: &ReloadContext,
1033 after_prepare: F,
1034) -> ReloadOutcome
1035where
1036 F: FnOnce() + Send,
1037{
1038 let mut effective_args = ctx.args.clone();
1043 if let Some(dev) = &ctx.dev_extension {
1044 if let Err(error) = dev
1045 .rebuild()
1046 .and_then(|_| dev.apply_to_args(&mut effective_args))
1047 {
1048 return ReloadOutcome {
1049 summary: format!(
1050 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1051 dev.package_name()
1052 ),
1053 had_warnings: true,
1054 details: Vec::new(),
1055 had_errors: true,
1056 };
1057 }
1058 }
1059 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1060 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1061 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1062 Ok(prepared) => prepared,
1063 Err(error) => {
1064 return ReloadOutcome {
1065 summary: format!(
1066 "Settings reload failed: {error}. Keeping the currently loaded resources."
1067 ),
1068 had_warnings: true,
1069 details: Vec::new(),
1070 had_errors: true,
1071 };
1072 }
1073 };
1074 after_prepare();
1075 let PreparedReloadInputs {
1076 extension_dirs,
1077 skill_base_dirs,
1078 prompt_base_dirs,
1079 } = prepared;
1080 let mut warnings = false;
1081
1082 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1089 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1090 Some(b) => b.clone_host(),
1091 None => {
1092 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1095 ctx.catalog.clone(),
1096 ctx.cwd.clone(),
1097 ctx.runtime.clone(),
1098 ctx.args.unknown_flags.clone(),
1099 ctx.tool_context.clone(),
1100 );
1101 crate::extensions_actions::HarnessActionHost::set_harness(
1102 &_cell,
1103 Arc::new(harness.clone()),
1104 );
1105 Arc::new(action_host)
1106 }
1107 };
1108
1109 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1110 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1111 ctx.runtime.clone(),
1112 host,
1113 reload_cb,
1114 ctx.ui_dialog.clone(),
1115 ctx.ext_status.clone(),
1116 ctx.editor_text.clone(),
1117 );
1118
1119 let extension_session = if effective_args.no_extensions {
1120 rpi_extensions::ExtensionSession::none()
1121 } else {
1122 load_extensions_from_dirs(
1123 &effective_args,
1124 &extension_dirs,
1125 Some(Arc::clone(&fresh_bridge)),
1126 )
1127 };
1128 if extension_session.is_empty() && !effective_args.no_extensions {
1129 }
1132 if effective_args.verbose {
1133 if let Some(s) = extension_session.summary() {
1134 eprintln!("reload: {s}");
1135 }
1136 report_deferred_renderers(&extension_session);
1137 }
1138
1139 {
1153 let mut session_guard = ctx.extension_session.lock().unwrap();
1154 let old_session = std::mem::replace(
1155 &mut *session_guard,
1156 rpi_extensions::ExtensionSession::none(),
1157 );
1158 if let Some(old_snap) = old_session.snapshot_arc() {
1159 old_snap.active_flag().store(false, Ordering::SeqCst);
1163 }
1164 }
1168 if let Some(old_b) = old_bridge {
1169 old_b.invalidate();
1170 }
1171
1172 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1175 *ctx.extension_session.lock().unwrap() = extension_session.clone();
1176
1177 let discovered = extension_session
1179 .snapshot_arc()
1180 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1181 .unwrap_or_default();
1182
1183 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1185 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1186
1187 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1188 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1189 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1190 if !effective_args.no_skills {
1191 let js_paths: &[PathBuf] = &[];
1195 let package_paths: &[PathBuf] = &[];
1196 let dirs = append_reload_resource_paths(
1197 skill_base_dirs,
1198 &effective_args.skill,
1199 &discovered.skill_paths,
1200 js_paths,
1201 &package_paths,
1202 );
1203 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1204 skills = result.skills;
1205 skill_diags = result.diagnostics;
1206 resource_diags.extend(result.resource_diagnostics);
1207 }
1208
1209 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1210 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1211 if !effective_args.no_prompt_templates {
1212 let js_paths: &[PathBuf] = &[];
1213 let package_paths: &[PathBuf] = &[];
1214 let dirs = append_reload_resource_paths(
1215 prompt_base_dirs,
1216 &effective_args.prompt_template,
1217 &discovered.prompt_paths,
1218 js_paths,
1219 &package_paths,
1220 );
1221 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1222 prompt_templates = result.prompt_templates;
1223 prompt_diags = result.diagnostics;
1224 resource_diags.extend(result.resource_diagnostics);
1225 }
1226
1227 let context_block = if effective_args.no_context_files {
1228 String::new()
1229 } else {
1230 let agent_dir = crate::config::agent_dir().ok();
1231 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1232 let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
1233 format_project_context(&files)
1234 };
1235
1236 let mut details: Vec<String> = Vec::new();
1237 if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
1238 warnings = true;
1239 for d in &skill_diags {
1244 details.push(format!(
1245 "warning: skill {} ({}): {}",
1246 d.path,
1247 d.code.as_str(),
1248 d.message
1249 ));
1250 }
1251 for d in &prompt_diags {
1252 details.push(format!(
1253 "warning: prompt template {} ({}): {}",
1254 d.path,
1255 d.code.as_str(),
1256 d.message
1257 ));
1258 }
1259 for d in &resource_diags {
1261 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1262 details.push(format!(
1263 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1264 c.resource_type.as_str(),
1265 c.name,
1266 c.winner_path,
1267 c.loser_path
1268 ));
1269 }
1270 }
1271 }
1272
1273 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1278 let base_prompt = match effective_args.system_prompt.as_deref() {
1279 Some(explicit) => explicit.to_string(),
1280 None => match discover_system_prompt_file(&ctx.cwd) {
1281 Some(path) => std::fs::read_to_string(&path)
1282 .unwrap_or_else(|_| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
1283 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1284 },
1285 };
1286 let mut append_texts: Vec<String> = Vec::new();
1287 for extra in &effective_args.append_system_prompt {
1288 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
1289 append_texts.push(text);
1290 }
1291 if effective_args.append_system_prompt.is_empty() {
1292 if let Some(path) = discover_append_system_prompt_file(&ctx.cwd) {
1293 if let Ok(text) = std::fs::read_to_string(&path) {
1294 append_texts.push(text);
1295 }
1296 }
1297 }
1298 let append_join = if append_texts.is_empty() {
1299 None
1300 } else {
1301 Some(append_texts.join("\n\n"))
1302 };
1303 let system_prompt = compose_system_prompt(
1304 Some(&base_prompt),
1305 &[],
1306 if context_block.is_empty() {
1307 None
1308 } else {
1309 Some(&context_block)
1310 },
1311 append_join.as_deref(),
1312 );
1313
1314 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1316 Some(snapshot) => {
1317 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1318 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1319 }
1320 None => ctx.broadcast.clone(),
1321 };
1322
1323 let resources = AgentHarnessResources {
1325 skills: if skills.is_empty() {
1326 None
1327 } else {
1328 Some(skills.clone())
1329 },
1330 prompt_templates: if prompt_templates.is_empty() {
1331 None
1332 } else {
1333 Some(prompt_templates.clone())
1334 },
1335 };
1336 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1337 let _ = harness.set_resources(resources).await;
1338 let _ = harness.set_agent_emitter(Some(emitter)).await;
1339 let _ = harness
1340 .set_models(build_models_with_extensions_for_reload(
1341 &ctx.gateway,
1342 &extension_session,
1343 ctx.runtime.clone(),
1344 ))
1345 .await;
1346 let _ = harness
1347 .set_provider_hooks(
1348 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1349 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1350 )
1351 .await;
1352
1353 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1357 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1358 let mut tools = build_tools(&tool_ctx, &effective_args);
1359 merge_extension_tools(
1360 &mut tools,
1361 &extension_session,
1362 &effective_args,
1363 &ctx.tool_context,
1364 );
1365 let mut active = active_tool_names(&tools, &effective_args);
1366 active = filter_active_tool_names(active, &effective_args);
1367 let _ = harness.set_tools(tools, Some(active)).await;
1368
1369 let summary = format!(
1370 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1371 extension_session.loaded_paths().len(),
1372 skills.len(),
1373 prompt_templates.len(),
1374 );
1375 ReloadOutcome {
1376 summary,
1377 had_warnings: warnings,
1378 details,
1379 had_errors: false,
1382 }
1383}
1384
1385#[derive(Clone, Debug, PartialEq, Eq)]
1386struct ReloadSettingsFields {
1387 skill_dirs: Option<Vec<String>>,
1388 prompt_dirs: Option<Vec<String>>,
1389 extension_dirs: Option<Vec<String>>,
1390}
1391
1392impl From<crate::settings::Settings> for ReloadSettingsFields {
1393 fn from(settings: crate::settings::Settings) -> Self {
1394 Self {
1395 skill_dirs: settings.skill_dirs,
1396 prompt_dirs: settings.prompt_dirs,
1397 extension_dirs: settings.extension_dirs,
1398 }
1399 }
1400}
1401
1402#[derive(Clone, Debug, PartialEq, Eq)]
1403struct ReloadSettingsSnapshot {
1404 global: ReloadSettingsFields,
1405 project: Option<ReloadSettingsFields>,
1406}
1407
1408fn reload_reads_settings(args: &Args) -> bool {
1409 !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
1410}
1411
1412fn load_reload_settings_snapshot(
1416 args: &Args,
1417 cwd: &Path,
1418 project_trusted: bool,
1419) -> Result<Option<ReloadSettingsSnapshot>, String> {
1420 if !reload_reads_settings(args) {
1421 return Ok(None);
1422 }
1423 let global = crate::settings::load_settings()
1424 .map_err(|error| format!("could not load global settings: {error}"))?
1425 .into();
1426 let project = if project_trusted {
1427 crate::settings::load_active_project_settings(cwd)
1428 .map_err(|error| format!("could not load project settings: {error}"))?
1429 .map(|(_, settings)| settings.into())
1430 } else {
1431 None
1432 };
1433 Ok(Some(ReloadSettingsSnapshot { global, project }))
1434}
1435
1436#[cfg(test)]
1439fn validate_settings_for_reload(
1440 args: &Args,
1441 cwd: &Path,
1442 project_trusted: bool,
1443) -> Result<(), String> {
1444 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1445}
1446
1447fn prepare_reload_inputs(
1448 args: &Args,
1449 cwd: &Path,
1450 project_trusted: bool,
1451) -> Result<PreparedReloadInputs, String> {
1452 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1453}
1454
1455fn prepare_reload_inputs_inner<F>(
1456 args: &Args,
1457 cwd: &Path,
1458 project_trusted: bool,
1459 before_verify: F,
1460) -> Result<PreparedReloadInputs, String>
1461where
1462 F: FnOnce(),
1463{
1464 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1465
1466 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1467 Vec::new()
1468 } else if project_trusted {
1469 extension_dirs(cwd)
1470 } else {
1471 global_extension_dirs()
1472 };
1473 if !args.no_extensions {
1474 extension_dirs.extend(args.extensions_dir.iter().cloned());
1475 }
1476
1477 let skill_base_dirs = if args.no_skills {
1478 Vec::new()
1479 } else if args.dev_local_only {
1480 project_skill_dirs(cwd)
1481 } else if project_trusted {
1482 skill_dirs(cwd)
1483 } else {
1484 global_skill_dirs()
1485 };
1486
1487 let prompt_base_dirs = if args.no_prompt_templates {
1488 Vec::new()
1489 } else if args.dev_local_only {
1490 project_prompt_template_dirs(cwd)
1491 } else if project_trusted {
1492 prompt_template_dirs(cwd)
1493 } else {
1494 global_prompt_template_dirs()
1495 };
1496
1497 before_verify();
1498 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1499 if settings_before != settings_after {
1500 return Err(
1501 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1502 );
1503 }
1504
1505 Ok(PreparedReloadInputs {
1506 extension_dirs,
1507 skill_base_dirs,
1508 prompt_base_dirs,
1509 })
1510}
1511
1512fn build_models_with_extensions_for_reload(
1517 gateway: &Arc<dyn Provider>,
1518 extension_session: &ExtensionSession,
1519 runtime: tokio::runtime::Handle,
1520) -> Vec<Arc<dyn Provider>> {
1521 let mut models: Vec<Arc<dyn Provider>> = vec![gateway.clone()];
1522 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1523 models.extend(pluggable);
1524 models
1525}
1526
1527fn report_deferred_renderers(session: &ExtensionSession) {
1531 let Some(snap) = session.snapshot_arc() else {
1532 return;
1533 };
1534 let all = snap.renderers();
1535 let markdown = all
1536 .iter()
1537 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1538 .count();
1539 let message = all
1540 .iter()
1541 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1542 .count();
1543 let entry = all
1544 .iter()
1545 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1546 .count();
1547 if markdown + message + entry == 0 {
1548 return;
1549 }
1550 eprintln!(
1551 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1552 markdown, message, entry
1553 );
1554}
1555
1556#[derive(Debug, thiserror::Error)]
1558pub enum BuildError {
1559 #[error("Could not create the session directory: {0}")]
1560 SessionDir(String),
1561 #[error("Could not open the saved session: {0}")]
1562 SessionOpen(String),
1563 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1564 SessionNotFound { requested: String, dir: String },
1565 #[error("Could not build the harness: {0}")]
1566 HarnessCreate(String),
1567}
1568
1569fn build_models_with_extensions(
1579 resolved: &ResolvedModel,
1580 extension_session: &ExtensionSession,
1581 runtime: tokio::runtime::Handle,
1582) -> Vec<Arc<dyn Provider>> {
1583 let mut models: Vec<Arc<dyn Provider>> = vec![resolved.provider.clone() as Arc<dyn Provider>];
1584 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1585 models.extend(pluggable);
1586 models
1587}
1588
1589pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1592 if let Some(override_value) = args.trust_override {
1593 return override_value;
1594 }
1595 crate::config::project_trust_decision(cwd)
1596 .ok()
1597 .flatten()
1598 .unwrap_or(true)
1599}
1600
1601fn load_extensions(
1609 args: &Args,
1610 cwd: &Path,
1611 project_trusted: bool,
1612 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1613) -> ExtensionSession {
1614 let mut dirs = if args.dev_local_only {
1615 Vec::new()
1616 } else if project_trusted {
1617 extension_dirs(cwd)
1618 } else {
1619 global_extension_dirs()
1620 };
1621 dirs.extend(args.extensions_dir.iter().cloned());
1622 load_extensions_from_dirs(args, &dirs, action_bridge)
1623}
1624
1625fn load_extensions_from_dirs(
1626 args: &Args,
1627 dirs: &[PathBuf],
1628 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1629) -> ExtensionSession {
1630 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1631 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1638}
1639
1640fn merge_extension_tools(
1646 tools: &mut Vec<HarnessTool>,
1647 session: &ExtensionSession,
1648 args: &Args,
1649 tool_context: &rpi_extensions::ToolCallContext,
1650) {
1651 let Some(snapshot) = session.snapshot() else {
1652 return;
1653 };
1654 for et in snapshot.tools() {
1655 let name = &et.tool.name;
1656 if !tool_name_allowed(name, args) {
1657 continue;
1658 }
1659 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
1660 .with_context(tool_context.clone());
1661 let harness_tool = HarnessTool::new(Arc::new(adapter));
1662 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1663 Some(slot) => *slot = harness_tool,
1664 None => tools.push(harness_tool),
1665 }
1666 }
1667}
1668
1669pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1676 use rpi_tools::tools::bash::BashToolOptions;
1677 let default = std::env::var("RPI_BASH_TIMEOUT")
1678 .ok()
1679 .and_then(|v| v.parse::<f64>().ok())
1680 .unwrap_or(120.0);
1681 let settings = crate::settings::load_settings().unwrap_or_default();
1687 BashToolOptions {
1688 command_prefix: settings.shell_command_prefix.clone(),
1689 default_timeout: Some(default),
1690 prepare: None,
1691 }
1692}
1693
1694fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1695 if args.no_tools {
1696 return Vec::new();
1697 }
1698 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1707 (
1708 "read",
1709 HarnessTool::new(create_read_tool(ctx, None)).with_replay(ToolReplay::Safe),
1710 ),
1711 (
1712 "bash",
1713 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1714 ),
1715 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1716 ("write", HarnessTool::new(create_write_tool(ctx))),
1717 (
1718 "docs",
1719 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
1720 ),
1721 ];
1722
1723 #[cfg(target_os = "windows")]
1725 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
1726
1727 if args.tools.is_none() {
1729 if let Ok(settings) = crate::settings::load_settings() {
1730 if let Some(default_tools) = &settings.default_tools {
1731 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
1732 }
1733 }
1734 }
1735
1736 if args.no_builtin_tools {
1740 all.clear();
1741 }
1742
1743 if let Some(allow) = &args.tools {
1745 all.retain(|(name, _)| allow.iter().any(|a| a == name));
1746 }
1747 if let Some(deny) = &args.exclude_tools {
1749 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1750 }
1751
1752 all.into_iter().map(|(_, t)| t).collect()
1753}
1754
1755fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1759 filter_active_tool_names(
1760 tools.iter().map(|tool| tool.tool.schema().name.clone()),
1761 args,
1762 )
1763}
1764
1765pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
1768 if args.no_tools {
1769 return false;
1770 }
1771 if args
1772 .tools
1773 .as_ref()
1774 .is_some_and(|allow| !allow.iter().any(|value| value == name))
1775 {
1776 return false;
1777 }
1778 if args
1779 .exclude_tools
1780 .as_ref()
1781 .is_some_and(|deny| deny.iter().any(|value| value == name))
1782 {
1783 return false;
1784 }
1785 true
1786}
1787
1788pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
1789where
1790 I: IntoIterator<Item = String>,
1791{
1792 names
1793 .into_iter()
1794 .filter(|name| tool_name_allowed(name, args))
1795 .collect()
1796}
1797
1798async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1800 match selection {
1801 SessionSelection::Ephemeral => Ok(ephemeral_session()),
1802 SessionSelection::New { dir, .. } => {
1803 std::fs::create_dir_all(dir)
1806 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1807 let session = create_jsonl_session(dir, cwd)
1808 .await
1809 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1810 Ok(session)
1811 }
1812 SessionSelection::Latest
1813 | SessionSelection::ById { .. }
1814 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
1815 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
1816 }
1817}
1818
1819async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1825 match selection {
1829 SessionSelection::Latest => {
1830 let metas = list_session_metadata(cwd).await?;
1831 let Some(meta) = first_resumable_session(&metas) else {
1832 return Err(BuildError::SessionNotFound {
1833 requested: "the most recent session".to_string(),
1834 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1835 });
1836 };
1837 open_session(meta, cwd).await
1838 }
1839 SessionSelection::ById { id } => open_session_by_id(id, cwd).await.map_err(|e| match e {
1840 OpenError::NotFound { requested } => BuildError::SessionNotFound {
1841 requested,
1842 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1843 },
1844 OpenError::Other(msg) => BuildError::SessionOpen(msg),
1845 }),
1846 SessionSelection::ByExactId { id } => {
1847 let metas = list_session_metadata(cwd).await?;
1850 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
1851 return open_session(meta, cwd).await;
1852 }
1853 let dir = default_session_dir(Path::new(cwd));
1854 std::fs::create_dir_all(&dir)
1855 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1856 create_jsonl_session_with_id(&dir, cwd, Some(id.clone()))
1857 .await
1858 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
1859 }
1860 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
1861 }
1862}
1863
1864async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
1867 use rpi_harness::session::jsonl::{
1868 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1869 };
1870 use rpi_harness::session::types::{ForkOptions, SessionStorage};
1871 use rpi_tools::FileSystem;
1872
1873 let dir = default_session_dir(Path::new(cwd));
1874 std::fs::create_dir_all(&dir)
1875 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1876 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1877 let fs: Arc<dyn FileSystem> = env.clone();
1878 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1879 fs: fs.clone(),
1880 sessions_root: dir.to_string_lossy().into_owned(),
1881 clock: Arc::new(SystemClock),
1882 ids: Arc::new(DefaultIdGenerator::new()),
1883 });
1884 let metas = repo
1885 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
1886 .await
1887 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
1888 let source_meta = metas
1889 .iter()
1890 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
1891 .ok_or_else(|| BuildError::SessionNotFound {
1892 requested: format!("--fork {source}"),
1893 dir: dir.display().to_string(),
1894 })?;
1895 let fork_storage = repo
1896 .fork_typed(
1897 source_meta,
1898 &JsonlSessionCreateOptions {
1899 id: None,
1900 parent_session_id: Some(source_meta.id.clone()),
1901 cwd: cwd.to_string(),
1902 metadata: None,
1903 },
1904 &ForkOptions::default(),
1905 )
1906 .await
1907 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
1908 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
1909 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
1910 Ok(Session::new(storage_arc, None))
1911}
1912
1913pub enum OpenError {
1916 NotFound { requested: String },
1918 Other(String),
1920}
1921
1922impl std::fmt::Display for OpenError {
1923 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1924 match self {
1925 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
1926 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
1931 }
1932 }
1933}
1934
1935pub async fn list_session_metadata(
1938 cwd: &str,
1939) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
1940 use rpi_harness::session::jsonl::{
1941 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1942 };
1943 use rpi_tools::FileSystem;
1944
1945 let dir = default_session_dir(Path::new(cwd));
1946 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1947 let fs: Arc<dyn FileSystem> = env.clone();
1948 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1949 fs: fs.clone(),
1950 sessions_root: dir.to_string_lossy().into_owned(),
1951 clock: Arc::new(SystemClock),
1952 ids: Arc::new(DefaultIdGenerator::new()),
1953 });
1954 repo.list_typed(&JsonlSessionListOptions::default())
1955 .await
1956 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
1957}
1958
1959#[derive(Debug, Clone, Default, PartialEq, Eq)]
1975pub struct SessionSummary {
1976 pub name: Option<String>,
1978 pub preview: Option<String>,
1980 pub empty: bool,
1982 pub bytes: u64,
1984}
1985
1986const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
1988const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
1990const SUMMARY_PREVIEW_CHARS: usize = 80;
1992
1993pub fn summarize_session_file(path: &Path) -> SessionSummary {
1997 use std::io::{Read, Seek, SeekFrom};
1998
1999 let Ok(mut file) = std::fs::File::open(path) else {
2000 return SessionSummary::default();
2001 };
2002 let Ok(size) = file.metadata().map(|m| m.len()) else {
2003 return SessionSummary::default();
2004 };
2005 if size == 0 {
2006 return SessionSummary {
2007 name: None,
2008 preview: None,
2009 empty: true,
2010 bytes: 0,
2011 };
2012 }
2013
2014 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2017 let head_len = file.read(&mut head).unwrap_or(0);
2018 head.truncate(head_len);
2019 let head = String::from_utf8_lossy(&head);
2020 let mut lines: Vec<&str> = head.lines().collect();
2021 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2022 let empty = (size as usize) <= header_len + 1;
2024 if (head_len as u64) < size && !lines.is_empty() {
2027 lines.pop();
2028 }
2029
2030 let mut summary = SessionSummary {
2031 name: None,
2032 preview: None,
2033 empty,
2034 bytes: size,
2035 };
2036 for line in lines {
2037 apply_summary_line(line, &mut summary);
2038 }
2039
2040 if size > SUMMARY_HEAD_BYTES
2044 && file
2045 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2046 .is_ok()
2047 {
2048 let mut tail = Vec::new();
2049 if file.read_to_end(&mut tail).is_ok() {
2050 let tail = String::from_utf8_lossy(&tail);
2051 let mut tail_lines = tail.lines();
2052 tail_lines.next();
2054 for line in tail_lines {
2055 if let Some(name) = name_from_line(line) {
2056 summary.name = name;
2057 }
2058 }
2059 }
2060 }
2061 summary
2062}
2063
2064fn name_from_line(line: &str) -> Option<Option<String>> {
2067 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2068 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2069 return None;
2070 }
2071 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2072 return None;
2073 }
2074 Some(match value.get("name") {
2075 Some(serde_json::Value::String(name)) => Some(name.clone()),
2076 _ => None,
2077 })
2078}
2079
2080fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2083 if let Some(name) = name_from_line(line) {
2084 summary.name = name;
2085 return;
2086 }
2087 if summary.preview.is_some() {
2088 return;
2089 }
2090 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2091 return;
2092 };
2093 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2094 return;
2095 }
2096 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2097 return;
2098 }
2099 let Some(message) = value.get("message") else {
2100 return;
2101 };
2102 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2103 return;
2104 }
2105 let text = match message.get("content") {
2106 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2107 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2108 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2109 .then(|| block.get("text").and_then(|v| v.as_str()))
2110 .flatten()
2111 }),
2112 _ => None,
2113 };
2114 let Some(text) = text else { return };
2115 let collapsed = text
2116 .lines()
2117 .map(str::trim)
2118 .filter(|line| !line.is_empty())
2119 .collect::<Vec<_>>()
2120 .join(" ");
2121 if collapsed.is_empty() {
2122 return;
2123 }
2124 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2125}
2126
2127pub fn session_display_label(summary: &SessionSummary) -> String {
2131 if let Some(name) = summary.name.as_deref().map(str::trim) {
2132 if !name.is_empty() {
2133 return name.to_string();
2134 }
2135 }
2136 match summary.preview.as_deref().map(str::trim) {
2137 Some(preview) if !preview.is_empty() => preview.to_string(),
2138 _ => "(no messages)".to_string(),
2139 }
2140}
2141
2142pub fn format_session_bytes(bytes: u64) -> String {
2145 if bytes >= 1_048_576 {
2146 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2147 } else if bytes >= 1024 {
2148 format!("{}KB", bytes / 1024)
2149 } else {
2150 format!("{bytes}B")
2151 }
2152}
2153
2154pub fn short_session_id(id: &str) -> &str {
2157 &id[..id.len().min(8)]
2158}
2159
2160pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2169 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2170 let mut visit = |path: PathBuf| {
2171 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2172 return;
2173 }
2174 let modified = std::fs::metadata(&path)
2175 .and_then(|m| m.modified())
2176 .unwrap_or(std::time::UNIX_EPOCH);
2177 files.push((path, modified));
2178 };
2179 if let Ok(entries) = std::fs::read_dir(dir) {
2180 for entry in entries.flatten() {
2181 let path = entry.path();
2182 if path.is_dir() {
2183 if let Ok(inner) = std::fs::read_dir(&path) {
2184 for child in inner.flatten() {
2185 visit(child.path());
2186 }
2187 }
2188 } else {
2189 visit(path);
2190 }
2191 }
2192 }
2193 files.sort_by(|a, b| b.1.cmp(&a.1));
2194 files.into_iter().map(|(path, _)| path).collect()
2195}
2196
2197fn first_resumable_session<'a>(
2202 metas: &'a [rpi_harness::session::jsonl::JsonlSessionMetadata],
2203) -> Option<&'a rpi_harness::session::jsonl::JsonlSessionMetadata> {
2204 metas
2205 .iter()
2206 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2207 .or_else(|| metas.first())
2208}
2209
2210pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2214 let metas = list_session_metadata(cwd)
2215 .await
2216 .map_err(|e| OpenError::Other(e.to_string()))?;
2217 let Some(meta) = metas
2218 .iter()
2219 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2220 else {
2221 return Err(OpenError::NotFound {
2222 requested: format!("session {id}"),
2223 });
2224 };
2225 open_session(meta, cwd).await.map_err(|e| {
2226 OpenError::Other(match e {
2227 BuildError::SessionOpen(reason) => reason,
2231 other => other.to_string(),
2232 })
2233 })
2234}
2235
2236fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
2261 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
2262 tool_context.set_session_id(id);
2263}
2264
2265pub(crate) async fn set_active_session(
2276 harness: &AgentHarness,
2277 session: Session,
2278 tool_context: Option<&rpi_extensions::ToolCallContext>,
2279) -> Result<(), String> {
2280 if let Ok(metadata) = session.get_metadata().await {
2281 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2282 if let Some(context) = tool_context {
2283 context.set_session_id(metadata.id);
2284 }
2285 }
2286 harness
2287 .set_session(session)
2288 .await
2289 .map_err(|e| e.to_string())
2290}
2291
2292pub(crate) async fn fork_session_storage(
2299 harness: &AgentHarness,
2300 cwd: &str,
2301) -> Result<Session, String> {
2302 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2303 use rpi_tools::FileSystem;
2304
2305 let dir = default_session_dir(Path::new(cwd));
2306 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2307 let fs: Arc<dyn FileSystem> = env.clone();
2308 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2309 fs,
2310 sessions_root: dir.to_string_lossy().into_owned(),
2311 clock: Arc::new(SystemClock),
2312 ids: Arc::new(DefaultIdGenerator::new()),
2313 });
2314 let id = harness.session().storage().metadata().id.clone();
2317 let metas = list_session_metadata(cwd)
2318 .await
2319 .map_err(|e| e.to_string())?;
2320 let Some(source) = metas.iter().find(|m| m.id == id) else {
2321 return Err(format!("current session {id} not found on disk"));
2322 };
2323 let fork_storage = repo
2324 .fork_typed(
2325 source,
2326 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2327 id: None,
2328 parent_session_id: Some(source.id.clone()),
2329 cwd: cwd.to_string(),
2330 metadata: None,
2331 },
2332 &rpi_harness::session::types::ForkOptions::default(),
2333 )
2334 .await
2335 .map_err(|e| e.to_string())?;
2336 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2337 Ok(Session::new(Arc::new(fork_storage), None))
2338}
2339
2340static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2342static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2345
2346fn claim_session_lock(session_path: &str) -> Result<(), String> {
2355 let lock_path = PathBuf::from(format!("{session_path}.lock"));
2356 if SESSION_LOCK_PATHS
2357 .get_or_init(|| Mutex::new(HashSet::new()))
2358 .lock()
2359 .unwrap()
2360 .contains(&lock_path)
2361 {
2362 return Ok(());
2363 }
2364 let file = std::fs::OpenOptions::new()
2365 .create(true)
2366 .write(true)
2367 .open(&lock_path)
2368 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2369 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2370 return Err(format!(
2371 "session {session_path} is already open in another rpi process"
2372 ));
2373 }
2374 SESSION_LOCKS
2375 .get_or_init(|| Mutex::new(Vec::new()))
2376 .lock()
2377 .unwrap()
2378 .push(file);
2379 SESSION_LOCK_PATHS
2380 .get_or_init(|| Mutex::new(HashSet::new()))
2381 .lock()
2382 .unwrap()
2383 .insert(lock_path);
2384 Ok(())
2385}
2386
2387async fn open_session(
2390 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2391 cwd: &str,
2392) -> Result<Session, BuildError> {
2393 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2394 use rpi_harness::session::types::SessionStorage;
2395 use rpi_tools::FileSystem;
2396
2397 let dir = default_session_dir(Path::new(cwd));
2398 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2399 let fs: Arc<dyn FileSystem> = env.clone();
2400 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2401 fs: fs.clone(),
2402 sessions_root: dir.to_string_lossy().into_owned(),
2403 clock: Arc::new(SystemClock),
2404 ids: Arc::new(DefaultIdGenerator::new()),
2405 });
2406 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2407 let storage = repo
2408 .open_by_jsonl_metadata(meta)
2409 .await
2410 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2411 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2415 Some(Path::new(&meta.path)),
2416 Path::new(&meta.cwd),
2417 Path::new(cwd),
2418 ) {
2419 eprintln!(
2420 "warning: {}",
2421 crate::session_cwd::format_missing_session_cwd_error(&issue)
2422 );
2423 }
2424 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2425 Ok(Session::new(storage_arc, None))
2426}
2427
2428fn ephemeral_session() -> Session {
2430 let storage = Arc::new(InMemorySessionStorage::new(
2431 SessionMetadata {
2432 id: "ephemeral".into(),
2433 created_at: 0,
2434 parent_session_id: None,
2435 },
2436 Arc::new(SystemClock),
2437 Arc::new(DefaultIdGenerator::new()),
2438 ));
2439 Session::new(storage, None)
2440}
2441
2442pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2453 create_jsonl_session_with_id(dir, cwd, None).await
2454}
2455
2456pub(crate) async fn create_jsonl_session_with_id(
2460 dir: &Path,
2461 cwd: &str,
2462 id: Option<String>,
2463) -> Result<Session, String> {
2464 use rpi_harness::session::jsonl::{
2465 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2466 };
2467 use rpi_tools::FileSystem;
2468
2469 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2472 let fs: Arc<dyn FileSystem> = env.clone();
2473
2474 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2475 fs: fs.clone(),
2476 sessions_root: dir.to_string_lossy().into_owned(),
2477 clock: Arc::new(SystemClock),
2478 ids: Arc::new(DefaultIdGenerator::new()),
2479 });
2480
2481 let opts = JsonlSessionCreateOptions {
2482 id, parent_session_id: None,
2484 cwd: cwd.to_string(),
2485 metadata: None,
2486 };
2487 let storage = repo
2488 .create_typed(&opts)
2489 .await
2490 .map_err(|e| format!("create session: {e}"))?;
2491 claim_session_lock(&storage.jsonl_metadata().path)
2492 .map_err(|e| format!("create session: {e}"))?;
2493 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2495 Ok(Session::new(storage_arc, None))
2496}
2497
2498fn read_append_target(target: &str) -> Option<String> {
2501 let path = Path::new(target);
2502 if path.is_file() {
2503 std::fs::read_to_string(path).ok()
2504 } else {
2505 None
2506 }
2507}
2508
2509#[cfg(test)]
2510mod tests {
2511 use super::*;
2512 use crate::args::Args;
2513
2514 fn scratch(name: &str) -> PathBuf {
2516 let nanos = std::time::SystemTime::now()
2517 .duration_since(std::time::UNIX_EPOCH)
2518 .unwrap()
2519 .as_nanos();
2520 let path = std::env::temp_dir().join(format!(
2521 "rpi-summary-{}-{nanos}-{name}.jsonl",
2522 std::process::id()
2523 ));
2524 path
2525 }
2526
2527 #[test]
2528 fn summary_marks_a_header_only_session_empty() {
2529 let path = scratch("empty");
2530 std::fs::write(
2531 &path,
2532 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2533 )
2534 .unwrap();
2535 let summary = summarize_session_file(&path);
2536 assert!(summary.empty, "a header-only file has nothing to resume");
2537 assert!(summary.preview.is_none());
2538 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
2539 std::fs::remove_file(&path).ok();
2540 }
2541
2542 #[test]
2543 fn summary_reads_the_first_user_prompt_and_name() {
2544 let path = scratch("content");
2545 let body = concat!(
2546 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2547 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
2548 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
2549 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
2550 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
2551 );
2552 std::fs::write(&path, body).unwrap();
2553 let summary = summarize_session_file(&path);
2554 assert!(!summary.empty);
2555 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
2556 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
2557 std::fs::remove_file(&path).ok();
2558 }
2559
2560 #[test]
2561 fn summary_extracts_a_block_style_user_prompt() {
2562 let path = scratch("blocks");
2563 let body = concat!(
2564 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2565 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
2566 );
2567 std::fs::write(&path, body).unwrap();
2568 let summary = summarize_session_file(&path);
2569 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
2570 assert_eq!(session_display_label(&summary), "line one line two");
2571 std::fs::remove_file(&path).ok();
2572 }
2573
2574 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
2575 rpi_harness::session::jsonl::JsonlSessionMetadata {
2576 id: id.to_string(),
2577 created_at: 0,
2578 cwd: ".".to_string(),
2579 path: path.to_string_lossy().into_owned(),
2580 modified_at: 0,
2581 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
2582 parent_session_id: None,
2583 legacy_parent_session_path: None,
2584 metadata: None,
2585 }
2586 }
2587
2588 #[test]
2589 fn continue_skips_newer_empty_sessions() {
2590 let empty = scratch("newest-empty");
2591 std::fs::write(
2592 &empty,
2593 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2594 )
2595 .unwrap();
2596 let content = scratch("older-content");
2597 std::fs::write(
2598 &content,
2599 concat!(
2600 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2601 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
2602 ),
2603 )
2604 .unwrap();
2605
2606 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
2607 let picked = first_resumable_session(&metas).expect("a resumable session");
2608 assert_eq!(picked.id, "old");
2609
2610 let all_empty = vec![meta_for(&empty, "empty")];
2613 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
2614
2615 std::fs::remove_file(&empty).ok();
2616 std::fs::remove_file(&content).ok();
2617 }
2618
2619 #[test]
2620 fn list_session_files_sync_walks_the_cwd_subdirectory() {
2621 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
2622 let nested = root.join("--proj--");
2623 std::fs::create_dir_all(&nested).unwrap();
2624 let file = nested.join("2026-01-01_a.jsonl");
2625 std::fs::write(&file, "{}\n").unwrap();
2626 let found = list_session_files_sync(&root);
2627 assert!(found.iter().any(|p| p == &file));
2628 std::fs::remove_dir_all(&root).ok();
2629 }
2630
2631 #[test]
2632 fn default_prompt_mentions_cwd_and_tools() {
2633 let p = default_system_prompt("/tmp/proj", false);
2634 assert!(p.contains("/tmp/proj"));
2635 assert!(p.contains("read"));
2636 assert!(p.contains("bash"));
2637 assert!(p.contains("edit"));
2638 assert!(p.contains("write"));
2639 assert!(p.contains("docs"));
2640 assert!(!p.contains("- grep"));
2641 assert!(!p.contains("- find"));
2642 assert!(!p.contains("- ls"));
2643 assert!(!p.contains("powershell"));
2644 }
2645
2646 #[test]
2647 fn default_prompt_instructs_language_matching() {
2648 let p = default_system_prompt("/tmp/proj", false);
2652 let lower = p.to_lowercase();
2653 assert!(
2654 lower.contains("same language") && lower.contains("detect"),
2655 "the prompt must tell the model to detect and mirror the user's language: {p}"
2656 );
2657 }
2658
2659 #[test]
2660 fn default_prompt_keeps_working_state_in_the_visible_channel() {
2661 let p = default_system_prompt("/tmp/proj", false);
2669 let lower = p.to_lowercase();
2670 assert!(
2672 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
2673 "the prompt must explain that reasoning does not survive the turn: {p}"
2674 );
2675 assert!(
2677 lower.contains("visible") && lower.contains("todo tool"),
2678 "the prompt must point at visible text and the todo tool: {p}"
2679 );
2680 assert!(
2682 lower.contains("re-stating the same plan"),
2683 "the prompt must call out plan re-statement as a bug: {p}"
2684 );
2685 assert!(
2687 lower.contains("already shown earlier"),
2688 "the prompt must discourage re-fetching known content: {p}"
2689 );
2690 }
2691
2692 #[test]
2693 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
2694 let p = default_system_prompt("/tmp/proj", true);
2701 let lower = p.to_lowercase();
2702 assert!(
2703 lower.contains("carried back"),
2704 "a replaying model must be told its reasoning survives: {p}"
2705 );
2706 assert!(
2707 !lower.contains("reasoning is not"),
2708 "the replayed variant must not claim reasoning is dropped: {p}"
2709 );
2710 assert!(
2711 lower.contains("do not re-derive"),
2712 "the replayed variant must forbid re-deriving the plan: {p}"
2713 );
2714 assert!(
2715 lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
2716 "the anti-repetition rule must survive the flip: {p}"
2717 );
2718 assert!(
2719 lower.contains("already shown earlier"),
2720 "the re-read rule must survive the flip: {p}"
2721 );
2722 }
2723
2724 #[test]
2732 fn only_read_only_tools_are_replayable() {
2733 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2734 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2735 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2736 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2737 let tools = build_tools(&context, &Args::default());
2738
2739 let replay_of = |name: &str| {
2740 tools
2741 .iter()
2742 .find(|tool| tool.tool.schema().name == name)
2743 .map(|tool| tool.replay)
2744 };
2745
2746 for read_only in ["read", "docs"] {
2747 assert_eq!(
2748 replay_of(read_only),
2749 Some(ToolReplay::Safe),
2750 "{read_only} is side-effect free, so recovery may re-run it"
2751 );
2752 }
2753 for mutating in ["bash", "edit", "write", "powershell"] {
2754 if let Some(replay) = replay_of(mutating) {
2755 assert_eq!(
2756 replay,
2757 ToolReplay::Never,
2758 "{mutating} must never be replayed: re-running it would duplicate its effect"
2759 );
2760 }
2761 }
2762 }
2763
2764 #[test]
2765 fn default_tools_include_docs_lookup() {
2766 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2767 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2768 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2769 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2770 let names: Vec<String> = build_tools(&context, &Args::default())
2771 .iter()
2772 .map(|tool| tool.tool.schema().name.clone())
2773 .collect();
2774
2775 assert!(names.contains(&"docs".to_string()));
2780 for legacy in ["grep", "find", "ls"] {
2781 assert!(
2782 !names.contains(&legacy.to_string()),
2783 "legacy tool {legacy} should not be registered by default"
2784 );
2785 }
2786 }
2787
2788 #[test]
2789 fn reload_settings_preflight_respects_trust() {
2790 struct RestoreConfigDir(Option<std::ffi::OsString>);
2791
2792 impl Drop for RestoreConfigDir {
2793 fn drop(&mut self) {
2794 match self.0.take() {
2795 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2796 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2797 }
2798 }
2799 }
2800
2801 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2802 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2803 let tmp = tempfile::tempdir().unwrap();
2804 let agent = tmp.path().join("agent");
2805 let cwd = tmp.path().join("project");
2806 std::fs::create_dir_all(&agent).unwrap();
2807 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2808 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2809 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
2810 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2811 let args = Args::default();
2812
2813 let trusted = validate_settings_for_reload(&args, &cwd, true);
2814 let untrusted = validate_settings_for_reload(&args, &cwd, false);
2815 assert!(trusted
2816 .unwrap_err()
2817 .contains("could not load project settings"));
2818 assert!(untrusted.is_ok());
2819
2820 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2821 assert!(validate_settings_for_reload(&args, &cwd, false)
2822 .unwrap_err()
2823 .contains("could not load global settings"));
2824
2825 let local_only = Args {
2826 dev_local_only: true,
2827 ..Args::default()
2828 };
2829 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
2830 }
2831
2832 #[tokio::test(flavor = "current_thread")]
2833 async fn reload_preserves_live_resources_when_settings_break() {
2834 struct RestoreConfigDir(Option<std::ffi::OsString>);
2835
2836 impl Drop for RestoreConfigDir {
2837 fn drop(&mut self) {
2838 match self.0.take() {
2839 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2840 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2841 }
2842 }
2843 }
2844
2845 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2846 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2847 let tmp = tempfile::tempdir().unwrap();
2848 let agent = tmp.path().join("agent");
2849 let cwd = tmp.path().join("project");
2850 let skill_dir = tmp.path().join("configured-skills");
2851 std::fs::create_dir_all(&agent).unwrap();
2852 std::fs::create_dir_all(&cwd).unwrap();
2853 std::fs::create_dir_all(&skill_dir).unwrap();
2854 std::fs::write(
2855 skill_dir.join("SKILL.md"),
2856 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
2857 )
2858 .unwrap();
2859 std::fs::write(
2860 agent.join("settings.json"),
2861 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2862 )
2863 .unwrap();
2864 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2865
2866 let resolved = crate::provider::resolve(
2867 Some("anthropic"),
2868 Some(crate::provider::DEFAULT_MODEL_ID),
2869 None,
2870 Some("test-key"),
2871 None,
2872 )
2873 .unwrap();
2874 let args = Args {
2875 trust_override: Some(false),
2876 no_session: true,
2877 no_extensions: true,
2878 no_prompt_templates: true,
2879 no_context_files: true,
2880 system_prompt: Some("stable system prompt".into()),
2881 ..Args::default()
2882 };
2883 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2884 let before_resources = harness.get_resources().await.unwrap();
2885 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
2886 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
2887 let before_prompt = harness.get_system_prompt().await.unwrap();
2888 let before_tools: Vec<String> = harness
2889 .get_tools()
2890 .await
2891 .unwrap()
2892 .iter()
2893 .map(|tool| tool.tool.schema().name.clone())
2894 .collect();
2895 let before_bridge = context
2896 .action_bridge
2897 .lock()
2898 .unwrap()
2899 .as_ref()
2900 .unwrap()
2901 .clone();
2902
2903 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2904 let outcome = reload_extension_resources(&harness, &context).await;
2905
2906 assert!(outcome.had_warnings);
2907 assert!(outcome.summary.contains("Settings reload failed"));
2908 assert!(outcome.summary.contains("could not load global settings"));
2909 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
2910 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
2911 let after_tools: Vec<String> = harness
2912 .get_tools()
2913 .await
2914 .unwrap()
2915 .iter()
2916 .map(|tool| tool.tool.schema().name.clone())
2917 .collect();
2918 assert_eq!(after_tools, before_tools);
2919 let after_bridge = context
2920 .action_bridge
2921 .lock()
2922 .unwrap()
2923 .as_ref()
2924 .unwrap()
2925 .clone();
2926 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
2927 }
2928
2929 #[test]
2930 fn reload_preparation_rejects_settings_changed_during_derivation() {
2931 struct RestoreConfigDir(Option<std::ffi::OsString>);
2932
2933 impl Drop for RestoreConfigDir {
2934 fn drop(&mut self) {
2935 match self.0.take() {
2936 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2937 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2938 }
2939 }
2940 }
2941
2942 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2943 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2944 let tmp = tempfile::tempdir().unwrap();
2945 let agent = tmp.path().join("agent");
2946 let cwd = tmp.path().join("project");
2947 let first_skill_dir = tmp.path().join("first-skills");
2948 let second_skill_dir = tmp.path().join("second-skills");
2949 std::fs::create_dir_all(&agent).unwrap();
2950 std::fs::create_dir_all(&cwd).unwrap();
2951 std::fs::write(
2952 agent.join("settings.json"),
2953 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
2954 )
2955 .unwrap();
2956 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2957 let args = Args {
2958 trust_override: Some(false),
2959 no_extensions: true,
2960 no_prompt_templates: true,
2961 ..Args::default()
2962 };
2963
2964 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
2965 std::fs::write(
2966 agent.join("settings.json"),
2967 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
2968 )
2969 .unwrap();
2970 });
2971
2972 let error = result
2973 .err()
2974 .expect("settings mutation must fail preparation");
2975 assert!(error.contains("settings changed while reload inputs were being prepared"));
2976 }
2977
2978 #[tokio::test(flavor = "current_thread")]
2979 async fn reload_uses_frozen_settings_inputs_after_preparation() {
2980 struct RestoreConfigDir(Option<std::ffi::OsString>);
2981
2982 impl Drop for RestoreConfigDir {
2983 fn drop(&mut self) {
2984 match self.0.take() {
2985 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2986 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2987 }
2988 }
2989 }
2990
2991 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2992 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2993 let tmp = tempfile::tempdir().unwrap();
2994 let agent = tmp.path().join("agent");
2995 let cwd = tmp.path().join("project");
2996 let skill_dir = tmp.path().join("configured-skills");
2997 std::fs::create_dir_all(&agent).unwrap();
2998 std::fs::create_dir_all(&cwd).unwrap();
2999 std::fs::create_dir_all(&skill_dir).unwrap();
3000 std::fs::write(
3001 skill_dir.join("SKILL.md"),
3002 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3003 )
3004 .unwrap();
3005 std::fs::write(
3006 agent.join("settings.json"),
3007 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3008 )
3009 .unwrap();
3010 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3011
3012 let resolved = crate::provider::resolve(
3013 Some("anthropic"),
3014 Some(crate::provider::DEFAULT_MODEL_ID),
3015 None,
3016 Some("test-key"),
3017 None,
3018 )
3019 .unwrap();
3020 let args = Args {
3021 trust_override: Some(false),
3022 no_session: true,
3023 no_extensions: true,
3024 no_prompt_templates: true,
3025 no_context_files: true,
3026 system_prompt: Some("stable system prompt".into()),
3027 ..Args::default()
3028 };
3029 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3030
3031 let outcome = reload_extension_resources_inner(&harness, &context, || {
3032 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3033 })
3034 .await;
3035
3036 assert!(!outcome.summary.contains("Settings reload failed"));
3037 assert_eq!(
3038 outcome.summary,
3039 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3040 );
3041 let resources = harness.get_resources().await.unwrap();
3042 let skills = resources.skills.as_ref().unwrap();
3043 assert_eq!(skills.len(), 1);
3044 assert_eq!(skills[0].name, "frozen-skill");
3045 }
3046
3047 #[tokio::test]
3048 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3049 let tmp = tempfile::tempdir().unwrap();
3050 let skill_path = tmp.path().join("explicit-skill.md");
3051 std::fs::write(
3052 &skill_path,
3053 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3054 )
3055 .unwrap();
3056 let prompt_path = tmp.path().join("explicit-prompt.md");
3057 std::fs::write(
3058 &prompt_path,
3059 "---\ndescription: Explicit prompt\n---\nPrompt body",
3060 )
3061 .unwrap();
3062
3063 let args = Args {
3064 skill: vec![skill_path.clone()],
3065 prompt_template: vec![prompt_path.clone()],
3066 ..Args::default()
3067 };
3068 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3069 let prompt_paths =
3070 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3071 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3072 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3073
3074 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3075 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3076 assert_eq!(skills.skills[0].name, "explicit-skill");
3077
3078 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3079 assert_eq!(
3080 prompts.prompt_templates.len(),
3081 1,
3082 "{:?}",
3083 prompts.diagnostics
3084 );
3085 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3086 }
3087
3088 #[test]
3089 fn tool_policy_applies_to_rust_and_js_active_names() {
3090 let names = vec![
3091 "read".to_string(),
3092 "ask_user_question".to_string(),
3093 "write".to_string(),
3094 ];
3095
3096 let args = Args {
3097 tools: Some(vec!["read".into(), "ask_user_question".into()]),
3098 ..Args::default()
3099 };
3100 assert_eq!(
3101 filter_active_tool_names(names.clone(), &args),
3102 vec!["read", "ask_user_question"]
3103 );
3104
3105 let args = Args {
3106 exclude_tools: Some(vec!["ask_user_question".into()]),
3107 ..Args::default()
3108 };
3109 assert_eq!(
3110 filter_active_tool_names(names.clone(), &args),
3111 vec!["read", "write"]
3112 );
3113
3114 let args = Args {
3115 no_tools: true,
3116 ..Args::default()
3117 };
3118 assert!(filter_active_tool_names(names, &args).is_empty());
3119 }
3120
3121 #[test]
3122 fn select_ephemeral_when_no_session() {
3123 let args = Args {
3124 no_session: true,
3125 ..Args::default()
3126 };
3127 let cwd = Path::new("/tmp");
3128 assert!(matches!(
3129 select_session(&args, cwd),
3130 SessionSelection::Ephemeral
3131 ));
3132 }
3133
3134 #[test]
3135 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3136 let defaults = Args::default();
3137 assert!(resolve_project_trust(
3138 &defaults,
3139 Path::new("C:/definitely-not-a-project")
3140 ));
3141
3142 let denied = Args {
3143 trust_override: Some(false),
3144 ..Args::default()
3145 };
3146 assert!(!resolve_project_trust(
3147 &denied,
3148 Path::new("C:/definitely-not-a-project")
3149 ));
3150 }
3151
3152 #[tokio::test(flavor = "current_thread")]
3153 async fn build_preserves_the_supplied_startup_project_snapshot() {
3154 struct RestoreConfigDir(Option<std::ffi::OsString>);
3155
3156 impl Drop for RestoreConfigDir {
3157 fn drop(&mut self) {
3158 match self.0.take() {
3159 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3160 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3161 }
3162 }
3163 }
3164
3165 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3166 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3167 let tmp = tempfile::tempdir().unwrap();
3168 let agent = tmp.path().join("agent");
3169 let cwd = tmp.path().join("project");
3170 std::fs::create_dir_all(&agent).unwrap();
3171 std::fs::create_dir_all(&cwd).unwrap();
3172 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3173
3174 let resolved = crate::provider::resolve(
3175 Some("anthropic"),
3176 Some(crate::provider::DEFAULT_MODEL_ID),
3177 None,
3178 Some("test-key"),
3179 None,
3180 )
3181 .unwrap();
3182 let args = Args {
3183 trust_override: Some(false),
3184 no_session: true,
3185 no_tools: true,
3186 no_extensions: true,
3187 no_skills: true,
3188 no_prompt_templates: true,
3189 no_context_files: true,
3190 system_prompt: Some("test prompt".into()),
3191 ..Args::default()
3192 };
3193
3194 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3195
3196 assert_eq!(context.cwd, cwd);
3197 assert!(context.project_trusted);
3198 }
3199
3200 #[tokio::test(flavor = "current_thread")]
3201 async fn build_applies_the_name_flag_to_the_session() {
3202 struct RestoreConfigDir(Option<std::ffi::OsString>);
3203
3204 impl Drop for RestoreConfigDir {
3205 fn drop(&mut self) {
3206 match self.0.take() {
3207 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3208 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3209 }
3210 }
3211 }
3212
3213 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3214 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3215 let tmp = tempfile::tempdir().unwrap();
3216 let agent = tmp.path().join("agent");
3217 let cwd = tmp.path().join("project");
3218 std::fs::create_dir_all(&agent).unwrap();
3219 std::fs::create_dir_all(&cwd).unwrap();
3220 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3221
3222 let resolved = crate::provider::resolve(
3223 Some("anthropic"),
3224 Some(crate::provider::DEFAULT_MODEL_ID),
3225 None,
3226 Some("test-key"),
3227 None,
3228 )
3229 .unwrap();
3230 let args = Args {
3231 trust_override: Some(false),
3232 no_tools: true,
3233 no_extensions: true,
3234 no_skills: true,
3235 no_prompt_templates: true,
3236 no_context_files: true,
3237 system_prompt: Some("test prompt".into()),
3238 name: Some("smoke name".into()),
3239 session_dir: Some(cwd.join("sessions")),
3240 ..Args::default()
3241 };
3242
3243 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3244 assert_eq!(
3245 harness.get_name().await.unwrap().as_deref(),
3246 Some("smoke name")
3247 );
3248 }
3249
3250 #[test]
3251 fn select_latest_for_continue_and_resume() {
3252 let args = Args {
3253 continue_session: true,
3254 ..Args::default()
3255 };
3256 let cwd = Path::new("/tmp");
3257 assert!(matches!(
3258 select_session(&args, cwd),
3259 SessionSelection::Latest
3260 ));
3261
3262 let args = Args {
3263 resume: true,
3264 ..Args::default()
3265 };
3266 assert!(matches!(
3267 select_session(&args, cwd),
3268 SessionSelection::Latest
3269 ));
3270 }
3271
3272 #[test]
3273 fn select_by_id_for_session_flag() {
3274 let args = Args {
3275 session: Some("01a02ece".into()),
3276 ..Args::default()
3277 };
3278 let cwd = Path::new("/tmp");
3279 assert!(matches!(
3280 select_session(&args, cwd),
3281 SessionSelection::ById { id } if id == "01a02ece"
3282 ));
3283 }
3284
3285 #[test]
3286 fn select_new_with_custom_dir() {
3287 let args = Args {
3288 session_dir: Some(PathBuf::from("/tmp/sess")),
3289 ..Args::default()
3290 };
3291 let cwd = Path::new("/tmp");
3292 match select_session(&args, cwd) {
3293 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
3294 other => panic!("expected New, got {other:?}"),
3295 }
3296 }
3297
3298 #[test]
3299 fn select_new_default_dir() {
3300 let args = Args::default();
3301 let cwd = Path::new("/proj");
3302 match select_session(&args, cwd) {
3303 SessionSelection::New { dir, .. } => {
3304 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
3305 }
3306 other => panic!("expected New, got {other:?}"),
3307 }
3308 }
3309
3310 #[test]
3311 fn default_session_dir_only_uses_rpi() {
3312 let tmp = tempfile::tempdir().unwrap();
3313 let cwd = tmp.path();
3314 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3315 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3316 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3317 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3318 }
3319
3320 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3321 async fn ephemeral_session_builds_roundtrips() {
3322 let s = ephemeral_session();
3326 let leaf = s.get_leaf_id().await;
3327 assert!(leaf.is_ok());
3328 }
3329
3330 #[test]
3348 fn host_private_env_matches_the_declared_contract() {
3349 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
3351 assert_eq!(
3352 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
3353 [
3354 "LANGFUSE_PI_PARENT_TRACE_ID",
3355 "LANGFUSE_PI_PARENT_SPAN_ID",
3356 "LANGFUSE_PI_PARENT_SESSION_ID",
3357 "LANGFUSE_PI_PARENT_DEPTH",
3358 ],
3359 "these strings are what rpi-langfuse (a different repo) matches; a \
3360 change here is a breaking cross-repo change, not a refactor"
3361 );
3362
3363 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
3366 .iter()
3367 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
3368 {
3369 assert!(
3370 rpi_tools::is_host_private_env(name),
3371 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
3372 rpi_tools::HOST_PRIVATE_ENV
3373 );
3374 }
3375
3376 for visible in [
3379 crate::args::RPI_OFFLINE_ENV,
3380 crate::config::CONFIG_DIR_ENV,
3381 "PATH",
3382 ] {
3383 assert!(
3384 !rpi_tools::is_host_private_env(visible),
3385 "{visible} describes the user's configuration and must still reach the shell"
3386 );
3387 }
3388 }
3389}