1use std::path::{Path, PathBuf};
34use std::sync::atomic::Ordering;
35use std::sync::{Arc, Mutex};
36
37use rpi_agent::AgentTool;
38use rpi_ai::Provider;
39use rpi_harness::agent_harness::AgentHarness;
40use rpi_harness::context_files::{format_project_context, load_project_context_files};
41use rpi_harness::session::memory::{InMemorySessionStorage, SystemClock};
42use rpi_harness::session::session::DefaultIdGenerator;
43use rpi_harness::session::types::{BranchBounds, EntryQuery, SessionMetadata};
44use rpi_harness::session::Session;
45use rpi_harness::system_prompt::compose_system_prompt;
46use rpi_harness::types::{
47 AgentHarnessOptions, AgentHarnessResources, AgentHarnessStreamOptions, DrivingMode,
48 HarnessTool, HarnessToolExecution, ToolReplay,
49};
50use rpi_tools::{
51 create_bash_tool, create_edit_tool, create_powershell_tool, create_read_tool,
52 create_write_tool, ExecutionToolContext, MutationQueueRegistry, OsExecutionEnv,
53};
54
55use crate::args::Args;
56use crate::docs_tool::create_docs_tool;
57use crate::extension_api::ExtensionBackend;
58use crate::provider::ResolvedModel;
59use crate::resource_dirs::{
60 discover_append_system_prompt_file_with_packages, discover_system_prompt_file_with_packages,
61 extension_dirs, global_extension_dirs, global_prompt_template_dirs, global_skill_dirs,
62 load_prompt_templates_with_precedence, load_skills_with_precedence,
63 project_prompt_template_dirs, project_skill_dirs, prompt_template_dirs, skill_dirs,
64};
65use rpi_extensions::{
66 emit_resources_discover, ExtensionEmitter, ExtensionSession, NullDiagnostics,
67 PluginDiagnostics, PluginToolAdapter, TeeEmitter,
68};
69
70pub const BUILTIN_TOOL_NAMES: &[&str] = &["read", "bash", "edit", "write", "docs"];
72
73pub(crate) fn should_load_js_packages(args: &Args) -> bool {
76 args.enable_pi_packages && !args.no_extensions
77}
78
79pub(crate) fn package_resources_for(
83 args: &Args,
84 cwd: &Path,
85 project_trusted: bool,
86) -> crate::packages::PackageResources {
87 if should_load_js_packages(args) {
88 if crate::args::offline_mode_enabled(args.offline) {
89 if project_trusted {
90 crate::packages::resolve_offline_from_settings(cwd)
91 } else {
92 crate::packages::resolve_offline_from_global_settings(cwd)
93 }
94 } else if project_trusted {
95 crate::packages::resolve_from_settings(cwd)
96 } else {
97 crate::packages::resolve_from_global_settings(cwd)
98 }
99 } else {
100 crate::packages::PackageResources::default()
101 }
102}
103
104pub(crate) fn package_resources_for_update_check(
109 args: &Args,
110 cwd: &Path,
111 project_trusted: bool,
112) -> crate::packages::PackageResources {
113 if args.dev_local_only {
114 crate::packages::PackageResources::default()
115 } else if project_trusted {
116 crate::packages::discover_from_settings(cwd)
117 } else {
118 crate::packages::discover_from_global_settings(cwd)
119 }
120}
121
122pub fn default_system_prompt(cwd: &str, prior_reasoning_replayed: bool) -> String {
125 let working_state_rule = if prior_reasoning_replayed {
131 "- Keep a SHORT working plan visible (a few bullets, or the todo tool) and update it as you go.
132 Your own reasoning is carried back to you on the next turn, so do not re-derive it or restate
133 the whole plan: continue from the last unfinished step, and when a step lands, say which one.
134 Re-listing the same plan without acting on it is a bug, not progress."
135 } else {
136 "- Keep your working state in your VISIBLE replies, not only in reasoning. Reasoning is not
137 carried into your next turn: only the text you write and the tool output you produce come
138 back. Before each batch of tool calls, write one short line naming the task you are on and
139 what remains. When you finish a step, say which one is done. If you keep the plan only in
140 your head you will re-derive it from scratch every turn."
141 };
142
143 format!(
144 "You are an expert coding assistant operating inside rpi, a coding agent harness. \
145You help users by reading files, executing commands, editing code, and writing new files.
146
147Available tools:
148- read — Read file contents
149- bash — Execute shell commands
150- edit — Find/replace edits to existing files
151- write — Create or overwrite files
152- docs — Look up rpi usage, extension, package, and compatibility documentation
153
154Guidelines:
155- Reply in the SAME language the user is writing in. Detect the language of the user's
156 latest message (and, when it is too short to be sure, the working language of the
157 conversation so far) and use it for your prose replies. Mirror it unless the user
158 explicitly asks for a different language; only fall back to English when the
159 user's language cannot be determined.
160- Be concise in your responses
161- Show file paths clearly when working with files
162- Prefer the smallest change that solves the problem
163{working_state_rule}
164- Track multi-step work with the todo tool instead of re-listing the plan in prose: add the
165 steps once, then mark them done as you go. Re-stating the same plan without acting on it is
166 a bug, not progress.
167- Read only what you need. If a file or search result was already shown earlier in this
168 conversation, use it instead of fetching it again.
169- When unsure about rpi commands, extensions, Pi package compatibility, or .rpi configuration, consult the project documentation before guessing
170
171Current working directory: {cwd}"
172 )
173}
174
175#[derive(Debug, Clone)]
182pub enum SessionSelection {
183 Ephemeral,
185 New { dir: PathBuf, name: Option<String> },
187 Latest,
189 ById { id: String },
192 ByExactId { id: String },
194 Fork { source: String },
197}
198
199pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
201 if args.no_session {
202 return SessionSelection::Ephemeral;
203 }
204 if args.continue_session || args.resume {
205 return SessionSelection::Latest;
207 }
208 if let Some(s) = &args.fork {
209 return SessionSelection::Fork { source: s.clone() };
210 }
211 if let Some(s) = &args.session_id {
212 return SessionSelection::ByExactId { id: s.clone() };
213 }
214 if let Some(s) = &args.session {
215 return SessionSelection::ById { id: s.clone() };
216 }
217 let dir = args
220 .session_dir
221 .clone()
222 .or_else(|| {
223 crate::settings::load_settings()
224 .ok()
225 .and_then(|settings| settings.session_dir)
226 })
227 .unwrap_or_else(|| default_session_dir(cwd));
228 SessionSelection::New {
229 dir,
230 name: args.name.clone(),
231 }
232}
233
234pub fn default_session_dir(cwd: &Path) -> PathBuf {
238 let preferred = cwd.join(".rpi").join("sessions");
239 let legacy = cwd.join(".pi").join("sessions");
240 if preferred.exists() || !legacy.exists() {
241 preferred
242 } else {
243 legacy
244 }
245}
246
247pub async fn build(
265 resolved: &ResolvedModel,
266 args: &Args,
267 cwd: &Path,
268 project_trusted: bool,
269) -> Result<
270 (
271 AgentHarness,
272 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
273 ReloadContext,
274 ),
275 BuildError,
276> {
277 let cwd_str = cwd.to_string_lossy().to_string();
278 if !project_trusted && args.verbose {
279 eprintln!(
280 "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)"
281 );
282 }
283 let package_resources = if args.dev_local_only {
287 crate::packages::PackageResources::default()
288 } else {
289 package_resources_for(args, cwd, project_trusted)
290 };
291
292 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
307 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
308 })?;
309 let catalog = crate::provider::available_catalog(resolved);
310 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
311 catalog.clone(),
312 cwd.to_path_buf(),
313 runtime.clone(),
314 args.unknown_flags.clone(),
315 );
316 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = Arc::new(action_host);
317 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
327 let ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
333 let ext_status_mailbox = rpi_extensions::ExtensionStatusMailbox::new();
337 let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
338 runtime.clone(),
339 host_arc,
340 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
341 ui_dialog_mailbox.clone(),
342 ext_status_mailbox.clone(),
343 );
344
345 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
347 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
348 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
349 let _registry = Arc::new(MutationQueueRegistry::new());
350 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
353
354 let tools = build_tools(&ctx, args);
355 let mut tools = tools;
356
357 let extension_session = if args.no_extensions {
365 ExtensionSession::none()
366 } else {
367 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
368 };
369 let js_extension_session = if !should_load_js_packages(args) {
370 None
371 } else {
372 let paths = js_extension_paths(args, cwd, project_trusted, &package_resources);
373 let js_context = serde_json::json!({
374 "cwd": cwd_str,
375 "theme": resolved.theme.clone(),
376 "currentModel": resolved.model.clone(),
377 "models": catalog.clone(),
378 "thinkingLevel": resolved.thinking_level,
379 });
380 match crate::js_extensions::JsExtensionSession::load_with_context(
381 &paths,
382 args.verbose,
383 js_context,
384 ) {
385 Ok(session) => session,
386 Err(error) => {
387 eprintln!("warning: JS/TS extensions were not loaded: {error}");
388 None
389 }
390 }
391 };
392 if js_extension_session.is_some() {
393 eprintln!(
394 "warning: enabled Pi JS/TS extensions execute with the current user's permissions"
395 );
396 }
397 if let Some(session) = &js_extension_session {
398 if let Err(error) =
399 session.enable_provider_runtime(resolved.provider.clone(), runtime.clone())
400 {
401 if args.verbose {
402 eprintln!("warning: JS provider runtime was not enabled: {error}");
403 }
404 }
405 }
406 if args.verbose {
407 if let Some(session) = &js_extension_session {
408 let info = session.backend_info();
409 eprintln!(
410 "JS extension backend: {} v{} ({})",
411 info.name,
412 info.api_version,
413 info.capability_names().join(", ")
414 );
415 }
416 if let Some(s) = extension_session.summary() {
417 eprintln!("extensions: {s}");
418 }
419 report_deferred_renderers(&extension_session);
420 }
421 merge_extension_tools(&mut tools, &extension_session, args);
422 if let Some(session) = &js_extension_session {
423 merge_js_extension_tools(&mut tools, session, args);
424 if args.verbose && !session.commands.is_empty() {
425 eprintln!("JS extension commands: {}", session.commands.join(", "));
426 }
427 }
428 let mut active = active_tool_names(&tools, args);
429 if let Some(session) = &js_extension_session {
434 let js_names = session.tool_names();
435 if let Some(js_active) = session.active_tools() {
436 active.retain(|name| !js_names.iter().any(|js| js == name));
437 active.extend(js_active.into_iter().filter(|name| {
438 js_names.iter().any(|js| js == name) && tool_name_allowed(name, args)
439 }));
440 }
441 }
442 active = filter_active_tool_names(active, args);
443
444 let selection = select_session(args, cwd);
446 let session = build_session(&selection, &cwd_str).await?;
447 if let Some(requested) = args.name.as_deref() {
453 let name = requested.trim();
454 let name = if name.is_empty() { None } else { Some(name) };
455 if let Err(error) = session.set_name(name).await {
456 if args.verbose {
457 eprintln!("warning: could not set session name: {error}");
458 }
459 }
460 }
461 if let Some(js) = &js_extension_session {
462 let session_id = session
463 .get_metadata()
464 .await
465 .ok()
466 .map(|metadata| metadata.id);
467 let leaf_id = session.get_leaf_id().await.ok().flatten();
468 if let Some(session_id) = session_id {
469 let branch = session
470 .find_entries_on_branch(&EntryQuery::default(), &BranchBounds::default())
471 .await
472 .ok()
473 .unwrap_or_default();
474 let branch_json =
475 serde_json::to_value(&branch).unwrap_or_else(|_| serde_json::json!([]));
476 let runtime_context = serde_json::json!({
477 "session": {
478 "id": session_id,
479 "leafId": leaf_id,
480 "branch": branch_json,
481 "entries": branch_json.clone(),
482 },
483 });
484 if let Err(error) = js.set_runtime_context(runtime_context) {
485 if args.verbose {
486 eprintln!("warning: could not sync JS session context: {error}");
487 }
488 }
489 }
490 }
491
492 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
502 let base_prompt = match args.system_prompt.as_deref() {
503 Some(explicit) => explicit.to_string(),
504 None if project_trusted => {
505 match discover_system_prompt_file_with_packages(cwd, &package_resources) {
506 Some(path) => std::fs::read_to_string(&path)
507 .unwrap_or_else(|_| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
508 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
509 }
510 }
511 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
512 };
513
514 let mut append_texts: Vec<String> = Vec::new();
521 for extra in &args.append_system_prompt {
522 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
523 append_texts.push(text);
524 }
525 if args.append_system_prompt.is_empty() {
526 if let Some(path) = project_trusted
527 .then(|| discover_append_system_prompt_file_with_packages(cwd, &package_resources))
528 .flatten()
529 {
530 if let Ok(text) = std::fs::read_to_string(&path) {
531 append_texts.push(text);
532 }
533 }
534 }
535 let append_join = if append_texts.is_empty() {
536 None
537 } else {
538 Some(append_texts.join("\n\n"))
539 };
540
541 let agent_dir = crate::config::agent_dir().ok();
553
554 let discovered = extension_session
570 .snapshot_arc()
571 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
572 .unwrap_or_default();
573
574 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
575 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
576 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
577 if !args.no_skills {
578 let mut dirs = if args.dev_local_only {
579 project_skill_dirs(cwd)
580 } else if project_trusted {
581 skill_dirs(cwd)
582 } else {
583 global_skill_dirs()
584 };
585 dirs.extend(args.skill.iter().cloned());
586 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
587 if let Some(session) = &js_extension_session {
588 dirs.extend(session.resources.skill_paths.iter().cloned());
589 }
590 dirs.extend(package_resources.skill_dirs());
591 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
592 skills = result.skills;
593 skill_diags = result.diagnostics;
594 resource_diags.extend(result.resource_diagnostics);
595 }
596
597 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
598 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
599 if !args.no_prompt_templates {
600 let mut dirs = if args.dev_local_only {
601 project_prompt_template_dirs(cwd)
602 } else if project_trusted {
603 prompt_template_dirs(cwd)
604 } else {
605 global_prompt_template_dirs()
606 };
607 dirs.extend(args.prompt_template.iter().cloned());
608 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
609 if let Some(session) = &js_extension_session {
610 dirs.extend(session.resources.prompt_paths.iter().cloned());
611 }
612 dirs.extend(package_resources.prompt_dirs());
613 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
614 prompt_templates = result.prompt_templates;
615 prompt_diags = result.diagnostics;
616 resource_diags.extend(result.resource_diagnostics);
617 }
618
619 let context_block = if args.no_context_files || !project_trusted {
620 String::new()
621 } else {
622 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
627 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
628 format_project_context(&files)
629 };
630
631 if args.verbose {
633 for d in &package_resources.diagnostics {
634 eprintln!("warning: package {}: {}", d.spec, d.message);
635 }
636 for d in &skill_diags {
637 eprintln!(
638 "warning: skill {} ({}): {}",
639 d.path,
640 d.code.as_str(),
641 d.message
642 );
643 }
644 for d in &prompt_diags {
645 eprintln!(
646 "warning: prompt template {} ({}): {}",
647 d.path,
648 d.code.as_str(),
649 d.message
650 );
651 }
652 for d in &resource_diags {
654 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
655 eprintln!(
656 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
657 c.resource_type.as_str(),
658 c.name,
659 c.winner_path,
660 c.loser_path
661 );
662 }
663 }
664 }
665
666 let system_prompt = compose_system_prompt(
675 Some(&base_prompt),
676 &[], if context_block.is_empty() {
678 None
679 } else {
680 Some(&context_block)
681 },
682 append_join.as_deref(),
683 );
684
685 if args.debug_system_prompt {
694 eprintln!("=== --debug-system-prompt ===");
695 let base_src = if args.system_prompt.is_some() {
696 "--system-prompt"
697 } else if discover_system_prompt_file_with_packages(cwd, &package_resources).is_some() {
698 "SYSTEM.md"
699 } else {
700 "default"
701 };
702 eprintln!("[base source: {base_src}]");
703 eprintln!("--- base ---\n{base_prompt}");
704 if let Some(append) = append_join.as_deref() {
705 eprintln!("--- append ---\n{append}");
706 } else {
707 eprintln!("--- append: (none) ---");
708 }
709 if context_block.is_empty() {
710 eprintln!("--- context: (none) ---");
711 } else {
712 eprintln!("--- context ---{context_block}");
713 }
714 let visible_skills = skills
715 .iter()
716 .filter(|s| s.disable_model_invocation != Some(true))
717 .count();
718 eprintln!(
719 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
720 skills.len(),
721 visible_skills,
722 skills.len() - visible_skills
723 );
724 for s in &skills {
725 let hidden = if s.disable_model_invocation == Some(true) {
726 " [hidden]"
727 } else {
728 ""
729 };
730 eprintln!(" {}{hidden} — {}", s.name, s.description);
731 }
732 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
733 for t in &prompt_templates {
734 eprintln!(" /{}", t.name);
735 }
736 eprintln!(
740 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
741 discovered.skill_paths.len(),
742 discovered.prompt_paths.len(),
743 discovered.theme_paths.len(),
744 );
745 for p in &discovered.skill_paths {
746 eprintln!(" skill: {p}");
747 }
748 for p in &discovered.prompt_paths {
749 eprintln!(" prompt: {p}");
750 }
751 eprintln!(
752 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
753 );
754 eprintln!("=== end --debug-system-prompt ===");
755 }
756
757 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
762 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
763 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
767
768 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
777 Some(snapshot) => {
778 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
779 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
780 }
781 None => broadcast_emitter,
782 };
783
784 let saved_settings = crate::settings::load_settings().unwrap_or_default();
788
789 if let Some(proxy) = &saved_settings.http_proxy {
794 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
795 if std::env::var_os(key).is_none() {
796 std::env::set_var(key, proxy);
797 }
798 }
799 }
800
801 let options = AgentHarnessOptions {
802 model: resolved.model.clone(),
803 thinking_level: resolved.thinking_level,
804 active_tool_names: active,
805 tools,
806 system_prompt: Some(system_prompt),
807 resources: AgentHarnessResources {
808 skills: if skills.is_empty() {
809 None
810 } else {
811 Some(skills)
812 },
813 prompt_templates: if prompt_templates.is_empty() {
814 None
815 } else {
816 Some(prompt_templates)
817 },
818 },
819 allow_existing_session: matches!(
822 selection,
823 SessionSelection::Latest
824 | SessionSelection::ById { .. }
825 | SessionSelection::ByExactId { .. }
826 | SessionSelection::Fork { .. }
827 ),
828 stream_options: AgentHarnessStreamOptions {
829 timeout: args.timeout,
830 ..Default::default()
831 },
832 retry: saved_settings.retry_policy(),
837 compaction: saved_settings.compaction_settings(),
838 steering_mode: saved_settings.steering_mode(),
839 follow_up_mode: saved_settings.follow_up_mode(),
840 tool_execution: HarnessToolExecution::default(),
841 drive: DrivingMode::default(),
842 session,
843 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
851 to_provider_messages: None,
852 entry_projectors: Default::default(),
853 agent_emitter: Some(emitter),
854 before_tool_call: None,
860 after_tool_call: None,
861 transform_context: None,
862 entry_transforms: Vec::new(),
863 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
869 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
870 };
871
872 let harness = match AgentHarness::create(options).await {
873 Ok(h) => {
874 crate::extensions_actions::HarnessActionHost::set_harness(
877 &harness_cell,
878 Arc::new(h.clone()),
879 );
880 h
881 }
882 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
883 };
884
885 {
893 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
894 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
895 Ok(reports) => {
896 for (lane, report) in reports {
897 if report.is_clean() {
898 continue;
899 }
900 if report.is_corrupt() {
901 eprintln!(
902 "warning: session lane '{lane}' is corrupt ({} open operations); \
903 refusing to auto-repair — inspect the session file",
904 report.open_operations.len()
905 );
906 continue;
907 }
908 for decision in runtime.reconcile(&report) {
909 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
910 if args.verbose {
911 eprintln!(
912 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
913 );
914 }
915 }
916 }
917 runtime.emit_recovery_events(harness.events(), &report);
918 }
919 }
920 Err(e) => {
921 if args.verbose {
922 eprintln!("warning: session recovery sweep failed: {e}");
923 }
924 }
925 }
926 }
927
928 let reload_context = ReloadContext {
935 extension_session: Arc::new(Mutex::new(extension_session)),
936 js_extension_session: js_extension_session.clone(),
937 package_resources: Arc::new(package_resources.clone()),
938 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
939 catalog,
940 gateway: resolved.provider.clone(),
941 runtime: runtime.clone(),
942 cwd: cwd.to_path_buf(),
943 project_trusted,
944 args: args.clone(),
945 resolved_model: resolved.model.clone(),
946 broadcast: broadcast_for_context,
947 mailbox: reload_mailbox,
948 ui_dialog: ui_dialog_mailbox,
949 ext_status: ext_status_mailbox,
950 dev_extension: None,
951 };
952
953 Ok((harness, event_rx, reload_context))
954}
955
956pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
1001
1002pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
1007
1008#[derive(Clone)]
1016pub struct ReloadContext {
1017 pub extension_session: ExtensionSessionCell,
1019 pub js_extension_session: Option<crate::js_extensions::JsExtensionSession>,
1021 pub package_resources: Arc<crate::packages::PackageResources>,
1025 pub action_bridge: ActionBridgeCell,
1027 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
1030 pub catalog: Vec<rpi_ai::Model>,
1035 pub gateway: Arc<dyn Provider>,
1038 pub runtime: tokio::runtime::Handle,
1042 pub cwd: PathBuf,
1044 pub project_trusted: bool,
1048 pub args: Args,
1054 pub resolved_model: rpi_ai::Model,
1057 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
1063 pub mailbox: rpi_extensions::ReloadMailbox,
1070 pub ui_dialog: rpi_extensions::UiDialogMailbox,
1076 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
1080}
1081
1082pub async fn dispatch_session_event_async(
1097 reload_context: &ReloadContext,
1098 tag: rpi_plugin_sdk::EventTag,
1099) -> Option<String> {
1100 let (snapshot, keepalive) = {
1106 let session = reload_context.extension_session.lock().unwrap();
1107 (session.snapshot_arc(), session.keepalive())
1108 };
1109 match snapshot {
1110 Some(snapshot) => {
1111 let _keepalive = keepalive;
1112 rpi_extensions::dispatch_lifecycle_event(&snapshot, tag).await
1113 }
1114 None => None,
1115 }
1116}
1117
1118pub struct ReloadOutcome {
1121 pub summary: String,
1124 pub had_warnings: bool,
1126 pub details: Vec<String>,
1131 pub had_errors: bool,
1135}
1136
1137struct PreparedReloadInputs {
1138 package_resources: crate::packages::PackageResources,
1139 extension_dirs: Vec<PathBuf>,
1140 skill_base_dirs: Vec<PathBuf>,
1141 prompt_base_dirs: Vec<PathBuf>,
1142}
1143
1144fn append_reload_resource_paths(
1149 mut paths: Vec<PathBuf>,
1150 explicit: &[PathBuf],
1151 discovered: &[String],
1152 js_paths: &[PathBuf],
1153 package_paths: &[PathBuf],
1154) -> Vec<PathBuf> {
1155 paths.extend(explicit.iter().cloned());
1156 paths.extend(discovered.iter().map(PathBuf::from));
1157 paths.extend(js_paths.iter().cloned());
1158 paths.extend(package_paths.iter().cloned());
1159 paths
1160}
1161
1162pub async fn reload_extension_resources(
1174 harness: &AgentHarness,
1175 ctx: &ReloadContext,
1176) -> ReloadOutcome {
1177 reload_extension_resources_inner(harness, ctx, || {}).await
1178}
1179
1180async fn reload_extension_resources_inner<F>(
1181 harness: &AgentHarness,
1182 ctx: &ReloadContext,
1183 after_prepare: F,
1184) -> ReloadOutcome
1185where
1186 F: FnOnce() + Send,
1187{
1188 let mut effective_args = ctx.args.clone();
1193 if let Some(dev) = &ctx.dev_extension {
1194 if let Err(error) = dev
1195 .rebuild()
1196 .and_then(|_| dev.apply_to_args(&mut effective_args))
1197 {
1198 return ReloadOutcome {
1199 summary: format!(
1200 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1201 dev.package_name()
1202 ),
1203 had_warnings: true,
1204 details: Vec::new(),
1205 had_errors: true,
1206 };
1207 }
1208 }
1209 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1210 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1211 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1212 Ok(prepared) => prepared,
1213 Err(error) => {
1214 return ReloadOutcome {
1215 summary: format!(
1216 "Settings reload failed: {error}. Keeping the currently loaded resources."
1217 ),
1218 had_warnings: true,
1219 details: Vec::new(),
1220 had_errors: true,
1221 };
1222 }
1223 };
1224 after_prepare();
1225 let PreparedReloadInputs {
1226 package_resources,
1227 extension_dirs,
1228 skill_base_dirs,
1229 prompt_base_dirs,
1230 } = prepared;
1231 let mut warnings = false;
1232
1233 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1240 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1241 Some(b) => b.clone_host(),
1242 None => {
1243 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1246 ctx.catalog.clone(),
1247 ctx.cwd.clone(),
1248 ctx.runtime.clone(),
1249 ctx.args.unknown_flags.clone(),
1250 );
1251 crate::extensions_actions::HarnessActionHost::set_harness(
1252 &_cell,
1253 Arc::new(harness.clone()),
1254 );
1255 Arc::new(action_host)
1256 }
1257 };
1258
1259 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1260 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1261 ctx.runtime.clone(),
1262 host,
1263 reload_cb,
1264 ctx.ui_dialog.clone(),
1265 ctx.ext_status.clone(),
1266 );
1267
1268 let extension_session = if effective_args.no_extensions {
1269 rpi_extensions::ExtensionSession::none()
1270 } else {
1271 load_extensions_from_dirs(
1272 &effective_args,
1273 &extension_dirs,
1274 Some(Arc::clone(&fresh_bridge)),
1275 )
1276 };
1277 if extension_session.is_empty() && !effective_args.no_extensions {
1278 }
1281 if effective_args.verbose {
1282 if let Some(s) = extension_session.summary() {
1283 eprintln!("reload: {s}");
1284 }
1285 report_deferred_renderers(&extension_session);
1286 }
1287
1288 {
1302 let mut session_guard = ctx.extension_session.lock().unwrap();
1303 let old_session = std::mem::replace(
1304 &mut *session_guard,
1305 rpi_extensions::ExtensionSession::none(),
1306 );
1307 if let Some(old_snap) = old_session.snapshot_arc() {
1308 old_snap.active_flag().store(false, Ordering::SeqCst);
1312 }
1313 }
1317 if let Some(old_b) = old_bridge {
1318 old_b.invalidate();
1319 }
1320
1321 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1324 *ctx.extension_session.lock().unwrap() = extension_session.clone();
1325
1326 let discovered = extension_session
1328 .snapshot_arc()
1329 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1330 .unwrap_or_default();
1331
1332 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1334 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1335
1336 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1337 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1338 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1339 if !effective_args.no_skills {
1340 let js_paths: &[PathBuf] = ctx
1344 .js_extension_session
1345 .as_ref()
1346 .map(|js| js.resources.skill_paths.as_slice())
1347 .unwrap_or(&[]);
1348 let package_paths = package_resources.skill_dirs();
1349 let dirs = append_reload_resource_paths(
1350 skill_base_dirs,
1351 &effective_args.skill,
1352 &discovered.skill_paths,
1353 js_paths,
1354 &package_paths,
1355 );
1356 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1357 skills = result.skills;
1358 skill_diags = result.diagnostics;
1359 resource_diags.extend(result.resource_diagnostics);
1360 }
1361
1362 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1363 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1364 if !effective_args.no_prompt_templates {
1365 let js_paths: &[PathBuf] = ctx
1366 .js_extension_session
1367 .as_ref()
1368 .map(|js| js.resources.prompt_paths.as_slice())
1369 .unwrap_or(&[]);
1370 let package_paths = package_resources.prompt_dirs();
1371 let dirs = append_reload_resource_paths(
1372 prompt_base_dirs,
1373 &effective_args.prompt_template,
1374 &discovered.prompt_paths,
1375 js_paths,
1376 &package_paths,
1377 );
1378 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1379 prompt_templates = result.prompt_templates;
1380 prompt_diags = result.diagnostics;
1381 resource_diags.extend(result.resource_diagnostics);
1382 }
1383
1384 let context_block = if effective_args.no_context_files {
1385 String::new()
1386 } else {
1387 let agent_dir = crate::config::agent_dir().ok();
1388 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1389 let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
1390 format_project_context(&files)
1391 };
1392
1393 let mut details: Vec<String> = Vec::new();
1394 if !skill_diags.is_empty()
1395 || !prompt_diags.is_empty()
1396 || !package_resources.diagnostics.is_empty()
1397 || !resource_diags.is_empty()
1398 {
1399 warnings = true;
1400 for d in &package_resources.diagnostics {
1405 details.push(format!("warning: package {}: {}", d.spec, d.message));
1406 }
1407 for d in &skill_diags {
1408 details.push(format!(
1409 "warning: skill {} ({}): {}",
1410 d.path,
1411 d.code.as_str(),
1412 d.message
1413 ));
1414 }
1415 for d in &prompt_diags {
1416 details.push(format!(
1417 "warning: prompt template {} ({}): {}",
1418 d.path,
1419 d.code.as_str(),
1420 d.message
1421 ));
1422 }
1423 for d in &resource_diags {
1425 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1426 details.push(format!(
1427 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1428 c.resource_type.as_str(),
1429 c.name,
1430 c.winner_path,
1431 c.loser_path
1432 ));
1433 }
1434 }
1435 }
1436
1437 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1442 let base_prompt = match effective_args.system_prompt.as_deref() {
1443 Some(explicit) => explicit.to_string(),
1444 None => match discover_system_prompt_file_with_packages(&ctx.cwd, &package_resources) {
1445 Some(path) => std::fs::read_to_string(&path)
1446 .unwrap_or_else(|_| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
1447 None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1448 },
1449 };
1450 let mut append_texts: Vec<String> = Vec::new();
1451 for extra in &effective_args.append_system_prompt {
1452 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
1453 append_texts.push(text);
1454 }
1455 if effective_args.append_system_prompt.is_empty() {
1456 if let Some(path) =
1457 discover_append_system_prompt_file_with_packages(&ctx.cwd, &package_resources)
1458 {
1459 if let Ok(text) = std::fs::read_to_string(&path) {
1460 append_texts.push(text);
1461 }
1462 }
1463 }
1464 let append_join = if append_texts.is_empty() {
1465 None
1466 } else {
1467 Some(append_texts.join("\n\n"))
1468 };
1469 let system_prompt = compose_system_prompt(
1470 Some(&base_prompt),
1471 &[],
1472 if context_block.is_empty() {
1473 None
1474 } else {
1475 Some(&context_block)
1476 },
1477 append_join.as_deref(),
1478 );
1479
1480 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1482 Some(snapshot) => {
1483 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1484 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1485 }
1486 None => ctx.broadcast.clone(),
1487 };
1488
1489 let resources = AgentHarnessResources {
1491 skills: if skills.is_empty() {
1492 None
1493 } else {
1494 Some(skills.clone())
1495 },
1496 prompt_templates: if prompt_templates.is_empty() {
1497 None
1498 } else {
1499 Some(prompt_templates.clone())
1500 },
1501 };
1502 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1503 let _ = harness.set_resources(resources).await;
1504 let _ = harness.set_agent_emitter(Some(emitter)).await;
1505 let _ = harness
1506 .set_models(build_models_with_extensions_for_reload(
1507 &ctx.gateway,
1508 &extension_session,
1509 ctx.runtime.clone(),
1510 ))
1511 .await;
1512 let _ = harness
1513 .set_provider_hooks(
1514 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1515 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1516 )
1517 .await;
1518
1519 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1523 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1524 let mut tools = build_tools(&tool_ctx, &effective_args);
1525 merge_extension_tools(&mut tools, &extension_session, &effective_args);
1526 if let Some(js) = &ctx.js_extension_session {
1527 merge_js_extension_tools(&mut tools, js, &effective_args);
1528 }
1529 let mut active = active_tool_names(&tools, &effective_args);
1530 if let Some(js) = &ctx.js_extension_session {
1531 let js_names = js.tool_names();
1532 if let Some(js_active) = js.active_tools() {
1533 active.retain(|name| {
1534 tool_name_allowed(name, &effective_args)
1535 && !js_names.iter().any(|js_name| js_name == name)
1536 });
1537 active.extend(js_active.into_iter().filter(|name| {
1538 js_names.iter().any(|js_name| js_name == name)
1539 && tool_name_allowed(name, &effective_args)
1540 }));
1541 }
1542 }
1543 active = filter_active_tool_names(active, &effective_args);
1544 let _ = harness.set_tools(tools, Some(active)).await;
1545
1546 let summary = format!(
1547 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1548 extension_session.loaded_paths().len(),
1549 skills.len(),
1550 prompt_templates.len(),
1551 );
1552 ReloadOutcome {
1553 summary,
1554 had_warnings: warnings,
1555 details,
1556 had_errors: false,
1559 }
1560}
1561
1562#[derive(Clone, Debug, PartialEq, Eq)]
1563struct ReloadSettingsFields {
1564 packages: Option<Vec<crate::settings::PackageSetting>>,
1565 npm_command: Option<Vec<String>>,
1566 skill_dirs: Option<Vec<String>>,
1567 prompt_dirs: Option<Vec<String>>,
1568 extension_dirs: Option<Vec<String>>,
1569}
1570
1571impl From<crate::settings::Settings> for ReloadSettingsFields {
1572 fn from(settings: crate::settings::Settings) -> Self {
1573 Self {
1574 packages: settings.packages,
1575 npm_command: settings.npm_command,
1576 skill_dirs: settings.skill_dirs,
1577 prompt_dirs: settings.prompt_dirs,
1578 extension_dirs: settings.extension_dirs,
1579 }
1580 }
1581}
1582
1583#[derive(Clone, Debug, PartialEq, Eq)]
1584struct ReloadSettingsSnapshot {
1585 global: ReloadSettingsFields,
1586 project: Option<ReloadSettingsFields>,
1587}
1588
1589fn reload_reads_settings(args: &Args) -> bool {
1590 !args.dev_local_only
1591 && (should_load_js_packages(args)
1592 || !args.no_extensions
1593 || !args.no_skills
1594 || !args.no_prompt_templates)
1595}
1596
1597fn load_reload_settings_snapshot(
1601 args: &Args,
1602 cwd: &Path,
1603 project_trusted: bool,
1604) -> Result<Option<ReloadSettingsSnapshot>, String> {
1605 if !reload_reads_settings(args) {
1606 return Ok(None);
1607 }
1608 let global = crate::settings::load_settings()
1609 .map_err(|error| format!("could not load global settings: {error}"))?
1610 .into();
1611 let project = if project_trusted {
1612 crate::settings::load_active_project_settings(cwd)
1613 .map_err(|error| format!("could not load project settings: {error}"))?
1614 .map(|(_, settings)| settings.into())
1615 } else {
1616 None
1617 };
1618 Ok(Some(ReloadSettingsSnapshot { global, project }))
1619}
1620
1621#[cfg(test)]
1624fn validate_settings_for_reload(
1625 args: &Args,
1626 cwd: &Path,
1627 project_trusted: bool,
1628) -> Result<(), String> {
1629 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1630}
1631
1632fn prepare_reload_inputs(
1633 args: &Args,
1634 cwd: &Path,
1635 project_trusted: bool,
1636) -> Result<PreparedReloadInputs, String> {
1637 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1638}
1639
1640fn prepare_reload_inputs_inner<F>(
1641 args: &Args,
1642 cwd: &Path,
1643 project_trusted: bool,
1644 before_verify: F,
1645) -> Result<PreparedReloadInputs, String>
1646where
1647 F: FnOnce(),
1648{
1649 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1650
1651 let package_resources = if args.dev_local_only {
1652 crate::packages::PackageResources::default()
1653 } else {
1654 package_resources_for(args, cwd, project_trusted)
1655 };
1656
1657 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1658 Vec::new()
1659 } else if project_trusted {
1660 extension_dirs(cwd)
1661 } else {
1662 global_extension_dirs()
1663 };
1664 if !args.no_extensions {
1665 extension_dirs.extend(args.extensions_dir.iter().cloned());
1666 }
1667
1668 let skill_base_dirs = if args.no_skills {
1669 Vec::new()
1670 } else if args.dev_local_only {
1671 project_skill_dirs(cwd)
1672 } else if project_trusted {
1673 skill_dirs(cwd)
1674 } else {
1675 global_skill_dirs()
1676 };
1677
1678 let prompt_base_dirs = if args.no_prompt_templates {
1679 Vec::new()
1680 } else if args.dev_local_only {
1681 project_prompt_template_dirs(cwd)
1682 } else if project_trusted {
1683 prompt_template_dirs(cwd)
1684 } else {
1685 global_prompt_template_dirs()
1686 };
1687
1688 before_verify();
1689 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1690 if settings_before != settings_after {
1691 return Err(
1692 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1693 );
1694 }
1695
1696 Ok(PreparedReloadInputs {
1697 package_resources,
1698 extension_dirs,
1699 skill_base_dirs,
1700 prompt_base_dirs,
1701 })
1702}
1703
1704fn build_models_with_extensions_for_reload(
1709 gateway: &Arc<dyn Provider>,
1710 extension_session: &ExtensionSession,
1711 runtime: tokio::runtime::Handle,
1712) -> Vec<Arc<dyn Provider>> {
1713 let mut models: Vec<Arc<dyn Provider>> = vec![gateway.clone()];
1714 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1715 models.extend(pluggable);
1716 models
1717}
1718
1719fn report_deferred_renderers(session: &ExtensionSession) {
1723 let Some(snap) = session.snapshot_arc() else {
1724 return;
1725 };
1726 let all = snap.renderers();
1727 let markdown = all
1728 .iter()
1729 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1730 .count();
1731 let message = all
1732 .iter()
1733 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1734 .count();
1735 let entry = all
1736 .iter()
1737 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1738 .count();
1739 if markdown + message + entry == 0 {
1740 return;
1741 }
1742 eprintln!(
1743 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1744 markdown, message, entry
1745 );
1746}
1747
1748#[derive(Debug, thiserror::Error)]
1750pub enum BuildError {
1751 #[error("Could not create the session directory: {0}")]
1752 SessionDir(String),
1753 #[error("Could not open the saved session: {0}")]
1754 SessionOpen(String),
1755 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1756 SessionNotFound { requested: String, dir: String },
1757 #[error("Could not build the harness: {0}")]
1758 HarnessCreate(String),
1759}
1760
1761fn build_models_with_extensions(
1771 resolved: &ResolvedModel,
1772 extension_session: &ExtensionSession,
1773 runtime: tokio::runtime::Handle,
1774) -> Vec<Arc<dyn Provider>> {
1775 let mut models: Vec<Arc<dyn Provider>> = vec![resolved.provider.clone() as Arc<dyn Provider>];
1776 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1777 models.extend(pluggable);
1778 models
1779}
1780
1781pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1784 if let Some(override_value) = args.trust_override {
1785 return override_value;
1786 }
1787 crate::config::project_trust_decision(cwd)
1788 .ok()
1789 .flatten()
1790 .unwrap_or(true)
1791}
1792
1793fn load_extensions(
1801 args: &Args,
1802 cwd: &Path,
1803 project_trusted: bool,
1804 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1805) -> ExtensionSession {
1806 let mut dirs = if args.dev_local_only {
1807 Vec::new()
1808 } else if project_trusted {
1809 extension_dirs(cwd)
1810 } else {
1811 global_extension_dirs()
1812 };
1813 dirs.extend(args.extensions_dir.iter().cloned());
1814 load_extensions_from_dirs(args, &dirs, action_bridge)
1815}
1816
1817fn load_extensions_from_dirs(
1818 args: &Args,
1819 dirs: &[PathBuf],
1820 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1821) -> ExtensionSession {
1822 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1823 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1830}
1831
1832fn js_extension_paths(
1833 args: &Args,
1834 cwd: &Path,
1835 project_trusted: bool,
1836 packages: &crate::packages::PackageResources,
1837) -> Vec<PathBuf> {
1838 if args.dev_local_only {
1839 return Vec::new();
1840 }
1841 let mut paths = packages.extension_paths();
1842 let discovered_dirs = if project_trusted {
1843 extension_dirs(cwd)
1844 } else {
1845 global_extension_dirs()
1846 };
1847 for dir in discovered_dirs {
1848 if let Ok(entries) = std::fs::read_dir(dir) {
1849 paths.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
1850 matches!(
1851 path.extension()
1852 .and_then(|ext| ext.to_str())
1853 .map(|ext| ext.to_ascii_lowercase())
1854 .as_deref(),
1855 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1856 )
1857 }));
1858 }
1859 }
1860 paths.extend(
1861 args.extension
1862 .iter()
1863 .filter(|path| {
1864 matches!(
1865 path.extension()
1866 .and_then(|ext| ext.to_str())
1867 .map(|ext| ext.to_ascii_lowercase())
1868 .as_deref(),
1869 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1870 )
1871 })
1872 .cloned(),
1873 );
1874 paths
1875}
1876
1877fn merge_extension_tools(tools: &mut Vec<HarnessTool>, session: &ExtensionSession, args: &Args) {
1883 let Some(snapshot) = session.snapshot() else {
1884 return;
1885 };
1886 for et in snapshot.tools() {
1887 let name = &et.tool.name;
1888 if !tool_name_allowed(name, args) {
1889 continue;
1890 }
1891 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive());
1892 let harness_tool = HarnessTool::new(Arc::new(adapter));
1893 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1894 Some(slot) => *slot = harness_tool,
1895 None => tools.push(harness_tool),
1896 }
1897 }
1898}
1899
1900fn merge_js_extension_tools(
1901 tools: &mut Vec<HarnessTool>,
1902 session: &crate::js_extensions::JsExtensionSession,
1903 args: &Args,
1904) {
1905 for adapter in session.tools() {
1906 let name = adapter.schema().name.clone();
1907 if !tool_name_allowed(&name, args) {
1908 continue;
1909 }
1910 let harness_tool = HarnessTool::new(Arc::new(adapter));
1911 match tools
1912 .iter_mut()
1913 .find(|tool| tool.tool.schema().name == name)
1914 {
1915 Some(slot) => *slot = harness_tool,
1916 None => tools.push(harness_tool),
1917 }
1918 }
1919}
1920
1921pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1928 use rpi_tools::tools::bash::BashToolOptions;
1929 let default = std::env::var("RPI_BASH_TIMEOUT")
1930 .ok()
1931 .and_then(|v| v.parse::<f64>().ok())
1932 .unwrap_or(120.0);
1933 let settings = crate::settings::load_settings().unwrap_or_default();
1939 BashToolOptions {
1940 command_prefix: settings.shell_command_prefix.clone(),
1941 default_timeout: Some(default),
1942 }
1943}
1944
1945fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1946 if args.no_tools {
1947 return Vec::new();
1948 }
1949 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1958 (
1959 "read",
1960 HarnessTool::new(create_read_tool(ctx, None)).with_replay(ToolReplay::Safe),
1961 ),
1962 (
1963 "bash",
1964 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1965 ),
1966 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1967 ("write", HarnessTool::new(create_write_tool(ctx))),
1968 (
1969 "docs",
1970 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
1971 ),
1972 ];
1973
1974 #[cfg(target_os = "windows")]
1976 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
1977
1978 if args.tools.is_none() {
1980 if let Ok(settings) = crate::settings::load_settings() {
1981 if let Some(default_tools) = &settings.default_tools {
1982 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
1983 }
1984 }
1985 }
1986
1987 if args.no_builtin_tools {
1991 all.clear();
1992 }
1993
1994 if let Some(allow) = &args.tools {
1996 all.retain(|(name, _)| allow.iter().any(|a| a == name));
1997 }
1998 if let Some(deny) = &args.exclude_tools {
2000 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
2001 }
2002
2003 all.into_iter().map(|(_, t)| t).collect()
2004}
2005
2006fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
2010 filter_active_tool_names(
2011 tools.iter().map(|tool| tool.tool.schema().name.clone()),
2012 args,
2013 )
2014}
2015
2016pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
2020 if args.no_tools {
2021 return false;
2022 }
2023 if args
2024 .tools
2025 .as_ref()
2026 .is_some_and(|allow| !allow.iter().any(|value| value == name))
2027 {
2028 return false;
2029 }
2030 if args
2031 .exclude_tools
2032 .as_ref()
2033 .is_some_and(|deny| deny.iter().any(|value| value == name))
2034 {
2035 return false;
2036 }
2037 true
2038}
2039
2040pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
2041where
2042 I: IntoIterator<Item = String>,
2043{
2044 names
2045 .into_iter()
2046 .filter(|name| tool_name_allowed(name, args))
2047 .collect()
2048}
2049
2050async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2052 match selection {
2053 SessionSelection::Ephemeral => Ok(ephemeral_session()),
2054 SessionSelection::New { dir, .. } => {
2055 std::fs::create_dir_all(dir)
2058 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2059 let session = create_jsonl_session(dir, cwd)
2060 .await
2061 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2062 Ok(session)
2063 }
2064 SessionSelection::Latest
2065 | SessionSelection::ById { .. }
2066 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2067 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
2068 }
2069}
2070
2071async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2077 match selection {
2081 SessionSelection::Latest => {
2082 let metas = list_session_metadata(cwd).await?;
2083 let Some(meta) = first_resumable_session(&metas) else {
2084 return Err(BuildError::SessionNotFound {
2085 requested: "the most recent session".to_string(),
2086 dir: default_session_dir(Path::new(cwd)).display().to_string(),
2087 });
2088 };
2089 open_session(meta, cwd).await
2090 }
2091 SessionSelection::ById { id } => open_session_by_id(id, cwd).await.map_err(|e| match e {
2092 OpenError::NotFound { requested } => BuildError::SessionNotFound {
2093 requested,
2094 dir: default_session_dir(Path::new(cwd)).display().to_string(),
2095 },
2096 OpenError::Other(msg) => BuildError::SessionOpen(msg),
2097 }),
2098 SessionSelection::ByExactId { id } => {
2099 let metas = list_session_metadata(cwd).await?;
2102 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
2103 return open_session(meta, cwd).await;
2104 }
2105 let dir = default_session_dir(Path::new(cwd));
2106 std::fs::create_dir_all(&dir)
2107 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2108 create_jsonl_session_with_id(&dir, cwd, Some(id.clone()))
2109 .await
2110 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2111 }
2112 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
2113 }
2114}
2115
2116async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
2119 use rpi_harness::session::jsonl::{
2120 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2121 };
2122 use rpi_harness::session::types::{ForkOptions, SessionStorage};
2123 use rpi_tools::FileSystem;
2124
2125 let dir = default_session_dir(Path::new(cwd));
2126 std::fs::create_dir_all(&dir)
2127 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2128 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2129 let fs: Arc<dyn FileSystem> = env.clone();
2130 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2131 fs: fs.clone(),
2132 sessions_root: dir.to_string_lossy().into_owned(),
2133 clock: Arc::new(SystemClock),
2134 ids: Arc::new(DefaultIdGenerator::new()),
2135 });
2136 let metas = repo
2137 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2138 .await
2139 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2140 let source_meta = metas
2141 .iter()
2142 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2143 .ok_or_else(|| BuildError::SessionNotFound {
2144 requested: format!("--fork {source}"),
2145 dir: dir.display().to_string(),
2146 })?;
2147 let fork_storage = repo
2148 .fork_typed(
2149 source_meta,
2150 &JsonlSessionCreateOptions {
2151 id: None,
2152 parent_session_id: Some(source_meta.id.clone()),
2153 cwd: cwd.to_string(),
2154 metadata: None,
2155 },
2156 &ForkOptions::default(),
2157 )
2158 .await
2159 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2160 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2161 Ok(Session::new(storage_arc, None))
2162}
2163
2164pub enum OpenError {
2167 NotFound { requested: String },
2169 Other(String),
2171}
2172
2173impl std::fmt::Display for OpenError {
2174 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2175 match self {
2176 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2177 OpenError::Other(msg) => write!(f, "{msg}"),
2178 }
2179 }
2180}
2181
2182pub async fn list_session_metadata(
2185 cwd: &str,
2186) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2187 use rpi_harness::session::jsonl::{
2188 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2189 };
2190 use rpi_tools::FileSystem;
2191
2192 let dir = default_session_dir(Path::new(cwd));
2193 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2194 let fs: Arc<dyn FileSystem> = env.clone();
2195 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2196 fs: fs.clone(),
2197 sessions_root: dir.to_string_lossy().into_owned(),
2198 clock: Arc::new(SystemClock),
2199 ids: Arc::new(DefaultIdGenerator::new()),
2200 });
2201 repo.list_typed(&JsonlSessionListOptions::default())
2202 .await
2203 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2204}
2205
2206#[derive(Debug, Clone, Default, PartialEq, Eq)]
2222pub struct SessionSummary {
2223 pub name: Option<String>,
2225 pub preview: Option<String>,
2227 pub empty: bool,
2229 pub bytes: u64,
2231}
2232
2233const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
2235const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
2237const SUMMARY_PREVIEW_CHARS: usize = 80;
2239
2240pub fn summarize_session_file(path: &Path) -> SessionSummary {
2244 use std::io::{Read, Seek, SeekFrom};
2245
2246 let Ok(mut file) = std::fs::File::open(path) else {
2247 return SessionSummary::default();
2248 };
2249 let Ok(size) = file.metadata().map(|m| m.len()) else {
2250 return SessionSummary::default();
2251 };
2252 if size == 0 {
2253 return SessionSummary {
2254 name: None,
2255 preview: None,
2256 empty: true,
2257 bytes: 0,
2258 };
2259 }
2260
2261 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2264 let head_len = file.read(&mut head).unwrap_or(0);
2265 head.truncate(head_len);
2266 let head = String::from_utf8_lossy(&head);
2267 let mut lines: Vec<&str> = head.lines().collect();
2268 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2269 let empty = (size as usize) <= header_len + 1;
2271 if (head_len as u64) < size && !lines.is_empty() {
2274 lines.pop();
2275 }
2276
2277 let mut summary = SessionSummary {
2278 name: None,
2279 preview: None,
2280 empty,
2281 bytes: size,
2282 };
2283 for line in lines {
2284 apply_summary_line(line, &mut summary);
2285 }
2286
2287 if size > SUMMARY_HEAD_BYTES
2291 && file
2292 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2293 .is_ok()
2294 {
2295 let mut tail = Vec::new();
2296 if file.read_to_end(&mut tail).is_ok() {
2297 let tail = String::from_utf8_lossy(&tail);
2298 let mut tail_lines = tail.lines();
2299 tail_lines.next();
2301 for line in tail_lines {
2302 if let Some(name) = name_from_line(line) {
2303 summary.name = name;
2304 }
2305 }
2306 }
2307 }
2308 summary
2309}
2310
2311fn name_from_line(line: &str) -> Option<Option<String>> {
2314 let value: serde_json::Value = serde_json::from_str(line).ok()?;
2315 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2316 return None;
2317 }
2318 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2319 return None;
2320 }
2321 Some(match value.get("name") {
2322 Some(serde_json::Value::String(name)) => Some(name.clone()),
2323 _ => None,
2324 })
2325}
2326
2327fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2330 if let Some(name) = name_from_line(line) {
2331 summary.name = name;
2332 return;
2333 }
2334 if summary.preview.is_some() {
2335 return;
2336 }
2337 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2338 return;
2339 };
2340 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2341 return;
2342 }
2343 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2344 return;
2345 }
2346 let Some(message) = value.get("message") else {
2347 return;
2348 };
2349 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2350 return;
2351 }
2352 let text = match message.get("content") {
2353 Some(serde_json::Value::String(text)) => Some(text.as_str()),
2354 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2355 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2356 .then(|| block.get("text").and_then(|v| v.as_str()))
2357 .flatten()
2358 }),
2359 _ => None,
2360 };
2361 let Some(text) = text else { return };
2362 let collapsed = text
2363 .lines()
2364 .map(str::trim)
2365 .filter(|line| !line.is_empty())
2366 .collect::<Vec<_>>()
2367 .join(" ");
2368 if collapsed.is_empty() {
2369 return;
2370 }
2371 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2372}
2373
2374pub fn session_display_label(summary: &SessionSummary) -> String {
2378 if let Some(name) = summary.name.as_deref().map(str::trim) {
2379 if !name.is_empty() {
2380 return name.to_string();
2381 }
2382 }
2383 match summary.preview.as_deref().map(str::trim) {
2384 Some(preview) if !preview.is_empty() => preview.to_string(),
2385 _ => "(no messages)".to_string(),
2386 }
2387}
2388
2389pub fn format_session_bytes(bytes: u64) -> String {
2392 if bytes >= 1_048_576 {
2393 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2394 } else if bytes >= 1024 {
2395 format!("{}KB", bytes / 1024)
2396 } else {
2397 format!("{bytes}B")
2398 }
2399}
2400
2401pub fn short_session_id(id: &str) -> &str {
2404 &id[..id.len().min(8)]
2405}
2406
2407pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2416 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2417 let mut visit = |path: PathBuf| {
2418 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2419 return;
2420 }
2421 let modified = std::fs::metadata(&path)
2422 .and_then(|m| m.modified())
2423 .unwrap_or(std::time::UNIX_EPOCH);
2424 files.push((path, modified));
2425 };
2426 if let Ok(entries) = std::fs::read_dir(dir) {
2427 for entry in entries.flatten() {
2428 let path = entry.path();
2429 if path.is_dir() {
2430 if let Ok(inner) = std::fs::read_dir(&path) {
2431 for child in inner.flatten() {
2432 visit(child.path());
2433 }
2434 }
2435 } else {
2436 visit(path);
2437 }
2438 }
2439 }
2440 files.sort_by(|a, b| b.1.cmp(&a.1));
2441 files.into_iter().map(|(path, _)| path).collect()
2442}
2443
2444fn first_resumable_session<'a>(
2449 metas: &'a [rpi_harness::session::jsonl::JsonlSessionMetadata],
2450) -> Option<&'a rpi_harness::session::jsonl::JsonlSessionMetadata> {
2451 metas
2452 .iter()
2453 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2454 .or_else(|| metas.first())
2455}
2456
2457pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2461 let metas = list_session_metadata(cwd)
2462 .await
2463 .map_err(|e| OpenError::Other(e.to_string()))?;
2464 let Some(meta) = metas
2465 .iter()
2466 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2467 else {
2468 return Err(OpenError::NotFound {
2469 requested: format!("session {id}"),
2470 });
2471 };
2472 open_session(meta, cwd)
2473 .await
2474 .map_err(|e| OpenError::Other(e.to_string()))
2475}
2476
2477pub(crate) async fn fork_session_storage(
2484 harness: &AgentHarness,
2485 cwd: &str,
2486) -> Result<Session, String> {
2487 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2488 use rpi_tools::FileSystem;
2489
2490 let dir = default_session_dir(Path::new(cwd));
2491 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2492 let fs: Arc<dyn FileSystem> = env.clone();
2493 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2494 fs,
2495 sessions_root: dir.to_string_lossy().into_owned(),
2496 clock: Arc::new(SystemClock),
2497 ids: Arc::new(DefaultIdGenerator::new()),
2498 });
2499 let id = harness.session().storage().metadata().id.clone();
2502 let metas = list_session_metadata(cwd)
2503 .await
2504 .map_err(|e| e.to_string())?;
2505 let Some(source) = metas.iter().find(|m| m.id == id) else {
2506 return Err(format!("current session {id} not found on disk"));
2507 };
2508 let fork_storage = repo
2509 .fork_typed(
2510 source,
2511 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2512 id: None,
2513 parent_session_id: Some(source.id.clone()),
2514 cwd: cwd.to_string(),
2515 metadata: None,
2516 },
2517 &rpi_harness::session::types::ForkOptions::default(),
2518 )
2519 .await
2520 .map_err(|e| e.to_string())?;
2521 Ok(Session::new(Arc::new(fork_storage), None))
2522}
2523
2524async fn open_session(
2527 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2528 cwd: &str,
2529) -> Result<Session, BuildError> {
2530 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2531 use rpi_harness::session::types::SessionStorage;
2532 use rpi_tools::FileSystem;
2533
2534 let dir = default_session_dir(Path::new(cwd));
2535 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2536 let fs: Arc<dyn FileSystem> = env.clone();
2537 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2538 fs: fs.clone(),
2539 sessions_root: dir.to_string_lossy().into_owned(),
2540 clock: Arc::new(SystemClock),
2541 ids: Arc::new(DefaultIdGenerator::new()),
2542 });
2543 let storage = repo
2544 .open_by_jsonl_metadata(meta)
2545 .await
2546 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2547 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2551 Some(Path::new(&meta.path)),
2552 Path::new(&meta.cwd),
2553 Path::new(cwd),
2554 ) {
2555 eprintln!(
2556 "warning: {}",
2557 crate::session_cwd::format_missing_session_cwd_error(&issue)
2558 );
2559 }
2560 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2561 Ok(Session::new(storage_arc, None))
2562}
2563
2564fn ephemeral_session() -> Session {
2566 let storage = Arc::new(InMemorySessionStorage::new(
2567 SessionMetadata {
2568 id: "ephemeral".into(),
2569 created_at: 0,
2570 parent_session_id: None,
2571 },
2572 Arc::new(SystemClock),
2573 Arc::new(DefaultIdGenerator::new()),
2574 ));
2575 Session::new(storage, None)
2576}
2577
2578pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2589 create_jsonl_session_with_id(dir, cwd, None).await
2590}
2591
2592pub(crate) async fn create_jsonl_session_with_id(
2596 dir: &Path,
2597 cwd: &str,
2598 id: Option<String>,
2599) -> Result<Session, String> {
2600 use rpi_harness::session::jsonl::{
2601 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2602 };
2603 use rpi_tools::FileSystem;
2604
2605 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2608 let fs: Arc<dyn FileSystem> = env.clone();
2609
2610 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2611 fs: fs.clone(),
2612 sessions_root: dir.to_string_lossy().into_owned(),
2613 clock: Arc::new(SystemClock),
2614 ids: Arc::new(DefaultIdGenerator::new()),
2615 });
2616
2617 let opts = JsonlSessionCreateOptions {
2618 id, parent_session_id: None,
2620 cwd: cwd.to_string(),
2621 metadata: None,
2622 };
2623 let storage = repo
2624 .create_typed(&opts)
2625 .await
2626 .map_err(|e| format!("create session: {e}"))?;
2627 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2629 Ok(Session::new(storage_arc, None))
2630}
2631
2632fn read_append_target(target: &str) -> Option<String> {
2635 let path = Path::new(target);
2636 if path.is_file() {
2637 std::fs::read_to_string(path).ok()
2638 } else {
2639 None
2640 }
2641}
2642
2643#[cfg(test)]
2644mod tests {
2645 use super::*;
2646 use crate::args::Args;
2647
2648 fn scratch(name: &str) -> PathBuf {
2650 let nanos = std::time::SystemTime::now()
2651 .duration_since(std::time::UNIX_EPOCH)
2652 .unwrap()
2653 .as_nanos();
2654 let path = std::env::temp_dir().join(format!(
2655 "rpi-summary-{}-{nanos}-{name}.jsonl",
2656 std::process::id()
2657 ));
2658 path
2659 }
2660
2661 #[test]
2662 fn summary_marks_a_header_only_session_empty() {
2663 let path = scratch("empty");
2664 std::fs::write(
2665 &path,
2666 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2667 )
2668 .unwrap();
2669 let summary = summarize_session_file(&path);
2670 assert!(summary.empty, "a header-only file has nothing to resume");
2671 assert!(summary.preview.is_none());
2672 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
2673 std::fs::remove_file(&path).ok();
2674 }
2675
2676 #[test]
2677 fn summary_reads_the_first_user_prompt_and_name() {
2678 let path = scratch("content");
2679 let body = concat!(
2680 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2681 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
2682 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
2683 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
2684 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
2685 );
2686 std::fs::write(&path, body).unwrap();
2687 let summary = summarize_session_file(&path);
2688 assert!(!summary.empty);
2689 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
2690 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
2691 std::fs::remove_file(&path).ok();
2692 }
2693
2694 #[test]
2695 fn summary_extracts_a_block_style_user_prompt() {
2696 let path = scratch("blocks");
2697 let body = concat!(
2698 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2699 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
2700 );
2701 std::fs::write(&path, body).unwrap();
2702 let summary = summarize_session_file(&path);
2703 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
2704 assert_eq!(session_display_label(&summary), "line one line two");
2705 std::fs::remove_file(&path).ok();
2706 }
2707
2708 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
2709 rpi_harness::session::jsonl::JsonlSessionMetadata {
2710 id: id.to_string(),
2711 created_at: 0,
2712 cwd: ".".to_string(),
2713 path: path.to_string_lossy().into_owned(),
2714 modified_at: 0,
2715 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
2716 parent_session_id: None,
2717 legacy_parent_session_path: None,
2718 metadata: None,
2719 }
2720 }
2721
2722 #[test]
2723 fn continue_skips_newer_empty_sessions() {
2724 let empty = scratch("newest-empty");
2725 std::fs::write(
2726 &empty,
2727 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2728 )
2729 .unwrap();
2730 let content = scratch("older-content");
2731 std::fs::write(
2732 &content,
2733 concat!(
2734 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2735 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
2736 ),
2737 )
2738 .unwrap();
2739
2740 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
2741 let picked = first_resumable_session(&metas).expect("a resumable session");
2742 assert_eq!(picked.id, "old");
2743
2744 let all_empty = vec![meta_for(&empty, "empty")];
2747 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
2748
2749 std::fs::remove_file(&empty).ok();
2750 std::fs::remove_file(&content).ok();
2751 }
2752
2753 #[test]
2754 fn list_session_files_sync_walks_the_cwd_subdirectory() {
2755 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
2756 let nested = root.join("--proj--");
2757 std::fs::create_dir_all(&nested).unwrap();
2758 let file = nested.join("2026-01-01_a.jsonl");
2759 std::fs::write(&file, "{}\n").unwrap();
2760 let found = list_session_files_sync(&root);
2761 assert!(found.iter().any(|p| p == &file));
2762 std::fs::remove_dir_all(&root).ok();
2763 }
2764
2765 #[test]
2766 fn default_prompt_mentions_cwd_and_tools() {
2767 let p = default_system_prompt("/tmp/proj", false);
2768 assert!(p.contains("/tmp/proj"));
2769 assert!(p.contains("read"));
2770 assert!(p.contains("bash"));
2771 assert!(p.contains("edit"));
2772 assert!(p.contains("write"));
2773 assert!(p.contains("docs"));
2774 assert!(!p.contains("- grep"));
2775 assert!(!p.contains("- find"));
2776 assert!(!p.contains("- ls"));
2777 assert!(!p.contains("powershell"));
2778 }
2779
2780 #[test]
2781 fn default_prompt_instructs_language_matching() {
2782 let p = default_system_prompt("/tmp/proj", false);
2786 let lower = p.to_lowercase();
2787 assert!(
2788 lower.contains("same language") && lower.contains("detect"),
2789 "the prompt must tell the model to detect and mirror the user's language: {p}"
2790 );
2791 }
2792
2793 #[test]
2794 fn default_prompt_keeps_working_state_in_the_visible_channel() {
2795 let p = default_system_prompt("/tmp/proj", false);
2803 let lower = p.to_lowercase();
2804 assert!(
2806 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
2807 "the prompt must explain that reasoning does not survive the turn: {p}"
2808 );
2809 assert!(
2811 lower.contains("visible") && lower.contains("todo tool"),
2812 "the prompt must point at visible text and the todo tool: {p}"
2813 );
2814 assert!(
2816 lower.contains("re-stating the same plan"),
2817 "the prompt must call out plan re-statement as a bug: {p}"
2818 );
2819 assert!(
2821 lower.contains("already shown earlier"),
2822 "the prompt must discourage re-fetching known content: {p}"
2823 );
2824 }
2825
2826 #[test]
2827 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
2828 let p = default_system_prompt("/tmp/proj", true);
2835 let lower = p.to_lowercase();
2836 assert!(
2837 lower.contains("carried back"),
2838 "a replaying model must be told its reasoning survives: {p}"
2839 );
2840 assert!(
2841 !lower.contains("reasoning is not"),
2842 "the replayed variant must not claim reasoning is dropped: {p}"
2843 );
2844 assert!(
2845 lower.contains("do not re-derive"),
2846 "the replayed variant must forbid re-deriving the plan: {p}"
2847 );
2848 assert!(
2849 lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
2850 "the anti-repetition rule must survive the flip: {p}"
2851 );
2852 assert!(
2853 lower.contains("already shown earlier"),
2854 "the re-read rule must survive the flip: {p}"
2855 );
2856 }
2857
2858 #[test]
2866 fn only_read_only_tools_are_replayable() {
2867 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2868 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2869 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2870 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2871 let tools = build_tools(&context, &Args::default());
2872
2873 let replay_of = |name: &str| {
2874 tools
2875 .iter()
2876 .find(|tool| tool.tool.schema().name == name)
2877 .map(|tool| tool.replay)
2878 };
2879
2880 for read_only in ["read", "docs"] {
2881 assert_eq!(
2882 replay_of(read_only),
2883 Some(ToolReplay::Safe),
2884 "{read_only} is side-effect free, so recovery may re-run it"
2885 );
2886 }
2887 for mutating in ["bash", "edit", "write", "powershell"] {
2888 if let Some(replay) = replay_of(mutating) {
2889 assert_eq!(
2890 replay,
2891 ToolReplay::Never,
2892 "{mutating} must never be replayed: re-running it would duplicate its effect"
2893 );
2894 }
2895 }
2896 }
2897
2898 #[test]
2899 fn default_tools_include_docs_lookup() {
2900 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2901 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2902 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2903 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2904 let names: Vec<String> = build_tools(&context, &Args::default())
2905 .iter()
2906 .map(|tool| tool.tool.schema().name.clone())
2907 .collect();
2908
2909 assert!(names.contains(&"docs".to_string()));
2914 for legacy in ["grep", "find", "ls"] {
2915 assert!(
2916 !names.contains(&legacy.to_string()),
2917 "legacy tool {legacy} should not be registered by default"
2918 );
2919 }
2920 }
2921
2922 #[test]
2923 fn pi_package_loading_is_opt_in_and_respects_no_extensions() {
2924 let args = Args::default();
2925 assert!(!should_load_js_packages(&args));
2926 let resources = package_resources_for(&args, Path::new("."), false);
2927 assert!(resources.packages.is_empty());
2928
2929 let args = Args {
2930 enable_pi_packages: true,
2931 ..Args::default()
2932 };
2933 assert!(should_load_js_packages(&args));
2934
2935 let args = Args {
2936 enable_pi_packages: true,
2937 no_extensions: true,
2938 ..Args::default()
2939 };
2940 assert!(!should_load_js_packages(&args));
2941 assert!(package_resources_for(&args, Path::new("."), false)
2942 .packages
2943 .is_empty());
2944 }
2945
2946 #[test]
2947 fn pi_offline_env_disables_startup_package_remediation() {
2948 struct RestoreEnv {
2949 name: &'static str,
2950 value: Option<std::ffi::OsString>,
2951 }
2952
2953 impl Drop for RestoreEnv {
2954 fn drop(&mut self) {
2955 match self.value.take() {
2956 Some(value) => std::env::set_var(self.name, value),
2957 None => std::env::remove_var(self.name),
2958 }
2959 }
2960 }
2961
2962 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2963 let _restore_config = RestoreEnv {
2964 name: crate::config::CONFIG_DIR_ENV,
2965 value: std::env::var_os(crate::config::CONFIG_DIR_ENV),
2966 };
2967 let _restore_offline = RestoreEnv {
2968 name: crate::args::PI_OFFLINE_ENV,
2969 value: std::env::var_os(crate::args::PI_OFFLINE_ENV),
2970 };
2971 let tmp = tempfile::tempdir().unwrap();
2972 let agent = tmp.path().join("agent");
2973 let cwd = tmp.path().join("project");
2974 let package = agent.join("npm/node_modules/demo");
2975 std::fs::create_dir_all(package.join("extensions")).unwrap();
2976 std::fs::create_dir_all(&cwd).unwrap();
2977 std::fs::write(
2978 package.join("package.json"),
2979 r#"{"name":"demo","version":"1.0.0"}"#,
2980 )
2981 .unwrap();
2982 std::fs::write(
2983 package.join("extensions/index.js"),
2984 "export default () => {};",
2985 )
2986 .unwrap();
2987 std::fs::write(
2988 agent.join("settings.json"),
2989 r#"{"npmCommand":[""],"packages":["npm:demo@2.0.0"]}"#,
2990 )
2991 .unwrap();
2992 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2993 std::env::set_var(crate::args::PI_OFFLINE_ENV, "TrUe");
2994 let args = Args {
2995 enable_pi_packages: true,
2996 offline: false,
2997 ..Args::default()
2998 };
2999
3000 let resources = package_resources_for(&args, &cwd, false);
3001
3002 assert!(resources.packages.is_empty());
3003 assert_eq!(resources.diagnostics.len(), 1);
3004 assert!(resources.diagnostics[0].message.contains("offline"));
3005 assert_eq!(
3006 std::fs::read(package.join("package.json")).unwrap(),
3007 br#"{"name":"demo","version":"1.0.0"}"#
3008 );
3009 }
3010
3011 #[test]
3012 fn package_discovery_uses_the_callers_trust_snapshot() {
3013 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3014 let previous = std::env::var_os(crate::config::CONFIG_DIR_ENV);
3015 let tmp = tempfile::tempdir().unwrap();
3016 let agent = tmp.path().join("agent");
3017 let cwd = tmp.path().join("project");
3018 let package = cwd.join("package");
3019 std::fs::create_dir_all(&agent).unwrap();
3020 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3021 std::fs::create_dir_all(&package).unwrap();
3022 std::fs::write(agent.join("settings.json"), "{}").unwrap();
3023 std::fs::write(
3024 package.join("package.json"),
3025 r#"{"name":"snapshot-package","version":"1.0.0"}"#,
3026 )
3027 .unwrap();
3028 std::fs::write(
3029 cwd.join(".rpi/settings.json"),
3030 serde_json::json!({"packages": [package]}).to_string(),
3031 )
3032 .unwrap();
3033 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3034
3035 let args = Args {
3036 enable_pi_packages: true,
3037 ..Args::default()
3038 };
3039 assert_eq!(package_resources_for(&args, &cwd, true).packages.len(), 1);
3040 assert!(package_resources_for(&args, &cwd, false)
3041 .packages
3042 .is_empty());
3043 assert_eq!(
3044 package_resources_for_update_check(&args, &cwd, true)
3045 .packages
3046 .len(),
3047 1
3048 );
3049 assert!(package_resources_for_update_check(&args, &cwd, false)
3050 .packages
3051 .is_empty());
3052
3053 match previous {
3054 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3055 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3056 }
3057 }
3058
3059 #[test]
3060 fn reload_settings_preflight_is_independent_of_packages_and_respects_trust() {
3061 struct RestoreConfigDir(Option<std::ffi::OsString>);
3062
3063 impl Drop for RestoreConfigDir {
3064 fn drop(&mut self) {
3065 match self.0.take() {
3066 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3067 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3068 }
3069 }
3070 }
3071
3072 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3073 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3074 let tmp = tempfile::tempdir().unwrap();
3075 let agent = tmp.path().join("agent");
3076 let cwd = tmp.path().join("project");
3077 std::fs::create_dir_all(&agent).unwrap();
3078 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3079 std::fs::write(agent.join("settings.json"), "{}").unwrap();
3080 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
3081 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3082 let packages_disabled = Args::default();
3083 let packages_enabled = Args {
3084 enable_pi_packages: true,
3085 ..Args::default()
3086 };
3087
3088 for args in [&packages_disabled, &packages_enabled] {
3089 let trusted = validate_settings_for_reload(args, &cwd, true);
3090 let untrusted = validate_settings_for_reload(args, &cwd, false);
3091 assert!(trusted
3092 .unwrap_err()
3093 .contains("could not load project settings"));
3094 assert!(untrusted.is_ok());
3095 }
3096
3097 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3098 for args in [&packages_disabled, &packages_enabled] {
3099 assert!(validate_settings_for_reload(args, &cwd, false)
3100 .unwrap_err()
3101 .contains("could not load global settings"));
3102 }
3103
3104 let local_only = Args {
3105 dev_local_only: true,
3106 ..Args::default()
3107 };
3108 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
3109 }
3110
3111 #[tokio::test(flavor = "current_thread")]
3112 async fn reload_with_packages_disabled_preserves_live_resources_when_settings_break() {
3113 struct RestoreConfigDir(Option<std::ffi::OsString>);
3114
3115 impl Drop for RestoreConfigDir {
3116 fn drop(&mut self) {
3117 match self.0.take() {
3118 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3119 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3120 }
3121 }
3122 }
3123
3124 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3125 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3126 let tmp = tempfile::tempdir().unwrap();
3127 let agent = tmp.path().join("agent");
3128 let cwd = tmp.path().join("project");
3129 let skill_dir = tmp.path().join("configured-skills");
3130 std::fs::create_dir_all(&agent).unwrap();
3131 std::fs::create_dir_all(&cwd).unwrap();
3132 std::fs::create_dir_all(&skill_dir).unwrap();
3133 std::fs::write(
3134 skill_dir.join("SKILL.md"),
3135 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
3136 )
3137 .unwrap();
3138 std::fs::write(
3139 agent.join("settings.json"),
3140 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3141 )
3142 .unwrap();
3143 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3144
3145 let resolved = crate::provider::resolve(
3146 Some("anthropic"),
3147 Some(crate::provider::DEFAULT_MODEL_ID),
3148 None,
3149 Some("test-key"),
3150 None,
3151 )
3152 .unwrap();
3153 let args = Args {
3154 trust_override: Some(false),
3155 no_session: true,
3156 no_extensions: true,
3157 no_prompt_templates: true,
3158 no_context_files: true,
3159 system_prompt: Some("stable system prompt".into()),
3160 ..Args::default()
3161 };
3162 assert!(!should_load_js_packages(&args));
3163
3164 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3165 let before_resources = harness.get_resources().await.unwrap();
3166 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
3167 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
3168 let before_prompt = harness.get_system_prompt().await.unwrap();
3169 let before_tools: Vec<String> = harness
3170 .get_tools()
3171 .await
3172 .unwrap()
3173 .iter()
3174 .map(|tool| tool.tool.schema().name.clone())
3175 .collect();
3176 let before_bridge = context
3177 .action_bridge
3178 .lock()
3179 .unwrap()
3180 .as_ref()
3181 .unwrap()
3182 .clone();
3183
3184 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3185 let outcome = reload_extension_resources(&harness, &context).await;
3186
3187 assert!(outcome.had_warnings);
3188 assert!(outcome.summary.contains("Settings reload failed"));
3189 assert!(outcome.summary.contains("could not load global settings"));
3190 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
3191 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
3192 let after_tools: Vec<String> = harness
3193 .get_tools()
3194 .await
3195 .unwrap()
3196 .iter()
3197 .map(|tool| tool.tool.schema().name.clone())
3198 .collect();
3199 assert_eq!(after_tools, before_tools);
3200 let after_bridge = context
3201 .action_bridge
3202 .lock()
3203 .unwrap()
3204 .as_ref()
3205 .unwrap()
3206 .clone();
3207 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
3208 }
3209
3210 #[test]
3211 fn reload_preparation_rejects_settings_changed_during_derivation() {
3212 struct RestoreConfigDir(Option<std::ffi::OsString>);
3213
3214 impl Drop for RestoreConfigDir {
3215 fn drop(&mut self) {
3216 match self.0.take() {
3217 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3218 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3219 }
3220 }
3221 }
3222
3223 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3224 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3225 let tmp = tempfile::tempdir().unwrap();
3226 let agent = tmp.path().join("agent");
3227 let cwd = tmp.path().join("project");
3228 let first_skill_dir = tmp.path().join("first-skills");
3229 let second_skill_dir = tmp.path().join("second-skills");
3230 std::fs::create_dir_all(&agent).unwrap();
3231 std::fs::create_dir_all(&cwd).unwrap();
3232 std::fs::write(
3233 agent.join("settings.json"),
3234 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3235 )
3236 .unwrap();
3237 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3238 let args = Args {
3239 trust_override: Some(false),
3240 no_extensions: true,
3241 no_prompt_templates: true,
3242 ..Args::default()
3243 };
3244
3245 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3246 std::fs::write(
3247 agent.join("settings.json"),
3248 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3249 )
3250 .unwrap();
3251 });
3252
3253 let error = result
3254 .err()
3255 .expect("settings mutation must fail preparation");
3256 assert!(error.contains("settings changed while reload inputs were being prepared"));
3257 }
3258
3259 #[tokio::test(flavor = "current_thread")]
3260 async fn reload_uses_frozen_settings_inputs_after_preparation() {
3261 struct RestoreConfigDir(Option<std::ffi::OsString>);
3262
3263 impl Drop for RestoreConfigDir {
3264 fn drop(&mut self) {
3265 match self.0.take() {
3266 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3267 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3268 }
3269 }
3270 }
3271
3272 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3273 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3274 let tmp = tempfile::tempdir().unwrap();
3275 let agent = tmp.path().join("agent");
3276 let cwd = tmp.path().join("project");
3277 let skill_dir = tmp.path().join("configured-skills");
3278 std::fs::create_dir_all(&agent).unwrap();
3279 std::fs::create_dir_all(&cwd).unwrap();
3280 std::fs::create_dir_all(&skill_dir).unwrap();
3281 std::fs::write(
3282 skill_dir.join("SKILL.md"),
3283 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3284 )
3285 .unwrap();
3286 std::fs::write(
3287 agent.join("settings.json"),
3288 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3289 )
3290 .unwrap();
3291 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3292
3293 let resolved = crate::provider::resolve(
3294 Some("anthropic"),
3295 Some(crate::provider::DEFAULT_MODEL_ID),
3296 None,
3297 Some("test-key"),
3298 None,
3299 )
3300 .unwrap();
3301 let args = Args {
3302 trust_override: Some(false),
3303 no_session: true,
3304 no_extensions: true,
3305 no_prompt_templates: true,
3306 no_context_files: true,
3307 system_prompt: Some("stable system prompt".into()),
3308 ..Args::default()
3309 };
3310 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3311
3312 let outcome = reload_extension_resources_inner(&harness, &context, || {
3313 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3314 })
3315 .await;
3316
3317 assert!(!outcome.summary.contains("Settings reload failed"));
3318 assert_eq!(
3319 outcome.summary,
3320 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3321 );
3322 let resources = harness.get_resources().await.unwrap();
3323 let skills = resources.skills.as_ref().unwrap();
3324 assert_eq!(skills.len(), 1);
3325 assert_eq!(skills[0].name, "frozen-skill");
3326 }
3327
3328 #[tokio::test]
3329 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3330 let tmp = tempfile::tempdir().unwrap();
3331 let skill_path = tmp.path().join("explicit-skill.md");
3332 std::fs::write(
3333 &skill_path,
3334 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3335 )
3336 .unwrap();
3337 let prompt_path = tmp.path().join("explicit-prompt.md");
3338 std::fs::write(
3339 &prompt_path,
3340 "---\ndescription: Explicit prompt\n---\nPrompt body",
3341 )
3342 .unwrap();
3343
3344 let args = Args {
3345 skill: vec![skill_path.clone()],
3346 prompt_template: vec![prompt_path.clone()],
3347 ..Args::default()
3348 };
3349 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3350 let prompt_paths =
3351 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3352 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3353 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3354
3355 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3356 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3357 assert_eq!(skills.skills[0].name, "explicit-skill");
3358
3359 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3360 assert_eq!(
3361 prompts.prompt_templates.len(),
3362 1,
3363 "{:?}",
3364 prompts.diagnostics
3365 );
3366 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3367 }
3368
3369 #[test]
3370 fn tool_policy_applies_to_rust_and_js_active_names() {
3371 let names = vec![
3372 "read".to_string(),
3373 "ask_user_question".to_string(),
3374 "write".to_string(),
3375 ];
3376
3377 let args = Args {
3378 tools: Some(vec!["read".into(), "ask_user_question".into()]),
3379 ..Args::default()
3380 };
3381 assert_eq!(
3382 filter_active_tool_names(names.clone(), &args),
3383 vec!["read", "ask_user_question"]
3384 );
3385
3386 let args = Args {
3387 exclude_tools: Some(vec!["ask_user_question".into()]),
3388 ..Args::default()
3389 };
3390 assert_eq!(
3391 filter_active_tool_names(names.clone(), &args),
3392 vec!["read", "write"]
3393 );
3394
3395 let args = Args {
3396 no_tools: true,
3397 ..Args::default()
3398 };
3399 assert!(filter_active_tool_names(names, &args).is_empty());
3400 }
3401
3402 #[test]
3403 fn select_ephemeral_when_no_session() {
3404 let args = Args {
3405 no_session: true,
3406 ..Args::default()
3407 };
3408 let cwd = Path::new("/tmp");
3409 assert!(matches!(
3410 select_session(&args, cwd),
3411 SessionSelection::Ephemeral
3412 ));
3413 }
3414
3415 #[test]
3416 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3417 let defaults = Args::default();
3418 assert!(resolve_project_trust(
3419 &defaults,
3420 Path::new("C:/definitely-not-a-project")
3421 ));
3422
3423 let denied = Args {
3424 trust_override: Some(false),
3425 ..Args::default()
3426 };
3427 assert!(!resolve_project_trust(
3428 &denied,
3429 Path::new("C:/definitely-not-a-project")
3430 ));
3431 }
3432
3433 #[tokio::test(flavor = "current_thread")]
3434 async fn build_preserves_the_supplied_startup_project_snapshot() {
3435 struct RestoreConfigDir(Option<std::ffi::OsString>);
3436
3437 impl Drop for RestoreConfigDir {
3438 fn drop(&mut self) {
3439 match self.0.take() {
3440 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3441 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3442 }
3443 }
3444 }
3445
3446 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3447 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3448 let tmp = tempfile::tempdir().unwrap();
3449 let agent = tmp.path().join("agent");
3450 let cwd = tmp.path().join("project");
3451 std::fs::create_dir_all(&agent).unwrap();
3452 std::fs::create_dir_all(&cwd).unwrap();
3453 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3454
3455 let resolved = crate::provider::resolve(
3456 Some("anthropic"),
3457 Some(crate::provider::DEFAULT_MODEL_ID),
3458 None,
3459 Some("test-key"),
3460 None,
3461 )
3462 .unwrap();
3463 let args = Args {
3464 trust_override: Some(false),
3465 no_session: true,
3466 no_tools: true,
3467 no_extensions: true,
3468 no_skills: true,
3469 no_prompt_templates: true,
3470 no_context_files: true,
3471 system_prompt: Some("test prompt".into()),
3472 ..Args::default()
3473 };
3474
3475 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3476
3477 assert_eq!(context.cwd, cwd);
3478 assert!(context.project_trusted);
3479 }
3480
3481 #[tokio::test(flavor = "current_thread")]
3482 async fn build_applies_the_name_flag_to_the_session() {
3483 struct RestoreConfigDir(Option<std::ffi::OsString>);
3484
3485 impl Drop for RestoreConfigDir {
3486 fn drop(&mut self) {
3487 match self.0.take() {
3488 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3489 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3490 }
3491 }
3492 }
3493
3494 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3495 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3496 let tmp = tempfile::tempdir().unwrap();
3497 let agent = tmp.path().join("agent");
3498 let cwd = tmp.path().join("project");
3499 std::fs::create_dir_all(&agent).unwrap();
3500 std::fs::create_dir_all(&cwd).unwrap();
3501 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3502
3503 let resolved = crate::provider::resolve(
3504 Some("anthropic"),
3505 Some(crate::provider::DEFAULT_MODEL_ID),
3506 None,
3507 Some("test-key"),
3508 None,
3509 )
3510 .unwrap();
3511 let args = Args {
3512 trust_override: Some(false),
3513 no_tools: true,
3514 no_extensions: true,
3515 no_skills: true,
3516 no_prompt_templates: true,
3517 no_context_files: true,
3518 system_prompt: Some("test prompt".into()),
3519 name: Some("smoke name".into()),
3520 session_dir: Some(cwd.join("sessions")),
3521 ..Args::default()
3522 };
3523
3524 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3525 assert_eq!(
3526 harness.get_name().await.unwrap().as_deref(),
3527 Some("smoke name")
3528 );
3529 }
3530
3531 #[test]
3532 fn select_latest_for_continue_and_resume() {
3533 let args = Args {
3534 continue_session: true,
3535 ..Args::default()
3536 };
3537 let cwd = Path::new("/tmp");
3538 assert!(matches!(
3539 select_session(&args, cwd),
3540 SessionSelection::Latest
3541 ));
3542
3543 let args = Args {
3544 resume: true,
3545 ..Args::default()
3546 };
3547 assert!(matches!(
3548 select_session(&args, cwd),
3549 SessionSelection::Latest
3550 ));
3551 }
3552
3553 #[test]
3554 fn select_by_id_for_session_flag() {
3555 let args = Args {
3556 session: Some("01a02ece".into()),
3557 ..Args::default()
3558 };
3559 let cwd = Path::new("/tmp");
3560 assert!(matches!(
3561 select_session(&args, cwd),
3562 SessionSelection::ById { id } if id == "01a02ece"
3563 ));
3564 }
3565
3566 #[test]
3567 fn select_new_with_custom_dir() {
3568 let args = Args {
3569 session_dir: Some(PathBuf::from("/tmp/sess")),
3570 ..Args::default()
3571 };
3572 let cwd = Path::new("/tmp");
3573 match select_session(&args, cwd) {
3574 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
3575 other => panic!("expected New, got {other:?}"),
3576 }
3577 }
3578
3579 #[test]
3580 fn select_new_default_dir() {
3581 let args = Args::default();
3582 let cwd = Path::new("/proj");
3583 match select_session(&args, cwd) {
3584 SessionSelection::New { dir, .. } => {
3585 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
3586 }
3587 other => panic!("expected New, got {other:?}"),
3588 }
3589 }
3590
3591 #[test]
3592 fn default_session_dir_prefers_rpi_but_reads_legacy_pi() {
3593 let tmp = tempfile::tempdir().unwrap();
3594 let cwd = tmp.path();
3595 std::fs::create_dir_all(cwd.join(".pi/sessions")).unwrap();
3596 assert_eq!(default_session_dir(cwd), cwd.join(".pi/sessions"));
3597 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3598 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3599 }
3600
3601 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3602 async fn ephemeral_session_builds_roundtrips() {
3603 let s = ephemeral_session();
3607 let leaf = s.get_leaf_id().await;
3608 assert!(leaf.is_ok());
3609 }
3610
3611 }