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//! # Example
43//!
44//! ```no_run
45//! use std::sync::Arc;
46//! use claude_wrapper::{Claude, QueryCommand};
47//! use claude_wrapper::session::Session;
48//!
49//! # async fn example() -> claude_wrapper::Result<()> {
50//! let claude = Arc::new(Claude::builder().build()?);
51//!
52//! let mut session = Session::new(Arc::clone(&claude));
53//!
54//! // Simple path
55//! let first = session.send("explain quicksort").await?;
56//!
57//! // Full control: custom model, effort, permission mode, etc.
58//! let second = session
59//!     .execute(QueryCommand::new("now mergesort").model("opus"))
60//!     .await?;
61//!
62//! println!("total cost: ${:.4}", session.total_cost_usd());
63//! println!("turns: {}", session.total_turns());
64//! # Ok(())
65//! # }
66//! ```
67//!
68//! # Resuming an existing session
69//!
70//! ```no_run
71//! # use std::sync::Arc;
72//! # use claude_wrapper::{Claude};
73//! # use claude_wrapper::session::Session;
74//! # async fn example() -> claude_wrapper::Result<()> {
75//! # let claude = Arc::new(Claude::builder().build()?);
76//! // Reattach to a session you stored earlier
77//! let mut session = Session::resume(claude, "sess-abc123");
78//! let result = session.send("pick up where we left off").await?;
79//! # Ok(())
80//! # }
81//! ```
82
83use std::sync::Arc;
84
85use crate::Claude;
86use crate::budget::BudgetTracker;
87use crate::command::query::QueryCommand;
88use crate::error::Result;
89use crate::types::QueryResult;
90
91#[cfg(feature = "json")]
92use crate::streaming::{StreamEvent, stream_query};
93
94/// A multi-turn conversation handle.
95///
96/// Owns an `Arc<Claude>` so it can be moved between tasks and live
97/// inside long-running actors. Tracks `session_id`, cumulative cost,
98/// turn count, and per-turn result history.
99#[derive(Debug, Clone)]
100pub struct Session {
101    claude: Arc<Claude>,
102    session_id: Option<String>,
103    history: Vec<QueryResult>,
104    cumulative_cost_usd: f64,
105    cumulative_turns: u32,
106    budget: Option<BudgetTracker>,
107}
108
109impl Session {
110    /// Start a fresh session. The first turn will discover a session id
111    /// from its result; subsequent turns reuse it via `--resume`.
112    pub fn new(claude: Arc<Claude>) -> Self {
113        Self {
114            claude,
115            session_id: None,
116            history: Vec::new(),
117            cumulative_cost_usd: 0.0,
118            cumulative_turns: 0,
119            budget: None,
120        }
121    }
122
123    /// Reattach to an existing session by id. The next turn immediately
124    /// passes `--resume <id>`. Cost and turn counters start at zero
125    /// since no history is available.
126    pub fn resume(claude: Arc<Claude>, session_id: impl Into<String>) -> Self {
127        Self {
128            claude,
129            session_id: Some(session_id.into()),
130            history: Vec::new(),
131            cumulative_cost_usd: 0.0,
132            cumulative_turns: 0,
133            budget: None,
134        }
135    }
136
137    /// Attach a [`BudgetTracker`] to this session. Every turn's cost
138    /// (from [`QueryResult::cost_usd`]) is recorded on the tracker, and
139    /// [`Session::execute`]/[`Session::stream_execute`] return
140    /// [`crate::error::Error::BudgetExceeded`]
141    /// before dispatching a turn if the tracker's ceiling has been hit.
142    ///
143    /// Clone a tracker across several sessions to enforce a shared
144    /// ceiling; each `Session` then sees the same running total.
145    pub fn with_budget(mut self, budget: BudgetTracker) -> Self {
146        self.budget = Some(budget);
147        self
148    }
149
150    /// The attached [`BudgetTracker`], if any.
151    pub fn budget(&self) -> Option<&BudgetTracker> {
152        self.budget.as_ref()
153    }
154
155    /// Send a plain-prompt turn. Equivalent to
156    /// `execute(QueryCommand::new(prompt))`.
157    #[cfg(feature = "json")]
158    pub async fn send(&mut self, prompt: impl Into<String>) -> Result<QueryResult> {
159        self.execute(QueryCommand::new(prompt)).await
160    }
161
162    /// Send a turn with a fully-configured [`QueryCommand`].
163    ///
164    /// Any session-related flags on `cmd` (`--resume`, `--continue`,
165    /// `--session-id`, `--fork-session`) are overridden with this
166    /// session's current id, so they can't conflict.
167    #[cfg(feature = "json")]
168    pub async fn execute(&mut self, cmd: QueryCommand) -> Result<QueryResult> {
169        if let Some(b) = &self.budget {
170            b.check()?;
171        }
172
173        let cmd = match &self.session_id {
174            Some(id) => cmd.replace_session(id),
175            None => cmd,
176        };
177
178        let result = cmd.execute_json(&self.claude).await?;
179        self.record(&result);
180        Ok(result)
181    }
182
183    /// Stream a plain-prompt turn, dispatching each NDJSON event to
184    /// `handler`. The session's id is captured from the first event
185    /// that carries one, so subsequent turns can resume, and the id
186    /// persists even if the stream errors partway through.
187    #[cfg(feature = "json")]
188    pub async fn stream<F>(&mut self, prompt: impl Into<String>, handler: F) -> Result<()>
189    where
190        F: FnMut(StreamEvent),
191    {
192        self.stream_execute(QueryCommand::new(prompt), handler)
193            .await
194    }
195
196    /// Stream a turn with a fully-configured [`QueryCommand`], with the
197    /// same session-id capture semantics as [`Session::stream`].
198    ///
199    /// The command's output format is forced to `stream-json` and any
200    /// session-related flags are overridden as in [`Session::execute`].
201    #[cfg(feature = "json")]
202    pub async fn stream_execute<F>(&mut self, cmd: QueryCommand, mut handler: F) -> Result<()>
203    where
204        F: FnMut(StreamEvent),
205    {
206        use crate::types::OutputFormat;
207
208        if let Some(b) = &self.budget {
209            b.check()?;
210        }
211
212        let cmd = match &self.session_id {
213            Some(id) => cmd.replace_session(id),
214            None => cmd,
215        }
216        .output_format(OutputFormat::StreamJson);
217
218        // Capture session_id and result state from events inside a
219        // wrapper closure. The captures happen before the caller's
220        // handler runs, and self is updated after the stream completes
221        // (even on error) so id persists across partial failures.
222        let mut captured_session_id: Option<String> = None;
223        let mut captured_result: Option<QueryResult> = None;
224
225        let outcome = {
226            let wrap = |event: StreamEvent| {
227                if captured_session_id.is_none()
228                    && let Some(sid) = event.session_id()
229                {
230                    captured_session_id = Some(sid.to_string());
231                }
232                if event.is_result()
233                    && captured_result.is_none()
234                    && let Ok(qr) = serde_json::from_value::<QueryResult>(event.data.clone())
235                {
236                    captured_result = Some(qr);
237                }
238                handler(event);
239            };
240            stream_query(&self.claude, &cmd, wrap).await
241        };
242
243        if let Some(sid) = captured_session_id {
244            self.session_id = Some(sid);
245        }
246        if let Some(qr) = captured_result {
247            self.record(&qr);
248        }
249
250        outcome.map(|_| ())
251    }
252
253    /// Current session id, if one has been established.
254    pub fn id(&self) -> Option<&str> {
255        self.session_id.as_deref()
256    }
257
258    /// Cumulative cost in USD across all turns in this session.
259    pub fn total_cost_usd(&self) -> f64 {
260        self.cumulative_cost_usd
261    }
262
263    /// Cumulative turn count across all turns in this session.
264    pub fn total_turns(&self) -> u32 {
265        self.cumulative_turns
266    }
267
268    /// Cumulative token count across all recorded turns, summing every
269    /// bucket the CLI reported (cache and non-cache input both count;
270    /// see [`TokenUsage::total`](crate::TokenUsage::total)).
271    ///
272    /// Turns whose result carried no usage contribute nothing here.
273    /// Check [`turns_missing_usage`](Self::turns_missing_usage) to tell
274    /// a genuinely low total from an incomplete one.
275    pub fn total_tokens(&self) -> u64 {
276        self.history
277            .iter()
278            .filter_map(|r| r.usage.as_ref())
279            .map(crate::TokenUsage::total)
280            .sum()
281    }
282
283    /// Number of recorded turns whose result carried no usage at all
284    /// (no `usage` object, or one with every bucket absent). A non-zero
285    /// value means [`total_tokens`](Self::total_tokens) undercounts.
286    pub fn turns_missing_usage(&self) -> usize {
287        self.history
288            .iter()
289            .filter(|r| r.usage.as_ref().is_none_or(crate::TokenUsage::is_empty))
290            .count()
291    }
292
293    /// Full per-turn result history.
294    pub fn history(&self) -> &[QueryResult] {
295        &self.history
296    }
297
298    /// Result of the most recent turn, if any.
299    pub fn last_result(&self) -> Option<&QueryResult> {
300        self.history.last()
301    }
302
303    fn record(&mut self, result: &QueryResult) {
304        self.session_id = Some(result.session_id.clone());
305        let cost = result.cost_usd.unwrap_or(0.0);
306        self.cumulative_cost_usd += cost;
307        self.cumulative_turns += result.num_turns.unwrap_or(0);
308        if let Some(b) = &self.budget {
309            b.record(cost);
310        }
311        self.history.push(result.clone());
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    fn test_claude() -> Arc<Claude> {
320        Arc::new(
321            Claude::builder()
322                .binary("/usr/local/bin/claude")
323                .build()
324                .unwrap(),
325        )
326    }
327
328    #[test]
329    fn new_session_has_no_id() {
330        let session = Session::new(test_claude());
331        assert!(session.id().is_none());
332        assert_eq!(session.total_cost_usd(), 0.0);
333        assert_eq!(session.total_turns(), 0);
334        assert!(session.history().is_empty());
335        assert!(session.last_result().is_none());
336    }
337
338    #[test]
339    fn resume_session_has_preset_id() {
340        let session = Session::resume(test_claude(), "sess-abc");
341        assert_eq!(session.id(), Some("sess-abc"));
342        assert_eq!(session.total_cost_usd(), 0.0);
343        assert_eq!(session.total_turns(), 0);
344    }
345
346    #[test]
347    fn record_updates_state() {
348        let mut session = Session::new(test_claude());
349        let result = QueryResult {
350            result: "ok".into(),
351            session_id: "sess-1".into(),
352            cost_usd: Some(0.05),
353            duration_ms: None,
354            num_turns: Some(3),
355            is_error: false,
356            usage: None,
357            extra: Default::default(),
358        };
359        session.record(&result);
360        assert_eq!(session.id(), Some("sess-1"));
361        assert!((session.total_cost_usd() - 0.05).abs() < f64::EPSILON);
362        assert_eq!(session.total_turns(), 3);
363        assert_eq!(session.history().len(), 1);
364        assert_eq!(
365            session.last_result().map(|r| r.session_id.as_str()),
366            Some("sess-1")
367        );
368    }
369
370    #[test]
371    fn record_accumulates_across_turns() {
372        let mut session = Session::new(test_claude());
373        let r1 = QueryResult {
374            result: "a".into(),
375            session_id: "sess-1".into(),
376            cost_usd: Some(0.01),
377            duration_ms: None,
378            num_turns: Some(2),
379            is_error: false,
380            usage: None,
381            extra: Default::default(),
382        };
383        let r2 = QueryResult {
384            result: "b".into(),
385            session_id: "sess-1".into(),
386            cost_usd: Some(0.02),
387            duration_ms: None,
388            num_turns: Some(1),
389            is_error: false,
390            usage: None,
391            extra: Default::default(),
392        };
393        session.record(&r1);
394        session.record(&r2);
395        assert_eq!(session.total_turns(), 3);
396        assert!((session.total_cost_usd() - 0.03).abs() < f64::EPSILON);
397        assert_eq!(session.history().len(), 2);
398    }
399
400    #[test]
401    fn token_accounting_sums_and_flags_missing() {
402        use crate::TokenUsage;
403
404        let mut session = Session::new(test_claude());
405        let with_usage = QueryResult {
406            result: "a".into(),
407            session_id: "sess-1".into(),
408            cost_usd: Some(0.01),
409            duration_ms: None,
410            num_turns: Some(1),
411            is_error: false,
412            usage: Some(TokenUsage {
413                input_tokens: Some(100),
414                output_tokens: Some(25),
415                ..Default::default()
416            }),
417            extra: Default::default(),
418        };
419        let without_usage = QueryResult {
420            result: "b".into(),
421            session_id: "sess-1".into(),
422            cost_usd: Some(0.01),
423            duration_ms: None,
424            num_turns: Some(1),
425            is_error: false,
426            usage: None,
427            extra: Default::default(),
428        };
429        // A usage object with every bucket absent counts as missing,
430        // not as a zero-token turn.
431        let empty_usage = QueryResult {
432            usage: Some(TokenUsage::default()),
433            ..without_usage.clone()
434        };
435
436        session.record(&with_usage);
437        session.record(&without_usage);
438        session.record(&empty_usage);
439
440        assert_eq!(session.total_tokens(), 125);
441        assert_eq!(session.turns_missing_usage(), 2);
442    }
443
444    #[test]
445    fn record_forwards_cost_to_budget() {
446        use crate::budget::BudgetTracker;
447
448        let budget = BudgetTracker::builder().build();
449        let mut session = Session::new(test_claude()).with_budget(budget.clone());
450
451        let r = QueryResult {
452            result: "ok".into(),
453            session_id: "sess-1".into(),
454            cost_usd: Some(0.07),
455            duration_ms: None,
456            num_turns: Some(1),
457            is_error: false,
458            usage: None,
459            extra: Default::default(),
460        };
461        session.record(&r);
462
463        assert!((budget.total_usd() - 0.07).abs() < 1e-9);
464        assert!((session.total_cost_usd() - 0.07).abs() < 1e-9);
465    }
466
467    #[test]
468    fn budget_pre_check_would_block_next_turn() {
469        use crate::budget::BudgetTracker;
470        use crate::error::Error;
471
472        // The execute() pre-check defers to BudgetTracker::check().
473        // Exercise that directly with a pre-loaded tracker, so we don't
474        // need a live Claude CLI.
475        let budget = BudgetTracker::builder().max_usd(0.10).build();
476        budget.record(0.15);
477
478        let session = Session::new(test_claude()).with_budget(budget);
479        match session.budget().unwrap().check() {
480            Err(Error::BudgetExceeded { total_usd, max_usd }) => {
481                assert!((total_usd - 0.15).abs() < 1e-9);
482                assert!((max_usd - 0.10).abs() < 1e-9);
483            }
484            other => panic!("expected BudgetExceeded, got {other:?}"),
485        }
486    }
487
488    #[test]
489    fn replace_session_clears_conflicting_flags() {
490        use crate::command::ClaudeCommand;
491
492        // Verify that replace_session strips --continue/--session-id/
493        // --fork-session and sets --resume to the given id.
494        let cmd = QueryCommand::new("hi")
495            .continue_session()
496            .session_id("old")
497            .fork_session()
498            .replace_session("new-id");
499
500        let args = cmd.args();
501        assert!(args.contains(&"--resume".to_string()));
502        assert!(args.contains(&"new-id".to_string()));
503        assert!(!args.contains(&"--continue".to_string()));
504        assert!(!args.contains(&"--session-id".to_string()));
505        assert!(!args.contains(&"--fork-session".to_string()));
506    }
507}