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::{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/// Where a session content lives — used by export naming and `--continue`.
38#[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/// A snapshot of usage + context pressure for the footer/status line.
47#[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    /// Fraction of the rolled-up input tokens that came from cache. `None` when
58    /// no input tokens have been recorded yet (avoids a bogus `0 %`).
59    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/// One scoped-model entry: the routing id plus its human label.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ScopedModel {
71    pub id: String,
72    pub label: String,
73}
74
75impl ScopedModel {
76    /// Parse the `provider/model[:thinking]` form used by `--model` and
77    /// `settings.json#scopedModels`.
78    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/// The product-level session. Cheap to clone — the harness is behind an `Arc`.
93#[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    /// Wrap an existing harness. `catalog` is the resolved model catalog the
103    /// session may switch within; `scoped_models` is the (possibly empty)
104    /// settings-driven subset used by Ctrl+M / `/model`.
105    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    /// The wrapped harness, for the call sites (TUI drain task, extension
120    /// bridge) that already speak the harness API.
121    pub fn harness(&self) -> &AgentHarness {
122        &self.harness
123    }
124
125    /// The `Arc` handle, for spawning work onto the runtime.
126    pub fn harness_arc(&self) -> Arc<AgentHarness> {
127        Arc::clone(&self.harness)
128    }
129
130    /// The main-lane runner.
131    pub fn lane(&self) -> Arc<dyn AgentLane> {
132        self.harness.lane("main")
133    }
134
135    /// The working directory the session was opened in.
136    pub fn cwd(&self) -> &Path {
137        &self.cwd
138    }
139
140    /// The resolved model catalog.
141    pub fn catalog(&self) -> &[Model] {
142        &self.catalog
143    }
144
145    /// The active scoped-model set (`[]` → the full catalog is in play).
146    pub fn scoped_models(&self) -> &[ScopedModel] {
147        &self.scoped_models
148    }
149
150    // -- prompt / continue / queue -------------------------------------------
151
152    /// Send a prompt with optional image attachments. Mirrors TS
153    /// `AgentSession.prompt`.
154    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    /// Send an already-built user message. Mirrors TS
163    /// `AgentSession.sendUserMessage`.
164    pub async fn send_message(&self, message: AgentMessage) -> HarnessResult<RunResult> {
165        self.harness.prompt_message(message).await
166    }
167
168    /// Queue a steering message for the in-flight run. Mirrors TS
169    /// `AgentSession.steer`.
170    pub async fn steer(&self, message: AgentMessage) -> HarnessResult<()> {
171        self.harness.steer(message).await?;
172        Ok(())
173    }
174
175    /// Queue a follow-up message for after the in-flight run. Mirrors TS
176    /// `AgentSession.followUp`.
177    pub async fn follow_up(&self, message: AgentMessage) -> HarnessResult<()> {
178        self.harness.follow_up(message).await?;
179        Ok(())
180    }
181
182    /// Abort the in-flight run. Mirrors TS `AgentSession.abort`.
183    pub async fn abort(&self) -> HarnessResult<()> {
184        self.harness.abort().await?;
185        Ok(())
186    }
187
188    // -- model / thinking ----------------------------------------------------
189
190    /// The current model.
191    pub async fn model(&self) -> HarnessResult<Model> {
192        self.harness.get_model().await
193    }
194
195    /// Switch the model. Mirrors TS `AgentSession.setModel`.
196    pub async fn set_model(&self, model: Model) -> HarnessResult<()> {
197        self.harness.set_model(model).await
198    }
199
200    /// The current thinking level.
201    pub async fn thinking_level(&self) -> HarnessResult<ThinkingLevel> {
202        self.harness.get_thinking_level().await
203    }
204
205    /// Set the thinking level. Mirrors TS `AgentSession.setThinkingLevel`.
206    pub async fn set_thinking_level(&self, level: ThinkingLevel) -> HarnessResult<()> {
207        self.harness.set_thinking_level(level).await
208    }
209
210    /// The effective model cycle order: the scoped set when configured, else
211    /// the full catalog. Mirrors TS `AgentSession.getScopedModels`.
212    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    // -- compaction / retry --------------------------------------------------
231
232    /// Explicit compaction. Mirrors TS `AgentSession.compact`.
233    pub async fn compact(&self, instructions: Option<&str>) -> HarnessResult<()> {
234        self.harness.compact(instructions).await?;
235        Ok(())
236    }
237
238    /// Retry the last turn. The harness owns the retry policy; this re-runs the
239    /// last user message when one exists. Mirrors TS `AgentSession.retry`.
240    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    // -- durable reads -------------------------------------------------------
248
249    /// The current leaf entry id.
250    pub async fn tip_id(&self) -> HarnessResult<Option<String>> {
251        self.harness.get_tip_id().await
252    }
253
254    /// All entries on the session, oldest first.
255    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    /// The lane read model. Mirrors the TS runtime lane snapshot.
265    pub async fn snapshot(&self) -> HarnessResult<LaneSnapshot> {
266        self.harness.snapshot().await
267    }
268
269    /// Name/label facts. Mirrors TS `AgentSession.setName` / `setLabel`.
270    pub async fn name(&self) -> HarnessResult<Option<String>> {
271        self.harness.get_name().await
272    }
273
274    /// Set (or clear) the session name.
275    pub async fn set_name(&self, name: Option<&str>) -> HarnessResult<()> {
276        self.harness.set_name(name).await
277    }
278
279    /// Attach (or clear) a label on an entry.
280    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    /// Read a durable session value (see `rpi_harness::session::values`).
285    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    /// Persist a durable session value (latest-wins). Used for per-session
293    /// display preferences so they survive resume.
294    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    /// Rolled-up usage + context stats for the footer.
304    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    /// The summed usage across every assistant message (for `/context`).
316    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    /// The last finalized assistant text (for `/copy`). Mirrors TS
333    /// `AgentSession.getLastAssistantText`.
334    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    // -- export --------------------------------------------------------------
350
351    /// `exportToHtml()`. Mirrors TS `AgentSession.exportToHtml`.
352    pub async fn export_to_html(&self, path: &Path) -> HarnessResult<()> {
353        self.export(ExportFormat::Html, path).await
354    }
355
356    /// `exportToJsonl()`. Mirrors TS `AgentSession.exportToJsonl`.
357    pub async fn export_to_jsonl(&self, path: &Path) -> HarnessResult<()> {
358        self.export(ExportFormat::Jsonl, path).await
359    }
360
361    /// `exportToMarkdown()` — the local `/export` default.
362    pub async fn export_to_markdown(&self, path: &Path) -> HarnessResult<()> {
363        self.export(ExportFormat::Markdown, path).await
364    }
365
366    /// Export to `format`, deriving the default filename from the session
367    /// name/leaf when `path` is a directory.
368    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    /// The filename `/export` would write for `format`.
380    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    // -- fork / tree ---------------------------------------------------------
400
401    /// Fork the session at the current leaf into a labelled branch. The
402    /// navigation machinery creates the branch; the label tags its tip.
403    /// Mirrors TS `AgentSession.fork`.
404    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    /// Read back the terminal outcome of a run by id. Mirrors TS
412    /// `AgentSession.getResult`.
413    pub async fn result(&self, run_id: &str) -> HarnessResult<Option<RunResult>> {
414        self.harness.get_result(run_id).await
415    }
416
417    // -- internals -----------------------------------------------------------
418
419    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
432/// The finalized text of an assistant message (text blocks only), joined with
433/// blank lines. Mirrors the TS print-mode/export concat.
434pub 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
446/// Whether a run outcome is terminal (i.e. produced a final assistant message).
447pub 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        // Latest-wins.
577        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}