diff --git a/Cargo.lock b/Cargo.lock index c2816831..d19a0716 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -509,7 +509,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli-sub-agent" -version = "0.1.732" +version = "0.1.733" dependencies = [ "anyhow", "chrono", @@ -701,7 +701,7 @@ dependencies = [ [[package]] name = "csa-acp" -version = "0.1.732" +version = "0.1.733" dependencies = [ "agent-client-protocol", "anyhow", @@ -721,7 +721,7 @@ dependencies = [ [[package]] name = "csa-config" -version = "0.1.732" +version = "0.1.733" dependencies = [ "anyhow", "chrono", @@ -739,7 +739,7 @@ dependencies = [ [[package]] name = "csa-core" -version = "0.1.732" +version = "0.1.733" dependencies = [ "agent-teams", "chrono", @@ -755,7 +755,7 @@ dependencies = [ [[package]] name = "csa-eval" -version = "0.1.732" +version = "0.1.733" dependencies = [ "anyhow", "chrono", @@ -769,7 +769,7 @@ dependencies = [ [[package]] name = "csa-executor" -version = "0.1.732" +version = "0.1.733" dependencies = [ "agent-teams", "anyhow", @@ -797,7 +797,7 @@ dependencies = [ [[package]] name = "csa-hooks" -version = "0.1.732" +version = "0.1.733" dependencies = [ "anyhow", "chrono", @@ -816,7 +816,7 @@ dependencies = [ [[package]] name = "csa-lock" -version = "0.1.732" +version = "0.1.733" dependencies = [ "anyhow", "chrono", @@ -830,7 +830,7 @@ dependencies = [ [[package]] name = "csa-mcp-hub" -version = "0.1.732" +version = "0.1.733" dependencies = [ "anyhow", "axum", @@ -853,7 +853,7 @@ dependencies = [ [[package]] name = "csa-memory" -version = "0.1.732" +version = "0.1.733" dependencies = [ "anyhow", "async-trait", @@ -872,7 +872,7 @@ dependencies = [ [[package]] name = "csa-process" -version = "0.1.732" +version = "0.1.733" dependencies = [ "anyhow", "chrono", @@ -891,7 +891,7 @@ dependencies = [ [[package]] name = "csa-resource" -version = "0.1.732" +version = "0.1.733" dependencies = [ "anyhow", "csa-core", @@ -907,7 +907,7 @@ dependencies = [ [[package]] name = "csa-scheduler" -version = "0.1.732" +version = "0.1.733" dependencies = [ "anyhow", "chrono", @@ -925,7 +925,7 @@ dependencies = [ [[package]] name = "csa-session" -version = "0.1.732" +version = "0.1.733" dependencies = [ "anyhow", "chrono", @@ -946,11 +946,12 @@ dependencies = [ "tracing-subscriber", "trybuild", "ulid", + "xurl-core", ] [[package]] name = "csa-todo" -version = "0.1.732" +version = "0.1.733" dependencies = [ "anyhow", "chrono", @@ -4515,7 +4516,7 @@ dependencies = [ [[package]] name = "weave" -version = "0.1.732" +version = "0.1.733" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 44d55867..f942c814 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ default-members = [ resolver = "2" [workspace.package] -version = "0.1.732" +version = "0.1.733" edition = "2024" rust-version = "1.88" license = "Apache-2.0" diff --git a/crates/cli-sub-agent/src/cli.rs b/crates/cli-sub-agent/src/cli.rs index bf1edf85..3c78408c 100644 --- a/crates/cli-sub-agent/src/cli.rs +++ b/crates/cli-sub-agent/src/cli.rs @@ -96,17 +96,22 @@ pub enum Commands { #[arg(long, value_name = "SESSION_ID", value_parser = validate_ulid)] inline_context_from_review_session: Option, /// Resume existing session (ULID or prefix match) [DEPRECATED: use --fork-from] - #[arg(short, long, conflicts_with_all = ["last", "fork_from", "fork_last"])] + #[arg(short, long, conflicts_with_all = ["last", "fork_from", "fork_last", "fork_from_caller"])] session: Option, /// Resume the most recent session for this project [DEPRECATED: use --fork-last] - #[arg(long, conflicts_with_all = ["session", "ephemeral", "fork_from", "fork_last"])] + #[arg(long, conflicts_with_all = ["session", "ephemeral", "fork_from", "fork_last", "fork_from_caller"])] last: bool, /// Fork from a specific session (ULID or prefix match) - #[arg(long, conflicts_with_all = ["session", "last", "fork_last", "ephemeral"])] + #[arg(long, conflicts_with_all = ["session", "last", "fork_last", "fork_from_caller", "ephemeral"])] fork_from: Option, /// Fork the most recent session for this project - #[arg(long, conflicts_with_all = ["session", "last", "fork_from", "ephemeral"])] + #[arg(long, conflicts_with_all = ["session", "last", "fork_from", "fork_from_caller", "ephemeral"])] fork_last: bool, + /// Fork from the auto-detected caller's Claude conversation (CSA-lite, #1432). + /// Reads `CLAUDE_SESSION_ID` or the most recent Claude thread on disk, + /// extracts a budgeted conversation prefix, and seeds the new session with it. + #[arg(long, conflicts_with_all = ["session", "last", "fork_from", "fork_last", "ephemeral"])] + fork_from_caller: bool, /// Human-readable description for a new session #[arg(short, long)] description: Option, diff --git a/crates/cli-sub-agent/src/goal_loop.rs b/crates/cli-sub-agent/src/goal_loop.rs index e40e5dff..fd10ea3e 100644 --- a/crates/cli-sub-agent/src/goal_loop.rs +++ b/crates/cli-sub-agent/src/goal_loop.rs @@ -75,6 +75,7 @@ pub(crate) struct GoalRunRequest { pub(crate) last: bool, pub(crate) fork_from: Option, pub(crate) fork_last: bool, + pub(crate) fork_from_caller: bool, pub(crate) description: Option, pub(crate) fork_call: bool, pub(crate) return_to: Option, @@ -138,6 +139,7 @@ pub(crate) async fn handle_run_or_goal(request: GoalRunRequest) -> Result { request.last, request.fork_from, request.fork_last, + request.fork_from_caller, request.description, request.fork_call, request.return_to, @@ -293,6 +295,7 @@ async fn run_goal_iteration( first_iteration && request.last, first_iteration.then(|| request.fork_from.clone()).flatten(), first_iteration && request.fork_last, + first_iteration && request.fork_from_caller, request.description.clone(), request.fork_call, request.return_to.clone(), diff --git a/crates/cli-sub-agent/src/main.rs b/crates/cli-sub-agent/src/main.rs index f12511ae..ec52bed2 100644 --- a/crates/cli-sub-agent/src/main.rs +++ b/crates/cli-sub-agent/src/main.rs @@ -68,6 +68,7 @@ mod review_prior_rounds; mod review_routing; mod review_session_findings; mod run_cmd; +mod run_cmd_caller_fork; mod run_cmd_daemon; mod run_cmd_fork; mod run_cmd_post; @@ -80,6 +81,7 @@ mod self_update; mod session_cmds; mod session_cmds_daemon; mod session_cmds_result; +mod session_cmds_result_measure; mod session_dispatch; mod session_guard; mod session_observability; @@ -243,10 +245,8 @@ async fn run() -> Result<()> { }); if auto_upgrade { - use std::time::Duration; - let mut success = false; - let mut delay = Duration::from_secs(1); + let mut delay = std::time::Duration::from_secs(1); for attempt in 0..3 { let result = tokio::process::Command::new("weave") @@ -281,14 +281,6 @@ async fn run() -> Result<()> { let legacy_xdg_paths = csa_config::paths::legacy_paths_requiring_migration(); if !legacy_xdg_paths.is_empty() { - for path in &legacy_xdg_paths { - tracing::debug!( - label = path.label, - legacy = %path.legacy_path.display(), - new = %path.new_path.display(), - "legacy XDG path detected, auto-migrating" - ); - } match csa_config::migrate::run_xdg_migration() { Ok(()) => { tracing::debug!( @@ -320,6 +312,7 @@ async fn run() -> Result<()> { last, fork_from, fork_last, + fork_from_caller, description, fork_call, return_to, @@ -404,6 +397,7 @@ async fn run() -> Result<()> { last, fork_from, fork_last, + fork_from_caller, description, fork_call, return_to, diff --git a/crates/cli-sub-agent/src/pipeline_post_exec.rs b/crates/cli-sub-agent/src/pipeline_post_exec.rs index ad07408c..27c3d987 100644 --- a/crates/cli-sub-agent/src/pipeline_post_exec.rs +++ b/crates/cli-sub-agent/src/pipeline_post_exec.rs @@ -691,6 +691,12 @@ fn update_cumulative_tokens(session: &mut MetaSessionState, token_usage: Option< cumulative.estimated_cost_usd = Some( cumulative.estimated_cost_usd.unwrap_or(0.0) + new_usage.estimated_cost_usd.unwrap_or(0.0), ); + // Accumulate cache-read tokens only when the new usage reports a value; + // missing fields must not zero out a previously recorded total. + if let Some(new_cache_read) = new_usage.cache_read_input_tokens { + cumulative.cache_read_input_tokens = + Some(cumulative.cache_read_input_tokens.unwrap_or(0) + new_cache_read); + } // Update token budget tracking if let Some(ref mut budget) = session.token_budget { diff --git a/crates/cli-sub-agent/src/run_cmd.rs b/crates/cli-sub-agent/src/run_cmd.rs index cd51d788..a6e733ad 100644 --- a/crates/cli-sub-agent/src/run_cmd.rs +++ b/crates/cli-sub-agent/src/run_cmd.rs @@ -114,6 +114,7 @@ impl SubagentRunConfig { false, None, false, + false, None, false, None, diff --git a/crates/cli-sub-agent/src/run_cmd_attempt.rs b/crates/cli-sub-agent/src/run_cmd_attempt.rs index 1db18cdb..802caa5f 100644 --- a/crates/cli-sub-agent/src/run_cmd_attempt.rs +++ b/crates/cli-sub-agent/src/run_cmd_attempt.rs @@ -81,7 +81,7 @@ pub(crate) async fn execute_run_loop(request: RunLoopRequest<'_>) -> Result = None; let mut pre_created_fork_session_id: Option = None; - let mut fork_resolution: Option = None; + let mut fork_resolution: Option = request.caller_fork_resolution; let mut is_fork = request.is_fork; let mut failover_context_addendum: Option = None; let mut is_auto_seed_fork = request.is_auto_seed_fork; diff --git a/crates/cli-sub-agent/src/run_cmd_attempt_types.rs b/crates/cli-sub-agent/src/run_cmd_attempt_types.rs index c109415f..380dc680 100644 --- a/crates/cli-sub-agent/src/run_cmd_attempt_types.rs +++ b/crates/cli-sub-agent/src/run_cmd_attempt_types.rs @@ -44,6 +44,11 @@ pub(crate) struct RunLoopRequest<'a> { pub(crate) run_started_at: Instant, pub(crate) is_fork: bool, pub(crate) is_auto_seed_fork: bool, + /// Pre-resolved fork from `--fork-from-caller` (CSA-lite, #1432). + /// When present, supplies the initial fork_resolution before the loop + /// runs `resolve_fork()`; downstream prepend-to-prompt path picks up + /// the extracted caller conversation prefix. + pub(crate) caller_fork_resolution: Option, pub(crate) ephemeral: bool, pub(crate) fork_call: bool, pub(crate) session_arg: Option, diff --git a/crates/cli-sub-agent/src/run_cmd_caller_fork.rs b/crates/cli-sub-agent/src/run_cmd_caller_fork.rs new file mode 100644 index 00000000..876bea1b --- /dev/null +++ b/crates/cli-sub-agent/src/run_cmd_caller_fork.rs @@ -0,0 +1,160 @@ +//! `--fork-from-caller` resolution for CSA-lite Phase 1 (issue #1432). +//! +//! Detects the caller's Claude session via [`csa_session::detect_caller_session`], +//! extracts a token-budgeted conversation prefix via [`csa_acp::PrefixExtractor`] +//! (when the `acp` feature is enabled), and returns a [`ForkResolution`] whose +//! `context_prefix` carries the extracted text. The result flows into +//! `execute_run_loop` as the initial fork resolution so the existing +//! prepend-to-prompt path injects the caller's history. +//! +//! Graceful degradation: if detection or extraction fails, this returns +//! `None` and emits a `tracing::warn!`. The caller-side `handle_run` +//! continues with a normal cold start. + +use csa_config::ProjectConfig; +#[cfg(feature = "acp")] +use tracing::info; +use tracing::warn; + +use crate::run_cmd_fork::ForkResolution; + +/// Resolve `--fork-from-caller` into an optional [`ForkResolution`]. +/// +/// Returns `None` when no caller session can be detected, when prefix +/// extraction is unavailable (no `acp` feature), or when extraction fails. +pub(crate) fn resolve_fork_from_caller(config: Option<&ProjectConfig>) -> Option { + let caller = csa_session::detect_caller_session()?; + let budget = config + .map(|c| c.session.resolved_fork_prefix_budget()) + .unwrap_or(csa_config::DEFAULT_FORK_PREFIX_BUDGET_TOKENS); + + extract_caller_prefix(&caller, budget) +} + +#[cfg(feature = "acp")] +fn extract_caller_prefix( + caller: &csa_session::CallerSessionInfo, + budget: u32, +) -> Option { + let config = csa_acp::PrefixConfig { + budget_tokens: budget as usize, + skip_tool_results: true, + }; + let extractor = csa_acp::PrefixExtractor::new(config); + match extractor.extract_prefix(&caller.jsonl_path) { + Ok(prefix) => { + info!( + caller_session = %caller.session_id, + tokens = prefix.token_count, + messages = prefix.message_count, + truncated = prefix.truncated, + budget, + "caller fork: extracted conversation prefix" + ); + Some(ForkResolution { + provider_session_id: None, + context_prefix: Some(prefix.content), + source_session_id: caller.session_id.clone(), + source_provider_session_id: Some(caller.session_id.clone()), + }) + } + Err(err) => { + warn!( + caller_session = %caller.session_id, + jsonl = %caller.jsonl_path.display(), + error = %err, + "caller fork: prefix extraction failed; falling back to cold start" + ); + None + } + } +} + +#[cfg(not(feature = "acp"))] +fn extract_caller_prefix( + caller: &csa_session::CallerSessionInfo, + _budget: u32, +) -> Option { + warn!( + caller_session = %caller.session_id, + "caller fork: prefix extraction requires the `acp` feature; falling back to cold start" + ); + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_env_lock::{ScopedEnvVarRestore, TEST_ENV_LOCK}; + use csa_session::CallerSessionInfo; + use std::path::PathBuf; + + fn fake_caller(jsonl_path: PathBuf) -> CallerSessionInfo { + CallerSessionInfo { + session_id: "11111111-2222-3333-4444-555555555555".to_string(), + session_dir: jsonl_path.parent().unwrap().to_path_buf(), + jsonl_path, + provider: "claude".to_string(), + } + } + + #[cfg(feature = "acp")] + #[test] + fn extract_caller_prefix_returns_resolution_for_valid_jsonl() { + let tmp = tempfile::tempdir().expect("tempdir"); + let jsonl = tmp.path().join("session.jsonl"); + std::fs::write( + &jsonl, + r#"{"type":"user","message":{"role":"user","content":"hello caller"}} +"#, + ) + .expect("write fixture"); + + let caller = fake_caller(jsonl); + let resolution = extract_caller_prefix(&caller, 32_768) + .expect("extraction should succeed for valid JSONL"); + assert_eq!(resolution.source_session_id, caller.session_id); + assert_eq!( + resolution.source_provider_session_id.as_deref(), + Some(caller.session_id.as_str()) + ); + assert!(resolution.provider_session_id.is_none()); + let prefix = resolution.context_prefix.expect("context_prefix populated"); + assert!(prefix.contains("hello caller")); + } + + #[cfg(feature = "acp")] + #[test] + fn extract_caller_prefix_returns_none_for_missing_jsonl() { + let caller = fake_caller(PathBuf::from("/nonexistent/csa-test-caller-fork.jsonl")); + assert!(extract_caller_prefix(&caller, 32_768).is_none()); + } + + #[cfg(not(feature = "acp"))] + #[test] + fn extract_caller_prefix_returns_none_without_acp_feature() { + let tmp = tempfile::tempdir().expect("tempdir"); + let jsonl = tmp.path().join("session.jsonl"); + std::fs::write(&jsonl, "{}\n").expect("write fixture"); + let caller = fake_caller(jsonl); + assert!(extract_caller_prefix(&caller, 32_768).is_none()); + } + + /// Integration: when `CLAUDE_SESSION_ID` points at a non-existent + /// session, `resolve_fork_from_caller` must degrade gracefully + /// (return None) rather than propagating an error. + #[test] + fn resolve_fork_from_caller_returns_none_when_session_missing() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join("projects")).expect("mkdir projects"); + let claude_root = tmp.path().to_string_lossy().to_string(); + + // Hold the process-wide env lock for both var mutations. + let _lock = TEST_ENV_LOCK.clone().blocking_lock_owned(); + let _claude_root_guard = ScopedEnvVarRestore::set("CLAUDE_CONFIG_DIR", claude_root); + let _session_guard = + ScopedEnvVarRestore::set("CLAUDE_SESSION_ID", "deadbeef-0000-0000-0000-000000000000"); + + assert!(resolve_fork_from_caller(None).is_none()); + } +} diff --git a/crates/cli-sub-agent/src/run_cmd_execute.rs b/crates/cli-sub-agent/src/run_cmd_execute.rs index 105f6e09..0b8a9cff 100644 --- a/crates/cli-sub-agent/src/run_cmd_execute.rs +++ b/crates/cli-sub-agent/src/run_cmd_execute.rs @@ -70,6 +70,7 @@ pub(crate) async fn handle_run( last: bool, fork_from: Option, fork_last: bool, + fork_from_caller: bool, description: Option, fork_call: bool, return_to: Option, @@ -182,6 +183,15 @@ pub(crate) async fn handle_run( else { return Ok(1); }; + let caller_fork_resolution = if fork_from_caller { + let resolved = crate::run_cmd_caller_fork::resolve_fork_from_caller(config.as_ref()); + if resolved.is_none() { + warn!("--fork-from-caller: no caller session resolved; falling back to cold start"); + } + resolved + } else { + None + }; let branch_guard = crate::run_helpers_branch_guard::BranchGuardRuntime::for_run( allow_base_branch_working, config.as_ref(), @@ -531,6 +541,7 @@ pub(crate) async fn handle_run( run_started_at, is_fork, is_auto_seed_fork, + caller_fork_resolution, ephemeral, fork_call, session_arg, diff --git a/crates/cli-sub-agent/src/run_cmd_execute_pre_exec_tests.rs b/crates/cli-sub-agent/src/run_cmd_execute_pre_exec_tests.rs index ef3d0938..cbcf7382 100644 --- a/crates/cli-sub-agent/src/run_cmd_execute_pre_exec_tests.rs +++ b/crates/cli-sub-agent/src/run_cmd_execute_pre_exec_tests.rs @@ -98,6 +98,7 @@ async fn handle_run_persists_result_for_model_spec_tier_conflict() { false, None, false, + false, None, false, None, @@ -183,6 +184,7 @@ async fn handle_run_does_not_persist_result_for_non_conflict_pre_exec_error() { false, None, false, + false, None, false, None, diff --git a/crates/cli-sub-agent/src/run_cmd_tests_core.rs b/crates/cli-sub-agent/src/run_cmd_tests_core.rs index 0ec190ef..a0cf9027 100644 --- a/crates/cli-sub-agent/src/run_cmd_tests_core.rs +++ b/crates/cli-sub-agent/src/run_cmd_tests_core.rs @@ -192,6 +192,117 @@ fn test_cli_fork_from_conflicts_with_ephemeral() { assert!(result.is_err(), "fork-from and ephemeral should conflict"); } +#[test] +fn test_cli_fork_from_caller_parses() { + let cli = try_parse_cli(&["csa", "run", "--fork-from-caller", "do stuff"]).unwrap(); + match cli.command { + crate::cli::Commands::Run { + fork_from_caller, .. + } => { + assert!(fork_from_caller); + } + _ => panic!("expected Run command"), + } +} + +#[test] +fn test_cli_fork_from_caller_default_false() { + let cli = try_parse_cli(&["csa", "run", "do stuff"]).unwrap(); + match cli.command { + crate::cli::Commands::Run { + fork_from_caller, .. + } => { + assert!(!fork_from_caller); + } + _ => panic!("expected Run command"), + } +} + +#[test] +fn test_cli_fork_from_caller_conflicts_with_fork_from() { + let result = try_parse_cli(&[ + "csa", + "run", + "--fork-from-caller", + "--fork-from", + "01ABC", + "prompt", + ]); + assert!( + result.is_err(), + "fork-from-caller and fork-from should conflict" + ); +} + +#[test] +fn test_cli_fork_from_caller_conflicts_with_fork_last() { + let result = try_parse_cli(&[ + "csa", + "run", + "--fork-from-caller", + "--fork-last", + "prompt", + ]); + assert!( + result.is_err(), + "fork-from-caller and fork-last should conflict" + ); +} + +#[test] +fn test_cli_fork_from_caller_conflicts_with_session() { + let result = try_parse_cli(&[ + "csa", + "run", + "--fork-from-caller", + "--session", + "01DEF", + "prompt", + ]); + assert!( + result.is_err(), + "fork-from-caller and session should conflict" + ); +} + +#[test] +fn test_cli_fork_from_caller_conflicts_with_last() { + let result = try_parse_cli(&["csa", "run", "--fork-from-caller", "--last", "prompt"]); + assert!( + result.is_err(), + "fork-from-caller and last should conflict" + ); +} + +#[test] +fn test_cli_fork_from_caller_conflicts_with_ephemeral() { + let result = try_parse_cli(&[ + "csa", + "run", + "--fork-from-caller", + "--ephemeral", + "prompt", + ]); + assert!( + result.is_err(), + "fork-from-caller and ephemeral should conflict" + ); +} + +#[test] +fn test_cli_fork_from_caller_appears_in_help() { + let result = try_parse_cli(&["csa", "run", "--help"]); + let err = match result { + Ok(_) => panic!("--help should produce a help error"), + Err(err) => err, + }; + let help_text = err.to_string(); + assert!( + help_text.contains("--fork-from-caller"), + "help should mention --fork-from-caller, got: {help_text}" + ); +} + #[test] fn test_cli_fork_last_conflicts_with_ephemeral() { let result = try_parse_cli(&["csa", "run", "--fork-last", "--ephemeral", "prompt"]); diff --git a/crates/cli-sub-agent/src/run_cmd_tier_tests.rs b/crates/cli-sub-agent/src/run_cmd_tier_tests.rs index 6c8f491a..d4dedbc8 100644 --- a/crates/cli-sub-agent/src/run_cmd_tier_tests.rs +++ b/crates/cli-sub-agent/src/run_cmd_tier_tests.rs @@ -124,6 +124,7 @@ async fn handle_run_persists_result_for_direct_tool_tier_rejection() { false, None, false, + false, None, false, None, diff --git a/crates/cli-sub-agent/src/run_helpers_tests.rs b/crates/cli-sub-agent/src/run_helpers_tests.rs index e301b7dd..52b81ab5 100644 --- a/crates/cli-sub-agent/src/run_helpers_tests.rs +++ b/crates/cli-sub-agent/src/run_helpers_tests.rs @@ -615,6 +615,26 @@ fn parse_token_usage_empty_string_returns_none() { assert!(super::parse_token_usage("").is_none()); } +#[test] +fn parse_token_usage_with_cache_read_input_tokens() { + // cache_read_input_tokens MUST be captured AND MUST NOT shadow input_tokens. + let output = "input_tokens: 1000\ncache_read_input_tokens: 750\noutput_tokens: 500"; + let usage = super::parse_token_usage(output).unwrap(); + assert_eq!(usage.input_tokens, Some(1000)); + assert_eq!(usage.cache_read_input_tokens, Some(750)); + assert_eq!(usage.output_tokens, Some(500)); +} + +#[test] +fn parse_token_usage_cache_read_only_does_not_set_input() { + // When only cache_read_input_tokens is present, the lookback guard prevents + // the longer key from being mis-parsed as a bare input_tokens hit. + let output = "cache_read_input_tokens: 750"; + let usage = super::parse_token_usage(output).unwrap(); + assert_eq!(usage.cache_read_input_tokens, Some(750)); + assert_eq!(usage.input_tokens, None); +} + // --- extract_number tests --- #[test] diff --git a/crates/cli-sub-agent/src/run_helpers_token_parse.rs b/crates/cli-sub-agent/src/run_helpers_token_parse.rs index 4ddf6375..fc631b47 100644 --- a/crates/cli-sub-agent/src/run_helpers_token_parse.rs +++ b/crates/cli-sub-agent/src/run_helpers_token_parse.rs @@ -7,6 +7,7 @@ use csa_session::TokenUsage; /// Looks for common patterns in stdout/stderr: /// - "tokens: N" or "Tokens: N" or "total_tokens: N" /// - "input_tokens: N" / "output_tokens: N" +/// - "cache_read_input_tokens: N" (Anthropic prompt caching) /// - "cost: $N.NN" or "estimated_cost: $N.NN" pub(crate) fn parse_token_usage(output: &str) -> Option { let mut usage = TokenUsage::default(); @@ -16,14 +17,27 @@ pub(crate) fn parse_token_usage(output: &str) -> Option { for line in output.lines() { let line_lower = line.to_lowercase(); - // Parse input_tokens - if let Some(pos) = line_lower.find("input_tokens") + // Parse cache_read_input_tokens BEFORE input_tokens to prevent the + // less-specific "input_tokens" probe from matching this longer key. + if let Some(pos) = line_lower.find("cache_read_input_tokens") && let Some(val) = extract_number(&line[pos..]) { - usage.input_tokens = Some(val); + usage.cache_read_input_tokens = Some(val); found_any = true; } + // Parse input_tokens (only when not actually pointing at cache_read_input_tokens) + if let Some(pos) = line_lower.find("input_tokens") { + let prev_run_start = line_lower[..pos] + .rfind(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .map_or(0, |i| i + 1); + let is_cache_read = line_lower[prev_run_start..pos].ends_with("cache_read_"); + if !is_cache_read && let Some(val) = extract_number(&line[pos..]) { + usage.input_tokens = Some(val); + found_any = true; + } + } + // Parse output_tokens if let Some(pos) = line_lower.find("output_tokens") && let Some(val) = extract_number(&line[pos..]) diff --git a/crates/cli-sub-agent/src/session_cmds_result.rs b/crates/cli-sub-agent/src/session_cmds_result.rs index f9e11cf4..0855954c 100644 --- a/crates/cli-sub-agent/src/session_cmds_result.rs +++ b/crates/cli-sub-agent/src/session_cmds_result.rs @@ -4,6 +4,7 @@ use std::io::{BufRead, BufReader}; use std::path::Path; use csa_session::SessionResultView; +use csa_session::TokenUsage; use csa_session::state::ReviewSessionMeta; use crate::session_cmds::{ @@ -184,11 +185,15 @@ pub(crate) fn handle_session_result( } } }; + // Cross-project sessions cannot resolve their state through the + // local project path; load directly from the session_dir state.toml. + let token_usage = load_total_token_usage(&session_dir); if json { display_result_json( &result_view, transcript_summary.as_ref(), review_meta.as_ref(), + token_usage.as_ref(), )?; } else { display_result_text( @@ -196,6 +201,7 @@ pub(crate) fn handle_session_result( &result_view, transcript_summary.as_ref(), review_meta.as_ref(), + token_usage.as_ref(), ); } } @@ -225,8 +231,9 @@ fn display_result_json( result: &SessionResultView, transcript_summary: Option<&TranscriptSummary>, review_meta: Option<&ReviewSessionMeta>, + token_usage: Option<&TokenUsage>, ) -> Result<()> { - let payload = build_result_json_payload(result, transcript_summary, review_meta)?; + let payload = build_result_json_payload(result, transcript_summary, review_meta, token_usage)?; println!("{}", serde_json::to_string_pretty(&payload)?); Ok(()) } @@ -236,6 +243,7 @@ fn display_result_text( result: &SessionResultView, transcript_summary: Option<&TranscriptSummary>, review_meta: Option<&ReviewSessionMeta>, + token_usage: Option<&TokenUsage>, ) { let envelope = &result.envelope; println!("Session: {session_id}"); @@ -256,6 +264,9 @@ fn display_result_text( if let Some(meta) = review_meta { println!("Review Iterations: {}", meta.review_iterations); } + if let Some(usage) = token_usage { + print_token_usage(usage); + } if let Some(summary) = transcript_summary { println!("Transcript:"); println!(" Events: {}", summary.event_count); @@ -271,6 +282,68 @@ fn display_result_text( } } +/// Load total_token_usage from a session's state.toml on disk. +/// +/// Returns None on any parse/read failure or when the field is absent. +/// Reading directly avoids the project-root coupling of `load_session`, +/// which lets cross-project sessions render their token totals too. +fn load_total_token_usage(session_dir: &Path) -> Option { + let state_path = session_dir.join("state.toml"); + let content = fs::read_to_string(&state_path).ok()?; + let value: toml::Value = toml::from_str(&content).ok()?; + let usage_table = value.get("total_token_usage")?; + usage_table.clone().try_into::().ok() +} + +fn print_token_usage(usage: &TokenUsage) { + let any_field = usage.input_tokens.is_some() + || usage.output_tokens.is_some() + || usage.total_tokens.is_some() + || usage.estimated_cost_usd.is_some() + || usage.cache_read_input_tokens.is_some(); + if !any_field { + return; + } + println!("Tokens:"); + if let Some(v) = usage.input_tokens { + println!(" Input: {} tokens", format_thousands(v)); + } + if let Some(v) = usage.output_tokens { + println!(" Output: {} tokens", format_thousands(v)); + } + if let Some(v) = usage.total_tokens { + println!(" Total: {} tokens", format_thousands(v)); + } + if let Some(v) = usage.cache_read_input_tokens { + if let Some(ratio) = usage.cache_hit_ratio() { + println!( + " Cache read: {} tokens ({:.0}% hit rate)", + format_thousands(v), + ratio * 100.0 + ); + } else { + println!(" Cache read: {} tokens", format_thousands(v)); + } + } + if let Some(cost) = usage.estimated_cost_usd { + println!(" Cost: ${cost:.4}"); + } +} + +fn format_thousands(n: u64) -> String { + let s = n.to_string(); + let bytes = s.as_bytes(); + let len = bytes.len(); + let mut out = String::with_capacity(len + len / 3); + for (idx, byte) in bytes.iter().enumerate() { + if idx > 0 && (len - idx).is_multiple_of(3) { + out.push(','); + } + out.push(*byte as char); + } + out +} + fn load_review_meta(session_dir: &Path) -> Result> { let review_meta_path = session_dir.join("review_meta.json"); if !review_meta_path.is_file() { @@ -286,6 +359,7 @@ fn build_result_json_payload( result: &SessionResultView, transcript_summary: Option<&TranscriptSummary>, review_meta: Option<&ReviewSessionMeta>, + token_usage: Option<&TokenUsage>, ) -> Result { let mut payload = serde_json::to_value(&result.envelope)?; if let Some(sidecar) = result @@ -313,6 +387,13 @@ fn build_result_json_payload( if let Some(meta) = review_meta { payload["review_meta"] = serde_json::to_value(meta)?; } + if let Some(usage) = token_usage { + let mut value = serde_json::to_value(usage)?; + if let Some(ratio) = usage.cache_hit_ratio() { + value["cache_hit_ratio"] = serde_json::json!(ratio); + } + payload["total_token_usage"] = value; + } Ok(payload) } @@ -613,142 +694,9 @@ pub(crate) fn handle_session_artifacts(session: String, cd: Option) -> R Ok(()) } -/// Token savings measurement for structured output. -#[derive(Debug, Clone, serde::Serialize)] -pub(crate) struct TokenMeasurement { - pub session_id: String, - pub total_tokens: usize, - pub summary_tokens: usize, - pub savings_tokens: usize, - pub savings_percent: f64, - pub section_count: usize, - pub section_names: Vec, - pub is_structured: bool, -} - -pub(crate) fn handle_session_measure( - session: String, - json: bool, - cd: Option, -) -> Result<()> { - let project_root = crate::pipeline::determine_project_root(cd.as_deref())?; - let resolved = resolve_session_prefix_with_fallback(&project_root, &session)?; - let resolved_id = resolved.session_id; - let session_dir = csa_session::get_session_dir(&project_root, &resolved_id)?; - - let measurement = compute_token_measurement(&session_dir, &resolved_id)?; - - if json { - println!("{}", serde_json::to_string_pretty(&measurement)?); - } else { - let short_id = &resolved_id[..11.min(resolved_id.len())]; - println!("Session: {short_id}"); - println!( - "Total output: {} tokens", - format_number(measurement.total_tokens) - ); - println!( - "Summary only: {} tokens", - format_number(measurement.summary_tokens) - ); - if measurement.is_structured && measurement.total_tokens > 0 { - println!( - "Savings: {:.1}% ({} tokens saved)", - measurement.savings_percent, - format_number(measurement.savings_tokens) - ); - println!( - "Sections: {} ({})", - measurement.section_count, - measurement.section_names.join(", ") - ); - } else { - println!("Savings: N/A (unstructured output)"); - } - } - - Ok(()) -} - -pub(crate) fn compute_token_measurement( - session_dir: &Path, - session_id: &str, -) -> Result { - // Try loading the structured output index - let index = csa_session::load_output_index(session_dir)?; - - if let Some(index) = index { - let total_tokens = index.total_tokens; - let section_names: Vec = index.sections.iter().map(|s| s.id.clone()).collect(); - let section_count = index.sections.len(); - - // Find summary section tokens (first section named "summary", or first section) - let summary_tokens = index - .sections - .iter() - .find(|s| s.id == "summary") - .map(|s| s.token_estimate) - .unwrap_or_else(|| { - index - .sections - .first() - .map(|s| s.token_estimate) - .unwrap_or(0) - }); - - // "full" section means unstructured (parser wraps entire output as "full") - let is_structured = section_count > 1 || (section_count == 1 && section_names[0] != "full"); - - let savings_tokens = total_tokens.saturating_sub(summary_tokens); - let savings_percent = if total_tokens > 0 { - (1.0 - summary_tokens as f64 / total_tokens as f64) * 100.0 - } else { - 0.0 - }; - - Ok(TokenMeasurement { - session_id: session_id.to_string(), - total_tokens, - summary_tokens, - savings_tokens, - savings_percent, - section_count, - section_names, - is_structured, - }) - } else { - // No index — try computing from output.log directly - let output_log = session_dir.join("output.log"); - let total_tokens = if output_log.is_file() { - let content = fs::read_to_string(&output_log)?; - csa_session::estimate_tokens(&content) - } else { - 0 - }; - - Ok(TokenMeasurement { - session_id: session_id.to_string(), - total_tokens, - summary_tokens: total_tokens, - savings_tokens: 0, - savings_percent: 0.0, - section_count: 0, - section_names: vec![], - is_structured: false, - }) - } -} - -/// Format a number with commas for readability. -pub(crate) fn format_number(n: usize) -> String { - let s = n.to_string(); - let chars: Vec = s.chars().rev().collect(); - let chunks: Vec = chars - .chunks(3) - .map(|chunk| chunk.iter().collect::()) - .collect(); - chunks.join(",").chars().rev().collect() -} +pub(crate) use crate::session_cmds_result_measure::handle_session_measure; +#[cfg(test)] +pub(crate) use crate::session_cmds_result_measure::{compute_token_measurement, format_number}; /// Handle `csa session tool-output [index] [--list]`. pub(crate) fn handle_session_tool_output( diff --git a/crates/cli-sub-agent/src/session_cmds_result_measure.rs b/crates/cli-sub-agent/src/session_cmds_result_measure.rs new file mode 100644 index 00000000..435a4657 --- /dev/null +++ b/crates/cli-sub-agent/src/session_cmds_result_measure.rs @@ -0,0 +1,141 @@ +//! Token-savings measurement for `csa session measure`. + +use anyhow::Result; +use std::fs; +use std::path::Path; + +use crate::session_cmds::resolve_session_prefix_with_fallback; + +/// Token savings measurement for structured output. +#[derive(Debug, Clone, serde::Serialize)] +pub(crate) struct TokenMeasurement { + pub session_id: String, + pub total_tokens: usize, + pub summary_tokens: usize, + pub savings_tokens: usize, + pub savings_percent: f64, + pub section_count: usize, + pub section_names: Vec, + pub is_structured: bool, +} + +pub(crate) fn handle_session_measure( + session: String, + json: bool, + cd: Option, +) -> Result<()> { + let project_root = crate::pipeline::determine_project_root(cd.as_deref())?; + let resolved = resolve_session_prefix_with_fallback(&project_root, &session)?; + let resolved_id = resolved.session_id; + let session_dir = csa_session::get_session_dir(&project_root, &resolved_id)?; + + let measurement = compute_token_measurement(&session_dir, &resolved_id)?; + + if json { + println!("{}", serde_json::to_string_pretty(&measurement)?); + } else { + let short_id = &resolved_id[..11.min(resolved_id.len())]; + println!("Session: {short_id}"); + println!( + "Total output: {} tokens", + format_number(measurement.total_tokens) + ); + println!( + "Summary only: {} tokens", + format_number(measurement.summary_tokens) + ); + if measurement.is_structured && measurement.total_tokens > 0 { + println!( + "Savings: {:.1}% ({} tokens saved)", + measurement.savings_percent, + format_number(measurement.savings_tokens) + ); + println!( + "Sections: {} ({})", + measurement.section_count, + measurement.section_names.join(", ") + ); + } else { + println!("Savings: N/A (unstructured output)"); + } + } + + Ok(()) +} + +pub(crate) fn compute_token_measurement( + session_dir: &Path, + session_id: &str, +) -> Result { + let index = csa_session::load_output_index(session_dir)?; + + if let Some(index) = index { + let total_tokens = index.total_tokens; + let section_names: Vec = index.sections.iter().map(|s| s.id.clone()).collect(); + let section_count = index.sections.len(); + + let summary_tokens = index + .sections + .iter() + .find(|s| s.id == "summary") + .map(|s| s.token_estimate) + .unwrap_or_else(|| { + index + .sections + .first() + .map(|s| s.token_estimate) + .unwrap_or(0) + }); + + // "full" section means unstructured (parser wraps entire output as "full") + let is_structured = section_count > 1 || (section_count == 1 && section_names[0] != "full"); + + let savings_tokens = total_tokens.saturating_sub(summary_tokens); + let savings_percent = if total_tokens > 0 { + (1.0 - summary_tokens as f64 / total_tokens as f64) * 100.0 + } else { + 0.0 + }; + + Ok(TokenMeasurement { + session_id: session_id.to_string(), + total_tokens, + summary_tokens, + savings_tokens, + savings_percent, + section_count, + section_names, + is_structured, + }) + } else { + let output_log = session_dir.join("output.log"); + let total_tokens = if output_log.is_file() { + let content = fs::read_to_string(&output_log)?; + csa_session::estimate_tokens(&content) + } else { + 0 + }; + + Ok(TokenMeasurement { + session_id: session_id.to_string(), + total_tokens, + summary_tokens: total_tokens, + savings_tokens: 0, + savings_percent: 0.0, + section_count: 0, + section_names: vec![], + is_structured: false, + }) + } +} + +/// Format a number with commas for readability. +pub(crate) fn format_number(n: usize) -> String { + let s = n.to_string(); + let chars: Vec = s.chars().rev().collect(); + let chunks: Vec = chars + .chunks(3) + .map(|chunk| chunk.iter().collect::()) + .collect(); + chunks.join(",").chars().rev().collect() +} diff --git a/crates/cli-sub-agent/src/session_cmds_result_tests.rs b/crates/cli-sub-agent/src/session_cmds_result_tests.rs index 13526d46..597abb91 100644 --- a/crates/cli-sub-agent/src/session_cmds_result_tests.rs +++ b/crates/cli-sub-agent/src/session_cmds_result_tests.rs @@ -368,6 +368,7 @@ fn build_result_json_payload_includes_review_iterations() { }, None, Some(&review_meta), + None, ) .unwrap(); assert_eq!(payload["review_meta"]["review_iterations"], 4); @@ -410,7 +411,7 @@ fn build_result_json_payload_includes_result_sidecars() { ), }; - let payload = build_result_json_payload(&result, None, None).unwrap(); + let payload = build_result_json_payload(&result, None, None, None).unwrap(); assert_eq!( payload["manager_sidecar"]["report"]["summary"], "manager-visible" @@ -448,7 +449,7 @@ fn build_result_json_payload_redacts_result_sidecars() { legacy_sidecar: None, }; - let payload = build_result_json_payload(&result, None, None).unwrap(); + let payload = build_result_json_payload(&result, None, None, None).unwrap(); let rendered = serde_json::to_string(&payload).unwrap(); assert!(!rendered.contains("hunter2")); assert!(rendered.contains("[REDACTED]")); diff --git a/crates/cli-sub-agent/src/session_cmds_tests.rs b/crates/cli-sub-agent/src/session_cmds_tests.rs index 31b8da52..8f4a3b14 100644 --- a/crates/cli-sub-agent/src/session_cmds_tests.rs +++ b/crates/cli-sub-agent/src/session_cmds_tests.rs @@ -294,6 +294,7 @@ fn sample_session_state() -> MetaSessionState { output_tokens: Some(20), total_tokens: Some(30), estimated_cost_usd: None, + cache_read_input_tokens: None, }), phase: SessionPhase::Available, task_context: TaskContext { diff --git a/crates/csa-acp/src/client.rs b/crates/csa-acp/src/client.rs index 72f259a5..14876fc7 100644 --- a/crates/csa-acp/src/client.rs +++ b/crates/csa-acp/src/client.rs @@ -45,6 +45,16 @@ pub struct StreamingMetadata { pub has_thought_fallback: bool, /// Total bytes written to the output spool file. pub(crate) spool_bytes_written: u64, + /// Total input tokens reported by the underlying API response, when available. + pub input_tokens: Option, + /// Total output tokens reported by the underlying API response, when available. + pub output_tokens: Option, + /// Cache-read input tokens reported by the Anthropic API response. + /// + /// Anthropic returns `cache_read_input_tokens` in the response `usage` + /// block when prompt caching is active. Older API responses and non-Claude + /// backends may omit it, hence `Option`. + pub cache_read_input_tokens: Option, } impl StreamingMetadata { @@ -57,6 +67,19 @@ impl StreamingMetadata { self.extracted_commands = store.extracted_commands(); } + /// Ratio of cache-read input tokens to total input tokens (`cache_read / input_tokens`). + /// + /// Returns `None` when either field is missing or when `input_tokens` is + /// zero (no meaningful denominator). + pub fn cache_hit_ratio(&self) -> Option { + let cache_read = self.cache_read_input_tokens? as f64; + let total_input = self.input_tokens? as f64; + if total_input == 0.0 { + return None; + } + Some(cache_read / total_input) + } + /// Append agent message text to both the message-specific and combined tail buffers. pub(crate) fn append_message_text(&mut self, text: &str) { self.tail_text.push_str(text); @@ -230,436 +253,8 @@ impl SessionEventStore { } } -/// Quick heuristic: does a tool-call title look like `git commit --no-verify` -/// or `git commit -n`? This is intentionally simpler than the full shell -/// parser in `run_cmd_shell.rs` because it only needs to catch the common -/// pattern within ACP execute-tool-call titles (which are short, single -/// commands). The authoritative check still runs in -/// `apply_no_verify_commit_policy`; this flag merely ensures the event is -/// never silently evicted from the bounded ring buffer. -fn command_looks_like_no_verify_commit(cmd: &str) -> bool { - let tokens = tokenize_shell_tokens(cmd); - if let Some(shell_script_tokens) = extract_shell_c_payload_tokens(&tokens) - && shell_script_contains_no_verify_commit(shell_script_tokens) - { - return true; - } - tokens_contain_no_verify_commit(&tokens, |tokens| skip_command_prefix_tokens(tokens, 0)) -} - -fn tokenize_shell_tokens(segment: &str) -> Vec { - let mut tokens = Vec::new(); - let mut current = String::new(); - let mut chars = segment.chars().peekable(); - let mut in_single_quote = false; - let mut in_double_quote = false; - let mut escaped = false; - - while let Some(ch) = chars.next() { - if escaped { - if ch != '\n' { - current.push(ch); - } - escaped = false; - continue; - } - - if ch == '\\' { - escaped = true; - continue; - } - - if in_single_quote { - if ch == '\'' { - in_single_quote = false; - } else { - current.push(ch); - } - continue; - } - - if in_double_quote { - if ch == '"' { - in_double_quote = false; - } else { - current.push(ch); - } - continue; - } - - match ch { - '\'' => in_single_quote = true, - '"' => in_double_quote = true, - '\n' => { - push_shell_token(&mut tokens, &mut current); - tokens.push(";".to_string()); - } - ';' => { - push_shell_token(&mut tokens, &mut current); - tokens.push(";".to_string()); - } - '&' => { - push_shell_token(&mut tokens, &mut current); - if chars.peek().is_some_and(|next| *next == '&') { - let _ = chars.next(); - tokens.push("&&".to_string()); - } else { - tokens.push("&".to_string()); - } - } - '|' => { - push_shell_token(&mut tokens, &mut current); - if chars.peek().is_some_and(|next| *next == '|') { - let _ = chars.next(); - tokens.push("||".to_string()); - } else { - tokens.push("|".to_string()); - } - } - c if c.is_whitespace() => push_shell_token(&mut tokens, &mut current), - _ => current.push(ch), - } - } - - if escaped { - current.push('\\'); - } - push_shell_token(&mut tokens, &mut current); - tokens -} - -fn push_shell_token(tokens: &mut Vec, current: &mut String) { - let trimmed = current.trim(); - if !trimmed.is_empty() { - tokens.push(trimmed.to_string()); - } - current.clear(); -} - -fn extract_shell_c_payload_tokens(tokens: &[String]) -> Option<&[String]> { - let idx = skip_command_prefix_tokens(tokens, 0); - if idx + 2 >= tokens.len() || !is_shell_token(tokens[idx].as_str()) { - return None; - } - let shell_flag = tokens[idx + 1].as_str(); - if !shell_flag.starts_with('-') || !shell_flag.contains('c') { - return None; - } - Some(&tokens[idx + 2..]) -} - -fn shell_script_contains_no_verify_commit(tokens: &[String]) -> bool { - let mut script_tokens = Vec::new(); - for token in tokens { - script_tokens.extend(tokenize_shell_tokens(token)); - } - - tokens_contain_no_verify_commit(&script_tokens, |tokens| { - skip_command_prefix_tokens(tokens, 0) - }) -} - -fn tokens_contain_no_verify_commit(tokens: &[String], skip_prefix: F) -> bool -where - F: Fn(&[String]) -> usize, -{ - let mut command_start = 0usize; - - while command_start < tokens.len() { - let command_end = tokens[command_start..] - .iter() - .position(|token| is_command_separator_token(token.as_str())) - .map_or(tokens.len(), |idx| command_start + idx); - - if command_segment_contains_no_verify_commit( - &tokens[command_start..command_end], - &skip_prefix, - ) { - return true; - } - - command_start = command_end.saturating_add(1); - } - - false -} - -fn command_segment_contains_no_verify_commit(tokens: &[String], skip_prefix: &F) -> bool -where - F: Fn(&[String]) -> usize, -{ - if let Some(shell_script_tokens) = extract_shell_c_payload_tokens(tokens) - && shell_script_contains_no_verify_commit(shell_script_tokens) - { - return true; - } - - let idx = skip_prefix(tokens); - if idx >= tokens.len() || !is_git_token(tokens[idx].as_str()) { - return false; - } - let Some(commit_idx) = find_git_commit_subcommand(tokens, idx + 1) else { - return false; - }; - commit_args_include_no_verify(&tokens[commit_idx + 1..]) -} - -fn find_git_commit_subcommand(tokens: &[String], mut idx: usize) -> Option { - while idx < tokens.len() { - let current = tokens[idx].as_str(); - if current == "commit" { - return Some(idx); - } - if current == "--" { - break; - } - if current.starts_with('-') { - idx += 1; - if git_global_option_consumes_value(current) && !current.contains('=') { - idx = consume_option_value(tokens, idx); - } - continue; - } - break; - } - - None -} - -fn commit_args_include_no_verify(args: &[String]) -> bool { - let mut idx = 0usize; - while idx < args.len() { - let token = args[idx].as_str(); - if token == "--" || is_command_separator_token(token) { - break; - } - if token.eq_ignore_ascii_case("--no-verify") { - return true; - } - if token.starts_with("--") { - idx += 1; - if commit_long_option_consumes_value(token) && !token.contains('=') { - idx = consume_option_value(args, idx); - } - continue; - } - if token.starts_with('-') && token.len() > 1 { - let mut chars = token[1..].chars().peekable(); - let mut consumes_value = false; - while let Some(flag) = chars.next() { - if flag == 'n' { - return true; - } - if commit_short_option_consumes_value(flag) { - consumes_value = chars.peek().is_none(); - break; - } - } - idx += 1; - if consumes_value { - idx = consume_option_value(args, idx); - } - continue; - } - idx += 1; - } - false -} - -fn is_command_separator_token(token: &str) -> bool { - matches!(token, ";" | "&&" | "||" | "|" | "&") - || token.ends_with(';') - || token.ends_with("&&") - || token.ends_with("||") - || token.ends_with('|') - || token.ends_with('&') -} - -fn consume_option_value(args: &[String], mut idx: usize) -> usize { - if idx < args.len() { - idx += 1; - } - idx -} - -fn commit_short_option_consumes_value(flag: char) -> bool { - matches!(flag, 'm' | 'F' | 'c' | 'C' | 't') -} - -fn commit_long_option_consumes_value(token: &str) -> bool { - matches!( - token, - "--message" - | "--file" - | "--template" - | "--reuse-message" - | "--reedit-message" - | "--fixup" - | "--squash" - | "--author" - | "--date" - | "--trailer" - | "--pathspec-from-file" - | "--cleanup" - ) -} - -fn is_git_token(token: &str) -> bool { - token.eq_ignore_ascii_case("git") || token.ends_with("/git") -} - -fn is_shell_token(token: &str) -> bool { - matches!( - token.rsplit('/').next(), - Some("bash" | "sh" | "zsh" | "fish") - ) -} - -fn skip_command_prefix_tokens(tokens: &[String], mut idx: usize) -> usize { - while idx < tokens.len() { - let token = tokens[idx].as_str(); - if is_env_assignment(token) { - idx += 1; - continue; - } - if command_name_is(token, "sudo") { - idx += 1; - idx = skip_prefixed_command_options(tokens, idx, sudo_option_consumes_value); - continue; - } - if command_name_is(token, "env") { - idx += 1; - idx = skip_prefixed_command_options(tokens, idx, env_option_consumes_value); - while idx < tokens.len() && is_env_assignment(tokens[idx].as_str()) { - idx += 1; - } - continue; - } - if command_name_is(token, "nice") { - idx += 1; - idx = skip_prefixed_command_options(tokens, idx, nice_option_consumes_value); - continue; - } - if command_name_is(token, "ionice") { - idx += 1; - idx = skip_prefixed_command_options(tokens, idx, ionice_option_consumes_value); - continue; - } - if command_name_is(token, "strace") || command_name_is(token, "ltrace") { - idx += 1; - idx = skip_prefixed_command_options(tokens, idx, trace_option_consumes_value); - continue; - } - if command_name_is(token, "command") { - idx += 1; - idx = skip_prefixed_command_options(tokens, idx, command_option_consumes_value); - continue; - } - if command_name_is(token, "time") { - idx += 1; - idx = skip_prefixed_command_options(tokens, idx, time_option_consumes_value); - continue; - } - if command_name_is(token, "exec") || token == "--" { - idx += 1; - continue; - } - break; - } - - idx -} - -fn skip_prefixed_command_options(tokens: &[String], mut idx: usize, consumes_value: F) -> usize -where - F: Fn(&str) -> bool, -{ - while idx < tokens.len() { - let token = tokens[idx].as_str(); - if token == "--" { - idx += 1; - break; - } - if !token.starts_with('-') { - break; - } - let takes_value = consumes_value(token) && !token.contains('='); - idx += 1; - if takes_value && idx < tokens.len() { - idx += 1; - } - } - idx -} - -fn is_env_assignment(token: &str) -> bool { - token - .find('=') - .is_some_and(|eq_pos| eq_pos > 0 && !token.starts_with('-')) -} - -fn env_option_consumes_value(token: &str) -> bool { - matches!( - token, - "-u" | "--unset" | "-C" | "--chdir" | "-S" | "--split-string" - ) -} - -fn sudo_option_consumes_value(token: &str) -> bool { - matches!( - token, - "-u" | "--user" - | "-g" - | "--group" - | "-h" - | "--host" - | "-p" - | "--prompt" - | "-r" - | "--role" - | "-t" - | "--type" - | "-C" - | "--chdir" - ) -} - -fn nice_option_consumes_value(token: &str) -> bool { - matches!(token, "-n" | "--adjustment") -} - -fn ionice_option_consumes_value(token: &str) -> bool { - matches!( - token, - "-c" | "--class" | "-n" | "--classdata" | "-t" | "--ignore" | "-p" | "--pid" - ) -} - -fn trace_option_consumes_value(token: &str) -> bool { - matches!( - token, - "-e" | "--trace" | "-o" | "--output" | "-p" | "--attach" | "-u" | "--user" - ) -} - -fn command_option_consumes_value(_token: &str) -> bool { - false -} - -fn time_option_consumes_value(_token: &str) -> bool { - false -} - -fn command_name_is(token: &str, name: &str) -> bool { - token.eq_ignore_ascii_case(name) || token.rsplit('/').next() == Some(name) -} - -fn git_global_option_consumes_value(token: &str) -> bool { - matches!( - token, - "-c" | "-C" | "--exec-path" | "--git-dir" | "--work-tree" | "--namespace" | "--config-env" - ) -} +mod no_verify_detect; +use no_verify_detect::command_looks_like_no_verify_commit; pub(crate) type SharedEvents = Rc>; pub(crate) type SharedActivity = Rc>; diff --git a/crates/csa-acp/src/client/no_verify_detect.rs b/crates/csa-acp/src/client/no_verify_detect.rs new file mode 100644 index 00000000..ba3694f0 --- /dev/null +++ b/crates/csa-acp/src/client/no_verify_detect.rs @@ -0,0 +1,433 @@ +//! `git commit --no-verify` / `git commit -n` heuristic detector. +//! +//! Used to flag ACP execute-tool-call titles that look like a hook-bypassing +//! commit, so they are never silently evicted from the bounded ring buffer. +//! Intentionally simpler than the authoritative shell parser in +//! `run_cmd_shell.rs`; the canonical decision still happens in +//! `apply_no_verify_commit_policy`. + +/// Quick heuristic: does a tool-call title look like `git commit --no-verify` +/// or `git commit -n`? +pub(super) fn command_looks_like_no_verify_commit(cmd: &str) -> bool { + let tokens = tokenize_shell_tokens(cmd); + if let Some(shell_script_tokens) = extract_shell_c_payload_tokens(&tokens) + && shell_script_contains_no_verify_commit(shell_script_tokens) + { + return true; + } + tokens_contain_no_verify_commit(&tokens, |tokens| skip_command_prefix_tokens(tokens, 0)) +} + +fn tokenize_shell_tokens(segment: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut chars = segment.chars().peekable(); + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut escaped = false; + + while let Some(ch) = chars.next() { + if escaped { + if ch != '\n' { + current.push(ch); + } + escaped = false; + continue; + } + + if ch == '\\' { + escaped = true; + continue; + } + + if in_single_quote { + if ch == '\'' { + in_single_quote = false; + } else { + current.push(ch); + } + continue; + } + + if in_double_quote { + if ch == '"' { + in_double_quote = false; + } else { + current.push(ch); + } + continue; + } + + match ch { + '\'' => in_single_quote = true, + '"' => in_double_quote = true, + '\n' => { + push_shell_token(&mut tokens, &mut current); + tokens.push(";".to_string()); + } + ';' => { + push_shell_token(&mut tokens, &mut current); + tokens.push(";".to_string()); + } + '&' => { + push_shell_token(&mut tokens, &mut current); + if chars.peek().is_some_and(|next| *next == '&') { + let _ = chars.next(); + tokens.push("&&".to_string()); + } else { + tokens.push("&".to_string()); + } + } + '|' => { + push_shell_token(&mut tokens, &mut current); + if chars.peek().is_some_and(|next| *next == '|') { + let _ = chars.next(); + tokens.push("||".to_string()); + } else { + tokens.push("|".to_string()); + } + } + c if c.is_whitespace() => push_shell_token(&mut tokens, &mut current), + _ => current.push(ch), + } + } + + if escaped { + current.push('\\'); + } + push_shell_token(&mut tokens, &mut current); + tokens +} + +fn push_shell_token(tokens: &mut Vec, current: &mut String) { + let trimmed = current.trim(); + if !trimmed.is_empty() { + tokens.push(trimmed.to_string()); + } + current.clear(); +} + +fn extract_shell_c_payload_tokens(tokens: &[String]) -> Option<&[String]> { + let idx = skip_command_prefix_tokens(tokens, 0); + if idx + 2 >= tokens.len() || !is_shell_token(tokens[idx].as_str()) { + return None; + } + let shell_flag = tokens[idx + 1].as_str(); + if !shell_flag.starts_with('-') || !shell_flag.contains('c') { + return None; + } + Some(&tokens[idx + 2..]) +} + +fn shell_script_contains_no_verify_commit(tokens: &[String]) -> bool { + let mut script_tokens = Vec::new(); + for token in tokens { + script_tokens.extend(tokenize_shell_tokens(token)); + } + + tokens_contain_no_verify_commit(&script_tokens, |tokens| { + skip_command_prefix_tokens(tokens, 0) + }) +} + +fn tokens_contain_no_verify_commit(tokens: &[String], skip_prefix: F) -> bool +where + F: Fn(&[String]) -> usize, +{ + let mut command_start = 0usize; + + while command_start < tokens.len() { + let command_end = tokens[command_start..] + .iter() + .position(|token| is_command_separator_token(token.as_str())) + .map_or(tokens.len(), |idx| command_start + idx); + + if command_segment_contains_no_verify_commit( + &tokens[command_start..command_end], + &skip_prefix, + ) { + return true; + } + + command_start = command_end.saturating_add(1); + } + + false +} + +fn command_segment_contains_no_verify_commit(tokens: &[String], skip_prefix: &F) -> bool +where + F: Fn(&[String]) -> usize, +{ + if let Some(shell_script_tokens) = extract_shell_c_payload_tokens(tokens) + && shell_script_contains_no_verify_commit(shell_script_tokens) + { + return true; + } + + let idx = skip_prefix(tokens); + if idx >= tokens.len() || !is_git_token(tokens[idx].as_str()) { + return false; + } + let Some(commit_idx) = find_git_commit_subcommand(tokens, idx + 1) else { + return false; + }; + commit_args_include_no_verify(&tokens[commit_idx + 1..]) +} + +fn find_git_commit_subcommand(tokens: &[String], mut idx: usize) -> Option { + while idx < tokens.len() { + let current = tokens[idx].as_str(); + if current == "commit" { + return Some(idx); + } + if current == "--" { + break; + } + if current.starts_with('-') { + idx += 1; + if git_global_option_consumes_value(current) && !current.contains('=') { + idx = consume_option_value(tokens, idx); + } + continue; + } + break; + } + + None +} + +fn commit_args_include_no_verify(args: &[String]) -> bool { + let mut idx = 0usize; + while idx < args.len() { + let token = args[idx].as_str(); + if token == "--" || is_command_separator_token(token) { + break; + } + if token.eq_ignore_ascii_case("--no-verify") { + return true; + } + if token.starts_with("--") { + idx += 1; + if commit_long_option_consumes_value(token) && !token.contains('=') { + idx = consume_option_value(args, idx); + } + continue; + } + if token.starts_with('-') && token.len() > 1 { + let mut chars = token[1..].chars().peekable(); + let mut consumes_value = false; + while let Some(flag) = chars.next() { + if flag == 'n' { + return true; + } + if commit_short_option_consumes_value(flag) { + consumes_value = chars.peek().is_none(); + break; + } + } + idx += 1; + if consumes_value { + idx = consume_option_value(args, idx); + } + continue; + } + idx += 1; + } + false +} + +fn is_command_separator_token(token: &str) -> bool { + matches!(token, ";" | "&&" | "||" | "|" | "&") + || token.ends_with(';') + || token.ends_with("&&") + || token.ends_with("||") + || token.ends_with('|') + || token.ends_with('&') +} + +fn consume_option_value(args: &[String], mut idx: usize) -> usize { + if idx < args.len() { + idx += 1; + } + idx +} + +fn commit_short_option_consumes_value(flag: char) -> bool { + matches!(flag, 'm' | 'F' | 'c' | 'C' | 't') +} + +fn commit_long_option_consumes_value(token: &str) -> bool { + matches!( + token, + "--message" + | "--file" + | "--template" + | "--reuse-message" + | "--reedit-message" + | "--fixup" + | "--squash" + | "--author" + | "--date" + | "--trailer" + | "--pathspec-from-file" + | "--cleanup" + ) +} + +fn is_git_token(token: &str) -> bool { + token.eq_ignore_ascii_case("git") || token.ends_with("/git") +} + +fn is_shell_token(token: &str) -> bool { + matches!( + token.rsplit('/').next(), + Some("bash" | "sh" | "zsh" | "fish") + ) +} + +fn skip_command_prefix_tokens(tokens: &[String], mut idx: usize) -> usize { + while idx < tokens.len() { + let token = tokens[idx].as_str(); + if is_env_assignment(token) { + idx += 1; + continue; + } + if command_name_is(token, "sudo") { + idx += 1; + idx = skip_prefixed_command_options(tokens, idx, sudo_option_consumes_value); + continue; + } + if command_name_is(token, "env") { + idx += 1; + idx = skip_prefixed_command_options(tokens, idx, env_option_consumes_value); + while idx < tokens.len() && is_env_assignment(tokens[idx].as_str()) { + idx += 1; + } + continue; + } + if command_name_is(token, "nice") { + idx += 1; + idx = skip_prefixed_command_options(tokens, idx, nice_option_consumes_value); + continue; + } + if command_name_is(token, "ionice") { + idx += 1; + idx = skip_prefixed_command_options(tokens, idx, ionice_option_consumes_value); + continue; + } + if command_name_is(token, "strace") || command_name_is(token, "ltrace") { + idx += 1; + idx = skip_prefixed_command_options(tokens, idx, trace_option_consumes_value); + continue; + } + if command_name_is(token, "command") { + idx += 1; + idx = skip_prefixed_command_options(tokens, idx, command_option_consumes_value); + continue; + } + if command_name_is(token, "time") { + idx += 1; + idx = skip_prefixed_command_options(tokens, idx, time_option_consumes_value); + continue; + } + if command_name_is(token, "exec") || token == "--" { + idx += 1; + continue; + } + break; + } + + idx +} + +fn skip_prefixed_command_options(tokens: &[String], mut idx: usize, consumes_value: F) -> usize +where + F: Fn(&str) -> bool, +{ + while idx < tokens.len() { + let token = tokens[idx].as_str(); + if token == "--" { + idx += 1; + break; + } + if !token.starts_with('-') { + break; + } + let takes_value = consumes_value(token) && !token.contains('='); + idx += 1; + if takes_value && idx < tokens.len() { + idx += 1; + } + } + idx +} + +fn is_env_assignment(token: &str) -> bool { + token + .find('=') + .is_some_and(|eq_pos| eq_pos > 0 && !token.starts_with('-')) +} + +fn env_option_consumes_value(token: &str) -> bool { + matches!( + token, + "-u" | "--unset" | "-C" | "--chdir" | "-S" | "--split-string" + ) +} + +fn sudo_option_consumes_value(token: &str) -> bool { + matches!( + token, + "-u" | "--user" + | "-g" + | "--group" + | "-h" + | "--host" + | "-p" + | "--prompt" + | "-r" + | "--role" + | "-t" + | "--type" + | "-C" + | "--chdir" + ) +} + +fn nice_option_consumes_value(token: &str) -> bool { + matches!(token, "-n" | "--adjustment") +} + +fn ionice_option_consumes_value(token: &str) -> bool { + matches!( + token, + "-c" | "--class" | "-n" | "--classdata" | "-t" | "--ignore" | "-p" | "--pid" + ) +} + +fn trace_option_consumes_value(token: &str) -> bool { + matches!( + token, + "-e" | "--trace" | "-o" | "--output" | "-p" | "--attach" | "-u" | "--user" + ) +} + +fn command_option_consumes_value(_token: &str) -> bool { + false +} + +fn time_option_consumes_value(_token: &str) -> bool { + false +} + +fn command_name_is(token: &str, name: &str) -> bool { + token.eq_ignore_ascii_case(name) || token.rsplit('/').next() == Some(name) +} + +fn git_global_option_consumes_value(token: &str) -> bool { + matches!( + token, + "-c" | "-C" | "--exec-path" | "--git-dir" | "--work-tree" | "--namespace" | "--config-env" + ) +} diff --git a/crates/csa-acp/src/client/tests.rs b/crates/csa-acp/src/client/tests.rs index c3e89f86..c232bc7e 100644 --- a/crates/csa-acp/src/client/tests.rs +++ b/crates/csa-acp/src/client/tests.rs @@ -11,7 +11,7 @@ use agent_client_protocol::{ use super::{ AcpClient, MAX_EXTRACTED_COMMANDS, MAX_RETAINED_EVENTS, SessionEvent, SessionEventStore, - command_looks_like_no_verify_commit, + StreamingMetadata, command_looks_like_no_verify_commit, }; #[test] @@ -328,3 +328,44 @@ fn command_looks_like_no_verify_commit_detects_prefixed_shell_wrappers() { "env -i git commit --no-verify -m unsafe" )); } + +#[test] +fn streaming_metadata_cache_hit_ratio_returns_ratio_when_both_fields_set() { + let metadata = StreamingMetadata { + input_tokens: Some(200_000), + cache_read_input_tokens: Some(150_000), + ..Default::default() + }; + let ratio = metadata.cache_hit_ratio().expect("ratio"); + assert!((ratio - 0.75).abs() < f64::EPSILON); +} + +#[test] +fn streaming_metadata_cache_hit_ratio_returns_none_when_cache_read_missing() { + let metadata = StreamingMetadata { + input_tokens: Some(100), + cache_read_input_tokens: None, + ..Default::default() + }; + assert!(metadata.cache_hit_ratio().is_none()); +} + +#[test] +fn streaming_metadata_cache_hit_ratio_returns_none_when_input_tokens_missing() { + let metadata = StreamingMetadata { + input_tokens: None, + cache_read_input_tokens: Some(50), + ..Default::default() + }; + assert!(metadata.cache_hit_ratio().is_none()); +} + +#[test] +fn streaming_metadata_cache_hit_ratio_returns_none_when_input_tokens_zero() { + let metadata = StreamingMetadata { + input_tokens: Some(0), + cache_read_input_tokens: Some(0), + ..Default::default() + }; + assert!(metadata.cache_hit_ratio().is_none()); +} diff --git a/crates/csa-acp/src/lib.rs b/crates/csa-acp/src/lib.rs index 1b891a2a..56583403 100644 --- a/crates/csa-acp/src/lib.rs +++ b/crates/csa-acp/src/lib.rs @@ -2,6 +2,7 @@ pub mod client; pub mod connection; pub mod error; pub mod mcp_proxy_client; +pub mod prefix_extract; pub mod session_config; pub mod transport; @@ -11,5 +12,8 @@ pub use connection::{ fork_session_via_cli, }; pub use error::{AcpError, AcpResult}; +pub use prefix_extract::{ + DEFAULT_PREFIX_BUDGET_TOKENS, ExtractedPrefix, PrefixConfig, PrefixExtractor, +}; pub use session_config::{McpServerConfig, SessionConfig}; pub use transport::{AcpOutput, AcpOutputIoOptions, AcpRunOptions, AcpSession}; diff --git a/crates/csa-acp/src/prefix_extract.rs b/crates/csa-acp/src/prefix_extract.rs new file mode 100644 index 00000000..cc3d156d --- /dev/null +++ b/crates/csa-acp/src/prefix_extract.rs @@ -0,0 +1,380 @@ +//! JSONL conversation prefix extraction for CSA-lite fork (issue #1432). +//! +//! Reads a Claude Code JSONL session file and extracts a token-budgeted +//! conversation prefix suitable for context injection into a forked +//! session. The companion [`crate::detect_caller_session`]-style flow +//! (in `csa-session::caller_detect`) provides the JSONL path; this +//! module focuses purely on the read + filter + budget step. +//! +//! Filtering rules when [`PrefixConfig::skip_tool_results`] is `true`: +//! - Top-level `type` must be `"user"` or `"assistant"`; anything else +//! (progress events, system notes, API errors) is skipped. +//! - Within `message.content` arrays, blocks with `type == "tool_use"` +//! or `type == "tool_result"` are dropped before the message is +//! serialized to plain text. +//! - String-form `content` whose role is `"tool"` is dropped (defensive). +//! +//! Malformed JSON lines are skipped with a `tracing::debug!` log rather +//! than failing the whole extraction. + +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde_json::Value; +use tracing::debug; + +/// Default token budget when the caller does not override it. +pub const DEFAULT_PREFIX_BUDGET_TOKENS: usize = 32_768; + +/// Configuration for prefix extraction. +#[derive(Debug, Clone)] +pub struct PrefixConfig { + /// Maximum number of tokens to extract before truncating. + pub budget_tokens: usize, + /// If `true`, skip tool-result and tool-use content blocks. + pub skip_tool_results: bool, +} + +impl Default for PrefixConfig { + fn default() -> Self { + Self { + budget_tokens: DEFAULT_PREFIX_BUDGET_TOKENS, + skip_tool_results: true, + } + } +} + +/// Result of a [`PrefixExtractor::extract_prefix`] call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtractedPrefix { + /// Conversation text formatted for context injection. + pub content: String, + /// Estimated tokens consumed by [`Self::content`]. + pub token_count: usize, + /// Number of messages included in the prefix. + pub message_count: usize, + /// `true` if the budget was reached before the file was fully consumed. + pub truncated: bool, +} + +/// Reads a Claude Code JSONL file and produces a budget-limited prefix. +pub struct PrefixExtractor { + config: PrefixConfig, +} + +impl PrefixExtractor { + pub fn new(config: PrefixConfig) -> Self { + Self { config } + } + + /// Extract the conversation prefix from `jsonl_path`. + pub fn extract_prefix(&self, jsonl_path: &Path) -> Result { + let file = File::open(jsonl_path) + .with_context(|| format!("failed to open JSONL file: {}", jsonl_path.display()))?; + let reader = BufReader::new(file); + debug!(path = %jsonl_path.display(), budget = self.config.budget_tokens, "extracting prefix"); + self.extract_from_reader(reader) + } + + fn extract_from_reader(&self, reader: R) -> Result { + let mut parts: Vec = Vec::new(); + let mut token_count: usize = 0; + let mut message_count: usize = 0; + let mut truncated = false; + + for (line_no, line) in reader.lines().enumerate() { + let line = match line { + Ok(l) => l, + Err(err) => { + debug!(line_no, error = %err, "skipping unreadable JSONL line"); + continue; + } + }; + if line.trim().is_empty() { + continue; + } + + let value: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(err) => { + debug!(line_no, error = %err, "skipping malformed JSONL line"); + continue; + } + }; + + let Some(message) = extract_message(&self.config, &value) else { + continue; + }; + + let next_tokens = estimate_tokens(&message.text); + if token_count.saturating_add(next_tokens) > self.config.budget_tokens { + truncated = true; + debug!( + line_no, + token_count, + next_tokens, + budget = self.config.budget_tokens, + "budget exceeded; truncating prefix" + ); + break; + } + + parts.push(format!("[{}]\n{}", message.role, message.text.trim())); + token_count += next_tokens; + message_count += 1; + } + + let content = parts.join("\n\n"); + Ok(ExtractedPrefix { + content, + token_count, + message_count, + truncated, + }) + } +} + +struct Message { + role: String, + text: String, +} + +fn extract_message(config: &PrefixConfig, value: &Value) -> Option { + let outer_type = value.get("type").and_then(Value::as_str)?; + if outer_type != "user" && outer_type != "assistant" { + return None; + } + + let message = value.get("message")?; + let role = message.get("role").and_then(Value::as_str)?.to_string(); + let content = message.get("content")?; + + let text = match content { + Value::String(s) => { + if config.skip_tool_results && role == "tool" { + return None; + } + s.clone() + } + Value::Array(blocks) => { + let mut buf = String::new(); + for block in blocks { + let block_type = block.get("type").and_then(Value::as_str).unwrap_or(""); + if config.skip_tool_results + && (block_type == "tool_result" || block_type == "tool_use") + { + continue; + } + let snippet = block + .get("text") + .and_then(Value::as_str) + .or_else(|| block.get("content").and_then(Value::as_str)); + if let Some(s) = snippet { + if !buf.is_empty() { + buf.push('\n'); + } + buf.push_str(s); + } + } + if buf.is_empty() { + return None; + } + buf + } + _ => return None, + }; + + Some(Message { role, text }) +} + +/// Estimate token count using a simple word-based heuristic +/// (~4 chars per token, approximated as `words * 4 / 3`). +/// +/// Mirrors `csa-session::output_parser::estimate_tokens` rather than +/// pulling csa-session into the L3 transport crate. +fn estimate_tokens(content: &str) -> usize { + content.split_whitespace().count() * 4 / 3 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + fn extract(config: PrefixConfig, jsonl: &str) -> ExtractedPrefix { + let extractor = PrefixExtractor::new(config); + extractor + .extract_from_reader(Cursor::new(jsonl.as_bytes())) + .expect("extraction should not fail on in-memory reader") + } + + #[test] + fn empty_file_returns_empty_prefix() { + let result = extract(PrefixConfig::default(), ""); + assert_eq!(result.content, ""); + assert_eq!(result.token_count, 0); + assert_eq!(result.message_count, 0); + assert!(!result.truncated); + } + + #[test] + fn blank_lines_are_ignored() { + let result = extract(PrefixConfig::default(), "\n\n \n\n"); + assert_eq!(result.message_count, 0); + assert!(!result.truncated); + } + + #[test] + fn under_budget_includes_all_messages_and_keeps_order() { + let jsonl = concat!( + r#"{"type":"user","message":{"role":"user","content":"hello world"}}"#, + "\n", + r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi there"}]}}"#, + ); + + let result = extract(PrefixConfig::default(), jsonl); + assert_eq!(result.message_count, 2); + assert!(!result.truncated); + let user_idx = result.content.find("hello world").expect("user text"); + let asst_idx = result.content.find("hi there").expect("assistant text"); + assert!(user_idx < asst_idx, "messages should appear in input order"); + assert!(result.content.contains("[user]")); + assert!(result.content.contains("[assistant]")); + } + + #[test] + fn over_budget_truncates_after_first_fitting_message() { + // estimate_tokens("alpha beta gamma") = 3 * 4 / 3 = 4 + // budget = 4 ⇒ first fits exactly, second exceeds + let config = PrefixConfig { + budget_tokens: 4, + skip_tool_results: true, + }; + let jsonl = concat!( + r#"{"type":"user","message":{"role":"user","content":"alpha beta gamma"}}"#, + "\n", + r#"{"type":"user","message":{"role":"user","content":"second message text"}}"#, + ); + + let result = extract(config, jsonl); + assert!(result.truncated); + assert_eq!(result.message_count, 1); + assert!(result.content.contains("alpha beta gamma")); + assert!(!result.content.contains("second message text")); + assert_eq!(result.token_count, 4); + } + + #[test] + fn skip_tool_results_filters_tool_blocks() { + let jsonl = concat!( + r#"{"type":"user","message":{"role":"user","content":["#, + r#"{"type":"tool_result","content":"file contents secret"},"#, + r#"{"type":"text","text":"plain text"}"#, + r#"]}}"#, + "\n", + r#"{"type":"assistant","message":{"role":"assistant","content":["#, + r#"{"type":"tool_use","name":"Bash","input":{"command":"rm -rf /"}},"#, + r#"{"type":"text","text":"running command"}"#, + r#"]}}"#, + ); + + let result = extract(PrefixConfig::default(), jsonl); + assert!(result.content.contains("plain text")); + assert!(result.content.contains("running command")); + assert!(!result.content.contains("file contents secret")); + assert!(!result.content.contains("rm -rf")); + assert_eq!(result.message_count, 2); + } + + #[test] + fn keep_tool_results_when_disabled() { + let config = PrefixConfig { + budget_tokens: DEFAULT_PREFIX_BUDGET_TOKENS, + skip_tool_results: false, + }; + let jsonl = concat!( + r#"{"type":"user","message":{"role":"user","content":["#, + r#"{"type":"tool_result","content":"file contents"},"#, + r#"{"type":"text","text":"plain"}"#, + r#"]}}"#, + ); + + let result = extract(config, jsonl); + assert!(result.content.contains("file contents")); + assert!(result.content.contains("plain")); + } + + #[test] + fn malformed_json_lines_are_skipped() { + let jsonl = concat!( + "this is not json\n", + r#"{"type":"user","message":{"role":"user","content":"good message"}}"#, + "\n", + r#"{"oops":"missing brace"#, + "\n", + r#"{"type":"user","message":{"role":"user","content":"another message"}}"#, + ); + + let result = extract(PrefixConfig::default(), jsonl); + assert_eq!(result.message_count, 2); + assert!(result.content.contains("good message")); + assert!(result.content.contains("another message")); + assert!(!result.truncated); + } + + #[test] + fn non_user_assistant_entries_are_skipped() { + let jsonl = concat!( + r#"{"type":"progress","data":{"x":1}}"#, + "\n", + r#"{"type":"system","subtype":"api_error"}"#, + "\n", + r#"{"type":"user","message":{"role":"user","content":"only this"}}"#, + ); + + let result = extract(PrefixConfig::default(), jsonl); + assert_eq!(result.message_count, 1); + assert!(result.content.contains("only this")); + } + + #[test] + fn role_tool_string_content_is_skipped_when_filtering() { + let jsonl = + r#"{"type":"user","message":{"role":"tool","content":"raw tool dump"}}"#.to_string(); + let result = extract(PrefixConfig::default(), &jsonl); + assert_eq!(result.message_count, 0); + } + + #[test] + fn extract_prefix_reads_from_disk() { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join("session.jsonl"); + std::fs::write( + &path, + concat!( + r#"{"type":"user","message":{"role":"user","content":"disk hello"}}"#, + "\n", + ), + ) + .expect("write fixture"); + + let extractor = PrefixExtractor::new(PrefixConfig::default()); + let result = extractor + .extract_prefix(&path) + .expect("extract should succeed"); + assert_eq!(result.message_count, 1); + assert!(result.content.contains("disk hello")); + } + + #[test] + fn extract_prefix_missing_file_errors() { + let extractor = PrefixExtractor::new(PrefixConfig::default()); + let err = extractor + .extract_prefix(Path::new("/nonexistent/csa-test-prefix.jsonl")) + .expect_err("missing file should error"); + let msg = format!("{err:#}"); + assert!(msg.contains("failed to open JSONL file"), "got: {msg}"); + } +} diff --git a/crates/csa-config/src/config.rs b/crates/csa-config/src/config.rs index ae0e7a36..cdb3b848 100644 --- a/crates/csa-config/src/config.rs +++ b/crates/csa-config/src/config.rs @@ -210,8 +210,10 @@ fn default_recursion_depth() -> u32 { } pub use super::config_session::{ - DEFAULT_COOLDOWN_SECS, DEFAULT_RESULT_REPORT_SPILL_THRESHOLD_BYTES, ExecutionConfig, - HooksSection, PostExecGateConfig, RunConfig, SessionConfig, SnapshotTrigger, VcsConfig, + DEFAULT_COOLDOWN_SECS, DEFAULT_FORK_PREFIX_BUDGET_TOKENS, + DEFAULT_RESULT_REPORT_SPILL_THRESHOLD_BYTES, ExecutionConfig, FORK_PREFIX_BUDGET_MAX_TOKENS, + FORK_PREFIX_BUDGET_MIN_TOKENS, HooksSection, PostExecGateConfig, RunConfig, SessionConfig, + SnapshotTrigger, VcsConfig, }; pub use super::config_tool::{ ToolConfig, ToolFilesystemSandboxConfig, ToolRestrictions, TransportKind, diff --git a/crates/csa-config/src/config_session.rs b/crates/csa-config/src/config_session.rs index 6fac80ca..f1f13fe7 100644 --- a/crates/csa-config/src/config_session.rs +++ b/crates/csa-config/src/config_session.rs @@ -141,6 +141,14 @@ pub struct SessionConfig { /// daemon shutdown from hanging. Default: 5 seconds. #[serde(default = "default_stderr_drain_timeout_secs")] pub stderr_drain_timeout_secs: u64, + /// Token budget for CSA-lite fork prefix extraction (issue #1432). + /// + /// Caps the JSONL conversation prefix injected into a forked session. + /// `None` means use [`DEFAULT_FORK_PREFIX_BUDGET_TOKENS`] (32_768). + /// Resolved values are clamped to [`FORK_PREFIX_BUDGET_MIN_TOKENS`, + /// [`FORK_PREFIX_BUDGET_MAX_TOKENS`]]. + #[serde(default)] + pub fork_prefix_budget: Option, } fn default_seed_max_age_secs() -> u64 { @@ -183,6 +191,18 @@ const DEFAULT_SPOOL_MAX_MB: u32 = 32; const DEFAULT_STDERR_SPOOL_MAX_MB: u32 = 50; const DEFAULT_SPOOL_KEEP_ROTATED: bool = true; +/// Default token budget for CSA-lite fork prefix extraction (issue #1432). +/// +/// Mirrors `csa-acp::prefix_extract::DEFAULT_PREFIX_BUDGET_TOKENS` (32_768). +/// Duplicated here to avoid a `csa-config -> csa-acp` dependency edge. +pub const DEFAULT_FORK_PREFIX_BUDGET_TOKENS: u32 = 32_768; + +/// Minimum accepted value for `session.fork_prefix_budget`. +pub const FORK_PREFIX_BUDGET_MIN_TOKENS: u32 = 4_096; + +/// Maximum accepted value for `session.fork_prefix_budget`. +pub const FORK_PREFIX_BUDGET_MAX_TOKENS: u32 = 131_072; + impl Default for SessionConfig { fn default() -> Self { Self { @@ -201,6 +221,7 @@ impl Default for SessionConfig { result_report_spill_threshold_bytes: default_result_report_spill_threshold_bytes(), cooldown_seconds: default_cooldown_secs(), stderr_drain_timeout_secs: default_stderr_drain_timeout_secs(), + fork_prefix_budget: None, } } } @@ -223,6 +244,7 @@ impl SessionConfig { == default_result_report_spill_threshold_bytes() && self.cooldown_seconds == default_cooldown_secs() && self.stderr_drain_timeout_secs == default_stderr_drain_timeout_secs() + && self.fork_prefix_budget.is_none() } /// Resolve cooldown duration (0 = disabled). @@ -250,6 +272,20 @@ impl SessionConfig { self.spool_keep_rotated .unwrap_or(DEFAULT_SPOOL_KEEP_ROTATED) } + + /// Resolve the fork prefix token budget, clamping out-of-range values. + /// + /// Returns [`DEFAULT_FORK_PREFIX_BUDGET_TOKENS`] when unset; otherwise + /// clamps to [`FORK_PREFIX_BUDGET_MIN_TOKENS`, + /// [`FORK_PREFIX_BUDGET_MAX_TOKENS`]] silently. The corresponding + /// validation pass in `csa-config::validate` emits a user-visible + /// warning for out-of-range configured values. + pub fn resolved_fork_prefix_budget(&self) -> u32 { + match self.fork_prefix_budget { + None => DEFAULT_FORK_PREFIX_BUDGET_TOKENS, + Some(v) => v.clamp(FORK_PREFIX_BUDGET_MIN_TOKENS, FORK_PREFIX_BUDGET_MAX_TOKENS), + } + } } /// Project-level hook overrides (`[hooks]` in `.csa/config.toml`). diff --git a/crates/csa-config/src/config_tests_tail.rs b/crates/csa-config/src/config_tests_tail.rs index 52ea8fe7..98305ca9 100644 --- a/crates/csa-config/src/config_tests_tail.rs +++ b/crates/csa-config/src/config_tests_tail.rs @@ -440,6 +440,81 @@ fn test_session_config_is_default_reflects_result_report_spill_threshold() { assert!(!cfg.is_default()); } +#[test] +fn test_session_config_default_fork_prefix_budget_is_none() { + let cfg = SessionConfig::default(); + assert!(cfg.fork_prefix_budget.is_none()); + assert_eq!( + cfg.resolved_fork_prefix_budget(), + DEFAULT_FORK_PREFIX_BUDGET_TOKENS + ); + assert!(cfg.is_default()); +} + +#[test] +fn test_session_config_fork_prefix_budget_roundtrip() { + let cfg = SessionConfig { + fork_prefix_budget: Some(DEFAULT_FORK_PREFIX_BUDGET_TOKENS), + ..Default::default() + }; + let toml_str = toml::to_string(&cfg).expect("serialize"); + assert!( + toml_str.contains("fork_prefix_budget = 32768"), + "expected fork_prefix_budget in serialized output, got: {toml_str}" + ); + let parsed: SessionConfig = toml::from_str(&toml_str).expect("deserialize"); + assert_eq!( + parsed.fork_prefix_budget, + Some(DEFAULT_FORK_PREFIX_BUDGET_TOKENS) + ); + assert_eq!( + parsed.resolved_fork_prefix_budget(), + DEFAULT_FORK_PREFIX_BUDGET_TOKENS + ); +} + +#[test] +fn test_session_config_is_default_reflects_fork_prefix_budget() { + let cfg = SessionConfig { + fork_prefix_budget: Some(DEFAULT_FORK_PREFIX_BUDGET_TOKENS), + ..Default::default() + }; + assert!(!cfg.is_default()); +} + +#[test] +fn test_session_config_resolved_fork_prefix_budget_clamps_below_min() { + let cfg = SessionConfig { + fork_prefix_budget: Some(1024), + ..Default::default() + }; + assert_eq!( + cfg.resolved_fork_prefix_budget(), + FORK_PREFIX_BUDGET_MIN_TOKENS + ); +} + +#[test] +fn test_session_config_resolved_fork_prefix_budget_clamps_above_max() { + let cfg = SessionConfig { + fork_prefix_budget: Some(1_000_000), + ..Default::default() + }; + assert_eq!( + cfg.resolved_fork_prefix_budget(), + FORK_PREFIX_BUDGET_MAX_TOKENS + ); +} + +#[test] +fn test_session_config_resolved_fork_prefix_budget_passes_through_in_range() { + let cfg = SessionConfig { + fork_prefix_budget: Some(65_536), + ..Default::default() + }; + assert_eq!(cfg.resolved_fork_prefix_budget(), 65_536); +} + // --------------------------------------------------------------------------- // ResourcesConfig: initial_response_timeout_seconds // --------------------------------------------------------------------------- diff --git a/crates/csa-config/src/global_template.rs b/crates/csa-config/src/global_template.rs index 9e7454a5..3910e9f7 100644 --- a/crates/csa-config/src/global_template.rs +++ b/crates/csa-config/src/global_template.rs @@ -155,6 +155,10 @@ long_poll_seconds = 240 # ACP transport tuning. Project-level [acp] overrides these values. # [acp] # init_timeout_seconds = 120 # Timeout for ACP session creation (seconds) + +# Session behaviour. Project-level [session] overrides these values. +# [session] +# fork_prefix_budget = 32768 # CSA-lite fork prefix token budget; clamped to [4096, 131072] "# .to_string() } diff --git a/crates/csa-config/src/lib.rs b/crates/csa-config/src/lib.rs index 2f43de0e..c2e20fc8 100644 --- a/crates/csa-config/src/lib.rs +++ b/crates/csa-config/src/lib.rs @@ -28,10 +28,12 @@ pub mod weave_lock; pub use acp::AcpConfig; pub use config::{ - DEFAULT_COOLDOWN_SECS, DEFAULT_RESULT_REPORT_SPILL_THRESHOLD_BYTES, EnforcementMode, - ExecutionConfig, HooksSection, PostExecGateConfig, ProjectConfig, ProjectMeta, RunConfig, - SessionConfig, SnapshotTrigger, TierConfig, TierStrategy, ToolConfig, - ToolFilesystemSandboxConfig, ToolResourceProfile, ToolRestrictions, VcsConfig, + DEFAULT_COOLDOWN_SECS, DEFAULT_FORK_PREFIX_BUDGET_TOKENS, + DEFAULT_RESULT_REPORT_SPILL_THRESHOLD_BYTES, EnforcementMode, ExecutionConfig, + FORK_PREFIX_BUDGET_MAX_TOKENS, FORK_PREFIX_BUDGET_MIN_TOKENS, HooksSection, PostExecGateConfig, + ProjectConfig, ProjectMeta, RunConfig, SessionConfig, SnapshotTrigger, TierConfig, + TierStrategy, ToolConfig, ToolFilesystemSandboxConfig, ToolResourceProfile, ToolRestrictions, + VcsConfig, }; pub type MergedConfig = ProjectConfig; pub use config_filesystem_sandbox::FilesystemSandboxConfig; diff --git a/crates/csa-config/src/validate.rs b/crates/csa-config/src/validate.rs index 63688652..3905d3d6 100644 --- a/crates/csa-config/src/validate.rs +++ b/crates/csa-config/src/validate.rs @@ -2,7 +2,7 @@ use anyhow::{Result, bail}; use std::path::Path; use crate::TransportKind; -use crate::config::ProjectConfig; +use crate::config::{FORK_PREFIX_BUDGET_MAX_TOKENS, FORK_PREFIX_BUDGET_MIN_TOKENS, ProjectConfig}; use crate::global::ToolSelection; const KNOWN_TOOLS: &[&str] = &[ @@ -46,6 +46,7 @@ fn validate_loaded_config(config: Option) -> Result<()> { validate_debate(&config)?; validate_tiers(&config)?; warn_unknown_tool_priority(&config); + warn_fork_prefix_budget_out_of_range(&config); Ok(()) } @@ -403,6 +404,24 @@ fn validate_tiers(config: &ProjectConfig) -> Result<()> { Ok(()) } +/// Warn (non-fatal) when `session.fork_prefix_budget` falls outside the +/// supported range. The value is silently clamped at use-site by +/// [`crate::SessionConfig::resolved_fork_prefix_budget`]; this surfaces the +/// configuration mistake to the user. +fn warn_fork_prefix_budget_out_of_range(config: &ProjectConfig) { + let Some(raw) = config.session.fork_prefix_budget else { + return; + }; + if !(FORK_PREFIX_BUDGET_MIN_TOKENS..=FORK_PREFIX_BUDGET_MAX_TOKENS).contains(&raw) { + let clamped = raw.clamp(FORK_PREFIX_BUDGET_MIN_TOKENS, FORK_PREFIX_BUDGET_MAX_TOKENS); + eprintln!( + "warning: session.fork_prefix_budget = {raw} is outside the supported range \ + [{FORK_PREFIX_BUDGET_MIN_TOKENS}, {FORK_PREFIX_BUDGET_MAX_TOKENS}]; \ + clamping to {clamped}." + ); + } +} + /// Warn (non-fatal) if `preferences.tool_priority` contains unrecognized tool names. /// Unknown entries are harmless (sorted to end) but likely indicate a typo. fn warn_unknown_tool_priority(config: &ProjectConfig) { diff --git a/crates/csa-config/src/validate_tests_tail.rs b/crates/csa-config/src/validate_tests_tail.rs index bf707857..d6699bc0 100644 --- a/crates/csa-config/src/validate_tests_tail.rs +++ b/crates/csa-config/src/validate_tests_tail.rs @@ -433,6 +433,54 @@ fn test_validate_max_recursion_depth_zero() { assert!(result.is_ok(), "max_recursion_depth 0 should be valid"); } +#[test] +fn test_validate_config_warns_but_passes_on_fork_prefix_budget_below_min() { + let dir = tempdir().unwrap(); + + let mut tools = HashMap::new(); + tools.insert("codex".to_string(), ToolConfig::default()); + + let config = ProjectConfig { + schema_version: CURRENT_SCHEMA_VERSION, + project: ProjectMeta { + name: "test-project".to_string(), + created_at: Utc::now(), + max_recursion_depth: 5, + }, + resources: ResourcesConfig::default(), + acp: Default::default(), + tools, + review: None, + debate: None, + tiers: HashMap::new(), + tier_mapping: HashMap::new(), + aliases: HashMap::new(), + tool_aliases: HashMap::new(), + preferences: None, + github: None, + session: crate::SessionConfig { + fork_prefix_budget: Some(512), + ..Default::default() + }, + memory: Default::default(), + hooks: Default::default(), + run: Default::default(), + execution: Default::default(), + session_wait: None, + preflight: Default::default(), + vcs: Default::default(), + filesystem_sandbox: Default::default(), + }; + + config.save(dir.path()).unwrap(); + let config_path = dir.path().join(".csa").join("config.toml"); + let result = validate_config_with_paths(None, &config_path); + assert!( + result.is_ok(), + "out-of-range fork_prefix_budget should warn, not fail: {result:?}" + ); +} + include!("validate_tests_deprecated.rs"); include!("validate_tests_preferences.rs"); include!("validate_tests_sandbox.rs"); diff --git a/crates/csa-core/src/transport_events.rs b/crates/csa-core/src/transport_events.rs index a95ba8b0..045592c1 100644 --- a/crates/csa-core/src/transport_events.rs +++ b/crates/csa-core/src/transport_events.rs @@ -21,6 +21,31 @@ pub struct StreamingMetadata { pub thought_text: String, /// Whether the output used thought text as fallback. pub has_thought_fallback: bool, + /// Total input tokens reported by the underlying API response, when available. + pub input_tokens: Option, + /// Total output tokens reported by the underlying API response, when available. + pub output_tokens: Option, + /// Cache-read input tokens reported by the Anthropic API response. + /// + /// Anthropic returns `cache_read_input_tokens` in the response `usage` + /// block when prompt caching is active. Older API responses and non-Claude + /// backends may omit it, hence `Option`. + pub cache_read_input_tokens: Option, +} + +impl StreamingMetadata { + /// Ratio of cache-read input tokens to total input tokens (`cache_read / input_tokens`). + /// + /// Returns `None` when either field is missing or when `input_tokens` is + /// zero (no meaningful denominator). + pub fn cache_hit_ratio(&self) -> Option { + let cache_read = self.cache_read_input_tokens? as f64; + let total_input = self.input_tokens? as f64; + if total_input == 0.0 { + return None; + } + Some(cache_read / total_input) + } } /// Streaming session events collected from agent notifications. diff --git a/crates/csa-executor/src/transport.rs b/crates/csa-executor/src/transport.rs index 42a50068..84c5cba6 100644 --- a/crates/csa-executor/src/transport.rs +++ b/crates/csa-executor/src/transport.rs @@ -750,6 +750,9 @@ fn convert_acp_metadata( message_text: metadata.message_text, thought_text: metadata.thought_text, has_thought_fallback: metadata.has_thought_fallback, + input_tokens: metadata.input_tokens, + output_tokens: metadata.output_tokens, + cache_read_input_tokens: metadata.cache_read_input_tokens, } } diff --git a/crates/csa-session/Cargo.toml b/crates/csa-session/Cargo.toml index 725352d2..9a409b1c 100644 --- a/crates/csa-session/Cargo.toml +++ b/crates/csa-session/Cargo.toml @@ -23,6 +23,7 @@ regex.workspace = true sha2.workspace = true data-encoding = "2.6" tempfile.workspace = true +xurl-core.workspace = true [dev-dependencies] tracing-subscriber.workspace = true diff --git a/crates/csa-session/src/caller_detect.rs b/crates/csa-session/src/caller_detect.rs new file mode 100644 index 00000000..a96d6d3c --- /dev/null +++ b/crates/csa-session/src/caller_detect.rs @@ -0,0 +1,263 @@ +//! Caller session auto-detection for CSA-lite fork (issue #1432). +//! +//! Discovers the *caller's* Claude session — the conversation that +//! invoked CSA — so a fork operation can reload the caller's history. +//! Detection prefers the zero-cost `CLAUDE_SESSION_ID` env var; if +//! unset, falls back to a `xurl_core` query for the most recently +//! updated Claude thread on disk. + +use std::env; +use std::path::PathBuf; + +use tracing::debug; +use xurl_core::{AgentsUri, ProviderKind, ProviderRoots, ThreadQuery, resolve_thread}; + +const CLAUDE_SESSION_ID_ENV: &str = "CLAUDE_SESSION_ID"; +const CLAUDE_PROVIDER: &str = "claude"; + +/// Information about a caller session discovered on disk. +/// +/// `session_dir` is the directory containing `jsonl_path` — for Claude +/// this is `~/.claude/projects//`. Callers needing +/// per-session state (e.g. shadow checkpoints) should derive it from +/// `jsonl_path` rather than assume one-directory-per-session. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CallerSessionInfo { + pub session_id: String, + pub jsonl_path: PathBuf, + pub session_dir: PathBuf, + pub provider: String, +} + +/// Detect the caller's session, preferring the `CLAUDE_SESSION_ID` +/// env var over an `xurl_core`-based latest-thread fallback. +/// +/// Returns `None` when no session can be resolved (env unset and no +/// Claude threads on disk, or the resolved JSONL file is missing). +pub fn detect_caller_session() -> Option { + if let Some(info) = detect_from_env() { + debug!( + session_id = %info.session_id, + jsonl = %info.jsonl_path.display(), + "caller session detected via CLAUDE_SESSION_ID" + ); + return Some(info); + } + + if let Some(info) = detect_from_xurl_latest() { + debug!( + session_id = %info.session_id, + jsonl = %info.jsonl_path.display(), + "caller session detected via xurl latest-thread fallback" + ); + return Some(info); + } + + debug!("no caller session detected"); + None +} + +fn detect_from_env() -> Option { + let raw = env::var(CLAUDE_SESSION_ID_ENV).ok()?; + let session_id = raw.trim(); + if session_id.is_empty() { + debug!("CLAUDE_SESSION_ID is empty; skipping env detection"); + return None; + } + + let roots = match ProviderRoots::from_env_or_home() { + Ok(roots) => roots, + Err(err) => { + debug!(error = %err, "failed to resolve provider roots"); + return None; + } + }; + + let uri_str = format!("{CLAUDE_PROVIDER}://{session_id}"); + let uri: AgentsUri = match uri_str.parse() { + Ok(uri) => uri, + Err(err) => { + debug!(uri = %uri_str, error = %err, "failed to parse claude URI"); + return None; + } + }; + + let resolved = match resolve_thread(&uri, &roots) { + Ok(resolved) => resolved, + Err(err) => { + debug!(session_id = %session_id, error = %err, "xurl could not resolve session"); + return None; + } + }; + + build_info(resolved.session_id, resolved.path) +} + +fn detect_from_xurl_latest() -> Option { + let roots = ProviderRoots::from_env_or_home().ok()?; + let query = ThreadQuery { + uri: format!("{CLAUDE_PROVIDER}://"), + provider: ProviderKind::Claude, + role: None, + q: None, + limit: 1, + ignored_params: Vec::new(), + }; + + let result = xurl_core::query_threads(&query, &roots).ok()?; + let item = result.items.into_iter().next()?; + build_info(item.thread_id, PathBuf::from(item.thread_source)) +} + +fn build_info(session_id: String, jsonl_path: PathBuf) -> Option { + if !jsonl_path.is_file() { + debug!( + jsonl = %jsonl_path.display(), + "resolved JSONL path is not a file; rejecting" + ); + return None; + } + let session_dir = jsonl_path.parent()?.to_path_buf(); + Some(CallerSessionInfo { + session_id, + jsonl_path, + session_dir, + provider: CLAUDE_PROVIDER.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_env::TEST_ENV_LOCK; + use std::fs; + use tempfile::TempDir; + + /// RAII guard that sets env vars on construction and clears them on drop. + /// Holds TEST_ENV_LOCK to serialize tests that mutate process-wide env. + struct EnvGuard { + _lock: std::sync::MutexGuard<'static, ()>, + previous: Vec<(String, Option)>, + } + + impl EnvGuard { + fn new(vars: &[(&str, Option<&str>)]) -> Self { + let lock = TEST_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut previous = Vec::new(); + for (key, value) in vars { + previous.push(((*key).to_string(), env::var(key).ok())); + match value { + // SAFETY: serialized by TEST_ENV_LOCK. + Some(v) => unsafe { env::set_var(key, v) }, + // SAFETY: serialized by TEST_ENV_LOCK. + None => unsafe { env::remove_var(key) }, + } + } + Self { + _lock: lock, + previous, + } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, prev) in &self.previous { + match prev { + // SAFETY: serialized by TEST_ENV_LOCK. + Some(v) => unsafe { env::set_var(key, v) }, + // SAFETY: serialized by TEST_ENV_LOCK. + None => unsafe { env::remove_var(key) }, + } + } + } + } + + /// Build a fake `~/.claude/projects//.jsonl` + /// tree under `tempdir`, returning the JSONL path. + fn seed_claude_session(tempdir: &TempDir, session_id: &str) -> PathBuf { + let projects = tempdir.path().join("projects/-fake-project"); + fs::create_dir_all(&projects).expect("create projects dir"); + let jsonl = projects.join(format!("{session_id}.jsonl")); + let header = serde_json::json!({ + "type": "summary", + "sessionId": session_id, + }); + fs::write(&jsonl, format!("{header}\n")).expect("write jsonl"); + jsonl + } + + #[test] + fn env_var_set_with_valid_session_returns_some() { + let tempdir = TempDir::new().expect("tempdir"); + let session_id = "11111111-2222-3333-4444-555555555555"; + let jsonl = seed_claude_session(&tempdir, session_id); + + let claude_root = tempdir.path().to_string_lossy().to_string(); + let _guard = EnvGuard::new(&[ + (CLAUDE_SESSION_ID_ENV, Some(session_id)), + ("CLAUDE_CONFIG_DIR", Some(claude_root.as_str())), + ]); + + let info = detect_caller_session().expect("session should resolve"); + assert_eq!(info.session_id, session_id); + assert_eq!(info.jsonl_path, jsonl); + assert_eq!(info.session_dir, jsonl.parent().unwrap()); + assert_eq!(info.provider, CLAUDE_PROVIDER); + } + + #[test] + fn env_var_set_with_missing_session_returns_none() { + let tempdir = TempDir::new().expect("tempdir"); + fs::create_dir_all(tempdir.path().join("projects")).expect("mkdir"); + + let claude_root = tempdir.path().to_string_lossy().to_string(); + let _guard = EnvGuard::new(&[ + ( + CLAUDE_SESSION_ID_ENV, + Some("00000000-0000-0000-0000-000000000000"), + ), + ("CLAUDE_CONFIG_DIR", Some(claude_root.as_str())), + ]); + + assert!(detect_caller_session().is_none()); + } + + #[test] + fn env_var_empty_falls_through_to_fallback() { + let tempdir = TempDir::new().expect("tempdir"); + fs::create_dir_all(tempdir.path().join("projects")).expect("mkdir"); + + let claude_root = tempdir.path().to_string_lossy().to_string(); + let _guard = EnvGuard::new(&[ + (CLAUDE_SESSION_ID_ENV, Some("")), + ("CLAUDE_CONFIG_DIR", Some(claude_root.as_str())), + ]); + + // No sessions seeded → fallback returns None too. + assert!(detect_caller_session().is_none()); + } + + #[test] + fn env_var_unset_with_no_sessions_returns_none() { + let tempdir = TempDir::new().expect("tempdir"); + fs::create_dir_all(tempdir.path().join("projects")).expect("mkdir"); + + let claude_root = tempdir.path().to_string_lossy().to_string(); + let _guard = EnvGuard::new(&[ + (CLAUDE_SESSION_ID_ENV, None), + ("CLAUDE_CONFIG_DIR", Some(claude_root.as_str())), + ]); + + assert!(detect_caller_session().is_none()); + } + + #[test] + fn build_info_rejects_nonfile_path() { + let tempdir = TempDir::new().expect("tempdir"); + let missing = tempdir.path().join("nope.jsonl"); + assert!(build_info("sid".to_string(), missing).is_none()); + } +} diff --git a/crates/csa-session/src/checkpoint_tests.rs b/crates/csa-session/src/checkpoint_tests.rs index 3a2e2e3d..4d9a558c 100644 --- a/crates/csa-session/src/checkpoint_tests.rs +++ b/crates/csa-session/src/checkpoint_tests.rs @@ -423,6 +423,7 @@ fn test_note_from_session() { output_tokens: Some(500), total_tokens: Some(1500), estimated_cost_usd: None, + cache_read_input_tokens: None, }), phase: crate::state::SessionPhase::Retired, task_context: Default::default(), diff --git a/crates/csa-session/src/lib.rs b/crates/csa-session/src/lib.rs index b73adcd5..43587639 100644 --- a/crates/csa-session/src/lib.rs +++ b/crates/csa-session/src/lib.rs @@ -1,6 +1,7 @@ //! Session management with ULID-based genealogy tracking. pub mod adjudication; +pub mod caller_detect; pub mod checklist_store; pub mod checkpoint; pub mod cooldown; @@ -50,6 +51,7 @@ pub use cooldown::{ // Re-export key types pub use adjudication::{AdjudicationRecord, AdjudicationSet, Verdict}; +pub use caller_detect::{CallerSessionInfo, detect_caller_session}; pub use checklist_store::ChecklistStore; pub use state::{ ContextStatus, Genealogy, MetaSessionState, PhaseEvent, ReviewSessionMeta, SandboxInfo, diff --git a/crates/csa-session/src/state.rs b/crates/csa-session/src/state.rs index 1fc569d3..ac868e1f 100644 --- a/crates/csa-session/src/state.rs +++ b/crates/csa-session/src/state.rs @@ -318,6 +318,29 @@ pub struct TokenUsage { /// Estimated cost in USD #[serde(default, skip_serializing_if = "Option::is_none")] pub estimated_cost_usd: Option, + + /// Cache-read input tokens (Anthropic prompt caching). + /// + /// When present, this is the portion of `input_tokens` served from the + /// provider's prompt cache. Older sessions and non-Claude tools may not + /// populate this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_read_input_tokens: Option, +} + +impl TokenUsage { + /// Ratio of cache-read input tokens to total input tokens (`cache_read / input_tokens`). + /// + /// Returns `None` when either field is missing or when `input_tokens` is + /// zero (no meaningful denominator). + pub fn cache_hit_ratio(&self) -> Option { + let cache_read = self.cache_read_input_tokens? as f64; + let total_input = self.input_tokens? as f64; + if total_input == 0.0 { + return None; + } + Some(cache_read / total_input) + } } /// Token budget for session-level resource governance. diff --git a/weave.lock b/weave.lock index 15eb2533..3e7c8309 100644 --- a/weave.lock +++ b/weave.lock @@ -1,6 +1,6 @@ [versions] -csa = "0.1.652" -weave = "0.1.652" +csa = "0.1.732" +weave = "0.1.732" last_migrated_at = "2026-03-08T12:08:01.820964091Z" [migrations]