Skip to main content

claude_wrapper/
conversation.rs

1//! Host-side bookkeeping wrapper around [`DuplexSession`].
2//!
3//! [`Conversation`] keeps a rolling history of [`TurnResult`]s,
4//! cumulative cost, and an optional [`BudgetTracker`] hard stop on top
5//! of an underlying [`DuplexSession`]. The duplex session itself
6//! remains the transport; this wrapper only adds accounting that
7//! `DuplexSession::send` does not provide on its own.
8//!
9//! For the equivalent shape on top of transient `--resume` subprocess
10//! turns, see [`Session`](crate::session::Session). [`Conversation`]
11//! is the duplex-flavoured peer.
12//!
13//! # When to use
14//!
15//! Reach for [`Conversation`] when you already want a
16//! [`DuplexSession`] (long-running host, mid-turn interrupts, broadcast
17//! subscribers) AND want to answer questions like:
18//!
19//! - How much have I spent on this conversation so far?
20//! - What's the full history of turns and their costs?
21//! - Stop accepting new turns once I hit $5.
22//!
23//! If you do not need bookkeeping, use [`DuplexSession`] directly. If
24//! you want accounting on short-lived per-turn subprocess calls, use
25//! [`Session`](crate::session::Session) instead.
26//!
27//! # Example
28//!
29//! ```no_run
30//! use claude_wrapper::Claude;
31//! use claude_wrapper::conversation::Conversation;
32//! use claude_wrapper::duplex::{DuplexOptions, DuplexSession};
33//!
34//! # async fn example() -> claude_wrapper::Result<()> {
35//! let claude = Claude::builder().build()?;
36//! let session = DuplexSession::spawn(
37//!     &claude,
38//!     DuplexOptions::default().model("haiku"),
39//! ).await?;
40//!
41//! let mut conv = Conversation::new(session);
42//! let _first = conv.send("hello").await?;
43//! let _second = conv.send("and again").await?;
44//!
45//! println!("turns: {}", conv.total_turns());
46//! println!("cost:  ${:.4}", conv.total_cost_usd());
47//!
48//! conv.close().await?;
49//! # Ok(())
50//! # }
51//! ```
52//!
53//! # Cost accounting
54//!
55//! The CLI reports `total_cost_usd` on each turn's `result` message
56//! as the running total for the session, not the cost of that turn
57//! (see [`TurnResult::total_cost_usd`]). [`Conversation`] records the
58//! change from the previous turn's total as that turn's cost, clamped
59//! at zero, and adds it to [`Conversation::total_cost_usd`] and any
60//! attached [`BudgetTracker`]. A turn that reports no total, or the
61//! same total as the turn before, costs zero. Spend from a turn sent
62//! straight through [`Conversation::session`] lands in the next
63//! recorded turn's cost.
64//!
65//! If the [`DuplexSession`] was spawned with
66//! [`DuplexOptions::resume`](crate::duplex::DuplexOptions::resume) or
67//! [`DuplexOptions::continue_session`](crate::duplex::DuplexOptions::continue_session),
68//! the CLI restores the resumed session's total, so the first
69//! recorded turn also counts what that session spent before this
70//! process started.
71//! A falling total contributes zero and becomes the new baseline;
72//! spend during an unobserved reset cannot be recovered from that
73//! total alone.
74//!
75//! # Budget tracking
76//!
77//! Attach a [`BudgetTracker`] (the same type [`Session`](crate::session::Session)
78//! uses) to enforce a cumulative USD ceiling. The pre-turn check runs
79//! before delegating to [`DuplexSession::send`]; once the ceiling is
80//! hit, [`Conversation::send`] returns
81//! [`Error::BudgetExceeded`](crate::error::Error::BudgetExceeded)
82//! without touching the underlying session.
83//!
84//! ```no_run
85//! use claude_wrapper::{BudgetTracker, Claude};
86//! use claude_wrapper::conversation::Conversation;
87//! use claude_wrapper::duplex::{DuplexOptions, DuplexSession};
88//!
89//! # async fn example() -> claude_wrapper::Result<()> {
90//! let budget = BudgetTracker::builder().max_usd(5.00).build();
91//! let claude = Claude::builder().build()?;
92//! let session = DuplexSession::spawn(&claude, DuplexOptions::default()).await?;
93//!
94//! let mut conv = Conversation::new(session).with_budget(budget.clone());
95//! let _ = conv.send("hello").await?;
96//! println!("spent: ${:.4}", budget.total_usd());
97//! # Ok(())
98//! # }
99//! ```
100//!
101//! # Beyond bookkeeping
102//!
103//! [`Conversation::send`] is the only entry point that updates
104//! history. For [`DuplexSession::subscribe`],
105//! [`DuplexSession::interrupt`], and
106//! [`DuplexSession::respond_to_permission`], use [`Conversation::session`]
107//! to reach the inner handle. Those calls bypass the wrapper's
108//! accounting on purpose: an interrupt still produces a `TurnResult`
109//! that the in-flight [`Conversation::send`] records cleanly when the
110//! truncated turn lands.
111
112use crate::budget::BudgetTracker;
113use crate::duplex::{DuplexSession, TurnResult, turn_cost_delta};
114use crate::error::Result;
115
116/// Host-side bookkeeping over a [`DuplexSession`].
117///
118/// See the [module docs](crate::conversation) for the full design.
119#[derive(Debug)]
120pub struct Conversation {
121    inner: DuplexSession,
122    state: ConversationState,
123}
124
125/// Pure accounting state extracted so unit tests can exercise the
126/// bookkeeping logic without spawning a duplex child.
127#[derive(Debug, Default)]
128struct ConversationState {
129    history: Vec<TurnResult>,
130    cumulative_cost_usd: f64,
131    cumulative_turns: u32,
132    /// The last running total the CLI reported: the baseline for the
133    /// next turn's cost.
134    last_reported_total_usd: Option<f64>,
135    budget: Option<BudgetTracker>,
136}
137
138impl ConversationState {
139    fn record(&mut self, turn: TurnResult) {
140        let cost = turn_cost_delta(&mut self.last_reported_total_usd, turn.total_cost_usd())
141            .unwrap_or(0.0);
142        self.cumulative_cost_usd += cost;
143        self.cumulative_turns = self.cumulative_turns.saturating_add(1);
144        if let Some(b) = &self.budget {
145            b.record(cost);
146        }
147        self.history.push(turn);
148    }
149}
150
151impl Conversation {
152    /// Wrap a [`DuplexSession`] in a fresh [`Conversation`].
153    ///
154    /// The conversation starts with an empty history and zeroed
155    /// counters; the underlying session is not touched until the
156    /// first [`Conversation::send`].
157    #[must_use]
158    pub fn new(session: DuplexSession) -> Self {
159        Self {
160            inner: session,
161            state: ConversationState::default(),
162        }
163    }
164
165    /// Attach a [`BudgetTracker`] for cumulative-cost ceilings.
166    ///
167    /// Every turn's cost (the change in [`TurnResult::total_cost_usd`]
168    /// from the previous turn, see the
169    /// [module docs](crate::conversation#cost-accounting)) is
170    /// recorded on the tracker, and [`Conversation::send`] returns
171    /// [`Error::BudgetExceeded`](crate::error::Error::BudgetExceeded)
172    /// before dispatching a turn if the ceiling has been hit. Clone a
173    /// tracker across several conversations to enforce a shared
174    /// ceiling.
175    /// A resumed session's first recorded total includes historical
176    /// spend, which is also charged to this tracker. See the
177    /// [cost-accounting scope](crate::conversation#cost-accounting).
178    #[must_use]
179    pub fn with_budget(mut self, budget: BudgetTracker) -> Self {
180        self.state.budget = Some(budget);
181        self
182    }
183
184    /// The attached [`BudgetTracker`], if any.
185    #[must_use]
186    pub fn budget(&self) -> Option<&BudgetTracker> {
187        self.state.budget.as_ref()
188    }
189
190    /// Send one user message and record the resulting [`TurnResult`].
191    ///
192    /// Pre-turn: if a [`BudgetTracker`] is attached and its ceiling is
193    /// hit, returns
194    /// [`Error::BudgetExceeded`](crate::error::Error::BudgetExceeded)
195    /// without touching the underlying session.
196    ///
197    /// On success the returned reference points at the just-recorded
198    /// last entry of [`Conversation::history`]. Errors from the
199    /// underlying [`DuplexSession::send`]
200    /// (e.g. [`Error::DuplexTurnInFlight`](crate::error::Error::DuplexTurnInFlight),
201    /// [`Error::DuplexClosed`](crate::error::Error::DuplexClosed))
202    /// propagate unchanged and do not update the history or cost
203    /// counters.
204    pub async fn send(&mut self, prompt: impl Into<String>) -> Result<&TurnResult> {
205        if let Some(b) = &self.state.budget {
206            b.check()?;
207        }
208
209        let turn = self.inner.send(prompt).await?;
210        self.state.record(turn);
211        Ok(self
212            .state
213            .history
214            .last()
215            .expect("just-pushed entry must be present"))
216    }
217
218    /// Per-turn result history, in arrival order.
219    #[must_use]
220    pub fn history(&self) -> &[TurnResult] {
221        &self.state.history
222    }
223
224    /// Result of the most recent turn, if any.
225    #[must_use]
226    pub fn last(&self) -> Option<&TurnResult> {
227        self.state.history.last()
228    }
229
230    /// Cumulative cost in USD across every recorded turn: the sum of
231    /// each turn's change in the CLI's running total. See the
232    /// [module docs](crate::conversation#cost-accounting).
233    #[must_use]
234    pub fn total_cost_usd(&self) -> f64 {
235        self.state.cumulative_cost_usd
236    }
237
238    /// Number of turns recorded through [`Conversation::send`].
239    #[must_use]
240    pub fn total_turns(&self) -> u32 {
241        self.state.cumulative_turns
242    }
243
244    /// Session id from the most recent turn's `result` payload, if
245    /// any. Returns `None` until the first turn lands; later turns on
246    /// a single duplex child reuse the same id.
247    #[must_use]
248    pub fn session_id(&self) -> Option<&str> {
249        self.state.history.last().and_then(TurnResult::session_id)
250    }
251
252    /// Borrow the underlying [`DuplexSession`].
253    ///
254    /// Use this for [`DuplexSession::subscribe`],
255    /// [`DuplexSession::interrupt`], and
256    /// [`DuplexSession::respond_to_permission`]. Those calls bypass
257    /// [`Conversation`]'s bookkeeping on purpose -- an interrupt still
258    /// produces a [`TurnResult`] that the in-flight
259    /// [`Conversation::send`] records cleanly when the truncated turn
260    /// lands.
261    #[must_use]
262    pub fn session(&self) -> &DuplexSession {
263        &self.inner
264    }
265
266    /// Close the underlying [`DuplexSession`] and wait for its task to
267    /// exit. Consumes the [`Conversation`].
268    pub async fn close(self) -> Result<()> {
269        self.inner.close().await
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use serde_json::json;
277
278    /// A completed turn whose `result` reports `total` as the
279    /// session's running total.
280    fn turn(session_id: &str, total: f64) -> TurnResult {
281        TurnResult {
282            result: json!({
283                "type": "result",
284                "subtype": "success",
285                "is_error": false,
286                "result": "ok",
287                "session_id": session_id,
288                "total_cost_usd": total,
289            }),
290            events: vec![],
291        }
292    }
293
294    /// An interrupted turn, shaped like the CLI's: an error subtype,
295    /// `is_error: true`, no `result` text.
296    fn interrupted(session_id: &str, total: f64) -> TurnResult {
297        TurnResult {
298            result: json!({
299                "type": "result",
300                "subtype": "error_during_execution",
301                "is_error": true,
302                "session_id": session_id,
303                "total_cost_usd": total,
304            }),
305            events: vec![],
306        }
307    }
308
309    #[test]
310    fn record_pushes_turn_and_updates_counters() {
311        let mut state = ConversationState::default();
312        state.record(turn("sess-1", 0.05));
313
314        assert_eq!(state.history.len(), 1);
315        assert_eq!(state.cumulative_turns, 1);
316        assert!((state.cumulative_cost_usd - 0.05).abs() < 1e-9);
317        assert_eq!(state.history[0].session_id(), Some("sess-1"));
318    }
319
320    #[test]
321    fn record_takes_deltas_of_running_total() {
322        // The CLI reports a running total per result, so three turns
323        // costing 0.001, 0.002 and 0.003 report 0.001, 0.003, 0.006.
324        let budget = BudgetTracker::builder().build();
325        let mut state = ConversationState {
326            budget: Some(budget.clone()),
327            ..Default::default()
328        };
329        state.record(turn("sess-1", 0.001));
330        state.record(turn("sess-1", 0.003));
331        state.record(turn("sess-1", 0.006));
332
333        assert_eq!(state.history.len(), 3);
334        assert_eq!(state.cumulative_turns, 3);
335        assert!((state.cumulative_cost_usd - 0.006).abs() < 1e-9);
336        assert!((budget.total_usd() - 0.006).abs() < 1e-9);
337    }
338
339    #[test]
340    fn record_interrupted_turn_with_unchanged_total_costs_zero() {
341        let mut state = ConversationState::default();
342        state.record(turn("sess-1", 0.002));
343        state.record(interrupted("sess-1", 0.002));
344        assert_eq!(state.cumulative_turns, 2);
345        assert!((state.cumulative_cost_usd - 0.002).abs() < 1e-9);
346
347        state.record(turn("sess-1", 0.005));
348        assert!((state.cumulative_cost_usd - 0.005).abs() < 1e-9);
349    }
350
351    #[test]
352    fn record_turn_without_total_keeps_baseline() {
353        let mut state = ConversationState::default();
354        state.record(turn("sess-1", 0.002));
355        state.record(TurnResult {
356            result: json!({ "type": "result", "session_id": "sess-1" }),
357            events: vec![],
358        });
359        state.record(turn("sess-1", 0.004));
360
361        assert_eq!(state.cumulative_turns, 3);
362        assert!((state.cumulative_cost_usd - 0.004).abs() < 1e-9);
363    }
364
365    #[test]
366    fn record_clamps_falling_total_at_zero() {
367        // A total below the previous one costs zero and becomes the
368        // new baseline.
369        let mut state = ConversationState::default();
370        state.record(turn("sess-1", 0.004));
371        state.record(turn("sess-1", 0.001));
372        assert!((state.cumulative_cost_usd - 0.004).abs() < 1e-9);
373
374        state.record(turn("sess-1", 0.003));
375        assert!((state.cumulative_cost_usd - 0.006).abs() < 1e-9);
376    }
377
378    #[test]
379    fn record_treats_missing_cost_as_zero() {
380        let mut state = ConversationState::default();
381        let bare = TurnResult {
382            result: json!({ "type": "result", "session_id": "sess-1" }),
383            events: vec![],
384        };
385        state.record(bare);
386
387        assert_eq!(state.cumulative_turns, 1);
388        assert_eq!(state.cumulative_cost_usd, 0.0);
389    }
390
391    #[test]
392    fn record_forwards_cost_to_budget() {
393        let budget = BudgetTracker::builder().build();
394        let mut state = ConversationState {
395            budget: Some(budget.clone()),
396            ..Default::default()
397        };
398
399        state.record(turn("sess-1", 0.07));
400
401        assert!((budget.total_usd() - 0.07).abs() < 1e-9);
402        assert!((state.cumulative_cost_usd - 0.07).abs() < 1e-9);
403    }
404
405    #[test]
406    fn budget_check_blocks_before_send() {
407        use crate::error::Error;
408
409        // Conversation::send delegates pre-turn budget enforcement to
410        // BudgetTracker::check. Exercise that directly with a
411        // pre-loaded tracker so we don't need a live duplex session.
412        let budget = BudgetTracker::builder().max_usd(0.10).build();
413        budget.record(0.15);
414
415        match budget.check() {
416            Err(Error::BudgetExceeded { total_usd, max_usd }) => {
417                assert!((total_usd - 0.15).abs() < 1e-9);
418                assert!((max_usd - 0.10).abs() < 1e-9);
419            }
420            other => panic!("expected BudgetExceeded, got {other:?}"),
421        }
422    }
423
424    #[test]
425    fn last_returns_most_recent() {
426        let mut state = ConversationState::default();
427        assert!(state.history.last().is_none());
428
429        state.record(turn("sess-1", 0.01));
430        state.record(turn("sess-1", 0.02));
431        let last = state.history.last().expect("last entry present");
432        assert!((last.total_cost_usd().unwrap() - 0.02).abs() < 1e-9);
433    }
434
435    #[test]
436    fn session_id_pulled_from_last_turn() {
437        let mut state = ConversationState::default();
438        // Empty history: no session id yet.
439        assert!(
440            state
441                .history
442                .last()
443                .and_then(TurnResult::session_id)
444                .is_none()
445        );
446
447        state.record(turn("sess-A", 0.01));
448        state.record(turn("sess-B", 0.02));
449        assert_eq!(
450            state.history.last().and_then(TurnResult::session_id),
451            Some("sess-B")
452        );
453    }
454}