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, project_extension_dirs,
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 Bash commands using Bash/POSIX syntax, including on Windows
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- The bash tool runs Bash, even on Windows. Use ls, find, rg, and POSIX pipelines;
114 do not pass PowerShell cmdlets such as Get-ChildItem, Select-Object, or Select-String.
115 If a powershell tool is available, use it for PowerShell commands.
116{working_state_rule}
117- Track multi-step work with the todo tool instead of re-listing the plan in prose: add the
118 steps once, then mark them done as you go. Re-stating the same plan without acting on it is
119 a bug, not progress.
120- Read only what you need. If a file or search result was already shown earlier in this
121 conversation, use it instead of fetching it again.
122- When unsure about rpi commands, extensions, Pi package compatibility, or .rpi configuration, consult the project documentation before guessing
123
124Current working directory: {cwd}"
125 )
126}
127
128#[derive(Debug, Clone)]
135pub enum SessionSelection {
136 Ephemeral,
138 New { dir: PathBuf, name: Option<String> },
140 Latest { dir: PathBuf },
142 ById { id: String, dir: PathBuf },
145 ByExactId { id: String, dir: PathBuf },
147 Fork { source: String },
150}
151
152pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
154 if args.no_session {
155 return SessionSelection::Ephemeral;
156 }
157 let dir = args
161 .session_dir
162 .clone()
163 .or_else(|| {
164 crate::settings::load_settings()
165 .ok()
166 .and_then(|settings| settings.session_dir)
167 })
168 .unwrap_or_else(|| default_session_dir(cwd));
169 if args.continue_session || args.resume {
170 return SessionSelection::Latest { dir };
171 }
172 if let Some(s) = &args.fork {
173 return SessionSelection::Fork { source: s.clone() };
174 }
175 if let Some(s) = &args.session_id {
176 return SessionSelection::ByExactId { id: s.clone(), dir };
177 }
178 if let Some(s) = &args.session {
179 return SessionSelection::ById { id: s.clone(), dir };
180 }
181 SessionSelection::New {
182 dir,
183 name: args.name.clone(),
184 }
185}
186
187pub fn default_session_dir(cwd: &Path) -> PathBuf {
189 cwd.join(".rpi").join("sessions")
190}
191
192pub async fn build(
210 resolved: &ResolvedModel,
211 args: &Args,
212 cwd: &Path,
213 project_trusted: bool,
214) -> Result<
215 (
216 AgentHarness,
217 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
218 ReloadContext,
219 ),
220 BuildError,
221> {
222 let cwd_str = cwd.to_string_lossy().to_string();
223 let tool_context = rpi_extensions::ToolCallContext::new(cwd_str.clone());
228 if !project_trusted && args.verbose {
229 eprintln!(
230 "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)"
231 );
232 }
233 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
252 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
253 })?;
254 let catalog = crate::provider::available_catalog(resolved);
255 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
256 catalog.clone(),
257 cwd.to_path_buf(),
258 runtime.clone(),
259 args.unknown_flags.clone(),
260 tool_context.clone(),
261 );
262 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = Arc::new(action_host);
263 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
273 let ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
279 let ext_status_mailbox = rpi_extensions::ExtensionStatusMailbox::new();
283 let editor_text_mailbox = rpi_extensions::EditorTextMailbox::new();
288 let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
289 runtime.clone(),
290 host_arc,
291 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
292 ui_dialog_mailbox.clone(),
293 ext_status_mailbox.clone(),
294 editor_text_mailbox.clone(),
295 );
296
297 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
299 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
300 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
301 let _registry = Arc::new(MutationQueueRegistry::new());
302 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
305
306 let mut tools = build_tools(&ctx, args);
307
308 let extension_session = if args.no_extensions {
316 ExtensionSession::none()
317 } else {
318 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
319 };
320 if args.verbose {
321 if let Some(s) = extension_session.summary() {
322 eprintln!("extensions: {s}");
323 }
324 report_deferred_renderers(&extension_session);
325 }
326 merge_extension_tools(&mut tools, &extension_session, args, &tool_context);
327 let active = filter_active_tool_names(active_tool_names(&tools, args), args);
328
329 let selection = select_session(args, cwd);
331 let session = build_session(&selection, &cwd_str).await?;
332 if let Ok(metadata) = session.get_metadata().await {
336 publish_session_identity(&tool_context, &metadata.id);
337 }
338 if let Some(requested) = args.name.as_deref() {
344 let name = requested.trim();
345 let name = if name.is_empty() { None } else { Some(name) };
346 if let Err(error) = session.set_name(name).await {
347 if args.verbose {
348 eprintln!("warning: could not set session name: {error}");
349 }
350 }
351 }
352 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
362 let base_prompt = match args.system_prompt.as_deref() {
363 Some(explicit) => explicit.to_string(),
364 None if project_trusted => match discover_system_prompt_file(cwd) {
365 Some(path) => std::fs::read_to_string(&path)
366 .unwrap_or_else(|_| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
367 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
368 },
369 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
370 };
371
372 let mut append_texts: Vec<String> = Vec::new();
379 for extra in &args.append_system_prompt {
380 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
381 append_texts.push(text);
382 }
383 if args.append_system_prompt.is_empty() {
384 if let Some(path) = project_trusted
385 .then(|| discover_append_system_prompt_file(cwd))
386 .flatten()
387 {
388 if let Ok(text) = std::fs::read_to_string(&path) {
389 append_texts.push(text);
390 }
391 }
392 }
393 let append_join = if append_texts.is_empty() {
394 None
395 } else {
396 Some(append_texts.join("\n\n"))
397 };
398
399 let agent_dir = crate::config::agent_dir().ok();
411
412 let discovered = extension_session
428 .snapshot_arc()
429 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
430 .unwrap_or_default();
431
432 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
433 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
434 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
435 if !args.no_skills {
436 let mut dirs = if args.dev_local_only || args.project_only {
437 project_skill_dirs(cwd)
438 } else if project_trusted {
439 skill_dirs(cwd)
440 } else {
441 global_skill_dirs()
442 };
443 dirs.extend(args.skill.iter().cloned());
444 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
445 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
446 skills = result.skills;
447 skill_diags = result.diagnostics;
448 resource_diags.extend(result.resource_diagnostics);
449 }
450
451 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
452 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
453 if !args.no_prompt_templates {
454 let mut dirs = if args.dev_local_only || args.project_only {
455 project_prompt_template_dirs(cwd)
456 } else if project_trusted {
457 prompt_template_dirs(cwd)
458 } else {
459 global_prompt_template_dirs()
460 };
461 dirs.extend(args.prompt_template.iter().cloned());
462 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
463 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
464 prompt_templates = result.prompt_templates;
465 prompt_diags = result.diagnostics;
466 resource_diags.extend(result.resource_diagnostics);
467 }
468
469 let context_block = if args.no_context_files || !project_trusted {
470 String::new()
471 } else {
472 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
477 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
478 format_project_context(&files)
479 };
480
481 if args.verbose {
483 for d in &skill_diags {
484 eprintln!(
485 "warning: skill {} ({}): {}",
486 d.path,
487 d.code.as_str(),
488 d.message
489 );
490 }
491 for d in &prompt_diags {
492 eprintln!(
493 "warning: prompt template {} ({}): {}",
494 d.path,
495 d.code.as_str(),
496 d.message
497 );
498 }
499 for d in &resource_diags {
501 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
502 eprintln!(
503 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
504 c.resource_type.as_str(),
505 c.name,
506 c.winner_path,
507 c.loser_path
508 );
509 }
510 }
511 }
512
513 let system_prompt = compose_system_prompt(
522 Some(&base_prompt),
523 &[], if context_block.is_empty() {
525 None
526 } else {
527 Some(&context_block)
528 },
529 append_join.as_deref(),
530 );
531
532 if args.debug_system_prompt {
541 eprintln!("=== --debug-system-prompt ===");
542 let base_src = if args.system_prompt.is_some() {
543 "--system-prompt"
544 } else if discover_system_prompt_file(cwd).is_some() {
545 "SYSTEM.md"
546 } else {
547 "default"
548 };
549 eprintln!("[base source: {base_src}]");
550 eprintln!("--- base ---\n{base_prompt}");
551 if let Some(append) = append_join.as_deref() {
552 eprintln!("--- append ---\n{append}");
553 } else {
554 eprintln!("--- append: (none) ---");
555 }
556 if context_block.is_empty() {
557 eprintln!("--- context: (none) ---");
558 } else {
559 eprintln!("--- context ---{context_block}");
560 }
561 let visible_skills = skills
562 .iter()
563 .filter(|s| s.disable_model_invocation != Some(true))
564 .count();
565 eprintln!(
566 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
567 skills.len(),
568 visible_skills,
569 skills.len() - visible_skills
570 );
571 for s in &skills {
572 let hidden = if s.disable_model_invocation == Some(true) {
573 " [hidden]"
574 } else {
575 ""
576 };
577 eprintln!(" {}{hidden} — {}", s.name, s.description);
578 }
579 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
580 for t in &prompt_templates {
581 eprintln!(" /{}", t.name);
582 }
583 eprintln!(
587 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
588 discovered.skill_paths.len(),
589 discovered.prompt_paths.len(),
590 discovered.theme_paths.len(),
591 );
592 for p in &discovered.skill_paths {
593 eprintln!(" skill: {p}");
594 }
595 for p in &discovered.prompt_paths {
596 eprintln!(" prompt: {p}");
597 }
598 eprintln!(
599 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
600 );
601 eprintln!("=== end --debug-system-prompt ===");
602 }
603
604 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
609 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
610 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
614
615 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
624 Some(snapshot) => {
625 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
626 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
627 }
628 None => broadcast_emitter,
629 };
630
631 let saved_settings = crate::settings::load_settings().unwrap_or_default();
635
636 if let Some(proxy) = &saved_settings.http_proxy {
641 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
642 if std::env::var_os(key).is_none() {
643 std::env::set_var(key, proxy);
644 }
645 }
646 }
647
648 let options = AgentHarnessOptions {
649 model: resolved.model.clone(),
650 thinking_level: resolved.thinking_level,
651 active_tool_names: active,
652 tools,
653 system_prompt: Some(system_prompt),
654 resources: AgentHarnessResources {
655 skills: if skills.is_empty() {
656 None
657 } else {
658 Some(skills)
659 },
660 prompt_templates: if prompt_templates.is_empty() {
661 None
662 } else {
663 Some(prompt_templates)
664 },
665 },
666 allow_existing_session: matches!(
669 selection,
670 SessionSelection::Latest { .. }
671 | SessionSelection::ById { .. }
672 | SessionSelection::ByExactId { .. }
673 | SessionSelection::Fork { .. }
674 ),
675 stream_options: AgentHarnessStreamOptions {
676 timeout: args.timeout,
677 ..Default::default()
678 },
679 retry: saved_settings.retry_policy(),
684 compaction: saved_settings.compaction_settings(),
685 steering_mode: saved_settings.steering_mode(),
686 follow_up_mode: saved_settings.follow_up_mode(),
687 tool_execution: HarnessToolExecution::default(),
688 drive: DrivingMode::default(),
689 session,
690 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
698 to_provider_messages: None,
699 entry_projectors: Default::default(),
700 agent_emitter: Some(emitter),
701 before_tool_call: None,
707 after_tool_call: None,
708 transform_context: None,
709 entry_transforms: Vec::new(),
710 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
716 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
717 };
718
719 let harness = match AgentHarness::create(options).await {
720 Ok(h) => {
721 crate::extensions_actions::HarnessActionHost::set_harness(
724 &harness_cell,
725 Arc::new(h.clone()),
726 );
727 h
728 }
729 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
730 };
731
732 {
740 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
741 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
742 Ok(reports) => {
743 for (lane, report) in reports {
744 if report.is_clean() {
745 continue;
746 }
747 if report.is_corrupt() {
748 eprintln!(
749 "warning: session lane '{lane}' is corrupt ({} open operations); \
750 refusing to auto-repair — inspect the session file",
751 report.open_operations.len()
752 );
753 continue;
754 }
755 for decision in runtime.reconcile(&report) {
756 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
757 if args.verbose {
758 eprintln!(
759 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
760 );
761 }
762 }
763 }
764 runtime.emit_recovery_events(harness.events(), &report);
765 }
766 }
767 Err(e) => {
768 if args.verbose {
769 eprintln!("warning: session recovery sweep failed: {e}");
770 }
771 }
772 }
773 }
774
775 let reload_context = ReloadContext {
782 extension_session: Arc::new(Mutex::new(extension_session)),
783 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
784 tool_context,
785 catalog,
786 gateway: resolved.provider.clone(),
787 runtime: runtime.clone(),
788 cwd: cwd.to_path_buf(),
789 project_trusted,
790 args: args.clone(),
791 resolved_model: resolved.model.clone(),
792 broadcast: broadcast_for_context,
793 mailbox: reload_mailbox,
794 ui_dialog: ui_dialog_mailbox,
795 ext_status: ext_status_mailbox,
796 editor_text: editor_text_mailbox,
797 dev_extension: None,
798 };
799
800 Ok((harness, event_rx, reload_context))
801}
802
803pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
848
849pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
854
855#[derive(Clone)]
863pub struct ReloadContext {
864 pub extension_session: ExtensionSessionCell,
866 pub tool_context: rpi_extensions::ToolCallContext,
871 pub action_bridge: ActionBridgeCell,
873 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
876 pub editor_text: rpi_extensions::EditorTextMailbox,
883 pub catalog: Vec<rpi_ai::Model>,
888 pub gateway: Arc<dyn Provider>,
891 pub runtime: tokio::runtime::Handle,
895 pub cwd: PathBuf,
897 pub project_trusted: bool,
901 pub args: Args,
907 pub resolved_model: rpi_ai::Model,
910 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
916 pub mailbox: rpi_extensions::ReloadMailbox,
923 pub ui_dialog: rpi_extensions::UiDialogMailbox,
929 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
933}
934
935pub async fn dispatch_session_event_async(
950 reload_context: &ReloadContext,
951 tag: rpi_plugin_sdk::EventTag,
952) -> Option<String> {
953 let (snapshot, keepalive) = {
959 let session = reload_context.extension_session.lock().unwrap();
960 (session.snapshot_arc(), session.keepalive())
961 };
962 match snapshot {
963 Some(snapshot) => {
964 let _keepalive = keepalive;
965 rpi_extensions::dispatch_lifecycle_event(&snapshot, tag).await
966 }
967 None => None,
968 }
969}
970
971async fn shutdown_extensions_for_reload(
974 snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
975 keepalive: Arc<rpi_extensions::PluginKeepalive>,
976) -> Result<(), String> {
977 if let Some(snapshot) = snapshot {
978 tokio::task::spawn_blocking(move || {
979 let _keepalive = keepalive;
980 rpi_extensions::dispatch_empty_event(
981 &snapshot,
982 rpi_plugin_sdk::EventTag::SessionShutdown,
983 );
984 })
985 .await
986 .map_err(|error| error.to_string())?;
987 }
988 Ok(())
989}
990
991pub struct ReloadOutcome {
994 pub summary: String,
997 pub had_warnings: bool,
999 pub details: Vec<String>,
1004 pub had_errors: bool,
1008}
1009
1010struct PreparedReloadInputs {
1011 extension_dirs: Vec<PathBuf>,
1012 skill_base_dirs: Vec<PathBuf>,
1013 prompt_base_dirs: Vec<PathBuf>,
1014}
1015
1016fn append_reload_resource_paths(
1021 mut paths: Vec<PathBuf>,
1022 explicit: &[PathBuf],
1023 discovered: &[String],
1024 js_paths: &[PathBuf],
1025 package_paths: &[PathBuf],
1026) -> Vec<PathBuf> {
1027 paths.extend(explicit.iter().cloned());
1028 paths.extend(discovered.iter().map(PathBuf::from));
1029 paths.extend(js_paths.iter().cloned());
1030 paths.extend(package_paths.iter().cloned());
1031 paths
1032}
1033
1034pub async fn reload_extension_resources(
1046 harness: &AgentHarness,
1047 ctx: &ReloadContext,
1048) -> ReloadOutcome {
1049 reload_extension_resources_inner(harness, ctx, || {}).await
1050}
1051
1052async fn reload_extension_resources_inner<F>(
1053 harness: &AgentHarness,
1054 ctx: &ReloadContext,
1055 after_prepare: F,
1056) -> ReloadOutcome
1057where
1058 F: FnOnce() + Send,
1059{
1060 let mut effective_args = ctx.args.clone();
1065 if let Some(dev) = &ctx.dev_extension {
1066 if let Err(error) = dev
1067 .rebuild()
1068 .and_then(|_| dev.apply_to_args(&mut effective_args))
1069 {
1070 return ReloadOutcome {
1071 summary: format!(
1072 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1073 dev.package_name()
1074 ),
1075 had_warnings: true,
1076 details: Vec::new(),
1077 had_errors: true,
1078 };
1079 }
1080 }
1081 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1082 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1083 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1084 Ok(prepared) => prepared,
1085 Err(error) => {
1086 return ReloadOutcome {
1087 summary: format!(
1088 "Settings reload failed: {error}. Keeping the currently loaded resources."
1089 ),
1090 had_warnings: true,
1091 details: Vec::new(),
1092 had_errors: true,
1093 };
1094 }
1095 };
1096 after_prepare();
1097 let PreparedReloadInputs {
1098 extension_dirs,
1099 skill_base_dirs,
1100 prompt_base_dirs,
1101 } = prepared;
1102 let mut warnings = false;
1103
1104 let (shutdown_snapshot, shutdown_keepalive) = {
1108 let session = ctx.extension_session.lock().unwrap();
1109 (session.snapshot_arc(), session.keepalive())
1110 };
1111 if let Err(reason) = shutdown_extensions_for_reload(shutdown_snapshot, shutdown_keepalive).await
1112 {
1113 return ReloadOutcome {
1114 summary: "Reload failed during extension shutdown".into(),
1115 had_warnings: true,
1116 details: vec![reason],
1117 had_errors: true,
1118 };
1119 }
1120
1121 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1128 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1129 Some(b) => b.clone_host(),
1130 None => {
1131 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1134 ctx.catalog.clone(),
1135 ctx.cwd.clone(),
1136 ctx.runtime.clone(),
1137 ctx.args.unknown_flags.clone(),
1138 ctx.tool_context.clone(),
1139 );
1140 crate::extensions_actions::HarnessActionHost::set_harness(
1141 &_cell,
1142 Arc::new(harness.clone()),
1143 );
1144 Arc::new(action_host)
1145 }
1146 };
1147
1148 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1149 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1150 ctx.runtime.clone(),
1151 host,
1152 reload_cb,
1153 ctx.ui_dialog.clone(),
1154 ctx.ext_status.clone(),
1155 ctx.editor_text.clone(),
1156 );
1157
1158 let extension_session = if effective_args.no_extensions {
1159 rpi_extensions::ExtensionSession::none()
1160 } else {
1161 load_extensions_from_dirs(
1162 &effective_args,
1163 &extension_dirs,
1164 Some(Arc::clone(&fresh_bridge)),
1165 )
1166 };
1167 if extension_session.is_empty() && !effective_args.no_extensions {
1168 }
1171 if effective_args.verbose {
1172 if let Some(s) = extension_session.summary() {
1173 eprintln!("reload: {s}");
1174 }
1175 report_deferred_renderers(&extension_session);
1176 }
1177
1178 {
1192 let mut session_guard = ctx.extension_session.lock().unwrap();
1193 let old_session = std::mem::replace(
1194 &mut *session_guard,
1195 rpi_extensions::ExtensionSession::none(),
1196 );
1197 if let Some(old_snap) = old_session.snapshot_arc() {
1198 old_snap.active_flag().store(false, Ordering::SeqCst);
1202 }
1203 }
1207 if let Some(old_b) = old_bridge {
1208 old_b.invalidate();
1209 }
1210
1211 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1214 *ctx.extension_session.lock().unwrap() = extension_session.clone();
1215
1216 let discovered = extension_session
1218 .snapshot_arc()
1219 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1220 .unwrap_or_default();
1221
1222 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1224 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1225
1226 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1227 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1228 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1229 if !effective_args.no_skills {
1230 let js_paths: &[PathBuf] = &[];
1234 let package_paths: &[PathBuf] = &[];
1235 let dirs = append_reload_resource_paths(
1236 skill_base_dirs,
1237 &effective_args.skill,
1238 &discovered.skill_paths,
1239 js_paths,
1240 &package_paths,
1241 );
1242 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1243 skills = result.skills;
1244 skill_diags = result.diagnostics;
1245 resource_diags.extend(result.resource_diagnostics);
1246 }
1247
1248 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1249 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1250 if !effective_args.no_prompt_templates {
1251 let js_paths: &[PathBuf] = &[];
1252 let package_paths: &[PathBuf] = &[];
1253 let dirs = append_reload_resource_paths(
1254 prompt_base_dirs,
1255 &effective_args.prompt_template,
1256 &discovered.prompt_paths,
1257 js_paths,
1258 &package_paths,
1259 );
1260 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1261 prompt_templates = result.prompt_templates;
1262 prompt_diags = result.diagnostics;
1263 resource_diags.extend(result.resource_diagnostics);
1264 }
1265
1266 let context_block = if effective_args.no_context_files {
1267 String::new()
1268 } else {
1269 let agent_dir = crate::config::agent_dir().ok();
1270 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1271 let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
1272 format_project_context(&files)
1273 };
1274
1275 let mut details: Vec<String> = Vec::new();
1276 if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
1277 warnings = true;
1278 for d in &skill_diags {
1283 details.push(format!(
1284 "warning: skill {} ({}): {}",
1285 d.path,
1286 d.code.as_str(),
1287 d.message
1288 ));
1289 }
1290 for d in &prompt_diags {
1291 details.push(format!(
1292 "warning: prompt template {} ({}): {}",
1293 d.path,
1294 d.code.as_str(),
1295 d.message
1296 ));
1297 }
1298 for d in &resource_diags {
1300 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1301 details.push(format!(
1302 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1303 c.resource_type.as_str(),
1304 c.name,
1305 c.winner_path,
1306 c.loser_path
1307 ));
1308 }
1309 }
1310 }
1311
1312 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1317 let base_prompt = match effective_args.system_prompt.as_deref() {
1318 Some(explicit) => explicit.to_string(),
1319 None => match discover_system_prompt_file(&ctx.cwd) {
1320 Some(path) => std::fs::read_to_string(&path)
1321 .unwrap_or_else(|_| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
1322 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1323 },
1324 };
1325 let mut append_texts: Vec<String> = Vec::new();
1326 for extra in &effective_args.append_system_prompt {
1327 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
1328 append_texts.push(text);
1329 }
1330 if effective_args.append_system_prompt.is_empty() {
1331 if let Some(path) = discover_append_system_prompt_file(&ctx.cwd) {
1332 if let Ok(text) = std::fs::read_to_string(&path) {
1333 append_texts.push(text);
1334 }
1335 }
1336 }
1337 let append_join = if append_texts.is_empty() {
1338 None
1339 } else {
1340 Some(append_texts.join("\n\n"))
1341 };
1342 let system_prompt = compose_system_prompt(
1343 Some(&base_prompt),
1344 &[],
1345 if context_block.is_empty() {
1346 None
1347 } else {
1348 Some(&context_block)
1349 },
1350 append_join.as_deref(),
1351 );
1352
1353 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1355 Some(snapshot) => {
1356 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1357 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1358 }
1359 None => ctx.broadcast.clone(),
1360 };
1361
1362 let resources = AgentHarnessResources {
1364 skills: if skills.is_empty() {
1365 None
1366 } else {
1367 Some(skills.clone())
1368 },
1369 prompt_templates: if prompt_templates.is_empty() {
1370 None
1371 } else {
1372 Some(prompt_templates.clone())
1373 },
1374 };
1375 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1376 let _ = harness.set_resources(resources).await;
1377 let _ = harness.set_agent_emitter(Some(emitter)).await;
1378 let _ = harness
1379 .set_models(build_models_with_extensions_for_reload(
1380 &ctx.gateway,
1381 &extension_session,
1382 ctx.runtime.clone(),
1383 ))
1384 .await;
1385 let _ = harness
1386 .set_provider_hooks(
1387 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1388 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1389 )
1390 .await;
1391
1392 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1396 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1397 let mut tools = build_tools(&tool_ctx, &effective_args);
1398 merge_extension_tools(
1399 &mut tools,
1400 &extension_session,
1401 &effective_args,
1402 &ctx.tool_context,
1403 );
1404 let mut active = active_tool_names(&tools, &effective_args);
1405 active = filter_active_tool_names(active, &effective_args);
1406 let _ = harness.set_tools(tools, Some(active)).await;
1407
1408 let summary = format!(
1409 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1410 extension_session.loaded_paths().len(),
1411 skills.len(),
1412 prompt_templates.len(),
1413 );
1414 ReloadOutcome {
1415 summary,
1416 had_warnings: warnings,
1417 details,
1418 had_errors: false,
1421 }
1422}
1423
1424#[derive(Clone, Debug, PartialEq, Eq)]
1425struct ReloadSettingsFields {
1426 skill_dirs: Option<Vec<String>>,
1427 prompt_dirs: Option<Vec<String>>,
1428 extension_dirs: Option<Vec<String>>,
1429}
1430
1431impl From<crate::settings::Settings> for ReloadSettingsFields {
1432 fn from(settings: crate::settings::Settings) -> Self {
1433 Self {
1434 skill_dirs: settings.skill_dirs,
1435 prompt_dirs: settings.prompt_dirs,
1436 extension_dirs: settings.extension_dirs,
1437 }
1438 }
1439}
1440
1441#[derive(Clone, Debug, PartialEq, Eq)]
1442struct ReloadSettingsSnapshot {
1443 global: ReloadSettingsFields,
1444 project: Option<ReloadSettingsFields>,
1445}
1446
1447fn reload_reads_settings(args: &Args) -> bool {
1448 !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
1449}
1450
1451fn load_reload_settings_snapshot(
1455 args: &Args,
1456 cwd: &Path,
1457 project_trusted: bool,
1458) -> Result<Option<ReloadSettingsSnapshot>, String> {
1459 if !reload_reads_settings(args) {
1460 return Ok(None);
1461 }
1462 let global = crate::settings::load_settings()
1463 .map_err(|error| format!("could not load global settings: {error}"))?
1464 .into();
1465 let project = if project_trusted {
1466 crate::settings::load_active_project_settings(cwd)
1467 .map_err(|error| format!("could not load project settings: {error}"))?
1468 .map(|(_, settings)| settings.into())
1469 } else {
1470 None
1471 };
1472 Ok(Some(ReloadSettingsSnapshot { global, project }))
1473}
1474
1475#[cfg(test)]
1478fn validate_settings_for_reload(
1479 args: &Args,
1480 cwd: &Path,
1481 project_trusted: bool,
1482) -> Result<(), String> {
1483 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1484}
1485
1486fn prepare_reload_inputs(
1487 args: &Args,
1488 cwd: &Path,
1489 project_trusted: bool,
1490) -> Result<PreparedReloadInputs, String> {
1491 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1492}
1493
1494fn prepare_reload_inputs_inner<F>(
1495 args: &Args,
1496 cwd: &Path,
1497 project_trusted: bool,
1498 before_verify: F,
1499) -> Result<PreparedReloadInputs, String>
1500where
1501 F: FnOnce(),
1502{
1503 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1504
1505 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1506 Vec::new()
1507 } else if args.project_only {
1508 project_extension_dirs(cwd)
1509 } else if project_trusted {
1510 extension_dirs(cwd)
1511 } else {
1512 global_extension_dirs()
1513 };
1514 if !args.no_extensions {
1515 extension_dirs.extend(args.extensions_dir.iter().cloned());
1516 }
1517
1518 let skill_base_dirs = if args.no_skills {
1519 Vec::new()
1520 } else if args.dev_local_only || args.project_only {
1521 project_skill_dirs(cwd)
1522 } else if project_trusted {
1523 skill_dirs(cwd)
1524 } else {
1525 global_skill_dirs()
1526 };
1527
1528 let prompt_base_dirs = if args.no_prompt_templates {
1529 Vec::new()
1530 } else if args.dev_local_only || args.project_only {
1531 project_prompt_template_dirs(cwd)
1532 } else if project_trusted {
1533 prompt_template_dirs(cwd)
1534 } else {
1535 global_prompt_template_dirs()
1536 };
1537
1538 before_verify();
1539 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1540 if settings_before != settings_after {
1541 return Err(
1542 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1543 );
1544 }
1545
1546 Ok(PreparedReloadInputs {
1547 extension_dirs,
1548 skill_base_dirs,
1549 prompt_base_dirs,
1550 })
1551}
1552
1553fn build_models_with_extensions_for_reload(
1558 gateway: &Arc<dyn Provider>,
1559 extension_session: &ExtensionSession,
1560 runtime: tokio::runtime::Handle,
1561) -> Vec<Arc<dyn Provider>> {
1562 let mut models: Vec<Arc<dyn Provider>> = vec![gateway.clone()];
1563 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1564 models.extend(pluggable);
1565 models
1566}
1567
1568fn report_deferred_renderers(session: &ExtensionSession) {
1572 let Some(snap) = session.snapshot_arc() else {
1573 return;
1574 };
1575 let all = snap.renderers();
1576 let markdown = all
1577 .iter()
1578 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1579 .count();
1580 let message = all
1581 .iter()
1582 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1583 .count();
1584 let entry = all
1585 .iter()
1586 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1587 .count();
1588 if markdown + message + entry == 0 {
1589 return;
1590 }
1591 eprintln!(
1592 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1593 markdown, message, entry
1594 );
1595}
1596
1597#[derive(Debug, thiserror::Error)]
1599pub enum BuildError {
1600 #[error("Could not create the session directory: {0}")]
1601 SessionDir(String),
1602 #[error("Could not open the saved session: {0}")]
1603 SessionOpen(String),
1604 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1605 SessionNotFound { requested: String, dir: String },
1606 #[error("Could not build the harness: {0}")]
1607 HarnessCreate(String),
1608}
1609
1610fn build_models_with_extensions(
1620 resolved: &ResolvedModel,
1621 extension_session: &ExtensionSession,
1622 runtime: tokio::runtime::Handle,
1623) -> Vec<Arc<dyn Provider>> {
1624 let mut models: Vec<Arc<dyn Provider>> = vec![resolved.provider.clone() as Arc<dyn Provider>];
1625 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1626 models.extend(pluggable);
1627 models
1628}
1629
1630pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1633 if let Some(override_value) = args.trust_override {
1634 return override_value;
1635 }
1636 crate::config::project_trust_decision(cwd)
1637 .ok()
1638 .flatten()
1639 .unwrap_or(true)
1640}
1641
1642fn load_extensions(
1650 args: &Args,
1651 cwd: &Path,
1652 project_trusted: bool,
1653 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1654) -> ExtensionSession {
1655 let mut dirs = if args.dev_local_only {
1656 Vec::new()
1657 } else if args.project_only {
1658 project_extension_dirs(cwd)
1659 } else if project_trusted {
1660 extension_dirs(cwd)
1661 } else {
1662 global_extension_dirs()
1663 };
1664 dirs.extend(args.extensions_dir.iter().cloned());
1665 load_extensions_from_dirs(args, &dirs, action_bridge)
1666}
1667
1668fn load_extensions_from_dirs(
1669 args: &Args,
1670 dirs: &[PathBuf],
1671 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1672) -> ExtensionSession {
1673 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1674 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1681}
1682
1683fn merge_extension_tools(
1689 tools: &mut Vec<HarnessTool>,
1690 session: &ExtensionSession,
1691 args: &Args,
1692 tool_context: &rpi_extensions::ToolCallContext,
1693) {
1694 let Some(snapshot) = session.snapshot() else {
1695 return;
1696 };
1697 for et in snapshot.tools() {
1698 let name = &et.tool.name;
1699 if !tool_name_allowed(name, args) {
1700 continue;
1701 }
1702 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
1703 .with_context(tool_context.clone());
1704 let harness_tool = HarnessTool::new(Arc::new(adapter));
1705 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1706 Some(slot) => *slot = harness_tool,
1707 None => tools.push(harness_tool),
1708 }
1709 }
1710}
1711
1712pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1719 use rpi_tools::tools::bash::BashToolOptions;
1720 let default = std::env::var("RPI_BASH_TIMEOUT")
1721 .ok()
1722 .and_then(|v| v.parse::<f64>().ok())
1723 .unwrap_or(120.0);
1724 let settings = crate::settings::load_settings().unwrap_or_default();
1730 BashToolOptions {
1731 command_prefix: settings.shell_command_prefix.clone(),
1732 default_timeout: Some(default),
1733 prepare: None,
1734 }
1735}
1736
1737fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1738 if args.no_tools {
1739 return Vec::new();
1740 }
1741 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1750 (
1751 "read",
1752 HarnessTool::new(create_read_tool(ctx, None)).with_replay(ToolReplay::Safe),
1753 ),
1754 (
1755 "bash",
1756 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1757 ),
1758 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1759 ("write", HarnessTool::new(create_write_tool(ctx))),
1760 (
1761 "docs",
1762 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
1763 ),
1764 ];
1765
1766 #[cfg(target_os = "windows")]
1768 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
1769
1770 if args.tools.is_none() {
1772 if let Ok(settings) = crate::settings::load_settings() {
1773 if let Some(default_tools) = &settings.default_tools {
1774 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
1775 }
1776 }
1777 }
1778
1779 if args.no_builtin_tools {
1783 all.clear();
1784 }
1785
1786 if let Some(allow) = &args.tools {
1788 all.retain(|(name, _)| allow.iter().any(|a| a == name));
1789 }
1790 if let Some(deny) = &args.exclude_tools {
1792 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1793 }
1794
1795 all.into_iter().map(|(_, t)| t).collect()
1796}
1797
1798fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1802 filter_active_tool_names(
1803 tools.iter().map(|tool| tool.tool.schema().name.clone()),
1804 args,
1805 )
1806}
1807
1808pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
1811 if args.no_tools {
1812 return false;
1813 }
1814 if args
1815 .tools
1816 .as_ref()
1817 .is_some_and(|allow| !allow.iter().any(|value| value == name))
1818 {
1819 return false;
1820 }
1821 if args
1822 .exclude_tools
1823 .as_ref()
1824 .is_some_and(|deny| deny.iter().any(|value| value == name))
1825 {
1826 return false;
1827 }
1828 true
1829}
1830
1831pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
1832where
1833 I: IntoIterator<Item = String>,
1834{
1835 names
1836 .into_iter()
1837 .filter(|name| tool_name_allowed(name, args))
1838 .collect()
1839}
1840
1841async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1843 match selection {
1844 SessionSelection::Ephemeral => Ok(ephemeral_session()),
1845 SessionSelection::New { dir, .. } => {
1846 std::fs::create_dir_all(dir)
1849 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1850 let session = create_jsonl_session(dir, cwd)
1851 .await
1852 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1853 Ok(session)
1854 }
1855 SessionSelection::Latest { .. }
1856 | SessionSelection::ById { .. }
1857 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
1858 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
1859 }
1860}
1861
1862async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1868 match selection {
1872 SessionSelection::Latest { dir } => {
1873 let metas = list_session_metadata_in_dir(cwd, dir).await?;
1874 let Some(meta) = first_resumable_session(&metas) else {
1875 return Err(BuildError::SessionNotFound {
1876 requested: "the most recent session".to_string(),
1877 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1878 });
1879 };
1880 open_session(meta, cwd).await
1881 }
1882 SessionSelection::ById { id, dir } => open_session_by_id_in_dir(id, cwd, dir)
1883 .await
1884 .map_err(|e| match e {
1885 OpenError::NotFound { requested } => BuildError::SessionNotFound {
1886 requested,
1887 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1888 },
1889 OpenError::Other(msg) => BuildError::SessionOpen(msg),
1890 }),
1891 SessionSelection::ByExactId { id, dir } => {
1892 let metas = list_session_metadata_in_dir(cwd, dir).await?;
1895 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
1896 return open_session(meta, cwd).await;
1897 }
1898 std::fs::create_dir_all(dir)
1899 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1900 create_jsonl_session_with_id(dir, cwd, Some(id.clone()))
1901 .await
1902 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
1903 }
1904 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
1905 }
1906}
1907
1908async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
1911 use rpi_harness::session::jsonl::{
1912 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1913 };
1914 use rpi_harness::session::types::{ForkOptions, SessionStorage};
1915 use rpi_tools::FileSystem;
1916
1917 let dir = default_session_dir(Path::new(cwd));
1918 std::fs::create_dir_all(&dir)
1919 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1920 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1921 let fs: Arc<dyn FileSystem> = env.clone();
1922 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1923 fs: fs.clone(),
1924 sessions_root: dir.to_string_lossy().into_owned(),
1925 clock: Arc::new(SystemClock),
1926 ids: Arc::new(DefaultIdGenerator::new()),
1927 });
1928 let metas = repo
1929 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
1930 .await
1931 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
1932 let source_meta = metas
1933 .iter()
1934 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
1935 .ok_or_else(|| BuildError::SessionNotFound {
1936 requested: format!("--fork {source}"),
1937 dir: dir.display().to_string(),
1938 })?;
1939 let fork_storage = repo
1940 .fork_typed(
1941 source_meta,
1942 &JsonlSessionCreateOptions {
1943 id: None,
1944 parent_session_id: Some(source_meta.id.clone()),
1945 cwd: cwd.to_string(),
1946 metadata: None,
1947 },
1948 &ForkOptions::default(),
1949 )
1950 .await
1951 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
1952 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
1953 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
1954 Ok(Session::new(storage_arc, None))
1955}
1956
1957pub enum OpenError {
1960 NotFound { requested: String },
1962 Other(String),
1964}
1965
1966impl std::fmt::Display for OpenError {
1967 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1968 match self {
1969 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
1970 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
1975 }
1976 }
1977}
1978
1979pub async fn list_session_metadata(
1982 cwd: &str,
1983) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
1984 let dir = default_session_dir(Path::new(cwd));
1985 list_session_metadata_in_dir(cwd, &dir).await
1986}
1987
1988async fn list_session_metadata_in_dir(
1989 cwd: &str,
1990 dir: &Path,
1991) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
1992 use rpi_harness::session::jsonl::{
1993 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1994 };
1995 use rpi_tools::FileSystem;
1996
1997 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1998 let fs: Arc<dyn FileSystem> = env.clone();
1999 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2000 fs: fs.clone(),
2001 sessions_root: dir.to_string_lossy().into_owned(),
2002 clock: Arc::new(SystemClock),
2003 ids: Arc::new(DefaultIdGenerator::new()),
2004 });
2005 repo.list_typed(&JsonlSessionListOptions::default())
2006 .await
2007 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2008}
2009
2010#[derive(Debug, Clone, Default, PartialEq, Eq)]
2026pub struct SessionSummary {
2027 pub name: Option<String>,
2029 pub preview: Option<String>,
2031 pub empty: bool,
2033 pub bytes: u64,
2035}
2036
2037const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
2039const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
2041const SUMMARY_PREVIEW_CHARS: usize = 80;
2043
2044pub fn summarize_session_file(path: &Path) -> SessionSummary {
2048 use std::io::{Read, Seek, SeekFrom};
2049
2050 let Ok(mut file) = std::fs::File::open(path) else {
2051 return SessionSummary::default();
2052 };
2053 let Ok(size) = file.metadata().map(|m| m.len()) else {
2054 return SessionSummary::default();
2055 };
2056 if size == 0 {
2057 return SessionSummary {
2058 name: None,
2059 preview: None,
2060 empty: true,
2061 bytes: 0,
2062 };
2063 }
2064
2065 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2068 let head_len = file.read(&mut head).unwrap_or(0);
2069 head.truncate(head_len);
2070 let head = String::from_utf8_lossy(&head);
2071 let mut lines: Vec<&str> = head.lines().collect();
2072 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2073 let empty = (size as usize) <= header_len + 1;
2075 if (head_len as u64) < size && !lines.is_empty() {
2078 lines.pop();
2079 }
2080
2081 let mut summary = SessionSummary {
2082 name: None,
2083 preview: None,
2084 empty,
2085 bytes: size,
2086 };
2087 for line in lines {
2088 apply_summary_line(line, &mut summary);
2089 }
2090
2091 if size > SUMMARY_HEAD_BYTES
2095 && file
2096 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2097 .is_ok()
2098 {
2099 let mut tail = Vec::new();
2100 if file.read_to_end(&mut tail).is_ok() {
2101 let tail = String::from_utf8_lossy(&tail);
2102 let mut tail_lines = tail.lines();
2103 tail_lines.next();
2105 for line in tail_lines {
2106 if let Some(name) = name_from_line(line) {
2107 summary.name = name;
2108 }
2109 }
2110 }
2111 }
2112 summary
2113}
2114
2115fn name_from_line(line: &str) -> Option<Option<String>> {
2118 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2119 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2120 return None;
2121 }
2122 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2123 return None;
2124 }
2125 Some(match value.get("name") {
2126 Some(serde_json::Value::String(name)) => Some(name.clone()),
2127 _ => None,
2128 })
2129}
2130
2131fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2134 if let Some(name) = name_from_line(line) {
2135 summary.name = name;
2136 return;
2137 }
2138 if summary.preview.is_some() {
2139 return;
2140 }
2141 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2142 return;
2143 };
2144 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2145 return;
2146 }
2147 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2148 return;
2149 }
2150 let Some(message) = value.get("message") else {
2151 return;
2152 };
2153 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2154 return;
2155 }
2156 let text = match message.get("content") {
2157 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2158 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2159 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2160 .then(|| block.get("text").and_then(|v| v.as_str()))
2161 .flatten()
2162 }),
2163 _ => None,
2164 };
2165 let Some(text) = text else { return };
2166 let collapsed = text
2167 .lines()
2168 .map(str::trim)
2169 .filter(|line| !line.is_empty())
2170 .collect::<Vec<_>>()
2171 .join(" ");
2172 if collapsed.is_empty() {
2173 return;
2174 }
2175 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2176}
2177
2178pub fn session_display_label(summary: &SessionSummary) -> String {
2182 if let Some(name) = summary.name.as_deref().map(str::trim) {
2183 if !name.is_empty() {
2184 return name.to_string();
2185 }
2186 }
2187 match summary.preview.as_deref().map(str::trim) {
2188 Some(preview) if !preview.is_empty() => preview.to_string(),
2189 _ => "(no messages)".to_string(),
2190 }
2191}
2192
2193pub fn format_session_bytes(bytes: u64) -> String {
2196 if bytes >= 1_048_576 {
2197 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2198 } else if bytes >= 1024 {
2199 format!("{}KB", bytes / 1024)
2200 } else {
2201 format!("{bytes}B")
2202 }
2203}
2204
2205pub fn short_session_id(id: &str) -> &str {
2208 &id[..id.len().min(8)]
2209}
2210
2211pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2220 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2221 let mut visit = |path: PathBuf| {
2222 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2223 return;
2224 }
2225 let modified = std::fs::metadata(&path)
2226 .and_then(|m| m.modified())
2227 .unwrap_or(std::time::UNIX_EPOCH);
2228 files.push((path, modified));
2229 };
2230 if let Ok(entries) = std::fs::read_dir(dir) {
2231 for entry in entries.flatten() {
2232 let path = entry.path();
2233 if path.is_dir() {
2234 if let Ok(inner) = std::fs::read_dir(&path) {
2235 for child in inner.flatten() {
2236 visit(child.path());
2237 }
2238 }
2239 } else {
2240 visit(path);
2241 }
2242 }
2243 }
2244 files.sort_by(|a, b| b.1.cmp(&a.1));
2245 files.into_iter().map(|(path, _)| path).collect()
2246}
2247
2248fn first_resumable_session<'a>(
2253 metas: &'a [rpi_harness::session::jsonl::JsonlSessionMetadata],
2254) -> Option<&'a rpi_harness::session::jsonl::JsonlSessionMetadata> {
2255 metas
2256 .iter()
2257 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2258 .or_else(|| metas.first())
2259}
2260
2261pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2265 let dir = default_session_dir(Path::new(cwd));
2266 open_session_by_id_in_dir(id, cwd, &dir).await
2267}
2268
2269async fn open_session_by_id_in_dir(id: &str, cwd: &str, dir: &Path) -> Result<Session, OpenError> {
2270 let metas = list_session_metadata_in_dir(cwd, dir)
2271 .await
2272 .map_err(|e| OpenError::Other(e.to_string()))?;
2273 let Some(meta) = metas
2274 .iter()
2275 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2276 else {
2277 return Err(OpenError::NotFound {
2278 requested: format!("session {id}"),
2279 });
2280 };
2281 open_session(meta, cwd).await.map_err(|e| {
2282 OpenError::Other(match e {
2283 BuildError::SessionOpen(reason) => reason,
2287 other => other.to_string(),
2288 })
2289 })
2290}
2291
2292fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
2317 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
2318 tool_context.set_session_id(id);
2319}
2320
2321pub(crate) async fn set_active_session(
2332 harness: &AgentHarness,
2333 session: Session,
2334 tool_context: Option<&rpi_extensions::ToolCallContext>,
2335) -> Result<(), String> {
2336 if let Ok(metadata) = session.get_metadata().await {
2337 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2338 if let Some(context) = tool_context {
2339 context.set_session_id(metadata.id);
2340 }
2341 }
2342 harness
2343 .set_session(session)
2344 .await
2345 .map_err(|e| e.to_string())
2346}
2347
2348pub(crate) async fn fork_session_storage(
2355 harness: &AgentHarness,
2356 cwd: &str,
2357) -> Result<Session, String> {
2358 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2359 use rpi_tools::FileSystem;
2360
2361 let dir = default_session_dir(Path::new(cwd));
2362 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2363 let fs: Arc<dyn FileSystem> = env.clone();
2364 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2365 fs,
2366 sessions_root: dir.to_string_lossy().into_owned(),
2367 clock: Arc::new(SystemClock),
2368 ids: Arc::new(DefaultIdGenerator::new()),
2369 });
2370 let id = harness.session().storage().metadata().id.clone();
2373 let metas = list_session_metadata(cwd)
2374 .await
2375 .map_err(|e| e.to_string())?;
2376 let Some(source) = metas.iter().find(|m| m.id == id) else {
2377 return Err(format!("current session {id} not found on disk"));
2378 };
2379 let fork_storage = repo
2380 .fork_typed(
2381 source,
2382 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2383 id: None,
2384 parent_session_id: Some(source.id.clone()),
2385 cwd: cwd.to_string(),
2386 metadata: None,
2387 },
2388 &rpi_harness::session::types::ForkOptions::default(),
2389 )
2390 .await
2391 .map_err(|e| e.to_string())?;
2392 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2393 Ok(Session::new(Arc::new(fork_storage), None))
2394}
2395
2396static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2398static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2401
2402fn claim_session_lock(session_path: &str) -> Result<(), String> {
2411 let lock_path = PathBuf::from(format!("{session_path}.lock"));
2412 if SESSION_LOCK_PATHS
2413 .get_or_init(|| Mutex::new(HashSet::new()))
2414 .lock()
2415 .unwrap()
2416 .contains(&lock_path)
2417 {
2418 return Ok(());
2419 }
2420 let file = std::fs::OpenOptions::new()
2421 .create(true)
2422 .write(true)
2423 .open(&lock_path)
2424 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2425 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2426 return Err(format!(
2427 "session {session_path} is already open in another rpi process"
2428 ));
2429 }
2430 SESSION_LOCKS
2431 .get_or_init(|| Mutex::new(Vec::new()))
2432 .lock()
2433 .unwrap()
2434 .push(file);
2435 SESSION_LOCK_PATHS
2436 .get_or_init(|| Mutex::new(HashSet::new()))
2437 .lock()
2438 .unwrap()
2439 .insert(lock_path);
2440 Ok(())
2441}
2442
2443async fn open_session(
2446 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2447 cwd: &str,
2448) -> Result<Session, BuildError> {
2449 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2450 use rpi_harness::session::types::SessionStorage;
2451 use rpi_tools::FileSystem;
2452
2453 let dir = default_session_dir(Path::new(cwd));
2454 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2455 let fs: Arc<dyn FileSystem> = env.clone();
2456 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2457 fs: fs.clone(),
2458 sessions_root: dir.to_string_lossy().into_owned(),
2459 clock: Arc::new(SystemClock),
2460 ids: Arc::new(DefaultIdGenerator::new()),
2461 });
2462 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2463 let storage = repo
2464 .open_by_jsonl_metadata(meta)
2465 .await
2466 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2467 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2471 Some(Path::new(&meta.path)),
2472 Path::new(&meta.cwd),
2473 Path::new(cwd),
2474 ) {
2475 eprintln!(
2476 "warning: {}",
2477 crate::session_cwd::format_missing_session_cwd_error(&issue)
2478 );
2479 }
2480 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2481 Ok(Session::new(storage_arc, None))
2482}
2483
2484fn ephemeral_session() -> Session {
2486 let storage = Arc::new(InMemorySessionStorage::new(
2487 SessionMetadata {
2488 id: "ephemeral".into(),
2489 created_at: 0,
2490 parent_session_id: None,
2491 },
2492 Arc::new(SystemClock),
2493 Arc::new(DefaultIdGenerator::new()),
2494 ));
2495 Session::new(storage, None)
2496}
2497
2498pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2509 create_jsonl_session_with_id(dir, cwd, None).await
2510}
2511
2512pub(crate) async fn create_jsonl_session_with_id(
2516 dir: &Path,
2517 cwd: &str,
2518 id: Option<String>,
2519) -> Result<Session, String> {
2520 use rpi_harness::session::jsonl::{
2521 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2522 };
2523 use rpi_tools::FileSystem;
2524
2525 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2528 let fs: Arc<dyn FileSystem> = env.clone();
2529
2530 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2531 fs: fs.clone(),
2532 sessions_root: dir.to_string_lossy().into_owned(),
2533 clock: Arc::new(SystemClock),
2534 ids: Arc::new(DefaultIdGenerator::new()),
2535 });
2536
2537 let opts = JsonlSessionCreateOptions {
2538 id, parent_session_id: None,
2540 cwd: cwd.to_string(),
2541 metadata: None,
2542 };
2543 let storage = repo
2544 .create_typed(&opts)
2545 .await
2546 .map_err(|e| format!("create session: {e}"))?;
2547 claim_session_lock(&storage.jsonl_metadata().path)
2548 .map_err(|e| format!("create session: {e}"))?;
2549 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2551 Ok(Session::new(storage_arc, None))
2552}
2553
2554fn read_append_target(target: &str) -> Option<String> {
2557 let path = Path::new(target);
2558 if path.is_file() {
2559 std::fs::read_to_string(path).ok()
2560 } else {
2561 None
2562 }
2563}
2564
2565#[cfg(test)]
2566mod tests {
2567 use super::*;
2568 use crate::args::Args;
2569
2570 fn scratch(name: &str) -> PathBuf {
2572 let nanos = std::time::SystemTime::now()
2573 .duration_since(std::time::UNIX_EPOCH)
2574 .unwrap()
2575 .as_nanos();
2576 let path = std::env::temp_dir().join(format!(
2577 "rpi-summary-{}-{nanos}-{name}.jsonl",
2578 std::process::id()
2579 ));
2580 path
2581 }
2582
2583 #[test]
2584 fn summary_marks_a_header_only_session_empty() {
2585 let path = scratch("empty");
2586 std::fs::write(
2587 &path,
2588 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2589 )
2590 .unwrap();
2591 let summary = summarize_session_file(&path);
2592 assert!(summary.empty, "a header-only file has nothing to resume");
2593 assert!(summary.preview.is_none());
2594 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
2595 std::fs::remove_file(&path).ok();
2596 }
2597
2598 #[test]
2599 fn summary_reads_the_first_user_prompt_and_name() {
2600 let path = scratch("content");
2601 let body = concat!(
2602 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2603 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
2604 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
2605 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
2606 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
2607 );
2608 std::fs::write(&path, body).unwrap();
2609 let summary = summarize_session_file(&path);
2610 assert!(!summary.empty);
2611 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
2612 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
2613 std::fs::remove_file(&path).ok();
2614 }
2615
2616 #[test]
2617 fn summary_extracts_a_block_style_user_prompt() {
2618 let path = scratch("blocks");
2619 let body = concat!(
2620 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2621 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
2622 );
2623 std::fs::write(&path, body).unwrap();
2624 let summary = summarize_session_file(&path);
2625 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
2626 assert_eq!(session_display_label(&summary), "line one line two");
2627 std::fs::remove_file(&path).ok();
2628 }
2629
2630 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
2631 rpi_harness::session::jsonl::JsonlSessionMetadata {
2632 id: id.to_string(),
2633 created_at: 0,
2634 cwd: ".".to_string(),
2635 path: path.to_string_lossy().into_owned(),
2636 modified_at: 0,
2637 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
2638 parent_session_id: None,
2639 legacy_parent_session_path: None,
2640 metadata: None,
2641 }
2642 }
2643
2644 #[test]
2645 fn continue_skips_newer_empty_sessions() {
2646 let empty = scratch("newest-empty");
2647 std::fs::write(
2648 &empty,
2649 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2650 )
2651 .unwrap();
2652 let content = scratch("older-content");
2653 std::fs::write(
2654 &content,
2655 concat!(
2656 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2657 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
2658 ),
2659 )
2660 .unwrap();
2661
2662 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
2663 let picked = first_resumable_session(&metas).expect("a resumable session");
2664 assert_eq!(picked.id, "old");
2665
2666 let all_empty = vec![meta_for(&empty, "empty")];
2669 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
2670
2671 std::fs::remove_file(&empty).ok();
2672 std::fs::remove_file(&content).ok();
2673 }
2674
2675 #[test]
2676 fn list_session_files_sync_walks_the_cwd_subdirectory() {
2677 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
2678 let nested = root.join("--proj--");
2679 std::fs::create_dir_all(&nested).unwrap();
2680 let file = nested.join("2026-01-01_a.jsonl");
2681 std::fs::write(&file, "{}\n").unwrap();
2682 let found = list_session_files_sync(&root);
2683 assert!(found.iter().any(|p| p == &file));
2684 std::fs::remove_dir_all(&root).ok();
2685 }
2686
2687 #[test]
2688 fn default_prompt_mentions_cwd_and_tools() {
2689 let p = default_system_prompt("/tmp/proj", false);
2690 assert!(p.contains("/tmp/proj"));
2691 assert!(p.contains("read"));
2692 assert!(p.contains("bash"));
2693 assert!(p.contains("edit"));
2694 assert!(p.contains("write"));
2695 assert!(p.contains("docs"));
2696 assert!(!p.contains("- grep"));
2697 assert!(!p.contains("- find"));
2698 assert!(!p.contains("- ls"));
2699 assert!(!p.contains("- powershell"));
2700 assert!(p.contains("Bash/POSIX syntax, including on Windows"));
2701 assert!(p.contains("do not pass PowerShell cmdlets"));
2702 }
2703
2704 #[test]
2705 fn default_prompt_instructs_language_matching() {
2706 let p = default_system_prompt("/tmp/proj", false);
2710 let lower = p.to_lowercase();
2711 assert!(
2712 lower.contains("same language") && lower.contains("detect"),
2713 "the prompt must tell the model to detect and mirror the user's language: {p}"
2714 );
2715 }
2716
2717 #[test]
2718 fn default_prompt_keeps_working_state_in_the_visible_channel() {
2719 let p = default_system_prompt("/tmp/proj", false);
2727 let lower = p.to_lowercase();
2728 assert!(
2730 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
2731 "the prompt must explain that reasoning does not survive the turn: {p}"
2732 );
2733 assert!(
2735 lower.contains("visible") && lower.contains("todo tool"),
2736 "the prompt must point at visible text and the todo tool: {p}"
2737 );
2738 assert!(
2740 lower.contains("re-stating the same plan"),
2741 "the prompt must call out plan re-statement as a bug: {p}"
2742 );
2743 assert!(
2745 lower.contains("already shown earlier"),
2746 "the prompt must discourage re-fetching known content: {p}"
2747 );
2748 }
2749
2750 #[test]
2751 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
2752 let p = default_system_prompt("/tmp/proj", true);
2759 let lower = p.to_lowercase();
2760 assert!(
2761 lower.contains("carried back"),
2762 "a replaying model must be told its reasoning survives: {p}"
2763 );
2764 assert!(
2765 !lower.contains("reasoning is not"),
2766 "the replayed variant must not claim reasoning is dropped: {p}"
2767 );
2768 assert!(
2769 lower.contains("do not re-derive"),
2770 "the replayed variant must forbid re-deriving the plan: {p}"
2771 );
2772 assert!(
2773 lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
2774 "the anti-repetition rule must survive the flip: {p}"
2775 );
2776 assert!(
2777 lower.contains("already shown earlier"),
2778 "the re-read rule must survive the flip: {p}"
2779 );
2780 }
2781
2782 #[test]
2790 fn only_read_only_tools_are_replayable() {
2791 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2792 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2793 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2794 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2795 let tools = build_tools(&context, &Args::default());
2796
2797 let replay_of = |name: &str| {
2798 tools
2799 .iter()
2800 .find(|tool| tool.tool.schema().name == name)
2801 .map(|tool| tool.replay)
2802 };
2803
2804 for read_only in ["read", "docs"] {
2805 assert_eq!(
2806 replay_of(read_only),
2807 Some(ToolReplay::Safe),
2808 "{read_only} is side-effect free, so recovery may re-run it"
2809 );
2810 }
2811 for mutating in ["bash", "edit", "write", "powershell"] {
2812 if let Some(replay) = replay_of(mutating) {
2813 assert_eq!(
2814 replay,
2815 ToolReplay::Never,
2816 "{mutating} must never be replayed: re-running it would duplicate its effect"
2817 );
2818 }
2819 }
2820 }
2821
2822 #[test]
2823 fn default_tools_include_docs_lookup() {
2824 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2825 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2826 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2827 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2828 let names: Vec<String> = build_tools(&context, &Args::default())
2829 .iter()
2830 .map(|tool| tool.tool.schema().name.clone())
2831 .collect();
2832
2833 assert!(names.contains(&"docs".to_string()));
2838 for legacy in ["grep", "find", "ls"] {
2839 assert!(
2840 !names.contains(&legacy.to_string()),
2841 "legacy tool {legacy} should not be registered by default"
2842 );
2843 }
2844 }
2845
2846 #[test]
2847 fn reload_settings_preflight_respects_trust() {
2848 struct RestoreConfigDir(Option<std::ffi::OsString>);
2849
2850 impl Drop for RestoreConfigDir {
2851 fn drop(&mut self) {
2852 match self.0.take() {
2853 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2854 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2855 }
2856 }
2857 }
2858
2859 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2860 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2861 let tmp = tempfile::tempdir().unwrap();
2862 let agent = tmp.path().join("agent");
2863 let cwd = tmp.path().join("project");
2864 std::fs::create_dir_all(&agent).unwrap();
2865 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2866 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2867 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
2868 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2869 let args = Args::default();
2870
2871 let trusted = validate_settings_for_reload(&args, &cwd, true);
2872 let untrusted = validate_settings_for_reload(&args, &cwd, false);
2873 assert!(trusted
2874 .unwrap_err()
2875 .contains("could not load project settings"));
2876 assert!(untrusted.is_ok());
2877
2878 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2879 assert!(validate_settings_for_reload(&args, &cwd, false)
2880 .unwrap_err()
2881 .contains("could not load global settings"));
2882
2883 let local_only = Args {
2884 dev_local_only: true,
2885 ..Args::default()
2886 };
2887 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
2888 }
2889
2890 #[tokio::test(flavor = "current_thread")]
2891 async fn reload_preserves_live_resources_when_settings_break() {
2892 struct RestoreConfigDir(Option<std::ffi::OsString>);
2893
2894 impl Drop for RestoreConfigDir {
2895 fn drop(&mut self) {
2896 match self.0.take() {
2897 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2898 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2899 }
2900 }
2901 }
2902
2903 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2904 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2905 let tmp = tempfile::tempdir().unwrap();
2906 let agent = tmp.path().join("agent");
2907 let cwd = tmp.path().join("project");
2908 let skill_dir = tmp.path().join("configured-skills");
2909 std::fs::create_dir_all(&agent).unwrap();
2910 std::fs::create_dir_all(&cwd).unwrap();
2911 std::fs::create_dir_all(&skill_dir).unwrap();
2912 std::fs::write(
2913 skill_dir.join("SKILL.md"),
2914 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
2915 )
2916 .unwrap();
2917 std::fs::write(
2918 agent.join("settings.json"),
2919 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2920 )
2921 .unwrap();
2922 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2923
2924 let resolved = crate::provider::resolve(
2925 Some("anthropic"),
2926 Some(crate::provider::DEFAULT_MODEL_ID),
2927 None,
2928 Some("test-key"),
2929 None,
2930 )
2931 .unwrap();
2932 let args = Args {
2933 trust_override: Some(false),
2934 no_session: true,
2935 no_extensions: true,
2936 no_prompt_templates: true,
2937 no_context_files: true,
2938 system_prompt: Some("stable system prompt".into()),
2939 ..Args::default()
2940 };
2941 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2942 let before_resources = harness.get_resources().await.unwrap();
2943 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
2944 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
2945 let before_prompt = harness.get_system_prompt().await.unwrap();
2946 let before_tools: Vec<String> = harness
2947 .get_tools()
2948 .await
2949 .unwrap()
2950 .iter()
2951 .map(|tool| tool.tool.schema().name.clone())
2952 .collect();
2953 let before_bridge = context
2954 .action_bridge
2955 .lock()
2956 .unwrap()
2957 .as_ref()
2958 .unwrap()
2959 .clone();
2960
2961 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2962 let outcome = reload_extension_resources(&harness, &context).await;
2963
2964 assert!(outcome.had_warnings);
2965 assert!(outcome.summary.contains("Settings reload failed"));
2966 assert!(outcome.summary.contains("could not load global settings"));
2967 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
2968 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
2969 let after_tools: Vec<String> = harness
2970 .get_tools()
2971 .await
2972 .unwrap()
2973 .iter()
2974 .map(|tool| tool.tool.schema().name.clone())
2975 .collect();
2976 assert_eq!(after_tools, before_tools);
2977 let after_bridge = context
2978 .action_bridge
2979 .lock()
2980 .unwrap()
2981 .as_ref()
2982 .unwrap()
2983 .clone();
2984 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
2985 }
2986
2987 #[test]
2988 fn reload_preparation_rejects_settings_changed_during_derivation() {
2989 struct RestoreConfigDir(Option<std::ffi::OsString>);
2990
2991 impl Drop for RestoreConfigDir {
2992 fn drop(&mut self) {
2993 match self.0.take() {
2994 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2995 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2996 }
2997 }
2998 }
2999
3000 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3001 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3002 let tmp = tempfile::tempdir().unwrap();
3003 let agent = tmp.path().join("agent");
3004 let cwd = tmp.path().join("project");
3005 let first_skill_dir = tmp.path().join("first-skills");
3006 let second_skill_dir = tmp.path().join("second-skills");
3007 std::fs::create_dir_all(&agent).unwrap();
3008 std::fs::create_dir_all(&cwd).unwrap();
3009 std::fs::write(
3010 agent.join("settings.json"),
3011 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3012 )
3013 .unwrap();
3014 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3015 let args = Args {
3016 trust_override: Some(false),
3017 no_extensions: true,
3018 no_prompt_templates: true,
3019 ..Args::default()
3020 };
3021
3022 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3023 std::fs::write(
3024 agent.join("settings.json"),
3025 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3026 )
3027 .unwrap();
3028 });
3029
3030 let error = result
3031 .err()
3032 .expect("settings mutation must fail preparation");
3033 assert!(error.contains("settings changed while reload inputs were being prepared"));
3034 }
3035
3036 #[tokio::test(flavor = "current_thread")]
3037 async fn reload_uses_frozen_settings_inputs_after_preparation() {
3038 struct RestoreConfigDir(Option<std::ffi::OsString>);
3039
3040 impl Drop for RestoreConfigDir {
3041 fn drop(&mut self) {
3042 match self.0.take() {
3043 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3044 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3045 }
3046 }
3047 }
3048
3049 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3050 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3051 let tmp = tempfile::tempdir().unwrap();
3052 let agent = tmp.path().join("agent");
3053 let cwd = tmp.path().join("project");
3054 let skill_dir = tmp.path().join("configured-skills");
3055 std::fs::create_dir_all(&agent).unwrap();
3056 std::fs::create_dir_all(&cwd).unwrap();
3057 std::fs::create_dir_all(&skill_dir).unwrap();
3058 std::fs::write(
3059 skill_dir.join("SKILL.md"),
3060 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3061 )
3062 .unwrap();
3063 std::fs::write(
3064 agent.join("settings.json"),
3065 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3066 )
3067 .unwrap();
3068 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3069
3070 let resolved = crate::provider::resolve(
3071 Some("anthropic"),
3072 Some(crate::provider::DEFAULT_MODEL_ID),
3073 None,
3074 Some("test-key"),
3075 None,
3076 )
3077 .unwrap();
3078 let args = Args {
3079 trust_override: Some(false),
3080 no_session: true,
3081 no_extensions: true,
3082 no_prompt_templates: true,
3083 no_context_files: true,
3084 system_prompt: Some("stable system prompt".into()),
3085 ..Args::default()
3086 };
3087 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3088
3089 let outcome = reload_extension_resources_inner(&harness, &context, || {
3090 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3091 })
3092 .await;
3093
3094 assert!(!outcome.summary.contains("Settings reload failed"));
3095 assert_eq!(
3096 outcome.summary,
3097 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3098 );
3099 let resources = harness.get_resources().await.unwrap();
3100 let skills = resources.skills.as_ref().unwrap();
3101 assert_eq!(skills.len(), 1);
3102 assert_eq!(skills[0].name, "frozen-skill");
3103 }
3104
3105 #[tokio::test]
3106 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3107 let tmp = tempfile::tempdir().unwrap();
3108 let skill_path = tmp.path().join("explicit-skill.md");
3109 std::fs::write(
3110 &skill_path,
3111 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3112 )
3113 .unwrap();
3114 let prompt_path = tmp.path().join("explicit-prompt.md");
3115 std::fs::write(
3116 &prompt_path,
3117 "---\ndescription: Explicit prompt\n---\nPrompt body",
3118 )
3119 .unwrap();
3120
3121 let args = Args {
3122 skill: vec![skill_path.clone()],
3123 prompt_template: vec![prompt_path.clone()],
3124 ..Args::default()
3125 };
3126 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3127 let prompt_paths =
3128 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3129 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3130 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3131
3132 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3133 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3134 assert_eq!(skills.skills[0].name, "explicit-skill");
3135
3136 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3137 assert_eq!(
3138 prompts.prompt_templates.len(),
3139 1,
3140 "{:?}",
3141 prompts.diagnostics
3142 );
3143 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3144 }
3145
3146 #[test]
3147 fn tool_policy_applies_to_rust_and_js_active_names() {
3148 let names = vec![
3149 "read".to_string(),
3150 "ask_user_question".to_string(),
3151 "write".to_string(),
3152 ];
3153
3154 let args = Args {
3155 tools: Some(vec!["read".into(), "ask_user_question".into()]),
3156 ..Args::default()
3157 };
3158 assert_eq!(
3159 filter_active_tool_names(names.clone(), &args),
3160 vec!["read", "ask_user_question"]
3161 );
3162
3163 let args = Args {
3164 exclude_tools: Some(vec!["ask_user_question".into()]),
3165 ..Args::default()
3166 };
3167 assert_eq!(
3168 filter_active_tool_names(names.clone(), &args),
3169 vec!["read", "write"]
3170 );
3171
3172 let args = Args {
3173 no_tools: true,
3174 ..Args::default()
3175 };
3176 assert!(filter_active_tool_names(names, &args).is_empty());
3177 }
3178
3179 #[test]
3180 fn select_ephemeral_when_no_session() {
3181 let args = Args {
3182 no_session: true,
3183 ..Args::default()
3184 };
3185 let cwd = Path::new("/tmp");
3186 assert!(matches!(
3187 select_session(&args, cwd),
3188 SessionSelection::Ephemeral
3189 ));
3190 }
3191
3192 #[test]
3193 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3194 let defaults = Args::default();
3195 assert!(resolve_project_trust(
3196 &defaults,
3197 Path::new("C:/definitely-not-a-project")
3198 ));
3199
3200 let denied = Args {
3201 trust_override: Some(false),
3202 ..Args::default()
3203 };
3204 assert!(!resolve_project_trust(
3205 &denied,
3206 Path::new("C:/definitely-not-a-project")
3207 ));
3208 }
3209
3210 #[tokio::test(flavor = "current_thread")]
3211 async fn build_preserves_the_supplied_startup_project_snapshot() {
3212 struct RestoreConfigDir(Option<std::ffi::OsString>);
3213
3214 impl Drop for RestoreConfigDir {
3215 fn drop(&mut self) {
3216 match self.0.take() {
3217 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3218 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3219 }
3220 }
3221 }
3222
3223 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3224 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3225 let tmp = tempfile::tempdir().unwrap();
3226 let agent = tmp.path().join("agent");
3227 let cwd = tmp.path().join("project");
3228 std::fs::create_dir_all(&agent).unwrap();
3229 std::fs::create_dir_all(&cwd).unwrap();
3230 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3231
3232 let resolved = crate::provider::resolve(
3233 Some("anthropic"),
3234 Some(crate::provider::DEFAULT_MODEL_ID),
3235 None,
3236 Some("test-key"),
3237 None,
3238 )
3239 .unwrap();
3240 let args = Args {
3241 trust_override: Some(false),
3242 no_session: true,
3243 no_tools: true,
3244 no_extensions: true,
3245 no_skills: true,
3246 no_prompt_templates: true,
3247 no_context_files: true,
3248 system_prompt: Some("test prompt".into()),
3249 ..Args::default()
3250 };
3251
3252 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3253
3254 assert_eq!(context.cwd, cwd);
3255 assert!(context.project_trusted);
3256 }
3257
3258 #[tokio::test(flavor = "current_thread")]
3259 async fn build_applies_the_name_flag_to_the_session() {
3260 struct RestoreConfigDir(Option<std::ffi::OsString>);
3261
3262 impl Drop for RestoreConfigDir {
3263 fn drop(&mut self) {
3264 match self.0.take() {
3265 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3266 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3267 }
3268 }
3269 }
3270
3271 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3272 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3273 let tmp = tempfile::tempdir().unwrap();
3274 let agent = tmp.path().join("agent");
3275 let cwd = tmp.path().join("project");
3276 std::fs::create_dir_all(&agent).unwrap();
3277 std::fs::create_dir_all(&cwd).unwrap();
3278 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3279
3280 let resolved = crate::provider::resolve(
3281 Some("anthropic"),
3282 Some(crate::provider::DEFAULT_MODEL_ID),
3283 None,
3284 Some("test-key"),
3285 None,
3286 )
3287 .unwrap();
3288 let args = Args {
3289 trust_override: Some(false),
3290 no_tools: true,
3291 no_extensions: true,
3292 no_skills: true,
3293 no_prompt_templates: true,
3294 no_context_files: true,
3295 system_prompt: Some("test prompt".into()),
3296 name: Some("smoke name".into()),
3297 session_dir: Some(cwd.join("sessions")),
3298 ..Args::default()
3299 };
3300
3301 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3302 assert_eq!(
3303 harness.get_name().await.unwrap().as_deref(),
3304 Some("smoke name")
3305 );
3306 }
3307
3308 #[test]
3309 fn select_latest_for_continue_and_resume() {
3310 let args = Args {
3311 continue_session: true,
3312 ..Args::default()
3313 };
3314 let cwd = Path::new("/tmp");
3315 assert!(matches!(
3316 select_session(&args, cwd),
3317 SessionSelection::Latest { .. }
3318 ));
3319
3320 let args = Args {
3321 resume: true,
3322 ..Args::default()
3323 };
3324 assert!(matches!(
3325 select_session(&args, cwd),
3326 SessionSelection::Latest { .. }
3327 ));
3328 }
3329
3330 #[test]
3331 fn select_by_id_for_session_flag() {
3332 let args = Args {
3333 session: Some("01a02ece".into()),
3334 ..Args::default()
3335 };
3336 let cwd = Path::new("/tmp");
3337 assert!(matches!(
3338 select_session(&args, cwd),
3339 SessionSelection::ById { id, .. } if id == "01a02ece"
3340 ));
3341 }
3342
3343 #[test]
3344 fn select_new_with_custom_dir() {
3345 let args = Args {
3346 session_dir: Some(PathBuf::from("/tmp/sess")),
3347 ..Args::default()
3348 };
3349 let cwd = Path::new("/tmp");
3350 match select_session(&args, cwd) {
3351 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
3352 other => panic!("expected New, got {other:?}"),
3353 }
3354 }
3355
3356 #[test]
3357 fn select_new_default_dir() {
3358 let args = Args::default();
3359 let cwd = Path::new("/proj");
3360 match select_session(&args, cwd) {
3361 SessionSelection::New { dir, .. } => {
3362 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
3363 }
3364 other => panic!("expected New, got {other:?}"),
3365 }
3366 }
3367
3368 #[test]
3369 fn default_session_dir_only_uses_rpi() {
3370 let tmp = tempfile::tempdir().unwrap();
3371 let cwd = tmp.path();
3372 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3373 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3374 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3375 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3376 }
3377
3378 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3379 async fn ephemeral_session_builds_roundtrips() {
3380 let s = ephemeral_session();
3384 let leaf = s.get_leaf_id().await;
3385 assert!(leaf.is_ok());
3386 }
3387
3388 #[test]
3406 fn host_private_env_matches_the_declared_contract() {
3407 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
3409 assert_eq!(
3410 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
3411 [
3412 "LANGFUSE_PI_PARENT_TRACE_ID",
3413 "LANGFUSE_PI_PARENT_SPAN_ID",
3414 "LANGFUSE_PI_PARENT_SESSION_ID",
3415 "LANGFUSE_PI_PARENT_DEPTH",
3416 ],
3417 "these strings are what rpi-langfuse (a different repo) matches; a \
3418 change here is a breaking cross-repo change, not a refactor"
3419 );
3420
3421 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
3424 .iter()
3425 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
3426 {
3427 assert!(
3428 rpi_tools::is_host_private_env(name),
3429 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
3430 rpi_tools::HOST_PRIVATE_ENV
3431 );
3432 }
3433
3434 for visible in [
3437 crate::args::RPI_OFFLINE_ENV,
3438 crate::config::CONFIG_DIR_ENV,
3439 "PATH",
3440 ] {
3441 assert!(
3442 !rpi_tools::is_host_private_env(visible),
3443 "{visible} describes the user's configuration and must still reach the shell"
3444 );
3445 }
3446 }
3447 #[tokio::test]
3448 async fn reload_shutdown_joins_all_handlers_even_after_veto() {
3449 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
3450 use std::sync::atomic::AtomicUsize;
3451 extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
3452 let calls = unsafe { &*(data as *const AtomicUsize) };
3453 std::thread::sleep(std::time::Duration::from_millis(20));
3454 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
3455 rpi_plugin_sdk::EVENT_HANDLER_ABORT
3456 } else {
3457 0
3458 }
3459 }
3460 let calls = AtomicUsize::new(0);
3461 let mut registry = rpi_extensions::ExtensionRegistry::new();
3462 for name in ["first", "second"] {
3463 registry.register_event_handler(
3464 name.into(),
3465 EventTag::SessionShutdown,
3466 stop,
3467 &calls as *const _ as *mut std::ffi::c_void,
3468 );
3469 }
3470 super::shutdown_extensions_for_reload(
3471 Some(Arc::new(registry.snapshot())),
3472 rpi_extensions::PluginKeepalive::empty(),
3473 )
3474 .await
3475 .unwrap();
3476 assert_eq!(calls.load(Ordering::SeqCst), 2);
3477 }
3478}