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 harness.set_skill_execution_env(env_dyn.clone());
1281
1282 let harness_leases = resource_leases.clone();
1284 harness
1285 .events()
1286 .on_lifetime::<rpi_harness::events::RunEndEvent, _>(move |_| {
1287 let _ = &harness_leases;
1288 });
1289 install_extension_settlement_bridge(harness.events(), extension_session_cell.clone());
1290 if let Some(selected) = &resource_selection {
1291 harness
1292 .seal_resources(active)
1293 .map_err(|e| BuildError::HarnessCreate(e.to_string()))?;
1294 crate::resource_selection::attach(
1295 &harness,
1296 serde_json::json!({
1297 "version": 1,
1298 "hostId": uuid::Uuid::new_v4().to_string(),
1299 "host": {"name": crate::APP_NAME, "version": crate::VERSION, "pid": std::process::id()},
1300 "selection": selected.selection,
1301 "package": selected.package,
1302 "selectedSkills": selected.skills,
1303 "skillDiagnostics": [],
1304 "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<_>>(),
1305 "instructions": selected.provenance(),
1306 "baseSystemPrompt": "native-default",
1307 "projectContext": "disabled",
1308 "resourceDiscovery": "disabled",
1309 "changes": "restart-required"
1310 }),
1311 );
1312 let resource_observation = StartupSpan::start(Phase::ResourceObservation);
1313 crate::resource_selection::get_resource_state(&harness)
1314 .await
1315 .map_err(BuildError::HarnessCreate)?;
1316 resource_observation.finish();
1317 if selected.transition.is_some() {
1318 let meta = resolve_restore_target(&selection, &cwd_str)
1322 .await?
1323 .ok_or_else(|| {
1324 BuildError::SessionOpen("transition session disappeared before commit".into())
1325 })?;
1326 let path = std::fs::canonicalize(&meta.path)
1327 .map_err(|e| BuildError::SessionOpen(e.to_string()))?;
1328 let lock_path = PathBuf::from(format!("{}.lock", path.display()));
1329 if !SESSION_LOCK_PATHS
1330 .get_or_init(|| Mutex::new(HashSet::new()))
1331 .lock()
1332 .unwrap()
1333 .contains(&lock_path)
1334 {
1335 return Err(BuildError::SessionOpen(
1336 "resource transition lost canonical writer ownership".into(),
1337 ));
1338 }
1339 let facts =
1340 rpi_harness::session::jsonl::JsonlSessionStorage::read_resource_transition_facts(
1341 Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf())),
1342 &path.to_string_lossy(),
1343 Arc::new(SystemClock),
1344 Arc::new(DefaultIdGenerator::new()),
1345 )
1346 .await
1347 .map_err(|e| BuildError::SessionOpen(e.to_string()))?;
1348 crate::resource_selection::validate_session_binding(&facts.values, Some(selected))
1349 .map_err(BuildError::SessionOpen)?;
1350 let mirror = rpi_harness::session::SessionValues::load(harness.session())
1351 .await
1352 .map_err(|e| BuildError::SessionOpen(e.to_string()))?;
1353 if crate::resource_selection::binding_state(&mirror)
1354 != crate::resource_selection::binding_state(&facts.values)
1355 {
1356 return Err(BuildError::SessionOpen(
1357 "resource binding changed before commit".into(),
1358 ));
1359 }
1360 }
1361 let inventory_bind = StartupSpan::start(Phase::InventoryBind);
1362 crate::resource_selection::bind_inventory(&harness, selected)
1363 .await
1364 .map_err(BuildError::SessionOpen)?;
1365 inventory_bind.finish();
1366 }
1367 crate::extensions_actions::HarnessActionHost::set_harness(
1369 &harness_cell,
1370 Arc::new(harness.clone()),
1371 );
1372 register_file_io_router(&harness, &file_io);
1373
1374 {
1382 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
1383 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
1384 Ok(reports) => {
1385 for (lane, report) in reports {
1386 if report.is_clean() {
1387 continue;
1388 }
1389 if report.is_corrupt() {
1390 eprintln!(
1391 "warning: session lane '{lane}' is corrupt ({} open operations); \
1392 refusing to auto-repair — inspect the session file",
1393 report.open_operations.len()
1394 );
1395 continue;
1396 }
1397 for decision in runtime.reconcile(&report) {
1398 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
1399 if args.verbose {
1400 eprintln!(
1401 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
1402 );
1403 }
1404 }
1405 }
1406 runtime.emit_recovery_events(harness.events(), &report);
1407 }
1408 }
1409 Err(e) => {
1410 if args.verbose {
1411 eprintln!("warning: session recovery sweep failed: {e}");
1412 }
1413 }
1414 }
1415 }
1416
1417 let mut reload_args = args.clone();
1426 reload_args.session_dir = Some(session_dir);
1427 let reload_context = ReloadContext {
1428 resource_leases,
1429 fresh_session_only,
1430 extension_session: extension_session_cell,
1431 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
1432 tool_context,
1433 file_io,
1434 catalog: Arc::new(std::sync::RwLock::new(catalog)),
1435 providers: Arc::new(std::sync::RwLock::new(resolved.providers.clone())),
1436 runtime: runtime.clone(),
1437 cwd: cwd.to_path_buf(),
1438 project_trusted,
1439 discovered_theme_paths: Arc::new(std::sync::Mutex::new(discovered.theme_paths.clone())),
1440 args: reload_args,
1441 resolved_model: resolved.model.clone(),
1442 broadcast: broadcast_for_context,
1443 mailbox: reload_mailbox,
1444 ui_dialog: ui_dialog_mailbox,
1445 ext_status: ext_status_mailbox,
1446 editor_text: editor_text_mailbox,
1447 dev_extension: None,
1448 startup_notices: Vec::new(),
1449 };
1450
1451 Ok((harness, event_rx, reload_context))
1452}
1453
1454fn install_extension_settlement_bridge(
1458 events: &rpi_harness::events::HarnessEventBus,
1459 sessions: ExtensionSessionCell,
1460) {
1461 events.on_lifetime::<rpi_harness::events::RunEndEvent, _>(move |event| {
1462 if event.lane != "main" {
1463 return;
1464 }
1465 let session = sessions.lock().unwrap().clone();
1466 if let Some(snapshot) = session.snapshot_arc() {
1467 rpi_extensions::dispatch_empty_event(&snapshot, rpi_plugin_sdk::EventTag::AgentSettled);
1468 }
1469 });
1470}
1471
1472fn extension_tool_call_hook(
1475 sessions: ExtensionSessionCell,
1476 ui_dialog: rpi_extensions::UiDialogMailbox,
1477) -> rpi_agent::BeforeToolCall {
1478 Arc::new(move |ctx: rpi_agent::BeforeToolCallContext<'_>, signal| {
1479 let session = sessions
1480 .lock()
1481 .map(|session| session.clone())
1482 .map_err(|_| ());
1483 let ui_dialog = ui_dialog.clone();
1484 let tool_call_id = ctx.tool_call.id.clone();
1485 let tool_name = ctx.tool_call.name.clone();
1486 let params = ctx.args.clone();
1487 Box::pin(async move {
1488 let reason = match session {
1489 Ok(session) => {
1490 rpi_extensions::dispatch_tool_call_event(
1491 &session,
1492 &tool_call_id,
1493 &tool_name,
1494 ¶ms,
1495 &signal,
1496 &ui_dialog,
1497 )
1498 .await
1499 }
1500 Err(_) => Some("extension approval session is unavailable".to_string()),
1501 };
1502 reason.map(|reason| rpi_agent::BeforeToolCallResult {
1503 block: true,
1504 reason: Some(reason),
1505 terminate: false,
1506 args: None,
1507 })
1508 })
1509 })
1510}
1511
1512pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
1557
1558pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
1563
1564#[derive(Clone)]
1572pub struct ReloadContext {
1573 resource_leases: Arc<Vec<crate::resource_management::Lease>>,
1574 fresh_session_only: bool,
1576 pub file_io: Arc<rpi_tools::file_io::FileIORouter>,
1578 pub extension_session: ExtensionSessionCell,
1580 pub tool_context: rpi_extensions::ToolCallContext,
1585 pub action_bridge: ActionBridgeCell,
1587 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
1590 pub editor_text: rpi_extensions::EditorTextMailbox,
1597 pub catalog: Arc<std::sync::RwLock<Vec<rpi_ai::Model>>>,
1600 pub providers: Arc<std::sync::RwLock<Vec<Arc<dyn Provider>>>>,
1603 pub runtime: tokio::runtime::Handle,
1607 pub cwd: PathBuf,
1609 pub project_trusted: bool,
1613 pub discovered_theme_paths: Arc<std::sync::Mutex<Vec<String>>>,
1618 pub args: Args,
1625 pub resolved_model: rpi_ai::Model,
1628 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
1634 pub mailbox: rpi_extensions::ReloadMailbox,
1641 pub ui_dialog: rpi_extensions::UiDialogMailbox,
1647 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
1651 pub startup_notices: Vec<String>,
1657}
1658
1659impl ReloadContext {
1660 pub(crate) fn session_dir(&self) -> PathBuf {
1661 self.args
1663 .session_dir
1664 .clone()
1665 .unwrap_or_else(|| configured_session_dir(&self.args, &self.cwd))
1666 }
1667}
1668
1669pub async fn dispatch_session_event_async(
1684 reload_context: &ReloadContext,
1685 tag: rpi_plugin_sdk::EventTag,
1686) -> Option<String> {
1687 let (snapshot, keepalive) = {
1693 let session = reload_context.extension_session.lock().unwrap();
1694 (session.snapshot_arc(), session.keepalive())
1695 };
1696 match snapshot {
1697 Some(snapshot) => rpi_extensions::dispatch_lifecycle_event(&snapshot, tag, keepalive).await,
1698 None => None,
1699 }
1700}
1701
1702async fn shutdown_extensions_for_reload(
1705 snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
1706 keepalive: Arc<rpi_extensions::PluginKeepalive>,
1707) -> Result<(), String> {
1708 if let Some(snapshot) = snapshot {
1709 tokio::task::spawn_blocking(move || {
1710 let _keepalive = keepalive;
1711 rpi_extensions::dispatch_empty_event(
1712 &snapshot,
1713 rpi_plugin_sdk::EventTag::SessionShutdown,
1714 );
1715 })
1716 .await
1717 .map_err(|error| error.to_string())?;
1718 }
1719 Ok(())
1720}
1721
1722pub struct ReloadOutcome {
1725 pub summary: String,
1728 pub had_warnings: bool,
1730 pub details: Vec<String>,
1735 pub had_errors: bool,
1739 pub project_trusted: Option<bool>,
1744}
1745
1746struct PreparedReloadInputs {
1747 extension_dirs: Vec<PathBuf>,
1748 skill_base_dirs: Vec<PathBuf>,
1749 prompt_base_dirs: Vec<PathBuf>,
1750}
1751
1752fn append_reload_resource_paths(
1757 mut paths: Vec<PathBuf>,
1758 explicit: &[PathBuf],
1759 discovered: &[String],
1760 js_paths: &[PathBuf],
1761 package_paths: &[PathBuf],
1762) -> Vec<PathBuf> {
1763 paths.extend(explicit.iter().cloned());
1764 paths.extend(discovered.iter().map(PathBuf::from));
1765 paths.extend(js_paths.iter().cloned());
1766 paths.extend(package_paths.iter().cloned());
1767 paths
1768}
1769
1770pub async fn reload_extension_resources(
1782 harness: &AgentHarness,
1783 ctx: &ReloadContext,
1784) -> ReloadOutcome {
1785 reload_extension_resources_inner(harness, ctx, || {}).await
1786}
1787
1788async fn reload_extension_resources_inner<F>(
1789 harness: &AgentHarness,
1790 ctx: &ReloadContext,
1791 after_prepare: F,
1792) -> ReloadOutcome
1793where
1794 F: FnOnce() + Send,
1795{
1796 if harness.has_exact_resources()
1797 || ctx.args.resource_config.is_some()
1798 || ctx.args.resource_transition.is_some()
1799 {
1800 return ReloadOutcome {
1801 summary:
1802 "Exact resource selection is restart-required; reload/dev hot apply unsupported"
1803 .into(),
1804 had_warnings: true,
1805 details: Vec::new(),
1806 had_errors: true,
1807 project_trusted: None,
1808 };
1809 }
1810 let mut effective_args = ctx.args.clone();
1815 if let Some(dev) = &ctx.dev_extension {
1816 if let Err(error) = dev
1817 .rebuild()
1818 .and_then(|_| dev.apply_to_args(&mut effective_args))
1819 {
1820 return ReloadOutcome {
1821 summary: format!(
1822 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1823 dev.package_name()
1824 ),
1825 had_warnings: true,
1826 details: Vec::new(),
1827 had_errors: true,
1828 project_trusted: None,
1829 };
1830 }
1831 }
1832 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1833 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1834 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1835 Ok(prepared) => prepared,
1836 Err(error) => {
1837 return ReloadOutcome {
1838 summary: format!(
1839 "Settings reload failed: {error}. Keeping the currently loaded resources."
1840 ),
1841 had_warnings: true,
1842 details: Vec::new(),
1843 had_errors: true,
1844 project_trusted: None,
1845 };
1846 }
1847 };
1848 after_prepare();
1849 let PreparedReloadInputs {
1850 extension_dirs,
1851 skill_base_dirs,
1852 prompt_base_dirs,
1853 } = prepared;
1854 let mut warnings = false;
1855
1856 let (shutdown_snapshot, shutdown_keepalive) = {
1860 let session = ctx.extension_session.lock().unwrap();
1861 (session.snapshot_arc(), session.keepalive())
1862 };
1863 if let Err(reason) = shutdown_extensions_for_reload(shutdown_snapshot, shutdown_keepalive).await
1864 {
1865 return ReloadOutcome {
1866 summary: "Reload failed during extension shutdown".into(),
1867 had_warnings: true,
1868 details: vec![reason],
1869 had_errors: true,
1870 project_trusted: None,
1871 };
1872 }
1873
1874 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1881 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1882 Some(b) => b.clone_host(),
1883 None => {
1884 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1887 ctx.catalog
1888 .read()
1889 .unwrap_or_else(|error| error.into_inner())
1890 .clone(),
1891 ctx.cwd.clone(),
1892 ctx.runtime.clone(),
1893 ctx.args.unknown_flags.clone(),
1894 ctx.tool_context.clone(),
1895 );
1896 crate::extensions_actions::HarnessActionHost::set_harness(
1897 &_cell,
1898 Arc::new(harness.clone()),
1899 );
1900 Arc::new(
1901 action_host
1902 .with_session_dir(ctx.session_dir())
1903 .with_fresh_session_only(ctx.fresh_session_only),
1904 )
1905 }
1906 };
1907
1908 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1909 let publish_host = Arc::clone(&host);
1910 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1911 ctx.runtime.clone(),
1912 host,
1913 reload_cb,
1914 ctx.ui_dialog.clone(),
1915 ctx.ext_status.clone(),
1916 ctx.editor_text.clone(),
1917 );
1918
1919 let extension_session = if effective_args.no_extensions {
1920 rpi_extensions::ExtensionSession::none()
1921 } else {
1922 load_extensions_from_dirs(
1923 &effective_args,
1924 &extension_dirs,
1925 Some(Arc::clone(&fresh_bridge)),
1926 Some(ctx.resource_leases.clone()),
1927 )
1928 };
1929 if let Err(error) =
1930 crate::native_store::validate_stdio(ctx.fresh_session_only, extension_session.owns_stdio())
1931 {
1932 return ReloadOutcome {
1933 summary: "Reload refused by experimental native session policy".into(),
1934 had_warnings: true,
1935 details: vec![error],
1936 had_errors: true,
1937 project_trusted: None,
1938 };
1939 }
1940 if extension_session.is_empty() && !effective_args.no_extensions {
1941 }
1944 if effective_args.verbose {
1945 if let Some(s) = extension_session.summary() {
1946 eprintln!("reload: {s}");
1947 }
1948 report_deferred_renderers(&extension_session);
1949 }
1950
1951 let has_package_providers = |session: &ExtensionSession| {
1952 session.snapshot().is_some_and(|snapshot| {
1953 snapshot
1954 .providers()
1955 .iter()
1956 .any(|provider| snapshot.provider_manifest(&provider.provider_id).is_some())
1957 })
1958 };
1959 let packaged = has_package_providers(&extension_session)
1960 || has_package_providers(&ctx.extension_session.lock().unwrap());
1961 let refreshed = if packaged {
1962 let mut args = effective_args.clone();
1963 if let Ok(current) = harness.lane("main").get_model().await {
1964 let was_package = ctx
1965 .extension_session
1966 .lock()
1967 .unwrap()
1968 .snapshot()
1969 .is_some_and(|snapshot| snapshot.provider_manifest(¤t.provider).is_some());
1970 let has_replacement = extension_session.snapshot().is_some_and(|snapshot| {
1971 snapshot
1972 .providers()
1973 .iter()
1974 .any(|provider| provider.provider_id == current.provider)
1975 });
1976 if was_package && !has_replacement {
1977 return ReloadOutcome {
1978 summary: "Reload failed while resolving provider packages".into(),
1979 had_warnings: true,
1980 details: vec![format!(
1981 "active provider package '{}' is no longer loaded",
1982 current.provider
1983 )],
1984 had_errors: true,
1985 project_trusted: None,
1986 };
1987 }
1988 args.provider = Some(current.provider.clone());
1989 args.model = Some(current.id);
1990 if current.provider != ctx.resolved_model.provider {
1991 args.api_key = None;
1992 args.base_url = None;
1993 }
1994 }
1995 match crate::provider::resolve_with_extensions(
1996 &args,
1997 &ctx.cwd,
1998 ctx.project_trusted,
1999 &extension_session,
2000 ) {
2001 Ok(resolved) => Some(resolved),
2002 Err(error) => {
2003 return ReloadOutcome {
2004 summary: "Reload failed while resolving provider packages".into(),
2005 had_warnings: true,
2006 details: vec![error.to_string()],
2007 had_errors: true,
2008 project_trusted: None,
2009 }
2010 }
2011 }
2012 } else {
2013 None
2014 };
2015
2016 let old_session = {
2028 let mut session_guard = ctx.extension_session.lock().unwrap();
2029 let old_session = std::mem::replace(&mut *session_guard, extension_session.clone());
2030 if let Some(old_snap) = old_session.snapshot_arc() {
2031 old_snap.active_flag().store(false, Ordering::SeqCst);
2035 }
2036 old_session
2037 };
2038 if let Some(old_b) = old_bridge {
2039 old_b.invalidate();
2040 }
2041 old_session.begin_shutdown();
2043
2044 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
2046 publish_host.publish_registry_opt(extension_session.snapshot_arc());
2054
2055 let discovered = extension_session
2057 .snapshot_arc()
2058 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
2059 .unwrap_or_default();
2060 if let Ok(mut guard) = ctx.discovered_theme_paths.lock() {
2063 *guard = discovered.theme_paths.clone();
2064 }
2065
2066 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
2068 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2069
2070 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
2071 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
2072 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
2073 if !effective_args.no_skills {
2074 let js_paths: &[PathBuf] = &[];
2078 let package_paths: &[PathBuf] = &[];
2079 let dirs = append_reload_resource_paths(
2080 skill_base_dirs,
2081 &effective_args.skill,
2082 &discovered.skill_paths,
2083 js_paths,
2084 package_paths,
2085 );
2086 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
2087 skills = result.skills;
2088 skill_diags = result.diagnostics;
2089 resource_diags.extend(result.resource_diagnostics);
2090 }
2091
2092 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
2093 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
2094 if !effective_args.no_prompt_templates {
2095 let js_paths: &[PathBuf] = &[];
2096 let package_paths: &[PathBuf] = &[];
2097 let dirs = append_reload_resource_paths(
2098 prompt_base_dirs,
2099 &effective_args.prompt_template,
2100 &discovered.prompt_paths,
2101 js_paths,
2102 package_paths,
2103 );
2104 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
2105 prompt_templates = result.prompt_templates;
2106 prompt_diags = result.diagnostics;
2107 resource_diags.extend(result.resource_diagnostics);
2108 }
2109
2110 let context_files = if effective_args.no_context_files || !project_trusted {
2111 Vec::new()
2112 } else {
2113 let agent_dir = crate::config::agent_dir().ok();
2114 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
2115 load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await
2116 };
2117 let context_block = format_project_context(&context_files);
2118
2119 let mut details: Vec<String> = Vec::new();
2120 if let Some(resolved) = &refreshed {
2121 warnings |= !resolved.compat_warnings.is_empty();
2122 details.extend(
2123 resolved
2124 .compat_warnings
2125 .iter()
2126 .map(|warning| format!("warning: models.json: {warning}")),
2127 );
2128 }
2129 if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
2130 warnings = true;
2131 for d in &skill_diags {
2136 details.push(format!(
2137 "warning: skill {} ({}): {}",
2138 d.path,
2139 d.code.as_str(),
2140 d.message
2141 ));
2142 }
2143 for d in &prompt_diags {
2144 details.push(format!(
2145 "warning: prompt template {} ({}): {}",
2146 d.path,
2147 d.code.as_str(),
2148 d.message
2149 ));
2150 }
2151 for d in &resource_diags {
2153 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
2154 details.push(format!(
2155 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
2156 c.resource_type.as_str(),
2157 c.name,
2158 c.winner_path,
2159 c.loser_path
2160 ));
2161 }
2162 }
2163 }
2164
2165 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2174 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env))
2175 .with_file_io(ctx.file_io.clone());
2176 let mut tools = build_tools(&tool_ctx, &effective_args);
2177 merge_extension_tools(
2178 &mut tools,
2179 &extension_session,
2180 &effective_args,
2181 &ctx.tool_context,
2182 );
2183 let mut active = active_tool_names(&tools, &effective_args);
2184 active = filter_active_tool_names(active, &effective_args);
2185 let prompt_tools = active.clone();
2186 let prompt_metadata = extension_tool_prompt_metadata(&extension_session);
2187 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
2188 let default_prompt = || {
2189 default_system_prompt_with_tool_prompts(
2190 &cwd_str,
2191 prior_reasoning_replayed,
2192 &prompt_tools,
2193 &prompt_metadata,
2194 )
2195 };
2196 let mut context_paths = Vec::new();
2197 let base_prompt = match effective_args.system_prompt.as_deref() {
2198 Some(explicit) => explicit.to_string(),
2199 None if project_trusted => match discover_system_prompt_file(&ctx.cwd) {
2200 Some(path) => {
2201 read_prompt_source(&path, &mut context_paths).unwrap_or_else(default_prompt)
2202 }
2203 None => default_prompt(),
2204 },
2205 None => default_prompt(),
2206 };
2207 let mut append_texts: Vec<String> = Vec::new();
2208 for extra in &effective_args.append_system_prompt {
2209 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
2210 append_texts.push(text);
2211 }
2212 if effective_args.append_system_prompt.is_empty() {
2213 if let Some(path) = project_trusted
2214 .then(|| discover_append_system_prompt_file(&ctx.cwd))
2215 .flatten()
2216 {
2217 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
2218 append_texts.push(text);
2219 }
2220 }
2221 }
2222 context_paths.extend(
2223 context_files
2224 .iter()
2225 .map(|file| file.path.to_string_lossy().into_owned()),
2226 );
2227 let append_join = if append_texts.is_empty() {
2228 None
2229 } else {
2230 Some(append_texts.join("\n\n"))
2231 };
2232 let system_prompt = compose_system_prompt(
2233 Some(&base_prompt),
2234 &[],
2235 if context_block.is_empty() {
2236 None
2237 } else {
2238 Some(&context_block)
2239 },
2240 append_join.as_deref(),
2241 );
2242
2243 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
2245 Some(snapshot) => {
2246 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
2247 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
2248 }
2249 None => ctx.broadcast.clone(),
2250 };
2251
2252 let resources = AgentHarnessResources {
2254 context_files: context_paths,
2255 skills: if skills.is_empty() {
2256 None
2257 } else {
2258 Some(skills.clone())
2259 },
2260 prompt_templates: if prompt_templates.is_empty() {
2261 None
2262 } else {
2263 Some(prompt_templates.clone())
2264 },
2265 };
2266 let _ = harness.set_system_prompt(Some(system_prompt)).await;
2267 let _ = harness.set_resources(resources).await;
2268 let _ = harness.set_agent_emitter(Some(emitter)).await;
2269 let _ = harness
2270 .set_models(build_models_with_extensions_for_reload(
2271 &refreshed
2272 .as_ref()
2273 .map(|resolved| resolved.providers.clone())
2274 .unwrap_or_else(|| {
2275 ctx.providers
2276 .read()
2277 .unwrap_or_else(|error| error.into_inner())
2278 .clone()
2279 }),
2280 &extension_session,
2281 ctx.runtime.clone(),
2282 ))
2283 .await;
2284 let _ = harness
2285 .set_provider_hooks(
2286 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
2287 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
2288 )
2289 .await;
2290
2291 if let Some(resolved) = &refreshed {
2292 *ctx.providers
2293 .write()
2294 .unwrap_or_else(|error| error.into_inner()) = resolved.providers.clone();
2295 let catalog = crate::provider::authenticated_catalog(resolved);
2296 fresh_bridge
2297 .clone_host()
2298 .replace_model_catalog(catalog.clone());
2299 *ctx.catalog
2300 .write()
2301 .unwrap_or_else(|error| error.into_inner()) = catalog;
2302 let _ = harness.lane("main").set_model(resolved.model.clone()).await;
2305 }
2306
2307 let _ = harness.set_tools(tools, Some(active)).await;
2308
2309 let summary = format!(
2310 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
2311 extension_session.loaded_paths().len(),
2312 skills.len(),
2313 prompt_templates.len(),
2314 );
2315 ReloadOutcome {
2316 summary,
2317 had_warnings: warnings,
2318 details,
2319 had_errors: false,
2322 project_trusted: Some(project_trusted),
2323 }
2324}
2325
2326#[derive(Clone, Debug, PartialEq, Eq)]
2327struct ReloadSettingsFields {
2328 skill_dirs: Option<Vec<String>>,
2329 prompt_dirs: Option<Vec<String>>,
2330 extension_dirs: Option<Vec<String>>,
2331}
2332
2333impl From<crate::settings::Settings> for ReloadSettingsFields {
2334 fn from(settings: crate::settings::Settings) -> Self {
2335 Self {
2336 skill_dirs: settings.skill_dirs,
2337 prompt_dirs: settings.prompt_dirs,
2338 extension_dirs: settings.extension_dirs,
2339 }
2340 }
2341}
2342
2343#[derive(Clone, Debug, PartialEq, Eq)]
2344struct ReloadSettingsSnapshot {
2345 global: ReloadSettingsFields,
2346 project: Option<ReloadSettingsFields>,
2347}
2348
2349fn reload_reads_settings(args: &Args) -> bool {
2350 !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
2351}
2352
2353fn load_reload_settings_snapshot(
2357 args: &Args,
2358 cwd: &Path,
2359 project_trusted: bool,
2360) -> Result<Option<ReloadSettingsSnapshot>, String> {
2361 if !reload_reads_settings(args) {
2362 return Ok(None);
2363 }
2364 let global = crate::settings::load_settings()
2365 .map_err(|error| format!("could not load global settings: {error}"))?
2366 .into();
2367 let project = if project_trusted {
2368 crate::settings::load_active_project_settings(cwd)
2369 .map_err(|error| format!("could not load project settings: {error}"))?
2370 .map(|(_, settings)| settings.into())
2371 } else {
2372 None
2373 };
2374 Ok(Some(ReloadSettingsSnapshot { global, project }))
2375}
2376
2377#[cfg(test)]
2380fn validate_settings_for_reload(
2381 args: &Args,
2382 cwd: &Path,
2383 project_trusted: bool,
2384) -> Result<(), String> {
2385 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
2386}
2387
2388fn prepare_reload_inputs(
2389 args: &Args,
2390 cwd: &Path,
2391 project_trusted: bool,
2392) -> Result<PreparedReloadInputs, String> {
2393 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
2394}
2395
2396fn prepare_reload_inputs_inner<F>(
2397 args: &Args,
2398 cwd: &Path,
2399 project_trusted: bool,
2400 before_verify: F,
2401) -> Result<PreparedReloadInputs, String>
2402where
2403 F: FnOnce(),
2404{
2405 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
2406
2407 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
2408 Vec::new()
2409 } else if args.project_only {
2410 project_extension_dirs(cwd)
2411 } else if project_trusted {
2412 extension_dirs(cwd)
2413 } else {
2414 global_extension_dirs()
2415 };
2416 if !args.no_extensions {
2417 extension_dirs.extend(args.extensions_dir.iter().cloned());
2418 }
2419
2420 let skill_base_dirs = if args.no_skills {
2421 Vec::new()
2422 } else if args.dev_local_only || args.project_only {
2423 project_skill_dirs(cwd)
2424 } else if project_trusted {
2425 skill_dirs(cwd)
2426 } else {
2427 global_skill_dirs()
2428 };
2429
2430 let prompt_base_dirs = if args.no_prompt_templates {
2431 Vec::new()
2432 } else if args.dev_local_only || args.project_only {
2433 project_prompt_template_dirs(cwd)
2434 } else if project_trusted {
2435 prompt_template_dirs(cwd)
2436 } else {
2437 global_prompt_template_dirs()
2438 };
2439
2440 before_verify();
2441 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
2442 if settings_before != settings_after {
2443 return Err(
2444 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
2445 );
2446 }
2447
2448 Ok(PreparedReloadInputs {
2449 extension_dirs,
2450 skill_base_dirs,
2451 prompt_base_dirs,
2452 })
2453}
2454
2455fn build_models_with_extensions_for_reload(
2460 providers: &[Arc<dyn Provider>],
2461 extension_session: &ExtensionSession,
2462 runtime: tokio::runtime::Handle,
2463) -> Vec<Arc<dyn Provider>> {
2464 let mut models: Vec<Arc<dyn Provider>> = providers.to_vec();
2465 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
2466 for provider in pluggable {
2467 if extension_session
2471 .snapshot()
2472 .is_some_and(|snapshot| snapshot.provider_manifest(provider.id()).is_some())
2473 {
2474 continue;
2475 }
2476 if !models.iter().any(|known| known.id() == provider.id()) {
2477 models.push(provider);
2478 }
2479 }
2480 models
2481}
2482
2483fn report_deferred_renderers(session: &ExtensionSession) {
2487 let Some(snap) = session.snapshot_arc() else {
2488 return;
2489 };
2490 let all = snap.renderers();
2491 let markdown = all
2492 .iter()
2493 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
2494 .count();
2495 let message = all
2496 .iter()
2497 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
2498 .count();
2499 let entry = all
2500 .iter()
2501 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
2502 .count();
2503 if markdown + message + entry == 0 {
2504 return;
2505 }
2506 eprintln!(
2507 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
2508 markdown, message, entry
2509 );
2510}
2511
2512#[derive(Debug, thiserror::Error)]
2514pub enum BuildError {
2515 #[error("Experimental native session policy: {0}")]
2516 NativeStorePolicy(String),
2517 #[error("Could not create the session directory: {0}")]
2518 SessionDir(String),
2519 #[error("Could not open the saved session: {0}")]
2520 SessionOpen(String),
2521 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
2522 SessionNotFound { requested: String, dir: String },
2523 #[error("Could not build the harness: {0}")]
2524 HarnessCreate(String),
2525}
2526
2527fn build_models_with_extensions(
2530 resolved: &ResolvedModel,
2531 extension_session: &ExtensionSession,
2532 runtime: tokio::runtime::Handle,
2533) -> Vec<Arc<dyn Provider>> {
2534 build_models_with_extensions_for_reload(&resolved.providers, extension_session, runtime)
2535}
2536
2537pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
2540 if let Some(override_value) = args.trust_override {
2541 return override_value;
2542 }
2543 crate::config::project_trust_decision(cwd)
2544 .ok()
2545 .flatten()
2546 .unwrap_or(true)
2547}
2548
2549pub(crate) fn load_extensions(
2557 args: &Args,
2558 cwd: &Path,
2559 project_trusted: bool,
2560 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
2561) -> ExtensionSession {
2562 if args.no_extensions {
2563 return ExtensionSession::none();
2564 }
2565 let mut dirs = if args.dev_local_only {
2566 Vec::new()
2567 } else if args.project_only {
2568 project_extension_dirs(cwd)
2569 } else if project_trusted {
2570 extension_dirs(cwd)
2571 } else {
2572 global_extension_dirs()
2573 };
2574 dirs.extend(args.extensions_dir.iter().cloned());
2575 let leases = match crate::resource_management::host_leases(cwd) {
2576 Ok(leases) => leases,
2577 Err(error) => {
2578 eprintln!("warning: refusing extension loading: {error}");
2579 return ExtensionSession::none();
2580 }
2581 };
2582 load_extensions_from_dirs(args, &dirs, action_bridge, Some(leases))
2583}
2584
2585fn load_extensions_from_dirs(
2586 args: &Args,
2587 dirs: &[PathBuf],
2588 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
2589 host_guard: Option<Arc<dyn Send + Sync>>,
2590) -> ExtensionSession {
2591 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
2592 rpi_extensions::load_session_mixed_guarded(
2599 dirs,
2600 &args.extension,
2601 diagnostics,
2602 action_bridge,
2603 host_guard,
2604 )
2605}
2606
2607fn merge_extension_tools(
2613 tools: &mut Vec<HarnessTool>,
2614 session: &ExtensionSession,
2615 args: &Args,
2616 tool_context: &rpi_extensions::ToolCallContext,
2617) {
2618 let Some(snapshot) = session.snapshot() else {
2619 return;
2620 };
2621 for et in snapshot.tools() {
2622 let name = &et.tool.name;
2623 if !tool_name_allowed(name, args) {
2624 continue;
2625 }
2626 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
2627 .with_context(tool_context.clone());
2628 let harness_tool = HarnessTool::new(Arc::new(adapter));
2629 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
2630 Some(slot) => *slot = harness_tool,
2631 None => tools.push(harness_tool),
2632 }
2633 }
2634}
2635
2636pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
2643 use rpi_tools::tools::bash::BashToolOptions;
2644 let default = std::env::var("RPI_BASH_TIMEOUT")
2645 .ok()
2646 .and_then(|v| v.parse::<f64>().ok())
2647 .unwrap_or(rpi_tools::tools::bash::DEFAULT_BASH_TIMEOUT_SECONDS);
2648 let settings = crate::settings::load_settings().unwrap_or_default();
2654 BashToolOptions {
2655 command_prefix: settings.shell_command_prefix.clone(),
2656 default_timeout: Some(default),
2657 prepare: None,
2658 }
2659}
2660
2661fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
2662 if args.no_tools {
2663 return Vec::new();
2664 }
2665 let mut all: Vec<(&'static str, HarnessTool)> = vec![
2674 (
2675 "read",
2676 HarnessTool::new(create_read_tool(
2677 ctx,
2678 Some(rpi_tools::tools::read::ReadToolOptions {
2679 image_processor: Some(Arc::new(
2680 rpi_tools::image_processing::InlineImageProcessor,
2681 )),
2682 ..Default::default()
2683 }),
2684 ))
2685 .with_replay(ToolReplay::Safe),
2686 ),
2687 (
2688 "bash",
2689 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
2690 ),
2691 ("edit", HarnessTool::new(create_edit_tool(ctx))),
2692 ("write", HarnessTool::new(create_write_tool(ctx))),
2693 (
2694 "docs",
2695 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
2696 ),
2697 ];
2698
2699 #[cfg(target_os = "windows")]
2701 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
2702
2703 if args.tools.is_none() && args.resource_config.is_none() && args.resource_transition.is_none()
2705 {
2706 if let Ok(settings) = crate::settings::load_settings() {
2707 if let Some(default_tools) = &settings.default_tools {
2708 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
2709 }
2710 }
2711 }
2712
2713 if args.no_builtin_tools {
2717 all.clear();
2718 }
2719
2720 if let Some(allow) = &args.tools {
2722 all.retain(|(name, _)| allow.iter().any(|a| a == name));
2723 }
2724 if let Some(deny) = &args.exclude_tools {
2726 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
2727 }
2728
2729 all.into_iter().map(|(_, t)| t).collect()
2730}
2731
2732fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
2736 filter_active_tool_names(
2737 tools.iter().map(|tool| tool.tool.schema().name.clone()),
2738 args,
2739 )
2740}
2741
2742pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
2745 if args.no_tools {
2746 return false;
2747 }
2748 if args
2749 .tools
2750 .as_ref()
2751 .is_some_and(|allow| !allow.iter().any(|value| value == name))
2752 {
2753 return false;
2754 }
2755 if args
2756 .exclude_tools
2757 .as_ref()
2758 .is_some_and(|deny| deny.iter().any(|value| value == name))
2759 {
2760 return false;
2761 }
2762 true
2763}
2764
2765pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
2766where
2767 I: IntoIterator<Item = String>,
2768{
2769 names
2770 .into_iter()
2771 .filter(|name| tool_name_allowed(name, args))
2772 .collect()
2773}
2774
2775async fn build_session(
2777 selection: &SessionSelection,
2778 cwd: &str,
2779 selected: Option<&crate::resource_selection::ResolvedSelection>,
2780) -> Result<Session, BuildError> {
2781 match selection {
2782 SessionSelection::Ephemeral => Ok(ephemeral_session()),
2783 SessionSelection::ExperimentalNative { dir } => Ok(Session::new(
2784 backend::fresh_experimental_native(dir).await?,
2785 None,
2786 )),
2787 SessionSelection::New { dir, .. } => {
2788 std::fs::create_dir_all(dir)
2791 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2792 let session = create_jsonl_session(dir, cwd)
2793 .await
2794 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2795 Ok(session)
2796 }
2797 SessionSelection::Latest { .. }
2798 | SessionSelection::ById { .. }
2799 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2800 SessionSelection::Fork { source, dir } => {
2801 fork_session_at_launch(source, dir, cwd, selected).await
2802 }
2803 }
2804}
2805
2806async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2812 let Some(meta) = resolve_restore_target(selection, cwd).await? else {
2816 return match selection {
2817 SessionSelection::ByExactId { id, dir } => {
2820 std::fs::create_dir_all(dir)
2821 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2822 create_jsonl_session_with_id(dir, cwd, Some(id.clone()))
2823 .await
2824 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2825 }
2826 SessionSelection::Latest { dir } => Err(BuildError::SessionNotFound {
2827 requested: "the most recent session".to_string(),
2828 dir: dir.display().to_string(),
2829 }),
2830 SessionSelection::ById { id, dir } => Err(BuildError::SessionNotFound {
2831 requested: format!("session {id}"),
2832 dir: dir.display().to_string(),
2833 }),
2834 _ => unreachable!("resolve_restore_target only returns None for restore selections"),
2835 };
2836 };
2837 let sessions_root = selection_dir(selection);
2838 let writer_restore = StartupSpan::start(Phase::WriterRestore);
2839 let result = open_session(&meta, cwd, &sessions_root)
2840 .await
2841 .map(|restored| restored.session);
2842 if result.is_ok() {
2843 writer_restore.finish();
2844 }
2845 result
2846}
2847
2848fn selection_dir(selection: &SessionSelection) -> PathBuf {
2850 match selection {
2851 SessionSelection::Ephemeral => PathBuf::new(),
2852 SessionSelection::New { dir, .. }
2853 | SessionSelection::ExperimentalNative { dir }
2854 | SessionSelection::Latest { dir }
2855 | SessionSelection::ById { dir, .. }
2856 | SessionSelection::ByExactId { dir, .. }
2857 | SessionSelection::Fork { dir, .. } => dir.clone(),
2858 }
2859}
2860
2861async fn resolve_restore_target(
2868 selection: &SessionSelection,
2869 cwd: &str,
2870) -> Result<Option<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2871 match selection {
2872 SessionSelection::Latest { dir } => {
2873 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2874 Ok(first_resumable_session(&metas).cloned())
2875 }
2876 SessionSelection::ById { id, dir } => find_session_metadata(id, cwd, dir).await,
2877 SessionSelection::ByExactId { id, dir } => {
2878 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2879 Ok(metas.into_iter().find(|m| m.id == *id))
2880 }
2881 SessionSelection::Fork { .. }
2882 | SessionSelection::ExperimentalNative { .. }
2883 | SessionSelection::New { .. }
2884 | SessionSelection::Ephemeral => Ok(None),
2885 }
2886}
2887
2888async fn find_session_metadata(
2892 id: &str,
2893 cwd: &str,
2894 dir: &Path,
2895) -> Result<Option<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2896 let metas = list_session_metadata_in_dir(cwd, dir).await?;
2897 Ok(metas
2898 .into_iter()
2899 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id)))
2900}
2901
2902pub(crate) struct RestoredSessionCwd {
2904 pub id: String,
2910 pub recorded: PathBuf,
2912 pub adopted: Option<PathBuf>,
2915}
2916
2917pub(crate) async fn peek_restored_session_cwd(
2925 selection: &SessionSelection,
2926 launch_cwd: &Path,
2927) -> Option<RestoredSessionCwd> {
2928 let cwd_str = launch_cwd.to_string_lossy().into_owned();
2929 let meta = resolve_restore_target(selection, &cwd_str).await.ok()??;
2930 let recorded = PathBuf::from(&meta.cwd);
2931 if recorded.as_os_str().is_empty() {
2932 return None;
2933 }
2934 Some(RestoredSessionCwd {
2935 id: meta.id,
2936 adopted: crate::session_cwd::resolve_session_cwd(&recorded),
2937 recorded,
2938 })
2939}
2940
2941pub(crate) fn pin_restored_selection(args: &mut Args, restored: &RestoredSessionCwd) {
2951 args.continue_session = false;
2952 args.resume = false;
2953 args.session = Some(restored.id.clone());
2954}
2955
2956async fn fork_session_at_launch(
2959 source: &str,
2960 dir: &Path,
2961 cwd: &str,
2962 selected: Option<&crate::resource_selection::ResolvedSelection>,
2963) -> Result<Session, BuildError> {
2964 use rpi_harness::session::jsonl::{
2965 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2966 };
2967 use rpi_harness::session::types::{ForkOptions, SessionStorage};
2968 use rpi_tools::FileSystem;
2969
2970 let dir = dir.to_path_buf();
2971 std::fs::create_dir_all(&dir)
2972 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2973 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2974 let fs: Arc<dyn FileSystem> = env.clone();
2975 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2976 fs: fs.clone(),
2977 sessions_root: dir.to_string_lossy().into_owned(),
2978 clock: Arc::new(SystemClock),
2979 ids: Arc::new(DefaultIdGenerator::new()),
2980 });
2981 let source_meta = match session_metadata_at(Path::new(source)) {
2985 Some(meta) => meta,
2986 None => {
2987 let metas = repo
2988 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2989 .await
2990 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2991 metas
2992 .into_iter()
2993 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2994 .ok_or_else(|| BuildError::SessionNotFound {
2995 requested: format!("--fork {source}"),
2996 dir: dir.display().to_string(),
2997 })?
2998 }
2999 };
3000 let fork_storage = repo
3001 .fork_typed_with_value_initializer(
3002 &source_meta,
3003 &JsonlSessionCreateOptions {
3004 id: None,
3005 parent_session_id: Some(source_meta.id.clone()),
3006 cwd: cwd.to_string(),
3007 metadata: None,
3008 },
3009 &ForkOptions::default(),
3010 |values| crate::resource_selection::fork_initial_values_for_selection(values, selected),
3011 )
3012 .await
3013 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
3014 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
3015 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
3016 Ok(Session::new(storage_arc, None))
3017}
3018
3019fn session_metadata_at(path: &Path) -> Option<rpi_harness::session::jsonl::JsonlSessionMetadata> {
3022 use rpi_harness::session::jsonl::codec::{metadata_from_header, parse_header};
3023 use std::io::BufRead;
3024
3025 let file = std::fs::File::open(path).ok()?;
3026 let modified_at = file
3027 .metadata()
3028 .ok()?
3029 .modified()
3030 .ok()?
3031 .duration_since(std::time::UNIX_EPOCH)
3032 .ok()?
3033 .as_millis() as i64;
3034 let mut first_line = String::new();
3035 std::io::BufReader::new(file)
3036 .read_line(&mut first_line)
3037 .ok()?;
3038 let header = parse_header(first_line.trim_end()).ok()?;
3039 let path = std::fs::canonicalize(path).ok()?;
3040 Some(metadata_from_header(
3041 &header,
3042 &path.to_string_lossy(),
3043 modified_at,
3044 ))
3045}
3046
3047pub enum OpenError {
3050 NotFound { requested: String },
3052 Other(String),
3054}
3055
3056impl std::fmt::Display for OpenError {
3057 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3058 match self {
3059 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
3060 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
3065 }
3066 }
3067}
3068
3069pub async fn list_session_metadata(
3072 cwd: &str,
3073) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
3074 let dir = default_session_dir(Path::new(cwd));
3075 list_session_metadata_in_dir(cwd, &dir).await
3076}
3077
3078pub(crate) async fn list_session_metadata_in_dir(
3079 cwd: &str,
3080 dir: &Path,
3081) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
3082 use rpi_harness::session::jsonl::{
3083 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
3084 };
3085 use rpi_tools::FileSystem;
3086
3087 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3088 let fs: Arc<dyn FileSystem> = env.clone();
3089 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3090 fs: fs.clone(),
3091 sessions_root: dir.to_string_lossy().into_owned(),
3092 clock: Arc::new(SystemClock),
3093 ids: Arc::new(DefaultIdGenerator::new()),
3094 });
3095 let metadata_lookup = StartupSpan::start(Phase::MetadataLookup);
3096 let result = repo
3097 .list_typed(&JsonlSessionListOptions::default())
3098 .await
3099 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")));
3100 if result.is_ok() {
3101 metadata_lookup.finish();
3102 }
3103 result
3104}
3105
3106#[derive(Debug, Clone, Default, PartialEq, Eq)]
3122pub struct SessionSummary {
3123 pub name: Option<String>,
3125 pub preview: Option<String>,
3127 pub empty: bool,
3129 pub bytes: u64,
3131}
3132
3133const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
3135const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
3137const SUMMARY_PREVIEW_CHARS: usize = 80;
3139
3140pub fn summarize_session_file(path: &Path) -> SessionSummary {
3144 use std::io::{Read, Seek, SeekFrom};
3145
3146 let Ok(mut file) = std::fs::File::open(path) else {
3147 return SessionSummary::default();
3148 };
3149 let Ok(size) = file.metadata().map(|m| m.len()) else {
3150 return SessionSummary::default();
3151 };
3152 if size == 0 {
3153 return SessionSummary {
3154 name: None,
3155 preview: None,
3156 empty: true,
3157 bytes: 0,
3158 };
3159 }
3160
3161 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
3164 let head_len = file.read(&mut head).unwrap_or(0);
3165 head.truncate(head_len);
3166 let head = String::from_utf8_lossy(&head);
3167 let mut lines: Vec<&str> = head.lines().collect();
3168 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
3169 let empty = (size as usize) <= header_len + 1;
3171 if (head_len as u64) < size && !lines.is_empty() {
3174 lines.pop();
3175 }
3176
3177 let mut summary = SessionSummary {
3178 name: None,
3179 preview: None,
3180 empty,
3181 bytes: size,
3182 };
3183 for line in lines {
3184 apply_summary_line(line, &mut summary);
3185 }
3186
3187 if size > SUMMARY_HEAD_BYTES
3191 && file
3192 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
3193 .is_ok()
3194 {
3195 let mut tail = Vec::new();
3196 if file.read_to_end(&mut tail).is_ok() {
3197 let tail = String::from_utf8_lossy(&tail);
3198 let mut tail_lines = tail.lines();
3199 tail_lines.next();
3201 for line in tail_lines {
3202 if let Some(name) = name_from_line(line) {
3203 summary.name = name;
3204 }
3205 }
3206 }
3207 }
3208 summary
3209}
3210
3211fn name_from_line(line: &str) -> Option<Option<String>> {
3214 let value: serde_json::Value = serde_json::from_str(line).ok()?;
3215 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
3216 return None;
3217 }
3218 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
3219 return None;
3220 }
3221 Some(match value.get("name") {
3222 Some(serde_json::Value::String(name)) => Some(name.clone()),
3223 _ => None,
3224 })
3225}
3226
3227fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
3230 if let Some(name) = name_from_line(line) {
3231 summary.name = name;
3232 return;
3233 }
3234 if summary.preview.is_some() {
3235 return;
3236 }
3237 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
3238 return;
3239 };
3240 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
3241 return;
3242 }
3243 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
3244 return;
3245 }
3246 let Some(message) = value.get("message") else {
3247 return;
3248 };
3249 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
3250 return;
3251 }
3252 let text = match message.get("content") {
3253 Some(serde_json::Value::String(text)) => Some(text.as_str()),
3254 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
3255 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
3256 .then(|| block.get("text").and_then(|v| v.as_str()))
3257 .flatten()
3258 }),
3259 _ => None,
3260 };
3261 let Some(text) = text else { return };
3262 let collapsed = text
3263 .lines()
3264 .map(str::trim)
3265 .filter(|line| !line.is_empty())
3266 .collect::<Vec<_>>()
3267 .join(" ");
3268 if collapsed.is_empty() {
3269 return;
3270 }
3271 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
3272}
3273
3274pub fn session_display_label(summary: &SessionSummary) -> String {
3278 if let Some(name) = summary.name.as_deref().map(str::trim) {
3279 if !name.is_empty() {
3280 return name.to_string();
3281 }
3282 }
3283 match summary.preview.as_deref().map(str::trim) {
3284 Some(preview) if !preview.is_empty() => preview.to_string(),
3285 _ => "(no messages)".to_string(),
3286 }
3287}
3288
3289pub fn format_session_bytes(bytes: u64) -> String {
3292 if bytes >= 1_048_576 {
3293 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
3294 } else if bytes >= 1024 {
3295 format!("{}KB", bytes / 1024)
3296 } else {
3297 format!("{bytes}B")
3298 }
3299}
3300
3301pub fn short_session_id(id: &str) -> &str {
3304 &id[..id.len().min(8)]
3305}
3306
3307pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
3316 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
3317 let mut visit = |path: PathBuf| {
3318 if path.extension().and_then(|e| e.to_str()) != Some("jsonl")
3319 || path.to_string_lossy().ends_with(".progress.jsonl")
3320 {
3321 return;
3322 }
3323 let modified = std::fs::metadata(&path)
3324 .and_then(|m| m.modified())
3325 .unwrap_or(std::time::UNIX_EPOCH);
3326 files.push((path, modified));
3327 };
3328 if let Ok(entries) = std::fs::read_dir(dir) {
3329 for entry in entries.flatten() {
3330 let path = entry.path();
3331 if path.is_dir() {
3332 if let Ok(inner) = std::fs::read_dir(&path) {
3333 for child in inner.flatten() {
3334 visit(child.path());
3335 }
3336 }
3337 } else {
3338 visit(path);
3339 }
3340 }
3341 }
3342 files.sort_by(|a, b| b.1.cmp(&a.1));
3343 files.into_iter().map(|(path, _)| path).collect()
3344}
3345
3346fn first_resumable_session(
3351 metas: &[rpi_harness::session::jsonl::JsonlSessionMetadata],
3352) -> Option<&rpi_harness::session::jsonl::JsonlSessionMetadata> {
3353 metas
3354 .iter()
3355 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
3356 .or_else(|| metas.first())
3357}
3358
3359pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
3363 let dir = default_session_dir(Path::new(cwd));
3364 open_session_by_id_in_dir(id, cwd, &dir)
3365 .await
3366 .map(|restored| restored.session)
3367}
3368
3369pub(crate) async fn open_session_by_id_in_dir(
3370 id: &str,
3371 cwd: &str,
3372 dir: &Path,
3373) -> Result<RestoredSession, OpenError> {
3374 let meta = find_session_metadata(id, cwd, dir)
3375 .await
3376 .map_err(|e| OpenError::Other(e.to_string()))?;
3377 let Some(meta) = meta else {
3378 return Err(OpenError::NotFound {
3379 requested: format!("session {id}"),
3380 });
3381 };
3382 open_session(&meta, cwd, dir).await.map_err(|e| {
3383 OpenError::Other(match e {
3384 BuildError::SessionOpen(reason) => reason,
3388 other => other.to_string(),
3389 })
3390 })
3391}
3392
3393fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
3418 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
3419 tool_context.set_session_id(id);
3420}
3421
3422pub(crate) async fn set_active_session(
3433 harness: &AgentHarness,
3434 session: Session,
3435 tool_context: Option<&rpi_extensions::ToolCallContext>,
3436) -> Result<(), String> {
3437 let metadata = session.get_metadata().await.ok();
3438 harness
3439 .set_session(session)
3440 .await
3441 .map_err(|e| e.to_string())?;
3442 if let Some(metadata) = metadata {
3443 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
3444 if let Some(context) = tool_context {
3445 context.set_session_id(metadata.id);
3446 }
3447 }
3448 Ok(())
3449}
3450
3451pub(crate) async fn fork_session_storage(
3458 harness: &AgentHarness,
3459 cwd: &str,
3460 dir: &Path,
3461) -> Result<Session, String> {
3462 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3463 use rpi_tools::FileSystem;
3464
3465 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3466 let fs: Arc<dyn FileSystem> = env.clone();
3467 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3468 fs,
3469 sessions_root: dir.to_string_lossy().into_owned(),
3470 clock: Arc::new(SystemClock),
3471 ids: Arc::new(DefaultIdGenerator::new()),
3472 });
3473 let id = harness.session().storage().metadata().id.clone();
3476 let metas = list_session_metadata_in_dir(cwd, dir)
3477 .await
3478 .map_err(|e| e.to_string())?;
3479 let Some(source) = metas.iter().find(|m| m.id == id) else {
3480 return Err(format!("current session {id} not found on disk"));
3481 };
3482 let fork_storage = repo
3483 .fork_typed_with_value_initializer(
3484 source,
3485 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
3486 id: None,
3487 parent_session_id: Some(source.id.clone()),
3488 cwd: cwd.to_string(),
3489 metadata: None,
3490 },
3491 &rpi_harness::session::types::ForkOptions::default(),
3492 crate::resource_selection::fork_initial_values,
3493 )
3494 .await
3495 .map_err(|e| e.to_string())?;
3496 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
3497 Ok(Session::new(Arc::new(fork_storage), None))
3498}
3499
3500pub(crate) async fn import_session_file(
3504 source: &Path,
3505 cwd: &str,
3506 dir: &Path,
3507) -> Result<Session, String> {
3508 use rpi_harness::session::jsonl::{
3509 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions, JsonlSessionStorage,
3510 };
3511 use rpi_harness::session::types::SessionStorage;
3512 use rpi_tools::FileSystem;
3513
3514 let dir = if dir.is_absolute() {
3515 dir.to_path_buf()
3516 } else {
3517 Path::new(cwd).join(dir)
3518 };
3519 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
3520 let source_progress = PathBuf::from(format!("{}.progress.jsonl", source.display()));
3524 let source_lock_path = PathBuf::from(format!("{}.lock", source.display()));
3525 let canonical_lock = std::fs::canonicalize(&source_lock_path).ok();
3526 let owned_here = SESSION_LOCK_PATHS.get().is_some_and(|paths| {
3527 paths.lock().unwrap().iter().any(|path| {
3528 path == &source_lock_path
3529 || canonical_lock.as_ref().is_some_and(|canonical| {
3530 std::fs::canonicalize(path).ok().as_ref() == Some(canonical)
3531 })
3532 })
3533 });
3534 let _source_lock = if !owned_here && source_progress.try_exists().map_err(|e| e.to_string())? {
3535 let lock = std::fs::OpenOptions::new()
3536 .create(true)
3537 .write(true)
3538 .open(format!("{}.lock", source.display()))
3539 .map_err(|e| format!("lock import source: {e}"))?;
3540 fs2::FileExt::try_lock_exclusive(&lock).map_err(|_| {
3541 "close the source session before importing its recovery pair".to_owned()
3542 })?;
3543 Some(lock)
3544 } else {
3545 None
3546 };
3547 let staging = tempfile::tempdir_in(&dir).map_err(|e| e.to_string())?;
3548 let staged = staging.path().join("history.stage");
3549 let staged_progress = PathBuf::from(format!("{}.progress.jsonl", staged.display()));
3550 let mut attempts = 0;
3551 let has_progress = loop {
3552 std::fs::copy(source, &staged).map_err(|e| format!("copy import: {e}"))?;
3553 let copied_history = std::fs::read(&staged).map_err(|e| e.to_string())?;
3554 let has_progress = match std::fs::copy(&source_progress, &staged_progress) {
3555 Ok(_) => true,
3556 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3557 let _ = std::fs::remove_file(&staged_progress);
3558 false
3559 }
3560 Err(error) => return Err(format!("copy progress import: {error}")),
3561 };
3562 if copied_history == std::fs::read(source).map_err(|e| e.to_string())? {
3563 break has_progress;
3564 }
3565 attempts += 1;
3566 if attempts >= 4 {
3567 return Err(
3568 "source history changed during import; retry when the session is idle".into(),
3569 );
3570 }
3571 tokio::task::yield_now().await;
3572 };
3573 let fs: Arc<dyn FileSystem> = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3574 let validated = JsonlSessionStorage::load(
3575 fs.clone(),
3576 &staged.to_string_lossy(),
3577 Arc::new(SystemClock),
3578 Arc::new(DefaultIdGenerator::new()),
3579 )
3580 .await
3581 .map_err(|e| format!("invalid session import: {e}"))?;
3582 validated
3583 .read_progress("")
3584 .await
3585 .map_err(|e| format!("invalid recovery import: {e}"))?;
3586 let mut metadata = validated.jsonl_metadata().clone();
3587 drop(validated);
3588 let text = std::fs::read_to_string(&staged).map_err(|e| e.to_string())?;
3589 let (first, rest) = text
3590 .split_once('\n')
3591 .ok_or_else(|| "import has no complete header".to_owned())?;
3592 let mut header =
3593 rpi_harness::session::jsonl::codec::parse_header(first).map_err(|e| e.to_string())?;
3594 if has_progress && header.progress_format.is_none() {
3595 return Err("history-only artifact has an undeclared recovery companion".into());
3596 }
3597 header.progress_format = Some("assistant-progress-v1".into());
3600 std::fs::write(
3601 &staged,
3602 format!(
3603 "{}{}",
3604 rpi_harness::session::jsonl::codec::encode_header(&header),
3605 rest
3606 ),
3607 )
3608 .map_err(|e| e.to_string())?;
3609 if list_session_metadata_in_dir(cwd, &dir)
3610 .await
3611 .map_err(|e| e.to_string())?
3612 .iter()
3613 .any(|saved| saved.id == metadata.id)
3614 {
3615 return Err(format!(
3616 "session {} already exists in {}",
3617 metadata.id,
3618 dir.display()
3619 ));
3620 }
3621 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3622 fs,
3623 sessions_root: dir.to_string_lossy().into_owned(),
3624 clock: Arc::new(SystemClock),
3625 ids: Arc::new(DefaultIdGenerator::new()),
3626 });
3627 let destination = repo
3629 .create_typed(&JsonlSessionCreateOptions {
3630 id: Some(metadata.id.clone()),
3631 parent_session_id: metadata.parent_session_id.clone(),
3632 cwd: cwd.to_string(),
3633 metadata: metadata.metadata.clone(),
3634 })
3635 .await
3636 .map_err(|e| e.to_string())?;
3637 let path = destination.jsonl_metadata().path.clone();
3638 drop(destination);
3639 if let Err(error) = claim_session_lock(&path) {
3640 return Err(error);
3642 }
3643 let destination_progress = format!("{path}.progress.jsonl");
3644 let publish = (|| -> std::io::Result<()> {
3645 if has_progress {
3646 std::fs::rename(&staged_progress, &destination_progress)?;
3647 }
3648 std::fs::rename(&staged, &path)
3649 })();
3650 if let Err(error) = publish {
3651 let _ = std::fs::remove_file(&path);
3652 let _ = std::fs::remove_file(&destination_progress);
3653 return Err(format!("publish import: {error}"));
3654 }
3655 metadata.path = path;
3656 let storage = repo
3657 .open_by_jsonl_metadata(&metadata)
3658 .await
3659 .map_err(|e| e.to_string())?;
3660 Ok(Session::new(Arc::new(storage), None))
3661}
3662
3663static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
3665static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
3668
3669fn claim_session_lock(session_path: &str) -> Result<(), String> {
3678 claim_session_lock_inner(session_path, true)
3679}
3680fn claim_session_lock_inner(session_path: &str, allow_reentry: bool) -> Result<(), String> {
3681 let canonical =
3682 std::fs::canonicalize(session_path).unwrap_or_else(|_| PathBuf::from(session_path));
3683 let lock_path = PathBuf::from(format!("{}.lock", canonical.display()));
3684 let mut paths = SESSION_LOCK_PATHS
3685 .get_or_init(|| Mutex::new(HashSet::new()))
3686 .lock()
3687 .unwrap();
3688 if paths.contains(&lock_path) {
3689 return if allow_reentry {
3690 Ok(())
3691 } else {
3692 Err("transition session already has a writer in this process".into())
3693 };
3694 }
3695 let file = std::fs::OpenOptions::new()
3696 .create(true)
3697 .write(true)
3698 .open(&lock_path)
3699 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
3700 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
3701 return Err(format!(
3702 "session {session_path} is already open in another rpi process"
3703 ));
3704 }
3705 SESSION_LOCKS
3706 .get_or_init(|| Mutex::new(Vec::new()))
3707 .lock()
3708 .unwrap()
3709 .push(file);
3710 paths.insert(lock_path);
3711 Ok(())
3712}
3713
3714pub(crate) struct RestoredSession {
3723 pub session: Session,
3724 pub recorded_cwd: Option<PathBuf>,
3727}
3728
3729async fn open_session(
3736 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
3737 cwd: &str,
3738 sessions_root: &Path,
3739) -> Result<RestoredSession, BuildError> {
3740 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3741 use rpi_harness::session::types::SessionStorage;
3742 use rpi_tools::FileSystem;
3743
3744 let dir = sessions_root;
3745 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3746 let fs: Arc<dyn FileSystem> = env.clone();
3747 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3748 fs: fs.clone(),
3749 sessions_root: dir.to_string_lossy().into_owned(),
3750 clock: Arc::new(SystemClock),
3751 ids: Arc::new(DefaultIdGenerator::new()),
3752 });
3753 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
3754 let storage = repo
3755 .open_by_jsonl_metadata(meta)
3756 .await
3757 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
3758 let storage = storage
3761 .enable_progress_under_exclusive_lock()
3762 .await
3763 .map_err(|e| BuildError::SessionOpen(format!("upgrade {}: {e}", meta.path)))?;
3764 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
3765 Ok(RestoredSession {
3766 session: Session::new(storage_arc, None),
3767 recorded_cwd: crate::session_cwd::resolve_session_cwd(Path::new(&meta.cwd)),
3768 })
3769}
3770
3771pub(crate) fn ephemeral_session() -> Session {
3775 let storage = Arc::new(InMemorySessionStorage::new(
3776 SessionMetadata {
3777 id: "ephemeral".into(),
3778 created_at: 0,
3779 parent_session_id: None,
3780 },
3781 Arc::new(SystemClock),
3782 Arc::new(DefaultIdGenerator::new()),
3783 ));
3784 Session::new(storage, None).with_volatile_progress()
3785}
3786
3787pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
3798 create_jsonl_session_with_id(dir, cwd, None).await
3799}
3800
3801pub(crate) async fn create_jsonl_session_with_id(
3805 dir: &Path,
3806 cwd: &str,
3807 id: Option<String>,
3808) -> Result<Session, String> {
3809 use rpi_harness::session::jsonl::{
3810 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
3811 };
3812 use rpi_tools::FileSystem;
3813
3814 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3817 let fs: Arc<dyn FileSystem> = env.clone();
3818
3819 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3820 fs: fs.clone(),
3821 sessions_root: dir.to_string_lossy().into_owned(),
3822 clock: Arc::new(SystemClock),
3823 ids: Arc::new(DefaultIdGenerator::new()),
3824 });
3825
3826 let opts = JsonlSessionCreateOptions {
3827 id, parent_session_id: None,
3829 cwd: cwd.to_string(),
3830 metadata: None,
3831 };
3832 let storage = repo
3833 .create_typed(&opts)
3834 .await
3835 .map_err(|e| format!("create session: {e}"))?;
3836 claim_session_lock(&storage.jsonl_metadata().path)
3837 .map_err(|e| format!("create session: {e}"))?;
3838 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
3840 Ok(Session::new(storage_arc, None))
3841}
3842
3843fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
3846 let path = Path::new(target);
3847 if path.is_file() {
3848 read_prompt_source(path, sources)
3849 } else {
3850 None
3851 }
3852}
3853
3854fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
3857 let text = std::fs::read_to_string(path).ok()?;
3858 let absolute = if path.is_absolute() {
3859 path.to_path_buf()
3860 } else {
3861 std::env::current_dir()
3862 .map(|cwd| cwd.join(path))
3863 .unwrap_or_else(|_| path.to_path_buf())
3864 };
3865 let source = absolute.to_string_lossy().into_owned();
3866 if !sources.contains(&source) {
3867 sources.push(source);
3868 }
3869 Some(text)
3870}
3871
3872#[cfg(test)]
3873mod goal_outbox_smoke_tests;
3874#[cfg(all(test, feature = "experimental-native-store"))]
3875mod native_roundtrip_tests;
3876#[cfg(all(test, feature = "experimental-native-store"))]
3877mod native_store_tests;
3878#[cfg(test)]
3879mod runtime_session_tests;
3880
3881#[cfg(test)]
3882mod tests {
3883 use super::*;
3884
3885 #[test]
3886 fn settlement_bridge_follows_reload_and_releases_its_session_with_the_bus() {
3887 use rpi_harness::events::{HarnessEvent, HarnessEventBus, RunEndEvent, RunEndOutcome};
3888 use std::sync::atomic::{AtomicUsize, Ordering};
3889 static OLD: AtomicUsize = AtomicUsize::new(0);
3890 static NEW: AtomicUsize = AtomicUsize::new(0);
3891 extern "C" fn old(_: rpi_plugin_sdk::StablePluginEvent, _: *mut std::ffi::c_void) -> i32 {
3892 OLD.fetch_add(1, Ordering::SeqCst);
3893 0
3894 }
3895 extern "C" fn new(_: rpi_plugin_sdk::StablePluginEvent, _: *mut std::ffi::c_void) -> i32 {
3896 NEW.fetch_add(1, Ordering::SeqCst);
3897 0
3898 }
3899 let make = |handler: rpi_plugin_sdk::EventHandlerFn| {
3900 let mut registry = rpi_extensions::ExtensionRegistry::new();
3901 registry.register_event_handler(
3902 "settlement".into(),
3903 rpi_plugin_sdk::EventTag::AgentSettled,
3904 handler,
3905 std::ptr::null_mut(),
3906 );
3907 ExtensionSession::from_parts(
3908 Arc::new(registry.snapshot()),
3909 rpi_extensions::PluginKeepalive::empty(),
3910 vec![],
3911 None,
3912 )
3913 };
3914 let sessions = Arc::new(Mutex::new(make(old)));
3915 let weak = Arc::downgrade(&sessions);
3916 let bus = HarnessEventBus::new();
3917 install_extension_settlement_bridge(&bus, sessions.clone());
3918 let ended = |outcome| {
3919 HarnessEvent::RunEnd(RunEndEvent {
3920 lane: "main".into(),
3921 run_id: "run".into(),
3922 outcome,
3923 leaf_id: "leaf".into(),
3924 })
3925 };
3926 bus.emit(&ended(RunEndOutcome::Completed));
3927 assert_eq!(OLD.load(Ordering::SeqCst), 1);
3928 *sessions.lock().unwrap() = make(new);
3929 for outcome in [RunEndOutcome::Aborted, RunEndOutcome::Failed] {
3930 bus.emit(&ended(outcome));
3931 }
3932 assert_eq!(OLD.load(Ordering::SeqCst), 1);
3933 assert_eq!(NEW.load(Ordering::SeqCst), 2);
3934 drop(sessions);
3935 assert!(weak.upgrade().is_some());
3936 drop(bus);
3937 assert!(
3938 weak.upgrade().is_none(),
3939 "settlement observer must not create an ownership cycle"
3940 );
3941 }
3942
3943 #[tokio::test]
3944 async fn unauthenticated_packages_are_not_reintroduced_as_legacy_adapters() {
3945 extern "C" fn request(
3946 _: rpi_plugin_sdk::StbStringRef,
3947 _: *mut rpi_plugin_sdk::StbString,
3948 _: *mut std::ffi::c_void,
3949 ) -> i32 {
3950 1
3951 }
3952 let mut registry = rpi_extensions::ExtensionRegistry::new();
3953 for id in ["package", "legacy"] {
3954 registry.register_provider(rpi_extensions::RegisteredProvider {
3955 provider_id: id.into(),
3956 base_url: "http://localhost".into(),
3957 api_style: "openai-completions".into(),
3958 request_fn: request,
3959 stream_fn: None,
3960 plugin_free_string: rpi_plugin_sdk::callbacks::free_string,
3961 user_data: std::ptr::null_mut(),
3962 });
3963 }
3964 assert!(registry.apply_declaration_json(&serde_json::json!({"providers":[{
3965 "id":"package", "requiresAuth":true,
3966 "models":[rpi_ai::Model::new("model", "Model", rpi_ai::Api::OpenaiCompletions, "package", "http://localhost")]
3967 }]}).to_string()));
3968 let session = ExtensionSession::from_parts(
3969 Arc::new(registry.snapshot()),
3970 rpi_extensions::PluginKeepalive::empty(),
3971 vec![],
3972 None,
3973 );
3974 let providers = build_models_with_extensions_for_reload(
3975 &[],
3976 &session,
3977 tokio::runtime::Handle::current(),
3978 );
3979 assert_eq!(
3980 providers
3981 .iter()
3982 .map(|provider| provider.id())
3983 .collect::<Vec<_>>(),
3984 ["legacy"]
3985 );
3986 }
3987 use crate::args::Args;
3988
3989 #[test]
3990 fn loaded_context_paths_track_precedence_and_reload() {
3991 const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
3992 if std::env::var_os(CHILD).is_none() {
3993 let dir = tempfile::tempdir().unwrap();
3994 let output = std::process::Command::new(std::env::current_exe().unwrap())
3995 .args([
3996 "--exact",
3997 "session::tests::loaded_context_paths_track_precedence_and_reload",
3998 "--nocapture",
3999 ])
4000 .env(CHILD, "1")
4001 .env(crate::config::CONFIG_DIR_ENV, dir.path())
4002 .output()
4003 .unwrap();
4004 assert!(
4005 output.status.success(),
4006 "{}\n{}",
4007 String::from_utf8_lossy(&output.stdout),
4008 String::from_utf8_lossy(&output.stderr)
4009 );
4010 return;
4011 }
4012 tokio::runtime::Builder::new_current_thread()
4013 .enable_all()
4014 .build()
4015 .unwrap()
4016 .block_on(async {
4017 let agent = crate::config::agent_dir().unwrap();
4018 let cwd = agent.join("project");
4019 let project_config = cwd.join(".rpi");
4020 std::fs::create_dir_all(&project_config).unwrap();
4021 for (path, content) in [
4022 (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
4023 (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
4024 (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
4025 (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
4026 (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
4027 (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
4028 (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
4029 ] {
4030 std::fs::write(path, content).unwrap();
4031 }
4032 let resolved = crate::provider::resolve(
4033 Some("anthropic"),
4034 Some(crate::provider::DEFAULT_MODEL_ID),
4035 None,
4036 Some("test-key"),
4037 None,
4038 )
4039 .unwrap();
4040 let args = Args {
4041 no_session: true,
4042 no_extensions: true,
4043 no_skills: true,
4044 no_prompt_templates: true,
4045 trust_override: Some(true),
4046 ..Default::default()
4047 };
4048 let (harness, _events, mut context) =
4049 build(&resolved, &args, &cwd, true).await.unwrap();
4050 let resources = harness.get_resources().await.unwrap();
4051 let path = |p: PathBuf| p.to_string_lossy().into_owned();
4052 assert_eq!(
4053 &resources.context_files[..2],
4054 &[
4055 path(project_config.join("SYSTEM.md")),
4056 path(project_config.join("APPEND_SYSTEM.md")),
4057 ]
4058 );
4059 assert!(resources
4060 .context_files
4061 .contains(&path(agent.join("AGENTS.md"))));
4062 assert!(resources
4063 .context_files
4064 .contains(&path(cwd.join("AGENTS.md"))));
4065 assert!(!resources
4066 .context_files
4067 .contains(&path(cwd.join("CLAUDE.md"))));
4068 assert!(!resources
4069 .context_files
4070 .contains(&path(agent.join("SYSTEM.md"))));
4071 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
4072 assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
4073 assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
4074
4075 std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
4078 std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
4079 std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
4080 assert!(
4081 !reload_extension_resources(&harness, &context)
4082 .await
4083 .had_errors
4084 );
4085 let resources = harness.get_resources().await.unwrap();
4086 assert_eq!(
4087 &resources.context_files[..2],
4088 &[
4089 path(agent.join("SYSTEM.md")),
4090 path(agent.join("APPEND_SYSTEM.md")),
4091 ]
4092 );
4093 assert!(resources
4094 .context_files
4095 .contains(&path(cwd.join("CLAUDE.md"))));
4096 assert!(!resources
4097 .context_files
4098 .contains(&path(project_config.join("SYSTEM.md"))));
4099
4100 let explicit = agent.join("custom-append.md");
4103 std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
4104 context.args.system_prompt = Some("EXPLICIT_BASE".into());
4105 context.args.append_system_prompt =
4106 vec![path(explicit.clone()), "literal append".into()];
4107 context.args.no_context_files = true;
4108 assert!(
4109 !reload_extension_resources(&harness, &context)
4110 .await
4111 .had_errors
4112 );
4113 assert_eq!(
4114 harness.get_resources().await.unwrap().context_files,
4115 vec![path(explicit)]
4116 );
4117
4118 context.args.system_prompt = None;
4120 context.args.append_system_prompt.clear();
4121 context.args.no_context_files = false;
4122 context.args.trust_override = Some(false);
4123 assert!(
4124 !reload_extension_resources(&harness, &context)
4125 .await
4126 .had_errors
4127 );
4128 assert!(harness
4129 .get_resources()
4130 .await
4131 .unwrap()
4132 .context_files
4133 .is_empty());
4134 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
4135 assert!(
4136 !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
4137 );
4138 });
4139 }
4140
4141 #[test]
4142 fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
4143 let dir = tempfile::tempdir().unwrap();
4144 let file = dir.path().join("SYSTEM.md");
4145 let mut sources = Vec::new();
4146 assert!(read_prompt_source(&file, &mut sources).is_none());
4147 std::fs::write(&file, [0xff, 0xfe]).unwrap();
4148 assert!(read_prompt_source(&file, &mut sources).is_none());
4149 assert!(read_append_target("literal instructions", &mut sources).is_none());
4150 assert!(sources.is_empty());
4151 std::fs::write(&file, "loaded instructions").unwrap();
4152 assert_eq!(
4153 read_prompt_source(&file, &mut sources).as_deref(),
4154 Some("loaded instructions")
4155 );
4156 assert!(read_prompt_source(&file, &mut sources).is_some());
4157 assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
4158 }
4159
4160 extern "C" fn deny_test_tool_call(
4161 _event: rpi_plugin_sdk::StablePluginEvent,
4162 _data: *mut std::ffi::c_void,
4163 ) -> i32 {
4164 rpi_plugin_sdk::EVENT_HANDLER_ABORT
4165 }
4166
4167 async fn invoke_test_tool_hook(
4168 hook: &rpi_agent::BeforeToolCall,
4169 ) -> Option<rpi_agent::BeforeToolCallResult> {
4170 let message = rpi_ai::providers::faux::faux_assistant_message(
4171 "approval",
4172 rpi_ai::types::StopReason::ToolUse,
4173 );
4174 let rpi_ai::types::Content::ToolCall(call) =
4175 rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
4176 else {
4177 unreachable!()
4178 };
4179 hook(
4180 rpi_agent::BeforeToolCallContext {
4181 assistant_message: &message,
4182 tool_call: &call,
4183 args: &call.arguments,
4184 context: &rpi_agent::AgentContext::default(),
4185 },
4186 tokio_util::sync::CancellationToken::new(),
4187 )
4188 .await
4189 }
4190
4191 #[tokio::test]
4192 async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
4193 let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
4194 let hook =
4195 extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
4196 assert!(invoke_test_tool_hook(&hook).await.is_none());
4197 for name in ["first-gate", "reloaded-gate"] {
4198 let mut registry = rpi_extensions::ExtensionRegistry::new();
4199 registry.register_event_handler(
4200 name.into(),
4201 rpi_plugin_sdk::EventTag::ToolCall,
4202 deny_test_tool_call,
4203 std::ptr::null_mut(),
4204 );
4205 let fresh = ExtensionSession::from_parts(
4206 Arc::new(registry.snapshot()),
4207 rpi_extensions::PluginKeepalive::empty(),
4208 Vec::new(),
4209 None,
4210 );
4211 {
4212 let mut current = sessions.lock().unwrap();
4213 if let Some(snapshot) = current.snapshot() {
4214 snapshot.active_flag().store(false, Ordering::SeqCst);
4215 }
4216 *current = fresh;
4217 }
4218 let result = invoke_test_tool_hook(&hook).await.unwrap();
4219 assert!(result.block);
4220 assert!(result.reason.unwrap().contains(name));
4221 }
4222 *sessions.lock().unwrap() = ExtensionSession::none();
4223 assert!(invoke_test_tool_hook(&hook).await.is_none());
4224 }
4225
4226 fn scratch(name: &str) -> PathBuf {
4228 let nanos = std::time::SystemTime::now()
4229 .duration_since(std::time::UNIX_EPOCH)
4230 .unwrap()
4231 .as_nanos();
4232 let path = std::env::temp_dir().join(format!(
4233 "rpi-summary-{}-{nanos}-{name}.jsonl",
4234 std::process::id()
4235 ));
4236 path
4237 }
4238
4239 #[test]
4240 fn summary_marks_a_header_only_session_empty() {
4241 let path = scratch("empty");
4242 std::fs::write(
4243 &path,
4244 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4245 )
4246 .unwrap();
4247 let summary = summarize_session_file(&path);
4248 assert!(summary.empty, "a header-only file has nothing to resume");
4249 assert!(summary.preview.is_none());
4250 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
4251 std::fs::remove_file(&path).ok();
4252 }
4253
4254 #[test]
4255 fn summary_reads_the_first_user_prompt_and_name() {
4256 let path = scratch("content");
4257 let body = concat!(
4258 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4259 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
4260 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
4261 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
4262 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
4263 );
4264 std::fs::write(&path, body).unwrap();
4265 let summary = summarize_session_file(&path);
4266 assert!(!summary.empty);
4267 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
4268 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
4269 std::fs::remove_file(&path).ok();
4270 }
4271
4272 #[test]
4273 fn summary_extracts_a_block_style_user_prompt() {
4274 let path = scratch("blocks");
4275 let body = concat!(
4276 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4277 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
4278 );
4279 std::fs::write(&path, body).unwrap();
4280 let summary = summarize_session_file(&path);
4281 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
4282 assert_eq!(session_display_label(&summary), "line one line two");
4283 std::fs::remove_file(&path).ok();
4284 }
4285
4286 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
4287 rpi_harness::session::jsonl::JsonlSessionMetadata {
4288 id: id.to_string(),
4289 created_at: 0,
4290 cwd: ".".to_string(),
4291 path: path.to_string_lossy().into_owned(),
4292 modified_at: 0,
4293 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
4294 parent_session_id: None,
4295 legacy_parent_session_path: None,
4296 metadata: None,
4297 }
4298 }
4299
4300 #[test]
4301 fn continue_skips_newer_empty_sessions() {
4302 let empty = scratch("newest-empty");
4303 std::fs::write(
4304 &empty,
4305 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4306 )
4307 .unwrap();
4308 let content = scratch("older-content");
4309 std::fs::write(
4310 &content,
4311 concat!(
4312 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4313 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
4314 ),
4315 )
4316 .unwrap();
4317
4318 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
4319 let picked = first_resumable_session(&metas).expect("a resumable session");
4320 assert_eq!(picked.id, "old");
4321
4322 let all_empty = vec![meta_for(&empty, "empty")];
4325 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
4326
4327 std::fs::remove_file(&empty).ok();
4328 std::fs::remove_file(&content).ok();
4329 }
4330
4331 #[tokio::test]
4332 async fn import_preserves_validates_and_locks_the_recovery_pair() {
4333 use rpi_harness::session::jsonl::{JsonlSessionStorage, JsonlV4Header};
4334 use rpi_harness::session::types::{
4335 AssistantFrameOp, AssistantFrameRecord, RecordBase, SessionStorage,
4336 };
4337 let tmp = tempfile::tempdir().unwrap();
4338 let source = tmp.path().join("source.jsonl");
4339 let progress = tmp.path().join("source.jsonl.progress.jsonl");
4340 let fs: Arc<dyn rpi_tools::FileSystem> =
4341 Arc::new(OsExecutionEnv::with_cwd(tmp.path().into()));
4342 let storage = JsonlSessionStorage::create(
4343 fs,
4344 &source.to_string_lossy(),
4345 JsonlV4Header::new("import-pair".into(), 1, "/".into(), None, None, None),
4346 Arc::new(SystemClock),
4347 Arc::new(DefaultIdGenerator::new()),
4348 )
4349 .await
4350 .unwrap();
4351 storage
4352 .append_progress(vec![AssistantFrameRecord {
4353 base: RecordBase {
4354 id: "accepted-prefix".into(),
4355 seq: 0,
4356 lane: "main".into(),
4357 timestamp: 0,
4358 },
4359 run_id: "run".into(),
4360 stream_index: 0,
4361 op: AssistantFrameOp::Append,
4362 frame: Some(serde_json::json!({"payload":"keep"})),
4363 }])
4364 .await
4365 .unwrap();
4366 drop(storage);
4367 let original = std::fs::read(&progress).unwrap();
4368 let destination = tmp.path().join("imported");
4369 let imported = import_session_file(&source, &tmp.path().to_string_lossy(), &destination)
4370 .await
4371 .unwrap();
4372 assert_eq!(
4373 imported.read_progress("run").await.unwrap()[0].base.id,
4374 "accepted-prefix"
4375 );
4376 assert_eq!(
4377 std::fs::read(&progress).unwrap(),
4378 original,
4379 "import must not repair or mutate its source"
4380 );
4381 let lock = std::fs::OpenOptions::new()
4382 .create(true)
4383 .write(true)
4384 .open(tmp.path().join("source.jsonl.lock"))
4385 .unwrap();
4386 fs2::FileExt::try_lock_exclusive(&lock).unwrap();
4387 assert!(import_session_file(
4388 &source,
4389 &tmp.path().to_string_lossy(),
4390 &tmp.path().join("busy")
4391 )
4392 .await
4393 .is_err());
4394 drop(lock);
4395 let mut corrupt = String::from_utf8(original.clone()).unwrap();
4396 corrupt = corrupt.replace(
4397 "\"session_id\":\"import-pair\"",
4398 "\"session_id\":\"foreign\"",
4399 );
4400 assert_ne!(corrupt.as_bytes(), original.as_slice());
4401 std::fs::write(&progress, corrupt).unwrap();
4402 assert!(import_session_file(
4403 &source,
4404 &tmp.path().to_string_lossy(),
4405 &tmp.path().join("foreign")
4406 )
4407 .await
4408 .is_err());
4409 std::fs::remove_file(&progress).unwrap();
4410 assert!(import_session_file(
4411 &source,
4412 &tmp.path().to_string_lossy(),
4413 &tmp.path().join("missing")
4414 )
4415 .await
4416 .is_err());
4417 }
4418
4419 #[test]
4420 fn list_session_files_sync_walks_the_cwd_subdirectory() {
4421 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
4422 let nested = root.join("--proj--");
4423 std::fs::create_dir_all(&nested).unwrap();
4424 let file = nested.join("2026-01-01_a.jsonl");
4425 std::fs::write(&file, "{}\n").unwrap();
4426 let sidecar = nested.join("2026-01-01_a.jsonl.progress.jsonl");
4427 std::fs::write(&sidecar, "{}\n").unwrap();
4428 let found = list_session_files_sync(&root);
4429 assert!(found.iter().any(|p| p == &file));
4430 assert!(!found.contains(&sidecar));
4431 std::fs::remove_dir_all(&root).ok();
4432 }
4433
4434 fn default_prompt_tools() -> Vec<String> {
4436 BUILTIN_TOOL_NAMES
4437 .iter()
4438 .copied()
4439 .chain(["todo"])
4440 .map(str::to_owned)
4441 .collect()
4442 }
4443
4444 #[test]
4445 fn default_prompt_lists_only_the_active_tools() {
4446 let tools = ["read", "grep", "find", "ls"].map(str::to_owned);
4447 let p = default_system_prompt_with_tools("/tmp/proj", true, &tools);
4448 assert!(p.contains("- read — Read file contents"));
4449 assert!(p.contains("- grep — Search file contents"));
4450 for absent in ["- edit", "- write", "- bash", "- docs", "todo", "Windows"] {
4451 assert!(
4452 !p.contains(absent),
4453 "{absent} must not appear for a read-only run: {p}"
4454 );
4455 }
4456 assert!(p.contains("Re-stating the same plan without acting on it is a bug"));
4457 let none = default_system_prompt_with_tools("/tmp/proj", false, &[]);
4458 assert!(none.contains("Available tools:\n(none)"));
4459 }
4460
4461 #[test]
4462 fn default_prompt_mentions_cwd_and_tools() {
4463 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
4464 assert!(p.contains("/tmp/proj"));
4465 assert!(p.contains("read"));
4466 assert!(p.contains("bash"));
4467 assert!(p.contains("edit"));
4468 assert!(p.contains("write"));
4469 assert!(p.contains("docs"));
4470 assert!(!p.contains("- grep"));
4471 assert!(!p.contains("- find"));
4472 assert!(!p.contains("- ls"));
4473 assert!(!p.contains("- powershell"));
4474 assert!(p.contains("Bash/POSIX syntax, also on Windows"));
4475 }
4476
4477 #[test]
4478 fn default_prompt_instructs_language_matching() {
4479 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
4482 assert!(
4483 p.contains("same language as the user's latest message"),
4484 "the prompt must tell the model to mirror the user's language: {p}"
4485 );
4486 }
4487
4488 #[test]
4489 fn default_prompt_keeps_working_state_in_the_visible_channel() {
4490 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
4498 let lower = p.to_lowercase();
4499 assert!(
4501 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
4502 "the prompt must explain that reasoning does not survive the turn: {p}"
4503 );
4504 assert!(
4506 lower.contains("visible") && !lower.contains("todo tool"),
4507 "the prompt must keep state visible without inventing extension rules: {p}"
4508 );
4509 assert!(
4511 lower.contains("re-stating the same plan"),
4512 "the prompt must call out plan re-statement as a bug: {p}"
4513 );
4514 assert!(
4516 lower.contains("already shown earlier"),
4517 "the prompt must discourage re-fetching known content: {p}"
4518 );
4519 }
4520
4521 #[test]
4522 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
4523 let p = default_system_prompt_with_tools("/tmp/proj", true, &default_prompt_tools());
4530 let lower = p.to_lowercase();
4531 assert!(
4532 lower.contains("carried back"),
4533 "a replaying model must be told its reasoning survives: {p}"
4534 );
4535 assert!(
4536 !lower.contains("reasoning is not"),
4537 "the replayed variant must not claim reasoning is dropped: {p}"
4538 );
4539 assert!(
4540 lower.contains("do not re-derive"),
4541 "the replayed variant must forbid re-deriving the plan: {p}"
4542 );
4543 assert!(
4544 lower.contains("do not re-derive") && lower.contains("re-stating the same plan"),
4545 "the anti-repetition rule must survive the flip: {p}"
4546 );
4547 assert!(
4548 lower.contains("already shown earlier"),
4549 "the re-read rule must survive the flip: {p}"
4550 );
4551 }
4552
4553 #[test]
4554 fn default_prompt_states_each_rule_once() {
4555 for replayed in [false, true] {
4558 let p =
4559 default_system_prompt_with_tools("/tmp/proj", replayed, &default_prompt_tools());
4560 for phrase in ["the same plan", "Windows", "guessing"] {
4561 assert_eq!(p.matches(phrase).count(), 1, "{phrase:?} repeated: {p}");
4562 }
4563 assert!(
4564 !p.contains("todo tool"),
4565 "extension rules require explicit metadata"
4566 );
4567 }
4568 }
4569
4570 #[test]
4578 fn only_read_only_tools_are_replayable() {
4579 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
4580 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
4581 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
4582 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
4583 let tools = build_tools(&context, &Args::default());
4584
4585 let replay_of = |name: &str| {
4586 tools
4587 .iter()
4588 .find(|tool| tool.tool.schema().name == name)
4589 .map(|tool| tool.replay)
4590 };
4591
4592 for read_only in ["read", "docs"] {
4593 assert_eq!(
4594 replay_of(read_only),
4595 Some(ToolReplay::Safe),
4596 "{read_only} is side-effect free, so recovery may re-run it"
4597 );
4598 }
4599 for mutating in ["bash", "edit", "write", "powershell"] {
4600 if let Some(replay) = replay_of(mutating) {
4601 assert_eq!(
4602 replay,
4603 ToolReplay::Never,
4604 "{mutating} must never be replayed: re-running it would duplicate its effect"
4605 );
4606 }
4607 }
4608 }
4609
4610 #[test]
4611 fn default_tools_include_docs_lookup() {
4612 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
4613 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
4614 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
4615 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
4616 let names: Vec<String> = build_tools(&context, &Args::default())
4617 .iter()
4618 .map(|tool| tool.tool.schema().name.clone())
4619 .collect();
4620
4621 assert!(names.contains(&"docs".to_string()));
4626 for legacy in ["grep", "find", "ls"] {
4627 assert!(
4628 !names.contains(&legacy.to_string()),
4629 "legacy tool {legacy} should not be registered by default"
4630 );
4631 }
4632 }
4633
4634 #[test]
4635 fn reload_settings_preflight_respects_trust() {
4636 struct RestoreConfigDir(Option<std::ffi::OsString>);
4637
4638 impl Drop for RestoreConfigDir {
4639 fn drop(&mut self) {
4640 match self.0.take() {
4641 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4642 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4643 }
4644 }
4645 }
4646
4647 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4648 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4649 let tmp = tempfile::tempdir().unwrap();
4650 let agent = tmp.path().join("agent");
4651 let cwd = tmp.path().join("project");
4652 std::fs::create_dir_all(&agent).unwrap();
4653 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
4654 std::fs::write(agent.join("settings.json"), "{}").unwrap();
4655 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
4656 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4657 let args = Args::default();
4658
4659 let trusted = validate_settings_for_reload(&args, &cwd, true);
4660 let untrusted = validate_settings_for_reload(&args, &cwd, false);
4661 assert!(trusted
4662 .unwrap_err()
4663 .contains("could not load project settings"));
4664 assert!(untrusted.is_ok());
4665
4666 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
4667 assert!(validate_settings_for_reload(&args, &cwd, false)
4668 .unwrap_err()
4669 .contains("could not load global settings"));
4670
4671 let local_only = Args {
4672 dev_local_only: true,
4673 ..Args::default()
4674 };
4675 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
4676 }
4677
4678 #[tokio::test(flavor = "current_thread")]
4679 async fn reload_preserves_live_resources_when_settings_break() {
4680 struct RestoreConfigDir(Option<std::ffi::OsString>);
4681
4682 impl Drop for RestoreConfigDir {
4683 fn drop(&mut self) {
4684 match self.0.take() {
4685 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4686 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4687 }
4688 }
4689 }
4690
4691 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4692 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4693 let tmp = tempfile::tempdir().unwrap();
4694 let agent = tmp.path().join("agent");
4695 let cwd = tmp.path().join("project");
4696 let skill_dir = tmp.path().join("configured-skills");
4697 std::fs::create_dir_all(&agent).unwrap();
4698 std::fs::create_dir_all(&cwd).unwrap();
4699 std::fs::create_dir_all(&skill_dir).unwrap();
4700 std::fs::write(
4701 skill_dir.join("SKILL.md"),
4702 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
4703 )
4704 .unwrap();
4705 std::fs::write(
4706 agent.join("settings.json"),
4707 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
4708 )
4709 .unwrap();
4710 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4711
4712 let resolved = crate::provider::resolve(
4713 Some("anthropic"),
4714 Some(crate::provider::DEFAULT_MODEL_ID),
4715 None,
4716 Some("test-key"),
4717 None,
4718 )
4719 .unwrap();
4720 let args = Args {
4721 trust_override: Some(false),
4722 no_session: true,
4723 no_extensions: true,
4724 no_prompt_templates: true,
4725 no_context_files: true,
4726 system_prompt: Some("stable system prompt".into()),
4727 ..Args::default()
4728 };
4729 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
4730 let before_resources = harness.get_resources().await.unwrap();
4731 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
4732 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
4733 let before_prompt = harness.get_system_prompt().await.unwrap();
4734 let before_tools: Vec<String> = harness
4735 .get_tools()
4736 .await
4737 .unwrap()
4738 .iter()
4739 .map(|tool| tool.tool.schema().name.clone())
4740 .collect();
4741 let before_bridge = context
4742 .action_bridge
4743 .lock()
4744 .unwrap()
4745 .as_ref()
4746 .unwrap()
4747 .clone();
4748
4749 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
4750 let outcome = reload_extension_resources(&harness, &context).await;
4751
4752 assert!(outcome.had_warnings);
4753 assert!(outcome.summary.contains("Settings reload failed"));
4754 assert!(outcome.summary.contains("could not load global settings"));
4755 assert_eq!(outcome.project_trusted, None);
4759 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
4760 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
4761 let after_tools: Vec<String> = harness
4762 .get_tools()
4763 .await
4764 .unwrap()
4765 .iter()
4766 .map(|tool| tool.tool.schema().name.clone())
4767 .collect();
4768 assert_eq!(after_tools, before_tools);
4769 let after_bridge = context
4770 .action_bridge
4771 .lock()
4772 .unwrap()
4773 .as_ref()
4774 .unwrap()
4775 .clone();
4776 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
4777 }
4778
4779 #[tokio::test(flavor = "current_thread")]
4780 async fn reload_outcome_carries_the_current_project_trust() {
4781 struct RestoreConfigDir(Option<std::ffi::OsString>);
4782
4783 impl Drop for RestoreConfigDir {
4784 fn drop(&mut self) {
4785 match self.0.take() {
4786 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4787 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4788 }
4789 }
4790 }
4791
4792 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4793 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4794 let tmp = tempfile::tempdir().unwrap();
4795 let agent = tmp.path().join("agent");
4796 let cwd = tmp.path().join("project");
4797 std::fs::create_dir_all(&agent).unwrap();
4798 std::fs::create_dir_all(&cwd).unwrap();
4799 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4800
4801 crate::config::set_project_trust(&cwd, Some(true)).unwrap();
4803 let resolved = crate::provider::resolve(
4804 Some("anthropic"),
4805 Some(crate::provider::DEFAULT_MODEL_ID),
4806 None,
4807 Some("test-key"),
4808 None,
4809 )
4810 .unwrap();
4811 let args = Args {
4812 no_session: true,
4813 no_extensions: true,
4814 no_prompt_templates: true,
4815 no_context_files: true,
4816 system_prompt: Some("stable system prompt".into()),
4817 ..Args::default()
4818 };
4819 let (harness, _events, context) = build(&resolved, &args, &cwd, true).await.unwrap();
4820 assert!(context.project_trusted, "startup snapshot is trusted");
4821
4822 crate::config::set_project_trust(&cwd, Some(false)).unwrap();
4825 let outcome = reload_extension_resources(&harness, &context).await;
4826 assert!(!outcome.had_errors, "{}", outcome.summary);
4827 assert_eq!(outcome.project_trusted, Some(false));
4828
4829 crate::config::set_project_trust(&cwd, Some(true)).unwrap();
4831 let outcome = reload_extension_resources(&harness, &context).await;
4832 assert!(!outcome.had_errors, "{}", outcome.summary);
4833 assert_eq!(outcome.project_trusted, Some(true));
4834 }
4835
4836 #[test]
4837 fn reload_preparation_rejects_settings_changed_during_derivation() {
4838 struct RestoreConfigDir(Option<std::ffi::OsString>);
4839
4840 impl Drop for RestoreConfigDir {
4841 fn drop(&mut self) {
4842 match self.0.take() {
4843 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4844 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4845 }
4846 }
4847 }
4848
4849 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4850 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4851 let tmp = tempfile::tempdir().unwrap();
4852 let agent = tmp.path().join("agent");
4853 let cwd = tmp.path().join("project");
4854 let first_skill_dir = tmp.path().join("first-skills");
4855 let second_skill_dir = tmp.path().join("second-skills");
4856 std::fs::create_dir_all(&agent).unwrap();
4857 std::fs::create_dir_all(&cwd).unwrap();
4858 std::fs::write(
4859 agent.join("settings.json"),
4860 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
4861 )
4862 .unwrap();
4863 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4864 let args = Args {
4865 trust_override: Some(false),
4866 no_extensions: true,
4867 no_prompt_templates: true,
4868 ..Args::default()
4869 };
4870
4871 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
4872 std::fs::write(
4873 agent.join("settings.json"),
4874 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
4875 )
4876 .unwrap();
4877 });
4878
4879 let error = result
4880 .err()
4881 .expect("settings mutation must fail preparation");
4882 assert!(error.contains("settings changed while reload inputs were being prepared"));
4883 }
4884
4885 #[tokio::test(flavor = "current_thread")]
4886 async fn reload_uses_frozen_settings_inputs_after_preparation() {
4887 struct RestoreConfigDir(Option<std::ffi::OsString>);
4888
4889 impl Drop for RestoreConfigDir {
4890 fn drop(&mut self) {
4891 match self.0.take() {
4892 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4893 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4894 }
4895 }
4896 }
4897
4898 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4899 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4900 let tmp = tempfile::tempdir().unwrap();
4901 let agent = tmp.path().join("agent");
4902 let cwd = tmp.path().join("project");
4903 let skill_dir = tmp.path().join("configured-skills");
4904 std::fs::create_dir_all(&agent).unwrap();
4905 std::fs::create_dir_all(&cwd).unwrap();
4906 std::fs::create_dir_all(&skill_dir).unwrap();
4907 std::fs::write(
4908 skill_dir.join("SKILL.md"),
4909 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
4910 )
4911 .unwrap();
4912 std::fs::write(
4913 agent.join("settings.json"),
4914 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
4915 )
4916 .unwrap();
4917 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4918
4919 let resolved = crate::provider::resolve(
4920 Some("anthropic"),
4921 Some(crate::provider::DEFAULT_MODEL_ID),
4922 None,
4923 Some("test-key"),
4924 None,
4925 )
4926 .unwrap();
4927 let args = Args {
4928 trust_override: Some(false),
4929 no_session: true,
4930 no_extensions: true,
4931 no_prompt_templates: true,
4932 no_context_files: true,
4933 system_prompt: Some("stable system prompt".into()),
4934 ..Args::default()
4935 };
4936 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
4937
4938 let outcome = reload_extension_resources_inner(&harness, &context, || {
4939 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
4940 })
4941 .await;
4942
4943 assert!(!outcome.summary.contains("Settings reload failed"));
4944 assert_eq!(
4945 outcome.summary,
4946 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
4947 );
4948 let resources = harness.get_resources().await.unwrap();
4949 let skills = resources.skills.as_ref().unwrap();
4950 assert_eq!(skills.len(), 1);
4951 assert_eq!(skills[0].name, "frozen-skill");
4952 }
4953
4954 #[tokio::test]
4955 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
4956 let tmp = tempfile::tempdir().unwrap();
4957 let skill_path = tmp.path().join("explicit-skill.md");
4958 std::fs::write(
4959 &skill_path,
4960 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
4961 )
4962 .unwrap();
4963 let prompt_path = tmp.path().join("explicit-prompt.md");
4964 std::fs::write(
4965 &prompt_path,
4966 "---\ndescription: Explicit prompt\n---\nPrompt body",
4967 )
4968 .unwrap();
4969
4970 let args = Args {
4971 skill: vec![skill_path.clone()],
4972 prompt_template: vec![prompt_path.clone()],
4973 ..Args::default()
4974 };
4975 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
4976 let prompt_paths =
4977 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
4978 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
4979 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
4980
4981 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
4982 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
4983 assert_eq!(skills.skills[0].name, "explicit-skill");
4984
4985 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
4986 assert_eq!(
4987 prompts.prompt_templates.len(),
4988 1,
4989 "{:?}",
4990 prompts.diagnostics
4991 );
4992 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
4993 }
4994
4995 #[test]
4996 fn tool_policy_applies_to_rust_and_js_active_names() {
4997 let names = vec![
4998 "read".to_string(),
4999 "ask_user_question".to_string(),
5000 "write".to_string(),
5001 ];
5002
5003 let args = Args {
5004 tools: Some(vec!["read".into(), "ask_user_question".into()]),
5005 ..Args::default()
5006 };
5007 assert_eq!(
5008 filter_active_tool_names(names.clone(), &args),
5009 vec!["read", "ask_user_question"]
5010 );
5011
5012 let args = Args {
5013 exclude_tools: Some(vec!["ask_user_question".into()]),
5014 ..Args::default()
5015 };
5016 assert_eq!(
5017 filter_active_tool_names(names.clone(), &args),
5018 vec!["read", "write"]
5019 );
5020
5021 let args = Args {
5022 no_tools: true,
5023 ..Args::default()
5024 };
5025 assert!(filter_active_tool_names(names, &args).is_empty());
5026 }
5027
5028 #[test]
5029 fn select_ephemeral_when_no_session() {
5030 let args = Args {
5031 no_session: true,
5032 ..Args::default()
5033 };
5034 let cwd = Path::new("/tmp");
5035 assert!(matches!(
5036 select_session(&args, cwd),
5037 SessionSelection::Ephemeral
5038 ));
5039 }
5040
5041 #[test]
5042 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
5043 let defaults = Args::default();
5044 assert!(resolve_project_trust(
5045 &defaults,
5046 Path::new("C:/definitely-not-a-project")
5047 ));
5048
5049 let denied = Args {
5050 trust_override: Some(false),
5051 ..Args::default()
5052 };
5053 assert!(!resolve_project_trust(
5054 &denied,
5055 Path::new("C:/definitely-not-a-project")
5056 ));
5057 }
5058
5059 #[tokio::test(flavor = "current_thread")]
5060 async fn build_preserves_the_supplied_startup_project_snapshot() {
5061 struct RestoreConfigDir(Option<std::ffi::OsString>);
5062
5063 impl Drop for RestoreConfigDir {
5064 fn drop(&mut self) {
5065 match self.0.take() {
5066 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5067 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5068 }
5069 }
5070 }
5071
5072 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5073 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5074 let tmp = tempfile::tempdir().unwrap();
5075 let agent = tmp.path().join("agent");
5076 let cwd = tmp.path().join("project");
5077 std::fs::create_dir_all(&agent).unwrap();
5078 std::fs::create_dir_all(&cwd).unwrap();
5079 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5080
5081 let resolved = crate::provider::resolve(
5082 Some("anthropic"),
5083 Some(crate::provider::DEFAULT_MODEL_ID),
5084 None,
5085 Some("test-key"),
5086 None,
5087 )
5088 .unwrap();
5089 let args = Args {
5090 trust_override: Some(false),
5091 no_session: true,
5092 no_tools: true,
5093 no_extensions: true,
5094 no_skills: true,
5095 no_prompt_templates: true,
5096 no_context_files: true,
5097 system_prompt: Some("test prompt".into()),
5098 ..Args::default()
5099 };
5100
5101 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
5102
5103 assert_eq!(context.cwd, cwd);
5104 assert!(context.project_trusted);
5105 }
5106
5107 #[tokio::test(flavor = "current_thread")]
5108 async fn build_applies_the_name_flag_to_the_session() {
5109 struct RestoreConfigDir(Option<std::ffi::OsString>);
5110
5111 impl Drop for RestoreConfigDir {
5112 fn drop(&mut self) {
5113 match self.0.take() {
5114 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5115 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5116 }
5117 }
5118 }
5119
5120 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5121 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5122 let tmp = tempfile::tempdir().unwrap();
5123 let agent = tmp.path().join("agent");
5124 let cwd = tmp.path().join("project");
5125 std::fs::create_dir_all(&agent).unwrap();
5126 std::fs::create_dir_all(&cwd).unwrap();
5127 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5128
5129 let resolved = crate::provider::resolve(
5130 Some("anthropic"),
5131 Some(crate::provider::DEFAULT_MODEL_ID),
5132 None,
5133 Some("test-key"),
5134 None,
5135 )
5136 .unwrap();
5137 let args = Args {
5138 trust_override: Some(false),
5139 no_tools: true,
5140 no_extensions: true,
5141 no_skills: true,
5142 no_prompt_templates: true,
5143 no_context_files: true,
5144 system_prompt: Some("test prompt".into()),
5145 name: Some("smoke name".into()),
5146 session_dir: Some(cwd.join("sessions")),
5147 ..Args::default()
5148 };
5149
5150 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
5151 assert_eq!(
5152 harness.get_name().await.unwrap().as_deref(),
5153 Some("smoke name")
5154 );
5155 }
5156
5157 #[test]
5158 fn select_latest_for_continue_and_resume() {
5159 let _env = crate::config::test_support::SessionDirEnv::new(None);
5160 let args = Args {
5161 continue_session: true,
5162 ..Args::default()
5163 };
5164 let cwd = Path::new("/tmp");
5165 assert!(matches!(
5166 select_session(&args, cwd),
5167 SessionSelection::Latest { .. }
5168 ));
5169
5170 let args = Args {
5171 resume: true,
5172 ..Args::default()
5173 };
5174 assert!(matches!(
5175 select_session(&args, cwd),
5176 SessionSelection::Latest { .. }
5177 ));
5178 }
5179
5180 #[test]
5181 fn select_by_id_for_session_flag() {
5182 let _env = crate::config::test_support::SessionDirEnv::new(None);
5183 let args = Args {
5184 session: Some("01a02ece".into()),
5185 ..Args::default()
5186 };
5187 let cwd = Path::new("/tmp");
5188 assert!(matches!(
5189 select_session(&args, cwd),
5190 SessionSelection::ById { id, .. } if id == "01a02ece"
5191 ));
5192 }
5193
5194 #[test]
5195 fn select_new_with_custom_dir() {
5196 let _env = crate::config::test_support::SessionDirEnv::new(None);
5197 let args = Args {
5198 session_dir: Some(PathBuf::from("/tmp/sess")),
5199 ..Args::default()
5200 };
5201 let cwd = Path::new("/tmp");
5202 match select_session(&args, cwd) {
5203 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
5204 other => panic!("expected New, got {other:?}"),
5205 }
5206 }
5207
5208 #[test]
5209 fn select_new_default_dir() {
5210 let _env = crate::config::test_support::SessionDirEnv::new(None);
5211 let args = Args::default();
5212 let cwd = Path::new("/proj");
5213 match select_session(&args, cwd) {
5214 SessionSelection::New { dir, .. } => {
5215 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
5216 }
5217 other => panic!("expected New, got {other:?}"),
5218 }
5219 }
5220
5221 #[test]
5222 fn default_session_dir_only_uses_rpi() {
5223 let _env = crate::config::test_support::SessionDirEnv::new(None);
5224 let tmp = tempfile::tempdir().unwrap();
5225 let cwd = tmp.path();
5226 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
5227 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
5228 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
5229 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
5230 }
5231
5232 #[test]
5233 fn default_session_dir_uses_env_var() {
5234 let tmp = tempfile::tempdir().unwrap();
5235 let cwd = tmp.path();
5236 let custom = tmp.path().join("custom_sessions");
5237 let _env = crate::config::test_support::SessionDirEnv::new(Some(&custom));
5238 assert_eq!(default_session_dir(cwd), custom);
5239 }
5240
5241 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5242 async fn ephemeral_session_builds_roundtrips() {
5243 let s = ephemeral_session();
5247 let leaf = s.get_leaf_id().await;
5248 assert!(leaf.is_ok());
5249 }
5250
5251 #[test]
5269 fn host_private_env_matches_the_declared_contract() {
5270 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
5272 assert_eq!(
5273 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
5274 [
5275 "LANGFUSE_PI_PARENT_TRACE_ID",
5276 "LANGFUSE_PI_PARENT_SPAN_ID",
5277 "LANGFUSE_PI_PARENT_SESSION_ID",
5278 "LANGFUSE_PI_PARENT_DEPTH",
5279 ],
5280 "these strings are what rpi-langfuse (a different repo) matches; a \
5281 change here is a breaking cross-repo change, not a refactor"
5282 );
5283
5284 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
5287 .iter()
5288 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
5289 {
5290 assert!(
5291 rpi_tools::is_host_private_env(name),
5292 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
5293 rpi_tools::HOST_PRIVATE_ENV
5294 );
5295 }
5296
5297 for visible in [
5300 crate::args::RPI_OFFLINE_ENV,
5301 crate::config::CONFIG_DIR_ENV,
5302 "PATH",
5303 ] {
5304 assert!(
5305 !rpi_tools::is_host_private_env(visible),
5306 "{visible} describes the user's configuration and must still reach the shell"
5307 );
5308 }
5309 }
5310 #[tokio::test]
5311 async fn reload_shutdown_joins_all_handlers_even_after_veto() {
5312 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
5313 use std::sync::atomic::AtomicUsize;
5314 static CONTEXTS: rpi_plugin_sdk::handles::ToolHandles<Arc<AtomicUsize>> =
5315 rpi_plugin_sdk::handles::ToolHandles::new();
5316 struct ContextOwner(usize);
5317 impl Drop for ContextOwner {
5318 fn drop(&mut self) {
5319 drop(CONTEXTS.remove(self.0 as rpi_plugin_sdk::StepHandle));
5320 }
5321 }
5322 extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
5323 let context = CONTEXTS.get(data).expect("live test context");
5324 let calls = context.lock().clone();
5325 std::thread::sleep(std::time::Duration::from_millis(20));
5326 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
5327 rpi_plugin_sdk::EVENT_HANDLER_ABORT
5328 } else {
5329 0
5330 }
5331 }
5332 let calls = Arc::new(AtomicUsize::new(0));
5333 let context = CONTEXTS.insert(calls.clone());
5334 let _owner = ContextOwner(context as usize);
5335 let mut registry = rpi_extensions::ExtensionRegistry::new();
5336 for name in ["first", "second"] {
5337 registry.register_event_handler(name.into(), EventTag::SessionShutdown, stop, context);
5338 }
5339 super::shutdown_extensions_for_reload(
5340 Some(Arc::new(registry.snapshot())),
5341 rpi_extensions::PluginKeepalive::empty(),
5342 )
5343 .await
5344 .unwrap();
5345 assert_eq!(calls.load(Ordering::SeqCst), 2);
5346 }
5347
5348 #[tokio::test]
5357 async fn a_restored_session_adopts_its_recorded_directory() {
5358 let tmp = tempfile::tempdir().unwrap();
5359 let project = tmp.path().join("project-a");
5360 let launch = tmp.path().join("launch-dir");
5361 let sessions = tmp.path().join("sessions");
5362 std::fs::create_dir_all(&project).unwrap();
5363 std::fs::create_dir_all(&launch).unwrap();
5364 std::fs::create_dir_all(&sessions).unwrap();
5365 create_jsonl_session_with_id(
5366 &sessions,
5367 &project.to_string_lossy(),
5368 Some("cwdadopt01".into()),
5369 )
5370 .await
5371 .unwrap();
5372
5373 let selection = SessionSelection::ById {
5374 id: "cwdadopt01".into(),
5375 dir: sessions.clone(),
5376 };
5377 let peeked = peek_restored_session_cwd(&selection, &launch)
5378 .await
5379 .expect("a resumable session with a recorded cwd");
5380 assert_eq!(peeked.recorded, project);
5381 assert_eq!(peeked.adopted, Some(project));
5382 }
5383
5384 #[tokio::test]
5388 async fn a_missing_recorded_directory_is_reported_not_adopted() {
5389 let tmp = tempfile::tempdir().unwrap();
5390 let gone = tmp.path().join("project-gone");
5391 let launch = tmp.path().join("launch-dir");
5392 let sessions = tmp.path().join("sessions");
5393 std::fs::create_dir_all(&launch).unwrap();
5394 std::fs::create_dir_all(&sessions).unwrap();
5395 create_jsonl_session_with_id(
5396 &sessions,
5397 &gone.to_string_lossy(),
5398 Some("cwdgone001".into()),
5399 )
5400 .await
5401 .unwrap();
5402
5403 let selection = SessionSelection::ById {
5404 id: "cwdgone001".into(),
5405 dir: sessions.clone(),
5406 };
5407 let peeked = peek_restored_session_cwd(&selection, &launch)
5408 .await
5409 .expect("the session itself is resumable");
5410 assert_eq!(peeked.recorded, gone);
5411 assert_eq!(peeked.adopted, None);
5412 let note = crate::session_cwd::missing_session_cwd_note(&peeked.recorded, &launch);
5413 assert!(note.contains("no longer exists"), "{note}");
5414 assert!(note.contains("launch-dir"), "{note}");
5415 }
5416
5417 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5418 async fn legacy_progress_upgrade_normal_open_runs_and_reopens() {
5419 use rpi_ai::providers::faux::{FauxProvider, FauxScript};
5420 use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
5421 use rpi_harness::session::jsonl::{
5422 encode_header, JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
5423 JsonlV4Header,
5424 };
5425 let tmp = tempfile::tempdir().unwrap();
5426 let sessions = tmp.path().join("sessions");
5427 std::fs::create_dir_all(&sessions).unwrap();
5428 let cwd = tmp.path().to_string_lossy().into_owned();
5429 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
5430 fs: Arc::new(OsExecutionEnv::with_cwd(tmp.path().into())),
5431 sessions_root: sessions.to_string_lossy().into_owned(),
5432 clock: Arc::new(SystemClock),
5433 ids: Arc::new(DefaultIdGenerator::new()),
5434 });
5435 let fixture = repo
5436 .create_typed(&JsonlSessionCreateOptions {
5437 id: Some("legacycli001".into()),
5438 parent_session_id: None,
5439 cwd: cwd.clone(),
5440 metadata: None,
5441 })
5442 .await
5443 .unwrap();
5444 let path = PathBuf::from(fixture.jsonl_metadata().path);
5445 drop(fixture);
5446 let sidecar = PathBuf::from(format!("{}.progress.jsonl", path.display()));
5447 std::fs::remove_file(&sidecar).unwrap();
5448 let header = JsonlV4Header::new("legacycli001".into(), 1, cwd.clone(), None, None, None);
5449 std::fs::write(&path, encode_header(&header)).unwrap();
5450 assert!(!sidecar.exists());
5451 for _ in 0..2 {
5452 let restored = open_session_by_id_in_dir("legacycli001", &cwd, &sessions)
5453 .await
5454 .map_err(|e| e.to_string())
5455 .unwrap();
5456 assert_eq!(restored.session.storage().metadata().id, "legacycli001");
5457 let provider =
5458 FauxProvider::new(FauxScript::new().with_text("continued legacy session"));
5459 let harness = AgentHarness::create(AgentHarnessOptions {
5460 model: provider.default_model().clone(),
5461 models: vec![provider],
5462 session: restored.session,
5463 allow_existing_session: true,
5464 ..Default::default()
5465 })
5466 .await
5467 .unwrap();
5468 let run = harness.prompt_text("continue", vec![]).await.unwrap();
5469 assert!(matches!(run.outcome, HarnessRunOutcome::Completed { .. }));
5470 }
5471 let content = std::fs::read_to_string(&path).unwrap();
5472 assert!(content.lines().next().unwrap().contains("progressFormat"));
5473 assert!(sidecar.exists());
5474 }
5475
5476 #[tokio::test]
5479 async fn open_session_reports_the_recorded_directory() {
5480 let tmp = tempfile::tempdir().unwrap();
5481 let project = tmp.path().join("project-open");
5482 let launch = tmp.path().join("launch-dir");
5483 let sessions = tmp.path().join("sessions");
5484 std::fs::create_dir_all(&project).unwrap();
5485 std::fs::create_dir_all(&launch).unwrap();
5486 std::fs::create_dir_all(&sessions).unwrap();
5487 create_jsonl_session_with_id(
5488 &sessions,
5489 &project.to_string_lossy(),
5490 Some("cwdopen001".into()),
5491 )
5492 .await
5493 .unwrap();
5494
5495 let restored =
5496 open_session_by_id_in_dir("cwdopen001", &launch.to_string_lossy(), &sessions)
5497 .await
5498 .map_err(|error| error.to_string())
5499 .unwrap();
5500 assert_eq!(restored.recorded_cwd, Some(project.clone()));
5501 assert_eq!(restored.session.storage().metadata().id, "cwdopen001");
5502 }
5503
5504 #[tokio::test]
5513 async fn the_peeked_session_is_the_one_the_build_opens() {
5514 let tmp = tempfile::tempdir().unwrap();
5515 let first_project = tmp.path().join("project-first");
5516 let second_project = tmp.path().join("project-second");
5517 let launch = tmp.path().join("launch-dir");
5518 let sessions = tmp.path().join("sessions");
5519 for dir in [&first_project, &second_project, &launch, &sessions] {
5520 std::fs::create_dir_all(dir).unwrap();
5521 }
5522 create_jsonl_session_with_id(
5523 &sessions,
5524 &first_project.to_string_lossy(),
5525 Some("cwdrace001".into()),
5526 )
5527 .await
5528 .unwrap();
5529
5530 let mut args = Args {
5531 continue_session: true,
5532 session_dir: Some(sessions.clone()),
5533 ..Default::default()
5534 };
5535 let selection = select_session_in_dir(&args, sessions.clone());
5536 let peeked = peek_restored_session_cwd(&selection, &launch)
5537 .await
5538 .expect("a resumable session");
5539 assert_eq!(peeked.adopted, Some(first_project.clone()));
5540
5541 create_jsonl_session_with_id(
5543 &sessions,
5544 &second_project.to_string_lossy(),
5545 Some("cwdrace002".into()),
5546 )
5547 .await
5548 .unwrap();
5549
5550 pin_restored_selection(&mut args, &peeked);
5551 let pinned = select_session_in_dir(&args, sessions.clone());
5552 let reopened = resolve_restore_target(&pinned, &launch.to_string_lossy())
5553 .await
5554 .unwrap()
5555 .expect("the pinned session still exists");
5556 assert_eq!(
5557 reopened.id, peeked.id,
5558 "the build must open the session the peek adopted the directory of"
5559 );
5560 assert_eq!(reopened.cwd, first_project.to_string_lossy());
5561 }
5562
5563 #[test]
5568 fn trust_is_resolved_per_directory() {
5569 struct RestoreConfigDir(Option<std::ffi::OsString>);
5570
5571 impl Drop for RestoreConfigDir {
5572 fn drop(&mut self) {
5573 match self.0.take() {
5574 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5575 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5576 }
5577 }
5578 }
5579
5580 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5581 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5582 let tmp = tempfile::tempdir().unwrap();
5583 let agent = tmp.path().join("agent");
5584 std::fs::create_dir_all(&agent).unwrap();
5585 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5586
5587 let untrusted = tmp.path().join("project-untrusted");
5588 let launch = tmp.path().join("project-launch");
5589 std::fs::create_dir_all(&untrusted).unwrap();
5590 std::fs::create_dir_all(&launch).unwrap();
5591 crate::config::set_project_trust(&untrusted, Some(false)).unwrap();
5592
5593 let args = Args::default();
5594 assert!(!resolve_project_trust(&args, &untrusted));
5595 assert!(resolve_project_trust(&args, &launch));
5597 }
5598}