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