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}