1mod backend;
34
35use std::collections::{BTreeMap, HashSet};
36use std::path::{Path, PathBuf};
37use std::sync::atomic::Ordering;
38use std::sync::{Arc, Mutex, OnceLock};
39
40use rpi_ai::Provider;
41use rpi_harness::agent_harness::AgentHarness;
42use rpi_harness::context_files::{format_project_context, load_project_context_files};
43use rpi_harness::session::memory::{InMemorySessionStorage, SystemClock};
44use rpi_harness::session::session::DefaultIdGenerator;
45use rpi_harness::session::types::SessionMetadata;
46use rpi_harness::session::Session;
47use rpi_harness::system_prompt::compose_system_prompt;
48use rpi_harness::types::{
49 AgentHarnessOptions, AgentHarnessResources, AgentHarnessStreamOptions, DrivingMode,
50 HarnessTool, HarnessToolExecution, ToolReplay,
51};
52#[cfg(windows)]
53use rpi_tools::create_powershell_tool;
54use rpi_tools::{
55 create_bash_tool, create_edit_tool, create_read_tool, create_write_tool, ExecutionToolContext,
56 MutationQueueRegistry, OsExecutionEnv,
57};
58
59use crate::args::Args;
60use crate::docs_tool::create_docs_tool;
61use crate::provider::ResolvedModel;
62use crate::resource_dirs::{
63 discover_append_system_prompt_file, discover_system_prompt_file, extension_dirs,
64 global_extension_dirs, global_prompt_template_dirs, global_skill_dirs,
65 load_prompt_templates_with_precedence, load_skills_with_precedence, project_extension_dirs,
66 project_prompt_template_dirs, project_skill_dirs, prompt_template_dirs, skill_dirs,
67};
68use crate::startup_diagnostics::{Phase, StartupSpan};
69use rpi_extensions::{
70 emit_resources_discover, ExtensionEmitter, ExtensionSession, NullDiagnostics,
71 PluginDiagnostics, PluginToolAdapter, TeeEmitter, ToolPromptMetadata,
72};
73
74type FileIOAttachment = (
84 std::sync::Weak<dyn rpi_harness::session::types::SessionStorage>,
85 std::sync::Weak<rpi_tools::file_io::FileIORouter>,
86);
87fn file_io_attachments() -> &'static Mutex<Vec<FileIOAttachment>> {
88 static ATTACHMENTS: std::sync::OnceLock<Mutex<Vec<FileIOAttachment>>> =
89 std::sync::OnceLock::new();
90 ATTACHMENTS.get_or_init(|| Mutex::new(Vec::new()))
91}
92pub(crate) fn register_file_io_router(
93 harness: &AgentHarness,
94 router: &Arc<rpi_tools::file_io::FileIORouter>,
95) {
96 let storage = harness.session().storage();
97 let mut attachments = file_io_attachments()
98 .lock()
99 .expect("file I/O attachments poisoned");
100 attachments.retain(|(s, r)| {
101 s.strong_count() > 0
102 && r.strong_count() > 0
103 && !s
104 .upgrade()
105 .is_some_and(|known| Arc::ptr_eq(&known, &storage))
106 });
107 attachments.push((Arc::downgrade(&storage), Arc::downgrade(router)));
108}
109pub(crate) fn file_io_router(
110 harness: &AgentHarness,
111) -> Option<Arc<rpi_tools::file_io::FileIORouter>> {
112 let storage = harness.session().storage();
113 let mut attachments = file_io_attachments()
114 .lock()
115 .expect("file I/O attachments poisoned");
116 attachments.retain(|(s, r)| s.strong_count() > 0 && r.strong_count() > 0);
117 attachments.iter().find_map(|(s, r)| {
118 s.upgrade()
119 .filter(|known| Arc::ptr_eq(known, &storage))
120 .and_then(|_| r.upgrade())
121 })
122}
123
124pub const BUILTIN_TOOL_NAMES: &[&str] = &["read", "bash", "edit", "write", "docs"];
126
127pub fn default_system_prompt(cwd: &str, prior_reasoning_replayed: bool) -> String {
133 let tools = BUILTIN_TOOL_NAMES
134 .iter()
135 .map(|name| (*name).to_owned())
136 .collect::<Vec<_>>();
137 default_system_prompt_with_tools(cwd, prior_reasoning_replayed, &tools)
138}
139
140pub fn default_system_prompt_with_tools(
149 cwd: &str,
150 prior_reasoning_replayed: bool,
151 tools: &[String],
152) -> String {
153 default_system_prompt_with_tool_prompts(cwd, prior_reasoning_replayed, tools, &BTreeMap::new())
154}
155
156pub fn default_system_prompt_with_tool_prompts(
161 cwd: &str,
162 prior_reasoning_replayed: bool,
163 tools: &[String],
164 overrides: &BTreeMap<String, ToolPromptMetadata>,
165) -> String {
166 let metadata: BTreeMap<_, _> = tools
167 .iter()
168 .map(|name| {
169 let prompt = overrides
170 .get(name)
171 .cloned()
172 .unwrap_or_else(|| ToolPromptMetadata {
173 prompt_snippet: builtin_tool_snippet(name).map(str::to_owned),
174 prompt_guidelines: Vec::new(),
175 })
176 .normalized();
177 (name.as_str(), prompt)
178 })
179 .collect();
180 let working_state_rule = if prior_reasoning_replayed {
186 "- Your reasoning is carried back to you on the next turn: do not re-derive it or restate
187 the plan. Continue from the last unfinished step, and when a step lands, say which one.
188 Re-stating the same plan without acting on it is a bug, not progress."
189 } else {
190 "- Your reasoning is not carried into your next turn; only your visible replies and tool
191 output come back. Before each batch of tool calls, write one short line naming the step you
192 are on and what remains, and say which step is done when it lands.
193 Re-stating the same plan without acting on it is a bug, not progress."
194 };
195
196 let tool_lines = tools
199 .iter()
200 .filter_map(|name| {
201 metadata
202 .get(name.as_str())?
203 .prompt_snippet
204 .as_ref()
205 .map(|snippet| format!("- {name:<5} — {snippet}"))
206 })
207 .collect::<Vec<_>>()
208 .join("\n");
209 let tool_lines = if tool_lines.is_empty() {
210 "(none)".to_string()
211 } else {
212 tool_lines
213 };
214
215 let guidelines = [
218 "- Reply in the same language as the user's latest message (the conversation's language when
219 that message is too short to tell), unless the user asks for another.",
220 "- Be concise in your responses",
221 "- Show file paths clearly when working with files",
222 "- Prefer the smallest change that solves the problem",
223 working_state_rule,
224 "- Read only what you need. If a file or search result was already shown earlier in this
225 conversation, use it instead of fetching it again.",
226 ];
227 let mut seen = HashSet::new();
230 let tool_rules = tools
231 .iter()
232 .flat_map(|name| &metadata[name.as_str()].prompt_guidelines)
233 .map(String::as_str);
234 let core_rules = guidelines
235 .iter()
236 .map(|rule| rule.strip_prefix("- ").unwrap_or(rule));
237 let guidelines = tool_rules
238 .chain(core_rules)
239 .map(str::trim)
240 .filter(|rule| !rule.is_empty() && seen.insert(*rule))
241 .map(|rule| format!("- {rule}"))
242 .collect::<Vec<_>>()
243 .join("\n");
244
245 format!(
246 "You are an expert coding assistant operating inside rpi, a coding agent harness. \
247You help users by reading files, executing commands, editing code, and writing new files.
248
249Available tools:
250{tool_lines}
251
252In addition to the tools above, you may have access to other custom tools depending on the project.
253
254Guidelines:
255{guidelines}
256
257Current working directory: {cwd}"
258 )
259}
260
261fn extension_tool_prompt_metadata(
264 session: &ExtensionSession,
265) -> BTreeMap<String, ToolPromptMetadata> {
266 session
267 .snapshot()
268 .map(|snapshot| {
269 snapshot
270 .tools()
271 .iter()
272 .map(|tool| (tool.tool.name.clone(), tool.prompt.clone()))
273 .collect()
274 })
275 .unwrap_or_default()
276}
277
278fn builtin_tool_snippet(name: &str) -> Option<&'static str> {
280 Some(match name {
281 "read" => "Read file contents",
282 "bash" => "Execute Bash commands (Bash/POSIX syntax, also on Windows)",
283 "edit" => "Find/replace edits to existing files",
284 "write" => "Create or overwrite files",
285 "docs" => "Look up rpi commands, extensions, Pi package compatibility and .rpi configuration instead of guessing",
286 "grep" => "Search file contents for a pattern",
287 "find" => "Find files by glob pattern",
288 "ls" => "List directory contents",
289 "powershell" => "Execute PowerShell commands on Windows",
290 _ => return None,
291 })
292}
293
294#[derive(Debug, Clone)]
301pub enum SessionSelection {
302 Ephemeral,
304 New { dir: PathBuf, name: Option<String> },
306 ExperimentalNative { dir: PathBuf },
308 Latest { dir: PathBuf },
310 ById { id: String, dir: PathBuf },
313 ByExactId { id: String, dir: PathBuf },
315 Fork { source: String, dir: PathBuf },
318}
319
320pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
322 select_session_in_dir(args, configured_session_dir(args, cwd))
323}
324
325pub(crate) fn configured_session_dir(args: &Args, cwd: &Path) -> PathBuf {
327 if let Some(root) = &args.experimental_native_store {
328 return if root.is_absolute() {
329 root.clone()
330 } else {
331 cwd.join(root)
332 };
333 }
334 let dir = args
335 .session_dir
336 .clone()
337 .or_else(|| {
338 crate::settings::load_settings()
339 .ok()
340 .and_then(|settings| settings.session_dir)
341 })
342 .unwrap_or_else(|| default_session_dir(cwd));
343 if dir.is_relative() && !dir.has_root() {
346 cwd.join(dir)
347 } else {
348 dir
349 }
350}
351
352pub(crate) fn select_session_in_dir(args: &Args, dir: PathBuf) -> SessionSelection {
353 if args.experimental_native_store.is_some() {
354 return SessionSelection::ExperimentalNative { dir };
355 }
356 if args.no_session {
357 return SessionSelection::Ephemeral;
358 }
359 if args.continue_session || args.resume {
363 return SessionSelection::Latest { dir };
364 }
365 if let Some(s) = &args.fork {
366 return SessionSelection::Fork {
367 source: s.clone(),
368 dir,
369 };
370 }
371 if let Some(s) = &args.session_id {
372 return SessionSelection::ByExactId { id: s.clone(), dir };
373 }
374 if let Some(s) = &args.session {
375 return SessionSelection::ById { id: s.clone(), dir };
376 }
377 SessionSelection::New {
378 dir,
379 name: args.name.clone(),
380 }
381}
382
383pub fn default_session_dir(cwd: &Path) -> PathBuf {
386 std::env::var("RPI_CODING_AGENT_SESSION_DIR")
387 .ok()
388 .map(PathBuf::from)
389 .unwrap_or_else(|| cwd.join(".rpi").join("sessions"))
390}
391
392pub struct PreparedExtensions {
395 resource_leases: Arc<Vec<crate::resource_management::Lease>>,
396 resource_selection: Option<crate::resource_selection::ResolvedSelection>,
397 session_selection: SessionSelection,
398 transition_session: Option<Session>,
399 pub extension_session: ExtensionSession,
400 native_store: Option<PathBuf>,
401 session_dir: PathBuf,
402 runtime: tokio::runtime::Handle,
403 tool_context: rpi_extensions::ToolCallContext,
404 action_host: Arc<crate::extensions_actions::HarnessActionHost>,
405 harness_cell: Arc<std::sync::OnceLock<rpi_harness::agent_harness::WeakAgentHarness>>,
406 reload_mailbox: rpi_extensions::ReloadMailbox,
407 ui_dialog_mailbox: rpi_extensions::UiDialogMailbox,
408 ext_status_mailbox: rpi_extensions::ExtensionStatusMailbox,
409 editor_text_mailbox: rpi_extensions::EditorTextMailbox,
410 action_bridge: Arc<rpi_extensions::ActionBridge>,
411}
412
413pub(crate) fn preflight_extension_startup(
418 args: &Args,
419 cwd: &Path,
420 trusted: bool,
421) -> Result<
422 (
423 Option<crate::resource_selection::ResolvedSelection>,
424 SessionSelection,
425 ),
426 BuildError,
427> {
428 let mut resources = crate::resource_selection::ResolvedSelection::load(args, cwd, trusted)
429 .map_err(BuildError::HarnessCreate)?;
430 let selection = select_session(args, cwd);
431 let pinned = std::thread::scope(|scope| {
432 scope
433 .spawn(|| {
434 tokio::runtime::Builder::new_current_thread()
435 .enable_all()
436 .build()
437 .map_err(|e| BuildError::SessionOpen(e.to_string()))?
438 .block_on(async {
439 if let Some(selected) = &mut resources {
440 let env: Arc<dyn rpi_tools::ExecutionEnv> =
441 Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
442 selected
443 .validate_skills(&env)
444 .await
445 .map_err(BuildError::HarnessCreate)?;
446 }
447 let cwd_str = cwd.to_string_lossy();
448 let target = match &selection {
449 SessionSelection::Fork { source, dir } => {
450 match session_metadata_at(Path::new(source)) {
451 Some(meta) => Some(meta),
452 None => find_session_metadata(source, &cwd_str, dir).await?,
453 }
454 }
455 _ => resolve_restore_target(&selection, &cwd_str).await?,
456 };
457 let Some(meta) = target else {
458 if resources.as_ref().is_some_and(|r| r.transition.is_some()) {
459 return Err(BuildError::SessionOpen("resource transition requires an existing native session".into()));
460 }
461 return Ok(selection.clone());
462 };
463 if resources.as_ref().is_some_and(|r| r.transition.is_some()) && matches!(selection, SessionSelection::Fork { .. }) {
464 return Err(BuildError::SessionOpen("resource transition cannot fork".into()));
465 }
466 use rpi_harness::session::jsonl::{
467 JsonlSessionRepo, JsonlSessionRepoOptions,
468 };
469 let dir = selection_dir(&selection);
470 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
471 fs: Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf())),
472 sessions_root: dir.to_string_lossy().into_owned(),
473 clock: Arc::new(SystemClock),
474 ids: Arc::new(DefaultIdGenerator::new()),
475 });
476 let binding_facts = StartupSpan::start(Phase::BindingFacts);
477 let facts = if resources.as_ref().is_some_and(|r| r.transition.is_some()) {
478 rpi_harness::session::jsonl::JsonlSessionStorage::read_resource_transition_facts(
479 Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf())), &meta.path,
480 Arc::new(SystemClock), Arc::new(DefaultIdGenerator::new()),
481 ).await
482 } else { repo.read_facts_by_jsonl_metadata(&meta).await }
483 .map_err(|e| BuildError::SessionOpen(e.to_string()))?;
484 crate::resource_selection::validate_session_binding(
485 &facts.values,
486 resources.as_ref(),
487 )
488 .map_err(BuildError::SessionOpen)?;
489 binding_facts.finish();
490 Ok(match selection {
491 SessionSelection::Fork { .. } => SessionSelection::Fork {
492 source: meta.path,
493 dir,
494 },
495 _ => SessionSelection::ByExactId { id: meta.id, dir },
496 })
497 })
498 })
499 .join()
500 .map_err(|_| BuildError::SessionOpen("resource preflight reader panicked".into()))?
501 })?;
502 Ok((resources, pinned))
503}
504
505pub fn prepare_extensions(
506 args: &Args,
507 cwd: &Path,
508 project_trusted: bool,
509) -> Result<PreparedExtensions, BuildError> {
510 let native_store =
511 crate::native_store::root(args, cwd).map_err(BuildError::NativeStorePolicy)?;
512 let resource_leases =
513 crate::resource_management::host_leases(cwd).map_err(BuildError::HarnessCreate)?;
514 let (resource_selection, session_selection) =
515 preflight_extension_startup(args, cwd, project_trusted)?;
516 let transition_session = if resource_selection
519 .as_ref()
520 .is_some_and(|r| r.transition.is_some())
521 {
522 Some(std::thread::scope(|scope| {
523 scope.spawn(|| {
524 tokio::runtime::Builder::new_current_thread().enable_all().build()
525 .map_err(|e| BuildError::SessionOpen(e.to_string()))?.block_on(async {
526 let meta = resolve_restore_target(&session_selection, &cwd.to_string_lossy()).await?
527 .ok_or_else(|| BuildError::SessionOpen("transition session disappeared".into()))?;
528 let path = std::fs::canonicalize(&meta.path).map_err(|e| BuildError::SessionOpen(e.to_string()))?;
529 claim_session_lock_inner(&path.to_string_lossy(), false).map_err(BuildError::SessionOpen)?;
530 let storage = rpi_harness::session::jsonl::JsonlSessionStorage::load_for_resource_transition(
531 Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf())), &path.to_string_lossy(),
532 Arc::new(SystemClock), Arc::new(DefaultIdGenerator::new()),
533 ).await.map_err(|e| BuildError::SessionOpen(e.to_string()))?;
534 let session = Session::new(Arc::new(storage), None);
535 crate::resource_selection::bind_session(&session, resource_selection.as_ref()).await.map_err(BuildError::SessionOpen)?;
536 Ok::<_, BuildError>(session)
537 })
538 }).join().map_err(|_| BuildError::SessionOpen("transition writer preparation panicked".into()))?
539 })?)
540 } else {
541 None
542 };
543 let session_dir = if matches!(session_selection, SessionSelection::Ephemeral) {
544 configured_session_dir(args, cwd)
545 } else {
546 selection_dir(&session_selection)
547 };
548 let cwd_str = cwd.to_string_lossy().to_string();
549 let tool_context = rpi_extensions::ToolCallContext::new(cwd_str.clone());
550 if !project_trusted && args.verbose {
551 eprintln!(
552 "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)"
553 );
554 }
555 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
574 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
575 })?;
576 let catalog = Vec::new();
577 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
578 catalog.clone(),
579 cwd.to_path_buf(),
580 runtime.clone(),
581 args.unknown_flags.clone(),
582 tool_context.clone(),
583 );
584 let action_host = Arc::new(
585 action_host
586 .with_session_dir(session_dir.clone())
587 .with_fresh_session_only(native_store.is_some()),
588 );
589 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = action_host.clone();
590 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
600 let ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
606 let ext_status_mailbox = rpi_extensions::ExtensionStatusMailbox::new();
610 let editor_text_mailbox = rpi_extensions::EditorTextMailbox::new();
615 let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
616 runtime.clone(),
617 host_arc,
618 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
619 ui_dialog_mailbox.clone(),
620 ext_status_mailbox.clone(),
621 editor_text_mailbox.clone(),
622 );
623
624 let extension_register = StartupSpan::start(Phase::ExtensionRegister);
625 let extension_session = if let Some(selected) = &resource_selection {
626 let session = rpi_extensions::load_session_mixed_guarded(
627 &[],
628 &selected.extensions,
629 Arc::new(NullDiagnostics),
630 Some(Arc::clone(&action_bridge)),
631 Some(resource_leases.clone()),
632 );
633 if session.loaded_paths() != selected.extensions.as_slice() {
634 return Err(BuildError::HarnessCreate(
635 "selected native extension failed to load/register".into(),
636 ));
637 }
638 if session.owns_stdio() {
639 return Err(BuildError::HarnessCreate(
640 "stdio-owning extensions are unsupported with --resource-config".into(),
641 ));
642 }
643 session
644 } else if args.no_extensions {
645 ExtensionSession::none()
646 } else {
647 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
648 };
649 extension_register.finish();
650 if args.verbose {
651 if let Some(s) = extension_session.summary() {
652 eprintln!("extensions: {s}");
653 }
654 report_deferred_renderers(&extension_session);
655 }
656 crate::native_store::validate_stdio(native_store.is_some(), extension_session.owns_stdio())
657 .map_err(BuildError::NativeStorePolicy)?;
658 action_host.publish_registry_impl(extension_session.snapshot_arc());
663 Ok(PreparedExtensions {
664 resource_leases,
665 resource_selection,
666 session_selection,
667 transition_session,
668 native_store,
669 session_dir,
670 runtime,
671 tool_context,
672 action_host,
673 harness_cell,
674 reload_mailbox,
675 ui_dialog_mailbox,
676 ext_status_mailbox,
677 editor_text_mailbox,
678 action_bridge,
679 extension_session,
680 })
681}
682
683pub async fn build(
701 resolved: &ResolvedModel,
702 args: &Args,
703 cwd: &Path,
704 project_trusted: bool,
705) -> Result<
706 (
707 AgentHarness,
708 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
709 ReloadContext,
710 ),
711 BuildError,
712> {
713 let prepared = prepare_extensions(args, cwd, project_trusted)?;
714 build_prepared(resolved, args, cwd, project_trusted, prepared).await
715}
716
717pub async fn build_prepared(
718 resolved: &ResolvedModel,
719 args: &Args,
720 cwd: &Path,
721 project_trusted: bool,
722 prepared: PreparedExtensions,
723) -> Result<
724 (
725 AgentHarness,
726 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
727 ReloadContext,
728 ),
729 BuildError,
730> {
731 let native_store =
732 crate::native_store::root(args, cwd).map_err(BuildError::NativeStorePolicy)?;
733 if native_store != prepared.native_store {
734 return Err(BuildError::NativeStorePolicy(
735 "experimental session selection changed after extension preparation".into(),
736 ));
737 }
738 crate::native_store::validate_stdio(
739 native_store.is_some(),
740 prepared.extension_session.owns_stdio(),
741 )
742 .map_err(BuildError::NativeStorePolicy)?;
743 let fresh_session_only = native_store.is_some();
744 let cwd_str = cwd.to_string_lossy().to_string();
745 let PreparedExtensions {
750 resource_leases,
751 resource_selection,
752 session_selection: selection,
753 transition_session,
754 native_store: _,
755 session_dir,
756 runtime,
757 tool_context,
758 action_host,
759 harness_cell,
760 reload_mailbox,
761 ui_dialog_mailbox,
762 ext_status_mailbox,
763 editor_text_mailbox,
764 action_bridge,
765 extension_session,
766 } = prepared;
767 let catalog = crate::provider::authenticated_catalog(resolved);
768 action_host.set_catalog(catalog.clone());
769
770 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
772 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
773 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
774 let _registry = Arc::new(MutationQueueRegistry::new());
775 let file_io = Arc::new(rpi_tools::file_io::FileIORouter::new());
778 let ctx =
779 ExecutionToolContext::new(env_dyn.clone(), Some(mut_env)).with_file_io(file_io.clone());
780
781 let mut tools = build_tools(&ctx, args);
782
783 merge_extension_tools(&mut tools, &extension_session, args, &tool_context);
791 let active = if let Some(selected) = &resource_selection {
792 selected
793 .active_tools(
794 &tools
795 .iter()
796 .map(|t| t.tool.schema().name.clone())
797 .collect::<Vec<_>>(),
798 )
799 .map_err(BuildError::HarnessCreate)?
800 } else {
801 filter_active_tool_names(active_tool_names(&tools, args), args)
802 };
803
804 let session = match transition_session {
811 Some(session) => session,
812 None => build_session(&selection, &cwd_str, resource_selection.as_ref()).await?,
813 };
814 crate::resource_selection::bind_session(&session, resource_selection.as_ref())
815 .await
816 .map_err(BuildError::SessionOpen)?;
817 if let Ok(metadata) = session.get_metadata().await {
821 publish_session_identity(&tool_context, &metadata.id);
822 }
823 if let Some(requested) = args.name.as_deref() {
829 let name = requested.trim();
830 let name = if name.is_empty() { None } else { Some(name) };
831 if let Err(error) = session.set_name(name).await {
832 if fresh_session_only {
833 return Err(BuildError::SessionOpen(format!(
834 "native session name persistence failed: {error}"
835 )));
836 }
837 if args.verbose {
838 eprintln!("warning: could not set session name: {error}");
839 }
840 }
841 }
842 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
852 let prompt_tools = active.clone();
853 let prompt_metadata = extension_tool_prompt_metadata(&extension_session);
854 let default_prompt = || {
855 default_system_prompt_with_tool_prompts(
856 &cwd_str,
857 prior_reasoning_replayed,
858 &prompt_tools,
859 &prompt_metadata,
860 )
861 };
862 let mut context_paths = Vec::new();
863 let base_prompt = match args.system_prompt.as_deref() {
864 Some(explicit) => explicit.to_string(),
865 None if project_trusted && resource_selection.is_none() => {
866 match discover_system_prompt_file(cwd) {
867 Some(path) => {
868 read_prompt_source(&path, &mut context_paths).unwrap_or_else(default_prompt)
869 }
870 None => default_prompt(),
871 }
872 }
873 None => default_prompt(),
874 };
875
876 let mut append_texts: Vec<String> = Vec::new();
883 for extra in &args.append_system_prompt {
884 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
885 append_texts.push(text);
886 }
887 if let Some(selected) = &resource_selection {
888 append_texts.extend(
889 selected
890 .instructions
891 .iter()
892 .map(|(_, _, text)| text.clone()),
893 );
894 }
895 if args.append_system_prompt.is_empty() && resource_selection.is_none() {
896 if let Some(path) = project_trusted
897 .then(|| discover_append_system_prompt_file(cwd))
898 .flatten()
899 {
900 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
901 append_texts.push(text);
902 }
903 }
904 }
905 let append_join = if append_texts.is_empty() {
906 None
907 } else {
908 Some(append_texts.join("\n\n"))
909 };
910
911 let agent_dir = crate::config::agent_dir().ok();
923
924 let discovered = extension_session
940 .snapshot_arc()
941 .filter(|_| resource_selection.is_none())
942 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
943 .unwrap_or_default();
944
945 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
946 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
947 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
948 if let Some(selected) = &resource_selection {
949 skills = selected
952 .validated_skills()
953 .map_err(BuildError::HarnessCreate)?
954 .skills
955 .clone();
956 } else if !args.no_skills {
957 let mut dirs = if args.dev_local_only || args.project_only {
958 project_skill_dirs(cwd)
959 } else if project_trusted {
960 skill_dirs(cwd)
961 } else {
962 global_skill_dirs()
963 };
964 dirs.extend(args.skill.iter().cloned());
965 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
966 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
967 skills = result.skills;
968 skill_diags = result.diagnostics;
969 resource_diags.extend(result.resource_diagnostics);
970 }
971
972 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
973 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
974 if !args.no_prompt_templates && resource_selection.is_none() {
975 let mut dirs = if args.dev_local_only || args.project_only {
976 project_prompt_template_dirs(cwd)
977 } else if project_trusted {
978 prompt_template_dirs(cwd)
979 } else {
980 global_prompt_template_dirs()
981 };
982 dirs.extend(args.prompt_template.iter().cloned());
983 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
984 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
985 prompt_templates = result.prompt_templates;
986 prompt_diags = result.diagnostics;
987 resource_diags.extend(result.resource_diagnostics);
988 }
989
990 let context_block = if args.no_context_files || !project_trusted || resource_selection.is_some()
991 {
992 String::new()
993 } else {
994 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
999 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
1000 context_paths.extend(
1001 files
1002 .iter()
1003 .map(|file| file.path.to_string_lossy().into_owned()),
1004 );
1005 format_project_context(&files)
1006 };
1007
1008 if args.verbose {
1010 for d in &skill_diags {
1011 eprintln!(
1012 "warning: skill {} ({}): {}",
1013 d.path,
1014 d.code.as_str(),
1015 d.message
1016 );
1017 }
1018 for d in &prompt_diags {
1019 eprintln!(
1020 "warning: prompt template {} ({}): {}",
1021 d.path,
1022 d.code.as_str(),
1023 d.message
1024 );
1025 }
1026 for d in &resource_diags {
1028 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1029 eprintln!(
1030 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1031 c.resource_type.as_str(),
1032 c.name,
1033 c.winner_path,
1034 c.loser_path
1035 );
1036 }
1037 }
1038 }
1039
1040 let system_prompt = compose_system_prompt(
1049 Some(&base_prompt),
1050 &[], if context_block.is_empty() {
1052 None
1053 } else {
1054 Some(&context_block)
1055 },
1056 append_join.as_deref(),
1057 );
1058
1059 if args.debug_system_prompt {
1068 eprintln!("=== --debug-system-prompt ===");
1069 let base_src = if args.system_prompt.is_some() {
1070 "--system-prompt"
1071 } else if discover_system_prompt_file(cwd).is_some() {
1072 "SYSTEM.md"
1073 } else {
1074 "default"
1075 };
1076 eprintln!("[base source: {base_src}]");
1077 eprintln!("--- base ---\n{base_prompt}");
1078 if let Some(append) = append_join.as_deref() {
1079 eprintln!("--- append ---\n{append}");
1080 } else {
1081 eprintln!("--- append: (none) ---");
1082 }
1083 if context_block.is_empty() {
1084 eprintln!("--- context: (none) ---");
1085 } else {
1086 eprintln!("--- context ---{context_block}");
1087 }
1088 let visible_skills = skills
1089 .iter()
1090 .filter(|s| s.disable_model_invocation != Some(true))
1091 .count();
1092 eprintln!(
1093 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
1094 skills.len(),
1095 visible_skills,
1096 skills.len() - visible_skills
1097 );
1098 for s in &skills {
1099 let hidden = if s.disable_model_invocation == Some(true) {
1100 " [hidden]"
1101 } else {
1102 ""
1103 };
1104 eprintln!(" {}{hidden} — {}", s.name, s.description);
1105 }
1106 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
1107 for t in &prompt_templates {
1108 eprintln!(" /{}", t.name);
1109 }
1110 eprintln!(
1114 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
1115 discovered.skill_paths.len(),
1116 discovered.prompt_paths.len(),
1117 discovered.theme_paths.len(),
1118 );
1119 for p in &discovered.skill_paths {
1120 eprintln!(" skill: {p}");
1121 }
1122 for p in &discovered.prompt_paths {
1123 eprintln!(" prompt: {p}");
1124 }
1125 eprintln!(
1126 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
1127 );
1128 eprintln!("=== end --debug-system-prompt ===");
1129 }
1130
1131 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
1136 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
1137 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
1141
1142 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1151 Some(snapshot) => {
1152 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1153 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
1154 }
1155 None => broadcast_emitter,
1156 };
1157
1158 let saved_settings = crate::settings::load_settings().unwrap_or_default();
1162
1163 if let Some(proxy) = &saved_settings.http_proxy {
1168 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
1169 if std::env::var_os(key).is_none() {
1170 std::env::set_var(key, proxy);
1171 }
1172 }
1173 }
1174
1175 let extension_session_cell = Arc::new(Mutex::new(extension_session.clone()));
1180 let before_tool_call = extension_session
1185 .snapshot()
1186 .filter(|snapshot| {
1187 !snapshot
1188 .handlers_for(rpi_plugin_sdk::EventTag::ToolCall)
1189 .is_empty()
1190 })
1191 .map(|_| {
1192 extension_tool_call_hook(
1193 Arc::clone(&extension_session_cell),
1194 ui_dialog_mailbox.clone(),
1195 )
1196 });
1197
1198 let options = AgentHarnessOptions {
1199 model: resolved.model.clone(),
1200 thinking_level: resolved.thinking_level,
1201 active_tool_names: active.clone(),
1202 tools,
1203 system_prompt: Some(system_prompt),
1204 resources: AgentHarnessResources {
1205 context_files: context_paths,
1206 skills: if skills.is_empty() {
1207 None
1208 } else {
1209 Some(skills)
1210 },
1211 prompt_templates: if prompt_templates.is_empty() {
1212 None
1213 } else {
1214 Some(prompt_templates)
1215 },
1216 },
1217 allow_existing_session: matches!(
1220 selection,
1221 SessionSelection::Latest { .. }
1222 | SessionSelection::ById { .. }
1223 | SessionSelection::ByExactId { .. }
1224 | SessionSelection::Fork { .. }
1225 ),
1226 stream_options: AgentHarnessStreamOptions {
1227 timeout: args.timeout,
1228 stream_idle_timeout: args.stream_idle_timeout.or_else(|| {
1229 saved_settings
1230 .stream_idle_timeout
1231 .map(std::time::Duration::from_secs)
1232 }),
1233 thinking_budgets: saved_settings.thinking_budgets,
1234 ..Default::default()
1235 },
1236 retry: saved_settings.retry_policy(),
1241 compaction: saved_settings.compaction_settings(),
1242 steering_mode: saved_settings.steering_mode(),
1243 follow_up_mode: saved_settings.follow_up_mode(),
1244 tool_execution: HarnessToolExecution::default(),
1245 drive: DrivingMode::default(),
1246 session,
1247 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
1255 to_provider_messages: None,
1256 entry_projectors: Default::default(),
1257 agent_emitter: Some(emitter),
1258 before_tool_call,
1263 after_tool_call: None,
1264 transform_context: None,
1265 entry_transforms: Vec::new(),
1266 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1272 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1273 };
1274
1275 let harness = AgentHarness::create(options)
1276 .await
1277 .map_err(|e| BuildError::HarnessCreate(e.to_string()))?;
1278
1279 let harness_leases = resource_leases.clone();
1281 harness
1282 .events()
1283 .on_lifetime::<rpi_harness::events::RunEndEvent, _>(move |_| {
1284 let _ = &harness_leases;
1285 });
1286 install_extension_settlement_bridge(harness.events(), extension_session_cell.clone());
1287 if let Some(selected) = &resource_selection {
1288 harness
1289 .seal_resources(active)
1290 .map_err(|e| BuildError::HarnessCreate(e.to_string()))?;
1291 crate::resource_selection::attach(
1292 &harness,
1293 serde_json::json!({
1294 "version": 1,
1295 "hostId": uuid::Uuid::new_v4().to_string(),
1296 "host": {"name": crate::APP_NAME, "version": crate::VERSION, "pid": std::process::id()},
1297 "selection": selected.selection,
1298 "package": selected.package,
1299 "selectedSkills": selected.skills,
1300 "skillDiagnostics": [],
1301 "loadedExtensions": extension_session.loaded_paths().iter().map(|p| serde_json::json!({"identity": p.file_stem().and_then(|s| s.to_str()), "path": p})).collect::<Vec<_>>(),
1302 "instructions": selected.provenance(),
1303 "baseSystemPrompt": "native-default",
1304 "projectContext": "disabled",
1305 "resourceDiscovery": "disabled",
1306 "changes": "restart-required"
1307 }),
1308 );
1309 let resource_observation = StartupSpan::start(Phase::ResourceObservation);
1310 crate::resource_selection::get_resource_state(&harness)
1311 .await
1312 .map_err(BuildError::HarnessCreate)?;
1313 resource_observation.finish();
1314 if selected.transition.is_some() {
1315 let meta = resolve_restore_target(&selection, &cwd_str)
1319 .await?
1320 .ok_or_else(|| {
1321 BuildError::SessionOpen("transition session disappeared before commit".into())
1322 })?;
1323 let path = std::fs::canonicalize(&meta.path)
1324 .map_err(|e| BuildError::SessionOpen(e.to_string()))?;
1325 let lock_path = PathBuf::from(format!("{}.lock", path.display()));
1326 if !SESSION_LOCK_PATHS
1327 .get_or_init(|| Mutex::new(HashSet::new()))
1328 .lock()
1329 .unwrap()
1330 .contains(&lock_path)
1331 {
1332 return Err(BuildError::SessionOpen(
1333 "resource transition lost canonical writer ownership".into(),
1334 ));
1335 }
1336 let facts =
1337 rpi_harness::session::jsonl::JsonlSessionStorage::read_resource_transition_facts(
1338 Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf())),
1339 &path.to_string_lossy(),
1340 Arc::new(SystemClock),
1341 Arc::new(DefaultIdGenerator::new()),
1342 )
1343 .await
1344 .map_err(|e| BuildError::SessionOpen(e.to_string()))?;
1345 crate::resource_selection::validate_session_binding(&facts.values, Some(selected))
1346 .map_err(BuildError::SessionOpen)?;
1347 let mirror = rpi_harness::session::SessionValues::load(harness.session())
1348 .await
1349 .map_err(|e| BuildError::SessionOpen(e.to_string()))?;
1350 if crate::resource_selection::binding_state(&mirror)
1351 != crate::resource_selection::binding_state(&facts.values)
1352 {
1353 return Err(BuildError::SessionOpen(
1354 "resource binding changed before commit".into(),
1355 ));
1356 }
1357 }
1358 let inventory_bind = StartupSpan::start(Phase::InventoryBind);
1359 crate::resource_selection::bind_inventory(&harness, selected)
1360 .await
1361 .map_err(BuildError::SessionOpen)?;
1362 inventory_bind.finish();
1363 }
1364 crate::extensions_actions::HarnessActionHost::set_harness(
1366 &harness_cell,
1367 Arc::new(harness.clone()),
1368 );
1369 register_file_io_router(&harness, &file_io);
1370
1371 {
1379 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
1380 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
1381 Ok(reports) => {
1382 for (lane, report) in reports {
1383 if report.is_clean() {
1384 continue;
1385 }
1386 if report.is_corrupt() {
1387 eprintln!(
1388 "warning: session lane '{lane}' is corrupt ({} open operations); \
1389 refusing to auto-repair — inspect the session file",
1390 report.open_operations.len()
1391 );
1392 continue;
1393 }
1394 for decision in runtime.reconcile(&report) {
1395 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
1396 if args.verbose {
1397 eprintln!(
1398 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
1399 );
1400 }
1401 }
1402 }
1403 runtime.emit_recovery_events(harness.events(), &report);
1404 }
1405 }
1406 Err(e) => {
1407 if args.verbose {
1408 eprintln!("warning: session recovery sweep failed: {e}");
1409 }
1410 }
1411 }
1412 }
1413
1414 let mut reload_args = args.clone();
1423 reload_args.session_dir = Some(session_dir);
1424 let reload_context = ReloadContext {
1425 resource_leases,
1426 fresh_session_only,
1427 extension_session: extension_session_cell,
1428 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
1429 tool_context,
1430 file_io,
1431 catalog: Arc::new(std::sync::RwLock::new(catalog)),
1432 providers: Arc::new(std::sync::RwLock::new(resolved.providers.clone())),
1433 runtime: runtime.clone(),
1434 cwd: cwd.to_path_buf(),
1435 project_trusted,
1436 discovered_theme_paths: Arc::new(std::sync::Mutex::new(discovered.theme_paths.clone())),
1437 args: reload_args,
1438 resolved_model: resolved.model.clone(),
1439 broadcast: broadcast_for_context,
1440 mailbox: reload_mailbox,
1441 ui_dialog: ui_dialog_mailbox,
1442 ext_status: ext_status_mailbox,
1443 editor_text: editor_text_mailbox,
1444 dev_extension: None,
1445 startup_notices: Vec::new(),
1446 };
1447
1448 Ok((harness, event_rx, reload_context))
1449}
1450
1451fn install_extension_settlement_bridge(
1455 events: &rpi_harness::events::HarnessEventBus,
1456 sessions: ExtensionSessionCell,
1457) {
1458 events.on_lifetime::<rpi_harness::events::RunEndEvent, _>(move |event| {
1459 if event.lane != "main" {
1460 return;
1461 }
1462 let session = sessions.lock().unwrap().clone();
1463 if let Some(snapshot) = session.snapshot_arc() {
1464 rpi_extensions::dispatch_empty_event(&snapshot, rpi_plugin_sdk::EventTag::AgentSettled);
1465 }
1466 });
1467}
1468
1469fn extension_tool_call_hook(
1472 sessions: ExtensionSessionCell,
1473 ui_dialog: rpi_extensions::UiDialogMailbox,
1474) -> rpi_agent::BeforeToolCall {
1475 Arc::new(move |ctx: rpi_agent::BeforeToolCallContext<'_>, signal| {
1476 let session = sessions
1477 .lock()
1478 .map(|session| session.clone())
1479 .map_err(|_| ());
1480 let ui_dialog = ui_dialog.clone();
1481 let tool_call_id = ctx.tool_call.id.clone();
1482 let tool_name = ctx.tool_call.name.clone();
1483 let params = ctx.args.clone();
1484 Box::pin(async move {
1485 let reason = match session {
1486 Ok(session) => {
1487 rpi_extensions::dispatch_tool_call_event(
1488 &session,
1489 &tool_call_id,
1490 &tool_name,
1491 ¶ms,
1492 &signal,
1493 &ui_dialog,
1494 )
1495 .await
1496 }
1497 Err(_) => Some("extension approval session is unavailable".to_string()),
1498 };
1499 reason.map(|reason| rpi_agent::BeforeToolCallResult {
1500 block: true,
1501 reason: Some(reason),
1502 terminate: false,
1503 args: None,
1504 })
1505 })
1506 })
1507}
1508
1509pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
1554
1555pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
1560
1561#[derive(Clone)]
1569pub struct ReloadContext {
1570 resource_leases: Arc<Vec<crate::resource_management::Lease>>,
1571 fresh_session_only: bool,
1573 pub file_io: Arc<rpi_tools::file_io::FileIORouter>,
1575 pub extension_session: ExtensionSessionCell,
1577 pub tool_context: rpi_extensions::ToolCallContext,
1582 pub action_bridge: ActionBridgeCell,
1584 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
1587 pub editor_text: rpi_extensions::EditorTextMailbox,
1594 pub catalog: Arc<std::sync::RwLock<Vec<rpi_ai::Model>>>,
1597 pub providers: Arc<std::sync::RwLock<Vec<Arc<dyn Provider>>>>,
1600 pub runtime: tokio::runtime::Handle,
1604 pub cwd: PathBuf,
1606 pub project_trusted: bool,
1610 pub discovered_theme_paths: Arc<std::sync::Mutex<Vec<String>>>,
1615 pub args: Args,
1622 pub resolved_model: rpi_ai::Model,
1625 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
1631 pub mailbox: rpi_extensions::ReloadMailbox,
1638 pub ui_dialog: rpi_extensions::UiDialogMailbox,
1644 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
1648 pub startup_notices: Vec<String>,
1654}
1655
1656impl ReloadContext {
1657 pub(crate) fn session_dir(&self) -> PathBuf {
1658 self.args
1660 .session_dir
1661 .clone()
1662 .unwrap_or_else(|| configured_session_dir(&self.args, &self.cwd))
1663 }
1664}
1665
1666pub async fn dispatch_session_event_async(
1681 reload_context: &ReloadContext,
1682 tag: rpi_plugin_sdk::EventTag,
1683) -> Option<String> {
1684 let (snapshot, keepalive) = {
1690 let session = reload_context.extension_session.lock().unwrap();
1691 (session.snapshot_arc(), session.keepalive())
1692 };
1693 match snapshot {
1694 Some(snapshot) => rpi_extensions::dispatch_lifecycle_event(&snapshot, tag, keepalive).await,
1695 None => None,
1696 }
1697}
1698
1699async fn shutdown_extensions_for_reload(
1702 snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
1703 keepalive: Arc<rpi_extensions::PluginKeepalive>,
1704) -> Result<(), String> {
1705 if let Some(snapshot) = snapshot {
1706 tokio::task::spawn_blocking(move || {
1707 let _keepalive = keepalive;
1708 rpi_extensions::dispatch_empty_event(
1709 &snapshot,
1710 rpi_plugin_sdk::EventTag::SessionShutdown,
1711 );
1712 })
1713 .await
1714 .map_err(|error| error.to_string())?;
1715 }
1716 Ok(())
1717}
1718
1719pub struct ReloadOutcome {
1722 pub summary: String,
1725 pub had_warnings: bool,
1727 pub details: Vec<String>,
1732 pub had_errors: bool,
1736 pub project_trusted: Option<bool>,
1741}
1742
1743struct PreparedReloadInputs {
1744 extension_dirs: Vec<PathBuf>,
1745 skill_base_dirs: Vec<PathBuf>,
1746 prompt_base_dirs: Vec<PathBuf>,
1747}
1748
1749fn append_reload_resource_paths(
1754 mut paths: Vec<PathBuf>,
1755 explicit: &[PathBuf],
1756 discovered: &[String],
1757 js_paths: &[PathBuf],
1758 package_paths: &[PathBuf],
1759) -> Vec<PathBuf> {
1760 paths.extend(explicit.iter().cloned());
1761 paths.extend(discovered.iter().map(PathBuf::from));
1762 paths.extend(js_paths.iter().cloned());
1763 paths.extend(package_paths.iter().cloned());
1764 paths
1765}
1766
1767pub async fn reload_extension_resources(
1779 harness: &AgentHarness,
1780 ctx: &ReloadContext,
1781) -> ReloadOutcome {
1782 reload_extension_resources_inner(harness, ctx, || {}).await
1783}
1784
1785async fn reload_extension_resources_inner<F>(
1786 harness: &AgentHarness,
1787 ctx: &ReloadContext,
1788 after_prepare: F,
1789) -> ReloadOutcome
1790where
1791 F: FnOnce() + Send,
1792{
1793 if harness.has_exact_resources()
1794 || ctx.args.resource_config.is_some()
1795 || ctx.args.resource_transition.is_some()
1796 {
1797 return ReloadOutcome {
1798 summary:
1799 "Exact resource selection is restart-required; reload/dev hot apply unsupported"
1800 .into(),
1801 had_warnings: true,
1802 details: Vec::new(),
1803 had_errors: true,
1804 project_trusted: None,
1805 };
1806 }
1807 let mut effective_args = ctx.args.clone();
1812 if let Some(dev) = &ctx.dev_extension {
1813 if let Err(error) = dev
1814 .rebuild()
1815 .and_then(|_| dev.apply_to_args(&mut effective_args))
1816 {
1817 return ReloadOutcome {
1818 summary: format!(
1819 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1820 dev.package_name()
1821 ),
1822 had_warnings: true,
1823 details: Vec::new(),
1824 had_errors: true,
1825 project_trusted: None,
1826 };
1827 }
1828 }
1829 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1830 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1831 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1832 Ok(prepared) => prepared,
1833 Err(error) => {
1834 return ReloadOutcome {
1835 summary: format!(
1836 "Settings reload failed: {error}. Keeping the currently loaded resources."
1837 ),
1838 had_warnings: true,
1839 details: Vec::new(),
1840 had_errors: true,
1841 project_trusted: None,
1842 };
1843 }
1844 };
1845 after_prepare();
1846 let PreparedReloadInputs {
1847 extension_dirs,
1848 skill_base_dirs,
1849 prompt_base_dirs,
1850 } = prepared;
1851 let mut warnings = false;
1852
1853 let (shutdown_snapshot, shutdown_keepalive) = {
1857 let session = ctx.extension_session.lock().unwrap();
1858 (session.snapshot_arc(), session.keepalive())
1859 };
1860 if let Err(reason) = shutdown_extensions_for_reload(shutdown_snapshot, shutdown_keepalive).await
1861 {
1862 return ReloadOutcome {
1863 summary: "Reload failed during extension shutdown".into(),
1864 had_warnings: true,
1865 details: vec![reason],
1866 had_errors: true,
1867 project_trusted: None,
1868 };
1869 }
1870
1871 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1878 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1879 Some(b) => b.clone_host(),
1880 None => {
1881 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1884 ctx.catalog
1885 .read()
1886 .unwrap_or_else(|error| error.into_inner())
1887 .clone(),
1888 ctx.cwd.clone(),
1889 ctx.runtime.clone(),
1890 ctx.args.unknown_flags.clone(),
1891 ctx.tool_context.clone(),
1892 );
1893 crate::extensions_actions::HarnessActionHost::set_harness(
1894 &_cell,
1895 Arc::new(harness.clone()),
1896 );
1897 Arc::new(
1898 action_host
1899 .with_session_dir(ctx.session_dir())
1900 .with_fresh_session_only(ctx.fresh_session_only),
1901 )
1902 }
1903 };
1904
1905 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1906 let publish_host = Arc::clone(&host);
1907 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1908 ctx.runtime.clone(),
1909 host,
1910 reload_cb,
1911 ctx.ui_dialog.clone(),
1912 ctx.ext_status.clone(),
1913 ctx.editor_text.clone(),
1914 );
1915
1916 let extension_session = if effective_args.no_extensions {
1917 rpi_extensions::ExtensionSession::none()
1918 } else {
1919 load_extensions_from_dirs(
1920 &effective_args,
1921 &extension_dirs,
1922 Some(Arc::clone(&fresh_bridge)),
1923 Some(ctx.resource_leases.clone()),
1924 )
1925 };
1926 if let Err(error) =
1927 crate::native_store::validate_stdio(ctx.fresh_session_only, extension_session.owns_stdio())
1928 {
1929 return ReloadOutcome {
1930 summary: "Reload refused by experimental native session policy".into(),
1931 had_warnings: true,
1932 details: vec![error],
1933 had_errors: true,
1934 project_trusted: None,
1935 };
1936 }
1937 if extension_session.is_empty() && !effective_args.no_extensions {
1938 }
1941 if effective_args.verbose {
1942 if let Some(s) = extension_session.summary() {
1943 eprintln!("reload: {s}");
1944 }
1945 report_deferred_renderers(&extension_session);
1946 }
1947
1948 let has_package_providers = |session: &ExtensionSession| {
1949 session.snapshot().is_some_and(|snapshot| {
1950 snapshot
1951 .providers()
1952 .iter()
1953 .any(|provider| snapshot.provider_manifest(&provider.provider_id).is_some())
1954 })
1955 };
1956 let packaged = has_package_providers(&extension_session)
1957 || has_package_providers(&ctx.extension_session.lock().unwrap());
1958 let refreshed = if packaged {
1959 let mut args = effective_args.clone();
1960 if let Ok(current) = harness.lane("main").get_model().await {
1961 let was_package = ctx
1962 .extension_session
1963 .lock()
1964 .unwrap()
1965 .snapshot()
1966 .is_some_and(|snapshot| snapshot.provider_manifest(¤t.provider).is_some());
1967 let has_replacement = extension_session.snapshot().is_some_and(|snapshot| {
1968 snapshot
1969 .providers()
1970 .iter()
1971 .any(|provider| provider.provider_id == current.provider)
1972 });
1973 if was_package && !has_replacement {
1974 return ReloadOutcome {
1975 summary: "Reload failed while resolving provider packages".into(),
1976 had_warnings: true,
1977 details: vec![format!(
1978 "active provider package '{}' is no longer loaded",
1979 current.provider
1980 )],
1981 had_errors: true,
1982 project_trusted: None,
1983 };
1984 }
1985 args.provider = Some(current.provider.clone());
1986 args.model = Some(current.id);
1987 if current.provider != ctx.resolved_model.provider {
1988 args.api_key = None;
1989 args.base_url = None;
1990 }
1991 }
1992 match crate::provider::resolve_with_extensions(
1993 &args,
1994 &ctx.cwd,
1995 ctx.project_trusted,
1996 &extension_session,
1997 ) {
1998 Ok(resolved) => Some(resolved),
1999 Err(error) => {
2000 return ReloadOutcome {
2001 summary: "Reload failed while resolving provider packages".into(),
2002 had_warnings: true,
2003 details: vec![error.to_string()],
2004 had_errors: true,
2005 project_trusted: None,
2006 }
2007 }
2008 }
2009 } else {
2010 None
2011 };
2012
2013 let old_session = {
2025 let mut session_guard = ctx.extension_session.lock().unwrap();
2026 let old_session = std::mem::replace(&mut *session_guard, extension_session.clone());
2027 if let Some(old_snap) = old_session.snapshot_arc() {
2028 old_snap.active_flag().store(false, Ordering::SeqCst);
2032 }
2033 old_session
2034 };
2035 if let Some(old_b) = old_bridge {
2036 old_b.invalidate();
2037 }
2038 old_session.begin_shutdown();
2040
2041 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
2043 publish_host.publish_registry_opt(extension_session.snapshot_arc());
2051
2052 let discovered = extension_session
2054 .snapshot_arc()
2055 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
2056 .unwrap_or_default();
2057 if let Ok(mut guard) = ctx.discovered_theme_paths.lock() {
2060 *guard = discovered.theme_paths.clone();
2061 }
2062
2063 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
2065 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2066
2067 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
2068 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
2069 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
2070 if !effective_args.no_skills {
2071 let js_paths: &[PathBuf] = &[];
2075 let package_paths: &[PathBuf] = &[];
2076 let dirs = append_reload_resource_paths(
2077 skill_base_dirs,
2078 &effective_args.skill,
2079 &discovered.skill_paths,
2080 js_paths,
2081 package_paths,
2082 );
2083 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
2084 skills = result.skills;
2085 skill_diags = result.diagnostics;
2086 resource_diags.extend(result.resource_diagnostics);
2087 }
2088
2089 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
2090 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
2091 if !effective_args.no_prompt_templates {
2092 let js_paths: &[PathBuf] = &[];
2093 let package_paths: &[PathBuf] = &[];
2094 let dirs = append_reload_resource_paths(
2095 prompt_base_dirs,
2096 &effective_args.prompt_template,
2097 &discovered.prompt_paths,
2098 js_paths,
2099 package_paths,
2100 );
2101 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
2102 prompt_templates = result.prompt_templates;
2103 prompt_diags = result.diagnostics;
2104 resource_diags.extend(result.resource_diagnostics);
2105 }
2106
2107 let context_files = if effective_args.no_context_files || !project_trusted {
2108 Vec::new()
2109 } else {
2110 let agent_dir = crate::config::agent_dir().ok();
2111 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
2112 load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await
2113 };
2114 let context_block = format_project_context(&context_files);
2115
2116 let mut details: Vec<String> = Vec::new();
2117 if let Some(resolved) = &refreshed {
2118 warnings |= !resolved.compat_warnings.is_empty();
2119 details.extend(
2120 resolved
2121 .compat_warnings
2122 .iter()
2123 .map(|warning| format!("warning: models.json: {warning}")),
2124 );
2125 }
2126 if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
2127 warnings = true;
2128 for d in &skill_diags {
2133 details.push(format!(
2134 "warning: skill {} ({}): {}",
2135 d.path,
2136 d.code.as_str(),
2137 d.message
2138 ));
2139 }
2140 for d in &prompt_diags {
2141 details.push(format!(
2142 "warning: prompt template {} ({}): {}",
2143 d.path,
2144 d.code.as_str(),
2145 d.message
2146 ));
2147 }
2148 for d in &resource_diags {
2150 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
2151 details.push(format!(
2152 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
2153 c.resource_type.as_str(),
2154 c.name,
2155 c.winner_path,
2156 c.loser_path
2157 ));
2158 }
2159 }
2160 }
2161
2162 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2171 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env))
2172 .with_file_io(ctx.file_io.clone());
2173 let mut tools = build_tools(&tool_ctx, &effective_args);
2174 merge_extension_tools(
2175 &mut tools,
2176 &extension_session,
2177 &effective_args,
2178 &ctx.tool_context,
2179 );
2180 let mut active = active_tool_names(&tools, &effective_args);
2181 active = filter_active_tool_names(active, &effective_args);
2182 let prompt_tools = active.clone();
2183 let prompt_metadata = extension_tool_prompt_metadata(&extension_session);
2184 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
2185 let default_prompt = || {
2186 default_system_prompt_with_tool_prompts(
2187 &cwd_str,
2188 prior_reasoning_replayed,
2189 &prompt_tools,
2190 &prompt_metadata,
2191 )
2192 };
2193 let mut context_paths = Vec::new();
2194 let base_prompt = match effective_args.system_prompt.as_deref() {
2195 Some(explicit) => explicit.to_string(),
2196 None if project_trusted => match discover_system_prompt_file(&ctx.cwd) {
2197 Some(path) => {
2198 read_prompt_source(&path, &mut context_paths).unwrap_or_else(default_prompt)
2199 }
2200 None => default_prompt(),
2201 },
2202 None => default_prompt(),
2203 };
2204 let mut append_texts: Vec<String> = Vec::new();
2205 for extra in &effective_args.append_system_prompt {
2206 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
2207 append_texts.push(text);
2208 }
2209 if effective_args.append_system_prompt.is_empty() {
2210 if let Some(path) = project_trusted
2211 .then(|| discover_append_system_prompt_file(&ctx.cwd))
2212 .flatten()
2213 {
2214 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
2215 append_texts.push(text);
2216 }
2217 }
2218 }
2219 context_paths.extend(
2220 context_files
2221 .iter()
2222 .map(|file| file.path.to_string_lossy().into_owned()),
2223 );
2224 let append_join = if append_texts.is_empty() {
2225 None
2226 } else {
2227 Some(append_texts.join("\n\n"))
2228 };
2229 let system_prompt = compose_system_prompt(
2230 Some(&base_prompt),
2231 &[],
2232 if context_block.is_empty() {
2233 None
2234 } else {
2235 Some(&context_block)
2236 },
2237 append_join.as_deref(),
2238 );
2239
2240 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
2242 Some(snapshot) => {
2243 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
2244 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
2245 }
2246 None => ctx.broadcast.clone(),
2247 };
2248
2249 let resources = AgentHarnessResources {
2251 context_files: context_paths,
2252 skills: if skills.is_empty() {
2253 None
2254 } else {
2255 Some(skills.clone())
2256 },
2257 prompt_templates: if prompt_templates.is_empty() {
2258 None
2259 } else {
2260 Some(prompt_templates.clone())
2261 },
2262 };
2263 let _ = harness.set_system_prompt(Some(system_prompt)).await;
2264 let _ = harness.set_resources(resources).await;
2265 let _ = harness.set_agent_emitter(Some(emitter)).await;
2266 let _ = harness
2267 .set_models(build_models_with_extensions_for_reload(
2268 &refreshed
2269 .as_ref()
2270 .map(|resolved| resolved.providers.clone())
2271 .unwrap_or_else(|| {
2272 ctx.providers
2273 .read()
2274 .unwrap_or_else(|error| error.into_inner())
2275 .clone()
2276 }),
2277 &extension_session,
2278 ctx.runtime.clone(),
2279 ))
2280 .await;
2281 let _ = harness
2282 .set_provider_hooks(
2283 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
2284 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
2285 )
2286 .await;
2287
2288 if let Some(resolved) = &refreshed {
2289 *ctx.providers
2290 .write()
2291 .unwrap_or_else(|error| error.into_inner()) = resolved.providers.clone();
2292 let catalog = crate::provider::authenticated_catalog(resolved);
2293 fresh_bridge
2294 .clone_host()
2295 .replace_model_catalog(catalog.clone());
2296 *ctx.catalog
2297 .write()
2298 .unwrap_or_else(|error| error.into_inner()) = catalog;
2299 let _ = harness.lane("main").set_model(resolved.model.clone()).await;
2302 }
2303
2304 let _ = harness.set_tools(tools, Some(active)).await;
2305
2306 let summary = format!(
2307 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
2308 extension_session.loaded_paths().len(),
2309 skills.len(),
2310 prompt_templates.len(),
2311 );
2312 ReloadOutcome {
2313 summary,
2314 had_warnings: warnings,
2315 details,
2316 had_errors: false,
2319 project_trusted: Some(project_trusted),
2320 }
2321}
2322
2323#[derive(Clone, Debug, PartialEq, Eq)]
2324struct ReloadSettingsFields {
2325 skill_dirs: Option<Vec<String>>,
2326 prompt_dirs: Option<Vec<String>>,
2327 extension_dirs: Option<Vec<String>>,
2328}
2329
2330impl From<crate::settings::Settings> for ReloadSettingsFields {
2331 fn from(settings: crate::settings::Settings) -> Self {
2332 Self {
2333 skill_dirs: settings.skill_dirs,
2334 prompt_dirs: settings.prompt_dirs,
2335 extension_dirs: settings.extension_dirs,
2336 }
2337 }
2338}
2339
2340#[derive(Clone, Debug, PartialEq, Eq)]
2341struct ReloadSettingsSnapshot {
2342 global: ReloadSettingsFields,
2343 project: Option<ReloadSettingsFields>,
2344}
2345
2346fn reload_reads_settings(args: &Args) -> bool {
2347 !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
2348}
2349
2350fn load_reload_settings_snapshot(
2354 args: &Args,
2355 cwd: &Path,
2356 project_trusted: bool,
2357) -> Result<Option<ReloadSettingsSnapshot>, String> {
2358 if !reload_reads_settings(args) {
2359 return Ok(None);
2360 }
2361 let global = crate::settings::load_settings()
2362 .map_err(|error| format!("could not load global settings: {error}"))?
2363 .into();
2364 let project = if project_trusted {
2365 crate::settings::load_active_project_settings(cwd)
2366 .map_err(|error| format!("could not load project settings: {error}"))?
2367 .map(|(_, settings)| settings.into())
2368 } else {
2369 None
2370 };
2371 Ok(Some(ReloadSettingsSnapshot { global, project }))
2372}
2373
2374#[cfg(test)]
2377fn validate_settings_for_reload(
2378 args: &Args,
2379 cwd: &Path,
2380 project_trusted: bool,
2381) -> Result<(), String> {
2382 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
2383}
2384
2385fn prepare_reload_inputs(
2386 args: &Args,
2387 cwd: &Path,
2388 project_trusted: bool,
2389) -> Result<PreparedReloadInputs, String> {
2390 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
2391}
2392
2393fn prepare_reload_inputs_inner<F>(
2394 args: &Args,
2395 cwd: &Path,
2396 project_trusted: bool,
2397 before_verify: F,
2398) -> Result<PreparedReloadInputs, String>
2399where
2400 F: FnOnce(),
2401{
2402 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
2403
2404 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
2405 Vec::new()
2406 } else if args.project_only {
2407 project_extension_dirs(cwd)
2408 } else if project_trusted {
2409 extension_dirs(cwd)
2410 } else {
2411 global_extension_dirs()
2412 };
2413 if !args.no_extensions {
2414 extension_dirs.extend(args.extensions_dir.iter().cloned());
2415 }
2416
2417 let skill_base_dirs = if args.no_skills {
2418 Vec::new()
2419 } else if args.dev_local_only || args.project_only {
2420 project_skill_dirs(cwd)
2421 } else if project_trusted {
2422 skill_dirs(cwd)
2423 } else {
2424 global_skill_dirs()
2425 };
2426
2427 let prompt_base_dirs = if args.no_prompt_templates {
2428 Vec::new()
2429 } else if args.dev_local_only || args.project_only {
2430 project_prompt_template_dirs(cwd)
2431 } else if project_trusted {
2432 prompt_template_dirs(cwd)
2433 } else {
2434 global_prompt_template_dirs()
2435 };
2436
2437 before_verify();
2438 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
2439 if settings_before != settings_after {
2440 return Err(
2441 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
2442 );
2443 }
2444
2445 Ok(PreparedReloadInputs {
2446 extension_dirs,
2447 skill_base_dirs,
2448 prompt_base_dirs,
2449 })
2450}
2451
2452fn build_models_with_extensions_for_reload(
2457 providers: &[Arc<dyn Provider>],
2458 extension_session: &ExtensionSession,
2459 runtime: tokio::runtime::Handle,
2460) -> Vec<Arc<dyn Provider>> {
2461 let mut models: Vec<Arc<dyn Provider>> = providers.to_vec();
2462 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
2463 for provider in pluggable {
2464 if extension_session
2468 .snapshot()
2469 .is_some_and(|snapshot| snapshot.provider_manifest(provider.id()).is_some())
2470 {
2471 continue;
2472 }
2473 if !models.iter().any(|known| known.id() == provider.id()) {
2474 models.push(provider);
2475 }
2476 }
2477 models
2478}
2479
2480fn report_deferred_renderers(session: &ExtensionSession) {
2484 let Some(snap) = session.snapshot_arc() else {
2485 return;
2486 };
2487 let all = snap.renderers();
2488 let markdown = all
2489 .iter()
2490 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
2491 .count();
2492 let message = all
2493 .iter()
2494 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
2495 .count();
2496 let entry = all
2497 .iter()
2498 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
2499 .count();
2500 if markdown + message + entry == 0 {
2501 return;
2502 }
2503 eprintln!(
2504 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
2505 markdown, message, entry
2506 );
2507}
2508
2509#[derive(Debug, thiserror::Error)]
2511pub enum BuildError {
2512 #[error("Experimental native session policy: {0}")]
2513 NativeStorePolicy(String),
2514 #[error("Could not create the session directory: {0}")]
2515 SessionDir(String),
2516 #[error("Could not open the saved session: {0}")]
2517 SessionOpen(String),
2518 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
2519 SessionNotFound { requested: String, dir: String },
2520 #[error("Could not build the harness: {0}")]
2521 HarnessCreate(String),
2522}
2523
2524fn build_models_with_extensions(
2527 resolved: &ResolvedModel,
2528 extension_session: &ExtensionSession,
2529 runtime: tokio::runtime::Handle,
2530) -> Vec<Arc<dyn Provider>> {
2531 build_models_with_extensions_for_reload(&resolved.providers, extension_session, runtime)
2532}
2533
2534pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
2537 if let Some(override_value) = args.trust_override {
2538 return override_value;
2539 }
2540 crate::config::project_trust_decision(cwd)
2541 .ok()
2542 .flatten()
2543 .unwrap_or(true)
2544}
2545
2546pub(crate) fn load_extensions(
2554 args: &Args,
2555 cwd: &Path,
2556 project_trusted: bool,
2557 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
2558) -> ExtensionSession {
2559 if args.no_extensions {
2560 return ExtensionSession::none();
2561 }
2562 let mut dirs = if args.dev_local_only {
2563 Vec::new()
2564 } else if args.project_only {
2565 project_extension_dirs(cwd)
2566 } else if project_trusted {
2567 extension_dirs(cwd)
2568 } else {
2569 global_extension_dirs()
2570 };
2571 dirs.extend(args.extensions_dir.iter().cloned());
2572 let leases = match crate::resource_management::host_leases(cwd) {
2573 Ok(leases) => leases,
2574 Err(error) => {
2575 eprintln!("warning: refusing extension loading: {error}");
2576 return ExtensionSession::none();
2577 }
2578 };
2579 load_extensions_from_dirs(args, &dirs, action_bridge, Some(leases))
2580}
2581
2582fn load_extensions_from_dirs(
2583 args: &Args,
2584 dirs: &[PathBuf],
2585 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
2586 host_guard: Option<Arc<dyn Send + Sync>>,
2587) -> ExtensionSession {
2588 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
2589 rpi_extensions::load_session_mixed_guarded(
2596 dirs,
2597 &args.extension,
2598 diagnostics,
2599 action_bridge,
2600 host_guard,
2601 )
2602}
2603
2604fn merge_extension_tools(
2610 tools: &mut Vec<HarnessTool>,
2611 session: &ExtensionSession,
2612 args: &Args,
2613 tool_context: &rpi_extensions::ToolCallContext,
2614) {
2615 let Some(snapshot) = session.snapshot() else {
2616 return;
2617 };
2618 for et in snapshot.tools() {
2619 let name = &et.tool.name;
2620 if !tool_name_allowed(name, args) {
2621 continue;
2622 }
2623 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
2624 .with_context(tool_context.clone());
2625 let harness_tool = HarnessTool::new(Arc::new(adapter));
2626 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
2627 Some(slot) => *slot = harness_tool,
2628 None => tools.push(harness_tool),
2629 }
2630 }
2631}
2632
2633pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
2640 use rpi_tools::tools::bash::BashToolOptions;
2641 let default = std::env::var("RPI_BASH_TIMEOUT")
2642 .ok()
2643 .and_then(|v| v.parse::<f64>().ok())
2644 .unwrap_or(rpi_tools::tools::bash::DEFAULT_BASH_TIMEOUT_SECONDS);
2645 let settings = crate::settings::load_settings().unwrap_or_default();
2651 BashToolOptions {
2652 command_prefix: settings.shell_command_prefix.clone(),
2653 default_timeout: Some(default),
2654 prepare: None,
2655 }
2656}
2657
2658fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
2659 if args.no_tools {
2660 return Vec::new();
2661 }
2662 let mut all: Vec<(&'static str, HarnessTool)> = vec![
2671 (
2672 "read",
2673 HarnessTool::new(create_read_tool(
2674 ctx,
2675 Some(rpi_tools::tools::read::ReadToolOptions {
2676 image_processor: Some(Arc::new(
2677 rpi_tools::image_processing::InlineImageProcessor,
2678 )),
2679 ..Default::default()
2680 }),
2681 ))
2682 .with_replay(ToolReplay::Safe),
2683 ),
2684 (
2685 "bash",
2686 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
2687 ),
2688 ("edit", HarnessTool::new(create_edit_tool(ctx))),
2689 ("write", HarnessTool::new(create_write_tool(ctx))),
2690 (
2691 "docs",
2692 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
2693 ),
2694 ];
2695
2696 #[cfg(target_os = "windows")]
2698 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
2699
2700 if args.tools.is_none() && args.resource_config.is_none() && args.resource_transition.is_none()
2702 {
2703 if let Ok(settings) = crate::settings::load_settings() {
2704 if let Some(default_tools) = &settings.default_tools {
2705 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
2706 }
2707 }
2708 }
2709
2710 if args.no_builtin_tools {
2714 all.clear();
2715 }
2716
2717 if let Some(allow) = &args.tools {
2719 all.retain(|(name, _)| allow.iter().any(|a| a == name));
2720 }
2721 if let Some(deny) = &args.exclude_tools {
2723 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
2724 }
2725
2726 all.into_iter().map(|(_, t)| t).collect()
2727}
2728
2729fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
2733 filter_active_tool_names(
2734 tools.iter().map(|tool| tool.tool.schema().name.clone()),
2735 args,
2736 )
2737}
2738
2739pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
2742 if args.no_tools {
2743 return false;
2744 }
2745 if args
2746 .tools
2747 .as_ref()
2748 .is_some_and(|allow| !allow.iter().any(|value| value == name))
2749 {
2750 return false;
2751 }
2752 if args
2753 .exclude_tools
2754 .as_ref()
2755 .is_some_and(|deny| deny.iter().any(|value| value == name))
2756 {
2757 return false;
2758 }
2759 true
2760}
2761
2762pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
2763where
2764 I: IntoIterator<Item = String>,
2765{
2766 names
2767 .into_iter()
2768 .filter(|name| tool_name_allowed(name, args))
2769 .collect()
2770}
2771
2772async fn build_session(
2774 selection: &SessionSelection,
2775 cwd: &str,
2776 selected: Option<&crate::resource_selection::ResolvedSelection>,
2777) -> Result<Session, BuildError> {
2778 match selection {
2779 SessionSelection::Ephemeral => Ok(ephemeral_session()),
2780 SessionSelection::ExperimentalNative { dir } => Ok(Session::new(
2781 backend::fresh_experimental_native(dir).await?,
2782 None,
2783 )),
2784 SessionSelection::New { dir, .. } => {
2785 std::fs::create_dir_all(dir)
2788 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2789 let session = create_jsonl_session(dir, cwd)
2790 .await
2791 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2792 Ok(session)
2793 }
2794 SessionSelection::Latest { .. }
2795 | SessionSelection::ById { .. }
2796 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2797 SessionSelection::Fork { source, dir } => {
2798 fork_session_at_launch(source, dir, cwd, selected).await
2799 }
2800 }
2801}
2802
2803async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2809 let Some(meta) = resolve_restore_target(selection, cwd).await? else {
2813 return match selection {
2814 SessionSelection::ByExactId { id, dir } => {
2817 std::fs::create_dir_all(dir)
2818 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2819 create_jsonl_session_with_id(dir, cwd, Some(id.clone()))
2820 .await
2821 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2822 }
2823 SessionSelection::Latest { dir } => Err(BuildError::SessionNotFound {
2824 requested: "the most recent session".to_string(),
2825 dir: dir.display().to_string(),
2826 }),
2827 SessionSelection::ById { id, dir } => Err(BuildError::SessionNotFound {
2828 requested: format!("session {id}"),
2829 dir: dir.display().to_string(),
2830 }),
2831 _ => unreachable!("resolve_restore_target only returns None for restore selections"),
2832 };
2833 };
2834 let sessions_root = selection_dir(selection);
2835 let writer_restore = StartupSpan::start(Phase::WriterRestore);
2836 let result = open_session(&meta, cwd, &sessions_root)
2837 .await
2838 .map(|restored| restored.session);
2839 if result.is_ok() {
2840 writer_restore.finish();
2841 }
2842 result
2843}
2844
2845fn selection_dir(selection: &SessionSelection) -> PathBuf {
2847 match selection {
2848 SessionSelection::Ephemeral => PathBuf::new(),
2849 SessionSelection::New { dir, .. }
2850 | SessionSelection::ExperimentalNative { dir }
2851 | SessionSelection::Latest { dir }
2852 | SessionSelection::ById { dir, .. }
2853 | SessionSelection::ByExactId { dir, .. }
2854 | SessionSelection::Fork { dir, .. } => dir.clone(),
2855 }
2856}
2857
2858async fn resolve_restore_target(
2865 selection: &SessionSelection,
2866 cwd: &str,
2867) -> Result<Option<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2868 match selection {
2869 SessionSelection::Latest { dir } => {
2870 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2871 Ok(first_resumable_session(&metas).cloned())
2872 }
2873 SessionSelection::ById { id, dir } => find_session_metadata(id, cwd, dir).await,
2874 SessionSelection::ByExactId { id, dir } => {
2875 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2876 Ok(metas.into_iter().find(|m| m.id == *id))
2877 }
2878 SessionSelection::Fork { .. }
2879 | SessionSelection::ExperimentalNative { .. }
2880 | SessionSelection::New { .. }
2881 | SessionSelection::Ephemeral => Ok(None),
2882 }
2883}
2884
2885async fn find_session_metadata(
2889 id: &str,
2890 cwd: &str,
2891 dir: &Path,
2892) -> Result<Option<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2893 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2894 Ok(metas
2895 .into_iter()
2896 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id)))
2897}
2898
2899pub(crate) struct RestoredSessionCwd {
2901 pub id: String,
2907 pub recorded: PathBuf,
2909 pub adopted: Option<PathBuf>,
2912}
2913
2914pub(crate) async fn peek_restored_session_cwd(
2922 selection: &SessionSelection,
2923 launch_cwd: &Path,
2924) -> Option<RestoredSessionCwd> {
2925 let cwd_str = launch_cwd.to_string_lossy().into_owned();
2926 let meta = resolve_restore_target(selection, &cwd_str).await.ok()??;
2927 let recorded = PathBuf::from(&meta.cwd);
2928 if recorded.as_os_str().is_empty() {
2929 return None;
2930 }
2931 Some(RestoredSessionCwd {
2932 id: meta.id,
2933 adopted: crate::session_cwd::resolve_session_cwd(&recorded),
2934 recorded,
2935 })
2936}
2937
2938pub(crate) fn pin_restored_selection(args: &mut Args, restored: &RestoredSessionCwd) {
2948 args.continue_session = false;
2949 args.resume = false;
2950 args.session = Some(restored.id.clone());
2951}
2952
2953async fn fork_session_at_launch(
2956 source: &str,
2957 dir: &Path,
2958 cwd: &str,
2959 selected: Option<&crate::resource_selection::ResolvedSelection>,
2960) -> Result<Session, BuildError> {
2961 use rpi_harness::session::jsonl::{
2962 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2963 };
2964 use rpi_harness::session::types::{ForkOptions, SessionStorage};
2965 use rpi_tools::FileSystem;
2966
2967 let dir = dir.to_path_buf();
2968 std::fs::create_dir_all(&dir)
2969 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2970 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2971 let fs: Arc<dyn FileSystem> = env.clone();
2972 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2973 fs: fs.clone(),
2974 sessions_root: dir.to_string_lossy().into_owned(),
2975 clock: Arc::new(SystemClock),
2976 ids: Arc::new(DefaultIdGenerator::new()),
2977 });
2978 let source_meta = match session_metadata_at(Path::new(source)) {
2982 Some(meta) => meta,
2983 None => {
2984 let metas = repo
2985 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2986 .await
2987 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2988 metas
2989 .into_iter()
2990 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2991 .ok_or_else(|| BuildError::SessionNotFound {
2992 requested: format!("--fork {source}"),
2993 dir: dir.display().to_string(),
2994 })?
2995 }
2996 };
2997 let fork_storage = repo
2998 .fork_typed_with_value_initializer(
2999 &source_meta,
3000 &JsonlSessionCreateOptions {
3001 id: None,
3002 parent_session_id: Some(source_meta.id.clone()),
3003 cwd: cwd.to_string(),
3004 metadata: None,
3005 },
3006 &ForkOptions::default(),
3007 |values| crate::resource_selection::fork_initial_values_for_selection(values, selected),
3008 )
3009 .await
3010 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
3011 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
3012 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
3013 Ok(Session::new(storage_arc, None))
3014}
3015
3016fn session_metadata_at(path: &Path) -> Option<rpi_harness::session::jsonl::JsonlSessionMetadata> {
3019 use rpi_harness::session::jsonl::codec::{metadata_from_header, parse_header};
3020 use std::io::BufRead;
3021
3022 let file = std::fs::File::open(path).ok()?;
3023 let modified_at = file
3024 .metadata()
3025 .ok()?
3026 .modified()
3027 .ok()?
3028 .duration_since(std::time::UNIX_EPOCH)
3029 .ok()?
3030 .as_millis() as i64;
3031 let mut first_line = String::new();
3032 std::io::BufReader::new(file)
3033 .read_line(&mut first_line)
3034 .ok()?;
3035 let header = parse_header(first_line.trim_end()).ok()?;
3036 let path = std::fs::canonicalize(path).ok()?;
3037 Some(metadata_from_header(
3038 &header,
3039 &path.to_string_lossy(),
3040 modified_at,
3041 ))
3042}
3043
3044pub enum OpenError {
3047 NotFound { requested: String },
3049 Other(String),
3051}
3052
3053impl std::fmt::Display for OpenError {
3054 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3055 match self {
3056 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
3057 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
3062 }
3063 }
3064}
3065
3066pub async fn list_session_metadata(
3069 cwd: &str,
3070) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
3071 let dir = default_session_dir(Path::new(cwd));
3072 list_session_metadata_in_dir(cwd, &dir).await
3073}
3074
3075pub(crate) async fn list_session_metadata_in_dir(
3076 cwd: &str,
3077 dir: &Path,
3078) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
3079 use rpi_harness::session::jsonl::{
3080 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
3081 };
3082 use rpi_tools::FileSystem;
3083
3084 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3085 let fs: Arc<dyn FileSystem> = env.clone();
3086 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3087 fs: fs.clone(),
3088 sessions_root: dir.to_string_lossy().into_owned(),
3089 clock: Arc::new(SystemClock),
3090 ids: Arc::new(DefaultIdGenerator::new()),
3091 });
3092 let metadata_lookup = StartupSpan::start(Phase::MetadataLookup);
3093 let result = repo
3094 .list_typed(&JsonlSessionListOptions::default())
3095 .await
3096 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")));
3097 if result.is_ok() {
3098 metadata_lookup.finish();
3099 }
3100 result
3101}
3102
3103#[derive(Debug, Clone, Default, PartialEq, Eq)]
3119pub struct SessionSummary {
3120 pub name: Option<String>,
3122 pub preview: Option<String>,
3124 pub empty: bool,
3126 pub bytes: u64,
3128}
3129
3130const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
3132const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
3134const SUMMARY_PREVIEW_CHARS: usize = 80;
3136
3137pub fn summarize_session_file(path: &Path) -> SessionSummary {
3141 use std::io::{Read, Seek, SeekFrom};
3142
3143 let Ok(mut file) = std::fs::File::open(path) else {
3144 return SessionSummary::default();
3145 };
3146 let Ok(size) = file.metadata().map(|m| m.len()) else {
3147 return SessionSummary::default();
3148 };
3149 if size == 0 {
3150 return SessionSummary {
3151 name: None,
3152 preview: None,
3153 empty: true,
3154 bytes: 0,
3155 };
3156 }
3157
3158 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
3161 let head_len = file.read(&mut head).unwrap_or(0);
3162 head.truncate(head_len);
3163 let head = String::from_utf8_lossy(&head);
3164 let mut lines: Vec<&str> = head.lines().collect();
3165 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
3166 let empty = (size as usize) <= header_len + 1;
3168 if (head_len as u64) < size && !lines.is_empty() {
3171 lines.pop();
3172 }
3173
3174 let mut summary = SessionSummary {
3175 name: None,
3176 preview: None,
3177 empty,
3178 bytes: size,
3179 };
3180 for line in lines {
3181 apply_summary_line(line, &mut summary);
3182 }
3183
3184 if size > SUMMARY_HEAD_BYTES
3188 && file
3189 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
3190 .is_ok()
3191 {
3192 let mut tail = Vec::new();
3193 if file.read_to_end(&mut tail).is_ok() {
3194 let tail = String::from_utf8_lossy(&tail);
3195 let mut tail_lines = tail.lines();
3196 tail_lines.next();
3198 for line in tail_lines {
3199 if let Some(name) = name_from_line(line) {
3200 summary.name = name;
3201 }
3202 }
3203 }
3204 }
3205 summary
3206}
3207
3208fn name_from_line(line: &str) -> Option<Option<String>> {
3211 let value: serde_json::Value = serde_json::from_str(line).ok()?;
3212 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
3213 return None;
3214 }
3215 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
3216 return None;
3217 }
3218 Some(match value.get("name") {
3219 Some(serde_json::Value::String(name)) => Some(name.clone()),
3220 _ => None,
3221 })
3222}
3223
3224fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
3227 if let Some(name) = name_from_line(line) {
3228 summary.name = name;
3229 return;
3230 }
3231 if summary.preview.is_some() {
3232 return;
3233 }
3234 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
3235 return;
3236 };
3237 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
3238 return;
3239 }
3240 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
3241 return;
3242 }
3243 let Some(message) = value.get("message") else {
3244 return;
3245 };
3246 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
3247 return;
3248 }
3249 let text = match message.get("content") {
3250 Some(serde_json::Value::String(text)) => Some(text.as_str()),
3251 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
3252 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
3253 .then(|| block.get("text").and_then(|v| v.as_str()))
3254 .flatten()
3255 }),
3256 _ => None,
3257 };
3258 let Some(text) = text else { return };
3259 let collapsed = text
3260 .lines()
3261 .map(str::trim)
3262 .filter(|line| !line.is_empty())
3263 .collect::<Vec<_>>()
3264 .join(" ");
3265 if collapsed.is_empty() {
3266 return;
3267 }
3268 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
3269}
3270
3271pub fn session_display_label(summary: &SessionSummary) -> String {
3275 if let Some(name) = summary.name.as_deref().map(str::trim) {
3276 if !name.is_empty() {
3277 return name.to_string();
3278 }
3279 }
3280 match summary.preview.as_deref().map(str::trim) {
3281 Some(preview) if !preview.is_empty() => preview.to_string(),
3282 _ => "(no messages)".to_string(),
3283 }
3284}
3285
3286pub fn format_session_bytes(bytes: u64) -> String {
3289 if bytes >= 1_048_576 {
3290 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
3291 } else if bytes >= 1024 {
3292 format!("{}KB", bytes / 1024)
3293 } else {
3294 format!("{bytes}B")
3295 }
3296}
3297
3298pub fn short_session_id(id: &str) -> &str {
3301 &id[..id.len().min(8)]
3302}
3303
3304pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
3313 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
3314 let mut visit = |path: PathBuf| {
3315 if path.extension().and_then(|e| e.to_str()) != Some("jsonl")
3316 || path.to_string_lossy().ends_with(".progress.jsonl")
3317 {
3318 return;
3319 }
3320 let modified = std::fs::metadata(&path)
3321 .and_then(|m| m.modified())
3322 .unwrap_or(std::time::UNIX_EPOCH);
3323 files.push((path, modified));
3324 };
3325 if let Ok(entries) = std::fs::read_dir(dir) {
3326 for entry in entries.flatten() {
3327 let path = entry.path();
3328 if path.is_dir() {
3329 if let Ok(inner) = std::fs::read_dir(&path) {
3330 for child in inner.flatten() {
3331 visit(child.path());
3332 }
3333 }
3334 } else {
3335 visit(path);
3336 }
3337 }
3338 }
3339 files.sort_by(|a, b| b.1.cmp(&a.1));
3340 files.into_iter().map(|(path, _)| path).collect()
3341}
3342
3343fn first_resumable_session(
3348 metas: &[rpi_harness::session::jsonl::JsonlSessionMetadata],
3349) -> Option<&rpi_harness::session::jsonl::JsonlSessionMetadata> {
3350 metas
3351 .iter()
3352 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
3353 .or_else(|| metas.first())
3354}
3355
3356pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
3360 let dir = default_session_dir(Path::new(cwd));
3361 open_session_by_id_in_dir(id, cwd, &dir)
3362 .await
3363 .map(|restored| restored.session)
3364}
3365
3366pub(crate) async fn open_session_by_id_in_dir(
3367 id: &str,
3368 cwd: &str,
3369 dir: &Path,
3370) -> Result<RestoredSession, OpenError> {
3371 let meta = find_session_metadata(id, cwd, dir)
3372 .await
3373 .map_err(|e| OpenError::Other(e.to_string()))?;
3374 let Some(meta) = meta else {
3375 return Err(OpenError::NotFound {
3376 requested: format!("session {id}"),
3377 });
3378 };
3379 open_session(&meta, cwd, dir).await.map_err(|e| {
3380 OpenError::Other(match e {
3381 BuildError::SessionOpen(reason) => reason,
3385 other => other.to_string(),
3386 })
3387 })
3388}
3389
3390fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
3415 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
3416 tool_context.set_session_id(id);
3417}
3418
3419pub(crate) async fn set_active_session(
3430 harness: &AgentHarness,
3431 session: Session,
3432 tool_context: Option<&rpi_extensions::ToolCallContext>,
3433) -> Result<(), String> {
3434 let metadata = session.get_metadata().await.ok();
3435 harness
3436 .set_session(session)
3437 .await
3438 .map_err(|e| e.to_string())?;
3439 if let Some(metadata) = metadata {
3440 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
3441 if let Some(context) = tool_context {
3442 context.set_session_id(metadata.id);
3443 }
3444 }
3445 Ok(())
3446}
3447
3448pub(crate) async fn fork_session_storage(
3455 harness: &AgentHarness,
3456 cwd: &str,
3457 dir: &Path,
3458) -> Result<Session, String> {
3459 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3460 use rpi_tools::FileSystem;
3461
3462 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3463 let fs: Arc<dyn FileSystem> = env.clone();
3464 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3465 fs,
3466 sessions_root: dir.to_string_lossy().into_owned(),
3467 clock: Arc::new(SystemClock),
3468 ids: Arc::new(DefaultIdGenerator::new()),
3469 });
3470 let id = harness.session().storage().metadata().id.clone();
3473 let metas = list_session_metadata_in_dir(cwd, dir)
3474 .await
3475 .map_err(|e| e.to_string())?;
3476 let Some(source) = metas.iter().find(|m| m.id == id) else {
3477 return Err(format!("current session {id} not found on disk"));
3478 };
3479 let fork_storage = repo
3480 .fork_typed_with_value_initializer(
3481 source,
3482 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
3483 id: None,
3484 parent_session_id: Some(source.id.clone()),
3485 cwd: cwd.to_string(),
3486 metadata: None,
3487 },
3488 &rpi_harness::session::types::ForkOptions::default(),
3489 crate::resource_selection::fork_initial_values,
3490 )
3491 .await
3492 .map_err(|e| e.to_string())?;
3493 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
3494 Ok(Session::new(Arc::new(fork_storage), None))
3495}
3496
3497pub(crate) async fn import_session_file(
3501 source: &Path,
3502 cwd: &str,
3503 dir: &Path,
3504) -> Result<Session, String> {
3505 use rpi_harness::session::jsonl::{
3506 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions, JsonlSessionStorage,
3507 };
3508 use rpi_harness::session::types::SessionStorage;
3509 use rpi_tools::FileSystem;
3510
3511 let dir = if dir.is_absolute() {
3512 dir.to_path_buf()
3513 } else {
3514 Path::new(cwd).join(dir)
3515 };
3516 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
3517 let source_progress = PathBuf::from(format!("{}.progress.jsonl", source.display()));
3521 let source_lock_path = PathBuf::from(format!("{}.lock", source.display()));
3522 let canonical_lock = std::fs::canonicalize(&source_lock_path).ok();
3523 let owned_here = SESSION_LOCK_PATHS.get().is_some_and(|paths| {
3524 paths.lock().unwrap().iter().any(|path| {
3525 path == &source_lock_path
3526 || canonical_lock.as_ref().is_some_and(|canonical| {
3527 std::fs::canonicalize(path).ok().as_ref() == Some(canonical)
3528 })
3529 })
3530 });
3531 let _source_lock = if !owned_here && source_progress.try_exists().map_err(|e| e.to_string())? {
3532 let lock = std::fs::OpenOptions::new()
3533 .create(true)
3534 .write(true)
3535 .open(format!("{}.lock", source.display()))
3536 .map_err(|e| format!("lock import source: {e}"))?;
3537 fs2::FileExt::try_lock_exclusive(&lock).map_err(|_| {
3538 "close the source session before importing its recovery pair".to_owned()
3539 })?;
3540 Some(lock)
3541 } else {
3542 None
3543 };
3544 let staging = tempfile::tempdir_in(&dir).map_err(|e| e.to_string())?;
3545 let staged = staging.path().join("history.stage");
3546 let staged_progress = PathBuf::from(format!("{}.progress.jsonl", staged.display()));
3547 let mut attempts = 0;
3548 let has_progress = loop {
3549 std::fs::copy(source, &staged).map_err(|e| format!("copy import: {e}"))?;
3550 let copied_history = std::fs::read(&staged).map_err(|e| e.to_string())?;
3551 let has_progress = match std::fs::copy(&source_progress, &staged_progress) {
3552 Ok(_) => true,
3553 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3554 let _ = std::fs::remove_file(&staged_progress);
3555 false
3556 }
3557 Err(error) => return Err(format!("copy progress import: {error}")),
3558 };
3559 if copied_history == std::fs::read(source).map_err(|e| e.to_string())? {
3560 break has_progress;
3561 }
3562 attempts += 1;
3563 if attempts >= 4 {
3564 return Err(
3565 "source history changed during import; retry when the session is idle".into(),
3566 );
3567 }
3568 tokio::task::yield_now().await;
3569 };
3570 let fs: Arc<dyn FileSystem> = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3571 let validated = JsonlSessionStorage::load(
3572 fs.clone(),
3573 &staged.to_string_lossy(),
3574 Arc::new(SystemClock),
3575 Arc::new(DefaultIdGenerator::new()),
3576 )
3577 .await
3578 .map_err(|e| format!("invalid session import: {e}"))?;
3579 validated
3580 .read_progress("")
3581 .await
3582 .map_err(|e| format!("invalid recovery import: {e}"))?;
3583 let mut metadata = validated.jsonl_metadata().clone();
3584 drop(validated);
3585 let text = std::fs::read_to_string(&staged).map_err(|e| e.to_string())?;
3586 let (first, rest) = text
3587 .split_once('\n')
3588 .ok_or_else(|| "import has no complete header".to_owned())?;
3589 let mut header =
3590 rpi_harness::session::jsonl::codec::parse_header(first).map_err(|e| e.to_string())?;
3591 if has_progress && header.progress_format.is_none() {
3592 return Err("history-only artifact has an undeclared recovery companion".into());
3593 }
3594 header.progress_format = Some("assistant-progress-v1".into());
3597 std::fs::write(
3598 &staged,
3599 format!(
3600 "{}{}",
3601 rpi_harness::session::jsonl::codec::encode_header(&header),
3602 rest
3603 ),
3604 )
3605 .map_err(|e| e.to_string())?;
3606 if list_session_metadata_in_dir(cwd, &dir)
3607 .await
3608 .map_err(|e| e.to_string())?
3609 .iter()
3610 .any(|saved| saved.id == metadata.id)
3611 {
3612 return Err(format!(
3613 "session {} already exists in {}",
3614 metadata.id,
3615 dir.display()
3616 ));
3617 }
3618 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3619 fs,
3620 sessions_root: dir.to_string_lossy().into_owned(),
3621 clock: Arc::new(SystemClock),
3622 ids: Arc::new(DefaultIdGenerator::new()),
3623 });
3624 let destination = repo
3626 .create_typed(&JsonlSessionCreateOptions {
3627 id: Some(metadata.id.clone()),
3628 parent_session_id: metadata.parent_session_id.clone(),
3629 cwd: cwd.to_string(),
3630 metadata: metadata.metadata.clone(),
3631 })
3632 .await
3633 .map_err(|e| e.to_string())?;
3634 let path = destination.jsonl_metadata().path.clone();
3635 drop(destination);
3636 if let Err(error) = claim_session_lock(&path) {
3637 return Err(error);
3639 }
3640 let destination_progress = format!("{path}.progress.jsonl");
3641 let publish = (|| -> std::io::Result<()> {
3642 if has_progress {
3643 std::fs::rename(&staged_progress, &destination_progress)?;
3644 }
3645 std::fs::rename(&staged, &path)
3646 })();
3647 if let Err(error) = publish {
3648 let _ = std::fs::remove_file(&path);
3649 let _ = std::fs::remove_file(&destination_progress);
3650 return Err(format!("publish import: {error}"));
3651 }
3652 metadata.path = path;
3653 let storage = repo
3654 .open_by_jsonl_metadata(&metadata)
3655 .await
3656 .map_err(|e| e.to_string())?;
3657 Ok(Session::new(Arc::new(storage), None))
3658}
3659
3660static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
3662static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
3665
3666fn claim_session_lock(session_path: &str) -> Result<(), String> {
3675 claim_session_lock_inner(session_path, true)
3676}
3677fn claim_session_lock_inner(session_path: &str, allow_reentry: bool) -> Result<(), String> {
3678 let canonical =
3679 std::fs::canonicalize(session_path).unwrap_or_else(|_| PathBuf::from(session_path));
3680 let lock_path = PathBuf::from(format!("{}.lock", canonical.display()));
3681 let mut paths = SESSION_LOCK_PATHS
3682 .get_or_init(|| Mutex::new(HashSet::new()))
3683 .lock()
3684 .unwrap();
3685 if paths.contains(&lock_path) {
3686 return if allow_reentry {
3687 Ok(())
3688 } else {
3689 Err("transition session already has a writer in this process".into())
3690 };
3691 }
3692 let file = std::fs::OpenOptions::new()
3693 .create(true)
3694 .write(true)
3695 .open(&lock_path)
3696 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
3697 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
3698 return Err(format!(
3699 "session {session_path} is already open in another rpi process"
3700 ));
3701 }
3702 SESSION_LOCKS
3703 .get_or_init(|| Mutex::new(Vec::new()))
3704 .lock()
3705 .unwrap()
3706 .push(file);
3707 paths.insert(lock_path);
3708 Ok(())
3709}
3710
3711pub(crate) struct RestoredSession {
3720 pub session: Session,
3721 pub recorded_cwd: Option<PathBuf>,
3724}
3725
3726async fn open_session(
3733 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
3734 cwd: &str,
3735 sessions_root: &Path,
3736) -> Result<RestoredSession, BuildError> {
3737 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3738 use rpi_harness::session::types::SessionStorage;
3739 use rpi_tools::FileSystem;
3740
3741 let dir = sessions_root;
3742 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3743 let fs: Arc<dyn FileSystem> = env.clone();
3744 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3745 fs: fs.clone(),
3746 sessions_root: dir.to_string_lossy().into_owned(),
3747 clock: Arc::new(SystemClock),
3748 ids: Arc::new(DefaultIdGenerator::new()),
3749 });
3750 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
3751 let storage = repo
3752 .open_by_jsonl_metadata(meta)
3753 .await
3754 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
3755 let storage = storage
3758 .enable_progress_under_exclusive_lock()
3759 .await
3760 .map_err(|e| BuildError::SessionOpen(format!("upgrade {}: {e}", meta.path)))?;
3761 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
3762 Ok(RestoredSession {
3763 session: Session::new(storage_arc, None),
3764 recorded_cwd: crate::session_cwd::resolve_session_cwd(Path::new(&meta.cwd)),
3765 })
3766}
3767
3768pub(crate) fn ephemeral_session() -> Session {
3772 let storage = Arc::new(InMemorySessionStorage::new(
3773 SessionMetadata {
3774 id: "ephemeral".into(),
3775 created_at: 0,
3776 parent_session_id: None,
3777 },
3778 Arc::new(SystemClock),
3779 Arc::new(DefaultIdGenerator::new()),
3780 ));
3781 Session::new(storage, None).with_volatile_progress()
3782}
3783
3784pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
3795 create_jsonl_session_with_id(dir, cwd, None).await
3796}
3797
3798pub(crate) async fn create_jsonl_session_with_id(
3802 dir: &Path,
3803 cwd: &str,
3804 id: Option<String>,
3805) -> Result<Session, String> {
3806 use rpi_harness::session::jsonl::{
3807 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
3808 };
3809 use rpi_tools::FileSystem;
3810
3811 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3814 let fs: Arc<dyn FileSystem> = env.clone();
3815
3816 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3817 fs: fs.clone(),
3818 sessions_root: dir.to_string_lossy().into_owned(),
3819 clock: Arc::new(SystemClock),
3820 ids: Arc::new(DefaultIdGenerator::new()),
3821 });
3822
3823 let opts = JsonlSessionCreateOptions {
3824 id, parent_session_id: None,
3826 cwd: cwd.to_string(),
3827 metadata: None,
3828 };
3829 let storage = repo
3830 .create_typed(&opts)
3831 .await
3832 .map_err(|e| format!("create session: {e}"))?;
3833 claim_session_lock(&storage.jsonl_metadata().path)
3834 .map_err(|e| format!("create session: {e}"))?;
3835 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
3837 Ok(Session::new(storage_arc, None))
3838}
3839
3840fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
3843 let path = Path::new(target);
3844 if path.is_file() {
3845 read_prompt_source(path, sources)
3846 } else {
3847 None
3848 }
3849}
3850
3851fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
3854 let text = std::fs::read_to_string(path).ok()?;
3855 let absolute = if path.is_absolute() {
3856 path.to_path_buf()
3857 } else {
3858 std::env::current_dir()
3859 .map(|cwd| cwd.join(path))
3860 .unwrap_or_else(|_| path.to_path_buf())
3861 };
3862 let source = absolute.to_string_lossy().into_owned();
3863 if !sources.contains(&source) {
3864 sources.push(source);
3865 }
3866 Some(text)
3867}
3868
3869#[cfg(test)]
3870mod goal_outbox_smoke_tests;
3871#[cfg(all(test, feature = "experimental-native-store"))]
3872mod native_roundtrip_tests;
3873#[cfg(all(test, feature = "experimental-native-store"))]
3874mod native_store_tests;
3875#[cfg(test)]
3876mod runtime_session_tests;
3877
3878#[cfg(test)]
3879mod tests {
3880 use super::*;
3881
3882 #[test]
3883 fn settlement_bridge_follows_reload_and_releases_its_session_with_the_bus() {
3884 use rpi_harness::events::{HarnessEvent, HarnessEventBus, RunEndEvent, RunEndOutcome};
3885 use std::sync::atomic::{AtomicUsize, Ordering};
3886 static OLD: AtomicUsize = AtomicUsize::new(0);
3887 static NEW: AtomicUsize = AtomicUsize::new(0);
3888 extern "C" fn old(_: rpi_plugin_sdk::StablePluginEvent, _: *mut std::ffi::c_void) -> i32 {
3889 OLD.fetch_add(1, Ordering::SeqCst);
3890 0
3891 }
3892 extern "C" fn new(_: rpi_plugin_sdk::StablePluginEvent, _: *mut std::ffi::c_void) -> i32 {
3893 NEW.fetch_add(1, Ordering::SeqCst);
3894 0
3895 }
3896 let make = |handler: rpi_plugin_sdk::EventHandlerFn| {
3897 let mut registry = rpi_extensions::ExtensionRegistry::new();
3898 registry.register_event_handler(
3899 "settlement".into(),
3900 rpi_plugin_sdk::EventTag::AgentSettled,
3901 handler,
3902 std::ptr::null_mut(),
3903 );
3904 ExtensionSession::from_parts(
3905 Arc::new(registry.snapshot()),
3906 rpi_extensions::PluginKeepalive::empty(),
3907 vec![],
3908 None,
3909 )
3910 };
3911 let sessions = Arc::new(Mutex::new(make(old)));
3912 let weak = Arc::downgrade(&sessions);
3913 let bus = HarnessEventBus::new();
3914 install_extension_settlement_bridge(&bus, sessions.clone());
3915 let ended = |outcome| {
3916 HarnessEvent::RunEnd(RunEndEvent {
3917 lane: "main".into(),
3918 run_id: "run".into(),
3919 outcome,
3920 leaf_id: "leaf".into(),
3921 })
3922 };
3923 bus.emit(&ended(RunEndOutcome::Completed));
3924 assert_eq!(OLD.load(Ordering::SeqCst), 1);
3925 *sessions.lock().unwrap() = make(new);
3926 for outcome in [RunEndOutcome::Aborted, RunEndOutcome::Failed] {
3927 bus.emit(&ended(outcome));
3928 }
3929 assert_eq!(OLD.load(Ordering::SeqCst), 1);
3930 assert_eq!(NEW.load(Ordering::SeqCst), 2);
3931 drop(sessions);
3932 assert!(weak.upgrade().is_some());
3933 drop(bus);
3934 assert!(
3935 weak.upgrade().is_none(),
3936 "settlement observer must not create an ownership cycle"
3937 );
3938 }
3939
3940 #[tokio::test]
3941 async fn unauthenticated_packages_are_not_reintroduced_as_legacy_adapters() {
3942 extern "C" fn request(
3943 _: rpi_plugin_sdk::StbStringRef,
3944 _: *mut rpi_plugin_sdk::StbString,
3945 _: *mut std::ffi::c_void,
3946 ) -> i32 {
3947 1
3948 }
3949 let mut registry = rpi_extensions::ExtensionRegistry::new();
3950 for id in ["package", "legacy"] {
3951 registry.register_provider(rpi_extensions::RegisteredProvider {
3952 provider_id: id.into(),
3953 base_url: "http://localhost".into(),
3954 api_style: "openai-completions".into(),
3955 request_fn: request,
3956 stream_fn: None,
3957 plugin_free_string: rpi_plugin_sdk::callbacks::free_string,
3958 user_data: std::ptr::null_mut(),
3959 });
3960 }
3961 assert!(registry.apply_declaration_json(&serde_json::json!({"providers":[{
3962 "id":"package", "requiresAuth":true,
3963 "models":[rpi_ai::Model::new("model", "Model", rpi_ai::Api::OpenaiCompletions, "package", "http://localhost")]
3964 }]}).to_string()));
3965 let session = ExtensionSession::from_parts(
3966 Arc::new(registry.snapshot()),
3967 rpi_extensions::PluginKeepalive::empty(),
3968 vec![],
3969 None,
3970 );
3971 let providers = build_models_with_extensions_for_reload(
3972 &[],
3973 &session,
3974 tokio::runtime::Handle::current(),
3975 );
3976 assert_eq!(
3977 providers
3978 .iter()
3979 .map(|provider| provider.id())
3980 .collect::<Vec<_>>(),
3981 ["legacy"]
3982 );
3983 }
3984 use crate::args::Args;
3985
3986 #[test]
3987 fn loaded_context_paths_track_precedence_and_reload() {
3988 const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
3989 if std::env::var_os(CHILD).is_none() {
3990 let dir = tempfile::tempdir().unwrap();
3991 let output = std::process::Command::new(std::env::current_exe().unwrap())
3992 .args([
3993 "--exact",
3994 "session::tests::loaded_context_paths_track_precedence_and_reload",
3995 "--nocapture",
3996 ])
3997 .env(CHILD, "1")
3998 .env(crate::config::CONFIG_DIR_ENV, dir.path())
3999 .output()
4000 .unwrap();
4001 assert!(
4002 output.status.success(),
4003 "{}\n{}",
4004 String::from_utf8_lossy(&output.stdout),
4005 String::from_utf8_lossy(&output.stderr)
4006 );
4007 return;
4008 }
4009 tokio::runtime::Builder::new_current_thread()
4010 .enable_all()
4011 .build()
4012 .unwrap()
4013 .block_on(async {
4014 let agent = crate::config::agent_dir().unwrap();
4015 let cwd = agent.join("project");
4016 let project_config = cwd.join(".rpi");
4017 std::fs::create_dir_all(&project_config).unwrap();
4018 for (path, content) in [
4019 (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
4020 (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
4021 (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
4022 (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
4023 (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
4024 (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
4025 (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
4026 ] {
4027 std::fs::write(path, content).unwrap();
4028 }
4029 let resolved = crate::provider::resolve(
4030 Some("anthropic"),
4031 Some(crate::provider::DEFAULT_MODEL_ID),
4032 None,
4033 Some("test-key"),
4034 None,
4035 )
4036 .unwrap();
4037 let args = Args {
4038 no_session: true,
4039 no_extensions: true,
4040 no_skills: true,
4041 no_prompt_templates: true,
4042 trust_override: Some(true),
4043 ..Default::default()
4044 };
4045 let (harness, _events, mut context) =
4046 build(&resolved, &args, &cwd, true).await.unwrap();
4047 let resources = harness.get_resources().await.unwrap();
4048 let path = |p: PathBuf| p.to_string_lossy().into_owned();
4049 assert_eq!(
4050 &resources.context_files[..2],
4051 &[
4052 path(project_config.join("SYSTEM.md")),
4053 path(project_config.join("APPEND_SYSTEM.md")),
4054 ]
4055 );
4056 assert!(resources
4057 .context_files
4058 .contains(&path(agent.join("AGENTS.md"))));
4059 assert!(resources
4060 .context_files
4061 .contains(&path(cwd.join("AGENTS.md"))));
4062 assert!(!resources
4063 .context_files
4064 .contains(&path(cwd.join("CLAUDE.md"))));
4065 assert!(!resources
4066 .context_files
4067 .contains(&path(agent.join("SYSTEM.md"))));
4068 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
4069 assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
4070 assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
4071
4072 std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
4075 std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
4076 std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
4077 assert!(
4078 !reload_extension_resources(&harness, &context)
4079 .await
4080 .had_errors
4081 );
4082 let resources = harness.get_resources().await.unwrap();
4083 assert_eq!(
4084 &resources.context_files[..2],
4085 &[
4086 path(agent.join("SYSTEM.md")),
4087 path(agent.join("APPEND_SYSTEM.md")),
4088 ]
4089 );
4090 assert!(resources
4091 .context_files
4092 .contains(&path(cwd.join("CLAUDE.md"))));
4093 assert!(!resources
4094 .context_files
4095 .contains(&path(project_config.join("SYSTEM.md"))));
4096
4097 let explicit = agent.join("custom-append.md");
4100 std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
4101 context.args.system_prompt = Some("EXPLICIT_BASE".into());
4102 context.args.append_system_prompt =
4103 vec![path(explicit.clone()), "literal append".into()];
4104 context.args.no_context_files = true;
4105 assert!(
4106 !reload_extension_resources(&harness, &context)
4107 .await
4108 .had_errors
4109 );
4110 assert_eq!(
4111 harness.get_resources().await.unwrap().context_files,
4112 vec![path(explicit)]
4113 );
4114
4115 context.args.system_prompt = None;
4117 context.args.append_system_prompt.clear();
4118 context.args.no_context_files = false;
4119 context.args.trust_override = Some(false);
4120 assert!(
4121 !reload_extension_resources(&harness, &context)
4122 .await
4123 .had_errors
4124 );
4125 assert!(harness
4126 .get_resources()
4127 .await
4128 .unwrap()
4129 .context_files
4130 .is_empty());
4131 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
4132 assert!(
4133 !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
4134 );
4135 });
4136 }
4137
4138 #[test]
4139 fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
4140 let dir = tempfile::tempdir().unwrap();
4141 let file = dir.path().join("SYSTEM.md");
4142 let mut sources = Vec::new();
4143 assert!(read_prompt_source(&file, &mut sources).is_none());
4144 std::fs::write(&file, [0xff, 0xfe]).unwrap();
4145 assert!(read_prompt_source(&file, &mut sources).is_none());
4146 assert!(read_append_target("literal instructions", &mut sources).is_none());
4147 assert!(sources.is_empty());
4148 std::fs::write(&file, "loaded instructions").unwrap();
4149 assert_eq!(
4150 read_prompt_source(&file, &mut sources).as_deref(),
4151 Some("loaded instructions")
4152 );
4153 assert!(read_prompt_source(&file, &mut sources).is_some());
4154 assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
4155 }
4156
4157 extern "C" fn deny_test_tool_call(
4158 _event: rpi_plugin_sdk::StablePluginEvent,
4159 _data: *mut std::ffi::c_void,
4160 ) -> i32 {
4161 rpi_plugin_sdk::EVENT_HANDLER_ABORT
4162 }
4163
4164 async fn invoke_test_tool_hook(
4165 hook: &rpi_agent::BeforeToolCall,
4166 ) -> Option<rpi_agent::BeforeToolCallResult> {
4167 let message = rpi_ai::providers::faux::faux_assistant_message(
4168 "approval",
4169 rpi_ai::types::StopReason::ToolUse,
4170 );
4171 let rpi_ai::types::Content::ToolCall(call) =
4172 rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
4173 else {
4174 unreachable!()
4175 };
4176 hook(
4177 rpi_agent::BeforeToolCallContext {
4178 assistant_message: &message,
4179 tool_call: &call,
4180 args: &call.arguments,
4181 context: &rpi_agent::AgentContext::default(),
4182 },
4183 tokio_util::sync::CancellationToken::new(),
4184 )
4185 .await
4186 }
4187
4188 #[tokio::test]
4189 async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
4190 let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
4191 let hook =
4192 extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
4193 assert!(invoke_test_tool_hook(&hook).await.is_none());
4194 for name in ["first-gate", "reloaded-gate"] {
4195 let mut registry = rpi_extensions::ExtensionRegistry::new();
4196 registry.register_event_handler(
4197 name.into(),
4198 rpi_plugin_sdk::EventTag::ToolCall,
4199 deny_test_tool_call,
4200 std::ptr::null_mut(),
4201 );
4202 let fresh = ExtensionSession::from_parts(
4203 Arc::new(registry.snapshot()),
4204 rpi_extensions::PluginKeepalive::empty(),
4205 Vec::new(),
4206 None,
4207 );
4208 {
4209 let mut current = sessions.lock().unwrap();
4210 if let Some(snapshot) = current.snapshot() {
4211 snapshot.active_flag().store(false, Ordering::SeqCst);
4212 }
4213 *current = fresh;
4214 }
4215 let result = invoke_test_tool_hook(&hook).await.unwrap();
4216 assert!(result.block);
4217 assert!(result.reason.unwrap().contains(name));
4218 }
4219 *sessions.lock().unwrap() = ExtensionSession::none();
4220 assert!(invoke_test_tool_hook(&hook).await.is_none());
4221 }
4222
4223 fn scratch(name: &str) -> PathBuf {
4225 let nanos = std::time::SystemTime::now()
4226 .duration_since(std::time::UNIX_EPOCH)
4227 .unwrap()
4228 .as_nanos();
4229 let path = std::env::temp_dir().join(format!(
4230 "rpi-summary-{}-{nanos}-{name}.jsonl",
4231 std::process::id()
4232 ));
4233 path
4234 }
4235
4236 #[test]
4237 fn summary_marks_a_header_only_session_empty() {
4238 let path = scratch("empty");
4239 std::fs::write(
4240 &path,
4241 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4242 )
4243 .unwrap();
4244 let summary = summarize_session_file(&path);
4245 assert!(summary.empty, "a header-only file has nothing to resume");
4246 assert!(summary.preview.is_none());
4247 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
4248 std::fs::remove_file(&path).ok();
4249 }
4250
4251 #[test]
4252 fn summary_reads_the_first_user_prompt_and_name() {
4253 let path = scratch("content");
4254 let body = concat!(
4255 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4256 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
4257 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
4258 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
4259 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
4260 );
4261 std::fs::write(&path, body).unwrap();
4262 let summary = summarize_session_file(&path);
4263 assert!(!summary.empty);
4264 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
4265 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
4266 std::fs::remove_file(&path).ok();
4267 }
4268
4269 #[test]
4270 fn summary_extracts_a_block_style_user_prompt() {
4271 let path = scratch("blocks");
4272 let body = concat!(
4273 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4274 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
4275 );
4276 std::fs::write(&path, body).unwrap();
4277 let summary = summarize_session_file(&path);
4278 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
4279 assert_eq!(session_display_label(&summary), "line one line two");
4280 std::fs::remove_file(&path).ok();
4281 }
4282
4283 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
4284 rpi_harness::session::jsonl::JsonlSessionMetadata {
4285 id: id.to_string(),
4286 created_at: 0,
4287 cwd: ".".to_string(),
4288 path: path.to_string_lossy().into_owned(),
4289 modified_at: 0,
4290 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
4291 parent_session_id: None,
4292 legacy_parent_session_path: None,
4293 metadata: None,
4294 }
4295 }
4296
4297 #[test]
4298 fn continue_skips_newer_empty_sessions() {
4299 let empty = scratch("newest-empty");
4300 std::fs::write(
4301 &empty,
4302 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4303 )
4304 .unwrap();
4305 let content = scratch("older-content");
4306 std::fs::write(
4307 &content,
4308 concat!(
4309 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4310 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
4311 ),
4312 )
4313 .unwrap();
4314
4315 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
4316 let picked = first_resumable_session(&metas).expect("a resumable session");
4317 assert_eq!(picked.id, "old");
4318
4319 let all_empty = vec![meta_for(&empty, "empty")];
4322 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
4323
4324 std::fs::remove_file(&empty).ok();
4325 std::fs::remove_file(&content).ok();
4326 }
4327
4328 #[tokio::test]
4329 async fn import_preserves_validates_and_locks_the_recovery_pair() {
4330 use rpi_harness::session::jsonl::{JsonlSessionStorage, JsonlV4Header};
4331 use rpi_harness::session::types::{
4332 AssistantFrameOp, AssistantFrameRecord, RecordBase, SessionStorage,
4333 };
4334 let tmp = tempfile::tempdir().unwrap();
4335 let source = tmp.path().join("source.jsonl");
4336 let progress = tmp.path().join("source.jsonl.progress.jsonl");
4337 let fs: Arc<dyn rpi_tools::FileSystem> =
4338 Arc::new(OsExecutionEnv::with_cwd(tmp.path().into()));
4339 let storage = JsonlSessionStorage::create(
4340 fs,
4341 &source.to_string_lossy(),
4342 JsonlV4Header::new("import-pair".into(), 1, "/".into(), None, None, None),
4343 Arc::new(SystemClock),
4344 Arc::new(DefaultIdGenerator::new()),
4345 )
4346 .await
4347 .unwrap();
4348 storage
4349 .append_progress(vec![AssistantFrameRecord {
4350 base: RecordBase {
4351 id: "accepted-prefix".into(),
4352 seq: 0,
4353 lane: "main".into(),
4354 timestamp: 0,
4355 },
4356 run_id: "run".into(),
4357 stream_index: 0,
4358 op: AssistantFrameOp::Append,
4359 frame: Some(serde_json::json!({"payload":"keep"})),
4360 }])
4361 .await
4362 .unwrap();
4363 drop(storage);
4364 let original = std::fs::read(&progress).unwrap();
4365 let destination = tmp.path().join("imported");
4366 let imported = import_session_file(&source, &tmp.path().to_string_lossy(), &destination)
4367 .await
4368 .unwrap();
4369 assert_eq!(
4370 imported.read_progress("run").await.unwrap()[0].base.id,
4371 "accepted-prefix"
4372 );
4373 assert_eq!(
4374 std::fs::read(&progress).unwrap(),
4375 original,
4376 "import must not repair or mutate its source"
4377 );
4378 let lock = std::fs::OpenOptions::new()
4379 .create(true)
4380 .write(true)
4381 .open(tmp.path().join("source.jsonl.lock"))
4382 .unwrap();
4383 fs2::FileExt::try_lock_exclusive(&lock).unwrap();
4384 assert!(import_session_file(
4385 &source,
4386 &tmp.path().to_string_lossy(),
4387 &tmp.path().join("busy")
4388 )
4389 .await
4390 .is_err());
4391 drop(lock);
4392 let mut corrupt = String::from_utf8(original.clone()).unwrap();
4393 corrupt = corrupt.replace(
4394 "\"session_id\":\"import-pair\"",
4395 "\"session_id\":\"foreign\"",
4396 );
4397 assert_ne!(corrupt.as_bytes(), original.as_slice());
4398 std::fs::write(&progress, corrupt).unwrap();
4399 assert!(import_session_file(
4400 &source,
4401 &tmp.path().to_string_lossy(),
4402 &tmp.path().join("foreign")
4403 )
4404 .await
4405 .is_err());
4406 std::fs::remove_file(&progress).unwrap();
4407 assert!(import_session_file(
4408 &source,
4409 &tmp.path().to_string_lossy(),
4410 &tmp.path().join("missing")
4411 )
4412 .await
4413 .is_err());
4414 }
4415
4416 #[test]
4417 fn list_session_files_sync_walks_the_cwd_subdirectory() {
4418 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
4419 let nested = root.join("--proj--");
4420 std::fs::create_dir_all(&nested).unwrap();
4421 let file = nested.join("2026-01-01_a.jsonl");
4422 std::fs::write(&file, "{}\n").unwrap();
4423 let sidecar = nested.join("2026-01-01_a.jsonl.progress.jsonl");
4424 std::fs::write(&sidecar, "{}\n").unwrap();
4425 let found = list_session_files_sync(&root);
4426 assert!(found.iter().any(|p| p == &file));
4427 assert!(!found.contains(&sidecar));
4428 std::fs::remove_dir_all(&root).ok();
4429 }
4430
4431 fn default_prompt_tools() -> Vec<String> {
4433 BUILTIN_TOOL_NAMES
4434 .iter()
4435 .copied()
4436 .chain(["todo"])
4437 .map(str::to_owned)
4438 .collect()
4439 }
4440
4441 #[test]
4442 fn default_prompt_lists_only_the_active_tools() {
4443 let tools = ["read", "grep", "find", "ls"].map(str::to_owned);
4444 let p = default_system_prompt_with_tools("/tmp/proj", true, &tools);
4445 assert!(p.contains("- read — Read file contents"));
4446 assert!(p.contains("- grep — Search file contents"));
4447 for absent in ["- edit", "- write", "- bash", "- docs", "todo", "Windows"] {
4448 assert!(
4449 !p.contains(absent),
4450 "{absent} must not appear for a read-only run: {p}"
4451 );
4452 }
4453 assert!(p.contains("Re-stating the same plan without acting on it is a bug"));
4454 let none = default_system_prompt_with_tools("/tmp/proj", false, &[]);
4455 assert!(none.contains("Available tools:\n(none)"));
4456 }
4457
4458 #[test]
4459 fn default_prompt_mentions_cwd_and_tools() {
4460 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
4461 assert!(p.contains("/tmp/proj"));
4462 assert!(p.contains("read"));
4463 assert!(p.contains("bash"));
4464 assert!(p.contains("edit"));
4465 assert!(p.contains("write"));
4466 assert!(p.contains("docs"));
4467 assert!(!p.contains("- grep"));
4468 assert!(!p.contains("- find"));
4469 assert!(!p.contains("- ls"));
4470 assert!(!p.contains("- powershell"));
4471 assert!(p.contains("Bash/POSIX syntax, also on Windows"));
4472 }
4473
4474 #[test]
4475 fn default_prompt_instructs_language_matching() {
4476 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
4479 assert!(
4480 p.contains("same language as the user's latest message"),
4481 "the prompt must tell the model to mirror the user's language: {p}"
4482 );
4483 }
4484
4485 #[test]
4486 fn default_prompt_keeps_working_state_in_the_visible_channel() {
4487 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
4495 let lower = p.to_lowercase();
4496 assert!(
4498 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
4499 "the prompt must explain that reasoning does not survive the turn: {p}"
4500 );
4501 assert!(
4503 lower.contains("visible") && !lower.contains("todo tool"),
4504 "the prompt must keep state visible without inventing extension rules: {p}"
4505 );
4506 assert!(
4508 lower.contains("re-stating the same plan"),
4509 "the prompt must call out plan re-statement as a bug: {p}"
4510 );
4511 assert!(
4513 lower.contains("already shown earlier"),
4514 "the prompt must discourage re-fetching known content: {p}"
4515 );
4516 }
4517
4518 #[test]
4519 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
4520 let p = default_system_prompt_with_tools("/tmp/proj", true, &default_prompt_tools());
4527 let lower = p.to_lowercase();
4528 assert!(
4529 lower.contains("carried back"),
4530 "a replaying model must be told its reasoning survives: {p}"
4531 );
4532 assert!(
4533 !lower.contains("reasoning is not"),
4534 "the replayed variant must not claim reasoning is dropped: {p}"
4535 );
4536 assert!(
4537 lower.contains("do not re-derive"),
4538 "the replayed variant must forbid re-deriving the plan: {p}"
4539 );
4540 assert!(
4541 lower.contains("do not re-derive") && lower.contains("re-stating the same plan"),
4542 "the anti-repetition rule must survive the flip: {p}"
4543 );
4544 assert!(
4545 lower.contains("already shown earlier"),
4546 "the re-read rule must survive the flip: {p}"
4547 );
4548 }
4549
4550 #[test]
4551 fn default_prompt_states_each_rule_once() {
4552 for replayed in [false, true] {
4555 let p =
4556 default_system_prompt_with_tools("/tmp/proj", replayed, &default_prompt_tools());
4557 for phrase in ["the same plan", "Windows", "guessing"] {
4558 assert_eq!(p.matches(phrase).count(), 1, "{phrase:?} repeated: {p}");
4559 }
4560 assert!(
4561 !p.contains("todo tool"),
4562 "extension rules require explicit metadata"
4563 );
4564 }
4565 }
4566
4567 #[test]
4575 fn only_read_only_tools_are_replayable() {
4576 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
4577 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
4578 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
4579 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
4580 let tools = build_tools(&context, &Args::default());
4581
4582 let replay_of = |name: &str| {
4583 tools
4584 .iter()
4585 .find(|tool| tool.tool.schema().name == name)
4586 .map(|tool| tool.replay)
4587 };
4588
4589 for read_only in ["read", "docs"] {
4590 assert_eq!(
4591 replay_of(read_only),
4592 Some(ToolReplay::Safe),
4593 "{read_only} is side-effect free, so recovery may re-run it"
4594 );
4595 }
4596 for mutating in ["bash", "edit", "write", "powershell"] {
4597 if let Some(replay) = replay_of(mutating) {
4598 assert_eq!(
4599 replay,
4600 ToolReplay::Never,
4601 "{mutating} must never be replayed: re-running it would duplicate its effect"
4602 );
4603 }
4604 }
4605 }
4606
4607 #[test]
4608 fn default_tools_include_docs_lookup() {
4609 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
4610 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
4611 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
4612 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
4613 let names: Vec<String> = build_tools(&context, &Args::default())
4614 .iter()
4615 .map(|tool| tool.tool.schema().name.clone())
4616 .collect();
4617
4618 assert!(names.contains(&"docs".to_string()));
4623 for legacy in ["grep", "find", "ls"] {
4624 assert!(
4625 !names.contains(&legacy.to_string()),
4626 "legacy tool {legacy} should not be registered by default"
4627 );
4628 }
4629 }
4630
4631 #[test]
4632 fn reload_settings_preflight_respects_trust() {
4633 struct RestoreConfigDir(Option<std::ffi::OsString>);
4634
4635 impl Drop for RestoreConfigDir {
4636 fn drop(&mut self) {
4637 match self.0.take() {
4638 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4639 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4640 }
4641 }
4642 }
4643
4644 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4645 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4646 let tmp = tempfile::tempdir().unwrap();
4647 let agent = tmp.path().join("agent");
4648 let cwd = tmp.path().join("project");
4649 std::fs::create_dir_all(&agent).unwrap();
4650 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
4651 std::fs::write(agent.join("settings.json"), "{}").unwrap();
4652 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
4653 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4654 let args = Args::default();
4655
4656 let trusted = validate_settings_for_reload(&args, &cwd, true);
4657 let untrusted = validate_settings_for_reload(&args, &cwd, false);
4658 assert!(trusted
4659 .unwrap_err()
4660 .contains("could not load project settings"));
4661 assert!(untrusted.is_ok());
4662
4663 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
4664 assert!(validate_settings_for_reload(&args, &cwd, false)
4665 .unwrap_err()
4666 .contains("could not load global settings"));
4667
4668 let local_only = Args {
4669 dev_local_only: true,
4670 ..Args::default()
4671 };
4672 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
4673 }
4674
4675 #[tokio::test(flavor = "current_thread")]
4676 async fn reload_preserves_live_resources_when_settings_break() {
4677 struct RestoreConfigDir(Option<std::ffi::OsString>);
4678
4679 impl Drop for RestoreConfigDir {
4680 fn drop(&mut self) {
4681 match self.0.take() {
4682 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4683 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4684 }
4685 }
4686 }
4687
4688 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4689 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4690 let tmp = tempfile::tempdir().unwrap();
4691 let agent = tmp.path().join("agent");
4692 let cwd = tmp.path().join("project");
4693 let skill_dir = tmp.path().join("configured-skills");
4694 std::fs::create_dir_all(&agent).unwrap();
4695 std::fs::create_dir_all(&cwd).unwrap();
4696 std::fs::create_dir_all(&skill_dir).unwrap();
4697 std::fs::write(
4698 skill_dir.join("SKILL.md"),
4699 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
4700 )
4701 .unwrap();
4702 std::fs::write(
4703 agent.join("settings.json"),
4704 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
4705 )
4706 .unwrap();
4707 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4708
4709 let resolved = crate::provider::resolve(
4710 Some("anthropic"),
4711 Some(crate::provider::DEFAULT_MODEL_ID),
4712 None,
4713 Some("test-key"),
4714 None,
4715 )
4716 .unwrap();
4717 let args = Args {
4718 trust_override: Some(false),
4719 no_session: true,
4720 no_extensions: true,
4721 no_prompt_templates: true,
4722 no_context_files: true,
4723 system_prompt: Some("stable system prompt".into()),
4724 ..Args::default()
4725 };
4726 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
4727 let before_resources = harness.get_resources().await.unwrap();
4728 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
4729 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
4730 let before_prompt = harness.get_system_prompt().await.unwrap();
4731 let before_tools: Vec<String> = harness
4732 .get_tools()
4733 .await
4734 .unwrap()
4735 .iter()
4736 .map(|tool| tool.tool.schema().name.clone())
4737 .collect();
4738 let before_bridge = context
4739 .action_bridge
4740 .lock()
4741 .unwrap()
4742 .as_ref()
4743 .unwrap()
4744 .clone();
4745
4746 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
4747 let outcome = reload_extension_resources(&harness, &context).await;
4748
4749 assert!(outcome.had_warnings);
4750 assert!(outcome.summary.contains("Settings reload failed"));
4751 assert!(outcome.summary.contains("could not load global settings"));
4752 assert_eq!(outcome.project_trusted, None);
4756 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
4757 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
4758 let after_tools: Vec<String> = harness
4759 .get_tools()
4760 .await
4761 .unwrap()
4762 .iter()
4763 .map(|tool| tool.tool.schema().name.clone())
4764 .collect();
4765 assert_eq!(after_tools, before_tools);
4766 let after_bridge = context
4767 .action_bridge
4768 .lock()
4769 .unwrap()
4770 .as_ref()
4771 .unwrap()
4772 .clone();
4773 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
4774 }
4775
4776 #[tokio::test(flavor = "current_thread")]
4777 async fn reload_outcome_carries_the_current_project_trust() {
4778 struct RestoreConfigDir(Option<std::ffi::OsString>);
4779
4780 impl Drop for RestoreConfigDir {
4781 fn drop(&mut self) {
4782 match self.0.take() {
4783 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4784 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4785 }
4786 }
4787 }
4788
4789 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4790 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4791 let tmp = tempfile::tempdir().unwrap();
4792 let agent = tmp.path().join("agent");
4793 let cwd = tmp.path().join("project");
4794 std::fs::create_dir_all(&agent).unwrap();
4795 std::fs::create_dir_all(&cwd).unwrap();
4796 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4797
4798 crate::config::set_project_trust(&cwd, Some(true)).unwrap();
4800 let resolved = crate::provider::resolve(
4801 Some("anthropic"),
4802 Some(crate::provider::DEFAULT_MODEL_ID),
4803 None,
4804 Some("test-key"),
4805 None,
4806 )
4807 .unwrap();
4808 let args = Args {
4809 no_session: true,
4810 no_extensions: true,
4811 no_prompt_templates: true,
4812 no_context_files: true,
4813 system_prompt: Some("stable system prompt".into()),
4814 ..Args::default()
4815 };
4816 let (harness, _events, context) = build(&resolved, &args, &cwd, true).await.unwrap();
4817 assert!(context.project_trusted, "startup snapshot is trusted");
4818
4819 crate::config::set_project_trust(&cwd, Some(false)).unwrap();
4822 let outcome = reload_extension_resources(&harness, &context).await;
4823 assert!(!outcome.had_errors, "{}", outcome.summary);
4824 assert_eq!(outcome.project_trusted, Some(false));
4825
4826 crate::config::set_project_trust(&cwd, Some(true)).unwrap();
4828 let outcome = reload_extension_resources(&harness, &context).await;
4829 assert!(!outcome.had_errors, "{}", outcome.summary);
4830 assert_eq!(outcome.project_trusted, Some(true));
4831 }
4832
4833 #[test]
4834 fn reload_preparation_rejects_settings_changed_during_derivation() {
4835 struct RestoreConfigDir(Option<std::ffi::OsString>);
4836
4837 impl Drop for RestoreConfigDir {
4838 fn drop(&mut self) {
4839 match self.0.take() {
4840 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4841 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4842 }
4843 }
4844 }
4845
4846 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4847 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4848 let tmp = tempfile::tempdir().unwrap();
4849 let agent = tmp.path().join("agent");
4850 let cwd = tmp.path().join("project");
4851 let first_skill_dir = tmp.path().join("first-skills");
4852 let second_skill_dir = tmp.path().join("second-skills");
4853 std::fs::create_dir_all(&agent).unwrap();
4854 std::fs::create_dir_all(&cwd).unwrap();
4855 std::fs::write(
4856 agent.join("settings.json"),
4857 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
4858 )
4859 .unwrap();
4860 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4861 let args = Args {
4862 trust_override: Some(false),
4863 no_extensions: true,
4864 no_prompt_templates: true,
4865 ..Args::default()
4866 };
4867
4868 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
4869 std::fs::write(
4870 agent.join("settings.json"),
4871 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
4872 )
4873 .unwrap();
4874 });
4875
4876 let error = result
4877 .err()
4878 .expect("settings mutation must fail preparation");
4879 assert!(error.contains("settings changed while reload inputs were being prepared"));
4880 }
4881
4882 #[tokio::test(flavor = "current_thread")]
4883 async fn reload_uses_frozen_settings_inputs_after_preparation() {
4884 struct RestoreConfigDir(Option<std::ffi::OsString>);
4885
4886 impl Drop for RestoreConfigDir {
4887 fn drop(&mut self) {
4888 match self.0.take() {
4889 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4890 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4891 }
4892 }
4893 }
4894
4895 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4896 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4897 let tmp = tempfile::tempdir().unwrap();
4898 let agent = tmp.path().join("agent");
4899 let cwd = tmp.path().join("project");
4900 let skill_dir = tmp.path().join("configured-skills");
4901 std::fs::create_dir_all(&agent).unwrap();
4902 std::fs::create_dir_all(&cwd).unwrap();
4903 std::fs::create_dir_all(&skill_dir).unwrap();
4904 std::fs::write(
4905 skill_dir.join("SKILL.md"),
4906 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
4907 )
4908 .unwrap();
4909 std::fs::write(
4910 agent.join("settings.json"),
4911 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
4912 )
4913 .unwrap();
4914 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4915
4916 let resolved = crate::provider::resolve(
4917 Some("anthropic"),
4918 Some(crate::provider::DEFAULT_MODEL_ID),
4919 None,
4920 Some("test-key"),
4921 None,
4922 )
4923 .unwrap();
4924 let args = Args {
4925 trust_override: Some(false),
4926 no_session: true,
4927 no_extensions: true,
4928 no_prompt_templates: true,
4929 no_context_files: true,
4930 system_prompt: Some("stable system prompt".into()),
4931 ..Args::default()
4932 };
4933 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
4934
4935 let outcome = reload_extension_resources_inner(&harness, &context, || {
4936 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
4937 })
4938 .await;
4939
4940 assert!(!outcome.summary.contains("Settings reload failed"));
4941 assert_eq!(
4942 outcome.summary,
4943 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
4944 );
4945 let resources = harness.get_resources().await.unwrap();
4946 let skills = resources.skills.as_ref().unwrap();
4947 assert_eq!(skills.len(), 1);
4948 assert_eq!(skills[0].name, "frozen-skill");
4949 }
4950
4951 #[tokio::test]
4952 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
4953 let tmp = tempfile::tempdir().unwrap();
4954 let skill_path = tmp.path().join("explicit-skill.md");
4955 std::fs::write(
4956 &skill_path,
4957 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
4958 )
4959 .unwrap();
4960 let prompt_path = tmp.path().join("explicit-prompt.md");
4961 std::fs::write(
4962 &prompt_path,
4963 "---\ndescription: Explicit prompt\n---\nPrompt body",
4964 )
4965 .unwrap();
4966
4967 let args = Args {
4968 skill: vec![skill_path.clone()],
4969 prompt_template: vec![prompt_path.clone()],
4970 ..Args::default()
4971 };
4972 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
4973 let prompt_paths =
4974 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
4975 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
4976 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
4977
4978 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
4979 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
4980 assert_eq!(skills.skills[0].name, "explicit-skill");
4981
4982 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
4983 assert_eq!(
4984 prompts.prompt_templates.len(),
4985 1,
4986 "{:?}",
4987 prompts.diagnostics
4988 );
4989 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
4990 }
4991
4992 #[test]
4993 fn tool_policy_applies_to_rust_and_js_active_names() {
4994 let names = vec![
4995 "read".to_string(),
4996 "ask_user_question".to_string(),
4997 "write".to_string(),
4998 ];
4999
5000 let args = Args {
5001 tools: Some(vec!["read".into(), "ask_user_question".into()]),
5002 ..Args::default()
5003 };
5004 assert_eq!(
5005 filter_active_tool_names(names.clone(), &args),
5006 vec!["read", "ask_user_question"]
5007 );
5008
5009 let args = Args {
5010 exclude_tools: Some(vec!["ask_user_question".into()]),
5011 ..Args::default()
5012 };
5013 assert_eq!(
5014 filter_active_tool_names(names.clone(), &args),
5015 vec!["read", "write"]
5016 );
5017
5018 let args = Args {
5019 no_tools: true,
5020 ..Args::default()
5021 };
5022 assert!(filter_active_tool_names(names, &args).is_empty());
5023 }
5024
5025 #[test]
5026 fn select_ephemeral_when_no_session() {
5027 let args = Args {
5028 no_session: true,
5029 ..Args::default()
5030 };
5031 let cwd = Path::new("/tmp");
5032 assert!(matches!(
5033 select_session(&args, cwd),
5034 SessionSelection::Ephemeral
5035 ));
5036 }
5037
5038 #[test]
5039 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
5040 let defaults = Args::default();
5041 assert!(resolve_project_trust(
5042 &defaults,
5043 Path::new("C:/definitely-not-a-project")
5044 ));
5045
5046 let denied = Args {
5047 trust_override: Some(false),
5048 ..Args::default()
5049 };
5050 assert!(!resolve_project_trust(
5051 &denied,
5052 Path::new("C:/definitely-not-a-project")
5053 ));
5054 }
5055
5056 #[tokio::test(flavor = "current_thread")]
5057 async fn build_preserves_the_supplied_startup_project_snapshot() {
5058 struct RestoreConfigDir(Option<std::ffi::OsString>);
5059
5060 impl Drop for RestoreConfigDir {
5061 fn drop(&mut self) {
5062 match self.0.take() {
5063 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5064 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5065 }
5066 }
5067 }
5068
5069 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5070 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5071 let tmp = tempfile::tempdir().unwrap();
5072 let agent = tmp.path().join("agent");
5073 let cwd = tmp.path().join("project");
5074 std::fs::create_dir_all(&agent).unwrap();
5075 std::fs::create_dir_all(&cwd).unwrap();
5076 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5077
5078 let resolved = crate::provider::resolve(
5079 Some("anthropic"),
5080 Some(crate::provider::DEFAULT_MODEL_ID),
5081 None,
5082 Some("test-key"),
5083 None,
5084 )
5085 .unwrap();
5086 let args = Args {
5087 trust_override: Some(false),
5088 no_session: true,
5089 no_tools: true,
5090 no_extensions: true,
5091 no_skills: true,
5092 no_prompt_templates: true,
5093 no_context_files: true,
5094 system_prompt: Some("test prompt".into()),
5095 ..Args::default()
5096 };
5097
5098 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
5099
5100 assert_eq!(context.cwd, cwd);
5101 assert!(context.project_trusted);
5102 }
5103
5104 #[tokio::test(flavor = "current_thread")]
5105 async fn build_applies_the_name_flag_to_the_session() {
5106 struct RestoreConfigDir(Option<std::ffi::OsString>);
5107
5108 impl Drop for RestoreConfigDir {
5109 fn drop(&mut self) {
5110 match self.0.take() {
5111 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5112 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5113 }
5114 }
5115 }
5116
5117 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5118 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5119 let tmp = tempfile::tempdir().unwrap();
5120 let agent = tmp.path().join("agent");
5121 let cwd = tmp.path().join("project");
5122 std::fs::create_dir_all(&agent).unwrap();
5123 std::fs::create_dir_all(&cwd).unwrap();
5124 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5125
5126 let resolved = crate::provider::resolve(
5127 Some("anthropic"),
5128 Some(crate::provider::DEFAULT_MODEL_ID),
5129 None,
5130 Some("test-key"),
5131 None,
5132 )
5133 .unwrap();
5134 let args = Args {
5135 trust_override: Some(false),
5136 no_tools: true,
5137 no_extensions: true,
5138 no_skills: true,
5139 no_prompt_templates: true,
5140 no_context_files: true,
5141 system_prompt: Some("test prompt".into()),
5142 name: Some("smoke name".into()),
5143 session_dir: Some(cwd.join("sessions")),
5144 ..Args::default()
5145 };
5146
5147 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
5148 assert_eq!(
5149 harness.get_name().await.unwrap().as_deref(),
5150 Some("smoke name")
5151 );
5152 }
5153
5154 #[test]
5155 fn select_latest_for_continue_and_resume() {
5156 let _env = crate::config::test_support::SessionDirEnv::new(None);
5157 let args = Args {
5158 continue_session: true,
5159 ..Args::default()
5160 };
5161 let cwd = Path::new("/tmp");
5162 assert!(matches!(
5163 select_session(&args, cwd),
5164 SessionSelection::Latest { .. }
5165 ));
5166
5167 let args = Args {
5168 resume: true,
5169 ..Args::default()
5170 };
5171 assert!(matches!(
5172 select_session(&args, cwd),
5173 SessionSelection::Latest { .. }
5174 ));
5175 }
5176
5177 #[test]
5178 fn select_by_id_for_session_flag() {
5179 let _env = crate::config::test_support::SessionDirEnv::new(None);
5180 let args = Args {
5181 session: Some("01a02ece".into()),
5182 ..Args::default()
5183 };
5184 let cwd = Path::new("/tmp");
5185 assert!(matches!(
5186 select_session(&args, cwd),
5187 SessionSelection::ById { id, .. } if id == "01a02ece"
5188 ));
5189 }
5190
5191 #[test]
5192 fn select_new_with_custom_dir() {
5193 let _env = crate::config::test_support::SessionDirEnv::new(None);
5194 let args = Args {
5195 session_dir: Some(PathBuf::from("/tmp/sess")),
5196 ..Args::default()
5197 };
5198 let cwd = Path::new("/tmp");
5199 match select_session(&args, cwd) {
5200 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
5201 other => panic!("expected New, got {other:?}"),
5202 }
5203 }
5204
5205 #[test]
5206 fn select_new_default_dir() {
5207 let _env = crate::config::test_support::SessionDirEnv::new(None);
5208 let args = Args::default();
5209 let cwd = Path::new("/proj");
5210 match select_session(&args, cwd) {
5211 SessionSelection::New { dir, .. } => {
5212 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
5213 }
5214 other => panic!("expected New, got {other:?}"),
5215 }
5216 }
5217
5218 #[test]
5219 fn default_session_dir_only_uses_rpi() {
5220 let _env = crate::config::test_support::SessionDirEnv::new(None);
5221 let tmp = tempfile::tempdir().unwrap();
5222 let cwd = tmp.path();
5223 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
5224 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
5225 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
5226 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
5227 }
5228
5229 #[test]
5230 fn default_session_dir_uses_env_var() {
5231 let tmp = tempfile::tempdir().unwrap();
5232 let cwd = tmp.path();
5233 let custom = tmp.path().join("custom_sessions");
5234 let _env = crate::config::test_support::SessionDirEnv::new(Some(&custom));
5235 assert_eq!(default_session_dir(cwd), custom);
5236 }
5237
5238 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5239 async fn ephemeral_session_builds_roundtrips() {
5240 let s = ephemeral_session();
5244 let leaf = s.get_leaf_id().await;
5245 assert!(leaf.is_ok());
5246 }
5247
5248 #[test]
5266 fn host_private_env_matches_the_declared_contract() {
5267 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
5269 assert_eq!(
5270 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
5271 [
5272 "LANGFUSE_PI_PARENT_TRACE_ID",
5273 "LANGFUSE_PI_PARENT_SPAN_ID",
5274 "LANGFUSE_PI_PARENT_SESSION_ID",
5275 "LANGFUSE_PI_PARENT_DEPTH",
5276 ],
5277 "these strings are what rpi-langfuse (a different repo) matches; a \
5278 change here is a breaking cross-repo change, not a refactor"
5279 );
5280
5281 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
5284 .iter()
5285 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
5286 {
5287 assert!(
5288 rpi_tools::is_host_private_env(name),
5289 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
5290 rpi_tools::HOST_PRIVATE_ENV
5291 );
5292 }
5293
5294 for visible in [
5297 crate::args::RPI_OFFLINE_ENV,
5298 crate::config::CONFIG_DIR_ENV,
5299 "PATH",
5300 ] {
5301 assert!(
5302 !rpi_tools::is_host_private_env(visible),
5303 "{visible} describes the user's configuration and must still reach the shell"
5304 );
5305 }
5306 }
5307 #[tokio::test]
5308 async fn reload_shutdown_joins_all_handlers_even_after_veto() {
5309 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
5310 use std::sync::atomic::AtomicUsize;
5311 static CONTEXTS: rpi_plugin_sdk::handles::ToolHandles<Arc<AtomicUsize>> =
5312 rpi_plugin_sdk::handles::ToolHandles::new();
5313 struct ContextOwner(usize);
5314 impl Drop for ContextOwner {
5315 fn drop(&mut self) {
5316 drop(CONTEXTS.remove(self.0 as rpi_plugin_sdk::StepHandle));
5317 }
5318 }
5319 extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
5320 let context = CONTEXTS.get(data).expect("live test context");
5321 let calls = context.lock().clone();
5322 std::thread::sleep(std::time::Duration::from_millis(20));
5323 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
5324 rpi_plugin_sdk::EVENT_HANDLER_ABORT
5325 } else {
5326 0
5327 }
5328 }
5329 let calls = Arc::new(AtomicUsize::new(0));
5330 let context = CONTEXTS.insert(calls.clone());
5331 let _owner = ContextOwner(context as usize);
5332 let mut registry = rpi_extensions::ExtensionRegistry::new();
5333 for name in ["first", "second"] {
5334 registry.register_event_handler(name.into(), EventTag::SessionShutdown, stop, context);
5335 }
5336 super::shutdown_extensions_for_reload(
5337 Some(Arc::new(registry.snapshot())),
5338 rpi_extensions::PluginKeepalive::empty(),
5339 )
5340 .await
5341 .unwrap();
5342 assert_eq!(calls.load(Ordering::SeqCst), 2);
5343 }
5344
5345 #[tokio::test]
5354 async fn a_restored_session_adopts_its_recorded_directory() {
5355 let tmp = tempfile::tempdir().unwrap();
5356 let project = tmp.path().join("project-a");
5357 let launch = tmp.path().join("launch-dir");
5358 let sessions = tmp.path().join("sessions");
5359 std::fs::create_dir_all(&project).unwrap();
5360 std::fs::create_dir_all(&launch).unwrap();
5361 std::fs::create_dir_all(&sessions).unwrap();
5362 create_jsonl_session_with_id(
5363 &sessions,
5364 &project.to_string_lossy(),
5365 Some("cwdadopt01".into()),
5366 )
5367 .await
5368 .unwrap();
5369
5370 let selection = SessionSelection::ById {
5371 id: "cwdadopt01".into(),
5372 dir: sessions.clone(),
5373 };
5374 let peeked = peek_restored_session_cwd(&selection, &launch)
5375 .await
5376 .expect("a resumable session with a recorded cwd");
5377 assert_eq!(peeked.recorded, project);
5378 assert_eq!(peeked.adopted, Some(project));
5379 }
5380
5381 #[tokio::test]
5385 async fn a_missing_recorded_directory_is_reported_not_adopted() {
5386 let tmp = tempfile::tempdir().unwrap();
5387 let gone = tmp.path().join("project-gone");
5388 let launch = tmp.path().join("launch-dir");
5389 let sessions = tmp.path().join("sessions");
5390 std::fs::create_dir_all(&launch).unwrap();
5391 std::fs::create_dir_all(&sessions).unwrap();
5392 create_jsonl_session_with_id(
5393 &sessions,
5394 &gone.to_string_lossy(),
5395 Some("cwdgone001".into()),
5396 )
5397 .await
5398 .unwrap();
5399
5400 let selection = SessionSelection::ById {
5401 id: "cwdgone001".into(),
5402 dir: sessions.clone(),
5403 };
5404 let peeked = peek_restored_session_cwd(&selection, &launch)
5405 .await
5406 .expect("the session itself is resumable");
5407 assert_eq!(peeked.recorded, gone);
5408 assert_eq!(peeked.adopted, None);
5409 let note = crate::session_cwd::missing_session_cwd_note(&peeked.recorded, &launch);
5410 assert!(note.contains("no longer exists"), "{note}");
5411 assert!(note.contains("launch-dir"), "{note}");
5412 }
5413
5414 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5415 async fn legacy_progress_upgrade_normal_open_runs_and_reopens() {
5416 use rpi_ai::providers::faux::{FauxProvider, FauxScript};
5417 use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
5418 use rpi_harness::session::jsonl::{
5419 encode_header, JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
5420 JsonlV4Header,
5421 };
5422 let tmp = tempfile::tempdir().unwrap();
5423 let sessions = tmp.path().join("sessions");
5424 std::fs::create_dir_all(&sessions).unwrap();
5425 let cwd = tmp.path().to_string_lossy().into_owned();
5426 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
5427 fs: Arc::new(OsExecutionEnv::with_cwd(tmp.path().into())),
5428 sessions_root: sessions.to_string_lossy().into_owned(),
5429 clock: Arc::new(SystemClock),
5430 ids: Arc::new(DefaultIdGenerator::new()),
5431 });
5432 let fixture = repo
5433 .create_typed(&JsonlSessionCreateOptions {
5434 id: Some("legacycli001".into()),
5435 parent_session_id: None,
5436 cwd: cwd.clone(),
5437 metadata: None,
5438 })
5439 .await
5440 .unwrap();
5441 let path = PathBuf::from(fixture.jsonl_metadata().path);
5442 drop(fixture);
5443 let sidecar = PathBuf::from(format!("{}.progress.jsonl", path.display()));
5444 std::fs::remove_file(&sidecar).unwrap();
5445 let header = JsonlV4Header::new("legacycli001".into(), 1, cwd.clone(), None, None, None);
5446 std::fs::write(&path, encode_header(&header)).unwrap();
5447 assert!(!sidecar.exists());
5448 for _ in 0..2 {
5449 let restored = open_session_by_id_in_dir("legacycli001", &cwd, &sessions)
5450 .await
5451 .map_err(|e| e.to_string())
5452 .unwrap();
5453 assert_eq!(restored.session.storage().metadata().id, "legacycli001");
5454 let provider =
5455 FauxProvider::new(FauxScript::new().with_text("continued legacy session"));
5456 let harness = AgentHarness::create(AgentHarnessOptions {
5457 model: provider.default_model().clone(),
5458 models: vec![provider],
5459 session: restored.session,
5460 allow_existing_session: true,
5461 ..Default::default()
5462 })
5463 .await
5464 .unwrap();
5465 let run = harness.prompt_text("continue", vec![]).await.unwrap();
5466 assert!(matches!(run.outcome, HarnessRunOutcome::Completed { .. }));
5467 }
5468 let content = std::fs::read_to_string(&path).unwrap();
5469 assert!(content.lines().next().unwrap().contains("progressFormat"));
5470 assert!(sidecar.exists());
5471 }
5472
5473 #[tokio::test]
5476 async fn open_session_reports_the_recorded_directory() {
5477 let tmp = tempfile::tempdir().unwrap();
5478 let project = tmp.path().join("project-open");
5479 let launch = tmp.path().join("launch-dir");
5480 let sessions = tmp.path().join("sessions");
5481 std::fs::create_dir_all(&project).unwrap();
5482 std::fs::create_dir_all(&launch).unwrap();
5483 std::fs::create_dir_all(&sessions).unwrap();
5484 create_jsonl_session_with_id(
5485 &sessions,
5486 &project.to_string_lossy(),
5487 Some("cwdopen001".into()),
5488 )
5489 .await
5490 .unwrap();
5491
5492 let restored =
5493 open_session_by_id_in_dir("cwdopen001", &launch.to_string_lossy(), &sessions)
5494 .await
5495 .map_err(|error| error.to_string())
5496 .unwrap();
5497 assert_eq!(restored.recorded_cwd, Some(project.clone()));
5498 assert_eq!(restored.session.storage().metadata().id, "cwdopen001");
5499 }
5500
5501 #[tokio::test]
5510 async fn the_peeked_session_is_the_one_the_build_opens() {
5511 let tmp = tempfile::tempdir().unwrap();
5512 let first_project = tmp.path().join("project-first");
5513 let second_project = tmp.path().join("project-second");
5514 let launch = tmp.path().join("launch-dir");
5515 let sessions = tmp.path().join("sessions");
5516 for dir in [&first_project, &second_project, &launch, &sessions] {
5517 std::fs::create_dir_all(dir).unwrap();
5518 }
5519 create_jsonl_session_with_id(
5520 &sessions,
5521 &first_project.to_string_lossy(),
5522 Some("cwdrace001".into()),
5523 )
5524 .await
5525 .unwrap();
5526
5527 let mut args = Args {
5528 continue_session: true,
5529 session_dir: Some(sessions.clone()),
5530 ..Default::default()
5531 };
5532 let selection = select_session_in_dir(&args, sessions.clone());
5533 let peeked = peek_restored_session_cwd(&selection, &launch)
5534 .await
5535 .expect("a resumable session");
5536 assert_eq!(peeked.adopted, Some(first_project.clone()));
5537
5538 create_jsonl_session_with_id(
5540 &sessions,
5541 &second_project.to_string_lossy(),
5542 Some("cwdrace002".into()),
5543 )
5544 .await
5545 .unwrap();
5546
5547 pin_restored_selection(&mut args, &peeked);
5548 let pinned = select_session_in_dir(&args, sessions.clone());
5549 let reopened = resolve_restore_target(&pinned, &launch.to_string_lossy())
5550 .await
5551 .unwrap()
5552 .expect("the pinned session still exists");
5553 assert_eq!(
5554 reopened.id, peeked.id,
5555 "the build must open the session the peek adopted the directory of"
5556 );
5557 assert_eq!(reopened.cwd, first_project.to_string_lossy());
5558 }
5559
5560 #[test]
5565 fn trust_is_resolved_per_directory() {
5566 struct RestoreConfigDir(Option<std::ffi::OsString>);
5567
5568 impl Drop for RestoreConfigDir {
5569 fn drop(&mut self) {
5570 match self.0.take() {
5571 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5572 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5573 }
5574 }
5575 }
5576
5577 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5578 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5579 let tmp = tempfile::tempdir().unwrap();
5580 let agent = tmp.path().join("agent");
5581 std::fs::create_dir_all(&agent).unwrap();
5582 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5583
5584 let untrusted = tmp.path().join("project-untrusted");
5585 let launch = tmp.path().join("project-launch");
5586 std::fs::create_dir_all(&untrusted).unwrap();
5587 std::fs::create_dir_all(&launch).unwrap();
5588 crate::config::set_project_trust(&untrusted, Some(false)).unwrap();
5589
5590 let args = Args::default();
5591 assert!(!resolve_project_trust(&args, &untrusted));
5592 assert!(resolve_project_trust(&args, &launch));
5594 }
5595}