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 reload_models_mailbox: rpi_extensions::ReloadModelsMailbox,
409 ui_dialog_mailbox: rpi_extensions::UiDialogMailbox,
410 ext_status_mailbox: rpi_extensions::ExtensionStatusMailbox,
411 editor_text_mailbox: rpi_extensions::EditorTextMailbox,
412 action_bridge: Arc<rpi_extensions::ActionBridge>,
413}
414
415pub(crate) fn preflight_extension_startup(
420 args: &Args,
421 cwd: &Path,
422 trusted: bool,
423) -> Result<
424 (
425 Option<crate::resource_selection::ResolvedSelection>,
426 SessionSelection,
427 ),
428 BuildError,
429> {
430 let mut resources = crate::resource_selection::ResolvedSelection::load(args, cwd, trusted)
431 .map_err(BuildError::HarnessCreate)?;
432 let selection = select_session(args, cwd);
433 let pinned = std::thread::scope(|scope| {
434 scope
435 .spawn(|| {
436 tokio::runtime::Builder::new_current_thread()
437 .enable_all()
438 .build()
439 .map_err(|e| BuildError::SessionOpen(e.to_string()))?
440 .block_on(async {
441 if let Some(selected) = &mut resources {
442 let env: Arc<dyn rpi_tools::ExecutionEnv> =
443 Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
444 selected
445 .validate_skills(&env)
446 .await
447 .map_err(BuildError::HarnessCreate)?;
448 }
449 let cwd_str = cwd.to_string_lossy();
450 let target = match &selection {
451 SessionSelection::Fork { source, dir } => {
452 match session_metadata_at(Path::new(source)) {
453 Some(meta) => Some(meta),
454 None => find_session_metadata(source, &cwd_str, dir).await?,
455 }
456 }
457 _ => resolve_restore_target(&selection, &cwd_str).await?,
458 };
459 let Some(meta) = target else {
460 if resources.as_ref().is_some_and(|r| r.transition.is_some()) {
461 return Err(BuildError::SessionOpen("resource transition requires an existing native session".into()));
462 }
463 return Ok(selection.clone());
464 };
465 if resources.as_ref().is_some_and(|r| r.transition.is_some()) && matches!(selection, SessionSelection::Fork { .. }) {
466 return Err(BuildError::SessionOpen("resource transition cannot fork".into()));
467 }
468 use rpi_harness::session::jsonl::{
469 JsonlSessionRepo, JsonlSessionRepoOptions,
470 };
471 let dir = selection_dir(&selection);
472 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
473 fs: Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf())),
474 sessions_root: dir.to_string_lossy().into_owned(),
475 clock: Arc::new(SystemClock),
476 ids: Arc::new(DefaultIdGenerator::new()),
477 });
478 let binding_facts = StartupSpan::start(Phase::BindingFacts);
479 let facts = if resources.as_ref().is_some_and(|r| r.transition.is_some()) {
480 rpi_harness::session::jsonl::JsonlSessionStorage::read_resource_transition_facts(
481 Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf())), &meta.path,
482 Arc::new(SystemClock), Arc::new(DefaultIdGenerator::new()),
483 ).await
484 } else { repo.read_facts_by_jsonl_metadata(&meta).await }
485 .map_err(|e| BuildError::SessionOpen(e.to_string()))?;
486 crate::resource_selection::validate_session_binding(
487 &facts.values,
488 resources.as_ref(),
489 )
490 .map_err(BuildError::SessionOpen)?;
491 binding_facts.finish();
492 Ok(match selection {
493 SessionSelection::Fork { .. } => SessionSelection::Fork {
494 source: meta.path,
495 dir,
496 },
497 _ => SessionSelection::ByExactId { id: meta.id, dir },
498 })
499 })
500 })
501 .join()
502 .map_err(|_| BuildError::SessionOpen("resource preflight reader panicked".into()))?
503 })?;
504 Ok((resources, pinned))
505}
506
507pub fn prepare_extensions(
508 args: &Args,
509 cwd: &Path,
510 project_trusted: bool,
511) -> Result<PreparedExtensions, BuildError> {
512 let native_store =
513 crate::native_store::root(args, cwd).map_err(BuildError::NativeStorePolicy)?;
514 let resource_leases =
515 crate::resource_management::host_leases(cwd).map_err(BuildError::HarnessCreate)?;
516 let (resource_selection, session_selection) =
517 preflight_extension_startup(args, cwd, project_trusted)?;
518 let transition_session = if resource_selection
521 .as_ref()
522 .is_some_and(|r| r.transition.is_some())
523 {
524 Some(std::thread::scope(|scope| {
525 scope.spawn(|| {
526 tokio::runtime::Builder::new_current_thread().enable_all().build()
527 .map_err(|e| BuildError::SessionOpen(e.to_string()))?.block_on(async {
528 let meta = resolve_restore_target(&session_selection, &cwd.to_string_lossy()).await?
529 .ok_or_else(|| BuildError::SessionOpen("transition session disappeared".into()))?;
530 let path = std::fs::canonicalize(&meta.path).map_err(|e| BuildError::SessionOpen(e.to_string()))?;
531 claim_session_lock_inner(&path.to_string_lossy(), false).map_err(BuildError::SessionOpen)?;
532 let storage = rpi_harness::session::jsonl::JsonlSessionStorage::load_for_resource_transition(
533 Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf())), &path.to_string_lossy(),
534 Arc::new(SystemClock), Arc::new(DefaultIdGenerator::new()),
535 ).await.map_err(|e| BuildError::SessionOpen(e.to_string()))?;
536 let session = Session::new(Arc::new(storage), None);
537 crate::resource_selection::bind_session(&session, resource_selection.as_ref()).await.map_err(BuildError::SessionOpen)?;
538 Ok::<_, BuildError>(session)
539 })
540 }).join().map_err(|_| BuildError::SessionOpen("transition writer preparation panicked".into()))?
541 })?)
542 } else {
543 None
544 };
545 let session_dir = if matches!(session_selection, SessionSelection::Ephemeral) {
546 configured_session_dir(args, cwd)
547 } else {
548 selection_dir(&session_selection)
549 };
550 let cwd_str = cwd.to_string_lossy().to_string();
551 let tool_context = rpi_extensions::ToolCallContext::new(cwd_str.clone());
552 if !project_trusted && args.verbose {
553 eprintln!(
554 "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)"
555 );
556 }
557 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
576 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
577 })?;
578 let catalog = Vec::new();
579 let reload_models_mailbox = rpi_extensions::ReloadModelsMailbox::new();
583 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
584 catalog.clone(),
585 cwd.to_path_buf(),
586 runtime.clone(),
587 args.unknown_flags.clone(),
588 tool_context.clone(),
589 );
590 let action_host = Arc::new(
591 action_host
592 .with_session_dir(session_dir.clone())
593 .with_fresh_session_only(native_store.is_some())
594 .with_reload_models_mailbox(reload_models_mailbox.clone()),
595 );
596 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = action_host.clone();
597 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
607 let ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
613 let ext_status_mailbox = rpi_extensions::ExtensionStatusMailbox::new();
617 let editor_text_mailbox = rpi_extensions::EditorTextMailbox::new();
622 let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
623 runtime.clone(),
624 host_arc,
625 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
626 ui_dialog_mailbox.clone(),
627 ext_status_mailbox.clone(),
628 editor_text_mailbox.clone(),
629 );
630
631 let extension_register = StartupSpan::start(Phase::ExtensionRegister);
632 let extension_session = if let Some(selected) = &resource_selection {
633 let session = rpi_extensions::load_session_mixed_guarded(
634 &[],
635 &selected.extensions,
636 Arc::new(NullDiagnostics),
637 Some(Arc::clone(&action_bridge)),
638 Some(resource_leases.clone()),
639 );
640 if session.loaded_paths() != selected.extensions.as_slice() {
641 return Err(BuildError::HarnessCreate(
642 "selected native extension failed to load/register".into(),
643 ));
644 }
645 if session.owns_stdio() {
646 return Err(BuildError::HarnessCreate(
647 "stdio-owning extensions are unsupported with --resource-config".into(),
648 ));
649 }
650 session
651 } else if args.no_extensions {
652 ExtensionSession::none()
653 } else {
654 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
655 };
656 extension_register.finish();
657 if args.verbose {
658 if let Some(s) = extension_session.summary() {
659 eprintln!("extensions: {s}");
660 }
661 report_deferred_renderers(&extension_session);
662 }
663 crate::native_store::validate_stdio(native_store.is_some(), extension_session.owns_stdio())
664 .map_err(BuildError::NativeStorePolicy)?;
665 action_host.publish_registry_impl(extension_session.snapshot_arc());
670 Ok(PreparedExtensions {
671 resource_leases,
672 resource_selection,
673 session_selection,
674 transition_session,
675 native_store,
676 session_dir,
677 runtime,
678 tool_context,
679 action_host,
680 harness_cell,
681 reload_mailbox,
682 reload_models_mailbox,
683 ui_dialog_mailbox,
684 ext_status_mailbox,
685 editor_text_mailbox,
686 action_bridge,
687 extension_session,
688 })
689}
690
691pub async fn build(
709 resolved: &ResolvedModel,
710 args: &Args,
711 cwd: &Path,
712 project_trusted: bool,
713) -> Result<
714 (
715 AgentHarness,
716 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
717 ReloadContext,
718 ),
719 BuildError,
720> {
721 let prepared = prepare_extensions(args, cwd, project_trusted)?;
722 build_prepared(resolved, args, cwd, project_trusted, prepared).await
723}
724
725pub async fn build_prepared(
726 resolved: &ResolvedModel,
727 args: &Args,
728 cwd: &Path,
729 project_trusted: bool,
730 prepared: PreparedExtensions,
731) -> Result<
732 (
733 AgentHarness,
734 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
735 ReloadContext,
736 ),
737 BuildError,
738> {
739 let native_store =
740 crate::native_store::root(args, cwd).map_err(BuildError::NativeStorePolicy)?;
741 if native_store != prepared.native_store {
742 return Err(BuildError::NativeStorePolicy(
743 "experimental session selection changed after extension preparation".into(),
744 ));
745 }
746 crate::native_store::validate_stdio(
747 native_store.is_some(),
748 prepared.extension_session.owns_stdio(),
749 )
750 .map_err(BuildError::NativeStorePolicy)?;
751 let fresh_session_only = native_store.is_some();
752 let cwd_str = cwd.to_string_lossy().to_string();
753 let PreparedExtensions {
758 resource_leases,
759 resource_selection,
760 session_selection: selection,
761 transition_session,
762 native_store: _,
763 session_dir,
764 runtime,
765 tool_context,
766 action_host,
767 harness_cell,
768 reload_mailbox,
769 reload_models_mailbox,
770 ui_dialog_mailbox,
771 ext_status_mailbox,
772 editor_text_mailbox,
773 action_bridge,
774 extension_session,
775 } = prepared;
776 let catalog = crate::provider::authenticated_catalog(resolved);
777 action_host.set_catalog(catalog.clone());
778
779 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
781 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
782 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
783 let _registry = Arc::new(MutationQueueRegistry::new());
784 let file_io = Arc::new(rpi_tools::file_io::FileIORouter::new());
787 let ctx =
788 ExecutionToolContext::new(env_dyn.clone(), Some(mut_env)).with_file_io(file_io.clone());
789
790 let mut tools = build_tools(&ctx, args);
791
792 merge_extension_tools(&mut tools, &extension_session, args, &tool_context);
800 let active = if let Some(selected) = &resource_selection {
801 selected
802 .active_tools(
803 &tools
804 .iter()
805 .map(|t| t.tool.schema().name.clone())
806 .collect::<Vec<_>>(),
807 )
808 .map_err(BuildError::HarnessCreate)?
809 } else {
810 filter_active_tool_names(active_tool_names(&tools, args), args)
811 };
812
813 let session = match transition_session {
820 Some(session) => session,
821 None => build_session(&selection, &cwd_str, resource_selection.as_ref()).await?,
822 };
823 crate::resource_selection::bind_session(&session, resource_selection.as_ref())
824 .await
825 .map_err(BuildError::SessionOpen)?;
826 if let Ok(metadata) = session.get_metadata().await {
830 publish_session_identity(&tool_context, &metadata.id);
831 }
832 if let Some(requested) = args.name.as_deref() {
838 let name = requested.trim();
839 let name = if name.is_empty() { None } else { Some(name) };
840 if let Err(error) = session.set_name(name).await {
841 if fresh_session_only {
842 return Err(BuildError::SessionOpen(format!(
843 "native session name persistence failed: {error}"
844 )));
845 }
846 if args.verbose {
847 eprintln!("warning: could not set session name: {error}");
848 }
849 }
850 }
851 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
861 let prompt_tools = active.clone();
862 let prompt_metadata = extension_tool_prompt_metadata(&extension_session);
863 let default_prompt = || {
864 default_system_prompt_with_tool_prompts(
865 &cwd_str,
866 prior_reasoning_replayed,
867 &prompt_tools,
868 &prompt_metadata,
869 )
870 };
871 let mut context_paths = Vec::new();
872 let base_prompt = match args.system_prompt.as_deref() {
873 Some(explicit) => explicit.to_string(),
874 None if project_trusted && resource_selection.is_none() => {
875 match discover_system_prompt_file(cwd) {
876 Some(path) => {
877 read_prompt_source(&path, &mut context_paths).unwrap_or_else(default_prompt)
878 }
879 None => default_prompt(),
880 }
881 }
882 None => default_prompt(),
883 };
884
885 let mut append_texts: Vec<String> = Vec::new();
892 for extra in &args.append_system_prompt {
893 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
894 append_texts.push(text);
895 }
896 if let Some(selected) = &resource_selection {
897 append_texts.extend(
898 selected
899 .instructions
900 .iter()
901 .map(|(_, _, text)| text.clone()),
902 );
903 }
904 if args.append_system_prompt.is_empty() && resource_selection.is_none() {
905 if let Some(path) = project_trusted
906 .then(|| discover_append_system_prompt_file(cwd))
907 .flatten()
908 {
909 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
910 append_texts.push(text);
911 }
912 }
913 }
914 let append_join = if append_texts.is_empty() {
915 None
916 } else {
917 Some(append_texts.join("\n\n"))
918 };
919
920 let agent_dir = crate::config::agent_dir().ok();
932
933 let discovered = extension_session
949 .snapshot_arc()
950 .filter(|_| resource_selection.is_none())
951 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
952 .unwrap_or_default();
953
954 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
955 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
956 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
957 if let Some(selected) = &resource_selection {
958 skills = selected
961 .validated_skills()
962 .map_err(BuildError::HarnessCreate)?
963 .skills
964 .clone();
965 } else if !args.no_skills {
966 let mut dirs = if args.dev_local_only || args.project_only {
967 project_skill_dirs(cwd)
968 } else if project_trusted {
969 skill_dirs(cwd)
970 } else {
971 global_skill_dirs()
972 };
973 dirs.extend(args.skill.iter().cloned());
974 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
975 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
976 skills = result.skills;
977 skill_diags = result.diagnostics;
978 resource_diags.extend(result.resource_diagnostics);
979 }
980
981 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
982 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
983 if !args.no_prompt_templates && resource_selection.is_none() {
984 let mut dirs = if args.dev_local_only || args.project_only {
985 project_prompt_template_dirs(cwd)
986 } else if project_trusted {
987 prompt_template_dirs(cwd)
988 } else {
989 global_prompt_template_dirs()
990 };
991 dirs.extend(args.prompt_template.iter().cloned());
992 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
993 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
994 prompt_templates = result.prompt_templates;
995 prompt_diags = result.diagnostics;
996 resource_diags.extend(result.resource_diagnostics);
997 }
998
999 let context_block = if args.no_context_files || !project_trusted || resource_selection.is_some()
1000 {
1001 String::new()
1002 } else {
1003 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
1008 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
1009 context_paths.extend(
1010 files
1011 .iter()
1012 .map(|file| file.path.to_string_lossy().into_owned()),
1013 );
1014 format_project_context(&files)
1015 };
1016
1017 if args.verbose {
1019 for d in &skill_diags {
1020 eprintln!(
1021 "warning: skill {} ({}): {}",
1022 d.path,
1023 d.code.as_str(),
1024 d.message
1025 );
1026 }
1027 for d in &prompt_diags {
1028 eprintln!(
1029 "warning: prompt template {} ({}): {}",
1030 d.path,
1031 d.code.as_str(),
1032 d.message
1033 );
1034 }
1035 for d in &resource_diags {
1037 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1038 eprintln!(
1039 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1040 c.resource_type.as_str(),
1041 c.name,
1042 c.winner_path,
1043 c.loser_path
1044 );
1045 }
1046 }
1047 }
1048
1049 let system_prompt = compose_system_prompt(
1058 Some(&base_prompt),
1059 &[], if context_block.is_empty() {
1061 None
1062 } else {
1063 Some(&context_block)
1064 },
1065 append_join.as_deref(),
1066 );
1067
1068 if args.debug_system_prompt {
1077 eprintln!("=== --debug-system-prompt ===");
1078 let base_src = if args.system_prompt.is_some() {
1079 "--system-prompt"
1080 } else if discover_system_prompt_file(cwd).is_some() {
1081 "SYSTEM.md"
1082 } else {
1083 "default"
1084 };
1085 eprintln!("[base source: {base_src}]");
1086 eprintln!("--- base ---\n{base_prompt}");
1087 if let Some(append) = append_join.as_deref() {
1088 eprintln!("--- append ---\n{append}");
1089 } else {
1090 eprintln!("--- append: (none) ---");
1091 }
1092 if context_block.is_empty() {
1093 eprintln!("--- context: (none) ---");
1094 } else {
1095 eprintln!("--- context ---{context_block}");
1096 }
1097 let visible_skills = skills
1098 .iter()
1099 .filter(|s| s.disable_model_invocation != Some(true))
1100 .count();
1101 eprintln!(
1102 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
1103 skills.len(),
1104 visible_skills,
1105 skills.len() - visible_skills
1106 );
1107 for s in &skills {
1108 let hidden = if s.disable_model_invocation == Some(true) {
1109 " [hidden]"
1110 } else {
1111 ""
1112 };
1113 eprintln!(" {}{hidden} — {}", s.name, s.description);
1114 }
1115 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
1116 for t in &prompt_templates {
1117 eprintln!(" /{}", t.name);
1118 }
1119 eprintln!(
1123 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
1124 discovered.skill_paths.len(),
1125 discovered.prompt_paths.len(),
1126 discovered.theme_paths.len(),
1127 );
1128 for p in &discovered.skill_paths {
1129 eprintln!(" skill: {p}");
1130 }
1131 for p in &discovered.prompt_paths {
1132 eprintln!(" prompt: {p}");
1133 }
1134 eprintln!(
1135 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
1136 );
1137 eprintln!("=== end --debug-system-prompt ===");
1138 }
1139
1140 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
1145 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
1146 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
1150
1151 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1160 Some(snapshot) => {
1161 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1162 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
1163 }
1164 None => broadcast_emitter,
1165 };
1166
1167 let saved_settings = crate::settings::load_settings().unwrap_or_default();
1171
1172 if let Some(proxy) = &saved_settings.http_proxy {
1177 for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
1178 if std::env::var_os(key).is_none() {
1179 std::env::set_var(key, proxy);
1180 }
1181 }
1182 }
1183
1184 let extension_session_cell = Arc::new(Mutex::new(extension_session.clone()));
1189 let before_tool_call = extension_session
1194 .snapshot()
1195 .filter(|snapshot| {
1196 !snapshot
1197 .handlers_for(rpi_plugin_sdk::EventTag::ToolCall)
1198 .is_empty()
1199 })
1200 .map(|_| {
1201 extension_tool_call_hook(
1202 Arc::clone(&extension_session_cell),
1203 ui_dialog_mailbox.clone(),
1204 )
1205 });
1206
1207 let options = AgentHarnessOptions {
1208 model: resolved.model.clone(),
1209 thinking_level: resolved.thinking_level,
1210 active_tool_names: active.clone(),
1211 tools,
1212 system_prompt: Some(system_prompt),
1213 resources: AgentHarnessResources {
1214 context_files: context_paths,
1215 skills: if skills.is_empty() {
1216 None
1217 } else {
1218 Some(skills)
1219 },
1220 prompt_templates: if prompt_templates.is_empty() {
1221 None
1222 } else {
1223 Some(prompt_templates)
1224 },
1225 },
1226 allow_existing_session: matches!(
1229 selection,
1230 SessionSelection::Latest { .. }
1231 | SessionSelection::ById { .. }
1232 | SessionSelection::ByExactId { .. }
1233 | SessionSelection::Fork { .. }
1234 ),
1235 stream_options: AgentHarnessStreamOptions {
1236 timeout: args.timeout,
1237 stream_idle_timeout: args.stream_idle_timeout.or_else(|| {
1238 saved_settings
1239 .stream_idle_timeout
1240 .map(std::time::Duration::from_secs)
1241 }),
1242 thinking_budgets: saved_settings.thinking_budgets,
1243 ..Default::default()
1244 },
1245 retry: saved_settings.retry_policy(),
1250 compaction: saved_settings.compaction_settings(),
1251 steering_mode: saved_settings.steering_mode(),
1252 follow_up_mode: saved_settings.follow_up_mode(),
1253 tool_execution: HarnessToolExecution::default(),
1254 drive: DrivingMode::default(),
1255 session,
1256 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
1264 to_provider_messages: None,
1265 entry_projectors: Default::default(),
1266 agent_emitter: Some(emitter),
1267 before_tool_call,
1272 after_tool_call: None,
1273 transform_context: None,
1274 entry_transforms: Vec::new(),
1275 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1281 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1282 };
1283
1284 let harness = AgentHarness::create(options)
1285 .await
1286 .map_err(|e| BuildError::HarnessCreate(e.to_string()))?;
1287 harness.set_skill_execution_env(env_dyn.clone());
1290
1291 let harness_leases = resource_leases.clone();
1293 harness
1294 .events()
1295 .on_lifetime::<rpi_harness::events::RunEndEvent, _>(move |_| {
1296 let _ = &harness_leases;
1297 });
1298 install_extension_settlement_bridge(harness.events(), extension_session_cell.clone());
1299 if let Some(selected) = &resource_selection {
1300 harness
1301 .seal_resources(active)
1302 .map_err(|e| BuildError::HarnessCreate(e.to_string()))?;
1303 crate::resource_selection::attach(
1304 &harness,
1305 serde_json::json!({
1306 "version": 1,
1307 "hostId": uuid::Uuid::new_v4().to_string(),
1308 "host": {"name": crate::APP_NAME, "version": crate::VERSION, "pid": std::process::id()},
1309 "selection": selected.selection,
1310 "package": selected.package,
1311 "selectedSkills": selected.skills,
1312 "skillDiagnostics": [],
1313 "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<_>>(),
1314 "instructions": selected.provenance(),
1315 "baseSystemPrompt": "native-default",
1316 "projectContext": "disabled",
1317 "resourceDiscovery": "disabled",
1318 "changes": "restart-required"
1319 }),
1320 );
1321 let resource_observation = StartupSpan::start(Phase::ResourceObservation);
1322 crate::resource_selection::get_resource_state(&harness)
1323 .await
1324 .map_err(BuildError::HarnessCreate)?;
1325 resource_observation.finish();
1326 if selected.transition.is_some() {
1327 let meta = resolve_restore_target(&selection, &cwd_str)
1331 .await?
1332 .ok_or_else(|| {
1333 BuildError::SessionOpen("transition session disappeared before commit".into())
1334 })?;
1335 let path = std::fs::canonicalize(&meta.path)
1336 .map_err(|e| BuildError::SessionOpen(e.to_string()))?;
1337 let lock_path = PathBuf::from(format!("{}.lock", path.display()));
1338 if !SESSION_LOCK_PATHS
1339 .get_or_init(|| Mutex::new(HashSet::new()))
1340 .lock()
1341 .unwrap()
1342 .contains(&lock_path)
1343 {
1344 return Err(BuildError::SessionOpen(
1345 "resource transition lost canonical writer ownership".into(),
1346 ));
1347 }
1348 let facts =
1349 rpi_harness::session::jsonl::JsonlSessionStorage::read_resource_transition_facts(
1350 Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf())),
1351 &path.to_string_lossy(),
1352 Arc::new(SystemClock),
1353 Arc::new(DefaultIdGenerator::new()),
1354 )
1355 .await
1356 .map_err(|e| BuildError::SessionOpen(e.to_string()))?;
1357 crate::resource_selection::validate_session_binding(&facts.values, Some(selected))
1358 .map_err(BuildError::SessionOpen)?;
1359 let mirror = rpi_harness::session::SessionValues::load(harness.session())
1360 .await
1361 .map_err(|e| BuildError::SessionOpen(e.to_string()))?;
1362 if crate::resource_selection::binding_state(&mirror)
1363 != crate::resource_selection::binding_state(&facts.values)
1364 {
1365 return Err(BuildError::SessionOpen(
1366 "resource binding changed before commit".into(),
1367 ));
1368 }
1369 }
1370 let inventory_bind = StartupSpan::start(Phase::InventoryBind);
1371 crate::resource_selection::bind_inventory(&harness, selected)
1372 .await
1373 .map_err(BuildError::SessionOpen)?;
1374 inventory_bind.finish();
1375 }
1376 crate::extensions_actions::HarnessActionHost::set_harness(
1378 &harness_cell,
1379 Arc::new(harness.clone()),
1380 );
1381 register_file_io_router(&harness, &file_io);
1382
1383 {
1391 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
1392 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
1393 Ok(reports) => {
1394 for (lane, report) in reports {
1395 if report.is_clean() {
1396 continue;
1397 }
1398 if report.is_corrupt() {
1399 eprintln!(
1400 "warning: session lane '{lane}' is corrupt ({} open operations); \
1401 refusing to auto-repair — inspect the session file",
1402 report.open_operations.len()
1403 );
1404 continue;
1405 }
1406 for decision in runtime.reconcile(&report) {
1407 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
1408 if args.verbose {
1409 eprintln!(
1410 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
1411 );
1412 }
1413 }
1414 }
1415 runtime.emit_recovery_events(harness.events(), &report);
1416 }
1417 }
1418 Err(e) => {
1419 if args.verbose {
1420 eprintln!("warning: session recovery sweep failed: {e}");
1421 }
1422 }
1423 }
1424 }
1425
1426 let mut reload_args = args.clone();
1435 reload_args.session_dir = Some(session_dir);
1436 let reload_context = ReloadContext {
1437 resource_leases,
1438 fresh_session_only,
1439 extension_session: extension_session_cell,
1440 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
1441 tool_context,
1442 file_io,
1443 catalog: Arc::new(std::sync::RwLock::new(catalog)),
1444 providers: Arc::new(std::sync::RwLock::new(resolved.providers.clone())),
1445 runtime: runtime.clone(),
1446 cwd: cwd.to_path_buf(),
1447 project_trusted,
1448 discovered_theme_paths: Arc::new(std::sync::Mutex::new(discovered.theme_paths.clone())),
1449 args: reload_args,
1450 resolved_model: resolved.model.clone(),
1451 broadcast: broadcast_for_context,
1452 mailbox: reload_mailbox,
1453 reload_models_mailbox,
1456 ui_dialog: ui_dialog_mailbox,
1457 ext_status: ext_status_mailbox,
1458 editor_text: editor_text_mailbox,
1459 dev_extension: None,
1460 startup_notices: Vec::new(),
1461 };
1462
1463 Ok((harness, event_rx, reload_context))
1464}
1465
1466fn install_extension_settlement_bridge(
1470 events: &rpi_harness::events::HarnessEventBus,
1471 sessions: ExtensionSessionCell,
1472) {
1473 events.on_lifetime::<rpi_harness::events::RunEndEvent, _>(move |event| {
1474 if event.lane != "main" {
1475 return;
1476 }
1477 let session = sessions.lock().unwrap().clone();
1478 if let Some(snapshot) = session.snapshot_arc() {
1479 rpi_extensions::dispatch_empty_event(&snapshot, rpi_plugin_sdk::EventTag::AgentSettled);
1480 }
1481 });
1482}
1483
1484fn extension_tool_call_hook(
1487 sessions: ExtensionSessionCell,
1488 ui_dialog: rpi_extensions::UiDialogMailbox,
1489) -> rpi_agent::BeforeToolCall {
1490 Arc::new(move |ctx: rpi_agent::BeforeToolCallContext<'_>, signal| {
1491 let session = sessions
1492 .lock()
1493 .map(|session| session.clone())
1494 .map_err(|_| ());
1495 let ui_dialog = ui_dialog.clone();
1496 let tool_call_id = ctx.tool_call.id.clone();
1497 let tool_name = ctx.tool_call.name.clone();
1498 let params = ctx.args.clone();
1499 Box::pin(async move {
1500 let reason = match session {
1501 Ok(session) => {
1502 rpi_extensions::dispatch_tool_call_event(
1503 &session,
1504 &tool_call_id,
1505 &tool_name,
1506 ¶ms,
1507 &signal,
1508 &ui_dialog,
1509 )
1510 .await
1511 }
1512 Err(_) => Some("extension approval session is unavailable".to_string()),
1513 };
1514 reason.map(|reason| rpi_agent::BeforeToolCallResult {
1515 block: true,
1516 reason: Some(reason),
1517 terminate: false,
1518 args: None,
1519 })
1520 })
1521 })
1522}
1523
1524pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
1569
1570pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
1575
1576#[derive(Clone)]
1584pub struct ReloadContext {
1585 resource_leases: Arc<Vec<crate::resource_management::Lease>>,
1586 fresh_session_only: bool,
1588 pub file_io: Arc<rpi_tools::file_io::FileIORouter>,
1590 pub extension_session: ExtensionSessionCell,
1592 pub tool_context: rpi_extensions::ToolCallContext,
1597 pub action_bridge: ActionBridgeCell,
1599 pub reload_models_mailbox: rpi_extensions::ReloadModelsMailbox,
1604 pub ext_status: rpi_extensions::ExtensionStatusMailbox,
1607 pub editor_text: rpi_extensions::EditorTextMailbox,
1614 pub catalog: Arc<std::sync::RwLock<Vec<rpi_ai::Model>>>,
1617 pub providers: Arc<std::sync::RwLock<Vec<Arc<dyn Provider>>>>,
1620 pub runtime: tokio::runtime::Handle,
1624 pub cwd: PathBuf,
1626 pub project_trusted: bool,
1630 pub discovered_theme_paths: Arc<std::sync::Mutex<Vec<String>>>,
1635 pub args: Args,
1642 pub resolved_model: rpi_ai::Model,
1645 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
1651 pub mailbox: rpi_extensions::ReloadMailbox,
1658 pub ui_dialog: rpi_extensions::UiDialogMailbox,
1664 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
1668 pub startup_notices: Vec<String>,
1674}
1675
1676impl ReloadContext {
1677 pub(crate) fn session_dir(&self) -> PathBuf {
1678 self.args
1680 .session_dir
1681 .clone()
1682 .unwrap_or_else(|| configured_session_dir(&self.args, &self.cwd))
1683 }
1684}
1685
1686pub async fn dispatch_session_event_async(
1701 reload_context: &ReloadContext,
1702 tag: rpi_plugin_sdk::EventTag,
1703) -> Option<String> {
1704 let (snapshot, keepalive) = {
1710 let session = reload_context.extension_session.lock().unwrap();
1711 (session.snapshot_arc(), session.keepalive())
1712 };
1713 match snapshot {
1714 Some(snapshot) => rpi_extensions::dispatch_lifecycle_event(&snapshot, tag, keepalive).await,
1715 None => None,
1716 }
1717}
1718
1719async fn shutdown_extensions_for_reload(
1722 snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
1723 keepalive: Arc<rpi_extensions::PluginKeepalive>,
1724) -> Result<(), String> {
1725 if let Some(snapshot) = snapshot {
1726 tokio::task::spawn_blocking(move || {
1727 let _keepalive = keepalive;
1728 rpi_extensions::dispatch_empty_event(
1729 &snapshot,
1730 rpi_plugin_sdk::EventTag::SessionShutdown,
1731 );
1732 })
1733 .await
1734 .map_err(|error| error.to_string())?;
1735 }
1736 Ok(())
1737}
1738
1739pub struct ReloadOutcome {
1742 pub summary: String,
1745 pub had_warnings: bool,
1747 pub details: Vec<String>,
1752 pub had_errors: bool,
1756 pub project_trusted: Option<bool>,
1761}
1762
1763struct PreparedReloadInputs {
1764 extension_dirs: Vec<PathBuf>,
1765 skill_base_dirs: Vec<PathBuf>,
1766 prompt_base_dirs: Vec<PathBuf>,
1767}
1768
1769fn append_reload_resource_paths(
1774 mut paths: Vec<PathBuf>,
1775 explicit: &[PathBuf],
1776 discovered: &[String],
1777 js_paths: &[PathBuf],
1778 package_paths: &[PathBuf],
1779) -> Vec<PathBuf> {
1780 paths.extend(explicit.iter().cloned());
1781 paths.extend(discovered.iter().map(PathBuf::from));
1782 paths.extend(js_paths.iter().cloned());
1783 paths.extend(package_paths.iter().cloned());
1784 paths
1785}
1786
1787pub async fn reload_extension_resources(
1799 harness: &AgentHarness,
1800 ctx: &ReloadContext,
1801) -> ReloadOutcome {
1802 reload_extension_resources_inner(harness, ctx, || {}).await
1803}
1804
1805pub async fn reload_model_catalog_only(
1829 harness: &AgentHarness,
1830 ctx: &ReloadContext,
1831) -> Result<usize, String> {
1832 let project_trusted = resolve_project_trust(&ctx.args, &ctx.cwd);
1833
1834 let session_guard = ctx.extension_session.lock().unwrap();
1837 let extension_session = session_guard.clone();
1838 drop(session_guard);
1839
1840 let mut args = ctx.args.clone();
1844 let current = harness.lane("main").get_model().await.ok();
1845 if let Some(model) = current.as_ref() {
1846 args.provider = Some(model.provider.clone());
1847 args.model = Some(model.id.clone());
1848 if ctx.resolved_model.provider != model.provider {
1859 args.api_key = None;
1860 args.base_url = None;
1861 }
1862 }
1863 args.thinking = harness.lane("main").get_thinking_level().await.ok();
1866
1867 let resolved = crate::provider::resolve_with_extensions(
1868 &args,
1869 &ctx.cwd,
1870 project_trusted,
1871 &extension_session,
1872 )
1873 .map_err(|error| format!("重读模型清单失败:{error}"))?;
1874
1875 let catalog = crate::provider::authenticated_catalog(&resolved);
1876
1877 let providers = build_models_with_extensions_for_reload(
1881 &resolved.providers,
1882 &extension_session,
1883 ctx.runtime.clone(),
1884 );
1885 harness
1886 .set_models(providers)
1887 .await
1888 .map_err(|error| format!("换模型运行时失败:{error}"))?;
1889
1890 if let Some(bridge) = ctx.action_bridge.lock().unwrap().as_ref() {
1893 bridge.clone_host().replace_model_catalog(catalog.clone());
1894 }
1895 *ctx.catalog
1896 .write()
1897 .unwrap_or_else(|error| error.into_inner()) = catalog.clone();
1898 *ctx.providers
1899 .write()
1900 .unwrap_or_else(|error| error.into_inner()) = resolved.providers.clone();
1901
1902 let lane_model = current
1908 .as_ref()
1909 .and_then(|model| {
1910 catalog
1911 .iter()
1912 .find(|m| m.id == model.id && m.provider == model.provider)
1913 })
1914 .cloned()
1915 .unwrap_or_else(|| resolved.model.clone());
1916 harness
1917 .lane("main")
1918 .set_model(lane_model)
1919 .await
1920 .map_err(|error| format!("设置当前模型失败:{error}"))?;
1921
1922 tracing::info!(
1923 models = catalog.len(),
1924 provider = %resolved.model.provider,
1925 "模型清单已重读(未重载扩展)"
1926 );
1927 Ok(catalog.len())
1928}
1929
1930async fn reload_extension_resources_inner<F>(
1931 harness: &AgentHarness,
1932 ctx: &ReloadContext,
1933 after_prepare: F,
1934) -> ReloadOutcome
1935where
1936 F: FnOnce() + Send,
1937{
1938 if harness.has_exact_resources()
1939 || ctx.args.resource_config.is_some()
1940 || ctx.args.resource_transition.is_some()
1941 {
1942 return ReloadOutcome {
1943 summary:
1944 "Exact resource selection is restart-required; reload/dev hot apply unsupported"
1945 .into(),
1946 had_warnings: true,
1947 details: Vec::new(),
1948 had_errors: true,
1949 project_trusted: None,
1950 };
1951 }
1952 let mut effective_args = ctx.args.clone();
1957 if let Some(dev) = &ctx.dev_extension {
1958 if let Err(error) = dev
1959 .rebuild()
1960 .and_then(|_| dev.apply_to_args(&mut effective_args))
1961 {
1962 return ReloadOutcome {
1963 summary: format!(
1964 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1965 dev.package_name()
1966 ),
1967 had_warnings: true,
1968 details: Vec::new(),
1969 had_errors: true,
1970 project_trusted: None,
1971 };
1972 }
1973 }
1974 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1975 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1976 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1977 Ok(prepared) => prepared,
1978 Err(error) => {
1979 return ReloadOutcome {
1980 summary: format!(
1981 "Settings reload failed: {error}. Keeping the currently loaded resources."
1982 ),
1983 had_warnings: true,
1984 details: Vec::new(),
1985 had_errors: true,
1986 project_trusted: None,
1987 };
1988 }
1989 };
1990 after_prepare();
1991 let PreparedReloadInputs {
1992 extension_dirs,
1993 skill_base_dirs,
1994 prompt_base_dirs,
1995 } = prepared;
1996 let mut warnings = false;
1997
1998 let (shutdown_snapshot, shutdown_keepalive) = {
2002 let session = ctx.extension_session.lock().unwrap();
2003 (session.snapshot_arc(), session.keepalive())
2004 };
2005 if let Err(reason) = shutdown_extensions_for_reload(shutdown_snapshot, shutdown_keepalive).await
2006 {
2007 return ReloadOutcome {
2008 summary: "Reload failed during extension shutdown".into(),
2009 had_warnings: true,
2010 details: vec![reason],
2011 had_errors: true,
2012 project_trusted: None,
2013 };
2014 }
2015
2016 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
2023 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
2024 Some(b) => b.clone_host(),
2025 None => {
2026 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
2029 ctx.catalog
2030 .read()
2031 .unwrap_or_else(|error| error.into_inner())
2032 .clone(),
2033 ctx.cwd.clone(),
2034 ctx.runtime.clone(),
2035 ctx.args.unknown_flags.clone(),
2036 ctx.tool_context.clone(),
2037 );
2038 crate::extensions_actions::HarnessActionHost::set_harness(
2039 &_cell,
2040 Arc::new(harness.clone()),
2041 );
2042 Arc::new(
2043 action_host
2044 .with_session_dir(ctx.session_dir())
2045 .with_fresh_session_only(ctx.fresh_session_only),
2046 )
2047 }
2048 };
2049
2050 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
2051 let publish_host = Arc::clone(&host);
2052 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
2053 ctx.runtime.clone(),
2054 host,
2055 reload_cb,
2056 ctx.ui_dialog.clone(),
2057 ctx.ext_status.clone(),
2058 ctx.editor_text.clone(),
2059 );
2060
2061 let extension_session = if effective_args.no_extensions {
2062 rpi_extensions::ExtensionSession::none()
2063 } else {
2064 load_extensions_from_dirs(
2065 &effective_args,
2066 &extension_dirs,
2067 Some(Arc::clone(&fresh_bridge)),
2068 Some(ctx.resource_leases.clone()),
2069 )
2070 };
2071 if let Err(error) =
2072 crate::native_store::validate_stdio(ctx.fresh_session_only, extension_session.owns_stdio())
2073 {
2074 return ReloadOutcome {
2075 summary: "Reload refused by experimental native session policy".into(),
2076 had_warnings: true,
2077 details: vec![error],
2078 had_errors: true,
2079 project_trusted: None,
2080 };
2081 }
2082 if extension_session.is_empty() && !effective_args.no_extensions {
2083 }
2086 if effective_args.verbose {
2087 if let Some(s) = extension_session.summary() {
2088 eprintln!("reload: {s}");
2089 }
2090 report_deferred_renderers(&extension_session);
2091 }
2092
2093 let has_package_providers = |session: &ExtensionSession| {
2094 session.snapshot().is_some_and(|snapshot| {
2095 snapshot
2096 .providers()
2097 .iter()
2098 .any(|provider| snapshot.provider_manifest(&provider.provider_id).is_some())
2099 })
2100 };
2101 let packaged = has_package_providers(&extension_session)
2102 || has_package_providers(&ctx.extension_session.lock().unwrap());
2103 let refreshed = if packaged {
2104 let mut args = effective_args.clone();
2105 if let Ok(current) = harness.lane("main").get_model().await {
2106 let was_package = ctx
2107 .extension_session
2108 .lock()
2109 .unwrap()
2110 .snapshot()
2111 .is_some_and(|snapshot| snapshot.provider_manifest(¤t.provider).is_some());
2112 let has_replacement = extension_session.snapshot().is_some_and(|snapshot| {
2113 snapshot
2114 .providers()
2115 .iter()
2116 .any(|provider| provider.provider_id == current.provider)
2117 });
2118 if was_package && !has_replacement {
2119 return ReloadOutcome {
2120 summary: "Reload failed while resolving provider packages".into(),
2121 had_warnings: true,
2122 details: vec![format!(
2123 "active provider package '{}' is no longer loaded",
2124 current.provider
2125 )],
2126 had_errors: true,
2127 project_trusted: None,
2128 };
2129 }
2130 args.provider = Some(current.provider.clone());
2131 args.model = Some(current.id);
2132 if current.provider != ctx.resolved_model.provider {
2133 args.api_key = None;
2134 args.base_url = None;
2135 }
2136 }
2137 match crate::provider::resolve_with_extensions(
2138 &args,
2139 &ctx.cwd,
2140 ctx.project_trusted,
2141 &extension_session,
2142 ) {
2143 Ok(resolved) => Some(resolved),
2144 Err(error) => {
2145 return ReloadOutcome {
2146 summary: "Reload failed while resolving provider packages".into(),
2147 had_warnings: true,
2148 details: vec![error.to_string()],
2149 had_errors: true,
2150 project_trusted: None,
2151 }
2152 }
2153 }
2154 } else {
2155 None
2156 };
2157
2158 let old_session = {
2170 let mut session_guard = ctx.extension_session.lock().unwrap();
2171 let old_session = std::mem::replace(&mut *session_guard, extension_session.clone());
2172 if let Some(old_snap) = old_session.snapshot_arc() {
2173 old_snap.active_flag().store(false, Ordering::SeqCst);
2177 }
2178 old_session
2179 };
2180 if let Some(old_b) = old_bridge {
2181 old_b.invalidate();
2182 }
2183 old_session.begin_shutdown();
2185
2186 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
2188 publish_host.publish_registry_opt(extension_session.snapshot_arc());
2196
2197 let discovered = extension_session
2199 .snapshot_arc()
2200 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
2201 .unwrap_or_default();
2202 if let Ok(mut guard) = ctx.discovered_theme_paths.lock() {
2205 *guard = discovered.theme_paths.clone();
2206 }
2207
2208 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
2210 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2211
2212 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
2213 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
2214 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
2215 if !effective_args.no_skills {
2216 let js_paths: &[PathBuf] = &[];
2220 let package_paths: &[PathBuf] = &[];
2221 let dirs = append_reload_resource_paths(
2222 skill_base_dirs,
2223 &effective_args.skill,
2224 &discovered.skill_paths,
2225 js_paths,
2226 package_paths,
2227 );
2228 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
2229 skills = result.skills;
2230 skill_diags = result.diagnostics;
2231 resource_diags.extend(result.resource_diagnostics);
2232 }
2233
2234 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
2235 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
2236 if !effective_args.no_prompt_templates {
2237 let js_paths: &[PathBuf] = &[];
2238 let package_paths: &[PathBuf] = &[];
2239 let dirs = append_reload_resource_paths(
2240 prompt_base_dirs,
2241 &effective_args.prompt_template,
2242 &discovered.prompt_paths,
2243 js_paths,
2244 package_paths,
2245 );
2246 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
2247 prompt_templates = result.prompt_templates;
2248 prompt_diags = result.diagnostics;
2249 resource_diags.extend(result.resource_diagnostics);
2250 }
2251
2252 let context_files = if effective_args.no_context_files || !project_trusted {
2253 Vec::new()
2254 } else {
2255 let agent_dir = crate::config::agent_dir().ok();
2256 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
2257 load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await
2258 };
2259 let context_block = format_project_context(&context_files);
2260
2261 let mut details: Vec<String> = Vec::new();
2262 if let Some(resolved) = &refreshed {
2263 warnings |= !resolved.compat_warnings.is_empty();
2264 details.extend(
2265 resolved
2266 .compat_warnings
2267 .iter()
2268 .map(|warning| format!("warning: models.json: {warning}")),
2269 );
2270 }
2271 if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
2272 warnings = true;
2273 for d in &skill_diags {
2278 details.push(format!(
2279 "warning: skill {} ({}): {}",
2280 d.path,
2281 d.code.as_str(),
2282 d.message
2283 ));
2284 }
2285 for d in &prompt_diags {
2286 details.push(format!(
2287 "warning: prompt template {} ({}): {}",
2288 d.path,
2289 d.code.as_str(),
2290 d.message
2291 ));
2292 }
2293 for d in &resource_diags {
2295 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
2296 details.push(format!(
2297 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
2298 c.resource_type.as_str(),
2299 c.name,
2300 c.winner_path,
2301 c.loser_path
2302 ));
2303 }
2304 }
2305 }
2306
2307 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2316 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env))
2317 .with_file_io(ctx.file_io.clone());
2318 let mut tools = build_tools(&tool_ctx, &effective_args);
2319 merge_extension_tools(
2320 &mut tools,
2321 &extension_session,
2322 &effective_args,
2323 &ctx.tool_context,
2324 );
2325 let mut active = active_tool_names(&tools, &effective_args);
2326 active = filter_active_tool_names(active, &effective_args);
2327 let prompt_tools = active.clone();
2328 let prompt_metadata = extension_tool_prompt_metadata(&extension_session);
2329 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
2330 let default_prompt = || {
2331 default_system_prompt_with_tool_prompts(
2332 &cwd_str,
2333 prior_reasoning_replayed,
2334 &prompt_tools,
2335 &prompt_metadata,
2336 )
2337 };
2338 let mut context_paths = Vec::new();
2339 let base_prompt = match effective_args.system_prompt.as_deref() {
2340 Some(explicit) => explicit.to_string(),
2341 None if project_trusted => match discover_system_prompt_file(&ctx.cwd) {
2342 Some(path) => {
2343 read_prompt_source(&path, &mut context_paths).unwrap_or_else(default_prompt)
2344 }
2345 None => default_prompt(),
2346 },
2347 None => default_prompt(),
2348 };
2349 let mut append_texts: Vec<String> = Vec::new();
2350 for extra in &effective_args.append_system_prompt {
2351 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
2352 append_texts.push(text);
2353 }
2354 if effective_args.append_system_prompt.is_empty() {
2355 if let Some(path) = project_trusted
2356 .then(|| discover_append_system_prompt_file(&ctx.cwd))
2357 .flatten()
2358 {
2359 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
2360 append_texts.push(text);
2361 }
2362 }
2363 }
2364 context_paths.extend(
2365 context_files
2366 .iter()
2367 .map(|file| file.path.to_string_lossy().into_owned()),
2368 );
2369 let append_join = if append_texts.is_empty() {
2370 None
2371 } else {
2372 Some(append_texts.join("\n\n"))
2373 };
2374 let system_prompt = compose_system_prompt(
2375 Some(&base_prompt),
2376 &[],
2377 if context_block.is_empty() {
2378 None
2379 } else {
2380 Some(&context_block)
2381 },
2382 append_join.as_deref(),
2383 );
2384
2385 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
2387 Some(snapshot) => {
2388 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
2389 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
2390 }
2391 None => ctx.broadcast.clone(),
2392 };
2393
2394 let resources = AgentHarnessResources {
2396 context_files: context_paths,
2397 skills: if skills.is_empty() {
2398 None
2399 } else {
2400 Some(skills.clone())
2401 },
2402 prompt_templates: if prompt_templates.is_empty() {
2403 None
2404 } else {
2405 Some(prompt_templates.clone())
2406 },
2407 };
2408 let _ = harness.set_system_prompt(Some(system_prompt)).await;
2409 let _ = harness.set_resources(resources).await;
2410 let _ = harness.set_agent_emitter(Some(emitter)).await;
2411 let _ = harness
2412 .set_models(build_models_with_extensions_for_reload(
2413 &refreshed
2414 .as_ref()
2415 .map(|resolved| resolved.providers.clone())
2416 .unwrap_or_else(|| {
2417 ctx.providers
2418 .read()
2419 .unwrap_or_else(|error| error.into_inner())
2420 .clone()
2421 }),
2422 &extension_session,
2423 ctx.runtime.clone(),
2424 ))
2425 .await;
2426 let _ = harness
2427 .set_provider_hooks(
2428 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
2429 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
2430 )
2431 .await;
2432
2433 if let Some(resolved) = &refreshed {
2434 *ctx.providers
2435 .write()
2436 .unwrap_or_else(|error| error.into_inner()) = resolved.providers.clone();
2437 let catalog = crate::provider::authenticated_catalog(resolved);
2438 fresh_bridge
2439 .clone_host()
2440 .replace_model_catalog(catalog.clone());
2441 *ctx.catalog
2442 .write()
2443 .unwrap_or_else(|error| error.into_inner()) = catalog;
2444 let _ = harness.lane("main").set_model(resolved.model.clone()).await;
2447 }
2448
2449 let _ = harness.set_tools(tools, Some(active)).await;
2450
2451 let summary = format!(
2452 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
2453 extension_session.loaded_paths().len(),
2454 skills.len(),
2455 prompt_templates.len(),
2456 );
2457 ReloadOutcome {
2458 summary,
2459 had_warnings: warnings,
2460 details,
2461 had_errors: false,
2464 project_trusted: Some(project_trusted),
2465 }
2466}
2467
2468#[derive(Clone, Debug, PartialEq, Eq)]
2469struct ReloadSettingsFields {
2470 skill_dirs: Option<Vec<String>>,
2471 prompt_dirs: Option<Vec<String>>,
2472 extension_dirs: Option<Vec<String>>,
2473}
2474
2475impl From<crate::settings::Settings> for ReloadSettingsFields {
2476 fn from(settings: crate::settings::Settings) -> Self {
2477 Self {
2478 skill_dirs: settings.skill_dirs,
2479 prompt_dirs: settings.prompt_dirs,
2480 extension_dirs: settings.extension_dirs,
2481 }
2482 }
2483}
2484
2485#[derive(Clone, Debug, PartialEq, Eq)]
2486struct ReloadSettingsSnapshot {
2487 global: ReloadSettingsFields,
2488 project: Option<ReloadSettingsFields>,
2489}
2490
2491fn reload_reads_settings(args: &Args) -> bool {
2492 !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
2493}
2494
2495fn load_reload_settings_snapshot(
2499 args: &Args,
2500 cwd: &Path,
2501 project_trusted: bool,
2502) -> Result<Option<ReloadSettingsSnapshot>, String> {
2503 if !reload_reads_settings(args) {
2504 return Ok(None);
2505 }
2506 let global = crate::settings::load_settings()
2507 .map_err(|error| format!("could not load global settings: {error}"))?
2508 .into();
2509 let project = if project_trusted {
2510 crate::settings::load_active_project_settings(cwd)
2511 .map_err(|error| format!("could not load project settings: {error}"))?
2512 .map(|(_, settings)| settings.into())
2513 } else {
2514 None
2515 };
2516 Ok(Some(ReloadSettingsSnapshot { global, project }))
2517}
2518
2519#[cfg(test)]
2522fn validate_settings_for_reload(
2523 args: &Args,
2524 cwd: &Path,
2525 project_trusted: bool,
2526) -> Result<(), String> {
2527 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
2528}
2529
2530fn prepare_reload_inputs(
2531 args: &Args,
2532 cwd: &Path,
2533 project_trusted: bool,
2534) -> Result<PreparedReloadInputs, String> {
2535 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
2536}
2537
2538fn prepare_reload_inputs_inner<F>(
2539 args: &Args,
2540 cwd: &Path,
2541 project_trusted: bool,
2542 before_verify: F,
2543) -> Result<PreparedReloadInputs, String>
2544where
2545 F: FnOnce(),
2546{
2547 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
2548
2549 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
2550 Vec::new()
2551 } else if args.project_only {
2552 project_extension_dirs(cwd)
2553 } else if project_trusted {
2554 extension_dirs(cwd)
2555 } else {
2556 global_extension_dirs()
2557 };
2558 if !args.no_extensions {
2559 extension_dirs.extend(args.extensions_dir.iter().cloned());
2560 }
2561
2562 let skill_base_dirs = if args.no_skills {
2563 Vec::new()
2564 } else if args.dev_local_only || args.project_only {
2565 project_skill_dirs(cwd)
2566 } else if project_trusted {
2567 skill_dirs(cwd)
2568 } else {
2569 global_skill_dirs()
2570 };
2571
2572 let prompt_base_dirs = if args.no_prompt_templates {
2573 Vec::new()
2574 } else if args.dev_local_only || args.project_only {
2575 project_prompt_template_dirs(cwd)
2576 } else if project_trusted {
2577 prompt_template_dirs(cwd)
2578 } else {
2579 global_prompt_template_dirs()
2580 };
2581
2582 before_verify();
2583 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
2584 if settings_before != settings_after {
2585 return Err(
2586 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
2587 );
2588 }
2589
2590 Ok(PreparedReloadInputs {
2591 extension_dirs,
2592 skill_base_dirs,
2593 prompt_base_dirs,
2594 })
2595}
2596
2597fn build_models_with_extensions_for_reload(
2602 providers: &[Arc<dyn Provider>],
2603 extension_session: &ExtensionSession,
2604 runtime: tokio::runtime::Handle,
2605) -> Vec<Arc<dyn Provider>> {
2606 let mut models: Vec<Arc<dyn Provider>> = providers.to_vec();
2607 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
2608 for provider in pluggable {
2609 if extension_session
2613 .snapshot()
2614 .is_some_and(|snapshot| snapshot.provider_manifest(provider.id()).is_some())
2615 {
2616 continue;
2617 }
2618 if !models.iter().any(|known| known.id() == provider.id()) {
2619 models.push(provider);
2620 }
2621 }
2622 models
2623}
2624
2625fn report_deferred_renderers(session: &ExtensionSession) {
2629 let Some(snap) = session.snapshot_arc() else {
2630 return;
2631 };
2632 let all = snap.renderers();
2633 let markdown = all
2634 .iter()
2635 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
2636 .count();
2637 let message = all
2638 .iter()
2639 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
2640 .count();
2641 let entry = all
2642 .iter()
2643 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
2644 .count();
2645 if markdown + message + entry == 0 {
2646 return;
2647 }
2648 eprintln!(
2649 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
2650 markdown, message, entry
2651 );
2652}
2653
2654#[derive(Debug, thiserror::Error)]
2656pub enum BuildError {
2657 #[error("Experimental native session policy: {0}")]
2658 NativeStorePolicy(String),
2659 #[error("Could not create the session directory: {0}")]
2660 SessionDir(String),
2661 #[error("Could not open the saved session: {0}")]
2662 SessionOpen(String),
2663 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
2664 SessionNotFound { requested: String, dir: String },
2665 #[error("Could not build the harness: {0}")]
2666 HarnessCreate(String),
2667}
2668
2669fn build_models_with_extensions(
2672 resolved: &ResolvedModel,
2673 extension_session: &ExtensionSession,
2674 runtime: tokio::runtime::Handle,
2675) -> Vec<Arc<dyn Provider>> {
2676 build_models_with_extensions_for_reload(&resolved.providers, extension_session, runtime)
2677}
2678
2679pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
2682 if let Some(override_value) = args.trust_override {
2683 return override_value;
2684 }
2685 crate::config::project_trust_decision(cwd)
2686 .ok()
2687 .flatten()
2688 .unwrap_or(true)
2689}
2690
2691pub(crate) fn load_extensions(
2699 args: &Args,
2700 cwd: &Path,
2701 project_trusted: bool,
2702 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
2703) -> ExtensionSession {
2704 if args.no_extensions {
2705 return ExtensionSession::none();
2706 }
2707 let mut dirs = if args.dev_local_only {
2708 Vec::new()
2709 } else if args.project_only {
2710 project_extension_dirs(cwd)
2711 } else if project_trusted {
2712 extension_dirs(cwd)
2713 } else {
2714 global_extension_dirs()
2715 };
2716 dirs.extend(args.extensions_dir.iter().cloned());
2717 let leases = match crate::resource_management::host_leases(cwd) {
2718 Ok(leases) => leases,
2719 Err(error) => {
2720 eprintln!("warning: refusing extension loading: {error}");
2721 return ExtensionSession::none();
2722 }
2723 };
2724 load_extensions_from_dirs(args, &dirs, action_bridge, Some(leases))
2725}
2726
2727fn load_extensions_from_dirs(
2728 args: &Args,
2729 dirs: &[PathBuf],
2730 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
2731 host_guard: Option<Arc<dyn Send + Sync>>,
2732) -> ExtensionSession {
2733 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
2734 rpi_extensions::load_session_mixed_guarded(
2741 dirs,
2742 &args.extension,
2743 diagnostics,
2744 action_bridge,
2745 host_guard,
2746 )
2747}
2748
2749fn merge_extension_tools(
2755 tools: &mut Vec<HarnessTool>,
2756 session: &ExtensionSession,
2757 args: &Args,
2758 tool_context: &rpi_extensions::ToolCallContext,
2759) {
2760 let Some(snapshot) = session.snapshot() else {
2761 return;
2762 };
2763 for et in snapshot.tools() {
2764 let name = &et.tool.name;
2765 if !tool_name_allowed(name, args) {
2766 continue;
2767 }
2768 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
2769 .with_context(tool_context.clone());
2770 let harness_tool = HarnessTool::new(Arc::new(adapter));
2771 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
2772 Some(slot) => *slot = harness_tool,
2773 None => tools.push(harness_tool),
2774 }
2775 }
2776}
2777
2778pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
2785 use rpi_tools::tools::bash::BashToolOptions;
2786 let default = std::env::var("RPI_BASH_TIMEOUT")
2787 .ok()
2788 .and_then(|v| v.parse::<f64>().ok())
2789 .unwrap_or(rpi_tools::tools::bash::DEFAULT_BASH_TIMEOUT_SECONDS);
2790 let settings = crate::settings::load_settings().unwrap_or_default();
2796 BashToolOptions {
2797 command_prefix: settings.shell_command_prefix.clone(),
2798 default_timeout: Some(default),
2799 prepare: None,
2800 }
2801}
2802
2803fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
2804 if args.no_tools {
2805 return Vec::new();
2806 }
2807 let mut all: Vec<(&'static str, HarnessTool)> = vec![
2816 (
2817 "read",
2818 HarnessTool::new(create_read_tool(
2819 ctx,
2820 Some(rpi_tools::tools::read::ReadToolOptions {
2821 image_processor: Some(Arc::new(
2822 rpi_tools::image_processing::InlineImageProcessor,
2823 )),
2824 ..Default::default()
2825 }),
2826 ))
2827 .with_replay(ToolReplay::Safe),
2828 ),
2829 (
2830 "bash",
2831 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
2832 ),
2833 ("edit", HarnessTool::new(create_edit_tool(ctx))),
2834 ("write", HarnessTool::new(create_write_tool(ctx))),
2835 (
2836 "docs",
2837 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
2838 ),
2839 ];
2840
2841 #[cfg(target_os = "windows")]
2843 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
2844
2845 if args.tools.is_none() && args.resource_config.is_none() && args.resource_transition.is_none()
2847 {
2848 if let Ok(settings) = crate::settings::load_settings() {
2849 if let Some(default_tools) = &settings.default_tools {
2850 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
2851 }
2852 }
2853 }
2854
2855 if args.no_builtin_tools {
2859 all.clear();
2860 }
2861
2862 if let Some(allow) = &args.tools {
2864 all.retain(|(name, _)| allow.iter().any(|a| a == name));
2865 }
2866 if let Some(deny) = &args.exclude_tools {
2868 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
2869 }
2870
2871 all.into_iter().map(|(_, t)| t).collect()
2872}
2873
2874fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
2878 filter_active_tool_names(
2879 tools.iter().map(|tool| tool.tool.schema().name.clone()),
2880 args,
2881 )
2882}
2883
2884pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
2887 if args.no_tools {
2888 return false;
2889 }
2890 if args
2891 .tools
2892 .as_ref()
2893 .is_some_and(|allow| !allow.iter().any(|value| value == name))
2894 {
2895 return false;
2896 }
2897 if args
2898 .exclude_tools
2899 .as_ref()
2900 .is_some_and(|deny| deny.iter().any(|value| value == name))
2901 {
2902 return false;
2903 }
2904 true
2905}
2906
2907pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
2908where
2909 I: IntoIterator<Item = String>,
2910{
2911 names
2912 .into_iter()
2913 .filter(|name| tool_name_allowed(name, args))
2914 .collect()
2915}
2916
2917async fn build_session(
2919 selection: &SessionSelection,
2920 cwd: &str,
2921 selected: Option<&crate::resource_selection::ResolvedSelection>,
2922) -> Result<Session, BuildError> {
2923 match selection {
2924 SessionSelection::Ephemeral => Ok(ephemeral_session()),
2925 SessionSelection::ExperimentalNative { dir } => Ok(Session::new(
2926 backend::fresh_experimental_native(dir).await?,
2927 None,
2928 )),
2929 SessionSelection::New { dir, .. } => {
2930 std::fs::create_dir_all(dir)
2933 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2934 let session = create_jsonl_session(dir, cwd)
2935 .await
2936 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2937 Ok(session)
2938 }
2939 SessionSelection::Latest { .. }
2940 | SessionSelection::ById { .. }
2941 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2942 SessionSelection::Fork { source, dir } => {
2943 fork_session_at_launch(source, dir, cwd, selected).await
2944 }
2945 }
2946}
2947
2948async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2954 let Some(meta) = resolve_restore_target(selection, cwd).await? else {
2958 return match selection {
2959 SessionSelection::ByExactId { id, dir } => {
2962 std::fs::create_dir_all(dir)
2963 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2964 create_jsonl_session_with_id(dir, cwd, Some(id.clone()))
2965 .await
2966 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2967 }
2968 SessionSelection::Latest { dir } => Err(BuildError::SessionNotFound {
2969 requested: "the most recent session".to_string(),
2970 dir: dir.display().to_string(),
2971 }),
2972 SessionSelection::ById { id, dir } => Err(BuildError::SessionNotFound {
2973 requested: format!("session {id}"),
2974 dir: dir.display().to_string(),
2975 }),
2976 _ => unreachable!("resolve_restore_target only returns None for restore selections"),
2977 };
2978 };
2979 let sessions_root = selection_dir(selection);
2980 let writer_restore = StartupSpan::start(Phase::WriterRestore);
2981 let result = open_session(&meta, cwd, &sessions_root)
2982 .await
2983 .map(|restored| restored.session);
2984 if result.is_ok() {
2985 writer_restore.finish();
2986 }
2987 result
2988}
2989
2990fn selection_dir(selection: &SessionSelection) -> PathBuf {
2992 match selection {
2993 SessionSelection::Ephemeral => PathBuf::new(),
2994 SessionSelection::New { dir, .. }
2995 | SessionSelection::ExperimentalNative { dir }
2996 | SessionSelection::Latest { dir }
2997 | SessionSelection::ById { dir, .. }
2998 | SessionSelection::ByExactId { dir, .. }
2999 | SessionSelection::Fork { dir, .. } => dir.clone(),
3000 }
3001}
3002
3003async fn resolve_restore_target(
3010 selection: &SessionSelection,
3011 cwd: &str,
3012) -> Result<Option<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
3013 match selection {
3014 SessionSelection::Latest { dir } => {
3015 let metas = list_session_metadata_in_dir(cwd, dir).await?;
3016 Ok(first_resumable_session(&metas).cloned())
3017 }
3018 SessionSelection::ById { id, dir } => find_session_metadata(id, cwd, dir).await,
3019 SessionSelection::ByExactId { id, dir } => {
3020 let metas = list_session_metadata_in_dir(cwd, dir).await?;
3021 Ok(metas.into_iter().find(|m| m.id == *id))
3022 }
3023 SessionSelection::Fork { .. }
3024 | SessionSelection::ExperimentalNative { .. }
3025 | SessionSelection::New { .. }
3026 | SessionSelection::Ephemeral => Ok(None),
3027 }
3028}
3029
3030async fn find_session_metadata(
3034 id: &str,
3035 cwd: &str,
3036 dir: &Path,
3037) -> Result<Option<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
3038 let metas = list_session_metadata_in_dir(cwd, dir).await?;
3039 Ok(metas
3040 .into_iter()
3041 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id)))
3042}
3043
3044pub(crate) struct RestoredSessionCwd {
3046 pub id: String,
3052 pub recorded: PathBuf,
3054 pub adopted: Option<PathBuf>,
3057}
3058
3059pub(crate) async fn peek_restored_session_cwd(
3067 selection: &SessionSelection,
3068 launch_cwd: &Path,
3069) -> Option<RestoredSessionCwd> {
3070 let cwd_str = launch_cwd.to_string_lossy().into_owned();
3071 let meta = resolve_restore_target(selection, &cwd_str).await.ok()??;
3072 let recorded = PathBuf::from(&meta.cwd);
3073 if recorded.as_os_str().is_empty() {
3074 return None;
3075 }
3076 Some(RestoredSessionCwd {
3077 id: meta.id,
3078 adopted: crate::session_cwd::resolve_session_cwd(&recorded),
3079 recorded,
3080 })
3081}
3082
3083pub(crate) fn pin_restored_selection(args: &mut Args, restored: &RestoredSessionCwd) {
3093 args.continue_session = false;
3094 args.resume = false;
3095 args.session = Some(restored.id.clone());
3096}
3097
3098async fn fork_session_at_launch(
3101 source: &str,
3102 dir: &Path,
3103 cwd: &str,
3104 selected: Option<&crate::resource_selection::ResolvedSelection>,
3105) -> Result<Session, BuildError> {
3106 use rpi_harness::session::jsonl::{
3107 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
3108 };
3109 use rpi_harness::session::types::{ForkOptions, SessionStorage};
3110 use rpi_tools::FileSystem;
3111
3112 let dir = dir.to_path_buf();
3113 std::fs::create_dir_all(&dir)
3114 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
3115 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3116 let fs: Arc<dyn FileSystem> = env.clone();
3117 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3118 fs: fs.clone(),
3119 sessions_root: dir.to_string_lossy().into_owned(),
3120 clock: Arc::new(SystemClock),
3121 ids: Arc::new(DefaultIdGenerator::new()),
3122 });
3123 let source_meta = match session_metadata_at(Path::new(source)) {
3127 Some(meta) => meta,
3128 None => {
3129 let metas = repo
3130 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
3131 .await
3132 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
3133 metas
3134 .into_iter()
3135 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
3136 .ok_or_else(|| BuildError::SessionNotFound {
3137 requested: format!("--fork {source}"),
3138 dir: dir.display().to_string(),
3139 })?
3140 }
3141 };
3142 let fork_storage = repo
3143 .fork_typed_with_value_initializer(
3144 &source_meta,
3145 &JsonlSessionCreateOptions {
3146 id: None,
3147 parent_session_id: Some(source_meta.id.clone()),
3148 cwd: cwd.to_string(),
3149 metadata: None,
3150 },
3151 &ForkOptions::default(),
3152 |values| crate::resource_selection::fork_initial_values_for_selection(values, selected),
3153 )
3154 .await
3155 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
3156 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
3157 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
3158 Ok(Session::new(storage_arc, None))
3159}
3160
3161fn session_metadata_at(path: &Path) -> Option<rpi_harness::session::jsonl::JsonlSessionMetadata> {
3164 use rpi_harness::session::jsonl::codec::{metadata_from_header, parse_header};
3165 use std::io::BufRead;
3166
3167 let file = std::fs::File::open(path).ok()?;
3168 let modified_at = file
3169 .metadata()
3170 .ok()?
3171 .modified()
3172 .ok()?
3173 .duration_since(std::time::UNIX_EPOCH)
3174 .ok()?
3175 .as_millis() as i64;
3176 let mut first_line = String::new();
3177 std::io::BufReader::new(file)
3178 .read_line(&mut first_line)
3179 .ok()?;
3180 let header = parse_header(first_line.trim_end()).ok()?;
3181 let path = std::fs::canonicalize(path).ok()?;
3182 Some(metadata_from_header(
3183 &header,
3184 &path.to_string_lossy(),
3185 modified_at,
3186 ))
3187}
3188
3189pub enum OpenError {
3192 NotFound { requested: String },
3194 Other(String),
3196}
3197
3198impl std::fmt::Display for OpenError {
3199 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3200 match self {
3201 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
3202 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
3207 }
3208 }
3209}
3210
3211pub async fn list_session_metadata(
3214 cwd: &str,
3215) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
3216 let dir = default_session_dir(Path::new(cwd));
3217 list_session_metadata_in_dir(cwd, &dir).await
3218}
3219
3220pub(crate) async fn list_session_metadata_in_dir(
3221 cwd: &str,
3222 dir: &Path,
3223) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
3224 use rpi_harness::session::jsonl::{
3225 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
3226 };
3227 use rpi_tools::FileSystem;
3228
3229 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3230 let fs: Arc<dyn FileSystem> = env.clone();
3231 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3232 fs: fs.clone(),
3233 sessions_root: dir.to_string_lossy().into_owned(),
3234 clock: Arc::new(SystemClock),
3235 ids: Arc::new(DefaultIdGenerator::new()),
3236 });
3237 let metadata_lookup = StartupSpan::start(Phase::MetadataLookup);
3238 let result = repo
3239 .list_typed(&JsonlSessionListOptions::default())
3240 .await
3241 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")));
3242 if result.is_ok() {
3243 metadata_lookup.finish();
3244 }
3245 result
3246}
3247
3248#[derive(Debug, Clone, Default, PartialEq, Eq)]
3264pub struct SessionSummary {
3265 pub name: Option<String>,
3267 pub preview: Option<String>,
3269 pub empty: bool,
3271 pub bytes: u64,
3273}
3274
3275const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
3277const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
3279const SUMMARY_PREVIEW_CHARS: usize = 80;
3281
3282pub fn summarize_session_file(path: &Path) -> SessionSummary {
3286 use std::io::{Read, Seek, SeekFrom};
3287
3288 let Ok(mut file) = std::fs::File::open(path) else {
3289 return SessionSummary::default();
3290 };
3291 let Ok(size) = file.metadata().map(|m| m.len()) else {
3292 return SessionSummary::default();
3293 };
3294 if size == 0 {
3295 return SessionSummary {
3296 name: None,
3297 preview: None,
3298 empty: true,
3299 bytes: 0,
3300 };
3301 }
3302
3303 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
3306 let head_len = file.read(&mut head).unwrap_or(0);
3307 head.truncate(head_len);
3308 let head = String::from_utf8_lossy(&head);
3309 let mut lines: Vec<&str> = head.lines().collect();
3310 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
3311 let empty = (size as usize) <= header_len + 1;
3313 if (head_len as u64) < size && !lines.is_empty() {
3316 lines.pop();
3317 }
3318
3319 let mut summary = SessionSummary {
3320 name: None,
3321 preview: None,
3322 empty,
3323 bytes: size,
3324 };
3325 for line in lines {
3326 apply_summary_line(line, &mut summary);
3327 }
3328
3329 if size > SUMMARY_HEAD_BYTES
3333 && file
3334 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
3335 .is_ok()
3336 {
3337 let mut tail = Vec::new();
3338 if file.read_to_end(&mut tail).is_ok() {
3339 let tail = String::from_utf8_lossy(&tail);
3340 let mut tail_lines = tail.lines();
3341 tail_lines.next();
3343 for line in tail_lines {
3344 if let Some(name) = name_from_line(line) {
3345 summary.name = name;
3346 }
3347 }
3348 }
3349 }
3350 summary
3351}
3352
3353fn name_from_line(line: &str) -> Option<Option<String>> {
3356 let value: serde_json::Value = serde_json::from_str(line).ok()?;
3357 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
3358 return None;
3359 }
3360 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
3361 return None;
3362 }
3363 Some(match value.get("name") {
3364 Some(serde_json::Value::String(name)) => Some(name.clone()),
3365 _ => None,
3366 })
3367}
3368
3369fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
3372 if let Some(name) = name_from_line(line) {
3373 summary.name = name;
3374 return;
3375 }
3376 if summary.preview.is_some() {
3377 return;
3378 }
3379 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
3380 return;
3381 };
3382 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
3383 return;
3384 }
3385 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
3386 return;
3387 }
3388 let Some(message) = value.get("message") else {
3389 return;
3390 };
3391 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
3392 return;
3393 }
3394 let text = match message.get("content") {
3395 Some(serde_json::Value::String(text)) => Some(text.as_str()),
3396 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
3397 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
3398 .then(|| block.get("text").and_then(|v| v.as_str()))
3399 .flatten()
3400 }),
3401 _ => None,
3402 };
3403 let Some(text) = text else { return };
3404 let collapsed = text
3405 .lines()
3406 .map(str::trim)
3407 .filter(|line| !line.is_empty())
3408 .collect::<Vec<_>>()
3409 .join(" ");
3410 if collapsed.is_empty() {
3411 return;
3412 }
3413 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
3414}
3415
3416pub fn session_display_label(summary: &SessionSummary) -> String {
3420 if let Some(name) = summary.name.as_deref().map(str::trim) {
3421 if !name.is_empty() {
3422 return name.to_string();
3423 }
3424 }
3425 match summary.preview.as_deref().map(str::trim) {
3426 Some(preview) if !preview.is_empty() => preview.to_string(),
3427 _ => "(no messages)".to_string(),
3428 }
3429}
3430
3431pub fn format_session_bytes(bytes: u64) -> String {
3434 if bytes >= 1_048_576 {
3435 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
3436 } else if bytes >= 1024 {
3437 format!("{}KB", bytes / 1024)
3438 } else {
3439 format!("{bytes}B")
3440 }
3441}
3442
3443pub fn short_session_id(id: &str) -> &str {
3446 &id[..id.len().min(8)]
3447}
3448
3449pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
3458 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
3459 let mut visit = |path: PathBuf| {
3460 if path.extension().and_then(|e| e.to_str()) != Some("jsonl")
3461 || path.to_string_lossy().ends_with(".progress.jsonl")
3462 {
3463 return;
3464 }
3465 let modified = std::fs::metadata(&path)
3466 .and_then(|m| m.modified())
3467 .unwrap_or(std::time::UNIX_EPOCH);
3468 files.push((path, modified));
3469 };
3470 if let Ok(entries) = std::fs::read_dir(dir) {
3471 for entry in entries.flatten() {
3472 let path = entry.path();
3473 if path.is_dir() {
3474 if let Ok(inner) = std::fs::read_dir(&path) {
3475 for child in inner.flatten() {
3476 visit(child.path());
3477 }
3478 }
3479 } else {
3480 visit(path);
3481 }
3482 }
3483 }
3484 files.sort_by(|a, b| b.1.cmp(&a.1));
3485 files.into_iter().map(|(path, _)| path).collect()
3486}
3487
3488fn first_resumable_session(
3493 metas: &[rpi_harness::session::jsonl::JsonlSessionMetadata],
3494) -> Option<&rpi_harness::session::jsonl::JsonlSessionMetadata> {
3495 metas
3496 .iter()
3497 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
3498 .or_else(|| metas.first())
3499}
3500
3501pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
3505 let dir = default_session_dir(Path::new(cwd));
3506 open_session_by_id_in_dir(id, cwd, &dir)
3507 .await
3508 .map(|restored| restored.session)
3509}
3510
3511pub(crate) async fn open_session_by_id_in_dir(
3512 id: &str,
3513 cwd: &str,
3514 dir: &Path,
3515) -> Result<RestoredSession, OpenError> {
3516 let meta = find_session_metadata(id, cwd, dir)
3517 .await
3518 .map_err(|e| OpenError::Other(e.to_string()))?;
3519 let Some(meta) = meta else {
3520 return Err(OpenError::NotFound {
3521 requested: format!("session {id}"),
3522 });
3523 };
3524 open_session(&meta, cwd, dir).await.map_err(|e| {
3525 OpenError::Other(match e {
3526 BuildError::SessionOpen(reason) => reason,
3530 other => other.to_string(),
3531 })
3532 })
3533}
3534
3535fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
3560 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
3561 tool_context.set_session_id(id);
3562}
3563
3564pub(crate) async fn set_active_session(
3575 harness: &AgentHarness,
3576 session: Session,
3577 tool_context: Option<&rpi_extensions::ToolCallContext>,
3578) -> Result<(), String> {
3579 let metadata = session.get_metadata().await.ok();
3580 harness
3581 .set_session(session)
3582 .await
3583 .map_err(|e| e.to_string())?;
3584 if let Some(metadata) = metadata {
3585 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
3586 if let Some(context) = tool_context {
3587 context.set_session_id(metadata.id);
3588 }
3589 }
3590 Ok(())
3591}
3592
3593pub(crate) async fn fork_session_storage(
3600 harness: &AgentHarness,
3601 cwd: &str,
3602 dir: &Path,
3603) -> Result<Session, String> {
3604 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3605 use rpi_tools::FileSystem;
3606
3607 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3608 let fs: Arc<dyn FileSystem> = env.clone();
3609 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3610 fs,
3611 sessions_root: dir.to_string_lossy().into_owned(),
3612 clock: Arc::new(SystemClock),
3613 ids: Arc::new(DefaultIdGenerator::new()),
3614 });
3615 let id = harness.session().storage().metadata().id.clone();
3618 let metas = list_session_metadata_in_dir(cwd, dir)
3619 .await
3620 .map_err(|e| e.to_string())?;
3621 let Some(source) = metas.iter().find(|m| m.id == id) else {
3622 return Err(format!("current session {id} not found on disk"));
3623 };
3624 let fork_storage = repo
3625 .fork_typed_with_value_initializer(
3626 source,
3627 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
3628 id: None,
3629 parent_session_id: Some(source.id.clone()),
3630 cwd: cwd.to_string(),
3631 metadata: None,
3632 },
3633 &rpi_harness::session::types::ForkOptions::default(),
3634 crate::resource_selection::fork_initial_values,
3635 )
3636 .await
3637 .map_err(|e| e.to_string())?;
3638 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
3639 Ok(Session::new(Arc::new(fork_storage), None))
3640}
3641
3642pub(crate) async fn import_session_file(
3646 source: &Path,
3647 cwd: &str,
3648 dir: &Path,
3649) -> Result<Session, String> {
3650 use rpi_harness::session::jsonl::{
3651 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions, JsonlSessionStorage,
3652 };
3653 use rpi_harness::session::types::SessionStorage;
3654 use rpi_tools::FileSystem;
3655
3656 let dir = if dir.is_absolute() {
3657 dir.to_path_buf()
3658 } else {
3659 Path::new(cwd).join(dir)
3660 };
3661 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
3662 let source_progress = PathBuf::from(format!("{}.progress.jsonl", source.display()));
3666 let source_lock_path = PathBuf::from(format!("{}.lock", source.display()));
3667 let canonical_lock = std::fs::canonicalize(&source_lock_path).ok();
3668 let owned_here = SESSION_LOCK_PATHS.get().is_some_and(|paths| {
3669 paths.lock().unwrap().iter().any(|path| {
3670 path == &source_lock_path
3671 || canonical_lock.as_ref().is_some_and(|canonical| {
3672 std::fs::canonicalize(path).ok().as_ref() == Some(canonical)
3673 })
3674 })
3675 });
3676 let _source_lock = if !owned_here && source_progress.try_exists().map_err(|e| e.to_string())? {
3677 let lock = std::fs::OpenOptions::new()
3678 .create(true)
3679 .write(true)
3680 .open(format!("{}.lock", source.display()))
3681 .map_err(|e| format!("lock import source: {e}"))?;
3682 fs2::FileExt::try_lock_exclusive(&lock).map_err(|_| {
3683 "close the source session before importing its recovery pair".to_owned()
3684 })?;
3685 Some(lock)
3686 } else {
3687 None
3688 };
3689 let staging = tempfile::tempdir_in(&dir).map_err(|e| e.to_string())?;
3690 let staged = staging.path().join("history.stage");
3691 let staged_progress = PathBuf::from(format!("{}.progress.jsonl", staged.display()));
3692 let mut attempts = 0;
3693 let has_progress = loop {
3694 std::fs::copy(source, &staged).map_err(|e| format!("copy import: {e}"))?;
3695 let copied_history = std::fs::read(&staged).map_err(|e| e.to_string())?;
3696 let has_progress = match std::fs::copy(&source_progress, &staged_progress) {
3697 Ok(_) => true,
3698 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3699 let _ = std::fs::remove_file(&staged_progress);
3700 false
3701 }
3702 Err(error) => return Err(format!("copy progress import: {error}")),
3703 };
3704 if copied_history == std::fs::read(source).map_err(|e| e.to_string())? {
3705 break has_progress;
3706 }
3707 attempts += 1;
3708 if attempts >= 4 {
3709 return Err(
3710 "source history changed during import; retry when the session is idle".into(),
3711 );
3712 }
3713 tokio::task::yield_now().await;
3714 };
3715 let fs: Arc<dyn FileSystem> = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3716 let validated = JsonlSessionStorage::load(
3717 fs.clone(),
3718 &staged.to_string_lossy(),
3719 Arc::new(SystemClock),
3720 Arc::new(DefaultIdGenerator::new()),
3721 )
3722 .await
3723 .map_err(|e| format!("invalid session import: {e}"))?;
3724 validated
3725 .read_progress("")
3726 .await
3727 .map_err(|e| format!("invalid recovery import: {e}"))?;
3728 let mut metadata = validated.jsonl_metadata().clone();
3729 drop(validated);
3730 let text = std::fs::read_to_string(&staged).map_err(|e| e.to_string())?;
3731 let (first, rest) = text
3732 .split_once('\n')
3733 .ok_or_else(|| "import has no complete header".to_owned())?;
3734 let mut header =
3735 rpi_harness::session::jsonl::codec::parse_header(first).map_err(|e| e.to_string())?;
3736 if has_progress && header.progress_format.is_none() {
3737 return Err("history-only artifact has an undeclared recovery companion".into());
3738 }
3739 header.progress_format = Some("assistant-progress-v1".into());
3742 std::fs::write(
3743 &staged,
3744 format!(
3745 "{}{}",
3746 rpi_harness::session::jsonl::codec::encode_header(&header),
3747 rest
3748 ),
3749 )
3750 .map_err(|e| e.to_string())?;
3751 if list_session_metadata_in_dir(cwd, &dir)
3752 .await
3753 .map_err(|e| e.to_string())?
3754 .iter()
3755 .any(|saved| saved.id == metadata.id)
3756 {
3757 return Err(format!(
3758 "session {} already exists in {}",
3759 metadata.id,
3760 dir.display()
3761 ));
3762 }
3763 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3764 fs,
3765 sessions_root: dir.to_string_lossy().into_owned(),
3766 clock: Arc::new(SystemClock),
3767 ids: Arc::new(DefaultIdGenerator::new()),
3768 });
3769 let destination = repo
3771 .create_typed(&JsonlSessionCreateOptions {
3772 id: Some(metadata.id.clone()),
3773 parent_session_id: metadata.parent_session_id.clone(),
3774 cwd: cwd.to_string(),
3775 metadata: metadata.metadata.clone(),
3776 })
3777 .await
3778 .map_err(|e| e.to_string())?;
3779 let path = destination.jsonl_metadata().path.clone();
3780 drop(destination);
3781 if let Err(error) = claim_session_lock(&path) {
3782 return Err(error);
3784 }
3785 let destination_progress = format!("{path}.progress.jsonl");
3786 let publish = (|| -> std::io::Result<()> {
3787 if has_progress {
3788 std::fs::rename(&staged_progress, &destination_progress)?;
3789 }
3790 std::fs::rename(&staged, &path)
3791 })();
3792 if let Err(error) = publish {
3793 let _ = std::fs::remove_file(&path);
3794 let _ = std::fs::remove_file(&destination_progress);
3795 return Err(format!("publish import: {error}"));
3796 }
3797 metadata.path = path;
3798 let storage = repo
3799 .open_by_jsonl_metadata(&metadata)
3800 .await
3801 .map_err(|e| e.to_string())?;
3802 Ok(Session::new(Arc::new(storage), None))
3803}
3804
3805static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
3807static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
3810
3811fn claim_session_lock(session_path: &str) -> Result<(), String> {
3820 claim_session_lock_inner(session_path, true)
3821}
3822fn claim_session_lock_inner(session_path: &str, allow_reentry: bool) -> Result<(), String> {
3823 let canonical =
3824 std::fs::canonicalize(session_path).unwrap_or_else(|_| PathBuf::from(session_path));
3825 let lock_path = PathBuf::from(format!("{}.lock", canonical.display()));
3826 let mut paths = SESSION_LOCK_PATHS
3827 .get_or_init(|| Mutex::new(HashSet::new()))
3828 .lock()
3829 .unwrap();
3830 if paths.contains(&lock_path) {
3831 return if allow_reentry {
3832 Ok(())
3833 } else {
3834 Err("transition session already has a writer in this process".into())
3835 };
3836 }
3837 let file = std::fs::OpenOptions::new()
3838 .create(true)
3839 .write(true)
3840 .open(&lock_path)
3841 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
3842 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
3843 return Err(format!(
3844 "session {session_path} is already open in another rpi process"
3845 ));
3846 }
3847 SESSION_LOCKS
3848 .get_or_init(|| Mutex::new(Vec::new()))
3849 .lock()
3850 .unwrap()
3851 .push(file);
3852 paths.insert(lock_path);
3853 Ok(())
3854}
3855
3856pub(crate) struct RestoredSession {
3865 pub session: Session,
3866 pub recorded_cwd: Option<PathBuf>,
3869}
3870
3871async fn open_session(
3878 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
3879 cwd: &str,
3880 sessions_root: &Path,
3881) -> Result<RestoredSession, BuildError> {
3882 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3883 use rpi_harness::session::types::SessionStorage;
3884 use rpi_tools::FileSystem;
3885
3886 let dir = sessions_root;
3887 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3888 let fs: Arc<dyn FileSystem> = env.clone();
3889 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3890 fs: fs.clone(),
3891 sessions_root: dir.to_string_lossy().into_owned(),
3892 clock: Arc::new(SystemClock),
3893 ids: Arc::new(DefaultIdGenerator::new()),
3894 });
3895 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
3896 let storage = repo
3897 .open_by_jsonl_metadata(meta)
3898 .await
3899 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
3900 let storage = storage
3903 .enable_progress_under_exclusive_lock()
3904 .await
3905 .map_err(|e| BuildError::SessionOpen(format!("upgrade {}: {e}", meta.path)))?;
3906 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
3907 Ok(RestoredSession {
3908 session: Session::new(storage_arc, None),
3909 recorded_cwd: crate::session_cwd::resolve_session_cwd(Path::new(&meta.cwd)),
3910 })
3911}
3912
3913pub(crate) fn ephemeral_session() -> Session {
3917 let storage = Arc::new(InMemorySessionStorage::new(
3918 SessionMetadata {
3919 id: "ephemeral".into(),
3920 created_at: 0,
3921 parent_session_id: None,
3922 },
3923 Arc::new(SystemClock),
3924 Arc::new(DefaultIdGenerator::new()),
3925 ));
3926 Session::new(storage, None).with_volatile_progress()
3927}
3928
3929pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
3940 create_jsonl_session_with_id(dir, cwd, None).await
3941}
3942
3943pub(crate) async fn create_jsonl_session_with_id(
3947 dir: &Path,
3948 cwd: &str,
3949 id: Option<String>,
3950) -> Result<Session, String> {
3951 use rpi_harness::session::jsonl::{
3952 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
3953 };
3954 use rpi_tools::FileSystem;
3955
3956 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3959 let fs: Arc<dyn FileSystem> = env.clone();
3960
3961 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3962 fs: fs.clone(),
3963 sessions_root: dir.to_string_lossy().into_owned(),
3964 clock: Arc::new(SystemClock),
3965 ids: Arc::new(DefaultIdGenerator::new()),
3966 });
3967
3968 let opts = JsonlSessionCreateOptions {
3969 id, parent_session_id: None,
3971 cwd: cwd.to_string(),
3972 metadata: None,
3973 };
3974 let storage = repo
3975 .create_typed(&opts)
3976 .await
3977 .map_err(|e| format!("create session: {e}"))?;
3978 claim_session_lock(&storage.jsonl_metadata().path)
3979 .map_err(|e| format!("create session: {e}"))?;
3980 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
3982 Ok(Session::new(storage_arc, None))
3983}
3984
3985fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
3988 let path = Path::new(target);
3989 if path.is_file() {
3990 read_prompt_source(path, sources)
3991 } else {
3992 None
3993 }
3994}
3995
3996fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
3999 let text = std::fs::read_to_string(path).ok()?;
4000 let absolute = if path.is_absolute() {
4001 path.to_path_buf()
4002 } else {
4003 std::env::current_dir()
4004 .map(|cwd| cwd.join(path))
4005 .unwrap_or_else(|_| path.to_path_buf())
4006 };
4007 let source = absolute.to_string_lossy().into_owned();
4008 if !sources.contains(&source) {
4009 sources.push(source);
4010 }
4011 Some(text)
4012}
4013
4014#[cfg(test)]
4015mod goal_outbox_smoke_tests;
4016#[cfg(all(test, feature = "experimental-native-store"))]
4017mod native_roundtrip_tests;
4018#[cfg(all(test, feature = "experimental-native-store"))]
4019mod native_store_tests;
4020#[cfg(test)]
4021mod runtime_session_tests;
4022
4023#[cfg(test)]
4024mod tests {
4025 use super::*;
4026
4027 #[test]
4028 fn settlement_bridge_follows_reload_and_releases_its_session_with_the_bus() {
4029 use rpi_harness::events::{HarnessEvent, HarnessEventBus, RunEndEvent, RunEndOutcome};
4030 use std::sync::atomic::{AtomicUsize, Ordering};
4031 static OLD: AtomicUsize = AtomicUsize::new(0);
4032 static NEW: AtomicUsize = AtomicUsize::new(0);
4033 extern "C" fn old(_: rpi_plugin_sdk::StablePluginEvent, _: *mut std::ffi::c_void) -> i32 {
4034 OLD.fetch_add(1, Ordering::SeqCst);
4035 0
4036 }
4037 extern "C" fn new(_: rpi_plugin_sdk::StablePluginEvent, _: *mut std::ffi::c_void) -> i32 {
4038 NEW.fetch_add(1, Ordering::SeqCst);
4039 0
4040 }
4041 let make = |handler: rpi_plugin_sdk::EventHandlerFn| {
4042 let mut registry = rpi_extensions::ExtensionRegistry::new();
4043 registry.register_event_handler(
4044 "settlement".into(),
4045 rpi_plugin_sdk::EventTag::AgentSettled,
4046 handler,
4047 std::ptr::null_mut(),
4048 );
4049 ExtensionSession::from_parts(
4050 Arc::new(registry.snapshot()),
4051 rpi_extensions::PluginKeepalive::empty(),
4052 vec![],
4053 None,
4054 )
4055 };
4056 let sessions = Arc::new(Mutex::new(make(old)));
4057 let weak = Arc::downgrade(&sessions);
4058 let bus = HarnessEventBus::new();
4059 install_extension_settlement_bridge(&bus, sessions.clone());
4060 let ended = |outcome| {
4061 HarnessEvent::RunEnd(RunEndEvent {
4062 lane: "main".into(),
4063 run_id: "run".into(),
4064 outcome,
4065 leaf_id: "leaf".into(),
4066 })
4067 };
4068 bus.emit(&ended(RunEndOutcome::Completed));
4069 assert_eq!(OLD.load(Ordering::SeqCst), 1);
4070 *sessions.lock().unwrap() = make(new);
4071 for outcome in [RunEndOutcome::Aborted, RunEndOutcome::Failed] {
4072 bus.emit(&ended(outcome));
4073 }
4074 assert_eq!(OLD.load(Ordering::SeqCst), 1);
4075 assert_eq!(NEW.load(Ordering::SeqCst), 2);
4076 drop(sessions);
4077 assert!(weak.upgrade().is_some());
4078 drop(bus);
4079 assert!(
4080 weak.upgrade().is_none(),
4081 "settlement observer must not create an ownership cycle"
4082 );
4083 }
4084
4085 #[tokio::test]
4086 async fn unauthenticated_packages_are_not_reintroduced_as_legacy_adapters() {
4087 extern "C" fn request(
4088 _: rpi_plugin_sdk::StbStringRef,
4089 _: *mut rpi_plugin_sdk::StbString,
4090 _: *mut std::ffi::c_void,
4091 ) -> i32 {
4092 1
4093 }
4094 let mut registry = rpi_extensions::ExtensionRegistry::new();
4095 for id in ["package", "legacy"] {
4096 registry.register_provider(rpi_extensions::RegisteredProvider {
4097 provider_id: id.into(),
4098 base_url: "http://localhost".into(),
4099 api_style: "openai-completions".into(),
4100 request_fn: request,
4101 stream_fn: None,
4102 plugin_free_string: rpi_plugin_sdk::callbacks::free_string,
4103 user_data: std::ptr::null_mut(),
4104 });
4105 }
4106 assert!(registry.apply_declaration_json(&serde_json::json!({"providers":[{
4107 "id":"package", "requiresAuth":true,
4108 "models":[rpi_ai::Model::new("model", "Model", rpi_ai::Api::OpenaiCompletions, "package", "http://localhost")]
4109 }]}).to_string()));
4110 let session = ExtensionSession::from_parts(
4111 Arc::new(registry.snapshot()),
4112 rpi_extensions::PluginKeepalive::empty(),
4113 vec![],
4114 None,
4115 );
4116 let providers = build_models_with_extensions_for_reload(
4117 &[],
4118 &session,
4119 tokio::runtime::Handle::current(),
4120 );
4121 assert_eq!(
4122 providers
4123 .iter()
4124 .map(|provider| provider.id())
4125 .collect::<Vec<_>>(),
4126 ["legacy"]
4127 );
4128 }
4129
4130 #[test]
4134 fn catalog_refresh_keeps_model_identity_but_takes_new_metadata() {
4135 let stale = rpi_ai::Model {
4136 base_url: "http://localhost/old".into(),
4137 context_window: 8192,
4138 ..rpi_ai::Model::new(
4139 "model",
4140 "Model",
4141 rpi_ai::Api::OpenaiCompletions,
4142 "probe",
4143 "http://localhost",
4144 )
4145 };
4146 let catalog = vec![rpi_ai::Model {
4147 base_url: "http://localhost/new".into(),
4148 context_window: 32768,
4149 ..stale.clone()
4150 }];
4151 let current = Some(stale);
4152
4153 let lane_model = current
4154 .as_ref()
4155 .and_then(|model| {
4156 catalog
4157 .iter()
4158 .find(|m| m.id == model.id && m.provider == model.provider)
4159 })
4160 .cloned()
4161 .unwrap_or_else(|| catalog[0].clone());
4162
4163 assert_eq!(lane_model.base_url, "http://localhost/new");
4164 assert_eq!(lane_model.context_window, 32768);
4165 }
4166
4167 use crate::args::Args;
4168
4169 #[test]
4170 fn loaded_context_paths_track_precedence_and_reload() {
4171 const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
4172 if std::env::var_os(CHILD).is_none() {
4173 let dir = tempfile::tempdir().unwrap();
4174 let output = std::process::Command::new(std::env::current_exe().unwrap())
4175 .args([
4176 "--exact",
4177 "session::tests::loaded_context_paths_track_precedence_and_reload",
4178 "--nocapture",
4179 ])
4180 .env(CHILD, "1")
4181 .env(crate::config::CONFIG_DIR_ENV, dir.path())
4182 .output()
4183 .unwrap();
4184 assert!(
4185 output.status.success(),
4186 "{}\n{}",
4187 String::from_utf8_lossy(&output.stdout),
4188 String::from_utf8_lossy(&output.stderr)
4189 );
4190 return;
4191 }
4192 tokio::runtime::Builder::new_current_thread()
4193 .enable_all()
4194 .build()
4195 .unwrap()
4196 .block_on(async {
4197 let agent = crate::config::agent_dir().unwrap();
4198 let cwd = agent.join("project");
4199 let project_config = cwd.join(".rpi");
4200 std::fs::create_dir_all(&project_config).unwrap();
4201 for (path, content) in [
4202 (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
4203 (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
4204 (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
4205 (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
4206 (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
4207 (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
4208 (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
4209 ] {
4210 std::fs::write(path, content).unwrap();
4211 }
4212 let resolved = crate::provider::resolve(
4213 Some("anthropic"),
4214 Some(crate::provider::DEFAULT_MODEL_ID),
4215 None,
4216 Some("test-key"),
4217 None,
4218 )
4219 .unwrap();
4220 let args = Args {
4221 no_session: true,
4222 no_extensions: true,
4223 no_skills: true,
4224 no_prompt_templates: true,
4225 trust_override: Some(true),
4226 ..Default::default()
4227 };
4228 let (harness, _events, mut context) =
4229 build(&resolved, &args, &cwd, true).await.unwrap();
4230 let resources = harness.get_resources().await.unwrap();
4231 let path = |p: PathBuf| p.to_string_lossy().into_owned();
4232 assert_eq!(
4233 &resources.context_files[..2],
4234 &[
4235 path(project_config.join("SYSTEM.md")),
4236 path(project_config.join("APPEND_SYSTEM.md")),
4237 ]
4238 );
4239 assert!(resources
4240 .context_files
4241 .contains(&path(agent.join("AGENTS.md"))));
4242 assert!(resources
4243 .context_files
4244 .contains(&path(cwd.join("AGENTS.md"))));
4245 assert!(!resources
4246 .context_files
4247 .contains(&path(cwd.join("CLAUDE.md"))));
4248 assert!(!resources
4249 .context_files
4250 .contains(&path(agent.join("SYSTEM.md"))));
4251 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
4252 assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
4253 assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
4254
4255 std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
4258 std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
4259 std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
4260 assert!(
4261 !reload_extension_resources(&harness, &context)
4262 .await
4263 .had_errors
4264 );
4265 let resources = harness.get_resources().await.unwrap();
4266 assert_eq!(
4267 &resources.context_files[..2],
4268 &[
4269 path(agent.join("SYSTEM.md")),
4270 path(agent.join("APPEND_SYSTEM.md")),
4271 ]
4272 );
4273 assert!(resources
4274 .context_files
4275 .contains(&path(cwd.join("CLAUDE.md"))));
4276 assert!(!resources
4277 .context_files
4278 .contains(&path(project_config.join("SYSTEM.md"))));
4279
4280 let explicit = agent.join("custom-append.md");
4283 std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
4284 context.args.system_prompt = Some("EXPLICIT_BASE".into());
4285 context.args.append_system_prompt =
4286 vec![path(explicit.clone()), "literal append".into()];
4287 context.args.no_context_files = true;
4288 assert!(
4289 !reload_extension_resources(&harness, &context)
4290 .await
4291 .had_errors
4292 );
4293 assert_eq!(
4294 harness.get_resources().await.unwrap().context_files,
4295 vec![path(explicit)]
4296 );
4297
4298 context.args.system_prompt = None;
4300 context.args.append_system_prompt.clear();
4301 context.args.no_context_files = false;
4302 context.args.trust_override = Some(false);
4303 assert!(
4304 !reload_extension_resources(&harness, &context)
4305 .await
4306 .had_errors
4307 );
4308 assert!(harness
4309 .get_resources()
4310 .await
4311 .unwrap()
4312 .context_files
4313 .is_empty());
4314 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
4315 assert!(
4316 !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
4317 );
4318 });
4319 }
4320
4321 #[test]
4322 fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
4323 let dir = tempfile::tempdir().unwrap();
4324 let file = dir.path().join("SYSTEM.md");
4325 let mut sources = Vec::new();
4326 assert!(read_prompt_source(&file, &mut sources).is_none());
4327 std::fs::write(&file, [0xff, 0xfe]).unwrap();
4328 assert!(read_prompt_source(&file, &mut sources).is_none());
4329 assert!(read_append_target("literal instructions", &mut sources).is_none());
4330 assert!(sources.is_empty());
4331 std::fs::write(&file, "loaded instructions").unwrap();
4332 assert_eq!(
4333 read_prompt_source(&file, &mut sources).as_deref(),
4334 Some("loaded instructions")
4335 );
4336 assert!(read_prompt_source(&file, &mut sources).is_some());
4337 assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
4338 }
4339
4340 extern "C" fn deny_test_tool_call(
4341 _event: rpi_plugin_sdk::StablePluginEvent,
4342 _data: *mut std::ffi::c_void,
4343 ) -> i32 {
4344 rpi_plugin_sdk::EVENT_HANDLER_ABORT
4345 }
4346
4347 async fn invoke_test_tool_hook(
4348 hook: &rpi_agent::BeforeToolCall,
4349 ) -> Option<rpi_agent::BeforeToolCallResult> {
4350 let message = rpi_ai::providers::faux::faux_assistant_message(
4351 "approval",
4352 rpi_ai::types::StopReason::ToolUse,
4353 );
4354 let rpi_ai::types::Content::ToolCall(call) =
4355 rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
4356 else {
4357 unreachable!()
4358 };
4359 hook(
4360 rpi_agent::BeforeToolCallContext {
4361 assistant_message: &message,
4362 tool_call: &call,
4363 args: &call.arguments,
4364 context: &rpi_agent::AgentContext::default(),
4365 },
4366 tokio_util::sync::CancellationToken::new(),
4367 )
4368 .await
4369 }
4370
4371 #[tokio::test]
4372 async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
4373 let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
4374 let hook =
4375 extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
4376 assert!(invoke_test_tool_hook(&hook).await.is_none());
4377 for name in ["first-gate", "reloaded-gate"] {
4378 let mut registry = rpi_extensions::ExtensionRegistry::new();
4379 registry.register_event_handler(
4380 name.into(),
4381 rpi_plugin_sdk::EventTag::ToolCall,
4382 deny_test_tool_call,
4383 std::ptr::null_mut(),
4384 );
4385 let fresh = ExtensionSession::from_parts(
4386 Arc::new(registry.snapshot()),
4387 rpi_extensions::PluginKeepalive::empty(),
4388 Vec::new(),
4389 None,
4390 );
4391 {
4392 let mut current = sessions.lock().unwrap();
4393 if let Some(snapshot) = current.snapshot() {
4394 snapshot.active_flag().store(false, Ordering::SeqCst);
4395 }
4396 *current = fresh;
4397 }
4398 let result = invoke_test_tool_hook(&hook).await.unwrap();
4399 assert!(result.block);
4400 assert!(result.reason.unwrap().contains(name));
4401 }
4402 *sessions.lock().unwrap() = ExtensionSession::none();
4403 assert!(invoke_test_tool_hook(&hook).await.is_none());
4404 }
4405
4406 fn scratch(name: &str) -> PathBuf {
4408 let nanos = std::time::SystemTime::now()
4409 .duration_since(std::time::UNIX_EPOCH)
4410 .unwrap()
4411 .as_nanos();
4412 let path = std::env::temp_dir().join(format!(
4413 "rpi-summary-{}-{nanos}-{name}.jsonl",
4414 std::process::id()
4415 ));
4416 path
4417 }
4418
4419 #[test]
4420 fn summary_marks_a_header_only_session_empty() {
4421 let path = scratch("empty");
4422 std::fs::write(
4423 &path,
4424 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4425 )
4426 .unwrap();
4427 let summary = summarize_session_file(&path);
4428 assert!(summary.empty, "a header-only file has nothing to resume");
4429 assert!(summary.preview.is_none());
4430 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
4431 std::fs::remove_file(&path).ok();
4432 }
4433
4434 #[test]
4435 fn summary_reads_the_first_user_prompt_and_name() {
4436 let path = scratch("content");
4437 let body = concat!(
4438 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4439 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
4440 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
4441 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
4442 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
4443 );
4444 std::fs::write(&path, body).unwrap();
4445 let summary = summarize_session_file(&path);
4446 assert!(!summary.empty);
4447 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
4448 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
4449 std::fs::remove_file(&path).ok();
4450 }
4451
4452 #[test]
4453 fn summary_extracts_a_block_style_user_prompt() {
4454 let path = scratch("blocks");
4455 let body = concat!(
4456 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4457 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
4458 );
4459 std::fs::write(&path, body).unwrap();
4460 let summary = summarize_session_file(&path);
4461 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
4462 assert_eq!(session_display_label(&summary), "line one line two");
4463 std::fs::remove_file(&path).ok();
4464 }
4465
4466 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
4467 rpi_harness::session::jsonl::JsonlSessionMetadata {
4468 id: id.to_string(),
4469 created_at: 0,
4470 cwd: ".".to_string(),
4471 path: path.to_string_lossy().into_owned(),
4472 modified_at: 0,
4473 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
4474 parent_session_id: None,
4475 legacy_parent_session_path: None,
4476 metadata: None,
4477 }
4478 }
4479
4480 #[test]
4481 fn continue_skips_newer_empty_sessions() {
4482 let empty = scratch("newest-empty");
4483 std::fs::write(
4484 &empty,
4485 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4486 )
4487 .unwrap();
4488 let content = scratch("older-content");
4489 std::fs::write(
4490 &content,
4491 concat!(
4492 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4493 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
4494 ),
4495 )
4496 .unwrap();
4497
4498 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
4499 let picked = first_resumable_session(&metas).expect("a resumable session");
4500 assert_eq!(picked.id, "old");
4501
4502 let all_empty = vec![meta_for(&empty, "empty")];
4505 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
4506
4507 std::fs::remove_file(&empty).ok();
4508 std::fs::remove_file(&content).ok();
4509 }
4510
4511 #[tokio::test]
4512 async fn import_preserves_validates_and_locks_the_recovery_pair() {
4513 use rpi_harness::session::jsonl::{JsonlSessionStorage, JsonlV4Header};
4514 use rpi_harness::session::types::{
4515 AssistantFrameOp, AssistantFrameRecord, RecordBase, SessionStorage,
4516 };
4517 let tmp = tempfile::tempdir().unwrap();
4518 let source = tmp.path().join("source.jsonl");
4519 let progress = tmp.path().join("source.jsonl.progress.jsonl");
4520 let fs: Arc<dyn rpi_tools::FileSystem> =
4521 Arc::new(OsExecutionEnv::with_cwd(tmp.path().into()));
4522 let storage = JsonlSessionStorage::create(
4523 fs,
4524 &source.to_string_lossy(),
4525 JsonlV4Header::new("import-pair".into(), 1, "/".into(), None, None, None),
4526 Arc::new(SystemClock),
4527 Arc::new(DefaultIdGenerator::new()),
4528 )
4529 .await
4530 .unwrap();
4531 storage
4532 .append_progress(vec![AssistantFrameRecord {
4533 base: RecordBase {
4534 id: "accepted-prefix".into(),
4535 seq: 0,
4536 lane: "main".into(),
4537 timestamp: 0,
4538 },
4539 run_id: "run".into(),
4540 stream_index: 0,
4541 op: AssistantFrameOp::Append,
4542 frame: Some(serde_json::json!({"payload":"keep"})),
4543 }])
4544 .await
4545 .unwrap();
4546 drop(storage);
4547 let original = std::fs::read(&progress).unwrap();
4548 let destination = tmp.path().join("imported");
4549 let imported = import_session_file(&source, &tmp.path().to_string_lossy(), &destination)
4550 .await
4551 .unwrap();
4552 assert_eq!(
4553 imported.read_progress("run").await.unwrap()[0].base.id,
4554 "accepted-prefix"
4555 );
4556 assert_eq!(
4557 std::fs::read(&progress).unwrap(),
4558 original,
4559 "import must not repair or mutate its source"
4560 );
4561 let lock = std::fs::OpenOptions::new()
4562 .create(true)
4563 .write(true)
4564 .open(tmp.path().join("source.jsonl.lock"))
4565 .unwrap();
4566 fs2::FileExt::try_lock_exclusive(&lock).unwrap();
4567 assert!(import_session_file(
4568 &source,
4569 &tmp.path().to_string_lossy(),
4570 &tmp.path().join("busy")
4571 )
4572 .await
4573 .is_err());
4574 drop(lock);
4575 let mut corrupt = String::from_utf8(original.clone()).unwrap();
4576 corrupt = corrupt.replace(
4577 "\"session_id\":\"import-pair\"",
4578 "\"session_id\":\"foreign\"",
4579 );
4580 assert_ne!(corrupt.as_bytes(), original.as_slice());
4581 std::fs::write(&progress, corrupt).unwrap();
4582 assert!(import_session_file(
4583 &source,
4584 &tmp.path().to_string_lossy(),
4585 &tmp.path().join("foreign")
4586 )
4587 .await
4588 .is_err());
4589 std::fs::remove_file(&progress).unwrap();
4590 assert!(import_session_file(
4591 &source,
4592 &tmp.path().to_string_lossy(),
4593 &tmp.path().join("missing")
4594 )
4595 .await
4596 .is_err());
4597 }
4598
4599 #[test]
4600 fn list_session_files_sync_walks_the_cwd_subdirectory() {
4601 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
4602 let nested = root.join("--proj--");
4603 std::fs::create_dir_all(&nested).unwrap();
4604 let file = nested.join("2026-01-01_a.jsonl");
4605 std::fs::write(&file, "{}\n").unwrap();
4606 let sidecar = nested.join("2026-01-01_a.jsonl.progress.jsonl");
4607 std::fs::write(&sidecar, "{}\n").unwrap();
4608 let found = list_session_files_sync(&root);
4609 assert!(found.iter().any(|p| p == &file));
4610 assert!(!found.contains(&sidecar));
4611 std::fs::remove_dir_all(&root).ok();
4612 }
4613
4614 fn default_prompt_tools() -> Vec<String> {
4616 BUILTIN_TOOL_NAMES
4617 .iter()
4618 .copied()
4619 .chain(["todo"])
4620 .map(str::to_owned)
4621 .collect()
4622 }
4623
4624 #[test]
4625 fn default_prompt_lists_only_the_active_tools() {
4626 let tools = ["read", "grep", "find", "ls"].map(str::to_owned);
4627 let p = default_system_prompt_with_tools("/tmp/proj", true, &tools);
4628 assert!(p.contains("- read — Read file contents"));
4629 assert!(p.contains("- grep — Search file contents"));
4630 for absent in ["- edit", "- write", "- bash", "- docs", "todo", "Windows"] {
4631 assert!(
4632 !p.contains(absent),
4633 "{absent} must not appear for a read-only run: {p}"
4634 );
4635 }
4636 assert!(p.contains("Re-stating the same plan without acting on it is a bug"));
4637 let none = default_system_prompt_with_tools("/tmp/proj", false, &[]);
4638 assert!(none.contains("Available tools:\n(none)"));
4639 }
4640
4641 #[test]
4642 fn default_prompt_mentions_cwd_and_tools() {
4643 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
4644 assert!(p.contains("/tmp/proj"));
4645 assert!(p.contains("read"));
4646 assert!(p.contains("bash"));
4647 assert!(p.contains("edit"));
4648 assert!(p.contains("write"));
4649 assert!(p.contains("docs"));
4650 assert!(!p.contains("- grep"));
4651 assert!(!p.contains("- find"));
4652 assert!(!p.contains("- ls"));
4653 assert!(!p.contains("- powershell"));
4654 assert!(p.contains("Bash/POSIX syntax, also on Windows"));
4655 }
4656
4657 #[test]
4658 fn default_prompt_instructs_language_matching() {
4659 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
4662 assert!(
4663 p.contains("same language as the user's latest message"),
4664 "the prompt must tell the model to mirror the user's language: {p}"
4665 );
4666 }
4667
4668 #[test]
4669 fn default_prompt_keeps_working_state_in_the_visible_channel() {
4670 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
4678 let lower = p.to_lowercase();
4679 assert!(
4681 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
4682 "the prompt must explain that reasoning does not survive the turn: {p}"
4683 );
4684 assert!(
4686 lower.contains("visible") && !lower.contains("todo tool"),
4687 "the prompt must keep state visible without inventing extension rules: {p}"
4688 );
4689 assert!(
4691 lower.contains("re-stating the same plan"),
4692 "the prompt must call out plan re-statement as a bug: {p}"
4693 );
4694 assert!(
4696 lower.contains("already shown earlier"),
4697 "the prompt must discourage re-fetching known content: {p}"
4698 );
4699 }
4700
4701 #[test]
4702 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
4703 let p = default_system_prompt_with_tools("/tmp/proj", true, &default_prompt_tools());
4710 let lower = p.to_lowercase();
4711 assert!(
4712 lower.contains("carried back"),
4713 "a replaying model must be told its reasoning survives: {p}"
4714 );
4715 assert!(
4716 !lower.contains("reasoning is not"),
4717 "the replayed variant must not claim reasoning is dropped: {p}"
4718 );
4719 assert!(
4720 lower.contains("do not re-derive"),
4721 "the replayed variant must forbid re-deriving the plan: {p}"
4722 );
4723 assert!(
4724 lower.contains("do not re-derive") && lower.contains("re-stating the same plan"),
4725 "the anti-repetition rule must survive the flip: {p}"
4726 );
4727 assert!(
4728 lower.contains("already shown earlier"),
4729 "the re-read rule must survive the flip: {p}"
4730 );
4731 }
4732
4733 #[test]
4734 fn default_prompt_states_each_rule_once() {
4735 for replayed in [false, true] {
4738 let p =
4739 default_system_prompt_with_tools("/tmp/proj", replayed, &default_prompt_tools());
4740 for phrase in ["the same plan", "Windows", "guessing"] {
4741 assert_eq!(p.matches(phrase).count(), 1, "{phrase:?} repeated: {p}");
4742 }
4743 assert!(
4744 !p.contains("todo tool"),
4745 "extension rules require explicit metadata"
4746 );
4747 }
4748 }
4749
4750 #[test]
4758 fn only_read_only_tools_are_replayable() {
4759 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
4760 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
4761 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
4762 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
4763 let tools = build_tools(&context, &Args::default());
4764
4765 let replay_of = |name: &str| {
4766 tools
4767 .iter()
4768 .find(|tool| tool.tool.schema().name == name)
4769 .map(|tool| tool.replay)
4770 };
4771
4772 for read_only in ["read", "docs"] {
4773 assert_eq!(
4774 replay_of(read_only),
4775 Some(ToolReplay::Safe),
4776 "{read_only} is side-effect free, so recovery may re-run it"
4777 );
4778 }
4779 for mutating in ["bash", "edit", "write", "powershell"] {
4780 if let Some(replay) = replay_of(mutating) {
4781 assert_eq!(
4782 replay,
4783 ToolReplay::Never,
4784 "{mutating} must never be replayed: re-running it would duplicate its effect"
4785 );
4786 }
4787 }
4788 }
4789
4790 #[test]
4791 fn default_tools_include_docs_lookup() {
4792 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
4793 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
4794 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
4795 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
4796 let names: Vec<String> = build_tools(&context, &Args::default())
4797 .iter()
4798 .map(|tool| tool.tool.schema().name.clone())
4799 .collect();
4800
4801 assert!(names.contains(&"docs".to_string()));
4806 for legacy in ["grep", "find", "ls"] {
4807 assert!(
4808 !names.contains(&legacy.to_string()),
4809 "legacy tool {legacy} should not be registered by default"
4810 );
4811 }
4812 }
4813
4814 #[test]
4815 fn reload_settings_preflight_respects_trust() {
4816 struct RestoreConfigDir(Option<std::ffi::OsString>);
4817
4818 impl Drop for RestoreConfigDir {
4819 fn drop(&mut self) {
4820 match self.0.take() {
4821 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4822 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4823 }
4824 }
4825 }
4826
4827 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4828 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4829 let tmp = tempfile::tempdir().unwrap();
4830 let agent = tmp.path().join("agent");
4831 let cwd = tmp.path().join("project");
4832 std::fs::create_dir_all(&agent).unwrap();
4833 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
4834 std::fs::write(agent.join("settings.json"), "{}").unwrap();
4835 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
4836 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4837 let args = Args::default();
4838
4839 let trusted = validate_settings_for_reload(&args, &cwd, true);
4840 let untrusted = validate_settings_for_reload(&args, &cwd, false);
4841 assert!(trusted
4842 .unwrap_err()
4843 .contains("could not load project settings"));
4844 assert!(untrusted.is_ok());
4845
4846 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
4847 assert!(validate_settings_for_reload(&args, &cwd, false)
4848 .unwrap_err()
4849 .contains("could not load global settings"));
4850
4851 let local_only = Args {
4852 dev_local_only: true,
4853 ..Args::default()
4854 };
4855 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
4856 }
4857
4858 #[tokio::test(flavor = "current_thread")]
4859 async fn reload_preserves_live_resources_when_settings_break() {
4860 struct RestoreConfigDir(Option<std::ffi::OsString>);
4861
4862 impl Drop for RestoreConfigDir {
4863 fn drop(&mut self) {
4864 match self.0.take() {
4865 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4866 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4867 }
4868 }
4869 }
4870
4871 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4872 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4873 let tmp = tempfile::tempdir().unwrap();
4874 let agent = tmp.path().join("agent");
4875 let cwd = tmp.path().join("project");
4876 let skill_dir = tmp.path().join("configured-skills");
4877 std::fs::create_dir_all(&agent).unwrap();
4878 std::fs::create_dir_all(&cwd).unwrap();
4879 std::fs::create_dir_all(&skill_dir).unwrap();
4880 std::fs::write(
4881 skill_dir.join("SKILL.md"),
4882 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
4883 )
4884 .unwrap();
4885 std::fs::write(
4886 agent.join("settings.json"),
4887 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
4888 )
4889 .unwrap();
4890 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4891
4892 let resolved = crate::provider::resolve(
4893 Some("anthropic"),
4894 Some(crate::provider::DEFAULT_MODEL_ID),
4895 None,
4896 Some("test-key"),
4897 None,
4898 )
4899 .unwrap();
4900 let args = Args {
4901 trust_override: Some(false),
4902 no_session: true,
4903 no_extensions: true,
4904 no_prompt_templates: true,
4905 no_context_files: true,
4906 system_prompt: Some("stable system prompt".into()),
4907 ..Args::default()
4908 };
4909 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
4910 let before_resources = harness.get_resources().await.unwrap();
4911 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
4912 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
4913 let before_prompt = harness.get_system_prompt().await.unwrap();
4914 let before_tools: Vec<String> = harness
4915 .get_tools()
4916 .await
4917 .unwrap()
4918 .iter()
4919 .map(|tool| tool.tool.schema().name.clone())
4920 .collect();
4921 let before_bridge = context
4922 .action_bridge
4923 .lock()
4924 .unwrap()
4925 .as_ref()
4926 .unwrap()
4927 .clone();
4928
4929 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
4930 let outcome = reload_extension_resources(&harness, &context).await;
4931
4932 assert!(outcome.had_warnings);
4933 assert!(outcome.summary.contains("Settings reload failed"));
4934 assert!(outcome.summary.contains("could not load global settings"));
4935 assert_eq!(outcome.project_trusted, None);
4939 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
4940 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
4941 let after_tools: Vec<String> = harness
4942 .get_tools()
4943 .await
4944 .unwrap()
4945 .iter()
4946 .map(|tool| tool.tool.schema().name.clone())
4947 .collect();
4948 assert_eq!(after_tools, before_tools);
4949 let after_bridge = context
4950 .action_bridge
4951 .lock()
4952 .unwrap()
4953 .as_ref()
4954 .unwrap()
4955 .clone();
4956 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
4957 }
4958
4959 #[tokio::test(flavor = "current_thread")]
4960 async fn reload_outcome_carries_the_current_project_trust() {
4961 struct RestoreConfigDir(Option<std::ffi::OsString>);
4962
4963 impl Drop for RestoreConfigDir {
4964 fn drop(&mut self) {
4965 match self.0.take() {
4966 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4967 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4968 }
4969 }
4970 }
4971
4972 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4973 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4974 let tmp = tempfile::tempdir().unwrap();
4975 let agent = tmp.path().join("agent");
4976 let cwd = tmp.path().join("project");
4977 std::fs::create_dir_all(&agent).unwrap();
4978 std::fs::create_dir_all(&cwd).unwrap();
4979 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4980
4981 crate::config::set_project_trust(&cwd, Some(true)).unwrap();
4983 let resolved = crate::provider::resolve(
4984 Some("anthropic"),
4985 Some(crate::provider::DEFAULT_MODEL_ID),
4986 None,
4987 Some("test-key"),
4988 None,
4989 )
4990 .unwrap();
4991 let args = Args {
4992 no_session: true,
4993 no_extensions: true,
4994 no_prompt_templates: true,
4995 no_context_files: true,
4996 system_prompt: Some("stable system prompt".into()),
4997 ..Args::default()
4998 };
4999 let (harness, _events, context) = build(&resolved, &args, &cwd, true).await.unwrap();
5000 assert!(context.project_trusted, "startup snapshot is trusted");
5001
5002 crate::config::set_project_trust(&cwd, Some(false)).unwrap();
5005 let outcome = reload_extension_resources(&harness, &context).await;
5006 assert!(!outcome.had_errors, "{}", outcome.summary);
5007 assert_eq!(outcome.project_trusted, Some(false));
5008
5009 crate::config::set_project_trust(&cwd, Some(true)).unwrap();
5011 let outcome = reload_extension_resources(&harness, &context).await;
5012 assert!(!outcome.had_errors, "{}", outcome.summary);
5013 assert_eq!(outcome.project_trusted, Some(true));
5014 }
5015
5016 #[test]
5017 fn reload_preparation_rejects_settings_changed_during_derivation() {
5018 struct RestoreConfigDir(Option<std::ffi::OsString>);
5019
5020 impl Drop for RestoreConfigDir {
5021 fn drop(&mut self) {
5022 match self.0.take() {
5023 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5024 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5025 }
5026 }
5027 }
5028
5029 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5030 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5031 let tmp = tempfile::tempdir().unwrap();
5032 let agent = tmp.path().join("agent");
5033 let cwd = tmp.path().join("project");
5034 let first_skill_dir = tmp.path().join("first-skills");
5035 let second_skill_dir = tmp.path().join("second-skills");
5036 std::fs::create_dir_all(&agent).unwrap();
5037 std::fs::create_dir_all(&cwd).unwrap();
5038 std::fs::write(
5039 agent.join("settings.json"),
5040 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
5041 )
5042 .unwrap();
5043 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5044 let args = Args {
5045 trust_override: Some(false),
5046 no_extensions: true,
5047 no_prompt_templates: true,
5048 ..Args::default()
5049 };
5050
5051 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
5052 std::fs::write(
5053 agent.join("settings.json"),
5054 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
5055 )
5056 .unwrap();
5057 });
5058
5059 let error = result
5060 .err()
5061 .expect("settings mutation must fail preparation");
5062 assert!(error.contains("settings changed while reload inputs were being prepared"));
5063 }
5064
5065 #[tokio::test(flavor = "current_thread")]
5066 async fn reload_uses_frozen_settings_inputs_after_preparation() {
5067 struct RestoreConfigDir(Option<std::ffi::OsString>);
5068
5069 impl Drop for RestoreConfigDir {
5070 fn drop(&mut self) {
5071 match self.0.take() {
5072 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5073 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5074 }
5075 }
5076 }
5077
5078 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5079 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5080 let tmp = tempfile::tempdir().unwrap();
5081 let agent = tmp.path().join("agent");
5082 let cwd = tmp.path().join("project");
5083 let skill_dir = tmp.path().join("configured-skills");
5084 std::fs::create_dir_all(&agent).unwrap();
5085 std::fs::create_dir_all(&cwd).unwrap();
5086 std::fs::create_dir_all(&skill_dir).unwrap();
5087 std::fs::write(
5088 skill_dir.join("SKILL.md"),
5089 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
5090 )
5091 .unwrap();
5092 std::fs::write(
5093 agent.join("settings.json"),
5094 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
5095 )
5096 .unwrap();
5097 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5098
5099 let resolved = crate::provider::resolve(
5100 Some("anthropic"),
5101 Some(crate::provider::DEFAULT_MODEL_ID),
5102 None,
5103 Some("test-key"),
5104 None,
5105 )
5106 .unwrap();
5107 let args = Args {
5108 trust_override: Some(false),
5109 no_session: true,
5110 no_extensions: true,
5111 no_prompt_templates: true,
5112 no_context_files: true,
5113 system_prompt: Some("stable system prompt".into()),
5114 ..Args::default()
5115 };
5116 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
5117
5118 let outcome = reload_extension_resources_inner(&harness, &context, || {
5119 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
5120 })
5121 .await;
5122
5123 assert!(!outcome.summary.contains("Settings reload failed"));
5124 assert_eq!(
5125 outcome.summary,
5126 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
5127 );
5128 let resources = harness.get_resources().await.unwrap();
5129 let skills = resources.skills.as_ref().unwrap();
5130 assert_eq!(skills.len(), 1);
5131 assert_eq!(skills[0].name, "frozen-skill");
5132 }
5133
5134 #[tokio::test]
5135 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
5136 let tmp = tempfile::tempdir().unwrap();
5137 let skill_path = tmp.path().join("explicit-skill.md");
5138 std::fs::write(
5139 &skill_path,
5140 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
5141 )
5142 .unwrap();
5143 let prompt_path = tmp.path().join("explicit-prompt.md");
5144 std::fs::write(
5145 &prompt_path,
5146 "---\ndescription: Explicit prompt\n---\nPrompt body",
5147 )
5148 .unwrap();
5149
5150 let args = Args {
5151 skill: vec![skill_path.clone()],
5152 prompt_template: vec![prompt_path.clone()],
5153 ..Args::default()
5154 };
5155 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
5156 let prompt_paths =
5157 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
5158 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
5159 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
5160
5161 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
5162 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
5163 assert_eq!(skills.skills[0].name, "explicit-skill");
5164
5165 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
5166 assert_eq!(
5167 prompts.prompt_templates.len(),
5168 1,
5169 "{:?}",
5170 prompts.diagnostics
5171 );
5172 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
5173 }
5174
5175 #[test]
5176 fn tool_policy_applies_to_rust_and_js_active_names() {
5177 let names = vec![
5178 "read".to_string(),
5179 "ask_user_question".to_string(),
5180 "write".to_string(),
5181 ];
5182
5183 let args = Args {
5184 tools: Some(vec!["read".into(), "ask_user_question".into()]),
5185 ..Args::default()
5186 };
5187 assert_eq!(
5188 filter_active_tool_names(names.clone(), &args),
5189 vec!["read", "ask_user_question"]
5190 );
5191
5192 let args = Args {
5193 exclude_tools: Some(vec!["ask_user_question".into()]),
5194 ..Args::default()
5195 };
5196 assert_eq!(
5197 filter_active_tool_names(names.clone(), &args),
5198 vec!["read", "write"]
5199 );
5200
5201 let args = Args {
5202 no_tools: true,
5203 ..Args::default()
5204 };
5205 assert!(filter_active_tool_names(names, &args).is_empty());
5206 }
5207
5208 #[test]
5209 fn select_ephemeral_when_no_session() {
5210 let args = Args {
5211 no_session: true,
5212 ..Args::default()
5213 };
5214 let cwd = Path::new("/tmp");
5215 assert!(matches!(
5216 select_session(&args, cwd),
5217 SessionSelection::Ephemeral
5218 ));
5219 }
5220
5221 #[test]
5222 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
5223 let defaults = Args::default();
5224 assert!(resolve_project_trust(
5225 &defaults,
5226 Path::new("C:/definitely-not-a-project")
5227 ));
5228
5229 let denied = Args {
5230 trust_override: Some(false),
5231 ..Args::default()
5232 };
5233 assert!(!resolve_project_trust(
5234 &denied,
5235 Path::new("C:/definitely-not-a-project")
5236 ));
5237 }
5238
5239 #[tokio::test(flavor = "current_thread")]
5240 async fn build_preserves_the_supplied_startup_project_snapshot() {
5241 struct RestoreConfigDir(Option<std::ffi::OsString>);
5242
5243 impl Drop for RestoreConfigDir {
5244 fn drop(&mut self) {
5245 match self.0.take() {
5246 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5247 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5248 }
5249 }
5250 }
5251
5252 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5253 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5254 let tmp = tempfile::tempdir().unwrap();
5255 let agent = tmp.path().join("agent");
5256 let cwd = tmp.path().join("project");
5257 std::fs::create_dir_all(&agent).unwrap();
5258 std::fs::create_dir_all(&cwd).unwrap();
5259 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5260
5261 let resolved = crate::provider::resolve(
5262 Some("anthropic"),
5263 Some(crate::provider::DEFAULT_MODEL_ID),
5264 None,
5265 Some("test-key"),
5266 None,
5267 )
5268 .unwrap();
5269 let args = Args {
5270 trust_override: Some(false),
5271 no_session: true,
5272 no_tools: true,
5273 no_extensions: true,
5274 no_skills: true,
5275 no_prompt_templates: true,
5276 no_context_files: true,
5277 system_prompt: Some("test prompt".into()),
5278 ..Args::default()
5279 };
5280
5281 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
5282
5283 assert_eq!(context.cwd, cwd);
5284 assert!(context.project_trusted);
5285 }
5286
5287 #[tokio::test(flavor = "current_thread")]
5288 async fn build_applies_the_name_flag_to_the_session() {
5289 struct RestoreConfigDir(Option<std::ffi::OsString>);
5290
5291 impl Drop for RestoreConfigDir {
5292 fn drop(&mut self) {
5293 match self.0.take() {
5294 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5295 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5296 }
5297 }
5298 }
5299
5300 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5301 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5302 let tmp = tempfile::tempdir().unwrap();
5303 let agent = tmp.path().join("agent");
5304 let cwd = tmp.path().join("project");
5305 std::fs::create_dir_all(&agent).unwrap();
5306 std::fs::create_dir_all(&cwd).unwrap();
5307 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5308
5309 let resolved = crate::provider::resolve(
5310 Some("anthropic"),
5311 Some(crate::provider::DEFAULT_MODEL_ID),
5312 None,
5313 Some("test-key"),
5314 None,
5315 )
5316 .unwrap();
5317 let args = Args {
5318 trust_override: Some(false),
5319 no_tools: true,
5320 no_extensions: true,
5321 no_skills: true,
5322 no_prompt_templates: true,
5323 no_context_files: true,
5324 system_prompt: Some("test prompt".into()),
5325 name: Some("smoke name".into()),
5326 session_dir: Some(cwd.join("sessions")),
5327 ..Args::default()
5328 };
5329
5330 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
5331 assert_eq!(
5332 harness.get_name().await.unwrap().as_deref(),
5333 Some("smoke name")
5334 );
5335 }
5336
5337 #[test]
5338 fn select_latest_for_continue_and_resume() {
5339 let _env = crate::config::test_support::SessionDirEnv::new(None);
5340 let args = Args {
5341 continue_session: true,
5342 ..Args::default()
5343 };
5344 let cwd = Path::new("/tmp");
5345 assert!(matches!(
5346 select_session(&args, cwd),
5347 SessionSelection::Latest { .. }
5348 ));
5349
5350 let args = Args {
5351 resume: true,
5352 ..Args::default()
5353 };
5354 assert!(matches!(
5355 select_session(&args, cwd),
5356 SessionSelection::Latest { .. }
5357 ));
5358 }
5359
5360 #[test]
5361 fn select_by_id_for_session_flag() {
5362 let _env = crate::config::test_support::SessionDirEnv::new(None);
5363 let args = Args {
5364 session: Some("01a02ece".into()),
5365 ..Args::default()
5366 };
5367 let cwd = Path::new("/tmp");
5368 assert!(matches!(
5369 select_session(&args, cwd),
5370 SessionSelection::ById { id, .. } if id == "01a02ece"
5371 ));
5372 }
5373
5374 #[test]
5375 fn select_new_with_custom_dir() {
5376 let _env = crate::config::test_support::SessionDirEnv::new(None);
5377 let args = Args {
5378 session_dir: Some(PathBuf::from("/tmp/sess")),
5379 ..Args::default()
5380 };
5381 let cwd = Path::new("/tmp");
5382 match select_session(&args, cwd) {
5383 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
5384 other => panic!("expected New, got {other:?}"),
5385 }
5386 }
5387
5388 #[test]
5389 fn select_new_default_dir() {
5390 let _env = crate::config::test_support::SessionDirEnv::new(None);
5391 let args = Args::default();
5392 let cwd = Path::new("/proj");
5393 match select_session(&args, cwd) {
5394 SessionSelection::New { dir, .. } => {
5395 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
5396 }
5397 other => panic!("expected New, got {other:?}"),
5398 }
5399 }
5400
5401 #[test]
5402 fn default_session_dir_only_uses_rpi() {
5403 let _env = crate::config::test_support::SessionDirEnv::new(None);
5404 let tmp = tempfile::tempdir().unwrap();
5405 let cwd = tmp.path();
5406 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
5407 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
5408 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
5409 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
5410 }
5411
5412 #[test]
5413 fn default_session_dir_uses_env_var() {
5414 let tmp = tempfile::tempdir().unwrap();
5415 let cwd = tmp.path();
5416 let custom = tmp.path().join("custom_sessions");
5417 let _env = crate::config::test_support::SessionDirEnv::new(Some(&custom));
5418 assert_eq!(default_session_dir(cwd), custom);
5419 }
5420
5421 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5422 async fn ephemeral_session_builds_roundtrips() {
5423 let s = ephemeral_session();
5427 let leaf = s.get_leaf_id().await;
5428 assert!(leaf.is_ok());
5429 }
5430
5431 #[test]
5449 fn host_private_env_matches_the_declared_contract() {
5450 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
5452 assert_eq!(
5453 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
5454 [
5455 "LANGFUSE_PI_PARENT_TRACE_ID",
5456 "LANGFUSE_PI_PARENT_SPAN_ID",
5457 "LANGFUSE_PI_PARENT_SESSION_ID",
5458 "LANGFUSE_PI_PARENT_DEPTH",
5459 ],
5460 "these strings are what rpi-langfuse (a different repo) matches; a \
5461 change here is a breaking cross-repo change, not a refactor"
5462 );
5463
5464 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
5467 .iter()
5468 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
5469 {
5470 assert!(
5471 rpi_tools::is_host_private_env(name),
5472 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
5473 rpi_tools::HOST_PRIVATE_ENV
5474 );
5475 }
5476
5477 for visible in [
5480 crate::args::RPI_OFFLINE_ENV,
5481 crate::config::CONFIG_DIR_ENV,
5482 "PATH",
5483 ] {
5484 assert!(
5485 !rpi_tools::is_host_private_env(visible),
5486 "{visible} describes the user's configuration and must still reach the shell"
5487 );
5488 }
5489 }
5490 #[tokio::test]
5491 async fn reload_shutdown_joins_all_handlers_even_after_veto() {
5492 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
5493 use std::sync::atomic::AtomicUsize;
5494 static CONTEXTS: rpi_plugin_sdk::handles::ToolHandles<Arc<AtomicUsize>> =
5495 rpi_plugin_sdk::handles::ToolHandles::new();
5496 struct ContextOwner(usize);
5497 impl Drop for ContextOwner {
5498 fn drop(&mut self) {
5499 drop(CONTEXTS.remove(self.0 as rpi_plugin_sdk::StepHandle));
5500 }
5501 }
5502 extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
5503 let context = CONTEXTS.get(data).expect("live test context");
5504 let calls = context.lock().clone();
5505 std::thread::sleep(std::time::Duration::from_millis(20));
5506 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
5507 rpi_plugin_sdk::EVENT_HANDLER_ABORT
5508 } else {
5509 0
5510 }
5511 }
5512 let calls = Arc::new(AtomicUsize::new(0));
5513 let context = CONTEXTS.insert(calls.clone());
5514 let _owner = ContextOwner(context as usize);
5515 let mut registry = rpi_extensions::ExtensionRegistry::new();
5516 for name in ["first", "second"] {
5517 registry.register_event_handler(name.into(), EventTag::SessionShutdown, stop, context);
5518 }
5519 super::shutdown_extensions_for_reload(
5520 Some(Arc::new(registry.snapshot())),
5521 rpi_extensions::PluginKeepalive::empty(),
5522 )
5523 .await
5524 .unwrap();
5525 assert_eq!(calls.load(Ordering::SeqCst), 2);
5526 }
5527
5528 #[tokio::test]
5537 async fn a_restored_session_adopts_its_recorded_directory() {
5538 let tmp = tempfile::tempdir().unwrap();
5539 let project = tmp.path().join("project-a");
5540 let launch = tmp.path().join("launch-dir");
5541 let sessions = tmp.path().join("sessions");
5542 std::fs::create_dir_all(&project).unwrap();
5543 std::fs::create_dir_all(&launch).unwrap();
5544 std::fs::create_dir_all(&sessions).unwrap();
5545 create_jsonl_session_with_id(
5546 &sessions,
5547 &project.to_string_lossy(),
5548 Some("cwdadopt01".into()),
5549 )
5550 .await
5551 .unwrap();
5552
5553 let selection = SessionSelection::ById {
5554 id: "cwdadopt01".into(),
5555 dir: sessions.clone(),
5556 };
5557 let peeked = peek_restored_session_cwd(&selection, &launch)
5558 .await
5559 .expect("a resumable session with a recorded cwd");
5560 assert_eq!(peeked.recorded, project);
5561 assert_eq!(peeked.adopted, Some(project));
5562 }
5563
5564 #[tokio::test]
5568 async fn a_missing_recorded_directory_is_reported_not_adopted() {
5569 let tmp = tempfile::tempdir().unwrap();
5570 let gone = tmp.path().join("project-gone");
5571 let launch = tmp.path().join("launch-dir");
5572 let sessions = tmp.path().join("sessions");
5573 std::fs::create_dir_all(&launch).unwrap();
5574 std::fs::create_dir_all(&sessions).unwrap();
5575 create_jsonl_session_with_id(
5576 &sessions,
5577 &gone.to_string_lossy(),
5578 Some("cwdgone001".into()),
5579 )
5580 .await
5581 .unwrap();
5582
5583 let selection = SessionSelection::ById {
5584 id: "cwdgone001".into(),
5585 dir: sessions.clone(),
5586 };
5587 let peeked = peek_restored_session_cwd(&selection, &launch)
5588 .await
5589 .expect("the session itself is resumable");
5590 assert_eq!(peeked.recorded, gone);
5591 assert_eq!(peeked.adopted, None);
5592 let note = crate::session_cwd::missing_session_cwd_note(&peeked.recorded, &launch);
5593 assert!(note.contains("no longer exists"), "{note}");
5594 assert!(note.contains("launch-dir"), "{note}");
5595 }
5596
5597 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5598 async fn legacy_progress_upgrade_normal_open_runs_and_reopens() {
5599 use rpi_ai::providers::faux::{FauxProvider, FauxScript};
5600 use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
5601 use rpi_harness::session::jsonl::{
5602 encode_header, JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
5603 JsonlV4Header,
5604 };
5605 let tmp = tempfile::tempdir().unwrap();
5606 let sessions = tmp.path().join("sessions");
5607 std::fs::create_dir_all(&sessions).unwrap();
5608 let cwd = tmp.path().to_string_lossy().into_owned();
5609 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
5610 fs: Arc::new(OsExecutionEnv::with_cwd(tmp.path().into())),
5611 sessions_root: sessions.to_string_lossy().into_owned(),
5612 clock: Arc::new(SystemClock),
5613 ids: Arc::new(DefaultIdGenerator::new()),
5614 });
5615 let fixture = repo
5616 .create_typed(&JsonlSessionCreateOptions {
5617 id: Some("legacycli001".into()),
5618 parent_session_id: None,
5619 cwd: cwd.clone(),
5620 metadata: None,
5621 })
5622 .await
5623 .unwrap();
5624 let path = PathBuf::from(fixture.jsonl_metadata().path);
5625 drop(fixture);
5626 let sidecar = PathBuf::from(format!("{}.progress.jsonl", path.display()));
5627 std::fs::remove_file(&sidecar).unwrap();
5628 let header = JsonlV4Header::new("legacycli001".into(), 1, cwd.clone(), None, None, None);
5629 std::fs::write(&path, encode_header(&header)).unwrap();
5630 assert!(!sidecar.exists());
5631 for _ in 0..2 {
5632 let restored = open_session_by_id_in_dir("legacycli001", &cwd, &sessions)
5633 .await
5634 .map_err(|e| e.to_string())
5635 .unwrap();
5636 assert_eq!(restored.session.storage().metadata().id, "legacycli001");
5637 let provider =
5638 FauxProvider::new(FauxScript::new().with_text("continued legacy session"));
5639 let harness = AgentHarness::create(AgentHarnessOptions {
5640 model: provider.default_model().clone(),
5641 models: vec![provider],
5642 session: restored.session,
5643 allow_existing_session: true,
5644 ..Default::default()
5645 })
5646 .await
5647 .unwrap();
5648 let run = harness.prompt_text("continue", vec![]).await.unwrap();
5649 assert!(matches!(run.outcome, HarnessRunOutcome::Completed { .. }));
5650 }
5651 let content = std::fs::read_to_string(&path).unwrap();
5652 assert!(content.lines().next().unwrap().contains("progressFormat"));
5653 assert!(sidecar.exists());
5654 }
5655
5656 #[tokio::test]
5659 async fn open_session_reports_the_recorded_directory() {
5660 let tmp = tempfile::tempdir().unwrap();
5661 let project = tmp.path().join("project-open");
5662 let launch = tmp.path().join("launch-dir");
5663 let sessions = tmp.path().join("sessions");
5664 std::fs::create_dir_all(&project).unwrap();
5665 std::fs::create_dir_all(&launch).unwrap();
5666 std::fs::create_dir_all(&sessions).unwrap();
5667 create_jsonl_session_with_id(
5668 &sessions,
5669 &project.to_string_lossy(),
5670 Some("cwdopen001".into()),
5671 )
5672 .await
5673 .unwrap();
5674
5675 let restored =
5676 open_session_by_id_in_dir("cwdopen001", &launch.to_string_lossy(), &sessions)
5677 .await
5678 .map_err(|error| error.to_string())
5679 .unwrap();
5680 assert_eq!(restored.recorded_cwd, Some(project.clone()));
5681 assert_eq!(restored.session.storage().metadata().id, "cwdopen001");
5682 }
5683
5684 #[tokio::test]
5693 async fn the_peeked_session_is_the_one_the_build_opens() {
5694 let tmp = tempfile::tempdir().unwrap();
5695 let first_project = tmp.path().join("project-first");
5696 let second_project = tmp.path().join("project-second");
5697 let launch = tmp.path().join("launch-dir");
5698 let sessions = tmp.path().join("sessions");
5699 for dir in [&first_project, &second_project, &launch, &sessions] {
5700 std::fs::create_dir_all(dir).unwrap();
5701 }
5702 create_jsonl_session_with_id(
5703 &sessions,
5704 &first_project.to_string_lossy(),
5705 Some("cwdrace001".into()),
5706 )
5707 .await
5708 .unwrap();
5709
5710 let mut args = Args {
5711 continue_session: true,
5712 session_dir: Some(sessions.clone()),
5713 ..Default::default()
5714 };
5715 let selection = select_session_in_dir(&args, sessions.clone());
5716 let peeked = peek_restored_session_cwd(&selection, &launch)
5717 .await
5718 .expect("a resumable session");
5719 assert_eq!(peeked.adopted, Some(first_project.clone()));
5720
5721 create_jsonl_session_with_id(
5723 &sessions,
5724 &second_project.to_string_lossy(),
5725 Some("cwdrace002".into()),
5726 )
5727 .await
5728 .unwrap();
5729
5730 pin_restored_selection(&mut args, &peeked);
5731 let pinned = select_session_in_dir(&args, sessions.clone());
5732 let reopened = resolve_restore_target(&pinned, &launch.to_string_lossy())
5733 .await
5734 .unwrap()
5735 .expect("the pinned session still exists");
5736 assert_eq!(
5737 reopened.id, peeked.id,
5738 "the build must open the session the peek adopted the directory of"
5739 );
5740 assert_eq!(reopened.cwd, first_project.to_string_lossy());
5741 }
5742
5743 #[test]
5748 fn trust_is_resolved_per_directory() {
5749 struct RestoreConfigDir(Option<std::ffi::OsString>);
5750
5751 impl Drop for RestoreConfigDir {
5752 fn drop(&mut self) {
5753 match self.0.take() {
5754 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5755 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5756 }
5757 }
5758 }
5759
5760 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5761 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5762 let tmp = tempfile::tempdir().unwrap();
5763 let agent = tmp.path().join("agent");
5764 std::fs::create_dir_all(&agent).unwrap();
5765 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5766
5767 let untrusted = tmp.path().join("project-untrusted");
5768 let launch = tmp.path().join("project-launch");
5769 std::fs::create_dir_all(&untrusted).unwrap();
5770 std::fs::create_dir_all(&launch).unwrap();
5771 crate::config::set_project_trust(&untrusted, Some(false)).unwrap();
5772
5773 let args = Args::default();
5774 assert!(!resolve_project_trust(&args, &untrusted));
5775 assert!(resolve_project_trust(&args, &launch));
5777 }
5778}