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 action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
344 runtime.clone(),
345 host_arc,
346 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
347 ui_dialog_mailbox.clone(),
348 ext_status_mailbox.clone(),
349 );
350
351 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
353 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
354 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
355 let _registry = Arc::new(MutationQueueRegistry::new());
356 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
359
360 let tools = build_tools(&ctx, args);
361 let mut tools = tools;
362
363 let extension_session = if args.no_extensions {
371 ExtensionSession::none()
372 } else {
373 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
374 };
375 let js_extension_session = if !should_load_js_packages(args) {
376 None
377 } else {
378 let paths = js_extension_paths(args, cwd, project_trusted, &package_resources);
379 let js_context = serde_json::json!({
380 "cwd": cwd_str,
381 "theme": resolved.theme.clone(),
382 "currentModel": resolved.model.clone(),
383 "models": catalog.clone(),
384 "thinkingLevel": resolved.thinking_level,
385 });
386 match crate::js_extensions::JsExtensionSession::load_with_context(
387 &paths,
388 args.verbose,
389 js_context,
390 ) {
391 Ok(session) => session,
392 Err(error) => {
393 eprintln!("warning: JS/TS extensions were not loaded: {error}");
394 None
395 }
396 }
397 };
398 if js_extension_session.is_some() {
399 eprintln!(
400 "warning: enabled Pi JS/TS extensions execute with the current user's permissions"
401 );
402 }
403 if let Some(session) = &js_extension_session {
404 if let Err(error) =
405 session.enable_provider_runtime(resolved.provider.clone(), runtime.clone())
406 {
407 if args.verbose {
408 eprintln!("warning: JS provider runtime was not enabled: {error}");
409 }
410 }
411 }
412 if args.verbose {
413 if let Some(session) = &js_extension_session {
414 let info = session.backend_info();
415 eprintln!(
416 "JS extension backend: {} v{} ({})",
417 info.name,
418 info.api_version,
419 info.capability_names().join(", ")
420 );
421 }
422 if let Some(s) = extension_session.summary() {
423 eprintln!("extensions: {s}");
424 }
425 report_deferred_renderers(&extension_session);
426 }
427 merge_extension_tools(&mut tools, &extension_session, args, &tool_context);
428 if let Some(session) = &js_extension_session {
429 merge_js_extension_tools(&mut tools, session, args);
430 if args.verbose && !session.commands.is_empty() {
431 eprintln!("JS extension commands: {}", session.commands.join(", "));
432 }
433 }
434 let mut active = active_tool_names(&tools, args);
435 if let Some(session) = &js_extension_session {
440 let js_names = session.tool_names();
441 if let Some(js_active) = session.active_tools() {
442 active.retain(|name| !js_names.iter().any(|js| js == name));
443 active.extend(js_active.into_iter().filter(|name| {
444 js_names.iter().any(|js| js == name) && tool_name_allowed(name, args)
445 }));
446 }
447 }
448 active = filter_active_tool_names(active, args);
449
450 let selection = select_session(args, cwd);
452 let session = build_session(&selection, &cwd_str).await?;
453 if let Ok(metadata) = session.get_metadata().await {
457 tool_context.set_session_id(metadata.id);
458 }
459 if let Some(requested) = args.name.as_deref() {
465 let name = requested.trim();
466 let name = if name.is_empty() { None } else { Some(name) };
467 if let Err(error) = session.set_name(name).await {
468 if args.verbose {
469 eprintln!("warning: could not set session name: {error}");
470 }
471 }
472 }
473 if let Some(js) = &js_extension_session {
474 let session_id = session
475 .get_metadata()
476 .await
477 .ok()
478 .map(|metadata| metadata.id);
479 let leaf_id = session.get_leaf_id().await.ok().flatten();
480 if let Some(session_id) = session_id {
481 let branch = session
482 .find_entries_on_branch(&EntryQuery::default(), &BranchBounds::default())
483 .await
484 .ok()
485 .unwrap_or_default();
486 let branch_json =
487 serde_json::to_value(&branch).unwrap_or_else(|_| serde_json::json!([]));
488 let runtime_context = serde_json::json!({
489 "session": {
490 "id": session_id,
491 "leafId": leaf_id,
492 "branch": branch_json,
493 "entries": branch_json.clone(),
494 },
495 });
496 if let Err(error) = js.set_runtime_context(runtime_context) {
497 if args.verbose {
498 eprintln!("warning: could not sync JS session context: {error}");
499 }
500 }
501 }
502 }
503
504 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
514 let base_prompt = match args.system_prompt.as_deref() {
515 Some(explicit) => explicit.to_string(),
516 None if project_trusted => {
517 match discover_system_prompt_file_with_packages(cwd, &package_resources) {
518 Some(path) => std::fs::read_to_string(&path)
519 .unwrap_or_else(|_| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
520 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
521 }
522 }
523 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
524 };
525
526 let mut append_texts: Vec<String> = Vec::new();
533 for extra in &args.append_system_prompt {
534 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
535 append_texts.push(text);
536 }
537 if args.append_system_prompt.is_empty() {
538 if let Some(path) = project_trusted
539 .then(|| discover_append_system_prompt_file_with_packages(cwd, &package_resources))
540 .flatten()
541 {
542 if let Ok(text) = std::fs::read_to_string(&path) {
543 append_texts.push(text);
544 }
545 }
546 }
547 let append_join = if append_texts.is_empty() {
548 None
549 } else {
550 Some(append_texts.join("\n\n"))
551 };
552
553 let agent_dir = crate::config::agent_dir().ok();
565
566 let discovered = extension_session
582 .snapshot_arc()
583 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
584 .unwrap_or_default();
585
586 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
587 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
588 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
589 if !args.no_skills {
590 let mut dirs = if args.dev_local_only {
591 project_skill_dirs(cwd)
592 } else if project_trusted {
593 skill_dirs(cwd)
594 } else {
595 global_skill_dirs()
596 };
597 dirs.extend(args.skill.iter().cloned());
598 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
599 if let Some(session) = &js_extension_session {
600 dirs.extend(session.resources.skill_paths.iter().cloned());
601 }
602 dirs.extend(package_resources.skill_dirs());
603 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
604 skills = result.skills;
605 skill_diags = result.diagnostics;
606 resource_diags.extend(result.resource_diagnostics);
607 }
608
609 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
610 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
611 if !args.no_prompt_templates {
612 let mut dirs = if args.dev_local_only {
613 project_prompt_template_dirs(cwd)
614 } else if project_trusted {
615 prompt_template_dirs(cwd)
616 } else {
617 global_prompt_template_dirs()
618 };
619 dirs.extend(args.prompt_template.iter().cloned());
620 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
621 if let Some(session) = &js_extension_session {
622 dirs.extend(session.resources.prompt_paths.iter().cloned());
623 }
624 dirs.extend(package_resources.prompt_dirs());
625 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
626 prompt_templates = result.prompt_templates;
627 prompt_diags = result.diagnostics;
628 resource_diags.extend(result.resource_diagnostics);
629 }
630
631 let context_block = if args.no_context_files || !project_trusted {
632 String::new()
633 } else {
634 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
639 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
640 format_project_context(&files)
641 };
642
643 if args.verbose {
645 for d in &package_resources.diagnostics {
646 eprintln!("warning: package {}: {}", d.spec, d.message);
647 }
648 for d in &skill_diags {
649 eprintln!(
650 "warning: skill {} ({}): {}",
651 d.path,
652 d.code.as_str(),
653 d.message
654 );
655 }
656 for d in &prompt_diags {
657 eprintln!(
658 "warning: prompt template {} ({}): {}",
659 d.path,
660 d.code.as_str(),
661 d.message
662 );
663 }
664 for d in &resource_diags {
666 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
667 eprintln!(
668 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
669 c.resource_type.as_str(),
670 c.name,
671 c.winner_path,
672 c.loser_path
673 );
674 }
675 }
676 }
677
678 let system_prompt = compose_system_prompt(
687 Some(&base_prompt),
688 &[], if context_block.is_empty() {
690 None
691 } else {
692 Some(&context_block)
693 },
694 append_join.as_deref(),
695 );
696
697 if args.debug_system_prompt {
706 eprintln!("=== --debug-system-prompt ===");
707 let base_src = if args.system_prompt.is_some() {
708 "--system-prompt"
709 } else if discover_system_prompt_file_with_packages(cwd, &package_resources).is_some() {
710 "SYSTEM.md"
711 } else {
712 "default"
713 };
714 eprintln!("[base source: {base_src}]");
715 eprintln!("--- base ---\n{base_prompt}");
716 if let Some(append) = append_join.as_deref() {
717 eprintln!("--- append ---\n{append}");
718 } else {
719 eprintln!("--- append: (none) ---");
720 }
721 if context_block.is_empty() {
722 eprintln!("--- context: (none) ---");
723 } else {
724 eprintln!("--- context ---{context_block}");
725 }
726 let visible_skills = skills
727 .iter()
728 .filter(|s| s.disable_model_invocation != Some(true))
729 .count();
730 eprintln!(
731 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
732 skills.len(),
733 visible_skills,
734 skills.len() - visible_skills
735 );
736 for s in &skills {
737 let hidden = if s.disable_model_invocation == Some(true) {
738 " [hidden]"
739 } else {
740 ""
741 };
742 eprintln!(" {}{hidden} — {}", s.name, s.description);
743 }
744 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
745 for t in &prompt_templates {
746 eprintln!(" /{}", t.name);
747 }
748 eprintln!(
752 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
753 discovered.skill_paths.len(),
754 discovered.prompt_paths.len(),
755 discovered.theme_paths.len(),
756 );
757 for p in &discovered.skill_paths {
758 eprintln!(" skill: {p}");
759 }
760 for p in &discovered.prompt_paths {
761 eprintln!(" prompt: {p}");
762 }
763 eprintln!(
764 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
765 );
766 eprintln!("=== end --debug-system-prompt ===");
767 }
768
769 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
774 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
775 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
779
780 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
789 Some(snapshot) => {
790 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
791 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
792 }
793 None => broadcast_emitter,
794 };
795
796 let saved_settings = crate::settings::load_settings().unwrap_or_default();
800
801 if let Some(proxy) = &saved_settings.http_proxy {
806 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
807 if std::env::var_os(key).is_none() {
808 std::env::set_var(key, proxy);
809 }
810 }
811 }
812
813 let options = AgentHarnessOptions {
814 model: resolved.model.clone(),
815 thinking_level: resolved.thinking_level,
816 active_tool_names: active,
817 tools,
818 system_prompt: Some(system_prompt),
819 resources: AgentHarnessResources {
820 skills: if skills.is_empty() {
821 None
822 } else {
823 Some(skills)
824 },
825 prompt_templates: if prompt_templates.is_empty() {
826 None
827 } else {
828 Some(prompt_templates)
829 },
830 },
831 allow_existing_session: matches!(
834 selection,
835 SessionSelection::Latest
836 | SessionSelection::ById { .. }
837 | SessionSelection::ByExactId { .. }
838 | SessionSelection::Fork { .. }
839 ),
840 stream_options: AgentHarnessStreamOptions {
841 timeout: args.timeout,
842 ..Default::default()
843 },
844 retry: saved_settings.retry_policy(),
849 compaction: saved_settings.compaction_settings(),
850 steering_mode: saved_settings.steering_mode(),
851 follow_up_mode: saved_settings.follow_up_mode(),
852 tool_execution: HarnessToolExecution::default(),
853 drive: DrivingMode::default(),
854 session,
855 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
863 to_provider_messages: None,
864 entry_projectors: Default::default(),
865 agent_emitter: Some(emitter),
866 before_tool_call: None,
872 after_tool_call: None,
873 transform_context: None,
874 entry_transforms: Vec::new(),
875 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
881 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
882 };
883
884 let harness = match AgentHarness::create(options).await {
885 Ok(h) => {
886 crate::extensions_actions::HarnessActionHost::set_harness(
889 &harness_cell,
890 Arc::new(h.clone()),
891 );
892 h
893 }
894 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
895 };
896
897 {
905 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
906 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
907 Ok(reports) => {
908 for (lane, report) in reports {
909 if report.is_clean() {
910 continue;
911 }
912 if report.is_corrupt() {
913 eprintln!(
914 "warning: session lane '{lane}' is corrupt ({} open operations); \
915 refusing to auto-repair — inspect the session file",
916 report.open_operations.len()
917 );
918 continue;
919 }
920 for decision in runtime.reconcile(&report) {
921 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
922 if args.verbose {
923 eprintln!(
924 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
925 );
926 }
927 }
928 }
929 runtime.emit_recovery_events(harness.events(), &report);
930 }
931 }
932 Err(e) => {
933 if args.verbose {
934 eprintln!("warning: session recovery sweep failed: {e}");
935 }
936 }
937 }
938 }
939
940 let reload_context = ReloadContext {
947 extension_session: Arc::new(Mutex::new(extension_session)),
948 js_extension_session: js_extension_session.clone(),
949 package_resources: Arc::new(package_resources.clone()),
950 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
951 tool_context,
952 catalog,
953 gateway: resolved.provider.clone(),
954 runtime: runtime.clone(),
955 cwd: cwd.to_path_buf(),
956 project_trusted,
957 args: args.clone(),
958 resolved_model: resolved.model.clone(),
959 broadcast: broadcast_for_context,
960 mailbox: reload_mailbox,
961 ui_dialog: ui_dialog_mailbox,
962 ext_status: ext_status_mailbox,
963 dev_extension: None,
964 };
965
966 Ok((harness, event_rx, reload_context))
967}
968
969pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
1014
1015pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
1020
1021#[derive(Clone)]
1029pub struct ReloadContext {
1030 pub extension_session: ExtensionSessionCell,
1032 pub tool_context: rpi_extensions::ToolCallContext,
1037 pub js_extension_session: Option<crate::js_extensions::JsExtensionSession>,
1039 pub package_resources: Arc<crate::packages::PackageResources>,
1043 pub action_bridge: ActionBridgeCell,
1045 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
1048 pub catalog: Vec<rpi_ai::Model>,
1053 pub gateway: Arc<dyn Provider>,
1056 pub runtime: tokio::runtime::Handle,
1060 pub cwd: PathBuf,
1062 pub project_trusted: bool,
1066 pub args: Args,
1072 pub resolved_model: rpi_ai::Model,
1075 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
1081 pub mailbox: rpi_extensions::ReloadMailbox,
1088 pub ui_dialog: rpi_extensions::UiDialogMailbox,
1094 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
1098}
1099
1100pub async fn dispatch_session_event_async(
1115 reload_context: &ReloadContext,
1116 tag: rpi_plugin_sdk::EventTag,
1117) -> Option<String> {
1118 let (snapshot, keepalive) = {
1124 let session = reload_context.extension_session.lock().unwrap();
1125 (session.snapshot_arc(), session.keepalive())
1126 };
1127 match snapshot {
1128 Some(snapshot) => {
1129 let _keepalive = keepalive;
1130 rpi_extensions::dispatch_lifecycle_event(&snapshot, tag).await
1131 }
1132 None => None,
1133 }
1134}
1135
1136pub struct ReloadOutcome {
1139 pub summary: String,
1142 pub had_warnings: bool,
1144 pub details: Vec<String>,
1149 pub had_errors: bool,
1153}
1154
1155struct PreparedReloadInputs {
1156 package_resources: crate::packages::PackageResources,
1157 extension_dirs: Vec<PathBuf>,
1158 skill_base_dirs: Vec<PathBuf>,
1159 prompt_base_dirs: Vec<PathBuf>,
1160}
1161
1162fn append_reload_resource_paths(
1167 mut paths: Vec<PathBuf>,
1168 explicit: &[PathBuf],
1169 discovered: &[String],
1170 js_paths: &[PathBuf],
1171 package_paths: &[PathBuf],
1172) -> Vec<PathBuf> {
1173 paths.extend(explicit.iter().cloned());
1174 paths.extend(discovered.iter().map(PathBuf::from));
1175 paths.extend(js_paths.iter().cloned());
1176 paths.extend(package_paths.iter().cloned());
1177 paths
1178}
1179
1180pub async fn reload_extension_resources(
1192 harness: &AgentHarness,
1193 ctx: &ReloadContext,
1194) -> ReloadOutcome {
1195 reload_extension_resources_inner(harness, ctx, || {}).await
1196}
1197
1198async fn reload_extension_resources_inner<F>(
1199 harness: &AgentHarness,
1200 ctx: &ReloadContext,
1201 after_prepare: F,
1202) -> ReloadOutcome
1203where
1204 F: FnOnce() + Send,
1205{
1206 let mut effective_args = ctx.args.clone();
1211 if let Some(dev) = &ctx.dev_extension {
1212 if let Err(error) = dev
1213 .rebuild()
1214 .and_then(|_| dev.apply_to_args(&mut effective_args))
1215 {
1216 return ReloadOutcome {
1217 summary: format!(
1218 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1219 dev.package_name()
1220 ),
1221 had_warnings: true,
1222 details: Vec::new(),
1223 had_errors: true,
1224 };
1225 }
1226 }
1227 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1228 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1229 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1230 Ok(prepared) => prepared,
1231 Err(error) => {
1232 return ReloadOutcome {
1233 summary: format!(
1234 "Settings reload failed: {error}. Keeping the currently loaded resources."
1235 ),
1236 had_warnings: true,
1237 details: Vec::new(),
1238 had_errors: true,
1239 };
1240 }
1241 };
1242 after_prepare();
1243 let PreparedReloadInputs {
1244 package_resources,
1245 extension_dirs,
1246 skill_base_dirs,
1247 prompt_base_dirs,
1248 } = prepared;
1249 let mut warnings = false;
1250
1251 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1258 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1259 Some(b) => b.clone_host(),
1260 None => {
1261 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1264 ctx.catalog.clone(),
1265 ctx.cwd.clone(),
1266 ctx.runtime.clone(),
1267 ctx.args.unknown_flags.clone(),
1268 );
1269 crate::extensions_actions::HarnessActionHost::set_harness(
1270 &_cell,
1271 Arc::new(harness.clone()),
1272 );
1273 Arc::new(action_host)
1274 }
1275 };
1276
1277 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1278 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1279 ctx.runtime.clone(),
1280 host,
1281 reload_cb,
1282 ctx.ui_dialog.clone(),
1283 ctx.ext_status.clone(),
1284 );
1285
1286 let extension_session = if effective_args.no_extensions {
1287 rpi_extensions::ExtensionSession::none()
1288 } else {
1289 load_extensions_from_dirs(
1290 &effective_args,
1291 &extension_dirs,
1292 Some(Arc::clone(&fresh_bridge)),
1293 )
1294 };
1295 if extension_session.is_empty() && !effective_args.no_extensions {
1296 }
1299 if effective_args.verbose {
1300 if let Some(s) = extension_session.summary() {
1301 eprintln!("reload: {s}");
1302 }
1303 report_deferred_renderers(&extension_session);
1304 }
1305
1306 {
1320 let mut session_guard = ctx.extension_session.lock().unwrap();
1321 let old_session = std::mem::replace(
1322 &mut *session_guard,
1323 rpi_extensions::ExtensionSession::none(),
1324 );
1325 if let Some(old_snap) = old_session.snapshot_arc() {
1326 old_snap.active_flag().store(false, Ordering::SeqCst);
1330 }
1331 }
1335 if let Some(old_b) = old_bridge {
1336 old_b.invalidate();
1337 }
1338
1339 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1342 *ctx.extension_session.lock().unwrap() = extension_session.clone();
1343
1344 let discovered = extension_session
1346 .snapshot_arc()
1347 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1348 .unwrap_or_default();
1349
1350 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1352 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1353
1354 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1355 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1356 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1357 if !effective_args.no_skills {
1358 let js_paths: &[PathBuf] = ctx
1362 .js_extension_session
1363 .as_ref()
1364 .map(|js| js.resources.skill_paths.as_slice())
1365 .unwrap_or(&[]);
1366 let package_paths = package_resources.skill_dirs();
1367 let dirs = append_reload_resource_paths(
1368 skill_base_dirs,
1369 &effective_args.skill,
1370 &discovered.skill_paths,
1371 js_paths,
1372 &package_paths,
1373 );
1374 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1375 skills = result.skills;
1376 skill_diags = result.diagnostics;
1377 resource_diags.extend(result.resource_diagnostics);
1378 }
1379
1380 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1381 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1382 if !effective_args.no_prompt_templates {
1383 let js_paths: &[PathBuf] = ctx
1384 .js_extension_session
1385 .as_ref()
1386 .map(|js| js.resources.prompt_paths.as_slice())
1387 .unwrap_or(&[]);
1388 let package_paths = package_resources.prompt_dirs();
1389 let dirs = append_reload_resource_paths(
1390 prompt_base_dirs,
1391 &effective_args.prompt_template,
1392 &discovered.prompt_paths,
1393 js_paths,
1394 &package_paths,
1395 );
1396 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1397 prompt_templates = result.prompt_templates;
1398 prompt_diags = result.diagnostics;
1399 resource_diags.extend(result.resource_diagnostics);
1400 }
1401
1402 let context_block = if effective_args.no_context_files {
1403 String::new()
1404 } else {
1405 let agent_dir = crate::config::agent_dir().ok();
1406 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1407 let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
1408 format_project_context(&files)
1409 };
1410
1411 let mut details: Vec<String> = Vec::new();
1412 if !skill_diags.is_empty()
1413 || !prompt_diags.is_empty()
1414 || !package_resources.diagnostics.is_empty()
1415 || !resource_diags.is_empty()
1416 {
1417 warnings = true;
1418 for d in &package_resources.diagnostics {
1423 details.push(format!("warning: package {}: {}", d.spec, d.message));
1424 }
1425 for d in &skill_diags {
1426 details.push(format!(
1427 "warning: skill {} ({}): {}",
1428 d.path,
1429 d.code.as_str(),
1430 d.message
1431 ));
1432 }
1433 for d in &prompt_diags {
1434 details.push(format!(
1435 "warning: prompt template {} ({}): {}",
1436 d.path,
1437 d.code.as_str(),
1438 d.message
1439 ));
1440 }
1441 for d in &resource_diags {
1443 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1444 details.push(format!(
1445 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1446 c.resource_type.as_str(),
1447 c.name,
1448 c.winner_path,
1449 c.loser_path
1450 ));
1451 }
1452 }
1453 }
1454
1455 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1460 let base_prompt = match effective_args.system_prompt.as_deref() {
1461 Some(explicit) => explicit.to_string(),
1462 None => match discover_system_prompt_file_with_packages(&ctx.cwd, &package_resources) {
1463 Some(path) => std::fs::read_to_string(&path)
1464 .unwrap_or_else(|_| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
1465 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1466 },
1467 };
1468 let mut append_texts: Vec<String> = Vec::new();
1469 for extra in &effective_args.append_system_prompt {
1470 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
1471 append_texts.push(text);
1472 }
1473 if effective_args.append_system_prompt.is_empty() {
1474 if let Some(path) =
1475 discover_append_system_prompt_file_with_packages(&ctx.cwd, &package_resources)
1476 {
1477 if let Ok(text) = std::fs::read_to_string(&path) {
1478 append_texts.push(text);
1479 }
1480 }
1481 }
1482 let append_join = if append_texts.is_empty() {
1483 None
1484 } else {
1485 Some(append_texts.join("\n\n"))
1486 };
1487 let system_prompt = compose_system_prompt(
1488 Some(&base_prompt),
1489 &[],
1490 if context_block.is_empty() {
1491 None
1492 } else {
1493 Some(&context_block)
1494 },
1495 append_join.as_deref(),
1496 );
1497
1498 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1500 Some(snapshot) => {
1501 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1502 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1503 }
1504 None => ctx.broadcast.clone(),
1505 };
1506
1507 let resources = AgentHarnessResources {
1509 skills: if skills.is_empty() {
1510 None
1511 } else {
1512 Some(skills.clone())
1513 },
1514 prompt_templates: if prompt_templates.is_empty() {
1515 None
1516 } else {
1517 Some(prompt_templates.clone())
1518 },
1519 };
1520 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1521 let _ = harness.set_resources(resources).await;
1522 let _ = harness.set_agent_emitter(Some(emitter)).await;
1523 let _ = harness
1524 .set_models(build_models_with_extensions_for_reload(
1525 &ctx.gateway,
1526 &extension_session,
1527 ctx.runtime.clone(),
1528 ))
1529 .await;
1530 let _ = harness
1531 .set_provider_hooks(
1532 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1533 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1534 )
1535 .await;
1536
1537 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1541 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1542 let mut tools = build_tools(&tool_ctx, &effective_args);
1543 merge_extension_tools(&mut tools, &extension_session, &effective_args, &ctx.tool_context);
1544 if let Some(js) = &ctx.js_extension_session {
1545 merge_js_extension_tools(&mut tools, js, &effective_args);
1546 }
1547 let mut active = active_tool_names(&tools, &effective_args);
1548 if let Some(js) = &ctx.js_extension_session {
1549 let js_names = js.tool_names();
1550 if let Some(js_active) = js.active_tools() {
1551 active.retain(|name| {
1552 tool_name_allowed(name, &effective_args)
1553 && !js_names.iter().any(|js_name| js_name == name)
1554 });
1555 active.extend(js_active.into_iter().filter(|name| {
1556 js_names.iter().any(|js_name| js_name == name)
1557 && tool_name_allowed(name, &effective_args)
1558 }));
1559 }
1560 }
1561 active = filter_active_tool_names(active, &effective_args);
1562 let _ = harness.set_tools(tools, Some(active)).await;
1563
1564 let summary = format!(
1565 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1566 extension_session.loaded_paths().len(),
1567 skills.len(),
1568 prompt_templates.len(),
1569 );
1570 ReloadOutcome {
1571 summary,
1572 had_warnings: warnings,
1573 details,
1574 had_errors: false,
1577 }
1578}
1579
1580#[derive(Clone, Debug, PartialEq, Eq)]
1581struct ReloadSettingsFields {
1582 packages: Option<Vec<crate::settings::PackageSetting>>,
1583 npm_command: Option<Vec<String>>,
1584 skill_dirs: Option<Vec<String>>,
1585 prompt_dirs: Option<Vec<String>>,
1586 extension_dirs: Option<Vec<String>>,
1587}
1588
1589impl From<crate::settings::Settings> for ReloadSettingsFields {
1590 fn from(settings: crate::settings::Settings) -> Self {
1591 Self {
1592 packages: settings.packages,
1593 npm_command: settings.npm_command,
1594 skill_dirs: settings.skill_dirs,
1595 prompt_dirs: settings.prompt_dirs,
1596 extension_dirs: settings.extension_dirs,
1597 }
1598 }
1599}
1600
1601#[derive(Clone, Debug, PartialEq, Eq)]
1602struct ReloadSettingsSnapshot {
1603 global: ReloadSettingsFields,
1604 project: Option<ReloadSettingsFields>,
1605}
1606
1607fn reload_reads_settings(args: &Args) -> bool {
1608 !args.dev_local_only
1609 && (should_load_js_packages(args)
1610 || !args.no_extensions
1611 || !args.no_skills
1612 || !args.no_prompt_templates)
1613}
1614
1615fn load_reload_settings_snapshot(
1619 args: &Args,
1620 cwd: &Path,
1621 project_trusted: bool,
1622) -> Result<Option<ReloadSettingsSnapshot>, String> {
1623 if !reload_reads_settings(args) {
1624 return Ok(None);
1625 }
1626 let global = crate::settings::load_settings()
1627 .map_err(|error| format!("could not load global settings: {error}"))?
1628 .into();
1629 let project = if project_trusted {
1630 crate::settings::load_active_project_settings(cwd)
1631 .map_err(|error| format!("could not load project settings: {error}"))?
1632 .map(|(_, settings)| settings.into())
1633 } else {
1634 None
1635 };
1636 Ok(Some(ReloadSettingsSnapshot { global, project }))
1637}
1638
1639#[cfg(test)]
1642fn validate_settings_for_reload(
1643 args: &Args,
1644 cwd: &Path,
1645 project_trusted: bool,
1646) -> Result<(), String> {
1647 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1648}
1649
1650fn prepare_reload_inputs(
1651 args: &Args,
1652 cwd: &Path,
1653 project_trusted: bool,
1654) -> Result<PreparedReloadInputs, String> {
1655 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1656}
1657
1658fn prepare_reload_inputs_inner<F>(
1659 args: &Args,
1660 cwd: &Path,
1661 project_trusted: bool,
1662 before_verify: F,
1663) -> Result<PreparedReloadInputs, String>
1664where
1665 F: FnOnce(),
1666{
1667 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1668
1669 let package_resources = if args.dev_local_only {
1670 crate::packages::PackageResources::default()
1671 } else {
1672 package_resources_for(args, cwd, project_trusted)
1673 };
1674
1675 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1676 Vec::new()
1677 } else if project_trusted {
1678 extension_dirs(cwd)
1679 } else {
1680 global_extension_dirs()
1681 };
1682 if !args.no_extensions {
1683 extension_dirs.extend(args.extensions_dir.iter().cloned());
1684 }
1685
1686 let skill_base_dirs = if args.no_skills {
1687 Vec::new()
1688 } else if args.dev_local_only {
1689 project_skill_dirs(cwd)
1690 } else if project_trusted {
1691 skill_dirs(cwd)
1692 } else {
1693 global_skill_dirs()
1694 };
1695
1696 let prompt_base_dirs = if args.no_prompt_templates {
1697 Vec::new()
1698 } else if args.dev_local_only {
1699 project_prompt_template_dirs(cwd)
1700 } else if project_trusted {
1701 prompt_template_dirs(cwd)
1702 } else {
1703 global_prompt_template_dirs()
1704 };
1705
1706 before_verify();
1707 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1708 if settings_before != settings_after {
1709 return Err(
1710 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1711 );
1712 }
1713
1714 Ok(PreparedReloadInputs {
1715 package_resources,
1716 extension_dirs,
1717 skill_base_dirs,
1718 prompt_base_dirs,
1719 })
1720}
1721
1722fn build_models_with_extensions_for_reload(
1727 gateway: &Arc<dyn Provider>,
1728 extension_session: &ExtensionSession,
1729 runtime: tokio::runtime::Handle,
1730) -> Vec<Arc<dyn Provider>> {
1731 let mut models: Vec<Arc<dyn Provider>> = vec![gateway.clone()];
1732 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1733 models.extend(pluggable);
1734 models
1735}
1736
1737fn report_deferred_renderers(session: &ExtensionSession) {
1741 let Some(snap) = session.snapshot_arc() else {
1742 return;
1743 };
1744 let all = snap.renderers();
1745 let markdown = all
1746 .iter()
1747 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1748 .count();
1749 let message = all
1750 .iter()
1751 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1752 .count();
1753 let entry = all
1754 .iter()
1755 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1756 .count();
1757 if markdown + message + entry == 0 {
1758 return;
1759 }
1760 eprintln!(
1761 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1762 markdown, message, entry
1763 );
1764}
1765
1766#[derive(Debug, thiserror::Error)]
1768pub enum BuildError {
1769 #[error("Could not create the session directory: {0}")]
1770 SessionDir(String),
1771 #[error("Could not open the saved session: {0}")]
1772 SessionOpen(String),
1773 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1774 SessionNotFound { requested: String, dir: String },
1775 #[error("Could not build the harness: {0}")]
1776 HarnessCreate(String),
1777}
1778
1779fn build_models_with_extensions(
1789 resolved: &ResolvedModel,
1790 extension_session: &ExtensionSession,
1791 runtime: tokio::runtime::Handle,
1792) -> Vec<Arc<dyn Provider>> {
1793 let mut models: Vec<Arc<dyn Provider>> = vec![resolved.provider.clone() as Arc<dyn Provider>];
1794 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1795 models.extend(pluggable);
1796 models
1797}
1798
1799pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1802 if let Some(override_value) = args.trust_override {
1803 return override_value;
1804 }
1805 crate::config::project_trust_decision(cwd)
1806 .ok()
1807 .flatten()
1808 .unwrap_or(true)
1809}
1810
1811fn load_extensions(
1819 args: &Args,
1820 cwd: &Path,
1821 project_trusted: bool,
1822 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1823) -> ExtensionSession {
1824 let mut dirs = if args.dev_local_only {
1825 Vec::new()
1826 } else if project_trusted {
1827 extension_dirs(cwd)
1828 } else {
1829 global_extension_dirs()
1830 };
1831 dirs.extend(args.extensions_dir.iter().cloned());
1832 load_extensions_from_dirs(args, &dirs, action_bridge)
1833}
1834
1835fn load_extensions_from_dirs(
1836 args: &Args,
1837 dirs: &[PathBuf],
1838 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1839) -> ExtensionSession {
1840 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1841 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1848}
1849
1850fn js_extension_paths(
1851 args: &Args,
1852 cwd: &Path,
1853 project_trusted: bool,
1854 packages: &crate::packages::PackageResources,
1855) -> Vec<PathBuf> {
1856 if args.dev_local_only {
1857 return Vec::new();
1858 }
1859 let mut paths = packages.extension_paths();
1860 let discovered_dirs = if project_trusted {
1861 extension_dirs(cwd)
1862 } else {
1863 global_extension_dirs()
1864 };
1865 for dir in discovered_dirs {
1866 if let Ok(entries) = std::fs::read_dir(dir) {
1867 paths.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
1868 matches!(
1869 path.extension()
1870 .and_then(|ext| ext.to_str())
1871 .map(|ext| ext.to_ascii_lowercase())
1872 .as_deref(),
1873 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1874 )
1875 }));
1876 }
1877 }
1878 paths.extend(
1879 args.extension
1880 .iter()
1881 .filter(|path| {
1882 matches!(
1883 path.extension()
1884 .and_then(|ext| ext.to_str())
1885 .map(|ext| ext.to_ascii_lowercase())
1886 .as_deref(),
1887 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1888 )
1889 })
1890 .cloned(),
1891 );
1892 paths
1893}
1894
1895fn merge_extension_tools(
1901 tools: &mut Vec<HarnessTool>,
1902 session: &ExtensionSession,
1903 args: &Args,
1904 tool_context: &rpi_extensions::ToolCallContext,
1905) {
1906 let Some(snapshot) = session.snapshot() else {
1907 return;
1908 };
1909 for et in snapshot.tools() {
1910 let name = &et.tool.name;
1911 if !tool_name_allowed(name, args) {
1912 continue;
1913 }
1914 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
1915 .with_context(tool_context.clone());
1916 let harness_tool = HarnessTool::new(Arc::new(adapter));
1917 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1918 Some(slot) => *slot = harness_tool,
1919 None => tools.push(harness_tool),
1920 }
1921 }
1922}
1923
1924fn merge_js_extension_tools(
1925 tools: &mut Vec<HarnessTool>,
1926 session: &crate::js_extensions::JsExtensionSession,
1927 args: &Args,
1928) {
1929 for adapter in session.tools() {
1930 let name = adapter.schema().name.clone();
1931 if !tool_name_allowed(&name, args) {
1932 continue;
1933 }
1934 let harness_tool = HarnessTool::new(Arc::new(adapter));
1935 match tools
1936 .iter_mut()
1937 .find(|tool| tool.tool.schema().name == name)
1938 {
1939 Some(slot) => *slot = harness_tool,
1940 None => tools.push(harness_tool),
1941 }
1942 }
1943}
1944
1945pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1952 use rpi_tools::tools::bash::BashToolOptions;
1953 let default = std::env::var("RPI_BASH_TIMEOUT")
1954 .ok()
1955 .and_then(|v| v.parse::<f64>().ok())
1956 .unwrap_or(120.0);
1957 let settings = crate::settings::load_settings().unwrap_or_default();
1963 BashToolOptions {
1964 command_prefix: settings.shell_command_prefix.clone(),
1965 default_timeout: Some(default),
1966 }
1967}
1968
1969fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1970 if args.no_tools {
1971 return Vec::new();
1972 }
1973 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1982 (
1983 "read",
1984 HarnessTool::new(create_read_tool(ctx, None)).with_replay(ToolReplay::Safe),
1985 ),
1986 (
1987 "bash",
1988 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1989 ),
1990 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1991 ("write", HarnessTool::new(create_write_tool(ctx))),
1992 (
1993 "docs",
1994 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
1995 ),
1996 ];
1997
1998 #[cfg(target_os = "windows")]
2000 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
2001
2002 if args.tools.is_none() {
2004 if let Ok(settings) = crate::settings::load_settings() {
2005 if let Some(default_tools) = &settings.default_tools {
2006 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
2007 }
2008 }
2009 }
2010
2011 if args.no_builtin_tools {
2015 all.clear();
2016 }
2017
2018 if let Some(allow) = &args.tools {
2020 all.retain(|(name, _)| allow.iter().any(|a| a == name));
2021 }
2022 if let Some(deny) = &args.exclude_tools {
2024 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
2025 }
2026
2027 all.into_iter().map(|(_, t)| t).collect()
2028}
2029
2030fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
2034 filter_active_tool_names(
2035 tools.iter().map(|tool| tool.tool.schema().name.clone()),
2036 args,
2037 )
2038}
2039
2040pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
2044 if args.no_tools {
2045 return false;
2046 }
2047 if args
2048 .tools
2049 .as_ref()
2050 .is_some_and(|allow| !allow.iter().any(|value| value == name))
2051 {
2052 return false;
2053 }
2054 if args
2055 .exclude_tools
2056 .as_ref()
2057 .is_some_and(|deny| deny.iter().any(|value| value == name))
2058 {
2059 return false;
2060 }
2061 true
2062}
2063
2064pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
2065where
2066 I: IntoIterator<Item = String>,
2067{
2068 names
2069 .into_iter()
2070 .filter(|name| tool_name_allowed(name, args))
2071 .collect()
2072}
2073
2074async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2076 match selection {
2077 SessionSelection::Ephemeral => Ok(ephemeral_session()),
2078 SessionSelection::New { dir, .. } => {
2079 std::fs::create_dir_all(dir)
2082 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2083 let session = create_jsonl_session(dir, cwd)
2084 .await
2085 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2086 Ok(session)
2087 }
2088 SessionSelection::Latest
2089 | SessionSelection::ById { .. }
2090 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2091 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
2092 }
2093}
2094
2095async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2101 match selection {
2105 SessionSelection::Latest => {
2106 let metas = list_session_metadata(cwd).await?;
2107 let Some(meta) = first_resumable_session(&metas) else {
2108 return Err(BuildError::SessionNotFound {
2109 requested: "the most recent session".to_string(),
2110 dir: default_session_dir(Path::new(cwd)).display().to_string(),
2111 });
2112 };
2113 open_session(meta, cwd).await
2114 }
2115 SessionSelection::ById { id } => open_session_by_id(id, cwd).await.map_err(|e| match e {
2116 OpenError::NotFound { requested } => BuildError::SessionNotFound {
2117 requested,
2118 dir: default_session_dir(Path::new(cwd)).display().to_string(),
2119 },
2120 OpenError::Other(msg) => BuildError::SessionOpen(msg),
2121 }),
2122 SessionSelection::ByExactId { id } => {
2123 let metas = list_session_metadata(cwd).await?;
2126 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
2127 return open_session(meta, cwd).await;
2128 }
2129 let dir = default_session_dir(Path::new(cwd));
2130 std::fs::create_dir_all(&dir)
2131 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2132 create_jsonl_session_with_id(&dir, cwd, Some(id.clone()))
2133 .await
2134 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2135 }
2136 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
2137 }
2138}
2139
2140async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
2143 use rpi_harness::session::jsonl::{
2144 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2145 };
2146 use rpi_harness::session::types::{ForkOptions, SessionStorage};
2147 use rpi_tools::FileSystem;
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 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2153 let fs: Arc<dyn FileSystem> = env.clone();
2154 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2155 fs: fs.clone(),
2156 sessions_root: dir.to_string_lossy().into_owned(),
2157 clock: Arc::new(SystemClock),
2158 ids: Arc::new(DefaultIdGenerator::new()),
2159 });
2160 let metas = repo
2161 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2162 .await
2163 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2164 let source_meta = metas
2165 .iter()
2166 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2167 .ok_or_else(|| BuildError::SessionNotFound {
2168 requested: format!("--fork {source}"),
2169 dir: dir.display().to_string(),
2170 })?;
2171 let fork_storage = repo
2172 .fork_typed(
2173 source_meta,
2174 &JsonlSessionCreateOptions {
2175 id: None,
2176 parent_session_id: Some(source_meta.id.clone()),
2177 cwd: cwd.to_string(),
2178 metadata: None,
2179 },
2180 &ForkOptions::default(),
2181 )
2182 .await
2183 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2184 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
2185 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2186 Ok(Session::new(storage_arc, None))
2187}
2188
2189pub enum OpenError {
2192 NotFound { requested: String },
2194 Other(String),
2196}
2197
2198impl std::fmt::Display for OpenError {
2199 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2200 match self {
2201 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2202 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
2207 }
2208 }
2209}
2210
2211pub async fn list_session_metadata(
2214 cwd: &str,
2215) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2216 use rpi_harness::session::jsonl::{
2217 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2218 };
2219 use rpi_tools::FileSystem;
2220
2221 let dir = default_session_dir(Path::new(cwd));
2222 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2223 let fs: Arc<dyn FileSystem> = env.clone();
2224 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2225 fs: fs.clone(),
2226 sessions_root: dir.to_string_lossy().into_owned(),
2227 clock: Arc::new(SystemClock),
2228 ids: Arc::new(DefaultIdGenerator::new()),
2229 });
2230 repo.list_typed(&JsonlSessionListOptions::default())
2231 .await
2232 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2233}
2234
2235#[derive(Debug, Clone, Default, PartialEq, Eq)]
2251pub struct SessionSummary {
2252 pub name: Option<String>,
2254 pub preview: Option<String>,
2256 pub empty: bool,
2258 pub bytes: u64,
2260}
2261
2262const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
2264const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
2266const SUMMARY_PREVIEW_CHARS: usize = 80;
2268
2269pub fn summarize_session_file(path: &Path) -> SessionSummary {
2273 use std::io::{Read, Seek, SeekFrom};
2274
2275 let Ok(mut file) = std::fs::File::open(path) else {
2276 return SessionSummary::default();
2277 };
2278 let Ok(size) = file.metadata().map(|m| m.len()) else {
2279 return SessionSummary::default();
2280 };
2281 if size == 0 {
2282 return SessionSummary {
2283 name: None,
2284 preview: None,
2285 empty: true,
2286 bytes: 0,
2287 };
2288 }
2289
2290 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2293 let head_len = file.read(&mut head).unwrap_or(0);
2294 head.truncate(head_len);
2295 let head = String::from_utf8_lossy(&head);
2296 let mut lines: Vec<&str> = head.lines().collect();
2297 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2298 let empty = (size as usize) <= header_len + 1;
2300 if (head_len as u64) < size && !lines.is_empty() {
2303 lines.pop();
2304 }
2305
2306 let mut summary = SessionSummary {
2307 name: None,
2308 preview: None,
2309 empty,
2310 bytes: size,
2311 };
2312 for line in lines {
2313 apply_summary_line(line, &mut summary);
2314 }
2315
2316 if size > SUMMARY_HEAD_BYTES
2320 && file
2321 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2322 .is_ok()
2323 {
2324 let mut tail = Vec::new();
2325 if file.read_to_end(&mut tail).is_ok() {
2326 let tail = String::from_utf8_lossy(&tail);
2327 let mut tail_lines = tail.lines();
2328 tail_lines.next();
2330 for line in tail_lines {
2331 if let Some(name) = name_from_line(line) {
2332 summary.name = name;
2333 }
2334 }
2335 }
2336 }
2337 summary
2338}
2339
2340fn name_from_line(line: &str) -> Option<Option<String>> {
2343 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2344 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2345 return None;
2346 }
2347 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2348 return None;
2349 }
2350 Some(match value.get("name") {
2351 Some(serde_json::Value::String(name)) => Some(name.clone()),
2352 _ => None,
2353 })
2354}
2355
2356fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2359 if let Some(name) = name_from_line(line) {
2360 summary.name = name;
2361 return;
2362 }
2363 if summary.preview.is_some() {
2364 return;
2365 }
2366 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2367 return;
2368 };
2369 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2370 return;
2371 }
2372 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2373 return;
2374 }
2375 let Some(message) = value.get("message") else {
2376 return;
2377 };
2378 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2379 return;
2380 }
2381 let text = match message.get("content") {
2382 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2383 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2384 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2385 .then(|| block.get("text").and_then(|v| v.as_str()))
2386 .flatten()
2387 }),
2388 _ => None,
2389 };
2390 let Some(text) = text else { return };
2391 let collapsed = text
2392 .lines()
2393 .map(str::trim)
2394 .filter(|line| !line.is_empty())
2395 .collect::<Vec<_>>()
2396 .join(" ");
2397 if collapsed.is_empty() {
2398 return;
2399 }
2400 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2401}
2402
2403pub fn session_display_label(summary: &SessionSummary) -> String {
2407 if let Some(name) = summary.name.as_deref().map(str::trim) {
2408 if !name.is_empty() {
2409 return name.to_string();
2410 }
2411 }
2412 match summary.preview.as_deref().map(str::trim) {
2413 Some(preview) if !preview.is_empty() => preview.to_string(),
2414 _ => "(no messages)".to_string(),
2415 }
2416}
2417
2418pub fn format_session_bytes(bytes: u64) -> String {
2421 if bytes >= 1_048_576 {
2422 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2423 } else if bytes >= 1024 {
2424 format!("{}KB", bytes / 1024)
2425 } else {
2426 format!("{bytes}B")
2427 }
2428}
2429
2430pub fn short_session_id(id: &str) -> &str {
2433 &id[..id.len().min(8)]
2434}
2435
2436pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2445 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2446 let mut visit = |path: PathBuf| {
2447 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2448 return;
2449 }
2450 let modified = std::fs::metadata(&path)
2451 .and_then(|m| m.modified())
2452 .unwrap_or(std::time::UNIX_EPOCH);
2453 files.push((path, modified));
2454 };
2455 if let Ok(entries) = std::fs::read_dir(dir) {
2456 for entry in entries.flatten() {
2457 let path = entry.path();
2458 if path.is_dir() {
2459 if let Ok(inner) = std::fs::read_dir(&path) {
2460 for child in inner.flatten() {
2461 visit(child.path());
2462 }
2463 }
2464 } else {
2465 visit(path);
2466 }
2467 }
2468 }
2469 files.sort_by(|a, b| b.1.cmp(&a.1));
2470 files.into_iter().map(|(path, _)| path).collect()
2471}
2472
2473fn first_resumable_session<'a>(
2478 metas: &'a [rpi_harness::session::jsonl::JsonlSessionMetadata],
2479) -> Option<&'a rpi_harness::session::jsonl::JsonlSessionMetadata> {
2480 metas
2481 .iter()
2482 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2483 .or_else(|| metas.first())
2484}
2485
2486pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2490 let metas = list_session_metadata(cwd)
2491 .await
2492 .map_err(|e| OpenError::Other(e.to_string()))?;
2493 let Some(meta) = metas
2494 .iter()
2495 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2496 else {
2497 return Err(OpenError::NotFound {
2498 requested: format!("session {id}"),
2499 });
2500 };
2501 open_session(meta, cwd).await.map_err(|e| {
2502 OpenError::Other(match e {
2503 BuildError::SessionOpen(reason) => reason,
2507 other => other.to_string(),
2508 })
2509 })
2510}
2511
2512pub(crate) async fn fork_session_storage(
2519 harness: &AgentHarness,
2520 cwd: &str,
2521) -> Result<Session, String> {
2522 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2523 use rpi_tools::FileSystem;
2524
2525 let dir = default_session_dir(Path::new(cwd));
2526 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2527 let fs: Arc<dyn FileSystem> = env.clone();
2528 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2529 fs,
2530 sessions_root: dir.to_string_lossy().into_owned(),
2531 clock: Arc::new(SystemClock),
2532 ids: Arc::new(DefaultIdGenerator::new()),
2533 });
2534 let id = harness.session().storage().metadata().id.clone();
2537 let metas = list_session_metadata(cwd)
2538 .await
2539 .map_err(|e| e.to_string())?;
2540 let Some(source) = metas.iter().find(|m| m.id == id) else {
2541 return Err(format!("current session {id} not found on disk"));
2542 };
2543 let fork_storage = repo
2544 .fork_typed(
2545 source,
2546 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2547 id: None,
2548 parent_session_id: Some(source.id.clone()),
2549 cwd: cwd.to_string(),
2550 metadata: None,
2551 },
2552 &rpi_harness::session::types::ForkOptions::default(),
2553 )
2554 .await
2555 .map_err(|e| e.to_string())?;
2556 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2557 Ok(Session::new(Arc::new(fork_storage), None))
2558}
2559
2560static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2562static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2565
2566fn claim_session_lock(session_path: &str) -> Result<(), String> {
2575 let lock_path = PathBuf::from(format!("{session_path}.lock"));
2576 if SESSION_LOCK_PATHS
2577 .get_or_init(|| Mutex::new(HashSet::new()))
2578 .lock()
2579 .unwrap()
2580 .contains(&lock_path)
2581 {
2582 return Ok(());
2583 }
2584 let file = std::fs::OpenOptions::new()
2585 .create(true)
2586 .write(true)
2587 .open(&lock_path)
2588 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2589 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2590 return Err(format!(
2591 "session {session_path} is already open in another rpi process"
2592 ));
2593 }
2594 SESSION_LOCKS
2595 .get_or_init(|| Mutex::new(Vec::new()))
2596 .lock()
2597 .unwrap()
2598 .push(file);
2599 SESSION_LOCK_PATHS
2600 .get_or_init(|| Mutex::new(HashSet::new()))
2601 .lock()
2602 .unwrap()
2603 .insert(lock_path);
2604 Ok(())
2605}
2606
2607async fn open_session(
2610 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2611 cwd: &str,
2612) -> Result<Session, BuildError> {
2613 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2614 use rpi_harness::session::types::SessionStorage;
2615 use rpi_tools::FileSystem;
2616
2617 let dir = default_session_dir(Path::new(cwd));
2618 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2619 let fs: Arc<dyn FileSystem> = env.clone();
2620 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2621 fs: fs.clone(),
2622 sessions_root: dir.to_string_lossy().into_owned(),
2623 clock: Arc::new(SystemClock),
2624 ids: Arc::new(DefaultIdGenerator::new()),
2625 });
2626 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2627 let storage = repo
2628 .open_by_jsonl_metadata(meta)
2629 .await
2630 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2631 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2635 Some(Path::new(&meta.path)),
2636 Path::new(&meta.cwd),
2637 Path::new(cwd),
2638 ) {
2639 eprintln!(
2640 "warning: {}",
2641 crate::session_cwd::format_missing_session_cwd_error(&issue)
2642 );
2643 }
2644 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2645 Ok(Session::new(storage_arc, None))
2646}
2647
2648fn ephemeral_session() -> Session {
2650 let storage = Arc::new(InMemorySessionStorage::new(
2651 SessionMetadata {
2652 id: "ephemeral".into(),
2653 created_at: 0,
2654 parent_session_id: None,
2655 },
2656 Arc::new(SystemClock),
2657 Arc::new(DefaultIdGenerator::new()),
2658 ));
2659 Session::new(storage, None)
2660}
2661
2662pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2673 create_jsonl_session_with_id(dir, cwd, None).await
2674}
2675
2676pub(crate) async fn create_jsonl_session_with_id(
2680 dir: &Path,
2681 cwd: &str,
2682 id: Option<String>,
2683) -> Result<Session, String> {
2684 use rpi_harness::session::jsonl::{
2685 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2686 };
2687 use rpi_tools::FileSystem;
2688
2689 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2692 let fs: Arc<dyn FileSystem> = env.clone();
2693
2694 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2695 fs: fs.clone(),
2696 sessions_root: dir.to_string_lossy().into_owned(),
2697 clock: Arc::new(SystemClock),
2698 ids: Arc::new(DefaultIdGenerator::new()),
2699 });
2700
2701 let opts = JsonlSessionCreateOptions {
2702 id, parent_session_id: None,
2704 cwd: cwd.to_string(),
2705 metadata: None,
2706 };
2707 let storage = repo
2708 .create_typed(&opts)
2709 .await
2710 .map_err(|e| format!("create session: {e}"))?;
2711 claim_session_lock(&storage.jsonl_metadata().path)
2712 .map_err(|e| format!("create session: {e}"))?;
2713 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2715 Ok(Session::new(storage_arc, None))
2716}
2717
2718fn read_append_target(target: &str) -> Option<String> {
2721 let path = Path::new(target);
2722 if path.is_file() {
2723 std::fs::read_to_string(path).ok()
2724 } else {
2725 None
2726 }
2727}
2728
2729#[cfg(test)]
2730mod tests {
2731 use super::*;
2732 use crate::args::Args;
2733
2734 fn scratch(name: &str) -> PathBuf {
2736 let nanos = std::time::SystemTime::now()
2737 .duration_since(std::time::UNIX_EPOCH)
2738 .unwrap()
2739 .as_nanos();
2740 let path = std::env::temp_dir().join(format!(
2741 "rpi-summary-{}-{nanos}-{name}.jsonl",
2742 std::process::id()
2743 ));
2744 path
2745 }
2746
2747 #[test]
2748 fn summary_marks_a_header_only_session_empty() {
2749 let path = scratch("empty");
2750 std::fs::write(
2751 &path,
2752 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2753 )
2754 .unwrap();
2755 let summary = summarize_session_file(&path);
2756 assert!(summary.empty, "a header-only file has nothing to resume");
2757 assert!(summary.preview.is_none());
2758 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
2759 std::fs::remove_file(&path).ok();
2760 }
2761
2762 #[test]
2763 fn summary_reads_the_first_user_prompt_and_name() {
2764 let path = scratch("content");
2765 let body = concat!(
2766 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2767 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
2768 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
2769 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
2770 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
2771 );
2772 std::fs::write(&path, body).unwrap();
2773 let summary = summarize_session_file(&path);
2774 assert!(!summary.empty);
2775 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
2776 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
2777 std::fs::remove_file(&path).ok();
2778 }
2779
2780 #[test]
2781 fn summary_extracts_a_block_style_user_prompt() {
2782 let path = scratch("blocks");
2783 let body = concat!(
2784 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2785 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
2786 );
2787 std::fs::write(&path, body).unwrap();
2788 let summary = summarize_session_file(&path);
2789 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
2790 assert_eq!(session_display_label(&summary), "line one line two");
2791 std::fs::remove_file(&path).ok();
2792 }
2793
2794 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
2795 rpi_harness::session::jsonl::JsonlSessionMetadata {
2796 id: id.to_string(),
2797 created_at: 0,
2798 cwd: ".".to_string(),
2799 path: path.to_string_lossy().into_owned(),
2800 modified_at: 0,
2801 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
2802 parent_session_id: None,
2803 legacy_parent_session_path: None,
2804 metadata: None,
2805 }
2806 }
2807
2808 #[test]
2809 fn continue_skips_newer_empty_sessions() {
2810 let empty = scratch("newest-empty");
2811 std::fs::write(
2812 &empty,
2813 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2814 )
2815 .unwrap();
2816 let content = scratch("older-content");
2817 std::fs::write(
2818 &content,
2819 concat!(
2820 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2821 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
2822 ),
2823 )
2824 .unwrap();
2825
2826 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
2827 let picked = first_resumable_session(&metas).expect("a resumable session");
2828 assert_eq!(picked.id, "old");
2829
2830 let all_empty = vec![meta_for(&empty, "empty")];
2833 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
2834
2835 std::fs::remove_file(&empty).ok();
2836 std::fs::remove_file(&content).ok();
2837 }
2838
2839 #[test]
2840 fn list_session_files_sync_walks_the_cwd_subdirectory() {
2841 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
2842 let nested = root.join("--proj--");
2843 std::fs::create_dir_all(&nested).unwrap();
2844 let file = nested.join("2026-01-01_a.jsonl");
2845 std::fs::write(&file, "{}\n").unwrap();
2846 let found = list_session_files_sync(&root);
2847 assert!(found.iter().any(|p| p == &file));
2848 std::fs::remove_dir_all(&root).ok();
2849 }
2850
2851 #[test]
2852 fn default_prompt_mentions_cwd_and_tools() {
2853 let p = default_system_prompt("/tmp/proj", false);
2854 assert!(p.contains("/tmp/proj"));
2855 assert!(p.contains("read"));
2856 assert!(p.contains("bash"));
2857 assert!(p.contains("edit"));
2858 assert!(p.contains("write"));
2859 assert!(p.contains("docs"));
2860 assert!(!p.contains("- grep"));
2861 assert!(!p.contains("- find"));
2862 assert!(!p.contains("- ls"));
2863 assert!(!p.contains("powershell"));
2864 }
2865
2866 #[test]
2867 fn default_prompt_instructs_language_matching() {
2868 let p = default_system_prompt("/tmp/proj", false);
2872 let lower = p.to_lowercase();
2873 assert!(
2874 lower.contains("same language") && lower.contains("detect"),
2875 "the prompt must tell the model to detect and mirror the user's language: {p}"
2876 );
2877 }
2878
2879 #[test]
2880 fn default_prompt_keeps_working_state_in_the_visible_channel() {
2881 let p = default_system_prompt("/tmp/proj", false);
2889 let lower = p.to_lowercase();
2890 assert!(
2892 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
2893 "the prompt must explain that reasoning does not survive the turn: {p}"
2894 );
2895 assert!(
2897 lower.contains("visible") && lower.contains("todo tool"),
2898 "the prompt must point at visible text and the todo tool: {p}"
2899 );
2900 assert!(
2902 lower.contains("re-stating the same plan"),
2903 "the prompt must call out plan re-statement as a bug: {p}"
2904 );
2905 assert!(
2907 lower.contains("already shown earlier"),
2908 "the prompt must discourage re-fetching known content: {p}"
2909 );
2910 }
2911
2912 #[test]
2913 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
2914 let p = default_system_prompt("/tmp/proj", true);
2921 let lower = p.to_lowercase();
2922 assert!(
2923 lower.contains("carried back"),
2924 "a replaying model must be told its reasoning survives: {p}"
2925 );
2926 assert!(
2927 !lower.contains("reasoning is not"),
2928 "the replayed variant must not claim reasoning is dropped: {p}"
2929 );
2930 assert!(
2931 lower.contains("do not re-derive"),
2932 "the replayed variant must forbid re-deriving the plan: {p}"
2933 );
2934 assert!(
2935 lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
2936 "the anti-repetition rule must survive the flip: {p}"
2937 );
2938 assert!(
2939 lower.contains("already shown earlier"),
2940 "the re-read rule must survive the flip: {p}"
2941 );
2942 }
2943
2944 #[test]
2952 fn only_read_only_tools_are_replayable() {
2953 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2954 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2955 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2956 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2957 let tools = build_tools(&context, &Args::default());
2958
2959 let replay_of = |name: &str| {
2960 tools
2961 .iter()
2962 .find(|tool| tool.tool.schema().name == name)
2963 .map(|tool| tool.replay)
2964 };
2965
2966 for read_only in ["read", "docs"] {
2967 assert_eq!(
2968 replay_of(read_only),
2969 Some(ToolReplay::Safe),
2970 "{read_only} is side-effect free, so recovery may re-run it"
2971 );
2972 }
2973 for mutating in ["bash", "edit", "write", "powershell"] {
2974 if let Some(replay) = replay_of(mutating) {
2975 assert_eq!(
2976 replay,
2977 ToolReplay::Never,
2978 "{mutating} must never be replayed: re-running it would duplicate its effect"
2979 );
2980 }
2981 }
2982 }
2983
2984 #[test]
2985 fn default_tools_include_docs_lookup() {
2986 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2987 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2988 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2989 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2990 let names: Vec<String> = build_tools(&context, &Args::default())
2991 .iter()
2992 .map(|tool| tool.tool.schema().name.clone())
2993 .collect();
2994
2995 assert!(names.contains(&"docs".to_string()));
3000 for legacy in ["grep", "find", "ls"] {
3001 assert!(
3002 !names.contains(&legacy.to_string()),
3003 "legacy tool {legacy} should not be registered by default"
3004 );
3005 }
3006 }
3007
3008 #[test]
3009 fn pi_package_loading_is_opt_in_and_respects_no_extensions() {
3010 let args = Args::default();
3011 assert!(!should_load_js_packages(&args));
3012 let resources = package_resources_for(&args, Path::new("."), false);
3013 assert!(resources.packages.is_empty());
3014
3015 let args = Args {
3016 enable_pi_packages: true,
3017 ..Args::default()
3018 };
3019 assert!(should_load_js_packages(&args));
3020
3021 let args = Args {
3022 enable_pi_packages: true,
3023 no_extensions: true,
3024 ..Args::default()
3025 };
3026 assert!(!should_load_js_packages(&args));
3027 assert!(package_resources_for(&args, Path::new("."), false)
3028 .packages
3029 .is_empty());
3030 }
3031
3032 #[test]
3033 fn pi_offline_env_disables_startup_package_remediation() {
3034 struct RestoreEnv {
3035 name: &'static str,
3036 value: Option<std::ffi::OsString>,
3037 }
3038
3039 impl Drop for RestoreEnv {
3040 fn drop(&mut self) {
3041 match self.value.take() {
3042 Some(value) => std::env::set_var(self.name, value),
3043 None => std::env::remove_var(self.name),
3044 }
3045 }
3046 }
3047
3048 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3049 let _restore_config = RestoreEnv {
3050 name: crate::config::CONFIG_DIR_ENV,
3051 value: std::env::var_os(crate::config::CONFIG_DIR_ENV),
3052 };
3053 let _restore_offline = RestoreEnv {
3054 name: crate::args::PI_OFFLINE_ENV,
3055 value: std::env::var_os(crate::args::PI_OFFLINE_ENV),
3056 };
3057 let tmp = tempfile::tempdir().unwrap();
3058 let agent = tmp.path().join("agent");
3059 let cwd = tmp.path().join("project");
3060 let package = agent.join("npm/node_modules/demo");
3061 std::fs::create_dir_all(package.join("extensions")).unwrap();
3062 std::fs::create_dir_all(&cwd).unwrap();
3063 std::fs::write(
3064 package.join("package.json"),
3065 r#"{"name":"demo","version":"1.0.0"}"#,
3066 )
3067 .unwrap();
3068 std::fs::write(
3069 package.join("extensions/index.js"),
3070 "export default () => {};",
3071 )
3072 .unwrap();
3073 std::fs::write(
3074 agent.join("settings.json"),
3075 r#"{"npmCommand":[""],"packages":["npm:demo@2.0.0"]}"#,
3076 )
3077 .unwrap();
3078 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3079 std::env::set_var(crate::args::PI_OFFLINE_ENV, "TrUe");
3080 let args = Args {
3081 enable_pi_packages: true,
3082 offline: false,
3083 ..Args::default()
3084 };
3085
3086 let resources = package_resources_for(&args, &cwd, false);
3087
3088 assert!(resources.packages.is_empty());
3089 assert_eq!(resources.diagnostics.len(), 1);
3090 assert!(resources.diagnostics[0].message.contains("offline"));
3091 assert_eq!(
3092 std::fs::read(package.join("package.json")).unwrap(),
3093 br#"{"name":"demo","version":"1.0.0"}"#
3094 );
3095 }
3096
3097 #[test]
3098 fn package_discovery_uses_the_callers_trust_snapshot() {
3099 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3100 let previous = std::env::var_os(crate::config::CONFIG_DIR_ENV);
3101 let tmp = tempfile::tempdir().unwrap();
3102 let agent = tmp.path().join("agent");
3103 let cwd = tmp.path().join("project");
3104 let package = cwd.join("package");
3105 std::fs::create_dir_all(&agent).unwrap();
3106 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3107 std::fs::create_dir_all(&package).unwrap();
3108 std::fs::write(agent.join("settings.json"), "{}").unwrap();
3109 std::fs::write(
3110 package.join("package.json"),
3111 r#"{"name":"snapshot-package","version":"1.0.0"}"#,
3112 )
3113 .unwrap();
3114 std::fs::write(
3115 cwd.join(".rpi/settings.json"),
3116 serde_json::json!({"packages": [package]}).to_string(),
3117 )
3118 .unwrap();
3119 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3120
3121 let args = Args {
3122 enable_pi_packages: true,
3123 ..Args::default()
3124 };
3125 assert_eq!(package_resources_for(&args, &cwd, true).packages.len(), 1);
3126 assert!(package_resources_for(&args, &cwd, false)
3127 .packages
3128 .is_empty());
3129 assert_eq!(
3130 package_resources_for_update_check(&args, &cwd, true)
3131 .packages
3132 .len(),
3133 1
3134 );
3135 assert!(package_resources_for_update_check(&args, &cwd, false)
3136 .packages
3137 .is_empty());
3138
3139 match previous {
3140 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3141 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3142 }
3143 }
3144
3145 #[test]
3146 fn reload_settings_preflight_is_independent_of_packages_and_respects_trust() {
3147 struct RestoreConfigDir(Option<std::ffi::OsString>);
3148
3149 impl Drop for RestoreConfigDir {
3150 fn drop(&mut self) {
3151 match self.0.take() {
3152 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3153 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3154 }
3155 }
3156 }
3157
3158 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3159 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3160 let tmp = tempfile::tempdir().unwrap();
3161 let agent = tmp.path().join("agent");
3162 let cwd = tmp.path().join("project");
3163 std::fs::create_dir_all(&agent).unwrap();
3164 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3165 std::fs::write(agent.join("settings.json"), "{}").unwrap();
3166 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
3167 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3168 let packages_disabled = Args::default();
3169 let packages_enabled = Args {
3170 enable_pi_packages: true,
3171 ..Args::default()
3172 };
3173
3174 for args in [&packages_disabled, &packages_enabled] {
3175 let trusted = validate_settings_for_reload(args, &cwd, true);
3176 let untrusted = validate_settings_for_reload(args, &cwd, false);
3177 assert!(trusted
3178 .unwrap_err()
3179 .contains("could not load project settings"));
3180 assert!(untrusted.is_ok());
3181 }
3182
3183 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3184 for args in [&packages_disabled, &packages_enabled] {
3185 assert!(validate_settings_for_reload(args, &cwd, false)
3186 .unwrap_err()
3187 .contains("could not load global settings"));
3188 }
3189
3190 let local_only = Args {
3191 dev_local_only: true,
3192 ..Args::default()
3193 };
3194 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
3195 }
3196
3197 #[tokio::test(flavor = "current_thread")]
3198 async fn reload_with_packages_disabled_preserves_live_resources_when_settings_break() {
3199 struct RestoreConfigDir(Option<std::ffi::OsString>);
3200
3201 impl Drop for RestoreConfigDir {
3202 fn drop(&mut self) {
3203 match self.0.take() {
3204 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3205 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3206 }
3207 }
3208 }
3209
3210 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3211 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3212 let tmp = tempfile::tempdir().unwrap();
3213 let agent = tmp.path().join("agent");
3214 let cwd = tmp.path().join("project");
3215 let skill_dir = tmp.path().join("configured-skills");
3216 std::fs::create_dir_all(&agent).unwrap();
3217 std::fs::create_dir_all(&cwd).unwrap();
3218 std::fs::create_dir_all(&skill_dir).unwrap();
3219 std::fs::write(
3220 skill_dir.join("SKILL.md"),
3221 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
3222 )
3223 .unwrap();
3224 std::fs::write(
3225 agent.join("settings.json"),
3226 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3227 )
3228 .unwrap();
3229 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3230
3231 let resolved = crate::provider::resolve(
3232 Some("anthropic"),
3233 Some(crate::provider::DEFAULT_MODEL_ID),
3234 None,
3235 Some("test-key"),
3236 None,
3237 )
3238 .unwrap();
3239 let args = Args {
3240 trust_override: Some(false),
3241 no_session: true,
3242 no_extensions: true,
3243 no_prompt_templates: true,
3244 no_context_files: true,
3245 system_prompt: Some("stable system prompt".into()),
3246 ..Args::default()
3247 };
3248 assert!(!should_load_js_packages(&args));
3249
3250 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3251 let before_resources = harness.get_resources().await.unwrap();
3252 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
3253 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
3254 let before_prompt = harness.get_system_prompt().await.unwrap();
3255 let before_tools: Vec<String> = harness
3256 .get_tools()
3257 .await
3258 .unwrap()
3259 .iter()
3260 .map(|tool| tool.tool.schema().name.clone())
3261 .collect();
3262 let before_bridge = context
3263 .action_bridge
3264 .lock()
3265 .unwrap()
3266 .as_ref()
3267 .unwrap()
3268 .clone();
3269
3270 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3271 let outcome = reload_extension_resources(&harness, &context).await;
3272
3273 assert!(outcome.had_warnings);
3274 assert!(outcome.summary.contains("Settings reload failed"));
3275 assert!(outcome.summary.contains("could not load global settings"));
3276 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
3277 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
3278 let after_tools: Vec<String> = harness
3279 .get_tools()
3280 .await
3281 .unwrap()
3282 .iter()
3283 .map(|tool| tool.tool.schema().name.clone())
3284 .collect();
3285 assert_eq!(after_tools, before_tools);
3286 let after_bridge = context
3287 .action_bridge
3288 .lock()
3289 .unwrap()
3290 .as_ref()
3291 .unwrap()
3292 .clone();
3293 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
3294 }
3295
3296 #[test]
3297 fn reload_preparation_rejects_settings_changed_during_derivation() {
3298 struct RestoreConfigDir(Option<std::ffi::OsString>);
3299
3300 impl Drop for RestoreConfigDir {
3301 fn drop(&mut self) {
3302 match self.0.take() {
3303 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3304 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3305 }
3306 }
3307 }
3308
3309 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3310 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3311 let tmp = tempfile::tempdir().unwrap();
3312 let agent = tmp.path().join("agent");
3313 let cwd = tmp.path().join("project");
3314 let first_skill_dir = tmp.path().join("first-skills");
3315 let second_skill_dir = tmp.path().join("second-skills");
3316 std::fs::create_dir_all(&agent).unwrap();
3317 std::fs::create_dir_all(&cwd).unwrap();
3318 std::fs::write(
3319 agent.join("settings.json"),
3320 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3321 )
3322 .unwrap();
3323 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3324 let args = Args {
3325 trust_override: Some(false),
3326 no_extensions: true,
3327 no_prompt_templates: true,
3328 ..Args::default()
3329 };
3330
3331 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3332 std::fs::write(
3333 agent.join("settings.json"),
3334 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3335 )
3336 .unwrap();
3337 });
3338
3339 let error = result
3340 .err()
3341 .expect("settings mutation must fail preparation");
3342 assert!(error.contains("settings changed while reload inputs were being prepared"));
3343 }
3344
3345 #[tokio::test(flavor = "current_thread")]
3346 async fn reload_uses_frozen_settings_inputs_after_preparation() {
3347 struct RestoreConfigDir(Option<std::ffi::OsString>);
3348
3349 impl Drop for RestoreConfigDir {
3350 fn drop(&mut self) {
3351 match self.0.take() {
3352 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3353 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3354 }
3355 }
3356 }
3357
3358 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3359 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3360 let tmp = tempfile::tempdir().unwrap();
3361 let agent = tmp.path().join("agent");
3362 let cwd = tmp.path().join("project");
3363 let skill_dir = tmp.path().join("configured-skills");
3364 std::fs::create_dir_all(&agent).unwrap();
3365 std::fs::create_dir_all(&cwd).unwrap();
3366 std::fs::create_dir_all(&skill_dir).unwrap();
3367 std::fs::write(
3368 skill_dir.join("SKILL.md"),
3369 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3370 )
3371 .unwrap();
3372 std::fs::write(
3373 agent.join("settings.json"),
3374 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3375 )
3376 .unwrap();
3377 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3378
3379 let resolved = crate::provider::resolve(
3380 Some("anthropic"),
3381 Some(crate::provider::DEFAULT_MODEL_ID),
3382 None,
3383 Some("test-key"),
3384 None,
3385 )
3386 .unwrap();
3387 let args = Args {
3388 trust_override: Some(false),
3389 no_session: true,
3390 no_extensions: true,
3391 no_prompt_templates: true,
3392 no_context_files: true,
3393 system_prompt: Some("stable system prompt".into()),
3394 ..Args::default()
3395 };
3396 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3397
3398 let outcome = reload_extension_resources_inner(&harness, &context, || {
3399 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3400 })
3401 .await;
3402
3403 assert!(!outcome.summary.contains("Settings reload failed"));
3404 assert_eq!(
3405 outcome.summary,
3406 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3407 );
3408 let resources = harness.get_resources().await.unwrap();
3409 let skills = resources.skills.as_ref().unwrap();
3410 assert_eq!(skills.len(), 1);
3411 assert_eq!(skills[0].name, "frozen-skill");
3412 }
3413
3414 #[tokio::test]
3415 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3416 let tmp = tempfile::tempdir().unwrap();
3417 let skill_path = tmp.path().join("explicit-skill.md");
3418 std::fs::write(
3419 &skill_path,
3420 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3421 )
3422 .unwrap();
3423 let prompt_path = tmp.path().join("explicit-prompt.md");
3424 std::fs::write(
3425 &prompt_path,
3426 "---\ndescription: Explicit prompt\n---\nPrompt body",
3427 )
3428 .unwrap();
3429
3430 let args = Args {
3431 skill: vec![skill_path.clone()],
3432 prompt_template: vec![prompt_path.clone()],
3433 ..Args::default()
3434 };
3435 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3436 let prompt_paths =
3437 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3438 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3439 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3440
3441 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3442 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3443 assert_eq!(skills.skills[0].name, "explicit-skill");
3444
3445 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3446 assert_eq!(
3447 prompts.prompt_templates.len(),
3448 1,
3449 "{:?}",
3450 prompts.diagnostics
3451 );
3452 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3453 }
3454
3455 #[test]
3456 fn tool_policy_applies_to_rust_and_js_active_names() {
3457 let names = vec![
3458 "read".to_string(),
3459 "ask_user_question".to_string(),
3460 "write".to_string(),
3461 ];
3462
3463 let args = Args {
3464 tools: Some(vec!["read".into(), "ask_user_question".into()]),
3465 ..Args::default()
3466 };
3467 assert_eq!(
3468 filter_active_tool_names(names.clone(), &args),
3469 vec!["read", "ask_user_question"]
3470 );
3471
3472 let args = Args {
3473 exclude_tools: Some(vec!["ask_user_question".into()]),
3474 ..Args::default()
3475 };
3476 assert_eq!(
3477 filter_active_tool_names(names.clone(), &args),
3478 vec!["read", "write"]
3479 );
3480
3481 let args = Args {
3482 no_tools: true,
3483 ..Args::default()
3484 };
3485 assert!(filter_active_tool_names(names, &args).is_empty());
3486 }
3487
3488 #[test]
3489 fn select_ephemeral_when_no_session() {
3490 let args = Args {
3491 no_session: true,
3492 ..Args::default()
3493 };
3494 let cwd = Path::new("/tmp");
3495 assert!(matches!(
3496 select_session(&args, cwd),
3497 SessionSelection::Ephemeral
3498 ));
3499 }
3500
3501 #[test]
3502 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3503 let defaults = Args::default();
3504 assert!(resolve_project_trust(
3505 &defaults,
3506 Path::new("C:/definitely-not-a-project")
3507 ));
3508
3509 let denied = Args {
3510 trust_override: Some(false),
3511 ..Args::default()
3512 };
3513 assert!(!resolve_project_trust(
3514 &denied,
3515 Path::new("C:/definitely-not-a-project")
3516 ));
3517 }
3518
3519 #[tokio::test(flavor = "current_thread")]
3520 async fn build_preserves_the_supplied_startup_project_snapshot() {
3521 struct RestoreConfigDir(Option<std::ffi::OsString>);
3522
3523 impl Drop for RestoreConfigDir {
3524 fn drop(&mut self) {
3525 match self.0.take() {
3526 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3527 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3528 }
3529 }
3530 }
3531
3532 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3533 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3534 let tmp = tempfile::tempdir().unwrap();
3535 let agent = tmp.path().join("agent");
3536 let cwd = tmp.path().join("project");
3537 std::fs::create_dir_all(&agent).unwrap();
3538 std::fs::create_dir_all(&cwd).unwrap();
3539 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3540
3541 let resolved = crate::provider::resolve(
3542 Some("anthropic"),
3543 Some(crate::provider::DEFAULT_MODEL_ID),
3544 None,
3545 Some("test-key"),
3546 None,
3547 )
3548 .unwrap();
3549 let args = Args {
3550 trust_override: Some(false),
3551 no_session: true,
3552 no_tools: true,
3553 no_extensions: true,
3554 no_skills: true,
3555 no_prompt_templates: true,
3556 no_context_files: true,
3557 system_prompt: Some("test prompt".into()),
3558 ..Args::default()
3559 };
3560
3561 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3562
3563 assert_eq!(context.cwd, cwd);
3564 assert!(context.project_trusted);
3565 }
3566
3567 #[tokio::test(flavor = "current_thread")]
3568 async fn build_applies_the_name_flag_to_the_session() {
3569 struct RestoreConfigDir(Option<std::ffi::OsString>);
3570
3571 impl Drop for RestoreConfigDir {
3572 fn drop(&mut self) {
3573 match self.0.take() {
3574 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3575 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3576 }
3577 }
3578 }
3579
3580 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3581 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3582 let tmp = tempfile::tempdir().unwrap();
3583 let agent = tmp.path().join("agent");
3584 let cwd = tmp.path().join("project");
3585 std::fs::create_dir_all(&agent).unwrap();
3586 std::fs::create_dir_all(&cwd).unwrap();
3587 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3588
3589 let resolved = crate::provider::resolve(
3590 Some("anthropic"),
3591 Some(crate::provider::DEFAULT_MODEL_ID),
3592 None,
3593 Some("test-key"),
3594 None,
3595 )
3596 .unwrap();
3597 let args = Args {
3598 trust_override: Some(false),
3599 no_tools: true,
3600 no_extensions: true,
3601 no_skills: true,
3602 no_prompt_templates: true,
3603 no_context_files: true,
3604 system_prompt: Some("test prompt".into()),
3605 name: Some("smoke name".into()),
3606 session_dir: Some(cwd.join("sessions")),
3607 ..Args::default()
3608 };
3609
3610 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3611 assert_eq!(
3612 harness.get_name().await.unwrap().as_deref(),
3613 Some("smoke name")
3614 );
3615 }
3616
3617 #[test]
3618 fn select_latest_for_continue_and_resume() {
3619 let args = Args {
3620 continue_session: true,
3621 ..Args::default()
3622 };
3623 let cwd = Path::new("/tmp");
3624 assert!(matches!(
3625 select_session(&args, cwd),
3626 SessionSelection::Latest
3627 ));
3628
3629 let args = Args {
3630 resume: true,
3631 ..Args::default()
3632 };
3633 assert!(matches!(
3634 select_session(&args, cwd),
3635 SessionSelection::Latest
3636 ));
3637 }
3638
3639 #[test]
3640 fn select_by_id_for_session_flag() {
3641 let args = Args {
3642 session: Some("01a02ece".into()),
3643 ..Args::default()
3644 };
3645 let cwd = Path::new("/tmp");
3646 assert!(matches!(
3647 select_session(&args, cwd),
3648 SessionSelection::ById { id } if id == "01a02ece"
3649 ));
3650 }
3651
3652 #[test]
3653 fn select_new_with_custom_dir() {
3654 let args = Args {
3655 session_dir: Some(PathBuf::from("/tmp/sess")),
3656 ..Args::default()
3657 };
3658 let cwd = Path::new("/tmp");
3659 match select_session(&args, cwd) {
3660 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
3661 other => panic!("expected New, got {other:?}"),
3662 }
3663 }
3664
3665 #[test]
3666 fn select_new_default_dir() {
3667 let args = Args::default();
3668 let cwd = Path::new("/proj");
3669 match select_session(&args, cwd) {
3670 SessionSelection::New { dir, .. } => {
3671 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
3672 }
3673 other => panic!("expected New, got {other:?}"),
3674 }
3675 }
3676
3677 #[test]
3678 fn default_session_dir_prefers_rpi_but_reads_legacy_pi() {
3679 let tmp = tempfile::tempdir().unwrap();
3680 let cwd = tmp.path();
3681 std::fs::create_dir_all(cwd.join(".pi/sessions")).unwrap();
3682 assert_eq!(default_session_dir(cwd), cwd.join(".pi/sessions"));
3683 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3684 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3685 }
3686
3687 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3688 async fn ephemeral_session_builds_roundtrips() {
3689 let s = ephemeral_session();
3693 let leaf = s.get_leaf_id().await;
3694 assert!(leaf.is_ok());
3695 }
3696
3697 }