1use std::collections::HashSet;
34use std::path::{Path, PathBuf};
35use std::sync::atomic::Ordering;
36use std::sync::{Arc, Mutex, OnceLock};
37
38use rpi_agent::AgentTool;
39use rpi_ai::Provider;
40use rpi_harness::agent_harness::AgentHarness;
41use rpi_harness::context_files::{format_project_context, load_project_context_files};
42use rpi_harness::session::memory::{InMemorySessionStorage, SystemClock};
43use rpi_harness::session::session::DefaultIdGenerator;
44use rpi_harness::session::types::{BranchBounds, EntryQuery, SessionMetadata};
45use rpi_harness::session::Session;
46use rpi_harness::system_prompt::compose_system_prompt;
47use rpi_harness::types::{
48 AgentHarnessOptions, AgentHarnessResources, AgentHarnessStreamOptions, DrivingMode,
49 HarnessTool, HarnessToolExecution, ToolReplay,
50};
51use rpi_tools::{
52 create_bash_tool, create_edit_tool, create_powershell_tool, create_read_tool,
53 create_write_tool, ExecutionToolContext, MutationQueueRegistry, OsExecutionEnv,
54};
55
56use crate::args::Args;
57use crate::docs_tool::create_docs_tool;
58use crate::extension_api::ExtensionBackend;
59use crate::provider::ResolvedModel;
60use crate::resource_dirs::{
61 discover_append_system_prompt_file_with_packages, discover_system_prompt_file_with_packages,
62 extension_dirs, global_extension_dirs, global_prompt_template_dirs, global_skill_dirs,
63 load_prompt_templates_with_precedence, load_skills_with_precedence,
64 project_prompt_template_dirs, project_skill_dirs, prompt_template_dirs, skill_dirs,
65};
66use rpi_extensions::{
67 emit_resources_discover, ExtensionEmitter, ExtensionSession, NullDiagnostics,
68 PluginDiagnostics, PluginToolAdapter, TeeEmitter,
69};
70
71pub const BUILTIN_TOOL_NAMES: &[&str] = &["read", "bash", "edit", "write", "docs"];
73
74pub(crate) fn should_load_js_packages(args: &Args) -> bool {
77 args.enable_pi_packages && !args.no_extensions
78}
79
80pub(crate) fn package_resources_for(
84 args: &Args,
85 cwd: &Path,
86 project_trusted: bool,
87) -> crate::packages::PackageResources {
88 if should_load_js_packages(args) {
89 if crate::args::offline_mode_enabled(args.offline) {
90 if project_trusted {
91 crate::packages::resolve_offline_from_settings(cwd)
92 } else {
93 crate::packages::resolve_offline_from_global_settings(cwd)
94 }
95 } else if project_trusted {
96 crate::packages::resolve_from_settings(cwd)
97 } else {
98 crate::packages::resolve_from_global_settings(cwd)
99 }
100 } else {
101 crate::packages::PackageResources::default()
102 }
103}
104
105pub(crate) fn package_resources_for_update_check(
110 args: &Args,
111 cwd: &Path,
112 project_trusted: bool,
113) -> crate::packages::PackageResources {
114 if args.dev_local_only {
115 crate::packages::PackageResources::default()
116 } else if project_trusted {
117 crate::packages::discover_from_settings(cwd)
118 } else {
119 crate::packages::discover_from_global_settings(cwd)
120 }
121}
122
123pub fn default_system_prompt(cwd: &str, prior_reasoning_replayed: bool) -> String {
126 let working_state_rule = if prior_reasoning_replayed {
132 "- Keep a SHORT working plan visible (a few bullets, or the todo tool) and update it as you go.
133 Your own reasoning is carried back to you on the next turn, so do not re-derive it or restate
134 the whole plan: continue from the last unfinished step, and when a step lands, say which one.
135 Re-listing the same plan without acting on it is a bug, not progress."
136 } else {
137 "- Keep your working state in your VISIBLE replies, not only in reasoning. Reasoning is not
138 carried into your next turn: only the text you write and the tool output you produce come
139 back. Before each batch of tool calls, write one short line naming the task you are on and
140 what remains. When you finish a step, say which one is done. If you keep the plan only in
141 your head you will re-derive it from scratch every turn."
142 };
143
144 format!(
145 "You are an expert coding assistant operating inside rpi, a coding agent harness. \
146You help users by reading files, executing commands, editing code, and writing new files.
147
148Available tools:
149- read — Read file contents
150- bash — Execute shell commands
151- edit — Find/replace edits to existing files
152- write — Create or overwrite files
153- docs — Look up rpi usage, extension, package, and compatibility documentation
154
155Guidelines:
156- Reply in the SAME language the user is writing in. Detect the language of the user's
157 latest message (and, when it is too short to be sure, the working language of the
158 conversation so far) and use it for your prose replies. Mirror it unless the user
159 explicitly asks for a different language; only fall back to English when the
160 user's language cannot be determined.
161- Be concise in your responses
162- Show file paths clearly when working with files
163- Prefer the smallest change that solves the problem
164{working_state_rule}
165- Track multi-step work with the todo tool instead of re-listing the plan in prose: add the
166 steps once, then mark them done as you go. Re-stating the same plan without acting on it is
167 a bug, not progress.
168- Read only what you need. If a file or search result was already shown earlier in this
169 conversation, use it instead of fetching it again.
170- When unsure about rpi commands, extensions, Pi package compatibility, or .rpi configuration, consult the project documentation before guessing
171
172Current working directory: {cwd}"
173 )
174}
175
176#[derive(Debug, Clone)]
183pub enum SessionSelection {
184 Ephemeral,
186 New { dir: PathBuf, name: Option<String> },
188 Latest,
190 ById { id: String },
193 ByExactId { id: String },
195 Fork { source: String },
198}
199
200pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
202 if args.no_session {
203 return SessionSelection::Ephemeral;
204 }
205 if args.continue_session || args.resume {
206 return SessionSelection::Latest;
208 }
209 if let Some(s) = &args.fork {
210 return SessionSelection::Fork { source: s.clone() };
211 }
212 if let Some(s) = &args.session_id {
213 return SessionSelection::ByExactId { id: s.clone() };
214 }
215 if let Some(s) = &args.session {
216 return SessionSelection::ById { id: s.clone() };
217 }
218 let dir = args
221 .session_dir
222 .clone()
223 .or_else(|| {
224 crate::settings::load_settings()
225 .ok()
226 .and_then(|settings| settings.session_dir)
227 })
228 .unwrap_or_else(|| default_session_dir(cwd));
229 SessionSelection::New {
230 dir,
231 name: args.name.clone(),
232 }
233}
234
235pub fn default_session_dir(cwd: &Path) -> PathBuf {
239 let preferred = cwd.join(".rpi").join("sessions");
240 let legacy = cwd.join(".pi").join("sessions");
241 if preferred.exists() || !legacy.exists() {
242 preferred
243 } else {
244 legacy
245 }
246}
247
248pub async fn build(
266 resolved: &ResolvedModel,
267 args: &Args,
268 cwd: &Path,
269 project_trusted: bool,
270) -> Result<
271 (
272 AgentHarness,
273 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
274 ReloadContext,
275 ),
276 BuildError,
277> {
278 let cwd_str = cwd.to_string_lossy().to_string();
279 let tool_context = rpi_extensions::ToolCallContext::new(cwd_str.clone());
284 if !project_trusted && args.verbose {
285 eprintln!(
286 "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)"
287 );
288 }
289 let package_resources = if args.dev_local_only {
293 crate::packages::PackageResources::default()
294 } else {
295 package_resources_for(args, cwd, project_trusted)
296 };
297
298 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
313 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
314 })?;
315 let catalog = crate::provider::available_catalog(resolved);
316 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
317 catalog.clone(),
318 cwd.to_path_buf(),
319 runtime.clone(),
320 args.unknown_flags.clone(),
321 );
322 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = Arc::new(action_host);
323 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
333 let ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
339 let ext_status_mailbox = rpi_extensions::ExtensionStatusMailbox::new();
343 let editor_text_mailbox = rpi_extensions::EditorTextMailbox::new();
348 let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
349 runtime.clone(),
350 host_arc,
351 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
352 ui_dialog_mailbox.clone(),
353 ext_status_mailbox.clone(),
354 editor_text_mailbox.clone(),
355 );
356
357 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
359 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
360 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
361 let _registry = Arc::new(MutationQueueRegistry::new());
362 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
365
366 let tools = build_tools(&ctx, args);
367 let mut tools = tools;
368
369 let extension_session = if args.no_extensions {
377 ExtensionSession::none()
378 } else {
379 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
380 };
381 let js_extension_session = if !should_load_js_packages(args) {
382 None
383 } else {
384 let paths = js_extension_paths(args, cwd, project_trusted, &package_resources);
385 let js_context = serde_json::json!({
386 "cwd": cwd_str,
387 "theme": resolved.theme.clone(),
388 "currentModel": resolved.model.clone(),
389 "models": catalog.clone(),
390 "thinkingLevel": resolved.thinking_level,
391 });
392 match crate::js_extensions::JsExtensionSession::load_with_context(
393 &paths,
394 args.verbose,
395 js_context,
396 ) {
397 Ok(session) => session,
398 Err(error) => {
399 eprintln!("warning: JS/TS extensions were not loaded: {error}");
400 None
401 }
402 }
403 };
404 if js_extension_session.is_some() {
405 eprintln!(
406 "warning: enabled Pi JS/TS extensions execute with the current user's permissions"
407 );
408 }
409 if let Some(session) = &js_extension_session {
410 if let Err(error) =
411 session.enable_provider_runtime(resolved.provider.clone(), runtime.clone())
412 {
413 if args.verbose {
414 eprintln!("warning: JS provider runtime was not enabled: {error}");
415 }
416 }
417 }
418 if args.verbose {
419 if let Some(session) = &js_extension_session {
420 let info = session.backend_info();
421 eprintln!(
422 "JS extension backend: {} v{} ({})",
423 info.name,
424 info.api_version,
425 info.capability_names().join(", ")
426 );
427 }
428 if let Some(s) = extension_session.summary() {
429 eprintln!("extensions: {s}");
430 }
431 report_deferred_renderers(&extension_session);
432 }
433 merge_extension_tools(&mut tools, &extension_session, args, &tool_context);
434 if let Some(session) = &js_extension_session {
435 merge_js_extension_tools(&mut tools, session, args);
436 if args.verbose && !session.commands.is_empty() {
437 eprintln!("JS extension commands: {}", session.commands.join(", "));
438 }
439 }
440 let mut active = active_tool_names(&tools, args);
441 if let Some(session) = &js_extension_session {
446 let js_names = session.tool_names();
447 if let Some(js_active) = session.active_tools() {
448 active.retain(|name| !js_names.iter().any(|js| js == name));
449 active.extend(js_active.into_iter().filter(|name| {
450 js_names.iter().any(|js| js == name) && tool_name_allowed(name, args)
451 }));
452 }
453 }
454 active = filter_active_tool_names(active, args);
455
456 let selection = select_session(args, cwd);
458 let session = build_session(&selection, &cwd_str).await?;
459 if let Ok(metadata) = session.get_metadata().await {
463 tool_context.set_session_id(metadata.id);
464 }
465 if let Some(requested) = args.name.as_deref() {
471 let name = requested.trim();
472 let name = if name.is_empty() { None } else { Some(name) };
473 if let Err(error) = session.set_name(name).await {
474 if args.verbose {
475 eprintln!("warning: could not set session name: {error}");
476 }
477 }
478 }
479 if let Some(js) = &js_extension_session {
480 let session_id = session
481 .get_metadata()
482 .await
483 .ok()
484 .map(|metadata| metadata.id);
485 let leaf_id = session.get_leaf_id().await.ok().flatten();
486 if let Some(session_id) = session_id {
487 let branch = session
488 .find_entries_on_branch(&EntryQuery::default(), &BranchBounds::default())
489 .await
490 .ok()
491 .unwrap_or_default();
492 let branch_json =
493 serde_json::to_value(&branch).unwrap_or_else(|_| serde_json::json!([]));
494 let runtime_context = serde_json::json!({
495 "session": {
496 "id": session_id,
497 "leafId": leaf_id,
498 "branch": branch_json,
499 "entries": branch_json.clone(),
500 },
501 });
502 if let Err(error) = js.set_runtime_context(runtime_context) {
503 if args.verbose {
504 eprintln!("warning: could not sync JS session context: {error}");
505 }
506 }
507 }
508 }
509
510 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
520 let base_prompt = match args.system_prompt.as_deref() {
521 Some(explicit) => explicit.to_string(),
522 None if project_trusted => {
523 match discover_system_prompt_file_with_packages(cwd, &package_resources) {
524 Some(path) => std::fs::read_to_string(&path)
525 .unwrap_or_else(|_| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
526 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
527 }
528 }
529 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
530 };
531
532 let mut append_texts: Vec<String> = Vec::new();
539 for extra in &args.append_system_prompt {
540 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
541 append_texts.push(text);
542 }
543 if args.append_system_prompt.is_empty() {
544 if let Some(path) = project_trusted
545 .then(|| discover_append_system_prompt_file_with_packages(cwd, &package_resources))
546 .flatten()
547 {
548 if let Ok(text) = std::fs::read_to_string(&path) {
549 append_texts.push(text);
550 }
551 }
552 }
553 let append_join = if append_texts.is_empty() {
554 None
555 } else {
556 Some(append_texts.join("\n\n"))
557 };
558
559 let agent_dir = crate::config::agent_dir().ok();
571
572 let discovered = extension_session
588 .snapshot_arc()
589 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
590 .unwrap_or_default();
591
592 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
593 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
594 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
595 if !args.no_skills {
596 let mut dirs = if args.dev_local_only {
597 project_skill_dirs(cwd)
598 } else if project_trusted {
599 skill_dirs(cwd)
600 } else {
601 global_skill_dirs()
602 };
603 dirs.extend(args.skill.iter().cloned());
604 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
605 if let Some(session) = &js_extension_session {
606 dirs.extend(session.resources.skill_paths.iter().cloned());
607 }
608 dirs.extend(package_resources.skill_dirs());
609 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
610 skills = result.skills;
611 skill_diags = result.diagnostics;
612 resource_diags.extend(result.resource_diagnostics);
613 }
614
615 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
616 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
617 if !args.no_prompt_templates {
618 let mut dirs = if args.dev_local_only {
619 project_prompt_template_dirs(cwd)
620 } else if project_trusted {
621 prompt_template_dirs(cwd)
622 } else {
623 global_prompt_template_dirs()
624 };
625 dirs.extend(args.prompt_template.iter().cloned());
626 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
627 if let Some(session) = &js_extension_session {
628 dirs.extend(session.resources.prompt_paths.iter().cloned());
629 }
630 dirs.extend(package_resources.prompt_dirs());
631 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
632 prompt_templates = result.prompt_templates;
633 prompt_diags = result.diagnostics;
634 resource_diags.extend(result.resource_diagnostics);
635 }
636
637 let context_block = if args.no_context_files || !project_trusted {
638 String::new()
639 } else {
640 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
645 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
646 format_project_context(&files)
647 };
648
649 if args.verbose {
651 for d in &package_resources.diagnostics {
652 eprintln!("warning: package {}: {}", d.spec, d.message);
653 }
654 for d in &skill_diags {
655 eprintln!(
656 "warning: skill {} ({}): {}",
657 d.path,
658 d.code.as_str(),
659 d.message
660 );
661 }
662 for d in &prompt_diags {
663 eprintln!(
664 "warning: prompt template {} ({}): {}",
665 d.path,
666 d.code.as_str(),
667 d.message
668 );
669 }
670 for d in &resource_diags {
672 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
673 eprintln!(
674 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
675 c.resource_type.as_str(),
676 c.name,
677 c.winner_path,
678 c.loser_path
679 );
680 }
681 }
682 }
683
684 let system_prompt = compose_system_prompt(
693 Some(&base_prompt),
694 &[], if context_block.is_empty() {
696 None
697 } else {
698 Some(&context_block)
699 },
700 append_join.as_deref(),
701 );
702
703 if args.debug_system_prompt {
712 eprintln!("=== --debug-system-prompt ===");
713 let base_src = if args.system_prompt.is_some() {
714 "--system-prompt"
715 } else if discover_system_prompt_file_with_packages(cwd, &package_resources).is_some() {
716 "SYSTEM.md"
717 } else {
718 "default"
719 };
720 eprintln!("[base source: {base_src}]");
721 eprintln!("--- base ---\n{base_prompt}");
722 if let Some(append) = append_join.as_deref() {
723 eprintln!("--- append ---\n{append}");
724 } else {
725 eprintln!("--- append: (none) ---");
726 }
727 if context_block.is_empty() {
728 eprintln!("--- context: (none) ---");
729 } else {
730 eprintln!("--- context ---{context_block}");
731 }
732 let visible_skills = skills
733 .iter()
734 .filter(|s| s.disable_model_invocation != Some(true))
735 .count();
736 eprintln!(
737 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
738 skills.len(),
739 visible_skills,
740 skills.len() - visible_skills
741 );
742 for s in &skills {
743 let hidden = if s.disable_model_invocation == Some(true) {
744 " [hidden]"
745 } else {
746 ""
747 };
748 eprintln!(" {}{hidden} — {}", s.name, s.description);
749 }
750 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
751 for t in &prompt_templates {
752 eprintln!(" /{}", t.name);
753 }
754 eprintln!(
758 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
759 discovered.skill_paths.len(),
760 discovered.prompt_paths.len(),
761 discovered.theme_paths.len(),
762 );
763 for p in &discovered.skill_paths {
764 eprintln!(" skill: {p}");
765 }
766 for p in &discovered.prompt_paths {
767 eprintln!(" prompt: {p}");
768 }
769 eprintln!(
770 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
771 );
772 eprintln!("=== end --debug-system-prompt ===");
773 }
774
775 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
780 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
781 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
785
786 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
795 Some(snapshot) => {
796 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
797 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
798 }
799 None => broadcast_emitter,
800 };
801
802 let saved_settings = crate::settings::load_settings().unwrap_or_default();
806
807 if let Some(proxy) = &saved_settings.http_proxy {
812 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
813 if std::env::var_os(key).is_none() {
814 std::env::set_var(key, proxy);
815 }
816 }
817 }
818
819 let options = AgentHarnessOptions {
820 model: resolved.model.clone(),
821 thinking_level: resolved.thinking_level,
822 active_tool_names: active,
823 tools,
824 system_prompt: Some(system_prompt),
825 resources: AgentHarnessResources {
826 skills: if skills.is_empty() {
827 None
828 } else {
829 Some(skills)
830 },
831 prompt_templates: if prompt_templates.is_empty() {
832 None
833 } else {
834 Some(prompt_templates)
835 },
836 },
837 allow_existing_session: matches!(
840 selection,
841 SessionSelection::Latest
842 | SessionSelection::ById { .. }
843 | SessionSelection::ByExactId { .. }
844 | SessionSelection::Fork { .. }
845 ),
846 stream_options: AgentHarnessStreamOptions {
847 timeout: args.timeout,
848 ..Default::default()
849 },
850 retry: saved_settings.retry_policy(),
855 compaction: saved_settings.compaction_settings(),
856 steering_mode: saved_settings.steering_mode(),
857 follow_up_mode: saved_settings.follow_up_mode(),
858 tool_execution: HarnessToolExecution::default(),
859 drive: DrivingMode::default(),
860 session,
861 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
869 to_provider_messages: None,
870 entry_projectors: Default::default(),
871 agent_emitter: Some(emitter),
872 before_tool_call: None,
878 after_tool_call: None,
879 transform_context: None,
880 entry_transforms: Vec::new(),
881 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
887 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
888 };
889
890 let harness = match AgentHarness::create(options).await {
891 Ok(h) => {
892 crate::extensions_actions::HarnessActionHost::set_harness(
895 &harness_cell,
896 Arc::new(h.clone()),
897 );
898 h
899 }
900 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
901 };
902
903 {
911 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
912 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
913 Ok(reports) => {
914 for (lane, report) in reports {
915 if report.is_clean() {
916 continue;
917 }
918 if report.is_corrupt() {
919 eprintln!(
920 "warning: session lane '{lane}' is corrupt ({} open operations); \
921 refusing to auto-repair — inspect the session file",
922 report.open_operations.len()
923 );
924 continue;
925 }
926 for decision in runtime.reconcile(&report) {
927 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
928 if args.verbose {
929 eprintln!(
930 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
931 );
932 }
933 }
934 }
935 runtime.emit_recovery_events(harness.events(), &report);
936 }
937 }
938 Err(e) => {
939 if args.verbose {
940 eprintln!("warning: session recovery sweep failed: {e}");
941 }
942 }
943 }
944 }
945
946 let reload_context = ReloadContext {
953 extension_session: Arc::new(Mutex::new(extension_session)),
954 js_extension_session: js_extension_session.clone(),
955 package_resources: Arc::new(package_resources.clone()),
956 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
957 tool_context,
958 catalog,
959 gateway: resolved.provider.clone(),
960 runtime: runtime.clone(),
961 cwd: cwd.to_path_buf(),
962 project_trusted,
963 args: args.clone(),
964 resolved_model: resolved.model.clone(),
965 broadcast: broadcast_for_context,
966 mailbox: reload_mailbox,
967 ui_dialog: ui_dialog_mailbox,
968 ext_status: ext_status_mailbox,
969 editor_text: editor_text_mailbox,
970 dev_extension: None,
971 };
972
973 Ok((harness, event_rx, reload_context))
974}
975
976pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
1021
1022pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
1027
1028#[derive(Clone)]
1036pub struct ReloadContext {
1037 pub extension_session: ExtensionSessionCell,
1039 pub tool_context: rpi_extensions::ToolCallContext,
1044 pub js_extension_session: Option<crate::js_extensions::JsExtensionSession>,
1046 pub package_resources: Arc<crate::packages::PackageResources>,
1050 pub action_bridge: ActionBridgeCell,
1052 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
1055 pub editor_text: rpi_extensions::EditorTextMailbox,
1062 pub catalog: Vec<rpi_ai::Model>,
1067 pub gateway: Arc<dyn Provider>,
1070 pub runtime: tokio::runtime::Handle,
1074 pub cwd: PathBuf,
1076 pub project_trusted: bool,
1080 pub args: Args,
1086 pub resolved_model: rpi_ai::Model,
1089 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
1095 pub mailbox: rpi_extensions::ReloadMailbox,
1102 pub ui_dialog: rpi_extensions::UiDialogMailbox,
1108 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
1112}
1113
1114pub async fn dispatch_session_event_async(
1129 reload_context: &ReloadContext,
1130 tag: rpi_plugin_sdk::EventTag,
1131) -> Option<String> {
1132 let (snapshot, keepalive) = {
1138 let session = reload_context.extension_session.lock().unwrap();
1139 (session.snapshot_arc(), session.keepalive())
1140 };
1141 match snapshot {
1142 Some(snapshot) => {
1143 let _keepalive = keepalive;
1144 rpi_extensions::dispatch_lifecycle_event(&snapshot, tag).await
1145 }
1146 None => None,
1147 }
1148}
1149
1150pub struct ReloadOutcome {
1153 pub summary: String,
1156 pub had_warnings: bool,
1158 pub details: Vec<String>,
1163 pub had_errors: bool,
1167}
1168
1169struct PreparedReloadInputs {
1170 package_resources: crate::packages::PackageResources,
1171 extension_dirs: Vec<PathBuf>,
1172 skill_base_dirs: Vec<PathBuf>,
1173 prompt_base_dirs: Vec<PathBuf>,
1174}
1175
1176fn append_reload_resource_paths(
1181 mut paths: Vec<PathBuf>,
1182 explicit: &[PathBuf],
1183 discovered: &[String],
1184 js_paths: &[PathBuf],
1185 package_paths: &[PathBuf],
1186) -> Vec<PathBuf> {
1187 paths.extend(explicit.iter().cloned());
1188 paths.extend(discovered.iter().map(PathBuf::from));
1189 paths.extend(js_paths.iter().cloned());
1190 paths.extend(package_paths.iter().cloned());
1191 paths
1192}
1193
1194pub async fn reload_extension_resources(
1206 harness: &AgentHarness,
1207 ctx: &ReloadContext,
1208) -> ReloadOutcome {
1209 reload_extension_resources_inner(harness, ctx, || {}).await
1210}
1211
1212async fn reload_extension_resources_inner<F>(
1213 harness: &AgentHarness,
1214 ctx: &ReloadContext,
1215 after_prepare: F,
1216) -> ReloadOutcome
1217where
1218 F: FnOnce() + Send,
1219{
1220 let mut effective_args = ctx.args.clone();
1225 if let Some(dev) = &ctx.dev_extension {
1226 if let Err(error) = dev
1227 .rebuild()
1228 .and_then(|_| dev.apply_to_args(&mut effective_args))
1229 {
1230 return ReloadOutcome {
1231 summary: format!(
1232 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1233 dev.package_name()
1234 ),
1235 had_warnings: true,
1236 details: Vec::new(),
1237 had_errors: true,
1238 };
1239 }
1240 }
1241 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1242 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1243 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1244 Ok(prepared) => prepared,
1245 Err(error) => {
1246 return ReloadOutcome {
1247 summary: format!(
1248 "Settings reload failed: {error}. Keeping the currently loaded resources."
1249 ),
1250 had_warnings: true,
1251 details: Vec::new(),
1252 had_errors: true,
1253 };
1254 }
1255 };
1256 after_prepare();
1257 let PreparedReloadInputs {
1258 package_resources,
1259 extension_dirs,
1260 skill_base_dirs,
1261 prompt_base_dirs,
1262 } = prepared;
1263 let mut warnings = false;
1264
1265 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1272 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1273 Some(b) => b.clone_host(),
1274 None => {
1275 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1278 ctx.catalog.clone(),
1279 ctx.cwd.clone(),
1280 ctx.runtime.clone(),
1281 ctx.args.unknown_flags.clone(),
1282 );
1283 crate::extensions_actions::HarnessActionHost::set_harness(
1284 &_cell,
1285 Arc::new(harness.clone()),
1286 );
1287 Arc::new(action_host)
1288 }
1289 };
1290
1291 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1292 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1293 ctx.runtime.clone(),
1294 host,
1295 reload_cb,
1296 ctx.ui_dialog.clone(),
1297 ctx.ext_status.clone(),
1298 ctx.editor_text.clone(),
1299 );
1300
1301 let extension_session = if effective_args.no_extensions {
1302 rpi_extensions::ExtensionSession::none()
1303 } else {
1304 load_extensions_from_dirs(
1305 &effective_args,
1306 &extension_dirs,
1307 Some(Arc::clone(&fresh_bridge)),
1308 )
1309 };
1310 if extension_session.is_empty() && !effective_args.no_extensions {
1311 }
1314 if effective_args.verbose {
1315 if let Some(s) = extension_session.summary() {
1316 eprintln!("reload: {s}");
1317 }
1318 report_deferred_renderers(&extension_session);
1319 }
1320
1321 {
1335 let mut session_guard = ctx.extension_session.lock().unwrap();
1336 let old_session = std::mem::replace(
1337 &mut *session_guard,
1338 rpi_extensions::ExtensionSession::none(),
1339 );
1340 if let Some(old_snap) = old_session.snapshot_arc() {
1341 old_snap.active_flag().store(false, Ordering::SeqCst);
1345 }
1346 }
1350 if let Some(old_b) = old_bridge {
1351 old_b.invalidate();
1352 }
1353
1354 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1357 *ctx.extension_session.lock().unwrap() = extension_session.clone();
1358
1359 let discovered = extension_session
1361 .snapshot_arc()
1362 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1363 .unwrap_or_default();
1364
1365 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1367 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1368
1369 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1370 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1371 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1372 if !effective_args.no_skills {
1373 let js_paths: &[PathBuf] = ctx
1377 .js_extension_session
1378 .as_ref()
1379 .map(|js| js.resources.skill_paths.as_slice())
1380 .unwrap_or(&[]);
1381 let package_paths = package_resources.skill_dirs();
1382 let dirs = append_reload_resource_paths(
1383 skill_base_dirs,
1384 &effective_args.skill,
1385 &discovered.skill_paths,
1386 js_paths,
1387 &package_paths,
1388 );
1389 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1390 skills = result.skills;
1391 skill_diags = result.diagnostics;
1392 resource_diags.extend(result.resource_diagnostics);
1393 }
1394
1395 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1396 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1397 if !effective_args.no_prompt_templates {
1398 let js_paths: &[PathBuf] = ctx
1399 .js_extension_session
1400 .as_ref()
1401 .map(|js| js.resources.prompt_paths.as_slice())
1402 .unwrap_or(&[]);
1403 let package_paths = package_resources.prompt_dirs();
1404 let dirs = append_reload_resource_paths(
1405 prompt_base_dirs,
1406 &effective_args.prompt_template,
1407 &discovered.prompt_paths,
1408 js_paths,
1409 &package_paths,
1410 );
1411 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1412 prompt_templates = result.prompt_templates;
1413 prompt_diags = result.diagnostics;
1414 resource_diags.extend(result.resource_diagnostics);
1415 }
1416
1417 let context_block = if effective_args.no_context_files {
1418 String::new()
1419 } else {
1420 let agent_dir = crate::config::agent_dir().ok();
1421 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1422 let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
1423 format_project_context(&files)
1424 };
1425
1426 let mut details: Vec<String> = Vec::new();
1427 if !skill_diags.is_empty()
1428 || !prompt_diags.is_empty()
1429 || !package_resources.diagnostics.is_empty()
1430 || !resource_diags.is_empty()
1431 {
1432 warnings = true;
1433 for d in &package_resources.diagnostics {
1438 details.push(format!("warning: package {}: {}", d.spec, d.message));
1439 }
1440 for d in &skill_diags {
1441 details.push(format!(
1442 "warning: skill {} ({}): {}",
1443 d.path,
1444 d.code.as_str(),
1445 d.message
1446 ));
1447 }
1448 for d in &prompt_diags {
1449 details.push(format!(
1450 "warning: prompt template {} ({}): {}",
1451 d.path,
1452 d.code.as_str(),
1453 d.message
1454 ));
1455 }
1456 for d in &resource_diags {
1458 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1459 details.push(format!(
1460 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1461 c.resource_type.as_str(),
1462 c.name,
1463 c.winner_path,
1464 c.loser_path
1465 ));
1466 }
1467 }
1468 }
1469
1470 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1475 let base_prompt = match effective_args.system_prompt.as_deref() {
1476 Some(explicit) => explicit.to_string(),
1477 None => match discover_system_prompt_file_with_packages(&ctx.cwd, &package_resources) {
1478 Some(path) => std::fs::read_to_string(&path)
1479 .unwrap_or_else(|_| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
1480 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1481 },
1482 };
1483 let mut append_texts: Vec<String> = Vec::new();
1484 for extra in &effective_args.append_system_prompt {
1485 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
1486 append_texts.push(text);
1487 }
1488 if effective_args.append_system_prompt.is_empty() {
1489 if let Some(path) =
1490 discover_append_system_prompt_file_with_packages(&ctx.cwd, &package_resources)
1491 {
1492 if let Ok(text) = std::fs::read_to_string(&path) {
1493 append_texts.push(text);
1494 }
1495 }
1496 }
1497 let append_join = if append_texts.is_empty() {
1498 None
1499 } else {
1500 Some(append_texts.join("\n\n"))
1501 };
1502 let system_prompt = compose_system_prompt(
1503 Some(&base_prompt),
1504 &[],
1505 if context_block.is_empty() {
1506 None
1507 } else {
1508 Some(&context_block)
1509 },
1510 append_join.as_deref(),
1511 );
1512
1513 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1515 Some(snapshot) => {
1516 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1517 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1518 }
1519 None => ctx.broadcast.clone(),
1520 };
1521
1522 let resources = AgentHarnessResources {
1524 skills: if skills.is_empty() {
1525 None
1526 } else {
1527 Some(skills.clone())
1528 },
1529 prompt_templates: if prompt_templates.is_empty() {
1530 None
1531 } else {
1532 Some(prompt_templates.clone())
1533 },
1534 };
1535 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1536 let _ = harness.set_resources(resources).await;
1537 let _ = harness.set_agent_emitter(Some(emitter)).await;
1538 let _ = harness
1539 .set_models(build_models_with_extensions_for_reload(
1540 &ctx.gateway,
1541 &extension_session,
1542 ctx.runtime.clone(),
1543 ))
1544 .await;
1545 let _ = harness
1546 .set_provider_hooks(
1547 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1548 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1549 )
1550 .await;
1551
1552 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1556 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1557 let mut tools = build_tools(&tool_ctx, &effective_args);
1558 merge_extension_tools(
1559 &mut tools,
1560 &extension_session,
1561 &effective_args,
1562 &ctx.tool_context,
1563 );
1564 if let Some(js) = &ctx.js_extension_session {
1565 merge_js_extension_tools(&mut tools, js, &effective_args);
1566 }
1567 let mut active = active_tool_names(&tools, &effective_args);
1568 if let Some(js) = &ctx.js_extension_session {
1569 let js_names = js.tool_names();
1570 if let Some(js_active) = js.active_tools() {
1571 active.retain(|name| {
1572 tool_name_allowed(name, &effective_args)
1573 && !js_names.iter().any(|js_name| js_name == name)
1574 });
1575 active.extend(js_active.into_iter().filter(|name| {
1576 js_names.iter().any(|js_name| js_name == name)
1577 && tool_name_allowed(name, &effective_args)
1578 }));
1579 }
1580 }
1581 active = filter_active_tool_names(active, &effective_args);
1582 let _ = harness.set_tools(tools, Some(active)).await;
1583
1584 let summary = format!(
1585 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1586 extension_session.loaded_paths().len(),
1587 skills.len(),
1588 prompt_templates.len(),
1589 );
1590 ReloadOutcome {
1591 summary,
1592 had_warnings: warnings,
1593 details,
1594 had_errors: false,
1597 }
1598}
1599
1600#[derive(Clone, Debug, PartialEq, Eq)]
1601struct ReloadSettingsFields {
1602 packages: Option<Vec<crate::settings::PackageSetting>>,
1603 npm_command: Option<Vec<String>>,
1604 skill_dirs: Option<Vec<String>>,
1605 prompt_dirs: Option<Vec<String>>,
1606 extension_dirs: Option<Vec<String>>,
1607}
1608
1609impl From<crate::settings::Settings> for ReloadSettingsFields {
1610 fn from(settings: crate::settings::Settings) -> Self {
1611 Self {
1612 packages: settings.packages,
1613 npm_command: settings.npm_command,
1614 skill_dirs: settings.skill_dirs,
1615 prompt_dirs: settings.prompt_dirs,
1616 extension_dirs: settings.extension_dirs,
1617 }
1618 }
1619}
1620
1621#[derive(Clone, Debug, PartialEq, Eq)]
1622struct ReloadSettingsSnapshot {
1623 global: ReloadSettingsFields,
1624 project: Option<ReloadSettingsFields>,
1625}
1626
1627fn reload_reads_settings(args: &Args) -> bool {
1628 !args.dev_local_only
1629 && (should_load_js_packages(args)
1630 || !args.no_extensions
1631 || !args.no_skills
1632 || !args.no_prompt_templates)
1633}
1634
1635fn load_reload_settings_snapshot(
1639 args: &Args,
1640 cwd: &Path,
1641 project_trusted: bool,
1642) -> Result<Option<ReloadSettingsSnapshot>, String> {
1643 if !reload_reads_settings(args) {
1644 return Ok(None);
1645 }
1646 let global = crate::settings::load_settings()
1647 .map_err(|error| format!("could not load global settings: {error}"))?
1648 .into();
1649 let project = if project_trusted {
1650 crate::settings::load_active_project_settings(cwd)
1651 .map_err(|error| format!("could not load project settings: {error}"))?
1652 .map(|(_, settings)| settings.into())
1653 } else {
1654 None
1655 };
1656 Ok(Some(ReloadSettingsSnapshot { global, project }))
1657}
1658
1659#[cfg(test)]
1662fn validate_settings_for_reload(
1663 args: &Args,
1664 cwd: &Path,
1665 project_trusted: bool,
1666) -> Result<(), String> {
1667 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1668}
1669
1670fn prepare_reload_inputs(
1671 args: &Args,
1672 cwd: &Path,
1673 project_trusted: bool,
1674) -> Result<PreparedReloadInputs, String> {
1675 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1676}
1677
1678fn prepare_reload_inputs_inner<F>(
1679 args: &Args,
1680 cwd: &Path,
1681 project_trusted: bool,
1682 before_verify: F,
1683) -> Result<PreparedReloadInputs, String>
1684where
1685 F: FnOnce(),
1686{
1687 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1688
1689 let package_resources = if args.dev_local_only {
1690 crate::packages::PackageResources::default()
1691 } else {
1692 package_resources_for(args, cwd, project_trusted)
1693 };
1694
1695 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1696 Vec::new()
1697 } else if project_trusted {
1698 extension_dirs(cwd)
1699 } else {
1700 global_extension_dirs()
1701 };
1702 if !args.no_extensions {
1703 extension_dirs.extend(args.extensions_dir.iter().cloned());
1704 }
1705
1706 let skill_base_dirs = if args.no_skills {
1707 Vec::new()
1708 } else if args.dev_local_only {
1709 project_skill_dirs(cwd)
1710 } else if project_trusted {
1711 skill_dirs(cwd)
1712 } else {
1713 global_skill_dirs()
1714 };
1715
1716 let prompt_base_dirs = if args.no_prompt_templates {
1717 Vec::new()
1718 } else if args.dev_local_only {
1719 project_prompt_template_dirs(cwd)
1720 } else if project_trusted {
1721 prompt_template_dirs(cwd)
1722 } else {
1723 global_prompt_template_dirs()
1724 };
1725
1726 before_verify();
1727 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1728 if settings_before != settings_after {
1729 return Err(
1730 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1731 );
1732 }
1733
1734 Ok(PreparedReloadInputs {
1735 package_resources,
1736 extension_dirs,
1737 skill_base_dirs,
1738 prompt_base_dirs,
1739 })
1740}
1741
1742fn build_models_with_extensions_for_reload(
1747 gateway: &Arc<dyn Provider>,
1748 extension_session: &ExtensionSession,
1749 runtime: tokio::runtime::Handle,
1750) -> Vec<Arc<dyn Provider>> {
1751 let mut models: Vec<Arc<dyn Provider>> = vec![gateway.clone()];
1752 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1753 models.extend(pluggable);
1754 models
1755}
1756
1757fn report_deferred_renderers(session: &ExtensionSession) {
1761 let Some(snap) = session.snapshot_arc() else {
1762 return;
1763 };
1764 let all = snap.renderers();
1765 let markdown = all
1766 .iter()
1767 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1768 .count();
1769 let message = all
1770 .iter()
1771 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1772 .count();
1773 let entry = all
1774 .iter()
1775 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1776 .count();
1777 if markdown + message + entry == 0 {
1778 return;
1779 }
1780 eprintln!(
1781 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1782 markdown, message, entry
1783 );
1784}
1785
1786#[derive(Debug, thiserror::Error)]
1788pub enum BuildError {
1789 #[error("Could not create the session directory: {0}")]
1790 SessionDir(String),
1791 #[error("Could not open the saved session: {0}")]
1792 SessionOpen(String),
1793 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1794 SessionNotFound { requested: String, dir: String },
1795 #[error("Could not build the harness: {0}")]
1796 HarnessCreate(String),
1797}
1798
1799fn build_models_with_extensions(
1809 resolved: &ResolvedModel,
1810 extension_session: &ExtensionSession,
1811 runtime: tokio::runtime::Handle,
1812) -> Vec<Arc<dyn Provider>> {
1813 let mut models: Vec<Arc<dyn Provider>> = vec![resolved.provider.clone() as Arc<dyn Provider>];
1814 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1815 models.extend(pluggable);
1816 models
1817}
1818
1819pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1822 if let Some(override_value) = args.trust_override {
1823 return override_value;
1824 }
1825 crate::config::project_trust_decision(cwd)
1826 .ok()
1827 .flatten()
1828 .unwrap_or(true)
1829}
1830
1831fn load_extensions(
1839 args: &Args,
1840 cwd: &Path,
1841 project_trusted: bool,
1842 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1843) -> ExtensionSession {
1844 let mut dirs = if args.dev_local_only {
1845 Vec::new()
1846 } else if project_trusted {
1847 extension_dirs(cwd)
1848 } else {
1849 global_extension_dirs()
1850 };
1851 dirs.extend(args.extensions_dir.iter().cloned());
1852 load_extensions_from_dirs(args, &dirs, action_bridge)
1853}
1854
1855fn load_extensions_from_dirs(
1856 args: &Args,
1857 dirs: &[PathBuf],
1858 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1859) -> ExtensionSession {
1860 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1861 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1868}
1869
1870fn js_extension_paths(
1871 args: &Args,
1872 cwd: &Path,
1873 project_trusted: bool,
1874 packages: &crate::packages::PackageResources,
1875) -> Vec<PathBuf> {
1876 if args.dev_local_only {
1877 return Vec::new();
1878 }
1879 let mut paths = packages.extension_paths();
1880 let discovered_dirs = if project_trusted {
1881 extension_dirs(cwd)
1882 } else {
1883 global_extension_dirs()
1884 };
1885 for dir in discovered_dirs {
1886 if let Ok(entries) = std::fs::read_dir(dir) {
1887 paths.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
1888 matches!(
1889 path.extension()
1890 .and_then(|ext| ext.to_str())
1891 .map(|ext| ext.to_ascii_lowercase())
1892 .as_deref(),
1893 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1894 )
1895 }));
1896 }
1897 }
1898 paths.extend(
1899 args.extension
1900 .iter()
1901 .filter(|path| {
1902 matches!(
1903 path.extension()
1904 .and_then(|ext| ext.to_str())
1905 .map(|ext| ext.to_ascii_lowercase())
1906 .as_deref(),
1907 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1908 )
1909 })
1910 .cloned(),
1911 );
1912 paths
1913}
1914
1915fn merge_extension_tools(
1921 tools: &mut Vec<HarnessTool>,
1922 session: &ExtensionSession,
1923 args: &Args,
1924 tool_context: &rpi_extensions::ToolCallContext,
1925) {
1926 let Some(snapshot) = session.snapshot() else {
1927 return;
1928 };
1929 for et in snapshot.tools() {
1930 let name = &et.tool.name;
1931 if !tool_name_allowed(name, args) {
1932 continue;
1933 }
1934 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
1935 .with_context(tool_context.clone());
1936 let harness_tool = HarnessTool::new(Arc::new(adapter));
1937 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1938 Some(slot) => *slot = harness_tool,
1939 None => tools.push(harness_tool),
1940 }
1941 }
1942}
1943
1944fn merge_js_extension_tools(
1945 tools: &mut Vec<HarnessTool>,
1946 session: &crate::js_extensions::JsExtensionSession,
1947 args: &Args,
1948) {
1949 for adapter in session.tools() {
1950 let name = adapter.schema().name.clone();
1951 if !tool_name_allowed(&name, args) {
1952 continue;
1953 }
1954 let harness_tool = HarnessTool::new(Arc::new(adapter));
1955 match tools
1956 .iter_mut()
1957 .find(|tool| tool.tool.schema().name == name)
1958 {
1959 Some(slot) => *slot = harness_tool,
1960 None => tools.push(harness_tool),
1961 }
1962 }
1963}
1964
1965pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1972 use rpi_tools::tools::bash::BashToolOptions;
1973 let default = std::env::var("RPI_BASH_TIMEOUT")
1974 .ok()
1975 .and_then(|v| v.parse::<f64>().ok())
1976 .unwrap_or(120.0);
1977 let settings = crate::settings::load_settings().unwrap_or_default();
1983 BashToolOptions {
1984 command_prefix: settings.shell_command_prefix.clone(),
1985 default_timeout: Some(default),
1986 }
1987}
1988
1989fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1990 if args.no_tools {
1991 return Vec::new();
1992 }
1993 let mut all: Vec<(&'static str, HarnessTool)> = vec![
2002 (
2003 "read",
2004 HarnessTool::new(create_read_tool(ctx, None)).with_replay(ToolReplay::Safe),
2005 ),
2006 (
2007 "bash",
2008 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
2009 ),
2010 ("edit", HarnessTool::new(create_edit_tool(ctx))),
2011 ("write", HarnessTool::new(create_write_tool(ctx))),
2012 (
2013 "docs",
2014 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
2015 ),
2016 ];
2017
2018 #[cfg(target_os = "windows")]
2020 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
2021
2022 if args.tools.is_none() {
2024 if let Ok(settings) = crate::settings::load_settings() {
2025 if let Some(default_tools) = &settings.default_tools {
2026 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
2027 }
2028 }
2029 }
2030
2031 if args.no_builtin_tools {
2035 all.clear();
2036 }
2037
2038 if let Some(allow) = &args.tools {
2040 all.retain(|(name, _)| allow.iter().any(|a| a == name));
2041 }
2042 if let Some(deny) = &args.exclude_tools {
2044 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
2045 }
2046
2047 all.into_iter().map(|(_, t)| t).collect()
2048}
2049
2050fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
2054 filter_active_tool_names(
2055 tools.iter().map(|tool| tool.tool.schema().name.clone()),
2056 args,
2057 )
2058}
2059
2060pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
2064 if args.no_tools {
2065 return false;
2066 }
2067 if args
2068 .tools
2069 .as_ref()
2070 .is_some_and(|allow| !allow.iter().any(|value| value == name))
2071 {
2072 return false;
2073 }
2074 if args
2075 .exclude_tools
2076 .as_ref()
2077 .is_some_and(|deny| deny.iter().any(|value| value == name))
2078 {
2079 return false;
2080 }
2081 true
2082}
2083
2084pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
2085where
2086 I: IntoIterator<Item = String>,
2087{
2088 names
2089 .into_iter()
2090 .filter(|name| tool_name_allowed(name, args))
2091 .collect()
2092}
2093
2094async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2096 match selection {
2097 SessionSelection::Ephemeral => Ok(ephemeral_session()),
2098 SessionSelection::New { dir, .. } => {
2099 std::fs::create_dir_all(dir)
2102 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2103 let session = create_jsonl_session(dir, cwd)
2104 .await
2105 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2106 Ok(session)
2107 }
2108 SessionSelection::Latest
2109 | SessionSelection::ById { .. }
2110 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2111 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
2112 }
2113}
2114
2115async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2121 match selection {
2125 SessionSelection::Latest => {
2126 let metas = list_session_metadata(cwd).await?;
2127 let Some(meta) = first_resumable_session(&metas) else {
2128 return Err(BuildError::SessionNotFound {
2129 requested: "the most recent session".to_string(),
2130 dir: default_session_dir(Path::new(cwd)).display().to_string(),
2131 });
2132 };
2133 open_session(meta, cwd).await
2134 }
2135 SessionSelection::ById { id } => open_session_by_id(id, cwd).await.map_err(|e| match e {
2136 OpenError::NotFound { requested } => BuildError::SessionNotFound {
2137 requested,
2138 dir: default_session_dir(Path::new(cwd)).display().to_string(),
2139 },
2140 OpenError::Other(msg) => BuildError::SessionOpen(msg),
2141 }),
2142 SessionSelection::ByExactId { id } => {
2143 let metas = list_session_metadata(cwd).await?;
2146 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
2147 return open_session(meta, cwd).await;
2148 }
2149 let dir = default_session_dir(Path::new(cwd));
2150 std::fs::create_dir_all(&dir)
2151 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2152 create_jsonl_session_with_id(&dir, cwd, Some(id.clone()))
2153 .await
2154 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2155 }
2156 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
2157 }
2158}
2159
2160async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
2163 use rpi_harness::session::jsonl::{
2164 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2165 };
2166 use rpi_harness::session::types::{ForkOptions, SessionStorage};
2167 use rpi_tools::FileSystem;
2168
2169 let dir = default_session_dir(Path::new(cwd));
2170 std::fs::create_dir_all(&dir)
2171 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2172 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2173 let fs: Arc<dyn FileSystem> = env.clone();
2174 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2175 fs: fs.clone(),
2176 sessions_root: dir.to_string_lossy().into_owned(),
2177 clock: Arc::new(SystemClock),
2178 ids: Arc::new(DefaultIdGenerator::new()),
2179 });
2180 let metas = repo
2181 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2182 .await
2183 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2184 let source_meta = metas
2185 .iter()
2186 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2187 .ok_or_else(|| BuildError::SessionNotFound {
2188 requested: format!("--fork {source}"),
2189 dir: dir.display().to_string(),
2190 })?;
2191 let fork_storage = repo
2192 .fork_typed(
2193 source_meta,
2194 &JsonlSessionCreateOptions {
2195 id: None,
2196 parent_session_id: Some(source_meta.id.clone()),
2197 cwd: cwd.to_string(),
2198 metadata: None,
2199 },
2200 &ForkOptions::default(),
2201 )
2202 .await
2203 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2204 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
2205 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2206 Ok(Session::new(storage_arc, None))
2207}
2208
2209pub enum OpenError {
2212 NotFound { requested: String },
2214 Other(String),
2216}
2217
2218impl std::fmt::Display for OpenError {
2219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2220 match self {
2221 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2222 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
2227 }
2228 }
2229}
2230
2231pub async fn list_session_metadata(
2234 cwd: &str,
2235) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2236 use rpi_harness::session::jsonl::{
2237 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2238 };
2239 use rpi_tools::FileSystem;
2240
2241 let dir = default_session_dir(Path::new(cwd));
2242 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2243 let fs: Arc<dyn FileSystem> = env.clone();
2244 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2245 fs: fs.clone(),
2246 sessions_root: dir.to_string_lossy().into_owned(),
2247 clock: Arc::new(SystemClock),
2248 ids: Arc::new(DefaultIdGenerator::new()),
2249 });
2250 repo.list_typed(&JsonlSessionListOptions::default())
2251 .await
2252 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2253}
2254
2255#[derive(Debug, Clone, Default, PartialEq, Eq)]
2271pub struct SessionSummary {
2272 pub name: Option<String>,
2274 pub preview: Option<String>,
2276 pub empty: bool,
2278 pub bytes: u64,
2280}
2281
2282const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
2284const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
2286const SUMMARY_PREVIEW_CHARS: usize = 80;
2288
2289pub fn summarize_session_file(path: &Path) -> SessionSummary {
2293 use std::io::{Read, Seek, SeekFrom};
2294
2295 let Ok(mut file) = std::fs::File::open(path) else {
2296 return SessionSummary::default();
2297 };
2298 let Ok(size) = file.metadata().map(|m| m.len()) else {
2299 return SessionSummary::default();
2300 };
2301 if size == 0 {
2302 return SessionSummary {
2303 name: None,
2304 preview: None,
2305 empty: true,
2306 bytes: 0,
2307 };
2308 }
2309
2310 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2313 let head_len = file.read(&mut head).unwrap_or(0);
2314 head.truncate(head_len);
2315 let head = String::from_utf8_lossy(&head);
2316 let mut lines: Vec<&str> = head.lines().collect();
2317 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2318 let empty = (size as usize) <= header_len + 1;
2320 if (head_len as u64) < size && !lines.is_empty() {
2323 lines.pop();
2324 }
2325
2326 let mut summary = SessionSummary {
2327 name: None,
2328 preview: None,
2329 empty,
2330 bytes: size,
2331 };
2332 for line in lines {
2333 apply_summary_line(line, &mut summary);
2334 }
2335
2336 if size > SUMMARY_HEAD_BYTES
2340 && file
2341 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2342 .is_ok()
2343 {
2344 let mut tail = Vec::new();
2345 if file.read_to_end(&mut tail).is_ok() {
2346 let tail = String::from_utf8_lossy(&tail);
2347 let mut tail_lines = tail.lines();
2348 tail_lines.next();
2350 for line in tail_lines {
2351 if let Some(name) = name_from_line(line) {
2352 summary.name = name;
2353 }
2354 }
2355 }
2356 }
2357 summary
2358}
2359
2360fn name_from_line(line: &str) -> Option<Option<String>> {
2363 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2364 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2365 return None;
2366 }
2367 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2368 return None;
2369 }
2370 Some(match value.get("name") {
2371 Some(serde_json::Value::String(name)) => Some(name.clone()),
2372 _ => None,
2373 })
2374}
2375
2376fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2379 if let Some(name) = name_from_line(line) {
2380 summary.name = name;
2381 return;
2382 }
2383 if summary.preview.is_some() {
2384 return;
2385 }
2386 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2387 return;
2388 };
2389 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2390 return;
2391 }
2392 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2393 return;
2394 }
2395 let Some(message) = value.get("message") else {
2396 return;
2397 };
2398 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2399 return;
2400 }
2401 let text = match message.get("content") {
2402 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2403 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2404 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2405 .then(|| block.get("text").and_then(|v| v.as_str()))
2406 .flatten()
2407 }),
2408 _ => None,
2409 };
2410 let Some(text) = text else { return };
2411 let collapsed = text
2412 .lines()
2413 .map(str::trim)
2414 .filter(|line| !line.is_empty())
2415 .collect::<Vec<_>>()
2416 .join(" ");
2417 if collapsed.is_empty() {
2418 return;
2419 }
2420 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2421}
2422
2423pub fn session_display_label(summary: &SessionSummary) -> String {
2427 if let Some(name) = summary.name.as_deref().map(str::trim) {
2428 if !name.is_empty() {
2429 return name.to_string();
2430 }
2431 }
2432 match summary.preview.as_deref().map(str::trim) {
2433 Some(preview) if !preview.is_empty() => preview.to_string(),
2434 _ => "(no messages)".to_string(),
2435 }
2436}
2437
2438pub fn format_session_bytes(bytes: u64) -> String {
2441 if bytes >= 1_048_576 {
2442 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2443 } else if bytes >= 1024 {
2444 format!("{}KB", bytes / 1024)
2445 } else {
2446 format!("{bytes}B")
2447 }
2448}
2449
2450pub fn short_session_id(id: &str) -> &str {
2453 &id[..id.len().min(8)]
2454}
2455
2456pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2465 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2466 let mut visit = |path: PathBuf| {
2467 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2468 return;
2469 }
2470 let modified = std::fs::metadata(&path)
2471 .and_then(|m| m.modified())
2472 .unwrap_or(std::time::UNIX_EPOCH);
2473 files.push((path, modified));
2474 };
2475 if let Ok(entries) = std::fs::read_dir(dir) {
2476 for entry in entries.flatten() {
2477 let path = entry.path();
2478 if path.is_dir() {
2479 if let Ok(inner) = std::fs::read_dir(&path) {
2480 for child in inner.flatten() {
2481 visit(child.path());
2482 }
2483 }
2484 } else {
2485 visit(path);
2486 }
2487 }
2488 }
2489 files.sort_by(|a, b| b.1.cmp(&a.1));
2490 files.into_iter().map(|(path, _)| path).collect()
2491}
2492
2493fn first_resumable_session<'a>(
2498 metas: &'a [rpi_harness::session::jsonl::JsonlSessionMetadata],
2499) -> Option<&'a rpi_harness::session::jsonl::JsonlSessionMetadata> {
2500 metas
2501 .iter()
2502 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2503 .or_else(|| metas.first())
2504}
2505
2506pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2510 let metas = list_session_metadata(cwd)
2511 .await
2512 .map_err(|e| OpenError::Other(e.to_string()))?;
2513 let Some(meta) = metas
2514 .iter()
2515 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2516 else {
2517 return Err(OpenError::NotFound {
2518 requested: format!("session {id}"),
2519 });
2520 };
2521 open_session(meta, cwd).await.map_err(|e| {
2522 OpenError::Other(match e {
2523 BuildError::SessionOpen(reason) => reason,
2527 other => other.to_string(),
2528 })
2529 })
2530}
2531
2532pub(crate) async fn fork_session_storage(
2539 harness: &AgentHarness,
2540 cwd: &str,
2541) -> Result<Session, String> {
2542 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2543 use rpi_tools::FileSystem;
2544
2545 let dir = default_session_dir(Path::new(cwd));
2546 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2547 let fs: Arc<dyn FileSystem> = env.clone();
2548 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2549 fs,
2550 sessions_root: dir.to_string_lossy().into_owned(),
2551 clock: Arc::new(SystemClock),
2552 ids: Arc::new(DefaultIdGenerator::new()),
2553 });
2554 let id = harness.session().storage().metadata().id.clone();
2557 let metas = list_session_metadata(cwd)
2558 .await
2559 .map_err(|e| e.to_string())?;
2560 let Some(source) = metas.iter().find(|m| m.id == id) else {
2561 return Err(format!("current session {id} not found on disk"));
2562 };
2563 let fork_storage = repo
2564 .fork_typed(
2565 source,
2566 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2567 id: None,
2568 parent_session_id: Some(source.id.clone()),
2569 cwd: cwd.to_string(),
2570 metadata: None,
2571 },
2572 &rpi_harness::session::types::ForkOptions::default(),
2573 )
2574 .await
2575 .map_err(|e| e.to_string())?;
2576 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2577 Ok(Session::new(Arc::new(fork_storage), None))
2578}
2579
2580static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2582static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2585
2586fn claim_session_lock(session_path: &str) -> Result<(), String> {
2595 let lock_path = PathBuf::from(format!("{session_path}.lock"));
2596 if SESSION_LOCK_PATHS
2597 .get_or_init(|| Mutex::new(HashSet::new()))
2598 .lock()
2599 .unwrap()
2600 .contains(&lock_path)
2601 {
2602 return Ok(());
2603 }
2604 let file = std::fs::OpenOptions::new()
2605 .create(true)
2606 .write(true)
2607 .open(&lock_path)
2608 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2609 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2610 return Err(format!(
2611 "session {session_path} is already open in another rpi process"
2612 ));
2613 }
2614 SESSION_LOCKS
2615 .get_or_init(|| Mutex::new(Vec::new()))
2616 .lock()
2617 .unwrap()
2618 .push(file);
2619 SESSION_LOCK_PATHS
2620 .get_or_init(|| Mutex::new(HashSet::new()))
2621 .lock()
2622 .unwrap()
2623 .insert(lock_path);
2624 Ok(())
2625}
2626
2627async fn open_session(
2630 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2631 cwd: &str,
2632) -> Result<Session, BuildError> {
2633 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2634 use rpi_harness::session::types::SessionStorage;
2635 use rpi_tools::FileSystem;
2636
2637 let dir = default_session_dir(Path::new(cwd));
2638 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2639 let fs: Arc<dyn FileSystem> = env.clone();
2640 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2641 fs: fs.clone(),
2642 sessions_root: dir.to_string_lossy().into_owned(),
2643 clock: Arc::new(SystemClock),
2644 ids: Arc::new(DefaultIdGenerator::new()),
2645 });
2646 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2647 let storage = repo
2648 .open_by_jsonl_metadata(meta)
2649 .await
2650 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2651 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2655 Some(Path::new(&meta.path)),
2656 Path::new(&meta.cwd),
2657 Path::new(cwd),
2658 ) {
2659 eprintln!(
2660 "warning: {}",
2661 crate::session_cwd::format_missing_session_cwd_error(&issue)
2662 );
2663 }
2664 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2665 Ok(Session::new(storage_arc, None))
2666}
2667
2668fn ephemeral_session() -> Session {
2670 let storage = Arc::new(InMemorySessionStorage::new(
2671 SessionMetadata {
2672 id: "ephemeral".into(),
2673 created_at: 0,
2674 parent_session_id: None,
2675 },
2676 Arc::new(SystemClock),
2677 Arc::new(DefaultIdGenerator::new()),
2678 ));
2679 Session::new(storage, None)
2680}
2681
2682pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2693 create_jsonl_session_with_id(dir, cwd, None).await
2694}
2695
2696pub(crate) async fn create_jsonl_session_with_id(
2700 dir: &Path,
2701 cwd: &str,
2702 id: Option<String>,
2703) -> Result<Session, String> {
2704 use rpi_harness::session::jsonl::{
2705 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2706 };
2707 use rpi_tools::FileSystem;
2708
2709 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2712 let fs: Arc<dyn FileSystem> = env.clone();
2713
2714 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2715 fs: fs.clone(),
2716 sessions_root: dir.to_string_lossy().into_owned(),
2717 clock: Arc::new(SystemClock),
2718 ids: Arc::new(DefaultIdGenerator::new()),
2719 });
2720
2721 let opts = JsonlSessionCreateOptions {
2722 id, parent_session_id: None,
2724 cwd: cwd.to_string(),
2725 metadata: None,
2726 };
2727 let storage = repo
2728 .create_typed(&opts)
2729 .await
2730 .map_err(|e| format!("create session: {e}"))?;
2731 claim_session_lock(&storage.jsonl_metadata().path)
2732 .map_err(|e| format!("create session: {e}"))?;
2733 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2735 Ok(Session::new(storage_arc, None))
2736}
2737
2738fn read_append_target(target: &str) -> Option<String> {
2741 let path = Path::new(target);
2742 if path.is_file() {
2743 std::fs::read_to_string(path).ok()
2744 } else {
2745 None
2746 }
2747}
2748
2749#[cfg(test)]
2750mod tests {
2751 use super::*;
2752 use crate::args::Args;
2753
2754 fn scratch(name: &str) -> PathBuf {
2756 let nanos = std::time::SystemTime::now()
2757 .duration_since(std::time::UNIX_EPOCH)
2758 .unwrap()
2759 .as_nanos();
2760 let path = std::env::temp_dir().join(format!(
2761 "rpi-summary-{}-{nanos}-{name}.jsonl",
2762 std::process::id()
2763 ));
2764 path
2765 }
2766
2767 #[test]
2768 fn summary_marks_a_header_only_session_empty() {
2769 let path = scratch("empty");
2770 std::fs::write(
2771 &path,
2772 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2773 )
2774 .unwrap();
2775 let summary = summarize_session_file(&path);
2776 assert!(summary.empty, "a header-only file has nothing to resume");
2777 assert!(summary.preview.is_none());
2778 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
2779 std::fs::remove_file(&path).ok();
2780 }
2781
2782 #[test]
2783 fn summary_reads_the_first_user_prompt_and_name() {
2784 let path = scratch("content");
2785 let body = concat!(
2786 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2787 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
2788 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
2789 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
2790 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
2791 );
2792 std::fs::write(&path, body).unwrap();
2793 let summary = summarize_session_file(&path);
2794 assert!(!summary.empty);
2795 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
2796 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
2797 std::fs::remove_file(&path).ok();
2798 }
2799
2800 #[test]
2801 fn summary_extracts_a_block_style_user_prompt() {
2802 let path = scratch("blocks");
2803 let body = concat!(
2804 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2805 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
2806 );
2807 std::fs::write(&path, body).unwrap();
2808 let summary = summarize_session_file(&path);
2809 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
2810 assert_eq!(session_display_label(&summary), "line one line two");
2811 std::fs::remove_file(&path).ok();
2812 }
2813
2814 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
2815 rpi_harness::session::jsonl::JsonlSessionMetadata {
2816 id: id.to_string(),
2817 created_at: 0,
2818 cwd: ".".to_string(),
2819 path: path.to_string_lossy().into_owned(),
2820 modified_at: 0,
2821 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
2822 parent_session_id: None,
2823 legacy_parent_session_path: None,
2824 metadata: None,
2825 }
2826 }
2827
2828 #[test]
2829 fn continue_skips_newer_empty_sessions() {
2830 let empty = scratch("newest-empty");
2831 std::fs::write(
2832 &empty,
2833 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2834 )
2835 .unwrap();
2836 let content = scratch("older-content");
2837 std::fs::write(
2838 &content,
2839 concat!(
2840 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2841 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
2842 ),
2843 )
2844 .unwrap();
2845
2846 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
2847 let picked = first_resumable_session(&metas).expect("a resumable session");
2848 assert_eq!(picked.id, "old");
2849
2850 let all_empty = vec![meta_for(&empty, "empty")];
2853 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
2854
2855 std::fs::remove_file(&empty).ok();
2856 std::fs::remove_file(&content).ok();
2857 }
2858
2859 #[test]
2860 fn list_session_files_sync_walks_the_cwd_subdirectory() {
2861 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
2862 let nested = root.join("--proj--");
2863 std::fs::create_dir_all(&nested).unwrap();
2864 let file = nested.join("2026-01-01_a.jsonl");
2865 std::fs::write(&file, "{}\n").unwrap();
2866 let found = list_session_files_sync(&root);
2867 assert!(found.iter().any(|p| p == &file));
2868 std::fs::remove_dir_all(&root).ok();
2869 }
2870
2871 #[test]
2872 fn default_prompt_mentions_cwd_and_tools() {
2873 let p = default_system_prompt("/tmp/proj", false);
2874 assert!(p.contains("/tmp/proj"));
2875 assert!(p.contains("read"));
2876 assert!(p.contains("bash"));
2877 assert!(p.contains("edit"));
2878 assert!(p.contains("write"));
2879 assert!(p.contains("docs"));
2880 assert!(!p.contains("- grep"));
2881 assert!(!p.contains("- find"));
2882 assert!(!p.contains("- ls"));
2883 assert!(!p.contains("powershell"));
2884 }
2885
2886 #[test]
2887 fn default_prompt_instructs_language_matching() {
2888 let p = default_system_prompt("/tmp/proj", false);
2892 let lower = p.to_lowercase();
2893 assert!(
2894 lower.contains("same language") && lower.contains("detect"),
2895 "the prompt must tell the model to detect and mirror the user's language: {p}"
2896 );
2897 }
2898
2899 #[test]
2900 fn default_prompt_keeps_working_state_in_the_visible_channel() {
2901 let p = default_system_prompt("/tmp/proj", false);
2909 let lower = p.to_lowercase();
2910 assert!(
2912 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
2913 "the prompt must explain that reasoning does not survive the turn: {p}"
2914 );
2915 assert!(
2917 lower.contains("visible") && lower.contains("todo tool"),
2918 "the prompt must point at visible text and the todo tool: {p}"
2919 );
2920 assert!(
2922 lower.contains("re-stating the same plan"),
2923 "the prompt must call out plan re-statement as a bug: {p}"
2924 );
2925 assert!(
2927 lower.contains("already shown earlier"),
2928 "the prompt must discourage re-fetching known content: {p}"
2929 );
2930 }
2931
2932 #[test]
2933 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
2934 let p = default_system_prompt("/tmp/proj", true);
2941 let lower = p.to_lowercase();
2942 assert!(
2943 lower.contains("carried back"),
2944 "a replaying model must be told its reasoning survives: {p}"
2945 );
2946 assert!(
2947 !lower.contains("reasoning is not"),
2948 "the replayed variant must not claim reasoning is dropped: {p}"
2949 );
2950 assert!(
2951 lower.contains("do not re-derive"),
2952 "the replayed variant must forbid re-deriving the plan: {p}"
2953 );
2954 assert!(
2955 lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
2956 "the anti-repetition rule must survive the flip: {p}"
2957 );
2958 assert!(
2959 lower.contains("already shown earlier"),
2960 "the re-read rule must survive the flip: {p}"
2961 );
2962 }
2963
2964 #[test]
2972 fn only_read_only_tools_are_replayable() {
2973 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2974 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2975 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2976 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2977 let tools = build_tools(&context, &Args::default());
2978
2979 let replay_of = |name: &str| {
2980 tools
2981 .iter()
2982 .find(|tool| tool.tool.schema().name == name)
2983 .map(|tool| tool.replay)
2984 };
2985
2986 for read_only in ["read", "docs"] {
2987 assert_eq!(
2988 replay_of(read_only),
2989 Some(ToolReplay::Safe),
2990 "{read_only} is side-effect free, so recovery may re-run it"
2991 );
2992 }
2993 for mutating in ["bash", "edit", "write", "powershell"] {
2994 if let Some(replay) = replay_of(mutating) {
2995 assert_eq!(
2996 replay,
2997 ToolReplay::Never,
2998 "{mutating} must never be replayed: re-running it would duplicate its effect"
2999 );
3000 }
3001 }
3002 }
3003
3004 #[test]
3005 fn default_tools_include_docs_lookup() {
3006 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3007 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3008 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3009 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3010 let names: Vec<String> = build_tools(&context, &Args::default())
3011 .iter()
3012 .map(|tool| tool.tool.schema().name.clone())
3013 .collect();
3014
3015 assert!(names.contains(&"docs".to_string()));
3020 for legacy in ["grep", "find", "ls"] {
3021 assert!(
3022 !names.contains(&legacy.to_string()),
3023 "legacy tool {legacy} should not be registered by default"
3024 );
3025 }
3026 }
3027
3028 #[test]
3029 fn pi_package_loading_is_opt_in_and_respects_no_extensions() {
3030 let args = Args::default();
3031 assert!(!should_load_js_packages(&args));
3032 let resources = package_resources_for(&args, Path::new("."), false);
3033 assert!(resources.packages.is_empty());
3034
3035 let args = Args {
3036 enable_pi_packages: true,
3037 ..Args::default()
3038 };
3039 assert!(should_load_js_packages(&args));
3040
3041 let args = Args {
3042 enable_pi_packages: true,
3043 no_extensions: true,
3044 ..Args::default()
3045 };
3046 assert!(!should_load_js_packages(&args));
3047 assert!(package_resources_for(&args, Path::new("."), false)
3048 .packages
3049 .is_empty());
3050 }
3051
3052 #[test]
3053 fn pi_offline_env_disables_startup_package_remediation() {
3054 struct RestoreEnv {
3055 name: &'static str,
3056 value: Option<std::ffi::OsString>,
3057 }
3058
3059 impl Drop for RestoreEnv {
3060 fn drop(&mut self) {
3061 match self.value.take() {
3062 Some(value) => std::env::set_var(self.name, value),
3063 None => std::env::remove_var(self.name),
3064 }
3065 }
3066 }
3067
3068 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3069 let _restore_config = RestoreEnv {
3070 name: crate::config::CONFIG_DIR_ENV,
3071 value: std::env::var_os(crate::config::CONFIG_DIR_ENV),
3072 };
3073 let _restore_offline = RestoreEnv {
3074 name: crate::args::PI_OFFLINE_ENV,
3075 value: std::env::var_os(crate::args::PI_OFFLINE_ENV),
3076 };
3077 let tmp = tempfile::tempdir().unwrap();
3078 let agent = tmp.path().join("agent");
3079 let cwd = tmp.path().join("project");
3080 let package = agent.join("npm/node_modules/demo");
3081 std::fs::create_dir_all(package.join("extensions")).unwrap();
3082 std::fs::create_dir_all(&cwd).unwrap();
3083 std::fs::write(
3084 package.join("package.json"),
3085 r#"{"name":"demo","version":"1.0.0"}"#,
3086 )
3087 .unwrap();
3088 std::fs::write(
3089 package.join("extensions/index.js"),
3090 "export default () => {};",
3091 )
3092 .unwrap();
3093 std::fs::write(
3094 agent.join("settings.json"),
3095 r#"{"npmCommand":[""],"packages":["npm:demo@2.0.0"]}"#,
3096 )
3097 .unwrap();
3098 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3099 std::env::set_var(crate::args::PI_OFFLINE_ENV, "TrUe");
3100 let args = Args {
3101 enable_pi_packages: true,
3102 offline: false,
3103 ..Args::default()
3104 };
3105
3106 let resources = package_resources_for(&args, &cwd, false);
3107
3108 assert!(resources.packages.is_empty());
3109 assert_eq!(resources.diagnostics.len(), 1);
3110 assert!(resources.diagnostics[0].message.contains("offline"));
3111 assert_eq!(
3112 std::fs::read(package.join("package.json")).unwrap(),
3113 br#"{"name":"demo","version":"1.0.0"}"#
3114 );
3115 }
3116
3117 #[test]
3118 fn package_discovery_uses_the_callers_trust_snapshot() {
3119 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3120 let previous = std::env::var_os(crate::config::CONFIG_DIR_ENV);
3121 let tmp = tempfile::tempdir().unwrap();
3122 let agent = tmp.path().join("agent");
3123 let cwd = tmp.path().join("project");
3124 let package = cwd.join("package");
3125 std::fs::create_dir_all(&agent).unwrap();
3126 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3127 std::fs::create_dir_all(&package).unwrap();
3128 std::fs::write(agent.join("settings.json"), "{}").unwrap();
3129 std::fs::write(
3130 package.join("package.json"),
3131 r#"{"name":"snapshot-package","version":"1.0.0"}"#,
3132 )
3133 .unwrap();
3134 std::fs::write(
3135 cwd.join(".rpi/settings.json"),
3136 serde_json::json!({"packages": [package]}).to_string(),
3137 )
3138 .unwrap();
3139 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3140
3141 let args = Args {
3142 enable_pi_packages: true,
3143 ..Args::default()
3144 };
3145 assert_eq!(package_resources_for(&args, &cwd, true).packages.len(), 1);
3146 assert!(package_resources_for(&args, &cwd, false)
3147 .packages
3148 .is_empty());
3149 assert_eq!(
3150 package_resources_for_update_check(&args, &cwd, true)
3151 .packages
3152 .len(),
3153 1
3154 );
3155 assert!(package_resources_for_update_check(&args, &cwd, false)
3156 .packages
3157 .is_empty());
3158
3159 match previous {
3160 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3161 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3162 }
3163 }
3164
3165 #[test]
3166 fn reload_settings_preflight_is_independent_of_packages_and_respects_trust() {
3167 struct RestoreConfigDir(Option<std::ffi::OsString>);
3168
3169 impl Drop for RestoreConfigDir {
3170 fn drop(&mut self) {
3171 match self.0.take() {
3172 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3173 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3174 }
3175 }
3176 }
3177
3178 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3179 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3180 let tmp = tempfile::tempdir().unwrap();
3181 let agent = tmp.path().join("agent");
3182 let cwd = tmp.path().join("project");
3183 std::fs::create_dir_all(&agent).unwrap();
3184 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3185 std::fs::write(agent.join("settings.json"), "{}").unwrap();
3186 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
3187 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3188 let packages_disabled = Args::default();
3189 let packages_enabled = Args {
3190 enable_pi_packages: true,
3191 ..Args::default()
3192 };
3193
3194 for args in [&packages_disabled, &packages_enabled] {
3195 let trusted = validate_settings_for_reload(args, &cwd, true);
3196 let untrusted = validate_settings_for_reload(args, &cwd, false);
3197 assert!(trusted
3198 .unwrap_err()
3199 .contains("could not load project settings"));
3200 assert!(untrusted.is_ok());
3201 }
3202
3203 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3204 for args in [&packages_disabled, &packages_enabled] {
3205 assert!(validate_settings_for_reload(args, &cwd, false)
3206 .unwrap_err()
3207 .contains("could not load global settings"));
3208 }
3209
3210 let local_only = Args {
3211 dev_local_only: true,
3212 ..Args::default()
3213 };
3214 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
3215 }
3216
3217 #[tokio::test(flavor = "current_thread")]
3218 async fn reload_with_packages_disabled_preserves_live_resources_when_settings_break() {
3219 struct RestoreConfigDir(Option<std::ffi::OsString>);
3220
3221 impl Drop for RestoreConfigDir {
3222 fn drop(&mut self) {
3223 match self.0.take() {
3224 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3225 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3226 }
3227 }
3228 }
3229
3230 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3231 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3232 let tmp = tempfile::tempdir().unwrap();
3233 let agent = tmp.path().join("agent");
3234 let cwd = tmp.path().join("project");
3235 let skill_dir = tmp.path().join("configured-skills");
3236 std::fs::create_dir_all(&agent).unwrap();
3237 std::fs::create_dir_all(&cwd).unwrap();
3238 std::fs::create_dir_all(&skill_dir).unwrap();
3239 std::fs::write(
3240 skill_dir.join("SKILL.md"),
3241 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
3242 )
3243 .unwrap();
3244 std::fs::write(
3245 agent.join("settings.json"),
3246 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3247 )
3248 .unwrap();
3249 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3250
3251 let resolved = crate::provider::resolve(
3252 Some("anthropic"),
3253 Some(crate::provider::DEFAULT_MODEL_ID),
3254 None,
3255 Some("test-key"),
3256 None,
3257 )
3258 .unwrap();
3259 let args = Args {
3260 trust_override: Some(false),
3261 no_session: true,
3262 no_extensions: true,
3263 no_prompt_templates: true,
3264 no_context_files: true,
3265 system_prompt: Some("stable system prompt".into()),
3266 ..Args::default()
3267 };
3268 assert!(!should_load_js_packages(&args));
3269
3270 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3271 let before_resources = harness.get_resources().await.unwrap();
3272 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
3273 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
3274 let before_prompt = harness.get_system_prompt().await.unwrap();
3275 let before_tools: Vec<String> = harness
3276 .get_tools()
3277 .await
3278 .unwrap()
3279 .iter()
3280 .map(|tool| tool.tool.schema().name.clone())
3281 .collect();
3282 let before_bridge = context
3283 .action_bridge
3284 .lock()
3285 .unwrap()
3286 .as_ref()
3287 .unwrap()
3288 .clone();
3289
3290 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3291 let outcome = reload_extension_resources(&harness, &context).await;
3292
3293 assert!(outcome.had_warnings);
3294 assert!(outcome.summary.contains("Settings reload failed"));
3295 assert!(outcome.summary.contains("could not load global settings"));
3296 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
3297 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
3298 let after_tools: Vec<String> = harness
3299 .get_tools()
3300 .await
3301 .unwrap()
3302 .iter()
3303 .map(|tool| tool.tool.schema().name.clone())
3304 .collect();
3305 assert_eq!(after_tools, before_tools);
3306 let after_bridge = context
3307 .action_bridge
3308 .lock()
3309 .unwrap()
3310 .as_ref()
3311 .unwrap()
3312 .clone();
3313 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
3314 }
3315
3316 #[test]
3317 fn reload_preparation_rejects_settings_changed_during_derivation() {
3318 struct RestoreConfigDir(Option<std::ffi::OsString>);
3319
3320 impl Drop for RestoreConfigDir {
3321 fn drop(&mut self) {
3322 match self.0.take() {
3323 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3324 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3325 }
3326 }
3327 }
3328
3329 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3330 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3331 let tmp = tempfile::tempdir().unwrap();
3332 let agent = tmp.path().join("agent");
3333 let cwd = tmp.path().join("project");
3334 let first_skill_dir = tmp.path().join("first-skills");
3335 let second_skill_dir = tmp.path().join("second-skills");
3336 std::fs::create_dir_all(&agent).unwrap();
3337 std::fs::create_dir_all(&cwd).unwrap();
3338 std::fs::write(
3339 agent.join("settings.json"),
3340 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3341 )
3342 .unwrap();
3343 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3344 let args = Args {
3345 trust_override: Some(false),
3346 no_extensions: true,
3347 no_prompt_templates: true,
3348 ..Args::default()
3349 };
3350
3351 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3352 std::fs::write(
3353 agent.join("settings.json"),
3354 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3355 )
3356 .unwrap();
3357 });
3358
3359 let error = result
3360 .err()
3361 .expect("settings mutation must fail preparation");
3362 assert!(error.contains("settings changed while reload inputs were being prepared"));
3363 }
3364
3365 #[tokio::test(flavor = "current_thread")]
3366 async fn reload_uses_frozen_settings_inputs_after_preparation() {
3367 struct RestoreConfigDir(Option<std::ffi::OsString>);
3368
3369 impl Drop for RestoreConfigDir {
3370 fn drop(&mut self) {
3371 match self.0.take() {
3372 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3373 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3374 }
3375 }
3376 }
3377
3378 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3379 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3380 let tmp = tempfile::tempdir().unwrap();
3381 let agent = tmp.path().join("agent");
3382 let cwd = tmp.path().join("project");
3383 let skill_dir = tmp.path().join("configured-skills");
3384 std::fs::create_dir_all(&agent).unwrap();
3385 std::fs::create_dir_all(&cwd).unwrap();
3386 std::fs::create_dir_all(&skill_dir).unwrap();
3387 std::fs::write(
3388 skill_dir.join("SKILL.md"),
3389 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3390 )
3391 .unwrap();
3392 std::fs::write(
3393 agent.join("settings.json"),
3394 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3395 )
3396 .unwrap();
3397 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3398
3399 let resolved = crate::provider::resolve(
3400 Some("anthropic"),
3401 Some(crate::provider::DEFAULT_MODEL_ID),
3402 None,
3403 Some("test-key"),
3404 None,
3405 )
3406 .unwrap();
3407 let args = Args {
3408 trust_override: Some(false),
3409 no_session: true,
3410 no_extensions: true,
3411 no_prompt_templates: true,
3412 no_context_files: true,
3413 system_prompt: Some("stable system prompt".into()),
3414 ..Args::default()
3415 };
3416 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3417
3418 let outcome = reload_extension_resources_inner(&harness, &context, || {
3419 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3420 })
3421 .await;
3422
3423 assert!(!outcome.summary.contains("Settings reload failed"));
3424 assert_eq!(
3425 outcome.summary,
3426 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3427 );
3428 let resources = harness.get_resources().await.unwrap();
3429 let skills = resources.skills.as_ref().unwrap();
3430 assert_eq!(skills.len(), 1);
3431 assert_eq!(skills[0].name, "frozen-skill");
3432 }
3433
3434 #[tokio::test]
3435 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3436 let tmp = tempfile::tempdir().unwrap();
3437 let skill_path = tmp.path().join("explicit-skill.md");
3438 std::fs::write(
3439 &skill_path,
3440 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3441 )
3442 .unwrap();
3443 let prompt_path = tmp.path().join("explicit-prompt.md");
3444 std::fs::write(
3445 &prompt_path,
3446 "---\ndescription: Explicit prompt\n---\nPrompt body",
3447 )
3448 .unwrap();
3449
3450 let args = Args {
3451 skill: vec![skill_path.clone()],
3452 prompt_template: vec![prompt_path.clone()],
3453 ..Args::default()
3454 };
3455 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3456 let prompt_paths =
3457 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3458 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3459 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3460
3461 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3462 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3463 assert_eq!(skills.skills[0].name, "explicit-skill");
3464
3465 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3466 assert_eq!(
3467 prompts.prompt_templates.len(),
3468 1,
3469 "{:?}",
3470 prompts.diagnostics
3471 );
3472 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3473 }
3474
3475 #[test]
3476 fn tool_policy_applies_to_rust_and_js_active_names() {
3477 let names = vec![
3478 "read".to_string(),
3479 "ask_user_question".to_string(),
3480 "write".to_string(),
3481 ];
3482
3483 let args = Args {
3484 tools: Some(vec!["read".into(), "ask_user_question".into()]),
3485 ..Args::default()
3486 };
3487 assert_eq!(
3488 filter_active_tool_names(names.clone(), &args),
3489 vec!["read", "ask_user_question"]
3490 );
3491
3492 let args = Args {
3493 exclude_tools: Some(vec!["ask_user_question".into()]),
3494 ..Args::default()
3495 };
3496 assert_eq!(
3497 filter_active_tool_names(names.clone(), &args),
3498 vec!["read", "write"]
3499 );
3500
3501 let args = Args {
3502 no_tools: true,
3503 ..Args::default()
3504 };
3505 assert!(filter_active_tool_names(names, &args).is_empty());
3506 }
3507
3508 #[test]
3509 fn select_ephemeral_when_no_session() {
3510 let args = Args {
3511 no_session: true,
3512 ..Args::default()
3513 };
3514 let cwd = Path::new("/tmp");
3515 assert!(matches!(
3516 select_session(&args, cwd),
3517 SessionSelection::Ephemeral
3518 ));
3519 }
3520
3521 #[test]
3522 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3523 let defaults = Args::default();
3524 assert!(resolve_project_trust(
3525 &defaults,
3526 Path::new("C:/definitely-not-a-project")
3527 ));
3528
3529 let denied = Args {
3530 trust_override: Some(false),
3531 ..Args::default()
3532 };
3533 assert!(!resolve_project_trust(
3534 &denied,
3535 Path::new("C:/definitely-not-a-project")
3536 ));
3537 }
3538
3539 #[tokio::test(flavor = "current_thread")]
3540 async fn build_preserves_the_supplied_startup_project_snapshot() {
3541 struct RestoreConfigDir(Option<std::ffi::OsString>);
3542
3543 impl Drop for RestoreConfigDir {
3544 fn drop(&mut self) {
3545 match self.0.take() {
3546 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3547 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3548 }
3549 }
3550 }
3551
3552 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3553 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3554 let tmp = tempfile::tempdir().unwrap();
3555 let agent = tmp.path().join("agent");
3556 let cwd = tmp.path().join("project");
3557 std::fs::create_dir_all(&agent).unwrap();
3558 std::fs::create_dir_all(&cwd).unwrap();
3559 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3560
3561 let resolved = crate::provider::resolve(
3562 Some("anthropic"),
3563 Some(crate::provider::DEFAULT_MODEL_ID),
3564 None,
3565 Some("test-key"),
3566 None,
3567 )
3568 .unwrap();
3569 let args = Args {
3570 trust_override: Some(false),
3571 no_session: true,
3572 no_tools: true,
3573 no_extensions: true,
3574 no_skills: true,
3575 no_prompt_templates: true,
3576 no_context_files: true,
3577 system_prompt: Some("test prompt".into()),
3578 ..Args::default()
3579 };
3580
3581 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3582
3583 assert_eq!(context.cwd, cwd);
3584 assert!(context.project_trusted);
3585 }
3586
3587 #[tokio::test(flavor = "current_thread")]
3588 async fn build_applies_the_name_flag_to_the_session() {
3589 struct RestoreConfigDir(Option<std::ffi::OsString>);
3590
3591 impl Drop for RestoreConfigDir {
3592 fn drop(&mut self) {
3593 match self.0.take() {
3594 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3595 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3596 }
3597 }
3598 }
3599
3600 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3601 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3602 let tmp = tempfile::tempdir().unwrap();
3603 let agent = tmp.path().join("agent");
3604 let cwd = tmp.path().join("project");
3605 std::fs::create_dir_all(&agent).unwrap();
3606 std::fs::create_dir_all(&cwd).unwrap();
3607 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3608
3609 let resolved = crate::provider::resolve(
3610 Some("anthropic"),
3611 Some(crate::provider::DEFAULT_MODEL_ID),
3612 None,
3613 Some("test-key"),
3614 None,
3615 )
3616 .unwrap();
3617 let args = Args {
3618 trust_override: Some(false),
3619 no_tools: true,
3620 no_extensions: true,
3621 no_skills: true,
3622 no_prompt_templates: true,
3623 no_context_files: true,
3624 system_prompt: Some("test prompt".into()),
3625 name: Some("smoke name".into()),
3626 session_dir: Some(cwd.join("sessions")),
3627 ..Args::default()
3628 };
3629
3630 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3631 assert_eq!(
3632 harness.get_name().await.unwrap().as_deref(),
3633 Some("smoke name")
3634 );
3635 }
3636
3637 #[test]
3638 fn select_latest_for_continue_and_resume() {
3639 let args = Args {
3640 continue_session: true,
3641 ..Args::default()
3642 };
3643 let cwd = Path::new("/tmp");
3644 assert!(matches!(
3645 select_session(&args, cwd),
3646 SessionSelection::Latest
3647 ));
3648
3649 let args = Args {
3650 resume: true,
3651 ..Args::default()
3652 };
3653 assert!(matches!(
3654 select_session(&args, cwd),
3655 SessionSelection::Latest
3656 ));
3657 }
3658
3659 #[test]
3660 fn select_by_id_for_session_flag() {
3661 let args = Args {
3662 session: Some("01a02ece".into()),
3663 ..Args::default()
3664 };
3665 let cwd = Path::new("/tmp");
3666 assert!(matches!(
3667 select_session(&args, cwd),
3668 SessionSelection::ById { id } if id == "01a02ece"
3669 ));
3670 }
3671
3672 #[test]
3673 fn select_new_with_custom_dir() {
3674 let args = Args {
3675 session_dir: Some(PathBuf::from("/tmp/sess")),
3676 ..Args::default()
3677 };
3678 let cwd = Path::new("/tmp");
3679 match select_session(&args, cwd) {
3680 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
3681 other => panic!("expected New, got {other:?}"),
3682 }
3683 }
3684
3685 #[test]
3686 fn select_new_default_dir() {
3687 let args = Args::default();
3688 let cwd = Path::new("/proj");
3689 match select_session(&args, cwd) {
3690 SessionSelection::New { dir, .. } => {
3691 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
3692 }
3693 other => panic!("expected New, got {other:?}"),
3694 }
3695 }
3696
3697 #[test]
3698 fn default_session_dir_prefers_rpi_but_reads_legacy_pi() {
3699 let tmp = tempfile::tempdir().unwrap();
3700 let cwd = tmp.path();
3701 std::fs::create_dir_all(cwd.join(".pi/sessions")).unwrap();
3702 assert_eq!(default_session_dir(cwd), cwd.join(".pi/sessions"));
3703 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3704 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3705 }
3706
3707 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3708 async fn ephemeral_session_builds_roundtrips() {
3709 let s = ephemeral_session();
3713 let leaf = s.get_leaf_id().await;
3714 assert!(leaf.is_ok());
3715 }
3716
3717 }