Skip to main content

rpi_cli/
agent_session.rs

1//! `AgentSession` — the coding-agent product layer over the harness.
2//!
3//! Mirrors the role of `packages/coding-agent/src/core/agent-session.ts`: one
4//! object the CLI/TUI holds that unifies prompt/continue, queueing,
5//! model/thinking mutation, scoped models, compaction + retry, export,
6//! session switching / fork, usage + context stats, and name/label facts.
7//!
8//! Design notes (deliberate, vs the TS original):
9//!
10//! - **Composition, not inheritance.** TS `AgentSession` *is* the harness plus
11//!   a pile of product concerns. Here [`AgentSession`] owns an `AgentHarness`
12//!   and adds the product surface; the harness stays usable on its own for
13//!   SDK consumers who do not want the product layer. This keeps the
14//!   `rpi-harness` crate free of CLI concerns (provider catalogs, settings
15//!   paths, export formats).
16//! - **Sync-friendly handles.** The TUI key loop is a blocking thread, so the
17//!   mutating surface that the loop touches (`set_thinking_level`,
18//!   `scoped_models`) works off cached state or spawns onto the runtime; the
19//!   async surface (`prompt`, `compact`, `export`) is `async fn`.
20//! - **Retry/compaction policy lives in the harness.** The session exposes
21//!   `retry()`/`compact()` as intent, and the harness applies its own
22//!   `RetryPolicy`/`CompactionSettings`. TS keeps the auto-retry driver in the
23//!   session; we keep it in the harness so non-CLI callers get it for free.
24
25use 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/// Where a session content lives — used by export naming and `--continue`.
40#[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/// A snapshot of usage + context pressure for the footer/status line.
49#[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    /// Fraction of the rolled-up input tokens that came from cache. `None` when
60    /// no input tokens have been recorded yet (avoids a bogus `0 %`).
61    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/// One scoped-model entry: the routing id plus its human label.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct ScopedModel {
73    pub id: String,
74    pub label: String,
75}
76
77impl ScopedModel {
78    /// Parse the `provider/model[:thinking]` form used by `--model` and
79    /// `settings.json#scopedModels`.
80    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/// The product-level session. Cheap to clone — the harness is behind an `Arc`.
95#[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    /// Wrap an existing harness. `catalog` is the resolved model catalog the
105    /// session may switch within; `scoped_models` is the (possibly empty)
106    /// settings-driven subset used by Ctrl+P / `/model`.
107    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    /// The wrapped harness, for the call sites (TUI drain task, extension
122    /// bridge) that already speak the harness API.
123    pub fn harness(&self) -> &AgentHarness {
124        &self.harness
125    }
126
127    /// The `Arc` handle, for spawning work onto the runtime.
128    pub fn harness_arc(&self) -> Arc<AgentHarness> {
129        Arc::clone(&self.harness)
130    }
131
132    /// The main-lane runner.
133    pub fn lane(&self) -> Arc<dyn AgentLane> {
134        self.harness.lane("main")
135    }
136
137    /// The working directory the session was opened in.
138    pub fn cwd(&self) -> &Path {
139        &self.cwd
140    }
141
142    /// The resolved model catalog.
143    pub fn catalog(&self) -> &[Model] {
144        &self.catalog
145    }
146
147    /// The active scoped-model set (`[]` → the full catalog is in play).
148    pub fn scoped_models(&self) -> &[ScopedModel] {
149        &self.scoped_models
150    }
151
152    // -- prompt / continue / queue -------------------------------------------
153
154    /// Send a prompt with optional image attachments. Mirrors TS
155    /// `AgentSession.prompt`.
156    pub async fn prompt(&self, text: &str, images: Vec<ImageContent>) -> HarnessResult<RunResult> {
157        self.harness.prompt_text(text, images).await
158    }
159
160    /// Send an already-built user message. Mirrors TS
161    /// `AgentSession.sendUserMessage`.
162    pub async fn send_message(&self, message: AgentMessage) -> HarnessResult<RunResult> {
163        self.harness.prompt_message(message).await
164    }
165
166    /// Queue a steering message for the in-flight run. Mirrors TS
167    /// `AgentSession.steer`.
168    pub async fn steer(&self, message: AgentMessage) -> HarnessResult<()> {
169        self.harness.steer(message).await?;
170        Ok(())
171    }
172
173    /// Queue a follow-up message for after the in-flight run. Mirrors TS
174    /// `AgentSession.followUp`.
175    pub async fn follow_up(&self, message: AgentMessage) -> HarnessResult<()> {
176        self.harness.follow_up(message).await?;
177        Ok(())
178    }
179
180    /// Abort the in-flight run. Mirrors TS `AgentSession.abort`.
181    pub async fn abort(&self) -> HarnessResult<()> {
182        self.harness.abort().await?;
183        Ok(())
184    }
185
186    // -- model / thinking ----------------------------------------------------
187
188    /// The current model.
189    pub async fn model(&self) -> HarnessResult<Model> {
190        self.harness.get_model().await
191    }
192
193    /// Switch the model. Mirrors TS `AgentSession.setModel`.
194    pub async fn set_model(&self, model: Model) -> HarnessResult<()> {
195        self.harness.set_model(model).await
196    }
197
198    /// The current thinking level.
199    pub async fn thinking_level(&self) -> HarnessResult<ThinkingLevel> {
200        self.harness.get_thinking_level().await
201    }
202
203    /// Set the thinking level. Mirrors TS `AgentSession.setThinkingLevel`.
204    pub async fn set_thinking_level(&self, level: ThinkingLevel) -> HarnessResult<()> {
205        self.harness.set_thinking_level(level).await
206    }
207
208    /// The effective model cycle order: the scoped set when configured, else
209    /// the full catalog. Mirrors TS `AgentSession.getScopedModels`.
210    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    // -- compaction / retry --------------------------------------------------
229
230    /// Explicit compaction. Mirrors TS `AgentSession.compact`.
231    pub async fn compact(&self, instructions: Option<&str>) -> HarnessResult<()> {
232        self.harness.compact(instructions).await?;
233        Ok(())
234    }
235
236    /// Retry the last turn. The harness owns the retry policy; this re-runs the
237    /// last user message when one exists. Mirrors TS `AgentSession.retry`.
238    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    // -- durable reads -------------------------------------------------------
246
247    /// The current leaf entry id.
248    pub async fn tip_id(&self) -> HarnessResult<Option<String>> {
249        self.harness.get_tip_id().await
250    }
251
252    /// All entries on the session, oldest first.
253    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    /// The lane read model. Mirrors the TS runtime lane snapshot.
263    pub async fn snapshot(&self) -> HarnessResult<LaneSnapshot> {
264        self.harness.snapshot().await
265    }
266
267    /// Name/label facts. Mirrors TS `AgentSession.setName` / `setLabel`.
268    pub async fn name(&self) -> HarnessResult<Option<String>> {
269        self.harness.get_name().await
270    }
271
272    /// Set (or clear) the session name.
273    pub async fn set_name(&self, name: Option<&str>) -> HarnessResult<()> {
274        self.harness.set_name(name).await
275    }
276
277    /// Attach (or clear) a label on an entry.
278    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    /// Read a durable session value (see `rpi_harness::session::values`).
283    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    /// Persist a durable session value (latest-wins). Used for per-session
291    /// display preferences so they survive resume.
292    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    /// Rolled-up usage + context stats for the footer.
302    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    /// The summed usage across every assistant message (for `/context`).
314    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    /// The last finalized assistant text (for `/copy`). Mirrors TS
331    /// `AgentSession.getLastAssistantText`.
332    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    // -- export --------------------------------------------------------------
348
349    /// `exportToHtml()`. Mirrors TS `AgentSession.exportToHtml`.
350    pub async fn export_to_html(&self, path: &Path) -> HarnessResult<()> {
351        self.export(ExportFormat::Html, path).await
352    }
353
354    /// `exportToJsonl()`. Mirrors TS `AgentSession.exportToJsonl`.
355    pub async fn export_to_jsonl(&self, path: &Path) -> HarnessResult<()> {
356        self.export(ExportFormat::Jsonl, path).await
357    }
358
359    /// `exportToMarkdown()` — the local `/export` default.
360    pub async fn export_to_markdown(&self, path: &Path) -> HarnessResult<()> {
361        self.export(ExportFormat::Markdown, path).await
362    }
363
364    /// Export to `format`, deriving the default filename from the session
365    /// name/leaf when `path` is a directory.
366    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    /// The filename `/export` would write for `format`.
378    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    // -- fork / tree ---------------------------------------------------------
398
399    /// Fork the session at the current leaf into a labelled branch. The
400    /// navigation machinery creates the branch; the label tags its tip.
401    /// Mirrors TS `AgentSession.fork`.
402    pub async fn fork(&self, label: Option<&str>) -> HarnessResult<()> {
403        self.harness.navigate_tree(None, false, None, label).await?;
404        Ok(())
405    }
406
407    /// Read back the terminal outcome of a run by id. Mirrors TS
408    /// `AgentSession.getResult`.
409    pub async fn result(&self, run_id: &str) -> HarnessResult<Option<RunResult>> {
410        self.harness.get_result(run_id).await
411    }
412
413    // -- internals -----------------------------------------------------------
414
415    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
428/// The finalized text of an assistant message (text blocks only), joined with
429/// blank lines. Mirrors the TS print-mode/export concat.
430pub 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
442/// Whether a run outcome is terminal (i.e. produced a final assistant message).
443pub 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        // Latest-wins.
572        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}