Skip to main content

claude_wrapper/
session.rs

1//! Multi-turn session management for short-lived processes.
2//!
3//! A [`Session`] threads Claude's `session_id` across turns automatically,
4//! so callers never need to scrape it out of a result event or pass
5//! `--resume` by hand.
6//!
7//! # When to use
8//!
9//! [`Session`] is the right fit when each turn can stand on its own
10//! and the host process is short-lived: CLIs, build scripts, batch
11//! jobs, lambdas. Each turn spawns a fresh `claude` subprocess and
12//! resumes the conversation via `--resume <session_id>`.
13//!
14//! For long-running hosts (IDE backends, daemons, agent servers,
15//! chat UIs) where holding a `claude` subprocess open across many
16//! turns is cheap, prefer [`DuplexSession`](crate::duplex::DuplexSession).
17//! It supports mid-turn interrupts, mid-turn permission decisions, and
18//! a broadcast event stream that [`Session`] cannot offer because of
19//! the transient subprocess model.
20//!
21//! # Ownership
22//!
23//! [`Session`] holds an `Arc<Claude>`. Wrapping the client in an `Arc`
24//! means a session can outlive the original client binding, be moved
25//! between tasks, and sit inside long-lived actor state -- which is the
26//! usage shape that callers like centralino-rs need. One `Arc::clone`
27//! per session is a negligible cost in exchange.
28//!
29//! # Two entry points
30//!
31//! - [`Session::send`] takes a plain prompt. Use this for straightforward
32//!   multi-turn chat.
33//! - [`Session::execute`] takes a fully-configured [`QueryCommand`]. Use
34//!   this when you want per-turn options like `model`, `max_turns`,
35//!   `permission_mode`, etc. The session automatically overrides any
36//!   session-related flags on the command (`--resume`, `--continue`,
37//!   `--session-id`, `--fork-session`) so they can't conflict.
38//!
39//! Streaming follows the same split: [`Session::stream`] and
40//! [`Session::stream_execute`].
41//!
42//! # Cost accounting
43//!
44//! The CLI's `total_cost_usd` is a running native-session total.
45//! [`Session`] records its nonnegative change in both the cumulative
46//! cost and any attached [`BudgetTracker`]. Missing totals leave the
47//! baseline unchanged; raw totals, including missing values, remain
48//! in [`Session::history`]. A falling total starts a new baseline and
49//! contributes zero because spend during a reset is unknown. A
50//! changed native session id starts an independent baseline.
51//! A missing id keeps the last known identity and cost baseline.
52//!
53//! The first observed total is counted in full, including historical
54//! spend restored by [`Session::resume`]. This tracks observed native
55//! lifetime spend, not spend since attaching this handle. That initial
56//! historical amount also reaches the attached budget tracker.
57//! Buffered failures that return an [`Error`](crate::Error) before a
58//! [`QueryResult`] are not recorded. A later reported total may catch
59//! up that spend; inspect the error's native cost metadata when no
60//! later turn is available.
61//!
62//! # Example
63//!
64//! ```no_run
65//! use std::sync::Arc;
66//! use claude_wrapper::{Claude, QueryCommand};
67//! use claude_wrapper::session::Session;
68//!
69//! # async fn example() -> claude_wrapper::Result<()> {
70//! let claude = Arc::new(Claude::builder().build()?);
71//!
72//! let mut session = Session::new(Arc::clone(&claude));
73//!
74//! // Simple path
75//! let first = session.send("explain quicksort").await?;
76//!
77//! // Full control: custom model, effort, permission mode, etc.
78//! let second = session
79//!     .execute(QueryCommand::new("now mergesort").model("opus"))
80//!     .await?;
81//!
82//! println!("total cost: ${:.4}", session.total_cost_usd());
83//! println!("turns: {}", session.total_turns());
84//! # Ok(())
85//! # }
86//! ```
87//!
88//! # Resuming an existing session
89//!
90//! ```no_run
91//! # use std::sync::Arc;
92//! # use claude_wrapper::{Claude};
93//! # use claude_wrapper::session::Session;
94//! # async fn example() -> claude_wrapper::Result<()> {
95//! # let claude = Arc::new(Claude::builder().build()?);
96//! // Reattach to a session you stored earlier
97//! let mut session = Session::resume(claude, "sess-abc123");
98//! let result = session.send("pick up where we left off").await?;
99//! # Ok(())
100//! # }
101//! ```
102
103use std::sync::Arc;
104
105use crate::Claude;
106use crate::budget::BudgetTracker;
107use crate::command::query::QueryCommand;
108use crate::duplex::turn_cost_delta;
109use crate::error::Result;
110use crate::types::QueryResult;
111
112#[cfg(feature = "json")]
113use crate::streaming::{StreamEvent, stream_query};
114
115/// A multi-turn conversation handle.
116///
117/// Owns an `Arc<Claude>` so it can be moved between tasks and live
118/// inside long-running actors. Tracks `session_id`, cumulative cost,
119/// turn count, and per-turn result history.
120#[derive(Debug, Clone)]
121pub struct Session {
122    claude: Arc<Claude>,
123    session_id: Option<String>,
124    history: Vec<QueryResult>,
125    cumulative_cost_usd: f64,
126    cumulative_turns: u32,
127    last_cost_session_id: Option<String>,
128    last_reported_total_usd: Option<f64>,
129    budget: Option<BudgetTracker>,
130}
131
132impl Session {
133    /// Start a fresh session. The first turn will discover a session id
134    /// from its result; subsequent turns reuse it via `--resume`.
135    pub fn new(claude: Arc<Claude>) -> Self {
136        Self {
137            claude,
138            session_id: None,
139            history: Vec::new(),
140            cumulative_cost_usd: 0.0,
141            cumulative_turns: 0,
142            last_cost_session_id: None,
143            last_reported_total_usd: None,
144            budget: None,
145        }
146    }
147
148    /// Reattach to an existing session by id. The next turn immediately
149    /// passes `--resume <id>`. Cost and turn counters start at zero
150    /// since no history is available. The first result's reported
151    /// total includes the native session's historical spend, which
152    /// is counted in full. See the [cost-accounting scope](crate::session#cost-accounting).
153    pub fn resume(claude: Arc<Claude>, session_id: impl Into<String>) -> Self {
154        let session_id = session_id.into();
155        Self {
156            claude,
157            session_id: Some(session_id.clone()),
158            history: Vec::new(),
159            cumulative_cost_usd: 0.0,
160            cumulative_turns: 0,
161            last_cost_session_id: Some(session_id),
162            last_reported_total_usd: None,
163            budget: None,
164        }
165    }
166
167    /// Attach a [`BudgetTracker`] to this session. Each nonnegative
168    /// change in [`QueryResult::cost_usd`] is recorded on the tracker, and
169    /// [`Session::execute`]/[`Session::stream_execute`] return
170    /// [`crate::error::Error::BudgetExceeded`]
171    /// before dispatching a turn if the tracker's ceiling has been hit.
172    ///
173    /// Clone a tracker across several sessions to enforce a shared
174    /// ceiling; each `Session` then sees the same running total.
175    /// The first observed total includes historical spend on resumed
176    /// native sessions; see the [cost-accounting scope](crate::session#cost-accounting).
177    pub fn with_budget(mut self, budget: BudgetTracker) -> Self {
178        self.budget = Some(budget);
179        self
180    }
181
182    /// The attached [`BudgetTracker`], if any.
183    pub fn budget(&self) -> Option<&BudgetTracker> {
184        self.budget.as_ref()
185    }
186
187    /// Send a plain-prompt turn. Equivalent to
188    /// `execute(QueryCommand::new(prompt))`.
189    #[cfg(feature = "json")]
190    pub async fn send(&mut self, prompt: impl Into<String>) -> Result<QueryResult> {
191        self.execute(QueryCommand::new(prompt)).await
192    }
193
194    /// Send a turn with a fully-configured [`QueryCommand`].
195    ///
196    /// Any session-related flags on `cmd` (`--resume`, `--continue`,
197    /// `--session-id`, `--fork-session`) are overridden with this
198    /// session's current id, so they can't conflict.
199    #[cfg(feature = "json")]
200    pub async fn execute(&mut self, cmd: QueryCommand) -> Result<QueryResult> {
201        if let Some(b) = &self.budget {
202            b.check()?;
203        }
204
205        let cmd = match &self.session_id {
206            Some(id) => cmd.replace_session(id),
207            None => cmd,
208        };
209
210        let result = cmd.execute_json(&self.claude).await?;
211        self.record(&result);
212        Ok(result)
213    }
214
215    /// Stream a plain-prompt turn, dispatching each NDJSON event to
216    /// `handler`. The session's id is captured from the first event
217    /// that carries one, so subsequent turns can resume, and the id
218    /// persists even if the stream errors partway through.
219    #[cfg(feature = "json")]
220    pub async fn stream<F>(&mut self, prompt: impl Into<String>, handler: F) -> Result<()>
221    where
222        F: FnMut(StreamEvent),
223    {
224        self.stream_execute(QueryCommand::new(prompt), handler)
225            .await
226    }
227
228    /// Stream a turn with a fully-configured [`QueryCommand`], with the
229    /// same session-id capture semantics as [`Session::stream`].
230    ///
231    /// The command's output format is forced to `stream-json` and any
232    /// session-related flags are overridden as in [`Session::execute`].
233    #[cfg(feature = "json")]
234    pub async fn stream_execute<F>(&mut self, cmd: QueryCommand, mut handler: F) -> Result<()>
235    where
236        F: FnMut(StreamEvent),
237    {
238        use crate::types::OutputFormat;
239
240        if let Some(b) = &self.budget {
241            b.check()?;
242        }
243
244        let cmd = match &self.session_id {
245            Some(id) => cmd.replace_session(id),
246            None => cmd,
247        }
248        .output_format(OutputFormat::StreamJson);
249
250        // Capture session_id and result state from events inside a
251        // wrapper closure. The captures happen before the caller's
252        // handler runs, and self is updated after the stream completes
253        // (even on error) so id persists across partial failures.
254        let mut captured_session_id: Option<String> = None;
255        let mut captured_result: Option<QueryResult> = None;
256
257        let outcome = {
258            let wrap = |event: StreamEvent| {
259                if captured_session_id.is_none()
260                    && let Some(sid) = event.session_id()
261                {
262                    captured_session_id = Some(sid.to_string());
263                }
264                if event.is_result()
265                    && captured_result.is_none()
266                    && let Ok(qr) = serde_json::from_value::<QueryResult>(event.data.clone())
267                {
268                    captured_result = Some(qr);
269                }
270                handler(event);
271            };
272            stream_query(&self.claude, &cmd, wrap).await
273        };
274
275        if let Some(sid) = captured_session_id {
276            self.observe_session_id(&sid);
277        }
278        if let Some(qr) = captured_result {
279            self.record(&qr);
280        }
281
282        outcome.map(|_| ())
283    }
284
285    /// Current session id, if one has been established.
286    pub fn id(&self) -> Option<&str> {
287        self.session_id.as_deref()
288    }
289
290    /// Sum of observed nonnegative changes in native cost totals.
291    ///
292    /// Includes the first reported total in full, including historical
293    /// spend on a resumed session. Missing totals remain unknown in
294    /// [`Session::history`]; see the [cost-accounting scope](crate::session#cost-accounting).
295    pub fn total_cost_usd(&self) -> f64 {
296        self.cumulative_cost_usd
297    }
298
299    /// Cumulative turn count across all turns in this session.
300    pub fn total_turns(&self) -> u32 {
301        self.cumulative_turns
302    }
303
304    /// Cumulative token count across all recorded turns, summing every
305    /// bucket the CLI reported (cache and non-cache input both count;
306    /// see [`TokenUsage::total`](crate::TokenUsage::total)).
307    ///
308    /// Turns whose result carried no usage contribute nothing here.
309    /// Check [`turns_missing_usage`](Self::turns_missing_usage) to tell
310    /// a genuinely low total from an incomplete one.
311    pub fn total_tokens(&self) -> u64 {
312        self.history
313            .iter()
314            .filter_map(|r| r.usage.as_ref())
315            .map(crate::TokenUsage::total)
316            .sum()
317    }
318
319    /// Number of recorded turns whose result carried no usage at all
320    /// (no `usage` object, or one with every bucket absent). A non-zero
321    /// value means [`total_tokens`](Self::total_tokens) undercounts.
322    pub fn turns_missing_usage(&self) -> usize {
323        self.history
324            .iter()
325            .filter(|r| r.usage.as_ref().is_none_or(crate::TokenUsage::is_empty))
326            .count()
327    }
328
329    /// Full per-turn result history.
330    pub fn history(&self) -> &[QueryResult] {
331        &self.history
332    }
333
334    /// Result of the most recent turn, if any.
335    pub fn last_result(&self) -> Option<&QueryResult> {
336        self.history.last()
337    }
338
339    fn observe_session_id(&mut self, session_id: &str) {
340        if session_id.is_empty() {
341            return;
342        }
343        if self
344            .last_cost_session_id
345            .as_deref()
346            .is_some_and(|previous| previous != session_id)
347        {
348            self.last_reported_total_usd = None;
349        }
350        self.last_cost_session_id = Some(session_id.to_owned());
351        self.session_id = Some(session_id.to_owned());
352    }
353
354    fn record(&mut self, result: &QueryResult) {
355        self.observe_session_id(&result.session_id);
356        let cost =
357            turn_cost_delta(&mut self.last_reported_total_usd, result.cost_usd).unwrap_or(0.0);
358        self.cumulative_cost_usd += cost;
359        self.cumulative_turns += result.num_turns.unwrap_or(0);
360        if let Some(b) = &self.budget {
361            b.record(cost);
362        }
363        self.history.push(result.clone());
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    fn test_claude() -> Arc<Claude> {
372        Arc::new(
373            Claude::builder()
374                .binary("/usr/local/bin/claude")
375                .build()
376                .unwrap(),
377        )
378    }
379
380    #[test]
381    fn new_session_has_no_id() {
382        let session = Session::new(test_claude());
383        assert!(session.id().is_none());
384        assert_eq!(session.total_cost_usd(), 0.0);
385        assert_eq!(session.total_turns(), 0);
386        assert!(session.history().is_empty());
387        assert!(session.last_result().is_none());
388    }
389
390    #[test]
391    fn resume_session_has_preset_id() {
392        let session = Session::resume(test_claude(), "sess-abc");
393        assert_eq!(session.id(), Some("sess-abc"));
394        assert_eq!(session.total_cost_usd(), 0.0);
395        assert_eq!(session.total_turns(), 0);
396    }
397
398    #[test]
399    fn record_updates_state() {
400        let mut session = Session::new(test_claude());
401        let result = QueryResult {
402            result: "ok".into(),
403            session_id: "sess-1".into(),
404            cost_usd: Some(0.05),
405            duration_ms: None,
406            num_turns: Some(3),
407            is_error: false,
408            usage: None,
409            extra: Default::default(),
410        };
411        session.record(&result);
412        assert_eq!(session.id(), Some("sess-1"));
413        assert!((session.total_cost_usd() - 0.05).abs() < f64::EPSILON);
414        assert_eq!(session.total_turns(), 3);
415        assert_eq!(session.history().len(), 1);
416        assert_eq!(
417            session.last_result().map(|r| r.session_id.as_str()),
418            Some("sess-1")
419        );
420    }
421
422    #[test]
423    fn record_accumulates_across_turns() {
424        let mut session = Session::new(test_claude());
425        let r1 = QueryResult {
426            result: "a".into(),
427            session_id: "sess-1".into(),
428            cost_usd: Some(0.01),
429            duration_ms: None,
430            num_turns: Some(2),
431            is_error: false,
432            usage: None,
433            extra: Default::default(),
434        };
435        let r2 = QueryResult {
436            result: "b".into(),
437            session_id: "sess-1".into(),
438            cost_usd: Some(0.02),
439            duration_ms: None,
440            num_turns: Some(1),
441            is_error: false,
442            usage: None,
443            extra: Default::default(),
444        };
445        session.record(&r1);
446        session.record(&r2);
447        assert_eq!(session.total_turns(), 3);
448        assert!((session.total_cost_usd() - 0.02).abs() < f64::EPSILON);
449        assert_eq!(session.history().len(), 2);
450    }
451
452    #[test]
453    fn token_accounting_sums_and_flags_missing() {
454        use crate::TokenUsage;
455
456        let mut session = Session::new(test_claude());
457        let with_usage = QueryResult {
458            result: "a".into(),
459            session_id: "sess-1".into(),
460            cost_usd: Some(0.01),
461            duration_ms: None,
462            num_turns: Some(1),
463            is_error: false,
464            usage: Some(TokenUsage {
465                input_tokens: Some(100),
466                output_tokens: Some(25),
467                ..Default::default()
468            }),
469            extra: Default::default(),
470        };
471        let without_usage = QueryResult {
472            result: "b".into(),
473            session_id: "sess-1".into(),
474            cost_usd: Some(0.01),
475            duration_ms: None,
476            num_turns: Some(1),
477            is_error: false,
478            usage: None,
479            extra: Default::default(),
480        };
481        // A usage object with every bucket absent counts as missing,
482        // not as a zero-token turn.
483        let empty_usage = QueryResult {
484            usage: Some(TokenUsage::default()),
485            ..without_usage.clone()
486        };
487
488        session.record(&with_usage);
489        session.record(&without_usage);
490        session.record(&empty_usage);
491
492        assert_eq!(session.total_tokens(), 125);
493        assert_eq!(session.turns_missing_usage(), 2);
494    }
495
496    #[test]
497    fn record_forwards_cost_to_budget() {
498        use crate::budget::BudgetTracker;
499
500        let budget = BudgetTracker::builder().build();
501        let mut session = Session::new(test_claude()).with_budget(budget.clone());
502
503        let r = QueryResult {
504            result: "ok".into(),
505            session_id: "sess-1".into(),
506            cost_usd: Some(0.07),
507            duration_ms: None,
508            num_turns: Some(1),
509            is_error: false,
510            usage: None,
511            extra: Default::default(),
512        };
513        session.record(&r);
514
515        assert!((budget.total_usd() - 0.07).abs() < 1e-9);
516        assert!((session.total_cost_usd() - 0.07).abs() < 1e-9);
517    }
518
519    fn cost_result(session_id: &str, total: Option<f64>) -> QueryResult {
520        QueryResult {
521            result: "ok".into(),
522            session_id: session_id.into(),
523            cost_usd: total,
524            duration_ms: None,
525            num_turns: Some(1),
526            is_error: false,
527            usage: None,
528            extra: Default::default(),
529        }
530    }
531
532    #[test]
533    fn record_cost_totals_count_once() {
534        let budget = BudgetTracker::builder().max_usd(0.007).build();
535        let mut session = Session::new(test_claude()).with_budget(budget.clone());
536        for total in [0.001, 0.003, 0.006] {
537            session.record(&cost_result("sess-1", Some(total)));
538        }
539        assert!((session.total_cost_usd() - 0.006).abs() < 1e-9);
540        assert!((budget.total_usd() - 0.006).abs() < 1e-9);
541        assert!(budget.check().is_ok());
542        assert_eq!(session.history()[1].cost_usd, Some(0.003));
543    }
544
545    #[test]
546    fn record_cost_missing_total_preserves_baseline() {
547        let mut session = Session::new(test_claude());
548        for total in [Some(0.002), None, Some(0.006)] {
549            session.record(&cost_result("sess-1", total));
550        }
551        assert!((session.total_cost_usd() - 0.006).abs() < 1e-9);
552        assert_eq!(session.history()[1].cost_usd, None);
553    }
554
555    #[test]
556    fn record_cost_falling_total_starts_new_baseline() {
557        let mut session = Session::new(test_claude());
558        for total in [0.004, 0.001, 0.003] {
559            session.record(&cost_result("sess-1", Some(total)));
560        }
561        assert!((session.total_cost_usd() - 0.006).abs() < 1e-9);
562    }
563
564    #[test]
565    fn record_cost_changed_session_resets_baseline_even_without_total() {
566        let budget = BudgetTracker::builder().build();
567        let mut session = Session::new(test_claude()).with_budget(budget.clone());
568        for (id, total) in [
569            ("sess-1", Some(0.004)),
570            ("sess-2", Some(0.001)),
571            ("sess-2", None),
572            ("sess-2", Some(0.003)),
573            ("sess-3", None),
574            ("sess-1", Some(0.001)),
575        ] {
576            session.record(&cost_result(id, total));
577        }
578        assert!((session.total_cost_usd() - 0.008).abs() < 1e-9);
579        assert!((budget.total_usd() - 0.008).abs() < 1e-9);
580    }
581
582    #[test]
583    fn record_cost_resumed_session_includes_initial_historical_total() {
584        let budget = BudgetTracker::builder().build();
585        let mut session =
586            Session::resume(test_claude(), "sess-existing").with_budget(budget.clone());
587        session.record(&cost_result("sess-existing", Some(0.4)));
588        session.record(&cost_result("sess-existing", Some(0.6)));
589        assert!((session.total_cost_usd() - 0.6).abs() < 1e-9);
590        assert!((budget.total_usd() - 0.6).abs() < 1e-9);
591    }
592
593    #[test]
594    fn record_cost_unchanged_error_total_counts_zero() {
595        let mut session = Session::new(test_claude());
596        session.record(&cost_result("sess-1", Some(0.002)));
597        let interrupted = QueryResult {
598            result: String::new(),
599            is_error: true,
600            ..cost_result("sess-1", Some(0.002))
601        };
602        session.record(&interrupted);
603        session.record(&cost_result("sess-1", Some(0.005)));
604        assert!((session.total_cost_usd() - 0.005).abs() < 1e-9);
605    }
606
607    #[test]
608    fn record_cost_missing_session_id_keeps_known_identity_and_baseline() {
609        let mut session = Session::new(test_claude());
610        session.record(&cost_result("sess-1", Some(0.001)));
611        session.record(&cost_result("", Some(0.003)));
612        assert_eq!(session.id(), Some("sess-1"));
613        session.record(&cost_result("sess-1", Some(0.006)));
614        assert!((session.total_cost_usd() - 0.006).abs() < 1e-9);
615
616        let mut unknown = Session::new(test_claude());
617        unknown.record(&cost_result("", Some(0.001)));
618        assert_eq!(unknown.id(), None);
619        unknown.record(&cost_result("discovered", Some(0.003)));
620        assert!((unknown.total_cost_usd() - 0.003).abs() < 1e-9);
621    }
622
623    #[test]
624    fn budget_pre_check_would_block_next_turn() {
625        use crate::budget::BudgetTracker;
626        use crate::error::Error;
627
628        // The execute() pre-check defers to BudgetTracker::check().
629        // Exercise that directly with a pre-loaded tracker, so we don't
630        // need a live Claude CLI.
631        let budget = BudgetTracker::builder().max_usd(0.10).build();
632        budget.record(0.15);
633
634        let session = Session::new(test_claude()).with_budget(budget);
635        match session.budget().unwrap().check() {
636            Err(Error::BudgetExceeded { total_usd, max_usd }) => {
637                assert!((total_usd - 0.15).abs() < 1e-9);
638                assert!((max_usd - 0.10).abs() < 1e-9);
639            }
640            other => panic!("expected BudgetExceeded, got {other:?}"),
641        }
642    }
643
644    #[test]
645    fn replace_session_clears_conflicting_flags() {
646        use crate::command::ClaudeCommand;
647
648        // Verify that replace_session strips --continue/--session-id/
649        // --fork-session and sets --resume to the given id.
650        let cmd = QueryCommand::new("hi")
651            .continue_session()
652            .session_id("old")
653            .fork_session()
654            .replace_session("new-id");
655
656        let args = cmd.args();
657        assert!(args.contains(&"--resume".to_string()));
658        assert!(args.contains(&"new-id".to_string()));
659        assert!(!args.contains(&"--continue".to_string()));
660        assert!(!args.contains(&"--session-id".to_string()));
661        assert!(!args.contains(&"--fork-session".to_string()));
662    }
663}