rpi_cli/session.rs
1//! Harness construction + session-storage wiring. Mirrors the Rust-side
2//! equivalent of the TS `packages/coding-agent/src/core/sdk.ts`
3//! (`createAgentSession`) — build the env, tools, durable session storage, and
4//! `AgentHarnessOptions`, then `AgentHarness::create`.
5//!
6//! v1 scope cuts vs the TS SDK (tracked in `docs/m6-cli-open-questions.md`):
7//! - **Skill / prompt-template / context-file discovery IS wired**
8//! (`--no-skills`/`-ns`, `--no-prompt-templates`/`-np`, `--no-context-files`/
9//! `-nc` each suppress one channel; project `.pi/<sub>` + global
10//! `agent_dir()<sub>` discovery with project-wins dedupe via
11//! [`crate::resource_dirs`]; SYSTEM.md/APPEND_SYSTEM.md project-wins
12//! precedence). **Extension `resources_discover` (B5b) feeds the SAME loaders:
13//! a plugin's discovered skill/prompt paths merge with the static dirs and
14//! re-run through `load_skills`/`load_prompt_templates` (individual `.md` files
15//! load too — `load_skills` accepts both dirs and files). Theme discovery is
16//! accepted but ignored (rpi has no theme system — documented divergence).**
17//! **Trust gating remains deferred** — project resources are discovered
18//! unconditionally (a copied `.pi/` drops in and works).
19//! - **No `--models` cycling, no `ModelRuntime`/multi-provider.** One model,
20//! one provider (Anthropic), resolved up-front by [`crate::provider`].
21//! - **Built-in tools**: `read`, `bash`, `edit`, `write` plus the read-only
22//! `grep`/`find`/`ls` (the TS `createCodingTools` default set). `grep`/`find`
23//! use an in-process `FileSystem`+`regex`/`globset` implementation (documented
24//! divergence from the TS `rg`/`fd` shell-out; see `docs/m4-tools-open-questions.md`).
25//! - **Session restore (`-c`/`-r`/`--session`)** is *partially* supported: a
26//! fresh session is always created. The harness's `create` rejects sessions
27//! that already have records unless `allow_existing_session` is enabled.
28//! The interactive `-c`/`-r`/`--session` paths enable that mode and replay
29//! the existing branch before appending new messages. See [`SessionSelection`].
30
31use std::path::{Path, PathBuf};
32use std::sync::atomic::Ordering;
33use std::sync::{Arc, Mutex};
34
35use rpi_ai::Provider;
36use rpi_harness::agent_harness::AgentHarness;
37use rpi_harness::context_files::{format_project_context, load_project_context_files};
38use rpi_harness::session::memory::{InMemorySessionStorage, SystemClock};
39use rpi_harness::session::session::DefaultIdGenerator;
40use rpi_harness::session::types::SessionMetadata;
41use rpi_harness::session::Session;
42use rpi_harness::system_prompt::compose_system_prompt;
43use rpi_harness::types::{
44 AgentHarnessOptions, AgentHarnessResources, CompactionSettings, DrivingMode, HarnessTool,
45 HarnessToolExecution, RetryPolicy, ToolReplay,
46};
47use rpi_tools::{
48 create_bash_tool, create_edit_tool, create_find_tool, create_grep_tool, create_ls_tool,
49 create_read_tool, create_write_tool, ExecutionToolContext, MutationQueueRegistry,
50 OsExecutionEnv,
51};
52
53use crate::args::Args;
54use crate::provider::ResolvedModel;
55use crate::resource_dirs::{
56 discover_append_system_prompt_file, discover_system_prompt_file, global_dir,
57 load_prompt_templates_with_precedence, load_skills_with_precedence, project_dir,
58 prompt_template_dirs, skill_dirs,
59};
60use rpi_extensions::{
61 emit_resources_discover, ExtensionEmitter, ExtensionSession, NullDiagnostics,
62 PluginDiagnostics, PluginToolAdapter, TeeEmitter,
63};
64
65/// The subdirectory (under both project `.pi/` and global `agent_dir()/`) where
66/// rpi scans for cdylib plugins. Mirrors pi's `.pi/extensions`.
67const EXTENSIONS_SUBDIR: &str = "extensions";
68
69/// The built-in tool names v1 ships, in the order the TS `createCodingTools`
70/// registers them: the mutating set (`read`/`bash`/`edit`/`write`) followed by
71/// the read-only search set (`grep`/`find`/`ls`).
72pub const BUILTIN_TOOL_NAMES: &[&str] = &["read", "bash", "edit", "write", "grep", "find", "ls"];
73
74/// The default coding system prompt. A condensed port of the TS
75/// `packages/coding-agent/src/core/system-prompt.ts` base prompt — the
76/// pi-internal docs/skills/context-file sections are omitted (v1 has none of
77/// that machinery), leaving the role + tools + guidelines core.
78pub fn default_system_prompt(cwd: &str) -> String {
79 format!(
80 "You are an expert coding assistant operating inside pi, a coding agent harness. \
81You help users by reading files, executing commands, editing code, and writing new files.
82
83Available tools:
84- read — Read file contents
85- bash — Execute shell commands
86- edit — Find/replace edits to existing files
87- write — Create or overwrite files
88- grep — Search file contents for a pattern
89- find — Search for files by glob pattern
90- ls — List directory contents
91
92Guidelines:
93- Be concise in your responses
94- Show file paths clearly when working with files
95- Prefer the smallest change that solves the problem
96
97Current working directory: {cwd}"
98 )
99}
100
101/// How the user asked to select a session. v1 honors `NoSession` (ephemeral
102/// `InMemorySessionStorage`), `New` (a fresh JSONL file), and — new this pass —
103/// `Latest` / `ById`, which **restore** an existing JSONL session on launch
104/// (`--continue`/`-c`, `--resume`/`-r`, `--session <id|path>`). The restored
105/// transcript renders into the TUI on startup and the run continues appending
106/// to the same file.
107#[derive(Debug, Clone)]
108pub enum SessionSelection {
109 /// `--no-session`: ephemeral, in-memory, nothing persisted.
110 Ephemeral,
111 /// Fresh durable JSONL session under `--session-dir` (or the default dir).
112 New { dir: PathBuf, name: Option<String> },
113 /// `-c` / `-r`: restore the most recent session in the default dir.
114 Latest,
115 /// `--session <id|path>`: restore the session whose id matches, or whose
116 /// file name contains the id.
117 ById { id: String },
118 /// `--session-id <id>`: use the EXACT session id, creating it if missing.
119 ByExactId { id: String },
120 /// `--fork <path|id>`: fork the given session into a new one and start in
121 /// the fork.
122 Fork { source: String },
123}
124
125/// Decide the session selection from parsed args + the resolved cwd.
126pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
127 if args.no_session {
128 return SessionSelection::Ephemeral;
129 }
130 if args.continue_session || args.resume {
131 // `--continue` and `--resume` both restore the most recent session.
132 return SessionSelection::Latest;
133 }
134 if let Some(s) = &args.fork {
135 return SessionSelection::Fork { source: s.clone() };
136 }
137 if let Some(s) = &args.session_id {
138 return SessionSelection::ByExactId { id: s.clone() };
139 }
140 if let Some(s) = &args.session {
141 return SessionSelection::ById { id: s.clone() };
142 }
143 let dir = args
144 .session_dir
145 .clone()
146 .unwrap_or_else(|| default_session_dir(cwd));
147 SessionSelection::New {
148 dir,
149 name: args.name.clone(),
150 }
151}
152
153/// The default session directory: `<cwd>/.pi/sessions`. Mirrors the TS
154/// `getDefaultSessionDir` (`.pi/agent/sessions` in TS; v1 uses `.pi/sessions`
155/// under the project — a documented divergence).
156pub fn default_session_dir(cwd: &Path) -> PathBuf {
157 cwd.join(".pi").join("sessions")
158}
159
160/// Build the `AgentHarness` from the resolved model + parsed args + cwd.
161///
162/// This is the v1 equivalent of TS `createAgentSession`. It:
163/// 1. Builds the `OsExecutionEnv` rooted at `cwd`.
164/// 2. Constructs the built-in tools (optionally filtered by `--tools`/
165/// `--exclude-tools`/`--no-tools`/`--no-builtin-tools`).
166/// 3. Resolves the session storage (ephemeral vs fresh JSONL vs restore-error).
167/// 4. Assembles `AgentHarnessOptions` and calls `AgentHarness::create`.
168///
169/// Returns the harness plus a `broadcast::Receiver<AgentEvent>` carrying the
170/// live `AgentEvent` stream from every run (backed by a `BroadcastEmitter`
171/// installed on the harness). Interactive mode drains this to render streaming
172/// responses; the non-interactive modes simply drop it.
173/// Returns the harness, the live `AgentEvent` broadcast receiver, and a
174/// [`ReloadContext`] the interactive TUI holds to drive `/reload` (and a
175/// plugin's `runtime_action(Reload)` via the mailbox). Non-interactive modes
176/// drop the context (no `/reload` surface in print/json mode).
177pub async fn build(
178 resolved: &ResolvedModel,
179 args: &Args,
180 cwd: &Path,
181) -> Result<
182 (
183 AgentHarness,
184 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
185 ReloadContext,
186 ),
187 BuildError,
188> {
189 let cwd_str = cwd.to_string_lossy().to_string();
190
191 // ---- B5a: build the action bridge BEFORE extension load ----
192 // Extensions load before `AgentHarness::create` (extensions provide tools the
193 // harness is built with), but a plugin stores the `ActionBridge`'s raw
194 // `user_data` pointer during `register` and it must remain valid + the host
195 // must be ready for the whole session. So:
196 // 1. Capture the current tokio `Handle` (the async main-thread runtime) —
197 // the bridge spawns dispatch from any thread via `Handle::spawn`.
198 // 2. Build an *empty* `HarnessActionHost` (its harness cell is unset; no
199 // plugin can call a runtime action before the harness runs).
200 // 3. Wrap it as `Arc<dyn RuntimeActionHost>` + `ActionBridge`, thread
201 // `Some(bridge)` into `load_extensions` so every plugin's `user_data`
202 // points at this bridge.
203 // 4. After `AgentHarness::create` succeeds, call `set_harness(&cell, …)` to
204 // fill the host cell the bridge recovers on the first action call.
205 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
206 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
207 })?;
208 let catalog = crate::provider::available_catalog(resolved);
209 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
210 catalog.clone(),
211 cwd.to_path_buf(),
212 runtime.clone(),
213 );
214 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = Arc::new(action_host);
215 // `runtime` is reused below (B5c: `PluggableProvider` needs a captured
216 // `Handle` to `spawn_blocking` the sync `ProviderRequestFn`), so clone here.
217 //
218 // B5d: build the initial bridge WITH a reload callback backed by a session-
219 // long `ReloadMailbox` (cloned into `ReloadContext` + handed to the TUI). A
220 // plugin's `runtime_action(Reload)` then signals the TUI's main loop instead
221 // of hitting the "not configured" fallback. The same mailbox is reused on
222 // `/reload` (the fresh bridge carries `ctx.mailbox`), so the bridge always
223 // points at the one TUI-installed sender across reloads.
224 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
225 let action_bridge = rpi_extensions::ActionBridge::with_reload(
226 runtime.clone(),
227 host_arc,
228 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
229 );
230
231 // ---- Execution env + tools ----
232 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
233 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
234 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
235 let _registry = Arc::new(MutationQueueRegistry::new());
236 // `env_dyn` is shared between the tool context (moved in) and the resource
237 // loaders below (borrowed); clone one branch so both hold a reference.
238 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
239
240 let tools = build_tools(&ctx, args);
241 let mut tools = tools;
242
243 // ---- Extensions (Part B2) ----
244 // Load cdylib plugins from the resolved extension dirs, merge their tools
245 // into the built-in set (extension overrides same-named built-in; first-
246 // extension-wins across plugins; explicit `--tools`/`--exclude-tools` still
247 // apply to the merged set), and keep the loaded `Library` handles alive for
248 // the harness lifetime via the returned session guard. `--no-extensions`
249 // skips discovery entirely (no dirs scanned, no plugins loaded).
250 let extension_session = if args.no_extensions {
251 ExtensionSession::none()
252 } else {
253 load_extensions(args, cwd, Some(Arc::clone(&action_bridge)))
254 };
255 if args.verbose {
256 if let Some(s) = extension_session.summary() {
257 eprintln!("extensions: {s}");
258 }
259 report_deferred_renderers(&extension_session);
260 }
261 merge_extension_tools(&mut tools, &extension_session, args);
262 let active = active_tool_names(&tools, args);
263
264 // ---- Session storage ----
265 let selection = select_session(args, cwd);
266 let session = build_session(&selection, &cwd_str).await?;
267
268 // ---- System prompt base (precedence: --system-prompt > SYSTEM.md > default) ----
269 // Mirrors pi `discoverSystemPromptFile` (`resource-loader.ts:1022-1034`):
270 // an explicit `--system-prompt` flag wins; otherwise a discovered
271 // `<cwd>/.pi/SYSTEM.md` (project) overrides `<agent_dir>/SYSTEM.md`
272 // (global); otherwise the built-in default. **Project-wins** — the same
273 // direction as skills/prompts precedence.
274 let base_prompt = match args.system_prompt.as_deref() {
275 Some(explicit) => explicit.to_string(),
276 None => match discover_system_prompt_file(cwd) {
277 Some(path) => {
278 std::fs::read_to_string(&path).unwrap_or_else(|_| default_system_prompt(&cwd_str))
279 }
280 None => default_system_prompt(&cwd_str),
281 },
282 };
283
284 // ---- Append-text sources (precedence: --append-system-prompt > APPEND_SYSTEM.md) ----
285 // Mirrors pi `appendSystemPrompt` (`resource-loader.ts:525-542`). Explicit
286 // `--append-system-prompt` flags are joined together; when none are given, a
287 // discovered `APPEND_SYSTEM.md` (project-wins over global) provides the
288 // append text. `--append-system-prompt` takes a value that may be a literal
289 // string OR a readable file path (mirrors TS `resolvePromptInput`).
290 let mut append_texts: Vec<String> = Vec::new();
291 for extra in &args.append_system_prompt {
292 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
293 append_texts.push(text);
294 }
295 if args.append_system_prompt.is_empty() {
296 if let Some(path) = discover_append_system_prompt_file(cwd) {
297 if let Ok(text) = std::fs::read_to_string(&path) {
298 append_texts.push(text);
299 }
300 }
301 }
302 let append_join = if append_texts.is_empty() {
303 None
304 } else {
305 Some(append_texts.join("\n\n"))
306 };
307
308 // ---- Resource discovery (skills + prompt-templates + context-files) ----
309 // The env is OS-backed, rooted at cwd. Each `--no-*` flag suppresses its
310 // channel independently (pi parity). Skills/prompts load project→global then
311 // dedupe first-wins-by-name (project wins). Context files walk
312 // global→ancestor(cwd→root), deepest-last (pi parity).
313 //
314 // **Trust gate (v1 divergence):** pi gates project `.pi/*` discovery on
315 // `isProjectTrusted()` (global resources are unconditional). rpi v1 has no
316 // trust prompt — project resources are discovered unconditionally (a copied
317 // `.pi/` drops in and works). Full trust gating is deferred.
318 let agent_dir = crate::config::agent_dir().ok();
319
320 // ---- B5b: extension resources_discover ----
321 // If any plugin registered a `resources_discover` handler, fan the event out
322 // (reason "startup") and collect skill/prompt/theme paths. These plugin-
323 // contributed paths merge WITH the static Part-A dirs (project `.pi/skills` +
324 // `agent_dir/skills`, etc.) and the loaders re-run over the union — the
325 // coherence point: a plugin's discovered skills land through the SAME loaders
326 // as static skills. Static dirs load FIRST so project skills keep winning name
327 // collisions (a plugin must not shadow a project skill of the same name —
328 // mirrors pi `extendResources` running AFTER the default load's first-wins
329 // map). `load_skills` now accepts both dirs and individual `.md` files, so a
330 // plugin returning bare `SKILL.md` paths loads them (the gap this closes).
331 // Themes are accepted but ignored (rpi has no theme system — documented).
332 // A `--no-*` flag suppresses its channel for BOTH static and discovered paths.
333 let discovered = extension_session
334 .snapshot_arc()
335 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
336 .unwrap_or_default();
337
338 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
339 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
340 if !args.no_skills {
341 let mut dirs = skill_dirs(cwd);
342 dirs.extend(args.skill.iter().cloned());
343 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
344 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
345 skills = result.skills;
346 skill_diags = result.diagnostics;
347 }
348
349 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
350 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
351 if !args.no_prompt_templates {
352 let mut dirs = prompt_template_dirs(cwd);
353 dirs.extend(args.prompt_template.iter().cloned());
354 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
355 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
356 prompt_templates = result.prompt_templates;
357 prompt_diags = result.diagnostics;
358 }
359
360 let context_block = if args.no_context_files {
361 String::new()
362 } else {
363 // `load_project_context_files` walks the global agentDir first then
364 // ancestor-walks cwd→root (deepest last). It needs a real agent_dir; if
365 // none is resolvable, pass the cwd dir so only the ancestor-walk runs
366 // (the global step returns None anyway).
367 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
368 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
369 format_project_context(&files)
370 };
371
372 // Surface resource-discovery diagnostics as startup warnings (verbose-only).
373 if args.verbose {
374 for d in &skill_diags {
375 eprintln!(
376 "warning: skill {} ({}): {}",
377 d.path,
378 d.code.as_str(),
379 d.message
380 );
381 }
382 for d in &prompt_diags {
383 eprintln!(
384 "warning: prompt template {} ({}): {}",
385 d.path,
386 d.code.as_str(),
387 d.message
388 );
389 }
390 }
391
392 // ---- Compose the full system prompt ----
393 // Order mirrors pi `buildSystemPrompt` (`system-prompt.ts:28-72`):
394 // base → append → context → skills. The skills listing is the harness's own
395 // section: `AgentHarness::compose_prompt` appends `<available_skills>` (gated
396 // on the `read` tool + `disable_model_invocation`, applied inside
397 // `format_skills_for_system_prompt`). So we pass None for skills here (the
398 // harness adds the listing itself) and fold only base+append+context into
399 // the prompt we hand the harness.
400 let system_prompt = compose_system_prompt(
401 Some(&base_prompt),
402 &[], // skills: harness appends the listing itself
403 if context_block.is_empty() {
404 None
405 } else {
406 Some(&context_block)
407 },
408 append_join.as_deref(),
409 );
410
411 // ---- Debug: dump the resolved system-prompt sections (verification) ----
412 // A verification affordance for Part-A resource discovery: prints the
413 // composed sections + resource counts to stderr so a smoke can confirm
414 // `<available_skills>` + `<project_context>` + appended text reached the
415 // prompt without parsing a provider round-trip. The harness composes the
416 // final prompt (base → append → context → skills); here we print the
417 // pre-harness sections (the harness adds the skills listing itself, gated
418 // on `read` + `disable_model_invocation`).
419 if args.debug_system_prompt {
420 eprintln!("=== --debug-system-prompt ===");
421 let base_src = if args.system_prompt.is_some() {
422 "--system-prompt"
423 } else if discover_system_prompt_file(cwd).is_some() {
424 "SYSTEM.md"
425 } else {
426 "default"
427 };
428 eprintln!("[base source: {base_src}]");
429 eprintln!("--- base ---\n{base_prompt}");
430 if let Some(append) = append_join.as_deref() {
431 eprintln!("--- append ---\n{append}");
432 } else {
433 eprintln!("--- append: (none) ---");
434 }
435 if context_block.is_empty() {
436 eprintln!("--- context: (none) ---");
437 } else {
438 eprintln!("--- context ---{context_block}");
439 }
440 let visible_skills = skills
441 .iter()
442 .filter(|s| s.disable_model_invocation != Some(true))
443 .count();
444 eprintln!(
445 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
446 skills.len(),
447 visible_skills,
448 skills.len() - visible_skills
449 );
450 for s in &skills {
451 let hidden = if s.disable_model_invocation == Some(true) {
452 " [hidden]"
453 } else {
454 ""
455 };
456 eprintln!(" {}{hidden} — {}", s.name, s.description);
457 }
458 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
459 for t in &prompt_templates {
460 eprintln!(" /{}", t.name);
461 }
462 // B5b: surface plugin-contributed discovery paths so a smoke can confirm
463 // the resources_discover round-trip fed the loaders (themes ignored).
464 eprintln!(
465 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) (ignored) ---",
466 discovered.skill_paths.len(),
467 discovered.prompt_paths.len(),
468 discovered.theme_paths.len(),
469 );
470 for p in &discovered.skill_paths {
471 eprintln!(" skill: {p}");
472 }
473 for p in &discovered.prompt_paths {
474 eprintln!(" prompt: {p}");
475 }
476 eprintln!(
477 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
478 );
479 eprintln!("=== end --debug-system-prompt ===");
480 }
481
482 // ---- Options ----
483 // Install a BroadcastEmitter so the caller (the interactive TUI) can drain
484 // AgentEvents live as a run unfolds. The corresponding broadcast::Receiver
485 // is returned alongside the harness; non-interactive modes simply drop it.
486 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
487 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
488 // The broadcast half stays live for the whole session (the TUI's drain task
489 // holds the receiver); reload re-wraps it in a fresh `TeeEmitter`, so keep
490 // a clone for the `ReloadContext` before the tee match consumes the original.
491 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
492
493 // ---- Extensions emitter (Part B3a) ----
494 // If extensions loaded + registered any `on()` handlers, wrap the
495 // broadcast emitter in a `TeeEmitter` so every `AgentEvent` flows to BOTH
496 // the TUI (via the broadcast receiver above) AND the plugin handlers (via
497 // the `ExtensionEmitter`, which translates each `AgentEvent` →
498 // `StablePluginEvent` and fans out to the handlers registered for its tag).
499 // With no extensions the tee degrades to the bare broadcast emitter (a
500 // one-child passthrough), so the TUI path is unchanged.
501 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
502 Some(snapshot) => {
503 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
504 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
505 }
506 None => broadcast_emitter,
507 };
508
509 let options = AgentHarnessOptions {
510 model: resolved.model.clone(),
511 thinking_level: resolved.thinking_level,
512 active_tool_names: active,
513 tools,
514 system_prompt: Some(system_prompt),
515 resources: AgentHarnessResources {
516 skills: if skills.is_empty() {
517 None
518 } else {
519 Some(skills)
520 },
521 prompt_templates: if prompt_templates.is_empty() {
522 None
523 } else {
524 Some(prompt_templates)
525 },
526 },
527 // A restored session (--continue/--resume/--session) already has
528 // records — let the harness load it and keep appending.
529 allow_existing_session: matches!(
530 selection,
531 SessionSelection::Latest
532 | SessionSelection::ById { .. }
533 | SessionSelection::ByExactId { .. }
534 | SessionSelection::Fork { .. }
535 ),
536 stream_options: Default::default(),
537 retry: RetryPolicy::default(),
538 compaction: CompactionSettings::default(),
539 steering_mode: Default::default(),
540 follow_up_mode: Default::default(),
541 tool_execution: HarnessToolExecution::default(),
542 drive: DrivingMode::default(),
543 session,
544 // B5c: inject the resolved gateway provider PLUS one `Arc<dyn Provider>`
545 // per registered extension provider (`PluggableProvider` wraps a plugin's
546 // sync `ProviderRequestFn`). The harness's `build_stream_fn` resolves a
547 // provider lazily per call by `models.iter().find(|p| p.id() == model.provider)`,
548 // so a catalog model whose `provider` matches an extension provider's id
549 // routes to it. Extension providers land AFTER the gateway so the gateway
550 // stays first-match for its own ids (first-wins on a `.find`).
551 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
552 to_provider_messages: None,
553 entry_projectors: Default::default(),
554 agent_emitter: Some(emitter),
555 // B3b: the three exists-but-`None` loop hooks — populated when an
556 // extension session registers handlers for the matching pi `on()`
557 // tags (before_tool_call/after_tool_call/context). v1 leaves them `None`
558 // here; the rpi-extensions adapter that owns plugin handler dispatch is
559 // wired in the same build path once B3b's host-side adapter lands.
560 before_tool_call: None,
561 after_tool_call: None,
562 transform_context: None,
563 entry_transforms: Vec::new(),
564 // Extension provider hooks (B4): plugins subscribing to the
565 // BeforeProviderRequest / BeforeProviderHeaders / AfterProviderResponse
566 // events observe every provider call (observer semantics — the handler
567 // ABI has no patch channel in v1). A session without provider-hook
568 // subscribers runs hook-free.
569 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
570 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
571 };
572
573 let harness = match AgentHarness::create(options).await {
574 Ok(h) => {
575 // Fill the extension action host now that the harness exists
576 // (plugin runtime_action calls can then reach it).
577 crate::extensions_actions::HarnessActionHost::set_harness(
578 &harness_cell,
579 Arc::new(h.clone()),
580 );
581 h
582 }
583 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
584 };
585
586 // ---- B5d: assemble the ReloadContext the TUI holds ----
587 // Every field is cheap to clone (Arc / Vec / args Clone). The cells own the
588 // live session + bridge so `/reload` can swap them; the harness itself is
589 // NOT held here (the TUI already owns a `&AgentHarness` / clone at the call
590 // site — passing it into `reload_extension_resources` keeps this structfree
591 // of a harness back-reference so it can be `Clone` into the reload callback).
592 let reload_context = ReloadContext {
593 extension_session: Arc::new(Mutex::new(extension_session)),
594 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
595 catalog,
596 gateway: resolved.provider.clone(),
597 runtime: runtime.clone(),
598 cwd: cwd.to_path_buf(),
599 args: args.clone(),
600 resolved_model: resolved.model.clone(),
601 broadcast: broadcast_for_context,
602 mailbox: reload_mailbox,
603 };
604
605 Ok((harness, event_rx, reload_context))
606}
607
608// ===========================================================================
609// B5d — `/reload`: re-run extension + resource discovery into a LIVE harness
610// ===========================================================================
611//
612// `/reload` (interactive TUI command, or a plugin's `runtime_action(Reload)`)
613// re-runs everything `build` did around resources/extensions WITHOUT rebuilding
614// the `AgentHarness` itself (rebuilding would tear down the session/lane/event
615// wiring + the broadcast drain task the TUI owns). Instead it:
616//
617// 1. Builds a fresh `ExtensionSession` (re-load the cdylibs) over the same
618// dir set, with a FRESH `ActionBridge` (the old one is `invalidate`d so
619// in-flight plugin→host calls on the old bridge fail fast).
620// 2. Fans `resources_discover(_, "reload")` over the fresh snapshot.
621// 3. Re-runs the Part-A loaders (skills/prompts/context/SYSTEM.md/
622// APPEND_SYSTEM.md) with the discovered paths merged in — same precedence
623// + `--no-*` gates as startup.
624// 4. Rebuilds the harness's live state via the B5d setters
625// (`set_system_prompt`/`set_resources`/`set_agent_emitter`/`set_models`/
626// `set_provider_hooks`/`set_tools`) so the NEXT run observes the reloaded
627// config (in-flight runs finish on the old `ConfigSnapshot`).
628// 5. Swaps the cells (`ExtensionSession`, `ActionBridge`, harness action
629// host's harness cell stays — the harness is the same object) and drops
630// the old session + bridge (their keepalives unmap the old cdylibs; the
631// new session's keepalive holds the fresh mappings).
632//
633// The reload is a `rpi-cli` concern (NOT a harness op): `rpi-extensions`
634// carries only the `ActionBridge` staleness flag + a `ReloadMailbox` `()` signal
635// (no pi-cli `TuiMessage` type — leaf DAG preserved). The TUI owns the mailbox
636// receiver + the actual reload routine; a plugin's
637// `runtime_action(Reload)` signals the mailbox and returns `Ok(null)`
638// immediately so the calling plugin's cdylib is NOT unmapped while its
639// `runtime_action` frame is still on the stack (the self-unmapping race a
640// synchronous plugin-initiated reload would have).
641//
642// `reload_extension_resources` is the shared routine both `/reload` (TUI) and
643// a plugin's `runtime_action(Reload)` (via the mailbox) drive. It is `pub` so
644// the TUI's main-loop handler + the mailbox-driven path call the same code.
645
646/// The cell that holds the live `ExtensionSession` across a `/reload`. Cloned
647/// into every site that needs the current session (the TUI, the reload
648/// callback). On reload the old session is `replace`d out (its `active` flag
649/// flipped + its keepalive dropped, unmapping the old cdylibs) and the fresh one
650/// `store`d. Carried as a plain `ExtensionSession` (not `Option`) — a `none()`
651/// placeholder fills the slot while the fresh one is being built.
652pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
653
654/// The cell that holds the live `ActionBridge` across a `/reload`. A plugin
655/// stores the bridge's raw `user_data` pointer during `register`; on reload the
656/// old bridge is `invalidate`d (in-flight calls fail fast) and the fresh one
657/// `store`d. The fresh session's plugins are handed the fresh bridge pointer.
658pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
659
660/// Everything `/reload` needs to rebuild extension + resource state into a live
661/// harness. Built once in [`build`] (alongside the harness) and held by the TUI
662/// (cloned into the reload callback the bridge carries + the `/reload` command
663/// handler). The harness itself is NOT held here — the TUI already owns a
664/// `&AgentHarness` / a clone; passing it at the call site keeps this struct
665/// free of a harness back-reference (so it can be `Clone` and moved into the
666/// reload callback without borrowing the harness).
667#[derive(Clone)]
668pub struct ReloadContext {
669 /// The live extension-session cell (swapped on reload).
670 pub extension_session: ExtensionSessionCell,
671 /// The live action-bridge cell (swapped + old invalidated on reload).
672 pub action_bridge: ActionBridgeCell,
673 /// The model catalog (read-only) the host uses to resolve `set_model(id)`.
674 /// `available_catalog(resolved)` is captured once — reload does not re-resolve
675 /// the provider (auth/provider resolution is a startup concern; reloading
676 /// extensions does not re-open auth).
677 pub catalog: Vec<rpi_ai::Model>,
678 /// The resolved gateway provider clone (for rebuilding `models` =
679 /// `vec![gateway] + PluggableProvider::from_session`). Cheap to clone (`Arc`).
680 pub gateway: Arc<dyn Provider>,
681 /// The ambient runtime handle (captured in `build`) — `PluggableProvider`
682 /// + the fresh `ActionBridge` need a captured `Handle` to spawn from any
683 /// thread.
684 pub runtime: tokio::runtime::Handle,
685 /// The cwd (for static resource-dir resolution + context-file walk).
686 pub cwd: PathBuf,
687 /// The parsed args (cloned) — `--no-*`/`--tools`/`--exclude-tools`/
688 /// `--extensions-dir`/`--no-extensions`/`--system-prompt`/etc all apply on
689 /// reload exactly as at startup (a reload re-reads the same flags; it does
690 /// not pick up argv changes mid-session, which is the right contract — pi's
691 /// `/reload` re-runs discovery with the same config).
692 pub args: Args,
693 /// The resolved model + thinking level (the harness's active model stays
694 /// unless `set_model` changed it; reload does not touch the model).
695 pub resolved_model: rpi_ai::Model,
696 /// The broadcast emitter the harness was built with. Reload rebuilds the
697 /// `TeeEmitter` over the fresh `ExtensionEmitter` (the old tee's extension
698 /// child is dropped, unsubscribing from the old registry). The broadcast
699 /// half stays live the whole session (the TUI's drain task holds the
700 /// receiver), so we keep a handle to re-wrap.
701 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
702 /// The session-long reload mailbox (B5d). Build creates one, installs it on
703 /// the initial `ActionBridge` via [`reload_callback_from_mailbox`], and hands
704 /// a clone to the TUI. The TUI installs its `TuiMessage` sender so a plugin's
705 /// `runtime_action(Reload)` signals the main loop — the reload routine reuses
706 /// THIS mailbox (not a fresh default) when building the fresh bridge, so the
707 /// bridge always carries the mailbox the TUI installed across reloads.
708 pub mailbox: rpi_extensions::ReloadMailbox,
709}
710
711/// The outcome of a reload: a human-readable status line for the transcript
712/// (counts of what reloaded), and whether any load diagnostics appeared.
713pub struct ReloadOutcome {
714 /// One-line summary for the transcript note (e.g. "Reloaded 2 plugin(s),
715 /// 5 skill(s), 1 prompt(s).").
716 pub summary: String,
717 /// True iff at least one extension load warning fired (ABI mismatch / skip).
718 pub had_warnings: bool,
719}
720
721/// Re-run extension + resource discovery and push the rebuilt state into the
722/// live `harness` via the B5d setters. The old `ExtensionSession` +
723/// `ActionBridge` are invalidated + swapped in [`ReloadContext`]'s cells. This
724/// is the single routine both `/reload` (TUI) and a plugin's
725/// `runtime_action(Reload)` drive (the latter via the mailbox signal).
726///
727/// Returns a [`ReloadOutcome`] for the transcript. Best-effort: a failure in
728/// one channel (e.g. a plugin that fails to reload) does not abort the others —
729/// the reload completes with whatever loaded, mirroring pi's per-plugin
730/// skip-on-error. A hard failure (e.g. the harness is closed) surfaces as an
731/// error summary.
732pub async fn reload_extension_resources(
733 harness: &AgentHarness,
734 ctx: &ReloadContext,
735) -> ReloadOutcome {
736 let cwd_str = ctx.cwd.to_string_lossy().to_string();
737 let mut warnings = false;
738
739 // ---- 1. Build a fresh ActionBridge + ExtensionSession ----
740 // The fresh bridge carries the SAME `HarnessActionHost` (the host's harness
741 // cell already points at this harness; the host impl is reusable across
742 // reloads — only the bridge's staleness flag + reload callback differ). We
743 // re-use the host by reading it off the OLD bridge (it's the same
744 // `Arc<dyn RuntimeActionHost>`).
745 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
746 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
747 Some(b) => b.clone_host(),
748 None => {
749 // No prior bridge (no extensions ever loaded). Build a fresh host so
750 // a reload that newly discovers plugins can still drive actions.
751 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
752 ctx.catalog.clone(),
753 ctx.cwd.clone(),
754 ctx.runtime.clone(),
755 );
756 crate::extensions_actions::HarnessActionHost::set_harness(
757 &_cell,
758 Arc::new(harness.clone()),
759 );
760 Arc::new(action_host)
761 }
762 };
763
764 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
765 let fresh_bridge =
766 rpi_extensions::ActionBridge::with_reload(ctx.runtime.clone(), host, reload_cb);
767
768 let extension_session = if ctx.args.no_extensions {
769 rpi_extensions::ExtensionSession::none()
770 } else {
771 load_extensions(&ctx.args, &ctx.cwd, Some(Arc::clone(&fresh_bridge)))
772 };
773 if extension_session.is_empty() && !ctx.args.no_extensions {
774 // The fresh session may be empty if no cdylibs are present — not a
775 // warning per se, but note it.
776 }
777 if ctx.args.verbose {
778 if let Some(s) = extension_session.summary() {
779 eprintln!("reload: {s}");
780 }
781 report_deferred_renderers(&extension_session);
782 }
783
784 // ---- 2. Invalidate the old session + bridge BEFORE the swap ----
785 // The old registry's `active` flag flips false so any in-flight
786 // `emit_resources_discover`/event dispatch on the old snapshot no-ops; the
787 // old bridge's flag flips false so in-flight `runtime_action` calls parked
788 // on the old `user_data` hit the staleness guard. We do this BEFORE storing
789 // the fresh session so there is no window where both are "active".
790 //
791 // The session cell carries a plain `ExtensionSession` (not `Option`), so we
792 // `mem::replace` the live one out with a `none()` placeholder to extract it
793 // for invalidation (the snapshot's `active` flag is on a shared `Arc`, so a
794 // borrow of the extracted value is enough to flip it; the extraction itself
795 // also drops the old keepalive once we drop `old_session`, unmapping the old
796 // cdylibs). `mem::replace` (not `.take()`) because the cell is not `Option`.
797 {
798 let mut session_guard = ctx.extension_session.lock().unwrap();
799 let old_session = std::mem::replace(
800 &mut *session_guard,
801 rpi_extensions::ExtensionSession::none(),
802 );
803 if let Some(old_snap) = old_session.snapshot_arc() {
804 // `invalidate` is on the registry, but the snapshot shares the flag —
805 // flipping the snapshot's flag invalidates the registry too (same Arc).
806 // `RegistrySnapshot` exposes `active_flag()` for this.
807 old_snap.active_flag().store(false, Ordering::SeqCst);
808 }
809 // `old_session` drops here — its keepalive releases the old `Library`
810 // handles (unmapping the old cdylibs). The fresh session's keepalive
811 // (built below) holds the fresh mappings.
812 }
813 if let Some(old_b) = old_bridge {
814 old_b.invalidate();
815 }
816
817 // The fresh bridge is now the live one. Store it + the fresh session so
818 // subsequent reloads (or plugin calls still resolving the cells) see them.
819 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
820 *ctx.extension_session.lock().unwrap() = extension_session.clone();
821
822 // ---- 3. resources_discover ("reload") over the fresh snapshot ----
823 let discovered = extension_session
824 .snapshot_arc()
825 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
826 .unwrap_or_default();
827
828 // ---- 4. Re-run the Part-A loaders (same precedence + --no-* gates) ----
829 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
830 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
831
832 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
833 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
834 if !ctx.args.no_skills {
835 let mut dirs = skill_dirs(&ctx.cwd);
836 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
837 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
838 skills = result.skills;
839 skill_diags = result.diagnostics;
840 }
841
842 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
843 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
844 if !ctx.args.no_prompt_templates {
845 let mut dirs = prompt_template_dirs(&ctx.cwd);
846 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
847 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
848 prompt_templates = result.prompt_templates;
849 prompt_diags = result.diagnostics;
850 }
851
852 let context_block = if ctx.args.no_context_files {
853 String::new()
854 } else {
855 let agent_dir = crate::config::agent_dir().ok();
856 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
857 let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
858 format_project_context(&files)
859 };
860
861 if !skill_diags.is_empty() || !prompt_diags.is_empty() {
862 warnings = true;
863 if ctx.args.verbose {
864 for d in &skill_diags {
865 eprintln!(
866 "warning: skill {} ({}): {}",
867 d.path,
868 d.code.as_str(),
869 d.message
870 );
871 }
872 for d in &prompt_diags {
873 eprintln!(
874 "warning: prompt template {} ({}): {}",
875 d.path,
876 d.code.as_str(),
877 d.message
878 );
879 }
880 }
881 }
882
883 // ---- Re-compose the system prompt (same precedence as build) ----
884 let base_prompt = match ctx.args.system_prompt.as_deref() {
885 Some(explicit) => explicit.to_string(),
886 None => match discover_system_prompt_file(&ctx.cwd) {
887 Some(path) => {
888 std::fs::read_to_string(&path).unwrap_or_else(|_| default_system_prompt(&cwd_str))
889 }
890 None => default_system_prompt(&cwd_str),
891 },
892 };
893 let mut append_texts: Vec<String> = Vec::new();
894 for extra in &ctx.args.append_system_prompt {
895 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
896 append_texts.push(text);
897 }
898 if ctx.args.append_system_prompt.is_empty() {
899 if let Some(path) = discover_append_system_prompt_file(&ctx.cwd) {
900 if let Ok(text) = std::fs::read_to_string(&path) {
901 append_texts.push(text);
902 }
903 }
904 }
905 let append_join = if append_texts.is_empty() {
906 None
907 } else {
908 Some(append_texts.join("\n\n"))
909 };
910 let system_prompt = compose_system_prompt(
911 Some(&base_prompt),
912 &[],
913 if context_block.is_empty() {
914 None
915 } else {
916 Some(&context_block)
917 },
918 append_join.as_deref(),
919 );
920
921 // ---- Rebuild the emitter (TeeEmitter over fresh ExtensionEmitter) ----
922 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
923 Some(snapshot) => {
924 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
925 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
926 }
927 None => ctx.broadcast.clone(),
928 };
929
930 // ---- 5. Push the rebuilt state into the live harness via the B5d setters ----
931 let resources = AgentHarnessResources {
932 skills: if skills.is_empty() {
933 None
934 } else {
935 Some(skills.clone())
936 },
937 prompt_templates: if prompt_templates.is_empty() {
938 None
939 } else {
940 Some(prompt_templates.clone())
941 },
942 };
943 let _ = harness.set_system_prompt(Some(system_prompt)).await;
944 let _ = harness.set_resources(resources).await;
945 let _ = harness.set_agent_emitter(Some(emitter)).await;
946 let _ = harness
947 .set_models(build_models_with_extensions_for_reload(
948 &ctx.gateway,
949 &extension_session,
950 ctx.runtime.clone(),
951 ))
952 .await;
953 let _ = harness
954 .set_provider_hooks(
955 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
956 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
957 )
958 .await;
959
960 // Re-merge extension tools (a reloaded plugin may have added/removed a
961 // tool). The built-in set is rebuilt from scratch + extension tools merged
962 // on top, mirroring `build`.
963 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
964 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
965 let mut tools = build_tools(&tool_ctx, &ctx.args);
966 merge_extension_tools(&mut tools, &extension_session, &ctx.args);
967 let active = active_tool_names(&tools, &ctx.args);
968 let _ = harness.set_tools(tools, Some(active)).await;
969
970 let summary = format!(
971 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
972 extension_session.loaded_paths().len(),
973 skills.len(),
974 prompt_templates.len(),
975 );
976 ReloadOutcome {
977 summary,
978 had_warnings: warnings,
979 }
980}
981
982/// `build_models_with_extensions` for the reload path: the resolved gateway
983/// (NOT `resolved` — the reload context carries the gateway `Arc<dyn Provider>`
984/// directly, since the provider/auth did not change) first, then one
985/// `PluggableProvider` per registered extension provider in the fresh session.
986fn build_models_with_extensions_for_reload(
987 gateway: &Arc<dyn Provider>,
988 extension_session: &ExtensionSession,
989 runtime: tokio::runtime::Handle,
990) -> Vec<Arc<dyn Provider>> {
991 let mut models: Vec<Arc<dyn Provider>> = vec![gateway.clone()];
992 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
993 models.extend(pluggable);
994 models
995}
996
997/// Diagnostic for registered TUI renderers. All three renderer kinds are
998/// consumed by the interactive TUI's JSON component adapter; this line remains
999/// useful under `--verbose` for extension authors.
1000fn report_deferred_renderers(session: &ExtensionSession) {
1001 let Some(snap) = session.snapshot_arc() else {
1002 return;
1003 };
1004 let all = snap.renderers();
1005 let markdown = all
1006 .iter()
1007 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1008 .count();
1009 let message = all
1010 .iter()
1011 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1012 .count();
1013 let entry = all
1014 .iter()
1015 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1016 .count();
1017 if markdown + message + entry == 0 {
1018 return;
1019 }
1020 eprintln!(
1021 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1022 markdown, message, entry
1023 );
1024}
1025
1026/// A harness-build error.
1027#[derive(Debug, thiserror::Error)]
1028pub enum BuildError {
1029 #[error("Could not create the session directory: {0}")]
1030 SessionDir(String),
1031 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1032 SessionNotFound { requested: String, dir: String },
1033 #[error("Could not build the harness: {0}")]
1034 HarnessCreate(String),
1035}
1036
1037/// B5c: build the `AgentHarnessOptions.models` vec — the resolved gateway
1038/// provider first, then one `Arc<dyn Provider>` per registered extension
1039/// provider (each a [`rpi_extensions::PluggableProvider`] wrapping a plugin's
1040/// sync `ProviderRequestFn`). The harness resolves a provider lazily per call by
1041/// `models.iter().find(|p| p.id() == model.provider)`, so the gateway stays
1042/// first-match for its own ids and an extension provider serves a catalog model
1043/// whose `provider` matches its id. `runtime` is the same `Handle` captured for
1044/// the action bridge — `PluggableProvider` needs a captured `Handle` to
1045/// `spawn_blocking` the sync ffi call from the async `stream_simple`.
1046fn build_models_with_extensions(
1047 resolved: &ResolvedModel,
1048 extension_session: &ExtensionSession,
1049 runtime: tokio::runtime::Handle,
1050) -> Vec<Arc<dyn Provider>> {
1051 let mut models: Vec<Arc<dyn Provider>> = vec![resolved.provider.clone() as Arc<dyn Provider>];
1052 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1053 models.extend(pluggable);
1054 models
1055}
1056
1057/// Resolve the extension dirs to scan and load the cdylib plugins, returning
1058/// the loaded session guard (keeps the `Library` handles alive for the harness
1059/// lifetime). Scan order: project `.pi/extensions`, global `agent_dir()/`
1060/// `extensions`, then any `--extensions-dir` flags (scanned after the defaults
1061/// — `args.rs`). Diagnostics are a no-op sink for now; load skips/ABI mismatches
1062/// surface via the `--verbose` summary.
1063fn load_extensions(
1064 args: &Args,
1065 cwd: &Path,
1066 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1067) -> ExtensionSession {
1068 let mut dirs = vec![project_dir(cwd, EXTENSIONS_SUBDIR)];
1069 if let Some(g) = global_dir(EXTENSIONS_SUBDIR) {
1070 dirs.push(g);
1071 }
1072 dirs.extend(args.extensions_dir.iter().cloned());
1073 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1074 // B5a: the action bridge is cloned into every loaded plugin's vtable
1075 // `user_data` so post-register `runtime_action` calls recover the harness
1076 // host from any thread. The call site already gates `load_extensions` behind
1077 // `!no_extensions` and threads `Some(bridge)`; `None` is only passed by the
1078 // `--no-extensions` branch (which calls `ExtensionSession::none()` directly)
1079 // and tests. Explicit `--extension`/`-e` files load after the dirs.
1080 rpi_extensions::load_session_mixed(&dirs, &args.extension, diagnostics, action_bridge)
1081}
1082
1083/// Merge the loaded extension tools into the built-in set. An extension tool
1084/// overrides a same-named built-in; first-extension-wins across plugins is
1085/// already guaranteed by the registry (`register_tool` keeps the prior). The
1086/// explicit `--tools` allowlist / `--exclude-tools` denylist apply to the
1087/// merged set (the built-ins were already filtered in [`build_tools`]).
1088fn merge_extension_tools(tools: &mut Vec<HarnessTool>, session: &ExtensionSession, args: &Args) {
1089 let Some(snapshot) = session.snapshot() else {
1090 return;
1091 };
1092 for et in snapshot.tools() {
1093 let name = &et.tool.name;
1094 if let Some(allow) = &args.tools {
1095 if !allow.iter().any(|a| a == name) {
1096 continue;
1097 }
1098 }
1099 if let Some(deny) = &args.exclude_tools {
1100 if deny.iter().any(|d| d == name) {
1101 continue;
1102 }
1103 }
1104 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive());
1105 let harness_tool = HarnessTool::new(Arc::new(adapter));
1106 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1107 Some(slot) => *slot = harness_tool,
1108 None => tools.push(harness_tool),
1109 }
1110 }
1111}
1112
1113/// Build the tool list per `--tools`/`--exclude-tools`/`--no-tools`/
1114/// `--no-builtin-tools`. Mirrors the TS `tools`/`excludeTools`/`noTools`
1115/// resolution in `createAgentSession`.
1116/// Default bash timeout: 120s when the model doesn't pass one (prevents a
1117/// forgotten `timeout` from hanging the run forever — the "卡住" report).
1118/// `RPI_BASH_TIMEOUT` overrides; a model-supplied timeout always wins.
1119pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1120 use rpi_tools::tools::bash::BashToolOptions;
1121 let default = std::env::var("RPI_BASH_TIMEOUT")
1122 .ok()
1123 .and_then(|v| v.parse::<f64>().ok())
1124 .unwrap_or(120.0);
1125 BashToolOptions {
1126 command_prefix: None,
1127 default_timeout: Some(default),
1128 }
1129}
1130
1131fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1132 if args.no_tools {
1133 return Vec::new();
1134 }
1135 // Construct every built-in once (cheap; the allowlist filters below).
1136 // Read-only search tools (grep/find/ls) take the same context and need no
1137 // mutation queue — they go through the `FileSystem` trait only.
1138 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1139 ("read", HarnessTool::new(create_read_tool(ctx, None))),
1140 (
1141 "bash",
1142 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1143 ),
1144 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1145 ("write", HarnessTool::new(create_write_tool(ctx))),
1146 ("grep", HarnessTool::new(create_grep_tool(ctx, None))),
1147 ("find", HarnessTool::new(create_find_tool(ctx, None))),
1148 ("ls", HarnessTool::new(create_ls_tool(ctx, None))),
1149 ];
1150
1151 // `--no-builtin-tools` disables the built-in set but would keep
1152 // extension/custom tools — v1 has none, so it's equivalent to `--no-tools`
1153 // here. We honor it by clearing the built-ins.
1154 if args.no_builtin_tools {
1155 all.clear();
1156 }
1157
1158 // Allowlist (`--tools`): keep only named built-ins.
1159 if let Some(allow) = &args.tools {
1160 all.retain(|(name, _)| allow.iter().any(|a| a == name));
1161 }
1162 // Denylist (`--exclude-tools`): drop named tools.
1163 if let Some(deny) = &args.exclude_tools {
1164 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1165 }
1166
1167 all.into_iter()
1168 .map(|(_, t)| t.with_replay(ToolReplay::Safe))
1169 .collect()
1170}
1171
1172/// Resolve the active tool names from the constructed tools when no explicit
1173/// `--tools` allowlist was given. Mirrors the TS default: all registered tools
1174/// active.
1175fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1176 if args.no_tools {
1177 return Vec::new();
1178 }
1179 if let Some(allow) = &args.tools {
1180 // The allowlist IS the active set (TS: `tools` doubles as the active
1181 // set when provided). Keep order + only those that exist.
1182 let names: Vec<String> = tools.iter().map(|t| t.tool.schema().name.clone()).collect();
1183 return allow
1184 .iter()
1185 .filter(|a| names.iter().any(|n| n == *a))
1186 .cloned()
1187 .collect();
1188 }
1189 // Default: every constructed tool is active. If `--exclude-tools` dropped
1190 // some, they're simply absent from `tools`, so this lands right.
1191 tools.iter().map(|t| t.tool.schema().name.clone()).collect()
1192}
1193
1194/// Build the `Session` facade for the chosen selection.
1195async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1196 match selection {
1197 SessionSelection::Ephemeral => Ok(ephemeral_session()),
1198 SessionSelection::New { dir, .. } => {
1199 // Ensure the sessions directory exists, then create a fresh JSONL
1200 // session file inside it.
1201 std::fs::create_dir_all(dir)
1202 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1203 let session = create_jsonl_session(dir, cwd)
1204 .await
1205 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1206 Ok(session)
1207 }
1208 SessionSelection::Latest
1209 | SessionSelection::ById { .. }
1210 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
1211 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
1212 }
1213}
1214
1215/// Open an existing JSONL session for `Latest` / `ById`. Mirrors the TS
1216/// `SessionManager.resume`/`open` flow: list the session dir (newest-first),
1217/// match the request, then open the matched file and wrap it in a `Session`
1218/// facade. The restored transcript renders into the TUI at startup and the
1219/// harness continues appending to the same file.
1220async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1221 // `list_typed` is newest-first; `Latest` takes the head, `ById` matches
1222 // the id exactly or by file-name containment (so `--session 01a02…` or a
1223 // partial id works, mirroring the TS id/path matching).
1224 match selection {
1225 SessionSelection::Latest => {
1226 let metas = list_session_metadata(cwd).await?;
1227 let Some(meta) = metas.first() else {
1228 return Err(BuildError::SessionNotFound {
1229 requested: "the most recent session".to_string(),
1230 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1231 });
1232 };
1233 open_session(meta, cwd).await
1234 }
1235 SessionSelection::ById { id } => open_session_by_id(id, cwd).await.map_err(|e| match e {
1236 OpenError::NotFound { requested } => BuildError::SessionNotFound {
1237 requested,
1238 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1239 },
1240 OpenError::Other(msg) => BuildError::SessionDir(msg),
1241 }),
1242 SessionSelection::ByExactId { id } => {
1243 // Exact id match only (pi `--session-id`): restore when the
1244 // session exists, else create a fresh one under the default dir.
1245 let metas = list_session_metadata(cwd).await?;
1246 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
1247 return open_session(meta, cwd).await;
1248 }
1249 let dir = default_session_dir(Path::new(cwd));
1250 std::fs::create_dir_all(&dir)
1251 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1252 create_jsonl_session_with_id(&dir, cwd, Some(id.clone()))
1253 .await
1254 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
1255 }
1256 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
1257 }
1258}
1259
1260/// `--fork <path|id>`: open the source session, fork it into a new JSONL
1261/// session (records the parent id), and start in the fork.
1262async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
1263 use rpi_harness::session::jsonl::{
1264 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1265 };
1266 use rpi_harness::session::types::{ForkOptions, SessionStorage};
1267 use rpi_tools::FileSystem;
1268
1269 let dir = default_session_dir(Path::new(cwd));
1270 std::fs::create_dir_all(&dir)
1271 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1272 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1273 let fs: Arc<dyn FileSystem> = env.clone();
1274 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1275 fs: fs.clone(),
1276 sessions_root: dir.to_string_lossy().into_owned(),
1277 clock: Arc::new(SystemClock),
1278 ids: Arc::new(DefaultIdGenerator::new()),
1279 });
1280 let metas = repo
1281 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
1282 .await
1283 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
1284 let source_meta = metas
1285 .iter()
1286 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
1287 .ok_or_else(|| BuildError::SessionNotFound {
1288 requested: format!("--fork {source}"),
1289 dir: dir.display().to_string(),
1290 })?;
1291 let fork_storage = repo
1292 .fork_typed(
1293 source_meta,
1294 &JsonlSessionCreateOptions {
1295 id: None,
1296 parent_session_id: Some(source_meta.id.clone()),
1297 cwd: cwd.to_string(),
1298 metadata: None,
1299 },
1300 &ForkOptions::default(),
1301 )
1302 .await
1303 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
1304 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
1305 Ok(Session::new(storage_arc, None))
1306}
1307
1308/// Errors from [`open_session_by_id`], split so the CLI can map them to
1309/// [`BuildError`] while the TUI can surface a friendlier note.
1310pub enum OpenError {
1311 /// No session matched the request.
1312 NotFound { requested: String },
1313 /// The match existed but could not be opened/parsed.
1314 Other(String),
1315}
1316
1317impl std::fmt::Display for OpenError {
1318 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1319 match self {
1320 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
1321 OpenError::Other(msg) => write!(f, "{msg}"),
1322 }
1323 }
1324}
1325
1326/// List the JSONL session metadata under the default session dir, newest
1327/// first. Shared by startup restore and the TUI `/session` hot-switch.
1328pub async fn list_session_metadata(
1329 cwd: &str,
1330) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
1331 use rpi_harness::session::jsonl::{
1332 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1333 };
1334 use rpi_tools::FileSystem;
1335
1336 let dir = default_session_dir(Path::new(cwd));
1337 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1338 let fs: Arc<dyn FileSystem> = env.clone();
1339 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1340 fs: fs.clone(),
1341 sessions_root: dir.to_string_lossy().into_owned(),
1342 clock: Arc::new(SystemClock),
1343 ids: Arc::new(DefaultIdGenerator::new()),
1344 });
1345 repo.list_typed(&JsonlSessionListOptions::default())
1346 .await
1347 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
1348}
1349
1350/// Open a session whose id matches exactly or by file-name containment
1351/// (so `--session 01a02…` / a partial id / a full file name all work). The
1352/// TUI `/session` hot-switch calls this with the selector's item value.
1353pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
1354 let metas = list_session_metadata(cwd)
1355 .await
1356 .map_err(|e| OpenError::Other(e.to_string()))?;
1357 let Some(meta) = metas
1358 .iter()
1359 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
1360 else {
1361 return Err(OpenError::NotFound {
1362 requested: format!("session {id}"),
1363 });
1364 };
1365 open_session(meta, cwd)
1366 .await
1367 .map_err(|e| OpenError::Other(e.to_string()))
1368}
1369
1370/// Fork the harness's current session into a new JSONL session (new id, parent
1371/// set to the source) and wrap it in a `Session`. Mirrors the TUI's
1372/// `fork_session` flow (`interactive_tui.rs`) — hoisted here so both the TUI
1373/// and the plugin `runtime_action(Fork)` host share one implementation.
1374/// Returns the new `Session` (NOT yet swapped onto the harness — the caller
1375/// does `harness.set_session(...)`).
1376pub(crate) async fn fork_session_storage(
1377 harness: &AgentHarness,
1378 cwd: &str,
1379) -> Result<Session, String> {
1380 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
1381 use rpi_tools::FileSystem;
1382
1383 let dir = default_session_dir(Path::new(cwd));
1384 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1385 let fs: Arc<dyn FileSystem> = env.clone();
1386 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1387 fs,
1388 sessions_root: dir.to_string_lossy().into_owned(),
1389 clock: Arc::new(SystemClock),
1390 ids: Arc::new(DefaultIdGenerator::new()),
1391 });
1392 // The fork needs the rich JSONL metadata (with the on-disk path); resolve
1393 // it from the session list by the current session's id.
1394 let id = harness.session().storage().metadata().id.clone();
1395 let metas = list_session_metadata(cwd)
1396 .await
1397 .map_err(|e| e.to_string())?;
1398 let Some(source) = metas.iter().find(|m| m.id == id) else {
1399 return Err(format!("current session {id} not found on disk"));
1400 };
1401 let fork_storage = repo
1402 .fork_typed(
1403 source,
1404 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
1405 id: None,
1406 parent_session_id: Some(source.id.clone()),
1407 cwd: cwd.to_string(),
1408 metadata: None,
1409 },
1410 &rpi_harness::session::types::ForkOptions::default(),
1411 )
1412 .await
1413 .map_err(|e| e.to_string())?;
1414 Ok(Session::new(Arc::new(fork_storage), None))
1415}
1416
1417/// Wrap an opened [`JsonlSessionStorage`] in the `Session` facade (shared by
1418/// startup restore + TUI hot-switch).
1419async fn open_session(
1420 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
1421 cwd: &str,
1422) -> Result<Session, BuildError> {
1423 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
1424 use rpi_harness::session::types::SessionStorage;
1425 use rpi_tools::FileSystem;
1426
1427 let dir = default_session_dir(Path::new(cwd));
1428 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1429 let fs: Arc<dyn FileSystem> = env.clone();
1430 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1431 fs: fs.clone(),
1432 sessions_root: dir.to_string_lossy().into_owned(),
1433 clock: Arc::new(SystemClock),
1434 ids: Arc::new(DefaultIdGenerator::new()),
1435 });
1436 let storage = repo
1437 .open_by_jsonl_metadata(meta)
1438 .await
1439 .map_err(|e| BuildError::SessionDir(format!("open {}: {e}", meta.path)))?;
1440 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
1441 Ok(Session::new(storage_arc, None))
1442}
1443
1444/// A fresh ephemeral in-memory session (no persistence). Used for `--no-session`.
1445fn ephemeral_session() -> Session {
1446 let storage = Arc::new(InMemorySessionStorage::new(
1447 SessionMetadata {
1448 id: "ephemeral".into(),
1449 created_at: 0,
1450 parent_session_id: None,
1451 },
1452 Arc::new(SystemClock),
1453 Arc::new(DefaultIdGenerator::new()),
1454 ));
1455 Session::new(storage, None)
1456}
1457
1458/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
1459///
1460/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
1461/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
1462/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
1463/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
1464///
1465/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
1466/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
1467/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
1468pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
1469 create_jsonl_session_with_id(dir, cwd, None).await
1470}
1471
1472/// `create_jsonl_session` with an explicit id (the `--session-id` fixed-id
1473/// contract: the file is named with the given id so later `--session-id`
1474/// launches restore the same session).
1475pub(crate) async fn create_jsonl_session_with_id(
1476 dir: &Path,
1477 cwd: &str,
1478 id: Option<String>,
1479) -> Result<Session, String> {
1480 use rpi_harness::session::jsonl::{
1481 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1482 };
1483 use rpi_tools::FileSystem;
1484
1485 // A dedicated OS env for session-file I/O, rooted at the cwd so the repo's
1486 // relative-path resolution matches the tool env.
1487 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1488 let fs: Arc<dyn FileSystem> = env.clone();
1489
1490 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1491 fs: fs.clone(),
1492 sessions_root: dir.to_string_lossy().into_owned(),
1493 clock: Arc::new(SystemClock),
1494 ids: Arc::new(DefaultIdGenerator::new()),
1495 });
1496
1497 let opts = JsonlSessionCreateOptions {
1498 id, // fresh uuidv7 when None (--session-id passes the fixed id)
1499 parent_session_id: None,
1500 cwd: cwd.to_string(),
1501 metadata: None,
1502 };
1503 let storage = repo
1504 .create_typed(&opts)
1505 .await
1506 .map_err(|e| format!("create session: {e}"))?;
1507 // `JsonlSessionStorage` implements `SessionStorage`; wrap in the facade.
1508 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
1509 Ok(Session::new(storage_arc, None))
1510}
1511
1512/// Read an `--append-system-prompt` target: if it's a readable file path, return
1513/// its contents; otherwise return `None` and let the caller use the literal.
1514fn read_append_target(target: &str) -> Option<String> {
1515 let path = Path::new(target);
1516 if path.is_file() {
1517 std::fs::read_to_string(path).ok()
1518 } else {
1519 None
1520 }
1521}
1522
1523#[cfg(test)]
1524mod tests {
1525 use super::*;
1526 use crate::args::Args;
1527
1528 #[test]
1529 fn default_prompt_mentions_cwd_and_tools() {
1530 let p = default_system_prompt("/tmp/proj");
1531 assert!(p.contains("/tmp/proj"));
1532 assert!(p.contains("read"));
1533 assert!(p.contains("bash"));
1534 assert!(p.contains("edit"));
1535 assert!(p.contains("write"));
1536 assert!(p.contains("grep"));
1537 assert!(p.contains("find"));
1538 assert!(p.contains("ls"));
1539 }
1540
1541 #[test]
1542 fn select_ephemeral_when_no_session() {
1543 let args = Args {
1544 no_session: true,
1545 ..Args::default()
1546 };
1547 let cwd = Path::new("/tmp");
1548 assert!(matches!(
1549 select_session(&args, cwd),
1550 SessionSelection::Ephemeral
1551 ));
1552 }
1553
1554 #[test]
1555 fn select_latest_for_continue_and_resume() {
1556 let args = Args {
1557 continue_session: true,
1558 ..Args::default()
1559 };
1560 let cwd = Path::new("/tmp");
1561 assert!(matches!(
1562 select_session(&args, cwd),
1563 SessionSelection::Latest
1564 ));
1565
1566 let args = Args {
1567 resume: true,
1568 ..Args::default()
1569 };
1570 assert!(matches!(
1571 select_session(&args, cwd),
1572 SessionSelection::Latest
1573 ));
1574 }
1575
1576 #[test]
1577 fn select_by_id_for_session_flag() {
1578 let args = Args {
1579 session: Some("01a02ece".into()),
1580 ..Args::default()
1581 };
1582 let cwd = Path::new("/tmp");
1583 assert!(matches!(
1584 select_session(&args, cwd),
1585 SessionSelection::ById { id } if id == "01a02ece"
1586 ));
1587 }
1588
1589 #[test]
1590 fn select_new_with_custom_dir() {
1591 let args = Args {
1592 session_dir: Some(PathBuf::from("/tmp/sess")),
1593 ..Args::default()
1594 };
1595 let cwd = Path::new("/tmp");
1596 match select_session(&args, cwd) {
1597 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
1598 other => panic!("expected New, got {other:?}"),
1599 }
1600 }
1601
1602 #[test]
1603 fn select_new_default_dir() {
1604 let args = Args::default();
1605 let cwd = Path::new("/proj");
1606 match select_session(&args, cwd) {
1607 SessionSelection::New { dir, .. } => {
1608 assert_eq!(dir, Path::new("/proj/.pi/sessions"));
1609 }
1610 other => panic!("expected New, got {other:?}"),
1611 }
1612 }
1613
1614 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1615 async fn ephemeral_session_builds_roundtrips() {
1616 // Sanity: the ephemeral path produces a usable Session facade (the
1617 // harness build itself needs a provider; tested via the integration
1618 // path in tests/build.rs instead).
1619 let s = ephemeral_session();
1620 let leaf = s.get_leaf_id().await;
1621 assert!(leaf.is_ok());
1622 }
1623
1624 // NOTE: `build_tools`/`active_tool_names` integration is exercised by the
1625 // `tests/build.rs` harness-build test (needs a provider + multi-thread rt).
1626}