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 extension_files: Vec<PathBuf>,
1766 skill_base_dirs: Vec<PathBuf>,
1767 prompt_base_dirs: Vec<PathBuf>,
1768}
1769
1770fn append_reload_resource_paths(
1775 mut paths: Vec<PathBuf>,
1776 explicit: &[PathBuf],
1777 discovered: &[String],
1778 js_paths: &[PathBuf],
1779 package_paths: &[PathBuf],
1780) -> Vec<PathBuf> {
1781 paths.extend(explicit.iter().cloned());
1782 paths.extend(discovered.iter().map(PathBuf::from));
1783 paths.extend(js_paths.iter().cloned());
1784 paths.extend(package_paths.iter().cloned());
1785 paths
1786}
1787
1788pub async fn reload_extension_resources(
1800 harness: &AgentHarness,
1801 ctx: &ReloadContext,
1802) -> ReloadOutcome {
1803 reload_extension_resources_inner(harness, ctx, || {}).await
1804}
1805
1806pub async fn reload_model_catalog_only(
1830 harness: &AgentHarness,
1831 ctx: &ReloadContext,
1832) -> Result<usize, String> {
1833 let project_trusted = resolve_project_trust(&ctx.args, &ctx.cwd);
1834
1835 let session_guard = ctx.extension_session.lock().unwrap();
1838 let extension_session = session_guard.clone();
1839 drop(session_guard);
1840
1841 let mut args = ctx.args.clone();
1845 let current = harness.lane("main").get_model().await.ok();
1846 if let Some(model) = current.as_ref() {
1847 args.provider = Some(model.provider.clone());
1848 args.model = Some(model.id.clone());
1849 if ctx.resolved_model.provider != model.provider {
1860 args.api_key = None;
1861 args.base_url = None;
1862 }
1863 }
1864 args.thinking = harness.lane("main").get_thinking_level().await.ok();
1867
1868 let resolved = crate::provider::resolve_with_extensions(
1869 &args,
1870 &ctx.cwd,
1871 project_trusted,
1872 &extension_session,
1873 )
1874 .map_err(|error| format!("重读模型清单失败:{error}"))?;
1875
1876 let catalog = crate::provider::authenticated_catalog(&resolved);
1877
1878 let providers = build_models_with_extensions_for_reload(
1882 &resolved.providers,
1883 &extension_session,
1884 ctx.runtime.clone(),
1885 );
1886 harness
1887 .set_models(providers)
1888 .await
1889 .map_err(|error| format!("换模型运行时失败:{error}"))?;
1890
1891 if let Some(bridge) = ctx.action_bridge.lock().unwrap().as_ref() {
1894 bridge.clone_host().replace_model_catalog(catalog.clone());
1895 }
1896 *ctx.catalog
1897 .write()
1898 .unwrap_or_else(|error| error.into_inner()) = catalog.clone();
1899 *ctx.providers
1900 .write()
1901 .unwrap_or_else(|error| error.into_inner()) = resolved.providers.clone();
1902
1903 let lane_model = current
1909 .as_ref()
1910 .and_then(|model| {
1911 catalog
1912 .iter()
1913 .find(|m| m.id == model.id && m.provider == model.provider)
1914 })
1915 .cloned()
1916 .unwrap_or_else(|| resolved.model.clone());
1917 harness
1918 .lane("main")
1919 .set_model(lane_model)
1920 .await
1921 .map_err(|error| format!("设置当前模型失败:{error}"))?;
1922
1923 tracing::info!(
1924 models = catalog.len(),
1925 provider = %resolved.model.provider,
1926 "模型清单已重读(未重载扩展)"
1927 );
1928 Ok(catalog.len())
1929}
1930
1931async fn reload_extension_resources_inner<F>(
1932 harness: &AgentHarness,
1933 ctx: &ReloadContext,
1934 after_prepare: F,
1935) -> ReloadOutcome
1936where
1937 F: FnOnce() + Send,
1938{
1939 if harness.has_exact_resources()
1940 || ctx.args.resource_config.is_some()
1941 || ctx.args.resource_transition.is_some()
1942 {
1943 return ReloadOutcome {
1944 summary:
1945 "Exact resource selection is restart-required; reload/dev hot apply unsupported"
1946 .into(),
1947 had_warnings: true,
1948 details: Vec::new(),
1949 had_errors: true,
1950 project_trusted: None,
1951 };
1952 }
1953 let mut effective_args = ctx.args.clone();
1958 if let Some(dev) = &ctx.dev_extension {
1959 if let Err(error) = dev
1960 .rebuild()
1961 .and_then(|_| dev.apply_to_args(&mut effective_args))
1962 {
1963 return ReloadOutcome {
1964 summary: format!(
1965 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1966 dev.package_name()
1967 ),
1968 had_warnings: true,
1969 details: Vec::new(),
1970 had_errors: true,
1971 project_trusted: None,
1972 };
1973 }
1974 }
1975 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1976 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1977 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1978 Ok(prepared) => prepared,
1979 Err(error) => {
1980 return ReloadOutcome {
1981 summary: format!(
1982 "Settings reload failed: {error}. Keeping the currently loaded resources."
1983 ),
1984 had_warnings: true,
1985 details: Vec::new(),
1986 had_errors: true,
1987 project_trusted: None,
1988 };
1989 }
1990 };
1991 after_prepare();
1992 let PreparedReloadInputs {
1993 extension_dirs,
1994 extension_files,
1995 skill_base_dirs,
1996 prompt_base_dirs,
1997 } = prepared;
1998 let mut warnings = false;
1999
2000 let (shutdown_snapshot, shutdown_keepalive) = {
2004 let session = ctx.extension_session.lock().unwrap();
2005 (session.snapshot_arc(), session.keepalive())
2006 };
2007 if let Err(reason) = shutdown_extensions_for_reload(shutdown_snapshot, shutdown_keepalive).await
2008 {
2009 return ReloadOutcome {
2010 summary: "Reload failed during extension shutdown".into(),
2011 had_warnings: true,
2012 details: vec![reason],
2013 had_errors: true,
2014 project_trusted: None,
2015 };
2016 }
2017
2018 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
2025 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
2026 Some(b) => b.clone_host(),
2027 None => {
2028 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
2031 ctx.catalog
2032 .read()
2033 .unwrap_or_else(|error| error.into_inner())
2034 .clone(),
2035 ctx.cwd.clone(),
2036 ctx.runtime.clone(),
2037 ctx.args.unknown_flags.clone(),
2038 ctx.tool_context.clone(),
2039 );
2040 crate::extensions_actions::HarnessActionHost::set_harness(
2041 &_cell,
2042 Arc::new(harness.clone()),
2043 );
2044 Arc::new(
2045 action_host
2046 .with_session_dir(ctx.session_dir())
2047 .with_fresh_session_only(ctx.fresh_session_only),
2048 )
2049 }
2050 };
2051
2052 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
2053 let publish_host = Arc::clone(&host);
2054 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
2055 ctx.runtime.clone(),
2056 host,
2057 reload_cb,
2058 ctx.ui_dialog.clone(),
2059 ctx.ext_status.clone(),
2060 ctx.editor_text.clone(),
2061 );
2062
2063 let extension_session = if effective_args.no_extensions {
2064 rpi_extensions::ExtensionSession::none()
2065 } else {
2066 load_extensions_from_files(
2067 &extension_dirs,
2068 &extension_files,
2069 Some(Arc::clone(&fresh_bridge)),
2070 Some(ctx.resource_leases.clone()),
2071 )
2072 };
2073 if let Err(error) =
2074 crate::native_store::validate_stdio(ctx.fresh_session_only, extension_session.owns_stdio())
2075 {
2076 return ReloadOutcome {
2077 summary: "Reload refused by experimental native session policy".into(),
2078 had_warnings: true,
2079 details: vec![error],
2080 had_errors: true,
2081 project_trusted: None,
2082 };
2083 }
2084 if extension_session.is_empty() && !effective_args.no_extensions {
2085 }
2088 if effective_args.verbose {
2089 if let Some(s) = extension_session.summary() {
2090 eprintln!("reload: {s}");
2091 }
2092 report_deferred_renderers(&extension_session);
2093 }
2094
2095 let has_package_providers = |session: &ExtensionSession| {
2096 session.snapshot().is_some_and(|snapshot| {
2097 snapshot
2098 .providers()
2099 .iter()
2100 .any(|provider| snapshot.provider_manifest(&provider.provider_id).is_some())
2101 })
2102 };
2103 let packaged = has_package_providers(&extension_session)
2104 || has_package_providers(&ctx.extension_session.lock().unwrap());
2105 let refreshed = if packaged {
2106 let mut args = effective_args.clone();
2107 if let Ok(current) = harness.lane("main").get_model().await {
2108 let was_package = ctx
2109 .extension_session
2110 .lock()
2111 .unwrap()
2112 .snapshot()
2113 .is_some_and(|snapshot| snapshot.provider_manifest(¤t.provider).is_some());
2114 let has_replacement = extension_session.snapshot().is_some_and(|snapshot| {
2115 snapshot
2116 .providers()
2117 .iter()
2118 .any(|provider| provider.provider_id == current.provider)
2119 });
2120 if was_package && !has_replacement {
2121 return ReloadOutcome {
2122 summary: "Reload failed while resolving provider packages".into(),
2123 had_warnings: true,
2124 details: vec![format!(
2125 "active provider package '{}' is no longer loaded",
2126 current.provider
2127 )],
2128 had_errors: true,
2129 project_trusted: None,
2130 };
2131 }
2132 args.provider = Some(current.provider.clone());
2133 args.model = Some(current.id);
2134 if current.provider != ctx.resolved_model.provider {
2135 args.api_key = None;
2136 args.base_url = None;
2137 }
2138 }
2139 match crate::provider::resolve_with_extensions(
2140 &args,
2141 &ctx.cwd,
2142 ctx.project_trusted,
2143 &extension_session,
2144 ) {
2145 Ok(resolved) => Some(resolved),
2146 Err(error) => {
2147 return ReloadOutcome {
2148 summary: "Reload failed while resolving provider packages".into(),
2149 had_warnings: true,
2150 details: vec![error.to_string()],
2151 had_errors: true,
2152 project_trusted: None,
2153 }
2154 }
2155 }
2156 } else {
2157 None
2158 };
2159
2160 let old_session = {
2172 let mut session_guard = ctx.extension_session.lock().unwrap();
2173 let old_session = std::mem::replace(&mut *session_guard, extension_session.clone());
2174 if let Some(old_snap) = old_session.snapshot_arc() {
2175 old_snap.active_flag().store(false, Ordering::SeqCst);
2179 }
2180 old_session
2181 };
2182 if let Some(old_b) = old_bridge {
2183 old_b.invalidate();
2184 }
2185 old_session.begin_shutdown();
2187
2188 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
2190 publish_host.publish_registry_opt(extension_session.snapshot_arc());
2198
2199 let discovered = extension_session
2201 .snapshot_arc()
2202 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
2203 .unwrap_or_default();
2204 if let Ok(mut guard) = ctx.discovered_theme_paths.lock() {
2207 *guard = discovered.theme_paths.clone();
2208 }
2209
2210 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
2212 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2213
2214 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
2215 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
2216 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
2217 if !effective_args.no_skills {
2218 let js_paths: &[PathBuf] = &[];
2222 let package_paths: &[PathBuf] = &[];
2223 let dirs = append_reload_resource_paths(
2224 skill_base_dirs,
2225 &effective_args.skill,
2226 &discovered.skill_paths,
2227 js_paths,
2228 package_paths,
2229 );
2230 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
2231 skills = result.skills;
2232 skill_diags = result.diagnostics;
2233 resource_diags.extend(result.resource_diagnostics);
2234 }
2235
2236 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
2237 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
2238 if !effective_args.no_prompt_templates {
2239 let js_paths: &[PathBuf] = &[];
2240 let package_paths: &[PathBuf] = &[];
2241 let dirs = append_reload_resource_paths(
2242 prompt_base_dirs,
2243 &effective_args.prompt_template,
2244 &discovered.prompt_paths,
2245 js_paths,
2246 package_paths,
2247 );
2248 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
2249 prompt_templates = result.prompt_templates;
2250 prompt_diags = result.diagnostics;
2251 resource_diags.extend(result.resource_diagnostics);
2252 }
2253
2254 let context_files = if effective_args.no_context_files || !project_trusted {
2255 Vec::new()
2256 } else {
2257 let agent_dir = crate::config::agent_dir().ok();
2258 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
2259 load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await
2260 };
2261 let context_block = format_project_context(&context_files);
2262
2263 let mut details: Vec<String> = Vec::new();
2264 if let Some(resolved) = &refreshed {
2265 warnings |= !resolved.compat_warnings.is_empty();
2266 details.extend(
2267 resolved
2268 .compat_warnings
2269 .iter()
2270 .map(|warning| format!("warning: models.json: {warning}")),
2271 );
2272 }
2273 if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
2274 warnings = true;
2275 for d in &skill_diags {
2280 details.push(format!(
2281 "warning: skill {} ({}): {}",
2282 d.path,
2283 d.code.as_str(),
2284 d.message
2285 ));
2286 }
2287 for d in &prompt_diags {
2288 details.push(format!(
2289 "warning: prompt template {} ({}): {}",
2290 d.path,
2291 d.code.as_str(),
2292 d.message
2293 ));
2294 }
2295 for d in &resource_diags {
2297 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
2298 details.push(format!(
2299 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
2300 c.resource_type.as_str(),
2301 c.name,
2302 c.winner_path,
2303 c.loser_path
2304 ));
2305 }
2306 }
2307 }
2308
2309 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2318 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env))
2319 .with_file_io(ctx.file_io.clone());
2320 let mut tools = build_tools(&tool_ctx, &effective_args);
2321 merge_extension_tools(
2322 &mut tools,
2323 &extension_session,
2324 &effective_args,
2325 &ctx.tool_context,
2326 );
2327 let mut active = active_tool_names(&tools, &effective_args);
2328 active = filter_active_tool_names(active, &effective_args);
2329 let prompt_tools = active.clone();
2330 let prompt_metadata = extension_tool_prompt_metadata(&extension_session);
2331 let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
2332 let default_prompt = || {
2333 default_system_prompt_with_tool_prompts(
2334 &cwd_str,
2335 prior_reasoning_replayed,
2336 &prompt_tools,
2337 &prompt_metadata,
2338 )
2339 };
2340 let mut context_paths = Vec::new();
2341 let base_prompt = match effective_args.system_prompt.as_deref() {
2342 Some(explicit) => explicit.to_string(),
2343 None if project_trusted => match discover_system_prompt_file(&ctx.cwd) {
2344 Some(path) => {
2345 read_prompt_source(&path, &mut context_paths).unwrap_or_else(default_prompt)
2346 }
2347 None => default_prompt(),
2348 },
2349 None => default_prompt(),
2350 };
2351 let mut append_texts: Vec<String> = Vec::new();
2352 for extra in &effective_args.append_system_prompt {
2353 let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
2354 append_texts.push(text);
2355 }
2356 if effective_args.append_system_prompt.is_empty() {
2357 if let Some(path) = project_trusted
2358 .then(|| discover_append_system_prompt_file(&ctx.cwd))
2359 .flatten()
2360 {
2361 if let Some(text) = read_prompt_source(&path, &mut context_paths) {
2362 append_texts.push(text);
2363 }
2364 }
2365 }
2366 context_paths.extend(
2367 context_files
2368 .iter()
2369 .map(|file| file.path.to_string_lossy().into_owned()),
2370 );
2371 let append_join = if append_texts.is_empty() {
2372 None
2373 } else {
2374 Some(append_texts.join("\n\n"))
2375 };
2376 let system_prompt = compose_system_prompt(
2377 Some(&base_prompt),
2378 &[],
2379 if context_block.is_empty() {
2380 None
2381 } else {
2382 Some(&context_block)
2383 },
2384 append_join.as_deref(),
2385 );
2386
2387 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
2389 Some(snapshot) => {
2390 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
2391 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
2392 }
2393 None => ctx.broadcast.clone(),
2394 };
2395
2396 let resources = AgentHarnessResources {
2398 context_files: context_paths,
2399 skills: if skills.is_empty() {
2400 None
2401 } else {
2402 Some(skills.clone())
2403 },
2404 prompt_templates: if prompt_templates.is_empty() {
2405 None
2406 } else {
2407 Some(prompt_templates.clone())
2408 },
2409 };
2410 let _ = harness.set_system_prompt(Some(system_prompt)).await;
2411 let _ = harness.set_resources(resources).await;
2412 let _ = harness.set_agent_emitter(Some(emitter)).await;
2413 let _ = harness
2414 .set_models(build_models_with_extensions_for_reload(
2415 &refreshed
2416 .as_ref()
2417 .map(|resolved| resolved.providers.clone())
2418 .unwrap_or_else(|| {
2419 ctx.providers
2420 .read()
2421 .unwrap_or_else(|error| error.into_inner())
2422 .clone()
2423 }),
2424 &extension_session,
2425 ctx.runtime.clone(),
2426 ))
2427 .await;
2428 let _ = harness
2429 .set_provider_hooks(
2430 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
2431 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
2432 )
2433 .await;
2434
2435 if let Some(resolved) = &refreshed {
2436 *ctx.providers
2437 .write()
2438 .unwrap_or_else(|error| error.into_inner()) = resolved.providers.clone();
2439 let catalog = crate::provider::authenticated_catalog(resolved);
2440 fresh_bridge
2441 .clone_host()
2442 .replace_model_catalog(catalog.clone());
2443 *ctx.catalog
2444 .write()
2445 .unwrap_or_else(|error| error.into_inner()) = catalog;
2446 let _ = harness.lane("main").set_model(resolved.model.clone()).await;
2449 }
2450
2451 let _ = harness.set_tools(tools, Some(active)).await;
2452
2453 let summary = format!(
2454 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
2455 extension_session.loaded_paths().len(),
2456 skills.len(),
2457 prompt_templates.len(),
2458 );
2459 ReloadOutcome {
2460 summary,
2461 had_warnings: warnings,
2462 details,
2463 had_errors: false,
2466 project_trusted: Some(project_trusted),
2467 }
2468}
2469
2470#[derive(Clone, Debug, PartialEq, Eq)]
2471struct ReloadSettingsFields {
2472 skill_dirs: Option<Vec<String>>,
2473 prompt_dirs: Option<Vec<String>>,
2474 extension_dirs: Option<Vec<String>>,
2475}
2476
2477impl From<crate::settings::Settings> for ReloadSettingsFields {
2478 fn from(settings: crate::settings::Settings) -> Self {
2479 Self {
2480 skill_dirs: settings.skill_dirs,
2481 prompt_dirs: settings.prompt_dirs,
2482 extension_dirs: settings.extension_dirs,
2483 }
2484 }
2485}
2486
2487#[derive(Clone, Debug, PartialEq, Eq)]
2488struct ReloadSettingsSnapshot {
2489 global: ReloadSettingsFields,
2490 project: Option<ReloadSettingsFields>,
2491}
2492
2493fn reload_reads_settings(args: &Args) -> bool {
2494 !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
2495}
2496
2497fn load_reload_settings_snapshot(
2501 args: &Args,
2502 cwd: &Path,
2503 project_trusted: bool,
2504) -> Result<Option<ReloadSettingsSnapshot>, String> {
2505 if !reload_reads_settings(args) {
2506 return Ok(None);
2507 }
2508 let global = crate::settings::load_settings()
2509 .map_err(|error| format!("could not load global settings: {error}"))?
2510 .into();
2511 let project = if project_trusted {
2512 crate::settings::load_active_project_settings(cwd)
2513 .map_err(|error| format!("could not load project settings: {error}"))?
2514 .map(|(_, settings)| settings.into())
2515 } else {
2516 None
2517 };
2518 Ok(Some(ReloadSettingsSnapshot { global, project }))
2519}
2520
2521#[cfg(test)]
2524fn validate_settings_for_reload(
2525 args: &Args,
2526 cwd: &Path,
2527 project_trusted: bool,
2528) -> Result<(), String> {
2529 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
2530}
2531
2532fn prepare_reload_inputs(
2533 args: &Args,
2534 cwd: &Path,
2535 project_trusted: bool,
2536) -> Result<PreparedReloadInputs, String> {
2537 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
2538}
2539
2540fn prepare_reload_inputs_inner<F>(
2541 args: &Args,
2542 cwd: &Path,
2543 project_trusted: bool,
2544 before_verify: F,
2545) -> Result<PreparedReloadInputs, String>
2546where
2547 F: FnOnce(),
2548{
2549 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
2550
2551 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
2552 Vec::new()
2553 } else if args.project_only {
2554 project_extension_dirs(cwd)
2555 } else if project_trusted {
2556 extension_dirs(cwd)
2557 } else {
2558 global_extension_dirs()
2559 };
2560 if !args.no_extensions {
2561 extension_dirs.extend(args.extensions_dir.iter().cloned());
2562 }
2563 remove_global_native_package_dir(&mut extension_dirs);
2564 remove_project_package_dirs(cwd, &mut extension_dirs);
2565 let mut extension_files = if args.no_extensions {
2566 Vec::new()
2567 } else {
2568 args.extension.clone()
2569 };
2570 if !args.no_extensions && !args.dev_local_only {
2571 if project_trusted || args.project_only {
2572 extension_files.extend(crate::packages::active_project_package_artifacts(cwd));
2573 }
2574 if !args.project_only {
2575 extension_files.extend(crate::install::active_global_native_package_artifacts());
2576 extension_files.extend(crate::install::unregistered_global_native_package_artifacts());
2577 }
2578 }
2579
2580 let skill_base_dirs = if args.no_skills {
2581 Vec::new()
2582 } else if args.dev_local_only || args.project_only {
2583 project_skill_dirs(cwd)
2584 } else if project_trusted {
2585 skill_dirs(cwd)
2586 } else {
2587 global_skill_dirs()
2588 };
2589
2590 let prompt_base_dirs = if args.no_prompt_templates {
2591 Vec::new()
2592 } else if args.dev_local_only || args.project_only {
2593 project_prompt_template_dirs(cwd)
2594 } else if project_trusted {
2595 prompt_template_dirs(cwd)
2596 } else {
2597 global_prompt_template_dirs()
2598 };
2599
2600 before_verify();
2601 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
2602 if settings_before != settings_after {
2603 return Err(
2604 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
2605 );
2606 }
2607
2608 Ok(PreparedReloadInputs {
2609 extension_dirs,
2610 extension_files,
2611 skill_base_dirs,
2612 prompt_base_dirs,
2613 })
2614}
2615
2616fn build_models_with_extensions_for_reload(
2621 providers: &[Arc<dyn Provider>],
2622 extension_session: &ExtensionSession,
2623 runtime: tokio::runtime::Handle,
2624) -> Vec<Arc<dyn Provider>> {
2625 let mut models: Vec<Arc<dyn Provider>> = providers.to_vec();
2626 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
2627 for provider in pluggable {
2628 if extension_session
2632 .snapshot()
2633 .is_some_and(|snapshot| snapshot.provider_manifest(provider.id()).is_some())
2634 {
2635 continue;
2636 }
2637 if !models.iter().any(|known| known.id() == provider.id()) {
2638 models.push(provider);
2639 }
2640 }
2641 models
2642}
2643
2644fn report_deferred_renderers(session: &ExtensionSession) {
2648 let Some(snap) = session.snapshot_arc() else {
2649 return;
2650 };
2651 let all = snap.renderers();
2652 let markdown = all
2653 .iter()
2654 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
2655 .count();
2656 let message = all
2657 .iter()
2658 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
2659 .count();
2660 let entry = all
2661 .iter()
2662 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
2663 .count();
2664 if markdown + message + entry == 0 {
2665 return;
2666 }
2667 eprintln!(
2668 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
2669 markdown, message, entry
2670 );
2671}
2672
2673#[derive(Debug, thiserror::Error)]
2675pub enum BuildError {
2676 #[error("Experimental native session policy: {0}")]
2677 NativeStorePolicy(String),
2678 #[error("Could not create the session directory: {0}")]
2679 SessionDir(String),
2680 #[error("Could not open the saved session: {0}")]
2681 SessionOpen(String),
2682 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
2683 SessionNotFound { requested: String, dir: String },
2684 #[error("Could not build the harness: {0}")]
2685 HarnessCreate(String),
2686}
2687
2688fn build_models_with_extensions(
2691 resolved: &ResolvedModel,
2692 extension_session: &ExtensionSession,
2693 runtime: tokio::runtime::Handle,
2694) -> Vec<Arc<dyn Provider>> {
2695 build_models_with_extensions_for_reload(&resolved.providers, extension_session, runtime)
2696}
2697
2698pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
2701 if let Some(override_value) = args.trust_override {
2702 return override_value;
2703 }
2704 crate::config::project_trust_decision(cwd)
2705 .ok()
2706 .flatten()
2707 .unwrap_or(true)
2708}
2709
2710pub(crate) fn load_extensions(
2718 args: &Args,
2719 cwd: &Path,
2720 project_trusted: bool,
2721 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
2722) -> ExtensionSession {
2723 if args.no_extensions {
2724 return ExtensionSession::none();
2725 }
2726 let mut dirs = if args.dev_local_only {
2727 Vec::new()
2728 } else if args.project_only {
2729 project_extension_dirs(cwd)
2730 } else if project_trusted {
2731 extension_dirs(cwd)
2732 } else {
2733 global_extension_dirs()
2734 };
2735 dirs.extend(args.extensions_dir.iter().cloned());
2736 remove_global_native_package_dir(&mut dirs);
2737 remove_project_package_dirs(cwd, &mut dirs);
2738 let mut files = args.extension.clone();
2739 if !args.dev_local_only {
2740 if project_trusted || args.project_only {
2741 files.extend(crate::packages::active_project_package_artifacts(cwd));
2742 }
2743 if !args.project_only {
2744 files.extend(crate::install::active_global_native_package_artifacts());
2745 files.extend(crate::install::unregistered_global_native_package_artifacts());
2746 }
2747 }
2748 let leases = match crate::resource_management::host_leases(cwd) {
2749 Ok(leases) => leases,
2750 Err(error) => {
2751 eprintln!("warning: refusing extension loading: {error}");
2752 return ExtensionSession::none();
2753 }
2754 };
2755 load_extensions_from_files(&dirs, &files, action_bridge, Some(leases))
2756}
2757
2758fn remove_global_native_package_dir(dirs: &mut Vec<PathBuf>) {
2759 let Ok(agent_dir) = crate::config::agent_dir() else {
2760 return;
2761 };
2762 let native_dir = agent_dir.join("extensions");
2763 dirs.retain(|dir| dir != &native_dir);
2764}
2765
2766fn remove_project_package_dirs(cwd: &Path, dirs: &mut Vec<PathBuf>) {
2767 let package_dirs = crate::packages::project_package_dirs(cwd, "extensions");
2768 dirs.retain(|dir| !package_dirs.iter().any(|package_dir| dir == package_dir));
2769}
2770
2771fn load_extensions_from_files(
2772 dirs: &[PathBuf],
2773 files: &[PathBuf],
2774 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
2775 host_guard: Option<Arc<dyn Send + Sync>>,
2776) -> ExtensionSession {
2777 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
2778 rpi_extensions::load_session_mixed_guarded(dirs, files, diagnostics, action_bridge, host_guard)
2784}
2785
2786fn merge_extension_tools(
2792 tools: &mut Vec<HarnessTool>,
2793 session: &ExtensionSession,
2794 args: &Args,
2795 tool_context: &rpi_extensions::ToolCallContext,
2796) {
2797 let Some(snapshot) = session.snapshot() else {
2798 return;
2799 };
2800 for et in snapshot.tools() {
2801 let name = &et.tool.name;
2802 if !tool_name_allowed(name, args) {
2803 continue;
2804 }
2805 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
2806 .with_context(tool_context.clone());
2807 let harness_tool = HarnessTool::new(Arc::new(adapter));
2808 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
2809 Some(slot) => *slot = harness_tool,
2810 None => tools.push(harness_tool),
2811 }
2812 }
2813 if let Some(snapshot) = session.snapshot_arc() {
2816 if !snapshot.tool_result().is_empty() {
2817 for tool in tools.iter_mut() {
2818 tool.tool = rpi_extensions::with_tool_result_transform(
2819 Arc::clone(&tool.tool),
2820 Arc::clone(&snapshot),
2821 session.keepalive(),
2822 );
2823 }
2824 }
2825 }
2826}
2827
2828pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
2835 use rpi_tools::tools::bash::BashToolOptions;
2836 let default = std::env::var("RPI_BASH_TIMEOUT")
2837 .ok()
2838 .and_then(|v| v.parse::<f64>().ok())
2839 .unwrap_or(rpi_tools::tools::bash::DEFAULT_BASH_TIMEOUT_SECONDS);
2840 let settings = crate::settings::load_settings().unwrap_or_default();
2846 BashToolOptions {
2847 command_prefix: settings.shell_command_prefix.clone(),
2848 default_timeout: Some(default),
2849 prepare: None,
2850 }
2851}
2852
2853fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
2854 if args.no_tools {
2855 return Vec::new();
2856 }
2857 let mut all: Vec<(&'static str, HarnessTool)> = vec![
2866 (
2867 "read",
2868 HarnessTool::new(create_read_tool(
2869 ctx,
2870 Some(rpi_tools::tools::read::ReadToolOptions {
2871 image_processor: Some(Arc::new(
2872 rpi_tools::image_processing::InlineImageProcessor,
2873 )),
2874 ..Default::default()
2875 }),
2876 ))
2877 .with_replay(ToolReplay::Safe),
2878 ),
2879 (
2880 "bash",
2881 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
2882 ),
2883 ("edit", HarnessTool::new(create_edit_tool(ctx))),
2884 ("write", HarnessTool::new(create_write_tool(ctx))),
2885 (
2886 "docs",
2887 HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
2888 ),
2889 ];
2890
2891 #[cfg(target_os = "windows")]
2893 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
2894
2895 if args.tools.is_none() && args.resource_config.is_none() && args.resource_transition.is_none()
2897 {
2898 if let Ok(settings) = crate::settings::load_settings() {
2899 if let Some(default_tools) = &settings.default_tools {
2900 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
2901 }
2902 }
2903 }
2904
2905 if args.no_builtin_tools {
2909 all.clear();
2910 }
2911
2912 if let Some(allow) = &args.tools {
2914 all.retain(|(name, _)| allow.iter().any(|a| a == name));
2915 }
2916 if let Some(deny) = &args.exclude_tools {
2918 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
2919 }
2920
2921 all.into_iter().map(|(_, t)| t).collect()
2922}
2923
2924fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
2928 filter_active_tool_names(
2929 tools.iter().map(|tool| tool.tool.schema().name.clone()),
2930 args,
2931 )
2932}
2933
2934pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
2937 if args.no_tools {
2938 return false;
2939 }
2940 if args
2941 .tools
2942 .as_ref()
2943 .is_some_and(|allow| !allow.iter().any(|value| value == name))
2944 {
2945 return false;
2946 }
2947 if args
2948 .exclude_tools
2949 .as_ref()
2950 .is_some_and(|deny| deny.iter().any(|value| value == name))
2951 {
2952 return false;
2953 }
2954 true
2955}
2956
2957pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
2958where
2959 I: IntoIterator<Item = String>,
2960{
2961 names
2962 .into_iter()
2963 .filter(|name| tool_name_allowed(name, args))
2964 .collect()
2965}
2966
2967async fn build_session(
2969 selection: &SessionSelection,
2970 cwd: &str,
2971 selected: Option<&crate::resource_selection::ResolvedSelection>,
2972) -> Result<Session, BuildError> {
2973 match selection {
2974 SessionSelection::Ephemeral => Ok(ephemeral_session()),
2975 SessionSelection::ExperimentalNative { dir } => Ok(Session::new(
2976 backend::fresh_experimental_native(dir).await?,
2977 None,
2978 )),
2979 SessionSelection::New { dir, .. } => {
2980 std::fs::create_dir_all(dir)
2983 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2984 let session = create_jsonl_session(dir, cwd)
2985 .await
2986 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2987 Ok(session)
2988 }
2989 SessionSelection::Latest { .. }
2990 | SessionSelection::ById { .. }
2991 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2992 SessionSelection::Fork { source, dir } => {
2993 fork_session_at_launch(source, dir, cwd, selected).await
2994 }
2995 }
2996}
2997
2998async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
3004 let Some(meta) = resolve_restore_target(selection, cwd).await? else {
3008 return match selection {
3009 SessionSelection::ByExactId { id, dir } => {
3012 std::fs::create_dir_all(dir)
3013 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
3014 create_jsonl_session_with_id(dir, cwd, Some(id.clone()))
3015 .await
3016 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
3017 }
3018 SessionSelection::Latest { dir } => Err(BuildError::SessionNotFound {
3019 requested: "the most recent session".to_string(),
3020 dir: dir.display().to_string(),
3021 }),
3022 SessionSelection::ById { id, dir } => Err(BuildError::SessionNotFound {
3023 requested: format!("session {id}"),
3024 dir: dir.display().to_string(),
3025 }),
3026 _ => unreachable!("resolve_restore_target only returns None for restore selections"),
3027 };
3028 };
3029 let sessions_root = selection_dir(selection);
3030 let writer_restore = StartupSpan::start(Phase::WriterRestore);
3031 let result = open_session(&meta, cwd, &sessions_root)
3032 .await
3033 .map(|restored| restored.session);
3034 if result.is_ok() {
3035 writer_restore.finish();
3036 }
3037 result
3038}
3039
3040fn selection_dir(selection: &SessionSelection) -> PathBuf {
3042 match selection {
3043 SessionSelection::Ephemeral => PathBuf::new(),
3044 SessionSelection::New { dir, .. }
3045 | SessionSelection::ExperimentalNative { dir }
3046 | SessionSelection::Latest { dir }
3047 | SessionSelection::ById { dir, .. }
3048 | SessionSelection::ByExactId { dir, .. }
3049 | SessionSelection::Fork { dir, .. } => dir.clone(),
3050 }
3051}
3052
3053async fn resolve_restore_target(
3060 selection: &SessionSelection,
3061 cwd: &str,
3062) -> Result<Option<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
3063 match selection {
3064 SessionSelection::Latest { dir } => {
3065 let metas = list_session_metadata_in_dir(cwd, dir).await?;
3066 Ok(first_resumable_session(&metas).cloned())
3067 }
3068 SessionSelection::ById { id, dir } => find_session_metadata(id, cwd, dir).await,
3069 SessionSelection::ByExactId { id, dir } => {
3070 let metas = list_session_metadata_in_dir(cwd, dir).await?;
3071 Ok(metas.into_iter().find(|m| m.id == *id))
3072 }
3073 SessionSelection::Fork { .. }
3074 | SessionSelection::ExperimentalNative { .. }
3075 | SessionSelection::New { .. }
3076 | SessionSelection::Ephemeral => Ok(None),
3077 }
3078}
3079
3080async fn find_session_metadata(
3084 id: &str,
3085 cwd: &str,
3086 dir: &Path,
3087) -> Result<Option<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
3088 let metas = list_session_metadata_in_dir(cwd, dir).await?;
3089 Ok(metas
3090 .into_iter()
3091 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id)))
3092}
3093
3094pub(crate) struct RestoredSessionCwd {
3096 pub id: String,
3102 pub recorded: PathBuf,
3104 pub adopted: Option<PathBuf>,
3107}
3108
3109pub(crate) async fn peek_restored_session_cwd(
3117 selection: &SessionSelection,
3118 launch_cwd: &Path,
3119) -> Option<RestoredSessionCwd> {
3120 let cwd_str = launch_cwd.to_string_lossy().into_owned();
3121 let meta = resolve_restore_target(selection, &cwd_str).await.ok()??;
3122 let recorded = PathBuf::from(&meta.cwd);
3123 if recorded.as_os_str().is_empty() {
3124 return None;
3125 }
3126 Some(RestoredSessionCwd {
3127 id: meta.id,
3128 adopted: crate::session_cwd::resolve_session_cwd(&recorded),
3129 recorded,
3130 })
3131}
3132
3133pub(crate) fn pin_restored_selection(args: &mut Args, restored: &RestoredSessionCwd) {
3143 args.continue_session = false;
3144 args.resume = false;
3145 args.session = Some(restored.id.clone());
3146}
3147
3148async fn fork_session_at_launch(
3151 source: &str,
3152 dir: &Path,
3153 cwd: &str,
3154 selected: Option<&crate::resource_selection::ResolvedSelection>,
3155) -> Result<Session, BuildError> {
3156 use rpi_harness::session::jsonl::{
3157 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
3158 };
3159 use rpi_harness::session::types::{ForkOptions, SessionStorage};
3160 use rpi_tools::FileSystem;
3161
3162 let dir = dir.to_path_buf();
3163 std::fs::create_dir_all(&dir)
3164 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
3165 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3166 let fs: Arc<dyn FileSystem> = env.clone();
3167 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3168 fs: fs.clone(),
3169 sessions_root: dir.to_string_lossy().into_owned(),
3170 clock: Arc::new(SystemClock),
3171 ids: Arc::new(DefaultIdGenerator::new()),
3172 });
3173 let source_meta = match session_metadata_at(Path::new(source)) {
3177 Some(meta) => meta,
3178 None => {
3179 let metas = repo
3180 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
3181 .await
3182 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
3183 metas
3184 .into_iter()
3185 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
3186 .ok_or_else(|| BuildError::SessionNotFound {
3187 requested: format!("--fork {source}"),
3188 dir: dir.display().to_string(),
3189 })?
3190 }
3191 };
3192 let fork_storage = repo
3193 .fork_typed_with_value_initializer(
3194 &source_meta,
3195 &JsonlSessionCreateOptions {
3196 id: None,
3197 parent_session_id: Some(source_meta.id.clone()),
3198 cwd: cwd.to_string(),
3199 metadata: None,
3200 },
3201 &ForkOptions::default(),
3202 |values| crate::resource_selection::fork_initial_values_for_selection(values, selected),
3203 )
3204 .await
3205 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
3206 claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
3207 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
3208 Ok(Session::new(storage_arc, None))
3209}
3210
3211fn session_metadata_at(path: &Path) -> Option<rpi_harness::session::jsonl::JsonlSessionMetadata> {
3214 use rpi_harness::session::jsonl::codec::{metadata_from_header, parse_header};
3215 use std::io::BufRead;
3216
3217 let file = std::fs::File::open(path).ok()?;
3218 let modified_at = file
3219 .metadata()
3220 .ok()?
3221 .modified()
3222 .ok()?
3223 .duration_since(std::time::UNIX_EPOCH)
3224 .ok()?
3225 .as_millis() as i64;
3226 let mut first_line = String::new();
3227 std::io::BufReader::new(file)
3228 .read_line(&mut first_line)
3229 .ok()?;
3230 let header = parse_header(first_line.trim_end()).ok()?;
3231 let path = std::fs::canonicalize(path).ok()?;
3232 Some(metadata_from_header(
3233 &header,
3234 &path.to_string_lossy(),
3235 modified_at,
3236 ))
3237}
3238
3239pub enum OpenError {
3242 NotFound { requested: String },
3244 Other(String),
3246}
3247
3248impl std::fmt::Display for OpenError {
3249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3250 match self {
3251 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
3252 OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
3257 }
3258 }
3259}
3260
3261pub async fn list_session_metadata(
3264 cwd: &str,
3265) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
3266 let dir = default_session_dir(Path::new(cwd));
3267 list_session_metadata_in_dir(cwd, &dir).await
3268}
3269
3270pub(crate) async fn list_session_metadata_in_dir(
3271 cwd: &str,
3272 dir: &Path,
3273) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
3274 use rpi_harness::session::jsonl::{
3275 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
3276 };
3277 use rpi_tools::FileSystem;
3278
3279 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3280 let fs: Arc<dyn FileSystem> = env.clone();
3281 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3282 fs: fs.clone(),
3283 sessions_root: dir.to_string_lossy().into_owned(),
3284 clock: Arc::new(SystemClock),
3285 ids: Arc::new(DefaultIdGenerator::new()),
3286 });
3287 let metadata_lookup = StartupSpan::start(Phase::MetadataLookup);
3288 let result = repo
3289 .list_typed(&JsonlSessionListOptions::default())
3290 .await
3291 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")));
3292 if result.is_ok() {
3293 metadata_lookup.finish();
3294 }
3295 result
3296}
3297
3298#[derive(Debug, Clone, Default, PartialEq, Eq)]
3314pub struct SessionSummary {
3315 pub name: Option<String>,
3317 pub preview: Option<String>,
3319 pub empty: bool,
3321 pub bytes: u64,
3323}
3324
3325const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
3327const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
3329const SUMMARY_PREVIEW_CHARS: usize = 80;
3331
3332pub fn summarize_session_file(path: &Path) -> SessionSummary {
3336 use std::io::{Read, Seek, SeekFrom};
3337
3338 let Ok(mut file) = std::fs::File::open(path) else {
3339 return SessionSummary::default();
3340 };
3341 let Ok(size) = file.metadata().map(|m| m.len()) else {
3342 return SessionSummary::default();
3343 };
3344 if size == 0 {
3345 return SessionSummary {
3346 name: None,
3347 preview: None,
3348 empty: true,
3349 bytes: 0,
3350 };
3351 }
3352
3353 let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
3356 let head_len = file.read(&mut head).unwrap_or(0);
3357 head.truncate(head_len);
3358 let head = String::from_utf8_lossy(&head);
3359 let mut lines: Vec<&str> = head.lines().collect();
3360 let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
3361 let empty = (size as usize) <= header_len + 1;
3363 if (head_len as u64) < size && !lines.is_empty() {
3366 lines.pop();
3367 }
3368
3369 let mut summary = SessionSummary {
3370 name: None,
3371 preview: None,
3372 empty,
3373 bytes: size,
3374 };
3375 for line in lines {
3376 apply_summary_line(line, &mut summary);
3377 }
3378
3379 if size > SUMMARY_HEAD_BYTES
3383 && file
3384 .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
3385 .is_ok()
3386 {
3387 let mut tail = Vec::new();
3388 if file.read_to_end(&mut tail).is_ok() {
3389 let tail = String::from_utf8_lossy(&tail);
3390 let mut tail_lines = tail.lines();
3391 tail_lines.next();
3393 for line in tail_lines {
3394 if let Some(name) = name_from_line(line) {
3395 summary.name = name;
3396 }
3397 }
3398 }
3399 }
3400 summary
3401}
3402
3403fn name_from_line(line: &str) -> Option<Option<String>> {
3406 let value: serde_json::Value = serde_json::from_str(line).ok()?;
3407 if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
3408 return None;
3409 }
3410 if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
3411 return None;
3412 }
3413 Some(match value.get("name") {
3414 Some(serde_json::Value::String(name)) => Some(name.clone()),
3415 _ => None,
3416 })
3417}
3418
3419fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
3422 if let Some(name) = name_from_line(line) {
3423 summary.name = name;
3424 return;
3425 }
3426 if summary.preview.is_some() {
3427 return;
3428 }
3429 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
3430 return;
3431 };
3432 if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
3433 return;
3434 }
3435 if value.get("type").and_then(|v| v.as_str()) != Some("message") {
3436 return;
3437 }
3438 let Some(message) = value.get("message") else {
3439 return;
3440 };
3441 if message.get("role").and_then(|v| v.as_str()) != Some("user") {
3442 return;
3443 }
3444 let text = match message.get("content") {
3445 Some(serde_json::Value::String(text)) => Some(text.as_str()),
3446 Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
3447 (block.get("type").and_then(|v| v.as_str()) == Some("text"))
3448 .then(|| block.get("text").and_then(|v| v.as_str()))
3449 .flatten()
3450 }),
3451 _ => None,
3452 };
3453 let Some(text) = text else { return };
3454 let collapsed = text
3455 .lines()
3456 .map(str::trim)
3457 .filter(|line| !line.is_empty())
3458 .collect::<Vec<_>>()
3459 .join(" ");
3460 if collapsed.is_empty() {
3461 return;
3462 }
3463 summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
3464}
3465
3466pub fn session_display_label(summary: &SessionSummary) -> String {
3470 if let Some(name) = summary.name.as_deref().map(str::trim) {
3471 if !name.is_empty() {
3472 return name.to_string();
3473 }
3474 }
3475 match summary.preview.as_deref().map(str::trim) {
3476 Some(preview) if !preview.is_empty() => preview.to_string(),
3477 _ => "(no messages)".to_string(),
3478 }
3479}
3480
3481pub fn format_session_bytes(bytes: u64) -> String {
3484 if bytes >= 1_048_576 {
3485 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
3486 } else if bytes >= 1024 {
3487 format!("{}KB", bytes / 1024)
3488 } else {
3489 format!("{bytes}B")
3490 }
3491}
3492
3493pub fn short_session_id(id: &str) -> &str {
3496 &id[..id.len().min(8)]
3497}
3498
3499pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
3508 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
3509 let mut visit = |path: PathBuf| {
3510 if path.extension().and_then(|e| e.to_str()) != Some("jsonl")
3511 || path.to_string_lossy().ends_with(".progress.jsonl")
3512 {
3513 return;
3514 }
3515 let modified = std::fs::metadata(&path)
3516 .and_then(|m| m.modified())
3517 .unwrap_or(std::time::UNIX_EPOCH);
3518 files.push((path, modified));
3519 };
3520 if let Ok(entries) = std::fs::read_dir(dir) {
3521 for entry in entries.flatten() {
3522 let path = entry.path();
3523 if path.is_dir() {
3524 if let Ok(inner) = std::fs::read_dir(&path) {
3525 for child in inner.flatten() {
3526 visit(child.path());
3527 }
3528 }
3529 } else {
3530 visit(path);
3531 }
3532 }
3533 }
3534 files.sort_by(|a, b| b.1.cmp(&a.1));
3535 files.into_iter().map(|(path, _)| path).collect()
3536}
3537
3538fn first_resumable_session(
3543 metas: &[rpi_harness::session::jsonl::JsonlSessionMetadata],
3544) -> Option<&rpi_harness::session::jsonl::JsonlSessionMetadata> {
3545 metas
3546 .iter()
3547 .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
3548 .or_else(|| metas.first())
3549}
3550
3551pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
3555 let dir = default_session_dir(Path::new(cwd));
3556 open_session_by_id_in_dir(id, cwd, &dir)
3557 .await
3558 .map(|restored| restored.session)
3559}
3560
3561pub(crate) async fn open_session_by_id_in_dir(
3562 id: &str,
3563 cwd: &str,
3564 dir: &Path,
3565) -> Result<RestoredSession, OpenError> {
3566 let meta = find_session_metadata(id, cwd, dir)
3567 .await
3568 .map_err(|e| OpenError::Other(e.to_string()))?;
3569 let Some(meta) = meta else {
3570 return Err(OpenError::NotFound {
3571 requested: format!("session {id}"),
3572 });
3573 };
3574 open_session(&meta, cwd, dir).await.map_err(|e| {
3575 OpenError::Other(match e {
3576 BuildError::SessionOpen(reason) => reason,
3580 other => other.to_string(),
3581 })
3582 })
3583}
3584
3585fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
3610 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
3611 tool_context.set_session_id(id);
3612}
3613
3614pub(crate) async fn set_active_session(
3625 harness: &AgentHarness,
3626 session: Session,
3627 tool_context: Option<&rpi_extensions::ToolCallContext>,
3628) -> Result<(), String> {
3629 let metadata = session.get_metadata().await.ok();
3630 harness
3631 .set_session(session)
3632 .await
3633 .map_err(|e| e.to_string())?;
3634 if let Some(metadata) = metadata {
3635 std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
3636 if let Some(context) = tool_context {
3637 context.set_session_id(metadata.id);
3638 }
3639 }
3640 Ok(())
3641}
3642
3643pub(crate) async fn fork_session_storage(
3650 harness: &AgentHarness,
3651 cwd: &str,
3652 dir: &Path,
3653) -> Result<Session, String> {
3654 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3655 use rpi_tools::FileSystem;
3656
3657 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3658 let fs: Arc<dyn FileSystem> = env.clone();
3659 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3660 fs,
3661 sessions_root: dir.to_string_lossy().into_owned(),
3662 clock: Arc::new(SystemClock),
3663 ids: Arc::new(DefaultIdGenerator::new()),
3664 });
3665 let id = harness.session().storage().metadata().id.clone();
3668 let metas = list_session_metadata_in_dir(cwd, dir)
3669 .await
3670 .map_err(|e| e.to_string())?;
3671 let Some(source) = metas.iter().find(|m| m.id == id) else {
3672 return Err(format!("current session {id} not found on disk"));
3673 };
3674 let fork_storage = repo
3675 .fork_typed_with_value_initializer(
3676 source,
3677 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
3678 id: None,
3679 parent_session_id: Some(source.id.clone()),
3680 cwd: cwd.to_string(),
3681 metadata: None,
3682 },
3683 &rpi_harness::session::types::ForkOptions::default(),
3684 crate::resource_selection::fork_initial_values,
3685 )
3686 .await
3687 .map_err(|e| e.to_string())?;
3688 claim_session_lock(&fork_storage.jsonl_metadata().path)?;
3689 Ok(Session::new(Arc::new(fork_storage), None))
3690}
3691
3692pub(crate) async fn import_session_file(
3696 source: &Path,
3697 cwd: &str,
3698 dir: &Path,
3699) -> Result<Session, String> {
3700 use rpi_harness::session::jsonl::{
3701 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions, JsonlSessionStorage,
3702 };
3703 use rpi_harness::session::types::SessionStorage;
3704 use rpi_tools::FileSystem;
3705
3706 let dir = if dir.is_absolute() {
3707 dir.to_path_buf()
3708 } else {
3709 Path::new(cwd).join(dir)
3710 };
3711 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
3712 let source_progress = PathBuf::from(format!("{}.progress.jsonl", source.display()));
3716 let source_lock_path = PathBuf::from(format!("{}.lock", source.display()));
3717 let canonical_lock = std::fs::canonicalize(&source_lock_path).ok();
3718 let owned_here = SESSION_LOCK_PATHS.get().is_some_and(|paths| {
3719 paths.lock().unwrap().iter().any(|path| {
3720 path == &source_lock_path
3721 || canonical_lock.as_ref().is_some_and(|canonical| {
3722 std::fs::canonicalize(path).ok().as_ref() == Some(canonical)
3723 })
3724 })
3725 });
3726 let _source_lock = if !owned_here && source_progress.try_exists().map_err(|e| e.to_string())? {
3727 let lock = std::fs::OpenOptions::new()
3728 .create(true)
3729 .write(true)
3730 .open(format!("{}.lock", source.display()))
3731 .map_err(|e| format!("lock import source: {e}"))?;
3732 fs2::FileExt::try_lock_exclusive(&lock).map_err(|_| {
3733 "close the source session before importing its recovery pair".to_owned()
3734 })?;
3735 Some(lock)
3736 } else {
3737 None
3738 };
3739 let staging = tempfile::tempdir_in(&dir).map_err(|e| e.to_string())?;
3740 let staged = staging.path().join("history.stage");
3741 let staged_progress = PathBuf::from(format!("{}.progress.jsonl", staged.display()));
3742 let mut attempts = 0;
3743 let has_progress = loop {
3744 std::fs::copy(source, &staged).map_err(|e| format!("copy import: {e}"))?;
3745 let copied_history = std::fs::read(&staged).map_err(|e| e.to_string())?;
3746 let has_progress = match std::fs::copy(&source_progress, &staged_progress) {
3747 Ok(_) => true,
3748 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3749 let _ = std::fs::remove_file(&staged_progress);
3750 false
3751 }
3752 Err(error) => return Err(format!("copy progress import: {error}")),
3753 };
3754 if copied_history == std::fs::read(source).map_err(|e| e.to_string())? {
3755 break has_progress;
3756 }
3757 attempts += 1;
3758 if attempts >= 4 {
3759 return Err(
3760 "source history changed during import; retry when the session is idle".into(),
3761 );
3762 }
3763 tokio::task::yield_now().await;
3764 };
3765 let fs: Arc<dyn FileSystem> = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3766 let validated = JsonlSessionStorage::load(
3767 fs.clone(),
3768 &staged.to_string_lossy(),
3769 Arc::new(SystemClock),
3770 Arc::new(DefaultIdGenerator::new()),
3771 )
3772 .await
3773 .map_err(|e| format!("invalid session import: {e}"))?;
3774 validated
3775 .read_progress("")
3776 .await
3777 .map_err(|e| format!("invalid recovery import: {e}"))?;
3778 let mut metadata = validated.jsonl_metadata().clone();
3779 drop(validated);
3780 let text = std::fs::read_to_string(&staged).map_err(|e| e.to_string())?;
3781 let (first, rest) = text
3782 .split_once('\n')
3783 .ok_or_else(|| "import has no complete header".to_owned())?;
3784 let mut header =
3785 rpi_harness::session::jsonl::codec::parse_header(first).map_err(|e| e.to_string())?;
3786 if has_progress && header.progress_format.is_none() {
3787 return Err("history-only artifact has an undeclared recovery companion".into());
3788 }
3789 header.progress_format = Some("assistant-progress-v1".into());
3792 std::fs::write(
3793 &staged,
3794 format!(
3795 "{}{}",
3796 rpi_harness::session::jsonl::codec::encode_header(&header),
3797 rest
3798 ),
3799 )
3800 .map_err(|e| e.to_string())?;
3801 if list_session_metadata_in_dir(cwd, &dir)
3802 .await
3803 .map_err(|e| e.to_string())?
3804 .iter()
3805 .any(|saved| saved.id == metadata.id)
3806 {
3807 return Err(format!(
3808 "session {} already exists in {}",
3809 metadata.id,
3810 dir.display()
3811 ));
3812 }
3813 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3814 fs,
3815 sessions_root: dir.to_string_lossy().into_owned(),
3816 clock: Arc::new(SystemClock),
3817 ids: Arc::new(DefaultIdGenerator::new()),
3818 });
3819 let destination = repo
3821 .create_typed(&JsonlSessionCreateOptions {
3822 id: Some(metadata.id.clone()),
3823 parent_session_id: metadata.parent_session_id.clone(),
3824 cwd: cwd.to_string(),
3825 metadata: metadata.metadata.clone(),
3826 })
3827 .await
3828 .map_err(|e| e.to_string())?;
3829 let path = destination.jsonl_metadata().path.clone();
3830 drop(destination);
3831 if let Err(error) = claim_session_lock(&path) {
3832 return Err(error);
3834 }
3835 let destination_progress = format!("{path}.progress.jsonl");
3836 let publish = (|| -> std::io::Result<()> {
3837 if has_progress {
3838 std::fs::rename(&staged_progress, &destination_progress)?;
3839 }
3840 std::fs::rename(&staged, &path)
3841 })();
3842 if let Err(error) = publish {
3843 let _ = std::fs::remove_file(&path);
3844 let _ = std::fs::remove_file(&destination_progress);
3845 return Err(format!("publish import: {error}"));
3846 }
3847 metadata.path = path;
3848 let storage = repo
3849 .open_by_jsonl_metadata(&metadata)
3850 .await
3851 .map_err(|e| e.to_string())?;
3852 Ok(Session::new(Arc::new(storage), None))
3853}
3854
3855static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
3857static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
3860
3861fn claim_session_lock(session_path: &str) -> Result<(), String> {
3870 claim_session_lock_inner(session_path, true)
3871}
3872fn claim_session_lock_inner(session_path: &str, allow_reentry: bool) -> Result<(), String> {
3873 let canonical =
3874 std::fs::canonicalize(session_path).unwrap_or_else(|_| PathBuf::from(session_path));
3875 let lock_path = PathBuf::from(format!("{}.lock", canonical.display()));
3876 let mut paths = SESSION_LOCK_PATHS
3877 .get_or_init(|| Mutex::new(HashSet::new()))
3878 .lock()
3879 .unwrap();
3880 if paths.contains(&lock_path) {
3881 return if allow_reentry {
3882 Ok(())
3883 } else {
3884 Err("transition session already has a writer in this process".into())
3885 };
3886 }
3887 let file = std::fs::OpenOptions::new()
3888 .create(true)
3889 .write(true)
3890 .open(&lock_path)
3891 .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
3892 if fs2::FileExt::try_lock_exclusive(&file).is_err() {
3893 return Err(format!(
3894 "session {session_path} is already open in another rpi process"
3895 ));
3896 }
3897 SESSION_LOCKS
3898 .get_or_init(|| Mutex::new(Vec::new()))
3899 .lock()
3900 .unwrap()
3901 .push(file);
3902 paths.insert(lock_path);
3903 Ok(())
3904}
3905
3906pub(crate) struct RestoredSession {
3915 pub session: Session,
3916 pub recorded_cwd: Option<PathBuf>,
3919}
3920
3921async fn open_session(
3928 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
3929 cwd: &str,
3930 sessions_root: &Path,
3931) -> Result<RestoredSession, BuildError> {
3932 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3933 use rpi_harness::session::types::SessionStorage;
3934 use rpi_tools::FileSystem;
3935
3936 let dir = sessions_root;
3937 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3938 let fs: Arc<dyn FileSystem> = env.clone();
3939 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3940 fs: fs.clone(),
3941 sessions_root: dir.to_string_lossy().into_owned(),
3942 clock: Arc::new(SystemClock),
3943 ids: Arc::new(DefaultIdGenerator::new()),
3944 });
3945 claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
3946 let storage = repo
3947 .open_by_jsonl_metadata(meta)
3948 .await
3949 .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
3950 let storage = storage
3953 .enable_progress_under_exclusive_lock()
3954 .await
3955 .map_err(|e| BuildError::SessionOpen(format!("upgrade {}: {e}", meta.path)))?;
3956 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
3957 Ok(RestoredSession {
3958 session: Session::new(storage_arc, None),
3959 recorded_cwd: crate::session_cwd::resolve_session_cwd(Path::new(&meta.cwd)),
3960 })
3961}
3962
3963pub(crate) fn ephemeral_session() -> Session {
3967 let storage = Arc::new(InMemorySessionStorage::new(
3968 SessionMetadata {
3969 id: "ephemeral".into(),
3970 created_at: 0,
3971 parent_session_id: None,
3972 },
3973 Arc::new(SystemClock),
3974 Arc::new(DefaultIdGenerator::new()),
3975 ));
3976 Session::new(storage, None).with_volatile_progress()
3977}
3978
3979pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
3990 create_jsonl_session_with_id(dir, cwd, None).await
3991}
3992
3993pub(crate) async fn create_jsonl_session_with_id(
3997 dir: &Path,
3998 cwd: &str,
3999 id: Option<String>,
4000) -> Result<Session, String> {
4001 use rpi_harness::session::jsonl::{
4002 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
4003 };
4004 use rpi_tools::FileSystem;
4005
4006 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
4009 let fs: Arc<dyn FileSystem> = env.clone();
4010
4011 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
4012 fs: fs.clone(),
4013 sessions_root: dir.to_string_lossy().into_owned(),
4014 clock: Arc::new(SystemClock),
4015 ids: Arc::new(DefaultIdGenerator::new()),
4016 });
4017
4018 let opts = JsonlSessionCreateOptions {
4019 id, parent_session_id: None,
4021 cwd: cwd.to_string(),
4022 metadata: None,
4023 };
4024 let storage = repo
4025 .create_typed(&opts)
4026 .await
4027 .map_err(|e| format!("create session: {e}"))?;
4028 claim_session_lock(&storage.jsonl_metadata().path)
4029 .map_err(|e| format!("create session: {e}"))?;
4030 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
4032 Ok(Session::new(storage_arc, None))
4033}
4034
4035fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
4038 let path = Path::new(target);
4039 if path.is_file() {
4040 read_prompt_source(path, sources)
4041 } else {
4042 None
4043 }
4044}
4045
4046fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
4049 let text = std::fs::read_to_string(path).ok()?;
4050 let absolute = if path.is_absolute() {
4051 path.to_path_buf()
4052 } else {
4053 std::env::current_dir()
4054 .map(|cwd| cwd.join(path))
4055 .unwrap_or_else(|_| path.to_path_buf())
4056 };
4057 let source = absolute.to_string_lossy().into_owned();
4058 if !sources.contains(&source) {
4059 sources.push(source);
4060 }
4061 Some(text)
4062}
4063
4064#[cfg(test)]
4065mod goal_outbox_smoke_tests;
4066#[cfg(all(test, feature = "experimental-native-store"))]
4067mod native_roundtrip_tests;
4068#[cfg(all(test, feature = "experimental-native-store"))]
4069mod native_store_tests;
4070#[cfg(test)]
4071mod runtime_session_tests;
4072
4073#[cfg(test)]
4074mod tests {
4075 use super::*;
4076
4077 #[test]
4078 fn settlement_bridge_follows_reload_and_releases_its_session_with_the_bus() {
4079 use rpi_harness::events::{HarnessEvent, HarnessEventBus, RunEndEvent, RunEndOutcome};
4080 use std::sync::atomic::{AtomicUsize, Ordering};
4081 static OLD: AtomicUsize = AtomicUsize::new(0);
4082 static NEW: AtomicUsize = AtomicUsize::new(0);
4083 extern "C" fn old(_: rpi_plugin_sdk::StablePluginEvent, _: *mut std::ffi::c_void) -> i32 {
4084 OLD.fetch_add(1, Ordering::SeqCst);
4085 0
4086 }
4087 extern "C" fn new(_: rpi_plugin_sdk::StablePluginEvent, _: *mut std::ffi::c_void) -> i32 {
4088 NEW.fetch_add(1, Ordering::SeqCst);
4089 0
4090 }
4091 let make = |handler: rpi_plugin_sdk::EventHandlerFn| {
4092 let mut registry = rpi_extensions::ExtensionRegistry::new();
4093 registry.register_event_handler(
4094 "settlement".into(),
4095 rpi_plugin_sdk::EventTag::AgentSettled,
4096 handler,
4097 std::ptr::null_mut(),
4098 );
4099 ExtensionSession::from_parts(
4100 Arc::new(registry.snapshot()),
4101 rpi_extensions::PluginKeepalive::empty(),
4102 vec![],
4103 None,
4104 )
4105 };
4106 let sessions = Arc::new(Mutex::new(make(old)));
4107 let weak = Arc::downgrade(&sessions);
4108 let bus = HarnessEventBus::new();
4109 install_extension_settlement_bridge(&bus, sessions.clone());
4110 let ended = |outcome| {
4111 HarnessEvent::RunEnd(RunEndEvent {
4112 lane: "main".into(),
4113 run_id: "run".into(),
4114 outcome,
4115 leaf_id: "leaf".into(),
4116 })
4117 };
4118 bus.emit(&ended(RunEndOutcome::Completed));
4119 assert_eq!(OLD.load(Ordering::SeqCst), 1);
4120 *sessions.lock().unwrap() = make(new);
4121 for outcome in [RunEndOutcome::Aborted, RunEndOutcome::Failed] {
4122 bus.emit(&ended(outcome));
4123 }
4124 assert_eq!(OLD.load(Ordering::SeqCst), 1);
4125 assert_eq!(NEW.load(Ordering::SeqCst), 2);
4126 drop(sessions);
4127 assert!(weak.upgrade().is_some());
4128 drop(bus);
4129 assert!(
4130 weak.upgrade().is_none(),
4131 "settlement observer must not create an ownership cycle"
4132 );
4133 }
4134
4135 #[tokio::test]
4136 async fn unauthenticated_packages_are_not_reintroduced_as_legacy_adapters() {
4137 extern "C" fn request(
4138 _: rpi_plugin_sdk::StbStringRef,
4139 _: *mut rpi_plugin_sdk::StbString,
4140 _: *mut std::ffi::c_void,
4141 ) -> i32 {
4142 1
4143 }
4144 let mut registry = rpi_extensions::ExtensionRegistry::new();
4145 for id in ["package", "legacy"] {
4146 registry.register_provider(rpi_extensions::RegisteredProvider {
4147 provider_id: id.into(),
4148 base_url: "http://localhost".into(),
4149 api_style: "openai-completions".into(),
4150 request_fn: request,
4151 stream_fn: None,
4152 plugin_free_string: rpi_plugin_sdk::callbacks::free_string,
4153 user_data: std::ptr::null_mut(),
4154 });
4155 }
4156 assert!(registry.apply_declaration_json(&serde_json::json!({"providers":[{
4157 "id":"package", "requiresAuth":true,
4158 "models":[rpi_ai::Model::new("model", "Model", rpi_ai::Api::OpenaiCompletions, "package", "http://localhost")]
4159 }]}).to_string()));
4160 let session = ExtensionSession::from_parts(
4161 Arc::new(registry.snapshot()),
4162 rpi_extensions::PluginKeepalive::empty(),
4163 vec![],
4164 None,
4165 );
4166 let providers = build_models_with_extensions_for_reload(
4167 &[],
4168 &session,
4169 tokio::runtime::Handle::current(),
4170 );
4171 assert_eq!(
4172 providers
4173 .iter()
4174 .map(|provider| provider.id())
4175 .collect::<Vec<_>>(),
4176 ["legacy"]
4177 );
4178 }
4179
4180 #[test]
4184 fn catalog_refresh_keeps_model_identity_but_takes_new_metadata() {
4185 let stale = rpi_ai::Model {
4186 base_url: "http://localhost/old".into(),
4187 context_window: 8192,
4188 ..rpi_ai::Model::new(
4189 "model",
4190 "Model",
4191 rpi_ai::Api::OpenaiCompletions,
4192 "probe",
4193 "http://localhost",
4194 )
4195 };
4196 let catalog = vec![rpi_ai::Model {
4197 base_url: "http://localhost/new".into(),
4198 context_window: 32768,
4199 ..stale.clone()
4200 }];
4201 let current = Some(stale);
4202
4203 let lane_model = current
4204 .as_ref()
4205 .and_then(|model| {
4206 catalog
4207 .iter()
4208 .find(|m| m.id == model.id && m.provider == model.provider)
4209 })
4210 .cloned()
4211 .unwrap_or_else(|| catalog[0].clone());
4212
4213 assert_eq!(lane_model.base_url, "http://localhost/new");
4214 assert_eq!(lane_model.context_window, 32768);
4215 }
4216
4217 use crate::args::Args;
4218
4219 #[test]
4220 fn loaded_context_paths_track_precedence_and_reload() {
4221 const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
4222 if std::env::var_os(CHILD).is_none() {
4223 let dir = tempfile::tempdir().unwrap();
4224 let output = std::process::Command::new(std::env::current_exe().unwrap())
4225 .args([
4226 "--exact",
4227 "session::tests::loaded_context_paths_track_precedence_and_reload",
4228 "--nocapture",
4229 ])
4230 .env(CHILD, "1")
4231 .env(crate::config::CONFIG_DIR_ENV, dir.path())
4232 .output()
4233 .unwrap();
4234 assert!(
4235 output.status.success(),
4236 "{}\n{}",
4237 String::from_utf8_lossy(&output.stdout),
4238 String::from_utf8_lossy(&output.stderr)
4239 );
4240 return;
4241 }
4242 tokio::runtime::Builder::new_current_thread()
4243 .enable_all()
4244 .build()
4245 .unwrap()
4246 .block_on(async {
4247 let agent = crate::config::agent_dir().unwrap();
4248 let cwd = agent.join("project");
4249 let project_config = cwd.join(".rpi");
4250 std::fs::create_dir_all(&project_config).unwrap();
4251 for (path, content) in [
4252 (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
4253 (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
4254 (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
4255 (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
4256 (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
4257 (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
4258 (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
4259 ] {
4260 std::fs::write(path, content).unwrap();
4261 }
4262 let resolved = crate::provider::resolve(
4263 Some("anthropic"),
4264 Some(crate::provider::DEFAULT_MODEL_ID),
4265 None,
4266 Some("test-key"),
4267 None,
4268 )
4269 .unwrap();
4270 let args = Args {
4271 no_session: true,
4272 no_extensions: true,
4273 no_skills: true,
4274 no_prompt_templates: true,
4275 trust_override: Some(true),
4276 ..Default::default()
4277 };
4278 let (harness, _events, mut context) =
4279 build(&resolved, &args, &cwd, true).await.unwrap();
4280 let resources = harness.get_resources().await.unwrap();
4281 let path = |p: PathBuf| p.to_string_lossy().into_owned();
4282 assert_eq!(
4283 &resources.context_files[..2],
4284 &[
4285 path(project_config.join("SYSTEM.md")),
4286 path(project_config.join("APPEND_SYSTEM.md")),
4287 ]
4288 );
4289 assert!(resources
4290 .context_files
4291 .contains(&path(agent.join("AGENTS.md"))));
4292 assert!(resources
4293 .context_files
4294 .contains(&path(cwd.join("AGENTS.md"))));
4295 assert!(!resources
4296 .context_files
4297 .contains(&path(cwd.join("CLAUDE.md"))));
4298 assert!(!resources
4299 .context_files
4300 .contains(&path(agent.join("SYSTEM.md"))));
4301 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
4302 assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
4303 assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
4304
4305 std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
4308 std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
4309 std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
4310 assert!(
4311 !reload_extension_resources(&harness, &context)
4312 .await
4313 .had_errors
4314 );
4315 let resources = harness.get_resources().await.unwrap();
4316 assert_eq!(
4317 &resources.context_files[..2],
4318 &[
4319 path(agent.join("SYSTEM.md")),
4320 path(agent.join("APPEND_SYSTEM.md")),
4321 ]
4322 );
4323 assert!(resources
4324 .context_files
4325 .contains(&path(cwd.join("CLAUDE.md"))));
4326 assert!(!resources
4327 .context_files
4328 .contains(&path(project_config.join("SYSTEM.md"))));
4329
4330 let explicit = agent.join("custom-append.md");
4333 std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
4334 context.args.system_prompt = Some("EXPLICIT_BASE".into());
4335 context.args.append_system_prompt =
4336 vec![path(explicit.clone()), "literal append".into()];
4337 context.args.no_context_files = true;
4338 assert!(
4339 !reload_extension_resources(&harness, &context)
4340 .await
4341 .had_errors
4342 );
4343 assert_eq!(
4344 harness.get_resources().await.unwrap().context_files,
4345 vec![path(explicit)]
4346 );
4347
4348 context.args.system_prompt = None;
4350 context.args.append_system_prompt.clear();
4351 context.args.no_context_files = false;
4352 context.args.trust_override = Some(false);
4353 assert!(
4354 !reload_extension_resources(&harness, &context)
4355 .await
4356 .had_errors
4357 );
4358 assert!(harness
4359 .get_resources()
4360 .await
4361 .unwrap()
4362 .context_files
4363 .is_empty());
4364 let prompt = harness.get_system_prompt().await.unwrap().unwrap();
4365 assert!(
4366 !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
4367 );
4368 });
4369 }
4370
4371 #[test]
4372 fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
4373 let dir = tempfile::tempdir().unwrap();
4374 let file = dir.path().join("SYSTEM.md");
4375 let mut sources = Vec::new();
4376 assert!(read_prompt_source(&file, &mut sources).is_none());
4377 std::fs::write(&file, [0xff, 0xfe]).unwrap();
4378 assert!(read_prompt_source(&file, &mut sources).is_none());
4379 assert!(read_append_target("literal instructions", &mut sources).is_none());
4380 assert!(sources.is_empty());
4381 std::fs::write(&file, "loaded instructions").unwrap();
4382 assert_eq!(
4383 read_prompt_source(&file, &mut sources).as_deref(),
4384 Some("loaded instructions")
4385 );
4386 assert!(read_prompt_source(&file, &mut sources).is_some());
4387 assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
4388 }
4389
4390 extern "C" fn deny_test_tool_call(
4391 _event: rpi_plugin_sdk::StablePluginEvent,
4392 _data: *mut std::ffi::c_void,
4393 ) -> i32 {
4394 rpi_plugin_sdk::EVENT_HANDLER_ABORT
4395 }
4396
4397 async fn invoke_test_tool_hook(
4398 hook: &rpi_agent::BeforeToolCall,
4399 ) -> Option<rpi_agent::BeforeToolCallResult> {
4400 let message = rpi_ai::providers::faux::faux_assistant_message(
4401 "approval",
4402 rpi_ai::types::StopReason::ToolUse,
4403 );
4404 let rpi_ai::types::Content::ToolCall(call) =
4405 rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
4406 else {
4407 unreachable!()
4408 };
4409 hook(
4410 rpi_agent::BeforeToolCallContext {
4411 assistant_message: &message,
4412 tool_call: &call,
4413 args: &call.arguments,
4414 context: &rpi_agent::AgentContext::default(),
4415 },
4416 tokio_util::sync::CancellationToken::new(),
4417 )
4418 .await
4419 }
4420
4421 #[tokio::test]
4422 async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
4423 let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
4424 let hook =
4425 extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
4426 assert!(invoke_test_tool_hook(&hook).await.is_none());
4427 for name in ["first-gate", "reloaded-gate"] {
4428 let mut registry = rpi_extensions::ExtensionRegistry::new();
4429 registry.register_event_handler(
4430 name.into(),
4431 rpi_plugin_sdk::EventTag::ToolCall,
4432 deny_test_tool_call,
4433 std::ptr::null_mut(),
4434 );
4435 let fresh = ExtensionSession::from_parts(
4436 Arc::new(registry.snapshot()),
4437 rpi_extensions::PluginKeepalive::empty(),
4438 Vec::new(),
4439 None,
4440 );
4441 {
4442 let mut current = sessions.lock().unwrap();
4443 if let Some(snapshot) = current.snapshot() {
4444 snapshot.active_flag().store(false, Ordering::SeqCst);
4445 }
4446 *current = fresh;
4447 }
4448 let result = invoke_test_tool_hook(&hook).await.unwrap();
4449 assert!(result.block);
4450 assert!(result.reason.unwrap().contains(name));
4451 }
4452 *sessions.lock().unwrap() = ExtensionSession::none();
4453 assert!(invoke_test_tool_hook(&hook).await.is_none());
4454 }
4455
4456 fn scratch(name: &str) -> PathBuf {
4458 let nanos = std::time::SystemTime::now()
4459 .duration_since(std::time::UNIX_EPOCH)
4460 .unwrap()
4461 .as_nanos();
4462 let path = std::env::temp_dir().join(format!(
4463 "rpi-summary-{}-{nanos}-{name}.jsonl",
4464 std::process::id()
4465 ));
4466 path
4467 }
4468
4469 #[test]
4470 fn summary_marks_a_header_only_session_empty() {
4471 let path = scratch("empty");
4472 std::fs::write(
4473 &path,
4474 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4475 )
4476 .unwrap();
4477 let summary = summarize_session_file(&path);
4478 assert!(summary.empty, "a header-only file has nothing to resume");
4479 assert!(summary.preview.is_none());
4480 assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
4481 std::fs::remove_file(&path).ok();
4482 }
4483
4484 #[test]
4485 fn summary_reads_the_first_user_prompt_and_name() {
4486 let path = scratch("content");
4487 let body = concat!(
4488 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4489 "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
4490 "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
4491 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
4492 "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
4493 );
4494 std::fs::write(&path, body).unwrap();
4495 let summary = summarize_session_file(&path);
4496 assert!(!summary.empty);
4497 assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
4498 assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
4499 std::fs::remove_file(&path).ok();
4500 }
4501
4502 #[test]
4503 fn summary_extracts_a_block_style_user_prompt() {
4504 let path = scratch("blocks");
4505 let body = concat!(
4506 "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4507 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
4508 );
4509 std::fs::write(&path, body).unwrap();
4510 let summary = summarize_session_file(&path);
4511 assert_eq!(summary.preview.as_deref(), Some("line one line two"));
4512 assert_eq!(session_display_label(&summary), "line one line two");
4513 std::fs::remove_file(&path).ok();
4514 }
4515
4516 fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
4517 rpi_harness::session::jsonl::JsonlSessionMetadata {
4518 id: id.to_string(),
4519 created_at: 0,
4520 cwd: ".".to_string(),
4521 path: path.to_string_lossy().into_owned(),
4522 modified_at: 0,
4523 source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
4524 parent_session_id: None,
4525 legacy_parent_session_path: None,
4526 metadata: None,
4527 }
4528 }
4529
4530 #[test]
4531 fn continue_skips_newer_empty_sessions() {
4532 let empty = scratch("newest-empty");
4533 std::fs::write(
4534 &empty,
4535 "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4536 )
4537 .unwrap();
4538 let content = scratch("older-content");
4539 std::fs::write(
4540 &content,
4541 concat!(
4542 "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4543 "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
4544 ),
4545 )
4546 .unwrap();
4547
4548 let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
4549 let picked = first_resumable_session(&metas).expect("a resumable session");
4550 assert_eq!(picked.id, "old");
4551
4552 let all_empty = vec![meta_for(&empty, "empty")];
4555 assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
4556
4557 std::fs::remove_file(&empty).ok();
4558 std::fs::remove_file(&content).ok();
4559 }
4560
4561 #[tokio::test]
4562 async fn import_preserves_validates_and_locks_the_recovery_pair() {
4563 use rpi_harness::session::jsonl::{JsonlSessionStorage, JsonlV4Header};
4564 use rpi_harness::session::types::{
4565 AssistantFrameOp, AssistantFrameRecord, RecordBase, SessionStorage,
4566 };
4567 let tmp = tempfile::tempdir().unwrap();
4568 let source = tmp.path().join("source.jsonl");
4569 let progress = tmp.path().join("source.jsonl.progress.jsonl");
4570 let fs: Arc<dyn rpi_tools::FileSystem> =
4571 Arc::new(OsExecutionEnv::with_cwd(tmp.path().into()));
4572 let storage = JsonlSessionStorage::create(
4573 fs,
4574 &source.to_string_lossy(),
4575 JsonlV4Header::new("import-pair".into(), 1, "/".into(), None, None, None),
4576 Arc::new(SystemClock),
4577 Arc::new(DefaultIdGenerator::new()),
4578 )
4579 .await
4580 .unwrap();
4581 storage
4582 .append_progress(vec![AssistantFrameRecord {
4583 base: RecordBase {
4584 id: "accepted-prefix".into(),
4585 seq: 0,
4586 lane: "main".into(),
4587 timestamp: 0,
4588 },
4589 run_id: "run".into(),
4590 stream_index: 0,
4591 op: AssistantFrameOp::Append,
4592 frame: Some(serde_json::json!({"payload":"keep"})),
4593 }])
4594 .await
4595 .unwrap();
4596 drop(storage);
4597 let original = std::fs::read(&progress).unwrap();
4598 let destination = tmp.path().join("imported");
4599 let imported = import_session_file(&source, &tmp.path().to_string_lossy(), &destination)
4600 .await
4601 .unwrap();
4602 assert_eq!(
4603 imported.read_progress("run").await.unwrap()[0].base.id,
4604 "accepted-prefix"
4605 );
4606 assert_eq!(
4607 std::fs::read(&progress).unwrap(),
4608 original,
4609 "import must not repair or mutate its source"
4610 );
4611 let lock = std::fs::OpenOptions::new()
4612 .create(true)
4613 .write(true)
4614 .open(tmp.path().join("source.jsonl.lock"))
4615 .unwrap();
4616 fs2::FileExt::try_lock_exclusive(&lock).unwrap();
4617 assert!(import_session_file(
4618 &source,
4619 &tmp.path().to_string_lossy(),
4620 &tmp.path().join("busy")
4621 )
4622 .await
4623 .is_err());
4624 drop(lock);
4625 let mut corrupt = String::from_utf8(original.clone()).unwrap();
4626 corrupt = corrupt.replace(
4627 "\"session_id\":\"import-pair\"",
4628 "\"session_id\":\"foreign\"",
4629 );
4630 assert_ne!(corrupt.as_bytes(), original.as_slice());
4631 std::fs::write(&progress, corrupt).unwrap();
4632 assert!(import_session_file(
4633 &source,
4634 &tmp.path().to_string_lossy(),
4635 &tmp.path().join("foreign")
4636 )
4637 .await
4638 .is_err());
4639 std::fs::remove_file(&progress).unwrap();
4640 assert!(import_session_file(
4641 &source,
4642 &tmp.path().to_string_lossy(),
4643 &tmp.path().join("missing")
4644 )
4645 .await
4646 .is_err());
4647 }
4648
4649 #[test]
4650 fn list_session_files_sync_walks_the_cwd_subdirectory() {
4651 let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
4652 let nested = root.join("--proj--");
4653 std::fs::create_dir_all(&nested).unwrap();
4654 let file = nested.join("2026-01-01_a.jsonl");
4655 std::fs::write(&file, "{}\n").unwrap();
4656 let sidecar = nested.join("2026-01-01_a.jsonl.progress.jsonl");
4657 std::fs::write(&sidecar, "{}\n").unwrap();
4658 let found = list_session_files_sync(&root);
4659 assert!(found.iter().any(|p| p == &file));
4660 assert!(!found.contains(&sidecar));
4661 std::fs::remove_dir_all(&root).ok();
4662 }
4663
4664 fn default_prompt_tools() -> Vec<String> {
4666 BUILTIN_TOOL_NAMES
4667 .iter()
4668 .copied()
4669 .chain(["todo"])
4670 .map(str::to_owned)
4671 .collect()
4672 }
4673
4674 #[test]
4675 fn default_prompt_lists_only_the_active_tools() {
4676 let tools = ["read", "grep", "find", "ls"].map(str::to_owned);
4677 let p = default_system_prompt_with_tools("/tmp/proj", true, &tools);
4678 assert!(p.contains("- read — Read file contents"));
4679 assert!(p.contains("- grep — Search file contents"));
4680 for absent in ["- edit", "- write", "- bash", "- docs", "todo", "Windows"] {
4681 assert!(
4682 !p.contains(absent),
4683 "{absent} must not appear for a read-only run: {p}"
4684 );
4685 }
4686 assert!(p.contains("Re-stating the same plan without acting on it is a bug"));
4687 let none = default_system_prompt_with_tools("/tmp/proj", false, &[]);
4688 assert!(none.contains("Available tools:\n(none)"));
4689 }
4690
4691 #[test]
4692 fn default_prompt_mentions_cwd_and_tools() {
4693 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
4694 assert!(p.contains("/tmp/proj"));
4695 assert!(p.contains("read"));
4696 assert!(p.contains("bash"));
4697 assert!(p.contains("edit"));
4698 assert!(p.contains("write"));
4699 assert!(p.contains("docs"));
4700 assert!(!p.contains("- grep"));
4701 assert!(!p.contains("- find"));
4702 assert!(!p.contains("- ls"));
4703 assert!(!p.contains("- powershell"));
4704 assert!(p.contains("Bash/POSIX syntax, also on Windows"));
4705 }
4706
4707 #[test]
4708 fn default_prompt_instructs_language_matching() {
4709 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
4712 assert!(
4713 p.contains("same language as the user's latest message"),
4714 "the prompt must tell the model to mirror the user's language: {p}"
4715 );
4716 }
4717
4718 #[test]
4719 fn default_prompt_keeps_working_state_in_the_visible_channel() {
4720 let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
4728 let lower = p.to_lowercase();
4729 assert!(
4731 lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
4732 "the prompt must explain that reasoning does not survive the turn: {p}"
4733 );
4734 assert!(
4736 lower.contains("visible") && !lower.contains("todo tool"),
4737 "the prompt must keep state visible without inventing extension rules: {p}"
4738 );
4739 assert!(
4741 lower.contains("re-stating the same plan"),
4742 "the prompt must call out plan re-statement as a bug: {p}"
4743 );
4744 assert!(
4746 lower.contains("already shown earlier"),
4747 "the prompt must discourage re-fetching known content: {p}"
4748 );
4749 }
4750
4751 #[test]
4752 fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
4753 let p = default_system_prompt_with_tools("/tmp/proj", true, &default_prompt_tools());
4760 let lower = p.to_lowercase();
4761 assert!(
4762 lower.contains("carried back"),
4763 "a replaying model must be told its reasoning survives: {p}"
4764 );
4765 assert!(
4766 !lower.contains("reasoning is not"),
4767 "the replayed variant must not claim reasoning is dropped: {p}"
4768 );
4769 assert!(
4770 lower.contains("do not re-derive"),
4771 "the replayed variant must forbid re-deriving the plan: {p}"
4772 );
4773 assert!(
4774 lower.contains("do not re-derive") && lower.contains("re-stating the same plan"),
4775 "the anti-repetition rule must survive the flip: {p}"
4776 );
4777 assert!(
4778 lower.contains("already shown earlier"),
4779 "the re-read rule must survive the flip: {p}"
4780 );
4781 }
4782
4783 #[test]
4784 fn default_prompt_states_each_rule_once() {
4785 for replayed in [false, true] {
4788 let p =
4789 default_system_prompt_with_tools("/tmp/proj", replayed, &default_prompt_tools());
4790 for phrase in ["the same plan", "Windows", "guessing"] {
4791 assert_eq!(p.matches(phrase).count(), 1, "{phrase:?} repeated: {p}");
4792 }
4793 assert!(
4794 !p.contains("todo tool"),
4795 "extension rules require explicit metadata"
4796 );
4797 }
4798 }
4799
4800 #[test]
4808 fn only_read_only_tools_are_replayable() {
4809 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
4810 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
4811 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
4812 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
4813 let tools = build_tools(&context, &Args::default());
4814
4815 let replay_of = |name: &str| {
4816 tools
4817 .iter()
4818 .find(|tool| tool.tool.schema().name == name)
4819 .map(|tool| tool.replay)
4820 };
4821
4822 for read_only in ["read", "docs"] {
4823 assert_eq!(
4824 replay_of(read_only),
4825 Some(ToolReplay::Safe),
4826 "{read_only} is side-effect free, so recovery may re-run it"
4827 );
4828 }
4829 for mutating in ["bash", "edit", "write", "powershell"] {
4830 if let Some(replay) = replay_of(mutating) {
4831 assert_eq!(
4832 replay,
4833 ToolReplay::Never,
4834 "{mutating} must never be replayed: re-running it would duplicate its effect"
4835 );
4836 }
4837 }
4838 }
4839
4840 #[test]
4841 fn default_tools_include_docs_lookup() {
4842 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
4843 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
4844 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
4845 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
4846 let names: Vec<String> = build_tools(&context, &Args::default())
4847 .iter()
4848 .map(|tool| tool.tool.schema().name.clone())
4849 .collect();
4850
4851 assert!(names.contains(&"docs".to_string()));
4856 for legacy in ["grep", "find", "ls"] {
4857 assert!(
4858 !names.contains(&legacy.to_string()),
4859 "legacy tool {legacy} should not be registered by default"
4860 );
4861 }
4862 }
4863
4864 #[test]
4865 fn reload_settings_preflight_respects_trust() {
4866 struct RestoreConfigDir(Option<std::ffi::OsString>);
4867
4868 impl Drop for RestoreConfigDir {
4869 fn drop(&mut self) {
4870 match self.0.take() {
4871 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4872 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4873 }
4874 }
4875 }
4876
4877 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4878 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4879 let tmp = tempfile::tempdir().unwrap();
4880 let agent = tmp.path().join("agent");
4881 let cwd = tmp.path().join("project");
4882 std::fs::create_dir_all(&agent).unwrap();
4883 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
4884 std::fs::write(agent.join("settings.json"), "{}").unwrap();
4885 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
4886 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4887 let args = Args::default();
4888
4889 let trusted = validate_settings_for_reload(&args, &cwd, true);
4890 let untrusted = validate_settings_for_reload(&args, &cwd, false);
4891 assert!(trusted
4892 .unwrap_err()
4893 .contains("could not load project settings"));
4894 assert!(untrusted.is_ok());
4895
4896 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
4897 assert!(validate_settings_for_reload(&args, &cwd, false)
4898 .unwrap_err()
4899 .contains("could not load global settings"));
4900
4901 let local_only = Args {
4902 dev_local_only: true,
4903 ..Args::default()
4904 };
4905 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
4906 }
4907
4908 #[tokio::test(flavor = "current_thread")]
4909 async fn reload_preserves_live_resources_when_settings_break() {
4910 struct RestoreConfigDir(Option<std::ffi::OsString>);
4911
4912 impl Drop for RestoreConfigDir {
4913 fn drop(&mut self) {
4914 match self.0.take() {
4915 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4916 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4917 }
4918 }
4919 }
4920
4921 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4922 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4923 let tmp = tempfile::tempdir().unwrap();
4924 let agent = tmp.path().join("agent");
4925 let cwd = tmp.path().join("project");
4926 let skill_dir = tmp.path().join("configured-skills");
4927 std::fs::create_dir_all(&agent).unwrap();
4928 std::fs::create_dir_all(&cwd).unwrap();
4929 std::fs::create_dir_all(&skill_dir).unwrap();
4930 std::fs::write(
4931 skill_dir.join("SKILL.md"),
4932 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
4933 )
4934 .unwrap();
4935 std::fs::write(
4936 agent.join("settings.json"),
4937 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
4938 )
4939 .unwrap();
4940 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4941
4942 let resolved = crate::provider::resolve(
4943 Some("anthropic"),
4944 Some(crate::provider::DEFAULT_MODEL_ID),
4945 None,
4946 Some("test-key"),
4947 None,
4948 )
4949 .unwrap();
4950 let args = Args {
4951 trust_override: Some(false),
4952 no_session: true,
4953 no_extensions: true,
4954 no_prompt_templates: true,
4955 no_context_files: true,
4956 system_prompt: Some("stable system prompt".into()),
4957 ..Args::default()
4958 };
4959 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
4960 let before_resources = harness.get_resources().await.unwrap();
4961 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
4962 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
4963 let before_prompt = harness.get_system_prompt().await.unwrap();
4964 let before_tools: Vec<String> = harness
4965 .get_tools()
4966 .await
4967 .unwrap()
4968 .iter()
4969 .map(|tool| tool.tool.schema().name.clone())
4970 .collect();
4971 let before_bridge = context
4972 .action_bridge
4973 .lock()
4974 .unwrap()
4975 .as_ref()
4976 .unwrap()
4977 .clone();
4978
4979 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
4980 let outcome = reload_extension_resources(&harness, &context).await;
4981
4982 assert!(outcome.had_warnings);
4983 assert!(outcome.summary.contains("Settings reload failed"));
4984 assert!(outcome.summary.contains("could not load global settings"));
4985 assert_eq!(outcome.project_trusted, None);
4989 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
4990 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
4991 let after_tools: Vec<String> = harness
4992 .get_tools()
4993 .await
4994 .unwrap()
4995 .iter()
4996 .map(|tool| tool.tool.schema().name.clone())
4997 .collect();
4998 assert_eq!(after_tools, before_tools);
4999 let after_bridge = context
5000 .action_bridge
5001 .lock()
5002 .unwrap()
5003 .as_ref()
5004 .unwrap()
5005 .clone();
5006 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
5007 }
5008
5009 #[tokio::test(flavor = "current_thread")]
5010 async fn reload_outcome_carries_the_current_project_trust() {
5011 struct RestoreConfigDir(Option<std::ffi::OsString>);
5012
5013 impl Drop for RestoreConfigDir {
5014 fn drop(&mut self) {
5015 match self.0.take() {
5016 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5017 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5018 }
5019 }
5020 }
5021
5022 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5023 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5024 let tmp = tempfile::tempdir().unwrap();
5025 let agent = tmp.path().join("agent");
5026 let cwd = tmp.path().join("project");
5027 std::fs::create_dir_all(&agent).unwrap();
5028 std::fs::create_dir_all(&cwd).unwrap();
5029 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5030
5031 crate::config::set_project_trust(&cwd, Some(true)).unwrap();
5033 let resolved = crate::provider::resolve(
5034 Some("anthropic"),
5035 Some(crate::provider::DEFAULT_MODEL_ID),
5036 None,
5037 Some("test-key"),
5038 None,
5039 )
5040 .unwrap();
5041 let args = Args {
5042 no_session: true,
5043 no_extensions: true,
5044 no_prompt_templates: true,
5045 no_context_files: true,
5046 system_prompt: Some("stable system prompt".into()),
5047 ..Args::default()
5048 };
5049 let (harness, _events, context) = build(&resolved, &args, &cwd, true).await.unwrap();
5050 assert!(context.project_trusted, "startup snapshot is trusted");
5051
5052 crate::config::set_project_trust(&cwd, Some(false)).unwrap();
5055 let outcome = reload_extension_resources(&harness, &context).await;
5056 assert!(!outcome.had_errors, "{}", outcome.summary);
5057 assert_eq!(outcome.project_trusted, Some(false));
5058
5059 crate::config::set_project_trust(&cwd, Some(true)).unwrap();
5061 let outcome = reload_extension_resources(&harness, &context).await;
5062 assert!(!outcome.had_errors, "{}", outcome.summary);
5063 assert_eq!(outcome.project_trusted, Some(true));
5064 }
5065
5066 #[test]
5067 fn reload_preparation_rejects_settings_changed_during_derivation() {
5068 struct RestoreConfigDir(Option<std::ffi::OsString>);
5069
5070 impl Drop for RestoreConfigDir {
5071 fn drop(&mut self) {
5072 match self.0.take() {
5073 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5074 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5075 }
5076 }
5077 }
5078
5079 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5080 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5081 let tmp = tempfile::tempdir().unwrap();
5082 let agent = tmp.path().join("agent");
5083 let cwd = tmp.path().join("project");
5084 let first_skill_dir = tmp.path().join("first-skills");
5085 let second_skill_dir = tmp.path().join("second-skills");
5086 std::fs::create_dir_all(&agent).unwrap();
5087 std::fs::create_dir_all(&cwd).unwrap();
5088 std::fs::write(
5089 agent.join("settings.json"),
5090 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
5091 )
5092 .unwrap();
5093 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5094 let args = Args {
5095 trust_override: Some(false),
5096 no_extensions: true,
5097 no_prompt_templates: true,
5098 ..Args::default()
5099 };
5100
5101 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
5102 std::fs::write(
5103 agent.join("settings.json"),
5104 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
5105 )
5106 .unwrap();
5107 });
5108
5109 let error = result
5110 .err()
5111 .expect("settings mutation must fail preparation");
5112 assert!(error.contains("settings changed while reload inputs were being prepared"));
5113 }
5114
5115 #[tokio::test(flavor = "current_thread")]
5116 async fn reload_uses_frozen_settings_inputs_after_preparation() {
5117 struct RestoreConfigDir(Option<std::ffi::OsString>);
5118
5119 impl Drop for RestoreConfigDir {
5120 fn drop(&mut self) {
5121 match self.0.take() {
5122 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5123 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5124 }
5125 }
5126 }
5127
5128 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5129 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5130 let tmp = tempfile::tempdir().unwrap();
5131 let agent = tmp.path().join("agent");
5132 let cwd = tmp.path().join("project");
5133 let skill_dir = tmp.path().join("configured-skills");
5134 std::fs::create_dir_all(&agent).unwrap();
5135 std::fs::create_dir_all(&cwd).unwrap();
5136 std::fs::create_dir_all(&skill_dir).unwrap();
5137 std::fs::write(
5138 skill_dir.join("SKILL.md"),
5139 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
5140 )
5141 .unwrap();
5142 std::fs::write(
5143 agent.join("settings.json"),
5144 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
5145 )
5146 .unwrap();
5147 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5148
5149 let resolved = crate::provider::resolve(
5150 Some("anthropic"),
5151 Some(crate::provider::DEFAULT_MODEL_ID),
5152 None,
5153 Some("test-key"),
5154 None,
5155 )
5156 .unwrap();
5157 let args = Args {
5158 trust_override: Some(false),
5159 no_session: true,
5160 no_extensions: true,
5161 no_prompt_templates: true,
5162 no_context_files: true,
5163 system_prompt: Some("stable system prompt".into()),
5164 ..Args::default()
5165 };
5166 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
5167
5168 let outcome = reload_extension_resources_inner(&harness, &context, || {
5169 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
5170 })
5171 .await;
5172
5173 assert!(!outcome.summary.contains("Settings reload failed"));
5174 assert_eq!(
5175 outcome.summary,
5176 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
5177 );
5178 let resources = harness.get_resources().await.unwrap();
5179 let skills = resources.skills.as_ref().unwrap();
5180 assert_eq!(skills.len(), 1);
5181 assert_eq!(skills[0].name, "frozen-skill");
5182 }
5183
5184 #[tokio::test]
5185 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
5186 let tmp = tempfile::tempdir().unwrap();
5187 let skill_path = tmp.path().join("explicit-skill.md");
5188 std::fs::write(
5189 &skill_path,
5190 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
5191 )
5192 .unwrap();
5193 let prompt_path = tmp.path().join("explicit-prompt.md");
5194 std::fs::write(
5195 &prompt_path,
5196 "---\ndescription: Explicit prompt\n---\nPrompt body",
5197 )
5198 .unwrap();
5199
5200 let args = Args {
5201 skill: vec![skill_path.clone()],
5202 prompt_template: vec![prompt_path.clone()],
5203 ..Args::default()
5204 };
5205 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
5206 let prompt_paths =
5207 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
5208 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
5209 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
5210
5211 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
5212 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
5213 assert_eq!(skills.skills[0].name, "explicit-skill");
5214
5215 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
5216 assert_eq!(
5217 prompts.prompt_templates.len(),
5218 1,
5219 "{:?}",
5220 prompts.diagnostics
5221 );
5222 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
5223 }
5224
5225 #[test]
5226 fn tool_policy_applies_to_rust_and_js_active_names() {
5227 let names = vec![
5228 "read".to_string(),
5229 "ask_user_question".to_string(),
5230 "write".to_string(),
5231 ];
5232
5233 let args = Args {
5234 tools: Some(vec!["read".into(), "ask_user_question".into()]),
5235 ..Args::default()
5236 };
5237 assert_eq!(
5238 filter_active_tool_names(names.clone(), &args),
5239 vec!["read", "ask_user_question"]
5240 );
5241
5242 let args = Args {
5243 exclude_tools: Some(vec!["ask_user_question".into()]),
5244 ..Args::default()
5245 };
5246 assert_eq!(
5247 filter_active_tool_names(names.clone(), &args),
5248 vec!["read", "write"]
5249 );
5250
5251 let args = Args {
5252 no_tools: true,
5253 ..Args::default()
5254 };
5255 assert!(filter_active_tool_names(names, &args).is_empty());
5256 }
5257
5258 #[test]
5259 fn select_ephemeral_when_no_session() {
5260 let args = Args {
5261 no_session: true,
5262 ..Args::default()
5263 };
5264 let cwd = Path::new("/tmp");
5265 assert!(matches!(
5266 select_session(&args, cwd),
5267 SessionSelection::Ephemeral
5268 ));
5269 }
5270
5271 #[test]
5272 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
5273 let defaults = Args::default();
5274 assert!(resolve_project_trust(
5275 &defaults,
5276 Path::new("C:/definitely-not-a-project")
5277 ));
5278
5279 let denied = Args {
5280 trust_override: Some(false),
5281 ..Args::default()
5282 };
5283 assert!(!resolve_project_trust(
5284 &denied,
5285 Path::new("C:/definitely-not-a-project")
5286 ));
5287 }
5288
5289 #[tokio::test(flavor = "current_thread")]
5290 async fn build_preserves_the_supplied_startup_project_snapshot() {
5291 struct RestoreConfigDir(Option<std::ffi::OsString>);
5292
5293 impl Drop for RestoreConfigDir {
5294 fn drop(&mut self) {
5295 match self.0.take() {
5296 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5297 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5298 }
5299 }
5300 }
5301
5302 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5303 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5304 let tmp = tempfile::tempdir().unwrap();
5305 let agent = tmp.path().join("agent");
5306 let cwd = tmp.path().join("project");
5307 std::fs::create_dir_all(&agent).unwrap();
5308 std::fs::create_dir_all(&cwd).unwrap();
5309 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5310
5311 let resolved = crate::provider::resolve(
5312 Some("anthropic"),
5313 Some(crate::provider::DEFAULT_MODEL_ID),
5314 None,
5315 Some("test-key"),
5316 None,
5317 )
5318 .unwrap();
5319 let args = Args {
5320 trust_override: Some(false),
5321 no_session: true,
5322 no_tools: true,
5323 no_extensions: true,
5324 no_skills: true,
5325 no_prompt_templates: true,
5326 no_context_files: true,
5327 system_prompt: Some("test prompt".into()),
5328 ..Args::default()
5329 };
5330
5331 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
5332
5333 assert_eq!(context.cwd, cwd);
5334 assert!(context.project_trusted);
5335 }
5336
5337 #[tokio::test(flavor = "current_thread")]
5338 async fn build_applies_the_name_flag_to_the_session() {
5339 struct RestoreConfigDir(Option<std::ffi::OsString>);
5340
5341 impl Drop for RestoreConfigDir {
5342 fn drop(&mut self) {
5343 match self.0.take() {
5344 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5345 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5346 }
5347 }
5348 }
5349
5350 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5351 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5352 let tmp = tempfile::tempdir().unwrap();
5353 let agent = tmp.path().join("agent");
5354 let cwd = tmp.path().join("project");
5355 std::fs::create_dir_all(&agent).unwrap();
5356 std::fs::create_dir_all(&cwd).unwrap();
5357 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5358
5359 let resolved = crate::provider::resolve(
5360 Some("anthropic"),
5361 Some(crate::provider::DEFAULT_MODEL_ID),
5362 None,
5363 Some("test-key"),
5364 None,
5365 )
5366 .unwrap();
5367 let args = Args {
5368 trust_override: Some(false),
5369 no_tools: true,
5370 no_extensions: true,
5371 no_skills: true,
5372 no_prompt_templates: true,
5373 no_context_files: true,
5374 system_prompt: Some("test prompt".into()),
5375 name: Some("smoke name".into()),
5376 session_dir: Some(cwd.join("sessions")),
5377 ..Args::default()
5378 };
5379
5380 let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
5381 assert_eq!(
5382 harness.get_name().await.unwrap().as_deref(),
5383 Some("smoke name")
5384 );
5385 }
5386
5387 #[test]
5388 fn select_latest_for_continue_and_resume() {
5389 let _env = crate::config::test_support::SessionDirEnv::new(None);
5390 let args = Args {
5391 continue_session: true,
5392 ..Args::default()
5393 };
5394 let cwd = Path::new("/tmp");
5395 assert!(matches!(
5396 select_session(&args, cwd),
5397 SessionSelection::Latest { .. }
5398 ));
5399
5400 let args = Args {
5401 resume: true,
5402 ..Args::default()
5403 };
5404 assert!(matches!(
5405 select_session(&args, cwd),
5406 SessionSelection::Latest { .. }
5407 ));
5408 }
5409
5410 #[test]
5411 fn select_by_id_for_session_flag() {
5412 let _env = crate::config::test_support::SessionDirEnv::new(None);
5413 let args = Args {
5414 session: Some("01a02ece".into()),
5415 ..Args::default()
5416 };
5417 let cwd = Path::new("/tmp");
5418 assert!(matches!(
5419 select_session(&args, cwd),
5420 SessionSelection::ById { id, .. } if id == "01a02ece"
5421 ));
5422 }
5423
5424 #[test]
5425 fn select_new_with_custom_dir() {
5426 let _env = crate::config::test_support::SessionDirEnv::new(None);
5427 let args = Args {
5428 session_dir: Some(PathBuf::from("/tmp/sess")),
5429 ..Args::default()
5430 };
5431 let cwd = Path::new("/tmp");
5432 match select_session(&args, cwd) {
5433 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
5434 other => panic!("expected New, got {other:?}"),
5435 }
5436 }
5437
5438 #[test]
5439 fn select_new_default_dir() {
5440 let _env = crate::config::test_support::SessionDirEnv::new(None);
5441 let args = Args::default();
5442 let cwd = Path::new("/proj");
5443 match select_session(&args, cwd) {
5444 SessionSelection::New { dir, .. } => {
5445 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
5446 }
5447 other => panic!("expected New, got {other:?}"),
5448 }
5449 }
5450
5451 #[test]
5452 fn default_session_dir_only_uses_rpi() {
5453 let _env = crate::config::test_support::SessionDirEnv::new(None);
5454 let tmp = tempfile::tempdir().unwrap();
5455 let cwd = tmp.path();
5456 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
5457 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
5458 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
5459 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
5460 }
5461
5462 #[test]
5463 fn default_session_dir_uses_env_var() {
5464 let tmp = tempfile::tempdir().unwrap();
5465 let cwd = tmp.path();
5466 let custom = tmp.path().join("custom_sessions");
5467 let _env = crate::config::test_support::SessionDirEnv::new(Some(&custom));
5468 assert_eq!(default_session_dir(cwd), custom);
5469 }
5470
5471 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5472 async fn ephemeral_session_builds_roundtrips() {
5473 let s = ephemeral_session();
5477 let leaf = s.get_leaf_id().await;
5478 assert!(leaf.is_ok());
5479 }
5480
5481 #[test]
5499 fn host_private_env_matches_the_declared_contract() {
5500 assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
5502 assert_eq!(
5503 rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
5504 [
5505 "LANGFUSE_PI_PARENT_TRACE_ID",
5506 "LANGFUSE_PI_PARENT_SPAN_ID",
5507 "LANGFUSE_PI_PARENT_SESSION_ID",
5508 "LANGFUSE_PI_PARENT_DEPTH",
5509 ],
5510 "these strings are what rpi-langfuse (a different repo) matches; a \
5511 change here is a breaking cross-repo change, not a refactor"
5512 );
5513
5514 for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
5517 .iter()
5518 .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
5519 {
5520 assert!(
5521 rpi_tools::is_host_private_env(name),
5522 "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
5523 rpi_tools::HOST_PRIVATE_ENV
5524 );
5525 }
5526
5527 for visible in [
5530 crate::args::RPI_OFFLINE_ENV,
5531 crate::config::CONFIG_DIR_ENV,
5532 "PATH",
5533 ] {
5534 assert!(
5535 !rpi_tools::is_host_private_env(visible),
5536 "{visible} describes the user's configuration and must still reach the shell"
5537 );
5538 }
5539 }
5540 #[tokio::test]
5541 async fn reload_shutdown_joins_all_handlers_even_after_veto() {
5542 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
5543 use std::sync::atomic::AtomicUsize;
5544 static CONTEXTS: rpi_plugin_sdk::handles::ToolHandles<Arc<AtomicUsize>> =
5545 rpi_plugin_sdk::handles::ToolHandles::new();
5546 struct ContextOwner(usize);
5547 impl Drop for ContextOwner {
5548 fn drop(&mut self) {
5549 drop(CONTEXTS.remove(self.0 as rpi_plugin_sdk::StepHandle));
5550 }
5551 }
5552 extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
5553 let context = CONTEXTS.get(data).expect("live test context");
5554 let calls = context.lock().clone();
5555 std::thread::sleep(std::time::Duration::from_millis(20));
5556 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
5557 rpi_plugin_sdk::EVENT_HANDLER_ABORT
5558 } else {
5559 0
5560 }
5561 }
5562 let calls = Arc::new(AtomicUsize::new(0));
5563 let context = CONTEXTS.insert(calls.clone());
5564 let _owner = ContextOwner(context as usize);
5565 let mut registry = rpi_extensions::ExtensionRegistry::new();
5566 for name in ["first", "second"] {
5567 registry.register_event_handler(name.into(), EventTag::SessionShutdown, stop, context);
5568 }
5569 super::shutdown_extensions_for_reload(
5570 Some(Arc::new(registry.snapshot())),
5571 rpi_extensions::PluginKeepalive::empty(),
5572 )
5573 .await
5574 .unwrap();
5575 assert_eq!(calls.load(Ordering::SeqCst), 2);
5576 }
5577
5578 #[tokio::test]
5587 async fn a_restored_session_adopts_its_recorded_directory() {
5588 let tmp = tempfile::tempdir().unwrap();
5589 let project = tmp.path().join("project-a");
5590 let launch = tmp.path().join("launch-dir");
5591 let sessions = tmp.path().join("sessions");
5592 std::fs::create_dir_all(&project).unwrap();
5593 std::fs::create_dir_all(&launch).unwrap();
5594 std::fs::create_dir_all(&sessions).unwrap();
5595 create_jsonl_session_with_id(
5596 &sessions,
5597 &project.to_string_lossy(),
5598 Some("cwdadopt01".into()),
5599 )
5600 .await
5601 .unwrap();
5602
5603 let selection = SessionSelection::ById {
5604 id: "cwdadopt01".into(),
5605 dir: sessions.clone(),
5606 };
5607 let peeked = peek_restored_session_cwd(&selection, &launch)
5608 .await
5609 .expect("a resumable session with a recorded cwd");
5610 assert_eq!(peeked.recorded, project);
5611 assert_eq!(peeked.adopted, Some(project));
5612 }
5613
5614 #[tokio::test]
5618 async fn a_missing_recorded_directory_is_reported_not_adopted() {
5619 let tmp = tempfile::tempdir().unwrap();
5620 let gone = tmp.path().join("project-gone");
5621 let launch = tmp.path().join("launch-dir");
5622 let sessions = tmp.path().join("sessions");
5623 std::fs::create_dir_all(&launch).unwrap();
5624 std::fs::create_dir_all(&sessions).unwrap();
5625 create_jsonl_session_with_id(
5626 &sessions,
5627 &gone.to_string_lossy(),
5628 Some("cwdgone001".into()),
5629 )
5630 .await
5631 .unwrap();
5632
5633 let selection = SessionSelection::ById {
5634 id: "cwdgone001".into(),
5635 dir: sessions.clone(),
5636 };
5637 let peeked = peek_restored_session_cwd(&selection, &launch)
5638 .await
5639 .expect("the session itself is resumable");
5640 assert_eq!(peeked.recorded, gone);
5641 assert_eq!(peeked.adopted, None);
5642 let note = crate::session_cwd::missing_session_cwd_note(&peeked.recorded, &launch);
5643 assert!(note.contains("no longer exists"), "{note}");
5644 assert!(note.contains("launch-dir"), "{note}");
5645 }
5646
5647 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5648 async fn legacy_progress_upgrade_normal_open_runs_and_reopens() {
5649 use rpi_ai::providers::faux::{FauxProvider, FauxScript};
5650 use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
5651 use rpi_harness::session::jsonl::{
5652 encode_header, JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
5653 JsonlV4Header,
5654 };
5655 let tmp = tempfile::tempdir().unwrap();
5656 let sessions = tmp.path().join("sessions");
5657 std::fs::create_dir_all(&sessions).unwrap();
5658 let cwd = tmp.path().to_string_lossy().into_owned();
5659 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
5660 fs: Arc::new(OsExecutionEnv::with_cwd(tmp.path().into())),
5661 sessions_root: sessions.to_string_lossy().into_owned(),
5662 clock: Arc::new(SystemClock),
5663 ids: Arc::new(DefaultIdGenerator::new()),
5664 });
5665 let fixture = repo
5666 .create_typed(&JsonlSessionCreateOptions {
5667 id: Some("legacycli001".into()),
5668 parent_session_id: None,
5669 cwd: cwd.clone(),
5670 metadata: None,
5671 })
5672 .await
5673 .unwrap();
5674 let path = PathBuf::from(fixture.jsonl_metadata().path);
5675 drop(fixture);
5676 let sidecar = PathBuf::from(format!("{}.progress.jsonl", path.display()));
5677 std::fs::remove_file(&sidecar).unwrap();
5678 let header = JsonlV4Header::new("legacycli001".into(), 1, cwd.clone(), None, None, None);
5679 std::fs::write(&path, encode_header(&header)).unwrap();
5680 assert!(!sidecar.exists());
5681 for _ in 0..2 {
5682 let restored = open_session_by_id_in_dir("legacycli001", &cwd, &sessions)
5683 .await
5684 .map_err(|e| e.to_string())
5685 .unwrap();
5686 assert_eq!(restored.session.storage().metadata().id, "legacycli001");
5687 let provider =
5688 FauxProvider::new(FauxScript::new().with_text("continued legacy session"));
5689 let harness = AgentHarness::create(AgentHarnessOptions {
5690 model: provider.default_model().clone(),
5691 models: vec![provider],
5692 session: restored.session,
5693 allow_existing_session: true,
5694 ..Default::default()
5695 })
5696 .await
5697 .unwrap();
5698 let run = harness.prompt_text("continue", vec![]).await.unwrap();
5699 assert!(matches!(run.outcome, HarnessRunOutcome::Completed { .. }));
5700 }
5701 let content = std::fs::read_to_string(&path).unwrap();
5702 assert!(content.lines().next().unwrap().contains("progressFormat"));
5703 assert!(sidecar.exists());
5704 }
5705
5706 #[tokio::test]
5709 async fn open_session_reports_the_recorded_directory() {
5710 let tmp = tempfile::tempdir().unwrap();
5711 let project = tmp.path().join("project-open");
5712 let launch = tmp.path().join("launch-dir");
5713 let sessions = tmp.path().join("sessions");
5714 std::fs::create_dir_all(&project).unwrap();
5715 std::fs::create_dir_all(&launch).unwrap();
5716 std::fs::create_dir_all(&sessions).unwrap();
5717 create_jsonl_session_with_id(
5718 &sessions,
5719 &project.to_string_lossy(),
5720 Some("cwdopen001".into()),
5721 )
5722 .await
5723 .unwrap();
5724
5725 let restored =
5726 open_session_by_id_in_dir("cwdopen001", &launch.to_string_lossy(), &sessions)
5727 .await
5728 .map_err(|error| error.to_string())
5729 .unwrap();
5730 assert_eq!(restored.recorded_cwd, Some(project.clone()));
5731 assert_eq!(restored.session.storage().metadata().id, "cwdopen001");
5732 }
5733
5734 #[tokio::test]
5743 async fn the_peeked_session_is_the_one_the_build_opens() {
5744 let tmp = tempfile::tempdir().unwrap();
5745 let first_project = tmp.path().join("project-first");
5746 let second_project = tmp.path().join("project-second");
5747 let launch = tmp.path().join("launch-dir");
5748 let sessions = tmp.path().join("sessions");
5749 for dir in [&first_project, &second_project, &launch, &sessions] {
5750 std::fs::create_dir_all(dir).unwrap();
5751 }
5752 create_jsonl_session_with_id(
5753 &sessions,
5754 &first_project.to_string_lossy(),
5755 Some("cwdrace001".into()),
5756 )
5757 .await
5758 .unwrap();
5759
5760 let mut args = Args {
5761 continue_session: true,
5762 session_dir: Some(sessions.clone()),
5763 ..Default::default()
5764 };
5765 let selection = select_session_in_dir(&args, sessions.clone());
5766 let peeked = peek_restored_session_cwd(&selection, &launch)
5767 .await
5768 .expect("a resumable session");
5769 assert_eq!(peeked.adopted, Some(first_project.clone()));
5770
5771 create_jsonl_session_with_id(
5773 &sessions,
5774 &second_project.to_string_lossy(),
5775 Some("cwdrace002".into()),
5776 )
5777 .await
5778 .unwrap();
5779
5780 pin_restored_selection(&mut args, &peeked);
5781 let pinned = select_session_in_dir(&args, sessions.clone());
5782 let reopened = resolve_restore_target(&pinned, &launch.to_string_lossy())
5783 .await
5784 .unwrap()
5785 .expect("the pinned session still exists");
5786 assert_eq!(
5787 reopened.id, peeked.id,
5788 "the build must open the session the peek adopted the directory of"
5789 );
5790 assert_eq!(reopened.cwd, first_project.to_string_lossy());
5791 }
5792
5793 #[test]
5798 fn trust_is_resolved_per_directory() {
5799 struct RestoreConfigDir(Option<std::ffi::OsString>);
5800
5801 impl Drop for RestoreConfigDir {
5802 fn drop(&mut self) {
5803 match self.0.take() {
5804 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5805 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5806 }
5807 }
5808 }
5809
5810 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5811 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5812 let tmp = tempfile::tempdir().unwrap();
5813 let agent = tmp.path().join("agent");
5814 std::fs::create_dir_all(&agent).unwrap();
5815 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5816
5817 let untrusted = tmp.path().join("project-untrusted");
5818 let launch = tmp.path().join("project-launch");
5819 std::fs::create_dir_all(&untrusted).unwrap();
5820 std::fs::create_dir_all(&launch).unwrap();
5821 crate::config::set_project_trust(&untrusted, Some(false)).unwrap();
5822
5823 let args = Args::default();
5824 assert!(!resolve_project_trust(&args, &untrusted));
5825 assert!(resolve_project_trust(&args, &launch));
5827 }
5828}