1use 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#[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 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 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 pub fn with_budget(mut self, budget: BudgetTracker) -> Self {
178 self.budget = Some(budget);
179 self
180 }
181
182 pub fn budget(&self) -> Option<&BudgetTracker> {
184 self.budget.as_ref()
185 }
186
187 #[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 #[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 #[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 #[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 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 pub fn id(&self) -> Option<&str> {
287 self.session_id.as_deref()
288 }
289
290 pub fn total_cost_usd(&self) -> f64 {
296 self.cumulative_cost_usd
297 }
298
299 pub fn total_turns(&self) -> u32 {
301 self.cumulative_turns
302 }
303
304 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 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 pub fn history(&self) -> &[QueryResult] {
331 &self.history
332 }
333
334 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 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 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 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}