1use std::path::{Path, PathBuf};
26use std::sync::Arc;
27
28use rpi_agent::AgentMessage;
29use rpi_ai::types::{AssistantMessage, Content, ImageContent, Usage};
30use rpi_ai::{Model, ThinkingLevel};
31use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome, LaneSnapshot, RunResult};
32use rpi_harness::result::{HarnessError, HarnessResult};
33use rpi_harness::session::types::{Entry, EntryOrder, EntryQuery};
34
35use crate::export::{export_session, ExportFormat};
36
37#[derive(Debug, Clone)]
39pub struct SessionDescriptor {
40 pub id: String,
41 pub name: Option<String>,
42 pub path: Option<PathBuf>,
43 pub cwd: PathBuf,
44}
45
46#[derive(Debug, Clone, Default)]
48pub struct SessionUsageStats {
49 pub message_count: u64,
50 pub total_tokens: i64,
51 pub cached_tokens: i64,
52 pub uncached_tokens: i64,
53 pub cost_total: f64,
54}
55
56impl SessionUsageStats {
57 pub fn cache_hit_ratio(&self) -> Option<f64> {
60 let input = self.cached_tokens + self.uncached_tokens;
61 if input <= 0 {
62 return None;
63 }
64 Some(self.cached_tokens as f64 / input as f64)
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ScopedModel {
71 pub id: String,
72 pub label: String,
73}
74
75impl ScopedModel {
76 pub fn parse(raw: &str) -> Self {
79 let id = raw.trim().to_string();
80 let label = id
81 .rsplit('/')
82 .next()
83 .unwrap_or(&id)
84 .split(':')
85 .next()
86 .unwrap_or(&id)
87 .to_string();
88 Self { id, label }
89 }
90}
91
92#[derive(Clone)]
94pub struct AgentSession {
95 harness: Arc<AgentHarness>,
96 catalog: Arc<Vec<Model>>,
97 scoped_models: Arc<Vec<ScopedModel>>,
98 cwd: PathBuf,
99}
100
101impl AgentSession {
102 pub fn new(
106 harness: AgentHarness,
107 catalog: Vec<Model>,
108 scoped_models: Vec<ScopedModel>,
109 cwd: impl Into<PathBuf>,
110 ) -> Self {
111 Self {
112 harness: Arc::new(harness),
113 catalog: Arc::new(catalog),
114 scoped_models: Arc::new(scoped_models),
115 cwd: cwd.into(),
116 }
117 }
118
119 pub fn harness(&self) -> &AgentHarness {
122 &self.harness
123 }
124
125 pub fn harness_arc(&self) -> Arc<AgentHarness> {
127 Arc::clone(&self.harness)
128 }
129
130 pub fn lane(&self) -> Arc<dyn AgentLane> {
132 self.harness.lane("main")
133 }
134
135 pub fn cwd(&self) -> &Path {
137 &self.cwd
138 }
139
140 pub fn catalog(&self) -> &[Model] {
142 &self.catalog
143 }
144
145 pub fn scoped_models(&self) -> &[ScopedModel] {
147 &self.scoped_models
148 }
149
150 pub async fn prompt(
155 &self,
156 text: &str,
157 images: Vec<ImageContent>,
158 ) -> HarnessResult<RunResult> {
159 self.harness.prompt_text(text, images).await
160 }
161
162 pub async fn send_message(&self, message: AgentMessage) -> HarnessResult<RunResult> {
165 self.harness.prompt_message(message).await
166 }
167
168 pub async fn steer(&self, message: AgentMessage) -> HarnessResult<()> {
171 self.harness.steer(message).await?;
172 Ok(())
173 }
174
175 pub async fn follow_up(&self, message: AgentMessage) -> HarnessResult<()> {
178 self.harness.follow_up(message).await?;
179 Ok(())
180 }
181
182 pub async fn abort(&self) -> HarnessResult<()> {
184 self.harness.abort().await?;
185 Ok(())
186 }
187
188 pub async fn model(&self) -> HarnessResult<Model> {
192 self.harness.get_model().await
193 }
194
195 pub async fn set_model(&self, model: Model) -> HarnessResult<()> {
197 self.harness.set_model(model).await
198 }
199
200 pub async fn thinking_level(&self) -> HarnessResult<ThinkingLevel> {
202 self.harness.get_thinking_level().await
203 }
204
205 pub async fn set_thinking_level(&self, level: ThinkingLevel) -> HarnessResult<()> {
207 self.harness.set_thinking_level(level).await
208 }
209
210 pub fn cycle_catalog(&self) -> Vec<Model> {
213 if self.scoped_models.is_empty() {
214 return self.catalog.to_vec();
215 }
216 let mut out = Vec::new();
217 for scoped in self.scoped_models.iter() {
218 let bare = scoped.id.split(':').next().unwrap_or(&scoped.id);
219 if let Some(model) = self.catalog.iter().find(|m| {
220 m.id == bare
221 || format!("{}/{}", m.provider, m.id) == bare
222 || format!("{}/{}", m.provider, m.id) == scoped.id
223 }) {
224 out.push(model.clone());
225 }
226 }
227 out
228 }
229
230 pub async fn compact(&self, instructions: Option<&str>) -> HarnessResult<()> {
234 self.harness.compact(instructions).await?;
235 Ok(())
236 }
237
238 pub async fn retry(&self) -> HarnessResult<Option<RunResult>> {
241 let Some(last_user) = self.last_user_message().await? else {
242 return Ok(None);
243 };
244 self.harness.prompt_message(last_user).await.map(Some)
245 }
246
247 pub async fn tip_id(&self) -> HarnessResult<Option<String>> {
251 self.harness.get_tip_id().await
252 }
253
254 pub async fn entries(&self) -> HarnessResult<Vec<Entry>> {
256 self.harness
257 .find_entries(&EntryQuery {
258 order: Some(EntryOrder::OldestFirst),
259 ..Default::default()
260 })
261 .await
262 }
263
264 pub async fn snapshot(&self) -> HarnessResult<LaneSnapshot> {
266 self.harness.snapshot().await
267 }
268
269 pub async fn name(&self) -> HarnessResult<Option<String>> {
271 self.harness.get_name().await
272 }
273
274 pub async fn set_name(&self, name: Option<&str>) -> HarnessResult<()> {
276 self.harness.set_name(name).await
277 }
278
279 pub async fn set_label(&self, target_id: &str, label: Option<&str>) -> HarnessResult<()> {
281 self.harness.set_label(target_id, label).await
282 }
283
284 pub async fn value(&self, key: &str) -> HarnessResult<Option<serde_json::Value>> {
286 let values = rpi_harness::session::SessionValues::load(self.harness.session())
287 .await
288 .map_err(|e| HarnessError::io(e.to_string()))?;
289 Ok(values.get(key).cloned())
290 }
291
292 pub async fn set_value(&self, key: &str, value: serde_json::Value) -> HarnessResult<()> {
295 let writer = rpi_harness::session::SessionValueWriter::new(self.harness.session().clone());
296 writer
297 .set(key, value)
298 .await
299 .map_err(|e| HarnessError::io(e.to_string()))?;
300 Ok(())
301 }
302
303 pub async fn usage_stats(&self) -> HarnessResult<SessionUsageStats> {
305 let stats = self.harness.get_stats().await?;
306 Ok(SessionUsageStats {
307 message_count: stats.message_count,
308 total_tokens: stats.total_tokens,
309 cached_tokens: stats.cached_tokens,
310 uncached_tokens: stats.uncached_tokens,
311 cost_total: stats.cost_total,
312 })
313 }
314
315 pub async fn total_usage(&self) -> HarnessResult<Usage> {
317 let mut total = Usage::zero();
318 for entry in self.entries().await? {
319 if let Entry::Message(me) = entry {
320 if let AgentMessage::Assistant(a) = &me.message {
321 total.input += a.usage.input;
322 total.output += a.usage.output;
323 total.cache_read += a.usage.cache_read;
324 total.cache_write += a.usage.cache_write;
325 total.total_tokens += a.usage.total_tokens;
326 }
327 }
328 }
329 Ok(total)
330 }
331
332 pub async fn last_assistant_text(&self) -> HarnessResult<Option<String>> {
335 let entries = self.entries().await?;
336 for entry in entries.into_iter().rev() {
337 if let Entry::Message(me) = entry {
338 if let AgentMessage::Assistant(a) = me.message {
339 let text = assistant_text(&a);
340 if !text.is_empty() {
341 return Ok(Some(text));
342 }
343 }
344 }
345 }
346 Ok(None)
347 }
348
349 pub async fn export_to_html(&self, path: &Path) -> HarnessResult<()> {
353 self.export(ExportFormat::Html, path).await
354 }
355
356 pub async fn export_to_jsonl(&self, path: &Path) -> HarnessResult<()> {
358 self.export(ExportFormat::Jsonl, path).await
359 }
360
361 pub async fn export_to_markdown(&self, path: &Path) -> HarnessResult<()> {
363 self.export(ExportFormat::Markdown, path).await
364 }
365
366 pub async fn export(&self, format: ExportFormat, path: &Path) -> HarnessResult<()> {
369 let target = if path.is_dir() {
370 path.join(self.default_export_file_name(format.clone()).await)
371 } else {
372 path.to_path_buf()
373 };
374 export_session(&self.harness, format, &target)
375 .await
376 .map_err(HarnessError::io)
377 }
378
379 pub async fn default_export_file_name(&self, format: ExportFormat) -> String {
381 let extension = match format {
382 ExportFormat::Markdown => "md",
383 ExportFormat::Html => "html",
384 ExportFormat::Jsonl => "jsonl",
385 };
386 let name = self.name().await.ok().flatten().unwrap_or_default();
387 if !name.is_empty() {
388 return format!("{name}.{extension}");
389 }
390 let id = self
391 .tip_id()
392 .await
393 .ok()
394 .flatten()
395 .unwrap_or_else(|| "session".to_string());
396 format!("{id}.{extension}")
397 }
398
399 pub async fn fork(&self, label: Option<&str>) -> HarnessResult<()> {
405 self.harness
406 .navigate_tree(None, false, None, label)
407 .await?;
408 Ok(())
409 }
410
411 pub async fn result(&self, run_id: &str) -> HarnessResult<Option<RunResult>> {
414 self.harness.get_result(run_id).await
415 }
416
417 async fn last_user_message(&self) -> HarnessResult<Option<AgentMessage>> {
420 let entries = self.entries().await?;
421 for entry in entries.into_iter().rev() {
422 if let Entry::Message(me) = entry {
423 if matches!(me.message, AgentMessage::User(_)) {
424 return Ok(Some(me.message));
425 }
426 }
427 }
428 Ok(None)
429 }
430}
431
432pub fn assistant_text(message: &AssistantMessage) -> String {
435 message
436 .content
437 .iter()
438 .filter_map(|c| match c {
439 Content::Text(t) => Some(t.text.as_str()),
440 _ => None,
441 })
442 .collect::<Vec<_>>()
443 .join("\n")
444}
445
446pub fn outcome_is_completed(outcome: &HarnessRunOutcome) -> bool {
448 matches!(outcome, HarnessRunOutcome::Completed { .. })
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use rpi_ai::Api;
455 use rpi_harness::agent_harness::AgentHarness;
456 use rpi_harness::session::memory::{InMemorySessionStorage, SystemClock};
457 use rpi_harness::session::session::{DefaultIdGenerator, Session};
458 use rpi_harness::session::types::SessionMetadata;
459 use rpi_harness::types::{
460 AgentHarnessOptions, AgentHarnessResources, DrivingMode, HarnessToolExecution,
461 RetryPolicy,
462 };
463
464 fn test_session() -> Session {
465 Session::new(
466 std::sync::Arc::new(InMemorySessionStorage::new(
467 SessionMetadata {
468 id: "s-agent-session".to_string(),
469 created_at: 0,
470 parent_session_id: None,
471 },
472 std::sync::Arc::new(SystemClock),
473 std::sync::Arc::new(DefaultIdGenerator::new()),
474 )),
475 None,
476 )
477 }
478
479 async fn test_harness() -> AgentHarness {
480 AgentHarness::create(AgentHarnessOptions {
481 model: Model::new("m", "m", Api::Faux, "faux", "http://x"),
482 thinking_level: Default::default(),
483 active_tool_names: Vec::new(),
484 tools: Vec::new(),
485 system_prompt: None,
486 resources: AgentHarnessResources::default(),
487 stream_options: Default::default(),
488 retry: RetryPolicy::default(),
489 compaction: Default::default(),
490 steering_mode: Default::default(),
491 follow_up_mode: Default::default(),
492 tool_execution: HarnessToolExecution::default(),
493 drive: DrivingMode::default(),
494 session: test_session(),
495 allow_existing_session: true,
496 models: Vec::new(),
497 to_provider_messages: None,
498 entry_projectors: Default::default(),
499 agent_emitter: None,
500 before_tool_call: None,
501 after_tool_call: None,
502 transform_context: None,
503 entry_transforms: Vec::new(),
504 provider_hooks: None,
505 })
506 .await
507 .unwrap()
508 }
509
510 #[test]
511 fn scoped_model_parses_provider_and_thinking_forms() {
512 let m = ScopedModel::parse("anthropic/claude-opus-4-8:high");
513 assert_eq!(m.id, "anthropic/claude-opus-4-8:high");
514 assert_eq!(m.label, "claude-opus-4-8");
515
516 let bare = ScopedModel::parse("gpt-5");
517 assert_eq!(bare.label, "gpt-5");
518 }
519
520 #[test]
521 fn cache_hit_ratio_is_none_without_input() {
522 let stats = SessionUsageStats::default();
523 assert!(stats.cache_hit_ratio().is_none());
524
525 let stats = SessionUsageStats {
526 cached_tokens: 75,
527 uncached_tokens: 25,
528 ..Default::default()
529 };
530 assert_eq!(stats.cache_hit_ratio(), Some(0.75));
531 }
532
533 #[test]
534 fn assistant_text_joins_text_blocks_only() {
535 let mut msg = rpi_ai::types::AssistantMessage::empty(
536 rpi_ai::Api::AnthropicMessages,
537 "p",
538 "m",
539 0,
540 );
541 msg.content = vec![
542 Content::Thinking(rpi_ai::types::ThinkingContent {
543 kind: rpi_ai::types::ThinkingContentType,
544 thinking: "hidden".into(),
545 thinking_signature: None,
546 redacted: false,
547 }),
548 Content::Text(rpi_ai::types::TextContent {
549 kind: rpi_ai::types::TextContentType,
550 text: "hello".into(),
551 text_signature: None,
552 }),
553 Content::Text(rpi_ai::types::TextContent {
554 kind: rpi_ai::types::TextContentType,
555 text: "world".into(),
556 text_signature: None,
557 }),
558 ];
559 assert_eq!(assistant_text(&msg), "hello\nworld");
560 }
561
562 #[tokio::test]
563 async fn session_values_round_trip_through_the_product_layer() {
564 let session = AgentSession::new(test_harness().await, Vec::new(), Vec::new(), ".");
565 assert!(session.value("display.hide_thinking").await.unwrap().is_none());
566
567 session
568 .set_value("display.hide_thinking", serde_json::json!(true))
569 .await
570 .unwrap();
571 assert_eq!(
572 session.value("display.hide_thinking").await.unwrap(),
573 Some(serde_json::json!(true))
574 );
575
576 session
578 .set_value("display.hide_thinking", serde_json::json!(false))
579 .await
580 .unwrap();
581 assert_eq!(
582 session.value("display.hide_thinking").await.unwrap(),
583 Some(serde_json::json!(false))
584 );
585 }
586
587 #[tokio::test]
588 async fn usage_stats_and_snapshot_read_from_the_empty_session() {
589 let session = AgentSession::new(test_harness().await, Vec::new(), Vec::new(), ".");
590 let stats = session.usage_stats().await.unwrap();
591 assert_eq!(stats.message_count, 0);
592 assert_eq!(stats.total_tokens, 0);
593 assert!(stats.cache_hit_ratio().is_none());
594
595 let snapshot = session.snapshot().await.unwrap();
596 assert_eq!(snapshot.lane, "main");
597 assert!(snapshot.active.is_none());
598 }
599
600 #[tokio::test]
601 async fn export_writes_every_supported_format() {
602 let session = AgentSession::new(test_harness().await, Vec::new(), Vec::new(), ".");
603 let dir = tempfile::tempdir().unwrap();
604
605 for (format, ext) in [
606 (ExportFormat::Markdown, "md"),
607 (ExportFormat::Html, "html"),
608 (ExportFormat::Jsonl, "jsonl"),
609 ] {
610 let name = session.default_export_file_name(format.clone()).await;
611 assert!(name.ends_with(&format!(".{ext}")), "unexpected name {name}");
612 let path = dir.path().join(&name);
613 session.export(format, &path).await.unwrap();
614 assert!(path.exists(), "{ext} export missing at {}", path.display());
615 }
616 }
617}