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 providers: resolved.providers.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 providers: Vec<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.providers,
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 providers: &[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>> = providers.to_vec();
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>> = resolved.providers.clone();
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(
1753 ctx,
1754 Some(rpi_tools::tools::read::ReadToolOptions {
1755 image_processor: Some(Arc::new(
1756 rpi_tools::image_processing::InlineImageProcessor,
1757 )),
1758 ..Default::default()
1759 }),
1760 ))
1761 .with_replay(ToolReplay::Safe),
1762 ),
1763 (
1764 "bash",
1765 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1766 ),
1767 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1768 ("write", HarnessTool::new(create_write_tool(ctx))),
1769 (
1770 "docs",
1771 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
1772 ),
1773 ];
1774
1775 #[cfg(target_os = "windows")]
1777 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
1778
1779 if args.tools.is_none() {
1781 if let Ok(settings) = crate::settings::load_settings() {
1782 if let Some(default_tools) = &settings.default_tools {
1783 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
1784 }
1785 }
1786 }
1787
1788 if args.no_builtin_tools {
1792 all.clear();
1793 }
1794
1795 if let Some(allow) = &args.tools {
1797 all.retain(|(name, _)| allow.iter().any(|a| a == name));
1798 }
1799 if let Some(deny) = &args.exclude_tools {
1801 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1802 }
1803
1804 all.into_iter().map(|(_, t)| t).collect()
1805}
1806
1807fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1811 filter_active_tool_names(
1812 tools.iter().map(|tool| tool.tool.schema().name.clone()),
1813 args,
1814 )
1815}
1816
1817pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
1820 if args.no_tools {
1821 return false;
1822 }
1823 if args
1824 .tools
1825 .as_ref()
1826 .is_some_and(|allow| !allow.iter().any(|value| value == name))
1827 {
1828 return false;
1829 }
1830 if args
1831 .exclude_tools
1832 .as_ref()
1833 .is_some_and(|deny| deny.iter().any(|value| value == name))
1834 {
1835 return false;
1836 }
1837 true
1838}
1839
1840pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
1841where
1842 I: IntoIterator<Item = String>,
1843{
1844 names
1845 .into_iter()
1846 .filter(|name| tool_name_allowed(name, args))
1847 .collect()
1848}
1849
1850async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1852 match selection {
1853 SessionSelection::Ephemeral => Ok(ephemeral_session()),
1854 SessionSelection::New { dir, .. } => {
1855 std::fs::create_dir_all(dir)
1858 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1859 let session = create_jsonl_session(dir, cwd)
1860 .await
1861 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1862 Ok(session)
1863 }
1864 SessionSelection::Latest { .. }
1865 | SessionSelection::ById { .. }
1866 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
1867 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
1868 }
1869}
1870
1871async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1877 match selection {
1881 SessionSelection::Latest { dir } => {
1882 let metas = list_session_metadata_in_dir(cwd, dir).await?;
1883 let Some(meta) = first_resumable_session(&metas) else {
1884 return Err(BuildError::SessionNotFound {
1885 requested: "the most recent session".to_string(),
1886 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1887 });
1888 };
1889 open_session(meta, cwd).await
1890 }
1891 SessionSelection::ById { id, dir } => open_session_by_id_in_dir(id, cwd, dir)
1892 .await
1893 .map_err(|e| match e {
1894 OpenError::NotFound { requested } => BuildError::SessionNotFound {
1895 requested,
1896 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1897 },
1898 OpenError::Other(msg) => BuildError::SessionOpen(msg),
1899 }),
1900 SessionSelection::ByExactId { id, dir } => {
1901 let metas = list_session_metadata_in_dir(cwd, dir).await?;
1904 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
1905 return open_session(meta, cwd).await;
1906 }
1907 std::fs::create_dir_all(dir)
1908 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1909 create_jsonl_session_with_id(dir, cwd, Some(id.clone()))
1910 .await
1911 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
1912 }
1913 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
1914 }
1915}
1916
1917async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
1920 use rpi_harness::session::jsonl::{
1921 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1922 };
1923 use rpi_harness::session::types::{ForkOptions, SessionStorage};
1924 use rpi_tools::FileSystem;
1925
1926 let dir = default_session_dir(Path::new(cwd));
1927 std::fs::create_dir_all(&dir)
1928 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1929 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1930 let fs: Arc<dyn FileSystem> = env.clone();
1931 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1932 fs: fs.clone(),
1933 sessions_root: dir.to_string_lossy().into_owned(),
1934 clock: Arc::new(SystemClock),
1935 ids: Arc::new(DefaultIdGenerator::new()),
1936 });
1937 let metas = repo
1938 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
1939 .await
1940 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
1941 let source_meta = metas
1942 .iter()
1943 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
1944 .ok_or_else(|| BuildError::SessionNotFound {
1945 requested: format!("--fork {source}"),
1946 dir: dir.display().to_string(),
1947 })?;
1948 let fork_storage = repo
1949 .fork_typed(
1950 source_meta,
1951 &JsonlSessionCreateOptions {
1952 id: None,
1953 parent_session_id: Some(source_meta.id.clone()),
1954 cwd: cwd.to_string(),
1955 metadata: None,
1956 },
1957 &ForkOptions::default(),
1958 )
1959 .await
1960 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
1961 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
1962 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
1963 Ok(Session::new(storage_arc, None))
1964}
1965
1966pub enum OpenError {
1969 NotFound { requested: String },
1971 Other(String),
1973}
1974
1975impl std::fmt::Display for OpenError {
1976 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1977 match self {
1978 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
1979 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
1984 }
1985 }
1986}
1987
1988pub async fn list_session_metadata(
1991 cwd: &str,
1992) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
1993 let dir = default_session_dir(Path::new(cwd));
1994 list_session_metadata_in_dir(cwd, &dir).await
1995}
1996
1997async fn list_session_metadata_in_dir(
1998 cwd: &str,
1999 dir: &Path,
2000) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2001 use rpi_harness::session::jsonl::{
2002 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2003 };
2004 use rpi_tools::FileSystem;
2005
2006 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2007 let fs: Arc<dyn FileSystem> = env.clone();
2008 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2009 fs: fs.clone(),
2010 sessions_root: dir.to_string_lossy().into_owned(),
2011 clock: Arc::new(SystemClock),
2012 ids: Arc::new(DefaultIdGenerator::new()),
2013 });
2014 repo.list_typed(&JsonlSessionListOptions::default())
2015 .await
2016 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2017}
2018
2019#[derive(Debug, Clone, Default, PartialEq, Eq)]
2035pub struct SessionSummary {
2036 pub name: Option<String>,
2038 pub preview: Option<String>,
2040 pub empty: bool,
2042 pub bytes: u64,
2044}
2045
2046const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
2048const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
2050const SUMMARY_PREVIEW_CHARS: usize = 80;
2052
2053pub fn summarize_session_file(path: &Path) -> SessionSummary {
2057 use std::io::{Read, Seek, SeekFrom};
2058
2059 let Ok(mut file) = std::fs::File::open(path) else {
2060 return SessionSummary::default();
2061 };
2062 let Ok(size) = file.metadata().map(|m| m.len()) else {
2063 return SessionSummary::default();
2064 };
2065 if size == 0 {
2066 return SessionSummary {
2067 name: None,
2068 preview: None,
2069 empty: true,
2070 bytes: 0,
2071 };
2072 }
2073
2074 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2077 let head_len = file.read(&mut head).unwrap_or(0);
2078 head.truncate(head_len);
2079 let head = String::from_utf8_lossy(&head);
2080 let mut lines: Vec<&str> = head.lines().collect();
2081 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2082 let empty = (size as usize) <= header_len + 1;
2084 if (head_len as u64) < size && !lines.is_empty() {
2087 lines.pop();
2088 }
2089
2090 let mut summary = SessionSummary {
2091 name: None,
2092 preview: None,
2093 empty,
2094 bytes: size,
2095 };
2096 for line in lines {
2097 apply_summary_line(line, &mut summary);
2098 }
2099
2100 if size > SUMMARY_HEAD_BYTES
2104 && file
2105 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2106 .is_ok()
2107 {
2108 let mut tail = Vec::new();
2109 if file.read_to_end(&mut tail).is_ok() {
2110 let tail = String::from_utf8_lossy(&tail);
2111 let mut tail_lines = tail.lines();
2112 tail_lines.next();
2114 for line in tail_lines {
2115 if let Some(name) = name_from_line(line) {
2116 summary.name = name;
2117 }
2118 }
2119 }
2120 }
2121 summary
2122}
2123
2124fn name_from_line(line: &str) -> Option<Option<String>> {
2127 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2128 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2129 return None;
2130 }
2131 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2132 return None;
2133 }
2134 Some(match value.get("name") {
2135 Some(serde_json::Value::String(name)) => Some(name.clone()),
2136 _ => None,
2137 })
2138}
2139
2140fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2143 if let Some(name) = name_from_line(line) {
2144 summary.name = name;
2145 return;
2146 }
2147 if summary.preview.is_some() {
2148 return;
2149 }
2150 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2151 return;
2152 };
2153 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2154 return;
2155 }
2156 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2157 return;
2158 }
2159 let Some(message) = value.get("message") else {
2160 return;
2161 };
2162 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2163 return;
2164 }
2165 let text = match message.get("content") {
2166 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2167 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2168 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2169 .then(|| block.get("text").and_then(|v| v.as_str()))
2170 .flatten()
2171 }),
2172 _ => None,
2173 };
2174 let Some(text) = text else { return };
2175 let collapsed = text
2176 .lines()
2177 .map(str::trim)
2178 .filter(|line| !line.is_empty())
2179 .collect::<Vec<_>>()
2180 .join(" ");
2181 if collapsed.is_empty() {
2182 return;
2183 }
2184 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2185}
2186
2187pub fn session_display_label(summary: &SessionSummary) -> String {
2191 if let Some(name) = summary.name.as_deref().map(str::trim) {
2192 if !name.is_empty() {
2193 return name.to_string();
2194 }
2195 }
2196 match summary.preview.as_deref().map(str::trim) {
2197 Some(preview) if !preview.is_empty() => preview.to_string(),
2198 _ => "(no messages)".to_string(),
2199 }
2200}
2201
2202pub fn format_session_bytes(bytes: u64) -> String {
2205 if bytes >= 1_048_576 {
2206 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2207 } else if bytes >= 1024 {
2208 format!("{}KB", bytes / 1024)
2209 } else {
2210 format!("{bytes}B")
2211 }
2212}
2213
2214pub fn short_session_id(id: &str) -> &str {
2217 &id[..id.len().min(8)]
2218}
2219
2220pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2229 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2230 let mut visit = |path: PathBuf| {
2231 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2232 return;
2233 }
2234 let modified = std::fs::metadata(&path)
2235 .and_then(|m| m.modified())
2236 .unwrap_or(std::time::UNIX_EPOCH);
2237 files.push((path, modified));
2238 };
2239 if let Ok(entries) = std::fs::read_dir(dir) {
2240 for entry in entries.flatten() {
2241 let path = entry.path();
2242 if path.is_dir() {
2243 if let Ok(inner) = std::fs::read_dir(&path) {
2244 for child in inner.flatten() {
2245 visit(child.path());
2246 }
2247 }
2248 } else {
2249 visit(path);
2250 }
2251 }
2252 }
2253 files.sort_by(|a, b| b.1.cmp(&a.1));
2254 files.into_iter().map(|(path, _)| path).collect()
2255}
2256
2257fn first_resumable_session<'a>(
2262 metas: &'a [rpi_harness::session::jsonl::JsonlSessionMetadata],
2263) -> Option<&'a rpi_harness::session::jsonl::JsonlSessionMetadata> {
2264 metas
2265 .iter()
2266 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2267 .or_else(|| metas.first())
2268}
2269
2270pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2274 let dir = default_session_dir(Path::new(cwd));
2275 open_session_by_id_in_dir(id, cwd, &dir).await
2276}
2277
2278async fn open_session_by_id_in_dir(id: &str, cwd: &str, dir: &Path) -> Result<Session, OpenError> {
2279 let metas = list_session_metadata_in_dir(cwd, dir)
2280 .await
2281 .map_err(|e| OpenError::Other(e.to_string()))?;
2282 let Some(meta) = metas
2283 .iter()
2284 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2285 else {
2286 return Err(OpenError::NotFound {
2287 requested: format!("session {id}"),
2288 });
2289 };
2290 open_session(meta, cwd).await.map_err(|e| {
2291 OpenError::Other(match e {
2292 BuildError::SessionOpen(reason) => reason,
2296 other => other.to_string(),
2297 })
2298 })
2299}
2300
2301fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
2326 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
2327 tool_context.set_session_id(id);
2328}
2329
2330pub(crate) async fn set_active_session(
2341 harness: &AgentHarness,
2342 session: Session,
2343 tool_context: Option<&rpi_extensions::ToolCallContext>,
2344) -> Result<(), String> {
2345 if let Ok(metadata) = session.get_metadata().await {
2346 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2347 if let Some(context) = tool_context {
2348 context.set_session_id(metadata.id);
2349 }
2350 }
2351 harness
2352 .set_session(session)
2353 .await
2354 .map_err(|e| e.to_string())
2355}
2356
2357pub(crate) async fn fork_session_storage(
2364 harness: &AgentHarness,
2365 cwd: &str,
2366) -> Result<Session, String> {
2367 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2368 use rpi_tools::FileSystem;
2369
2370 let dir = default_session_dir(Path::new(cwd));
2371 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2372 let fs: Arc<dyn FileSystem> = env.clone();
2373 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2374 fs,
2375 sessions_root: dir.to_string_lossy().into_owned(),
2376 clock: Arc::new(SystemClock),
2377 ids: Arc::new(DefaultIdGenerator::new()),
2378 });
2379 let id = harness.session().storage().metadata().id.clone();
2382 let metas = list_session_metadata(cwd)
2383 .await
2384 .map_err(|e| e.to_string())?;
2385 let Some(source) = metas.iter().find(|m| m.id == id) else {
2386 return Err(format!("current session {id} not found on disk"));
2387 };
2388 let fork_storage = repo
2389 .fork_typed(
2390 source,
2391 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2392 id: None,
2393 parent_session_id: Some(source.id.clone()),
2394 cwd: cwd.to_string(),
2395 metadata: None,
2396 },
2397 &rpi_harness::session::types::ForkOptions::default(),
2398 )
2399 .await
2400 .map_err(|e| e.to_string())?;
2401 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2402 Ok(Session::new(Arc::new(fork_storage), None))
2403}
2404
2405static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2407static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2410
2411fn claim_session_lock(session_path: &str) -> Result<(), String> {
2420 let lock_path = PathBuf::from(format!("{session_path}.lock"));
2421 if SESSION_LOCK_PATHS
2422 .get_or_init(|| Mutex::new(HashSet::new()))
2423 .lock()
2424 .unwrap()
2425 .contains(&lock_path)
2426 {
2427 return Ok(());
2428 }
2429 let file = std::fs::OpenOptions::new()
2430 .create(true)
2431 .write(true)
2432 .open(&lock_path)
2433 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2434 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2435 return Err(format!(
2436 "session {session_path} is already open in another rpi process"
2437 ));
2438 }
2439 SESSION_LOCKS
2440 .get_or_init(|| Mutex::new(Vec::new()))
2441 .lock()
2442 .unwrap()
2443 .push(file);
2444 SESSION_LOCK_PATHS
2445 .get_or_init(|| Mutex::new(HashSet::new()))
2446 .lock()
2447 .unwrap()
2448 .insert(lock_path);
2449 Ok(())
2450}
2451
2452async fn open_session(
2455 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2456 cwd: &str,
2457) -> Result<Session, BuildError> {
2458 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2459 use rpi_harness::session::types::SessionStorage;
2460 use rpi_tools::FileSystem;
2461
2462 let dir = default_session_dir(Path::new(cwd));
2463 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2464 let fs: Arc<dyn FileSystem> = env.clone();
2465 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2466 fs: fs.clone(),
2467 sessions_root: dir.to_string_lossy().into_owned(),
2468 clock: Arc::new(SystemClock),
2469 ids: Arc::new(DefaultIdGenerator::new()),
2470 });
2471 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2472 let storage = repo
2473 .open_by_jsonl_metadata(meta)
2474 .await
2475 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2476 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2480 Some(Path::new(&meta.path)),
2481 Path::new(&meta.cwd),
2482 Path::new(cwd),
2483 ) {
2484 eprintln!(
2485 "warning: {}",
2486 crate::session_cwd::format_missing_session_cwd_error(&issue)
2487 );
2488 }
2489 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2490 Ok(Session::new(storage_arc, None))
2491}
2492
2493fn ephemeral_session() -> Session {
2495 let storage = Arc::new(InMemorySessionStorage::new(
2496 SessionMetadata {
2497 id: "ephemeral".into(),
2498 created_at: 0,
2499 parent_session_id: None,
2500 },
2501 Arc::new(SystemClock),
2502 Arc::new(DefaultIdGenerator::new()),
2503 ));
2504 Session::new(storage, None)
2505}
2506
2507pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2518 create_jsonl_session_with_id(dir, cwd, None).await
2519}
2520
2521pub(crate) async fn create_jsonl_session_with_id(
2525 dir: &Path,
2526 cwd: &str,
2527 id: Option<String>,
2528) -> Result<Session, String> {
2529 use rpi_harness::session::jsonl::{
2530 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2531 };
2532 use rpi_tools::FileSystem;
2533
2534 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2537 let fs: Arc<dyn FileSystem> = env.clone();
2538
2539 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2540 fs: fs.clone(),
2541 sessions_root: dir.to_string_lossy().into_owned(),
2542 clock: Arc::new(SystemClock),
2543 ids: Arc::new(DefaultIdGenerator::new()),
2544 });
2545
2546 let opts = JsonlSessionCreateOptions {
2547 id, parent_session_id: None,
2549 cwd: cwd.to_string(),
2550 metadata: None,
2551 };
2552 let storage = repo
2553 .create_typed(&opts)
2554 .await
2555 .map_err(|e| format!("create session: {e}"))?;
2556 claim_session_lock(&storage.jsonl_metadata().path)
2557 .map_err(|e| format!("create session: {e}"))?;
2558 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2560 Ok(Session::new(storage_arc, None))
2561}
2562
2563fn read_append_target(target: &str) -> Option<String> {
2566 let path = Path::new(target);
2567 if path.is_file() {
2568 std::fs::read_to_string(path).ok()
2569 } else {
2570 None
2571 }
2572}
2573
2574#[cfg(test)]
2575mod tests {
2576 use super::*;
2577 use crate::args::Args;
2578
2579 fn scratch(name: &str) -> PathBuf {
2581 let nanos = std::time::SystemTime::now()
2582 .duration_since(std::time::UNIX_EPOCH)
2583 .unwrap()
2584 .as_nanos();
2585 let path = std::env::temp_dir().join(format!(
2586 "rpi-summary-{}-{nanos}-{name}.jsonl",
2587 std::process::id()
2588 ));
2589 path
2590 }
2591
2592 #[test]
2593 fn summary_marks_a_header_only_session_empty() {
2594 let path = scratch("empty");
2595 std::fs::write(
2596 &path,
2597 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2598 )
2599 .unwrap();
2600 let summary = summarize_session_file(&path);
2601 assert!(summary.empty, "a header-only file has nothing to resume");
2602 assert!(summary.preview.is_none());
2603 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
2604 std::fs::remove_file(&path).ok();
2605 }
2606
2607 #[test]
2608 fn summary_reads_the_first_user_prompt_and_name() {
2609 let path = scratch("content");
2610 let body = concat!(
2611 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2612 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
2613 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
2614 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
2615 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
2616 );
2617 std::fs::write(&path, body).unwrap();
2618 let summary = summarize_session_file(&path);
2619 assert!(!summary.empty);
2620 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
2621 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
2622 std::fs::remove_file(&path).ok();
2623 }
2624
2625 #[test]
2626 fn summary_extracts_a_block_style_user_prompt() {
2627 let path = scratch("blocks");
2628 let body = concat!(
2629 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2630 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
2631 );
2632 std::fs::write(&path, body).unwrap();
2633 let summary = summarize_session_file(&path);
2634 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
2635 assert_eq!(session_display_label(&summary), "line one line two");
2636 std::fs::remove_file(&path).ok();
2637 }
2638
2639 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
2640 rpi_harness::session::jsonl::JsonlSessionMetadata {
2641 id: id.to_string(),
2642 created_at: 0,
2643 cwd: ".".to_string(),
2644 path: path.to_string_lossy().into_owned(),
2645 modified_at: 0,
2646 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
2647 parent_session_id: None,
2648 legacy_parent_session_path: None,
2649 metadata: None,
2650 }
2651 }
2652
2653 #[test]
2654 fn continue_skips_newer_empty_sessions() {
2655 let empty = scratch("newest-empty");
2656 std::fs::write(
2657 &empty,
2658 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2659 )
2660 .unwrap();
2661 let content = scratch("older-content");
2662 std::fs::write(
2663 &content,
2664 concat!(
2665 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2666 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
2667 ),
2668 )
2669 .unwrap();
2670
2671 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
2672 let picked = first_resumable_session(&metas).expect("a resumable session");
2673 assert_eq!(picked.id, "old");
2674
2675 let all_empty = vec![meta_for(&empty, "empty")];
2678 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
2679
2680 std::fs::remove_file(&empty).ok();
2681 std::fs::remove_file(&content).ok();
2682 }
2683
2684 #[test]
2685 fn list_session_files_sync_walks_the_cwd_subdirectory() {
2686 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
2687 let nested = root.join("--proj--");
2688 std::fs::create_dir_all(&nested).unwrap();
2689 let file = nested.join("2026-01-01_a.jsonl");
2690 std::fs::write(&file, "{}\n").unwrap();
2691 let found = list_session_files_sync(&root);
2692 assert!(found.iter().any(|p| p == &file));
2693 std::fs::remove_dir_all(&root).ok();
2694 }
2695
2696 #[test]
2697 fn default_prompt_mentions_cwd_and_tools() {
2698 let p = default_system_prompt("/tmp/proj", false);
2699 assert!(p.contains("/tmp/proj"));
2700 assert!(p.contains("read"));
2701 assert!(p.contains("bash"));
2702 assert!(p.contains("edit"));
2703 assert!(p.contains("write"));
2704 assert!(p.contains("docs"));
2705 assert!(!p.contains("- grep"));
2706 assert!(!p.contains("- find"));
2707 assert!(!p.contains("- ls"));
2708 assert!(!p.contains("- powershell"));
2709 assert!(p.contains("Bash/POSIX syntax, including on Windows"));
2710 assert!(p.contains("do not pass PowerShell cmdlets"));
2711 }
2712
2713 #[test]
2714 fn default_prompt_instructs_language_matching() {
2715 let p = default_system_prompt("/tmp/proj", false);
2719 let lower = p.to_lowercase();
2720 assert!(
2721 lower.contains("same language") && lower.contains("detect"),
2722 "the prompt must tell the model to detect and mirror the user's language: {p}"
2723 );
2724 }
2725
2726 #[test]
2727 fn default_prompt_keeps_working_state_in_the_visible_channel() {
2728 let p = default_system_prompt("/tmp/proj", false);
2736 let lower = p.to_lowercase();
2737 assert!(
2739 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
2740 "the prompt must explain that reasoning does not survive the turn: {p}"
2741 );
2742 assert!(
2744 lower.contains("visible") && lower.contains("todo tool"),
2745 "the prompt must point at visible text and the todo tool: {p}"
2746 );
2747 assert!(
2749 lower.contains("re-stating the same plan"),
2750 "the prompt must call out plan re-statement as a bug: {p}"
2751 );
2752 assert!(
2754 lower.contains("already shown earlier"),
2755 "the prompt must discourage re-fetching known content: {p}"
2756 );
2757 }
2758
2759 #[test]
2760 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
2761 let p = default_system_prompt("/tmp/proj", true);
2768 let lower = p.to_lowercase();
2769 assert!(
2770 lower.contains("carried back"),
2771 "a replaying model must be told its reasoning survives: {p}"
2772 );
2773 assert!(
2774 !lower.contains("reasoning is not"),
2775 "the replayed variant must not claim reasoning is dropped: {p}"
2776 );
2777 assert!(
2778 lower.contains("do not re-derive"),
2779 "the replayed variant must forbid re-deriving the plan: {p}"
2780 );
2781 assert!(
2782 lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
2783 "the anti-repetition rule must survive the flip: {p}"
2784 );
2785 assert!(
2786 lower.contains("already shown earlier"),
2787 "the re-read rule must survive the flip: {p}"
2788 );
2789 }
2790
2791 #[test]
2799 fn only_read_only_tools_are_replayable() {
2800 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2801 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2802 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2803 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2804 let tools = build_tools(&context, &Args::default());
2805
2806 let replay_of = |name: &str| {
2807 tools
2808 .iter()
2809 .find(|tool| tool.tool.schema().name == name)
2810 .map(|tool| tool.replay)
2811 };
2812
2813 for read_only in ["read", "docs"] {
2814 assert_eq!(
2815 replay_of(read_only),
2816 Some(ToolReplay::Safe),
2817 "{read_only} is side-effect free, so recovery may re-run it"
2818 );
2819 }
2820 for mutating in ["bash", "edit", "write", "powershell"] {
2821 if let Some(replay) = replay_of(mutating) {
2822 assert_eq!(
2823 replay,
2824 ToolReplay::Never,
2825 "{mutating} must never be replayed: re-running it would duplicate its effect"
2826 );
2827 }
2828 }
2829 }
2830
2831 #[test]
2832 fn default_tools_include_docs_lookup() {
2833 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2834 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2835 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2836 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2837 let names: Vec<String> = build_tools(&context, &Args::default())
2838 .iter()
2839 .map(|tool| tool.tool.schema().name.clone())
2840 .collect();
2841
2842 assert!(names.contains(&"docs".to_string()));
2847 for legacy in ["grep", "find", "ls"] {
2848 assert!(
2849 !names.contains(&legacy.to_string()),
2850 "legacy tool {legacy} should not be registered by default"
2851 );
2852 }
2853 }
2854
2855 #[test]
2856 fn reload_settings_preflight_respects_trust() {
2857 struct RestoreConfigDir(Option<std::ffi::OsString>);
2858
2859 impl Drop for RestoreConfigDir {
2860 fn drop(&mut self) {
2861 match self.0.take() {
2862 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2863 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2864 }
2865 }
2866 }
2867
2868 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2869 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2870 let tmp = tempfile::tempdir().unwrap();
2871 let agent = tmp.path().join("agent");
2872 let cwd = tmp.path().join("project");
2873 std::fs::create_dir_all(&agent).unwrap();
2874 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2875 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2876 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
2877 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2878 let args = Args::default();
2879
2880 let trusted = validate_settings_for_reload(&args, &cwd, true);
2881 let untrusted = validate_settings_for_reload(&args, &cwd, false);
2882 assert!(trusted
2883 .unwrap_err()
2884 .contains("could not load project settings"));
2885 assert!(untrusted.is_ok());
2886
2887 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2888 assert!(validate_settings_for_reload(&args, &cwd, false)
2889 .unwrap_err()
2890 .contains("could not load global settings"));
2891
2892 let local_only = Args {
2893 dev_local_only: true,
2894 ..Args::default()
2895 };
2896 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
2897 }
2898
2899 #[tokio::test(flavor = "current_thread")]
2900 async fn reload_preserves_live_resources_when_settings_break() {
2901 struct RestoreConfigDir(Option<std::ffi::OsString>);
2902
2903 impl Drop for RestoreConfigDir {
2904 fn drop(&mut self) {
2905 match self.0.take() {
2906 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2907 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2908 }
2909 }
2910 }
2911
2912 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2913 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2914 let tmp = tempfile::tempdir().unwrap();
2915 let agent = tmp.path().join("agent");
2916 let cwd = tmp.path().join("project");
2917 let skill_dir = tmp.path().join("configured-skills");
2918 std::fs::create_dir_all(&agent).unwrap();
2919 std::fs::create_dir_all(&cwd).unwrap();
2920 std::fs::create_dir_all(&skill_dir).unwrap();
2921 std::fs::write(
2922 skill_dir.join("SKILL.md"),
2923 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
2924 )
2925 .unwrap();
2926 std::fs::write(
2927 agent.join("settings.json"),
2928 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2929 )
2930 .unwrap();
2931 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2932
2933 let resolved = crate::provider::resolve(
2934 Some("anthropic"),
2935 Some(crate::provider::DEFAULT_MODEL_ID),
2936 None,
2937 Some("test-key"),
2938 None,
2939 )
2940 .unwrap();
2941 let args = Args {
2942 trust_override: Some(false),
2943 no_session: true,
2944 no_extensions: true,
2945 no_prompt_templates: true,
2946 no_context_files: true,
2947 system_prompt: Some("stable system prompt".into()),
2948 ..Args::default()
2949 };
2950 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2951 let before_resources = harness.get_resources().await.unwrap();
2952 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
2953 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
2954 let before_prompt = harness.get_system_prompt().await.unwrap();
2955 let before_tools: Vec<String> = harness
2956 .get_tools()
2957 .await
2958 .unwrap()
2959 .iter()
2960 .map(|tool| tool.tool.schema().name.clone())
2961 .collect();
2962 let before_bridge = context
2963 .action_bridge
2964 .lock()
2965 .unwrap()
2966 .as_ref()
2967 .unwrap()
2968 .clone();
2969
2970 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2971 let outcome = reload_extension_resources(&harness, &context).await;
2972
2973 assert!(outcome.had_warnings);
2974 assert!(outcome.summary.contains("Settings reload failed"));
2975 assert!(outcome.summary.contains("could not load global settings"));
2976 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
2977 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
2978 let after_tools: Vec<String> = harness
2979 .get_tools()
2980 .await
2981 .unwrap()
2982 .iter()
2983 .map(|tool| tool.tool.schema().name.clone())
2984 .collect();
2985 assert_eq!(after_tools, before_tools);
2986 let after_bridge = context
2987 .action_bridge
2988 .lock()
2989 .unwrap()
2990 .as_ref()
2991 .unwrap()
2992 .clone();
2993 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
2994 }
2995
2996 #[test]
2997 fn reload_preparation_rejects_settings_changed_during_derivation() {
2998 struct RestoreConfigDir(Option<std::ffi::OsString>);
2999
3000 impl Drop for RestoreConfigDir {
3001 fn drop(&mut self) {
3002 match self.0.take() {
3003 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3004 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3005 }
3006 }
3007 }
3008
3009 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3010 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3011 let tmp = tempfile::tempdir().unwrap();
3012 let agent = tmp.path().join("agent");
3013 let cwd = tmp.path().join("project");
3014 let first_skill_dir = tmp.path().join("first-skills");
3015 let second_skill_dir = tmp.path().join("second-skills");
3016 std::fs::create_dir_all(&agent).unwrap();
3017 std::fs::create_dir_all(&cwd).unwrap();
3018 std::fs::write(
3019 agent.join("settings.json"),
3020 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3021 )
3022 .unwrap();
3023 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3024 let args = Args {
3025 trust_override: Some(false),
3026 no_extensions: true,
3027 no_prompt_templates: true,
3028 ..Args::default()
3029 };
3030
3031 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3032 std::fs::write(
3033 agent.join("settings.json"),
3034 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3035 )
3036 .unwrap();
3037 });
3038
3039 let error = result
3040 .err()
3041 .expect("settings mutation must fail preparation");
3042 assert!(error.contains("settings changed while reload inputs were being prepared"));
3043 }
3044
3045 #[tokio::test(flavor = "current_thread")]
3046 async fn reload_uses_frozen_settings_inputs_after_preparation() {
3047 struct RestoreConfigDir(Option<std::ffi::OsString>);
3048
3049 impl Drop for RestoreConfigDir {
3050 fn drop(&mut self) {
3051 match self.0.take() {
3052 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3053 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3054 }
3055 }
3056 }
3057
3058 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3059 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3060 let tmp = tempfile::tempdir().unwrap();
3061 let agent = tmp.path().join("agent");
3062 let cwd = tmp.path().join("project");
3063 let skill_dir = tmp.path().join("configured-skills");
3064 std::fs::create_dir_all(&agent).unwrap();
3065 std::fs::create_dir_all(&cwd).unwrap();
3066 std::fs::create_dir_all(&skill_dir).unwrap();
3067 std::fs::write(
3068 skill_dir.join("SKILL.md"),
3069 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3070 )
3071 .unwrap();
3072 std::fs::write(
3073 agent.join("settings.json"),
3074 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3075 )
3076 .unwrap();
3077 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3078
3079 let resolved = crate::provider::resolve(
3080 Some("anthropic"),
3081 Some(crate::provider::DEFAULT_MODEL_ID),
3082 None,
3083 Some("test-key"),
3084 None,
3085 )
3086 .unwrap();
3087 let args = Args {
3088 trust_override: Some(false),
3089 no_session: true,
3090 no_extensions: true,
3091 no_prompt_templates: true,
3092 no_context_files: true,
3093 system_prompt: Some("stable system prompt".into()),
3094 ..Args::default()
3095 };
3096 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3097
3098 let outcome = reload_extension_resources_inner(&harness, &context, || {
3099 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3100 })
3101 .await;
3102
3103 assert!(!outcome.summary.contains("Settings reload failed"));
3104 assert_eq!(
3105 outcome.summary,
3106 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3107 );
3108 let resources = harness.get_resources().await.unwrap();
3109 let skills = resources.skills.as_ref().unwrap();
3110 assert_eq!(skills.len(), 1);
3111 assert_eq!(skills[0].name, "frozen-skill");
3112 }
3113
3114 #[tokio::test]
3115 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3116 let tmp = tempfile::tempdir().unwrap();
3117 let skill_path = tmp.path().join("explicit-skill.md");
3118 std::fs::write(
3119 &skill_path,
3120 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3121 )
3122 .unwrap();
3123 let prompt_path = tmp.path().join("explicit-prompt.md");
3124 std::fs::write(
3125 &prompt_path,
3126 "---\ndescription: Explicit prompt\n---\nPrompt body",
3127 )
3128 .unwrap();
3129
3130 let args = Args {
3131 skill: vec![skill_path.clone()],
3132 prompt_template: vec![prompt_path.clone()],
3133 ..Args::default()
3134 };
3135 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3136 let prompt_paths =
3137 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3138 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3139 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3140
3141 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3142 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3143 assert_eq!(skills.skills[0].name, "explicit-skill");
3144
3145 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3146 assert_eq!(
3147 prompts.prompt_templates.len(),
3148 1,
3149 "{:?}",
3150 prompts.diagnostics
3151 );
3152 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3153 }
3154
3155 #[test]
3156 fn tool_policy_applies_to_rust_and_js_active_names() {
3157 let names = vec![
3158 "read".to_string(),
3159 "ask_user_question".to_string(),
3160 "write".to_string(),
3161 ];
3162
3163 let args = Args {
3164 tools: Some(vec!["read".into(), "ask_user_question".into()]),
3165 ..Args::default()
3166 };
3167 assert_eq!(
3168 filter_active_tool_names(names.clone(), &args),
3169 vec!["read", "ask_user_question"]
3170 );
3171
3172 let args = Args {
3173 exclude_tools: Some(vec!["ask_user_question".into()]),
3174 ..Args::default()
3175 };
3176 assert_eq!(
3177 filter_active_tool_names(names.clone(), &args),
3178 vec!["read", "write"]
3179 );
3180
3181 let args = Args {
3182 no_tools: true,
3183 ..Args::default()
3184 };
3185 assert!(filter_active_tool_names(names, &args).is_empty());
3186 }
3187
3188 #[test]
3189 fn select_ephemeral_when_no_session() {
3190 let args = Args {
3191 no_session: true,
3192 ..Args::default()
3193 };
3194 let cwd = Path::new("/tmp");
3195 assert!(matches!(
3196 select_session(&args, cwd),
3197 SessionSelection::Ephemeral
3198 ));
3199 }
3200
3201 #[test]
3202 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3203 let defaults = Args::default();
3204 assert!(resolve_project_trust(
3205 &defaults,
3206 Path::new("C:/definitely-not-a-project")
3207 ));
3208
3209 let denied = Args {
3210 trust_override: Some(false),
3211 ..Args::default()
3212 };
3213 assert!(!resolve_project_trust(
3214 &denied,
3215 Path::new("C:/definitely-not-a-project")
3216 ));
3217 }
3218
3219 #[tokio::test(flavor = "current_thread")]
3220 async fn build_preserves_the_supplied_startup_project_snapshot() {
3221 struct RestoreConfigDir(Option<std::ffi::OsString>);
3222
3223 impl Drop for RestoreConfigDir {
3224 fn drop(&mut self) {
3225 match self.0.take() {
3226 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3227 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3228 }
3229 }
3230 }
3231
3232 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3233 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3234 let tmp = tempfile::tempdir().unwrap();
3235 let agent = tmp.path().join("agent");
3236 let cwd = tmp.path().join("project");
3237 std::fs::create_dir_all(&agent).unwrap();
3238 std::fs::create_dir_all(&cwd).unwrap();
3239 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3240
3241 let resolved = crate::provider::resolve(
3242 Some("anthropic"),
3243 Some(crate::provider::DEFAULT_MODEL_ID),
3244 None,
3245 Some("test-key"),
3246 None,
3247 )
3248 .unwrap();
3249 let args = Args {
3250 trust_override: Some(false),
3251 no_session: true,
3252 no_tools: true,
3253 no_extensions: true,
3254 no_skills: true,
3255 no_prompt_templates: true,
3256 no_context_files: true,
3257 system_prompt: Some("test prompt".into()),
3258 ..Args::default()
3259 };
3260
3261 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3262
3263 assert_eq!(context.cwd, cwd);
3264 assert!(context.project_trusted);
3265 }
3266
3267 #[tokio::test(flavor = "current_thread")]
3268 async fn build_applies_the_name_flag_to_the_session() {
3269 struct RestoreConfigDir(Option<std::ffi::OsString>);
3270
3271 impl Drop for RestoreConfigDir {
3272 fn drop(&mut self) {
3273 match self.0.take() {
3274 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3275 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3276 }
3277 }
3278 }
3279
3280 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3281 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3282 let tmp = tempfile::tempdir().unwrap();
3283 let agent = tmp.path().join("agent");
3284 let cwd = tmp.path().join("project");
3285 std::fs::create_dir_all(&agent).unwrap();
3286 std::fs::create_dir_all(&cwd).unwrap();
3287 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3288
3289 let resolved = crate::provider::resolve(
3290 Some("anthropic"),
3291 Some(crate::provider::DEFAULT_MODEL_ID),
3292 None,
3293 Some("test-key"),
3294 None,
3295 )
3296 .unwrap();
3297 let args = Args {
3298 trust_override: Some(false),
3299 no_tools: true,
3300 no_extensions: true,
3301 no_skills: true,
3302 no_prompt_templates: true,
3303 no_context_files: true,
3304 system_prompt: Some("test prompt".into()),
3305 name: Some("smoke name".into()),
3306 session_dir: Some(cwd.join("sessions")),
3307 ..Args::default()
3308 };
3309
3310 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3311 assert_eq!(
3312 harness.get_name().await.unwrap().as_deref(),
3313 Some("smoke name")
3314 );
3315 }
3316
3317 #[test]
3318 fn select_latest_for_continue_and_resume() {
3319 let args = Args {
3320 continue_session: true,
3321 ..Args::default()
3322 };
3323 let cwd = Path::new("/tmp");
3324 assert!(matches!(
3325 select_session(&args, cwd),
3326 SessionSelection::Latest { .. }
3327 ));
3328
3329 let args = Args {
3330 resume: true,
3331 ..Args::default()
3332 };
3333 assert!(matches!(
3334 select_session(&args, cwd),
3335 SessionSelection::Latest { .. }
3336 ));
3337 }
3338
3339 #[test]
3340 fn select_by_id_for_session_flag() {
3341 let args = Args {
3342 session: Some("01a02ece".into()),
3343 ..Args::default()
3344 };
3345 let cwd = Path::new("/tmp");
3346 assert!(matches!(
3347 select_session(&args, cwd),
3348 SessionSelection::ById { id, .. } if id == "01a02ece"
3349 ));
3350 }
3351
3352 #[test]
3353 fn select_new_with_custom_dir() {
3354 let args = Args {
3355 session_dir: Some(PathBuf::from("/tmp/sess")),
3356 ..Args::default()
3357 };
3358 let cwd = Path::new("/tmp");
3359 match select_session(&args, cwd) {
3360 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
3361 other => panic!("expected New, got {other:?}"),
3362 }
3363 }
3364
3365 #[test]
3366 fn select_new_default_dir() {
3367 let args = Args::default();
3368 let cwd = Path::new("/proj");
3369 match select_session(&args, cwd) {
3370 SessionSelection::New { dir, .. } => {
3371 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
3372 }
3373 other => panic!("expected New, got {other:?}"),
3374 }
3375 }
3376
3377 #[test]
3378 fn default_session_dir_only_uses_rpi() {
3379 let tmp = tempfile::tempdir().unwrap();
3380 let cwd = tmp.path();
3381 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3382 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3383 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3384 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3385 }
3386
3387 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3388 async fn ephemeral_session_builds_roundtrips() {
3389 let s = ephemeral_session();
3393 let leaf = s.get_leaf_id().await;
3394 assert!(leaf.is_ok());
3395 }
3396
3397 #[test]
3415 fn host_private_env_matches_the_declared_contract() {
3416 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
3418 assert_eq!(
3419 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
3420 [
3421 "LANGFUSE_PI_PARENT_TRACE_ID",
3422 "LANGFUSE_PI_PARENT_SPAN_ID",
3423 "LANGFUSE_PI_PARENT_SESSION_ID",
3424 "LANGFUSE_PI_PARENT_DEPTH",
3425 ],
3426 "these strings are what rpi-langfuse (a different repo) matches; a \
3427 change here is a breaking cross-repo change, not a refactor"
3428 );
3429
3430 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
3433 .iter()
3434 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
3435 {
3436 assert!(
3437 rpi_tools::is_host_private_env(name),
3438 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
3439 rpi_tools::HOST_PRIVATE_ENV
3440 );
3441 }
3442
3443 for visible in [
3446 crate::args::RPI_OFFLINE_ENV,
3447 crate::config::CONFIG_DIR_ENV,
3448 "PATH",
3449 ] {
3450 assert!(
3451 !rpi_tools::is_host_private_env(visible),
3452 "{visible} describes the user's configuration and must still reach the shell"
3453 );
3454 }
3455 }
3456 #[tokio::test]
3457 async fn reload_shutdown_joins_all_handlers_even_after_veto() {
3458 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
3459 use std::sync::atomic::AtomicUsize;
3460 extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
3461 let calls = unsafe { &*(data as *const AtomicUsize) };
3462 std::thread::sleep(std::time::Duration::from_millis(20));
3463 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
3464 rpi_plugin_sdk::EVENT_HANDLER_ABORT
3465 } else {
3466 0
3467 }
3468 }
3469 let calls = AtomicUsize::new(0);
3470 let mut registry = rpi_extensions::ExtensionRegistry::new();
3471 for name in ["first", "second"] {
3472 registry.register_event_handler(
3473 name.into(),
3474 EventTag::SessionShutdown,
3475 stop,
3476 &calls as *const _ as *mut std::ffi::c_void,
3477 );
3478 }
3479 super::shutdown_extensions_for_reload(
3480 Some(Arc::new(registry.snapshot())),
3481 rpi_extensions::PluginKeepalive::empty(),
3482 )
3483 .await
3484 .unwrap();
3485 assert_eq!(calls.load(Ordering::SeqCst), 2);
3486 }
3487}