supercode-harness 0.4.8

The optional native Supercode agent and tool harness
Documentation
//! P4b (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P4", §1.6/§3.1, catalog §4a
//! "Turn/step usage records surfaced per turn"): persisted per-turn
//! token/usage records. [`crate::AgentEvent::Usage`] already streams this
//! data live (UX-23); this module makes it DURABLE session data — a typed,
//! serde-round-trippable record, not a lossy display-only channel (§1.13's
//! lossless/sidecar discipline: this is typed session data, exactly like
//! [`crate::reduce::ReductionLog`], not a text notice).

use serde::{Deserialize, Serialize};

use crate::provider::Usage;

/// One model round-trip's token accounting, with the context a bare
/// [`Usage`] lacks: which turn it was and which model served it (D9 "model
/// provenance" — obligation 6's "served-model provenance" row). Deliberately
/// flat/typed (not a formatted string) so it survives a save/load round trip
/// byte-for-byte in the fields that matter, and so a future reader (a
/// `doctor`/`inspect stats` command, a cost dashboard) can aggregate it
/// without re-parsing text.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct UsageRecord {
    /// 0-based index of the model round-trip this record covers (one per
    /// [`crate::AgentEvent::TurnCompleted`], the same cadence `UX-23`
    /// already uses).
    pub turn: usize,
    /// The model id that served this turn (`Config.model`, or the
    /// mid-session-switched model once obligation 10 lands — recorded
    /// per-turn rather than once per session so a handoff is visible in the
    /// log, not just implied).
    pub model: String,
    /// Input tokens.
    #[serde(default)]
    pub prompt_tokens: u64,
    /// Output tokens.
    #[serde(default)]
    pub completion_tokens: u64,
    /// Total tokens (provider-reported; not always `prompt + completion`
    /// exactly, so kept as its own field rather than derived).
    #[serde(default)]
    pub total_tokens: u64,
    /// Prompt tokens served from the provider's cache (B7), if reported.
    #[serde(default)]
    pub cached_tokens: Option<u64>,
    /// Unix-ms wall-clock time the record was created.
    #[serde(default)]
    pub timestamp_ms: i64,
}

impl UsageRecord {
    /// Build a record from a provider [`Usage`] plus the per-turn context a
    /// bare `Usage` doesn't carry.
    pub fn from_usage(turn: usize, model: &str, usage: &Usage, timestamp_ms: i64) -> UsageRecord {
        UsageRecord {
            turn,
            model: model.to_string(),
            prompt_tokens: usage.prompt_tokens,
            completion_tokens: usage.completion_tokens,
            total_tokens: usage.total_tokens,
            cached_tokens: usage.prompt_tokens_details.map(|d| d.cached_tokens),
            timestamp_ms,
        }
    }
}

/// Serialize `records` as JSONL (one [`UsageRecord`] per line) — the same
/// shape every other append-log in this crate uses (the transcript, the
/// sidecar). Never fails on an empty slice (produces an empty string).
pub fn to_jsonl(records: &[UsageRecord]) -> crate::Result<String> {
    let mut out = String::new();
    for r in records {
        out.push_str(&serde_json::to_string(r).map_err(crate::Error::Decode)?);
        out.push('\n');
    }
    Ok(out)
}

/// Parse a JSONL usage log back into records — the exact inverse of
/// [`to_jsonl`]. Blank lines are skipped (tolerates a trailing newline or
/// hand-edited whitespace); a malformed line is a hard error (unlike the
/// hooks/sidecar "fail open" posture — a corrupt usage record should be
/// visible, not silently dropped, since it's accounting data).
pub fn from_jsonl(text: &str) -> crate::Result<Vec<UsageRecord>> {
    let mut out = Vec::new();
    for line in text.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        out.push(serde_json::from_str(line).map_err(crate::Error::Decode)?);
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::provider::PromptTokensDetails;

    #[test]
    fn from_usage_carries_every_field() {
        let usage = Usage {
            prompt_tokens: 100,
            completion_tokens: 20,
            total_tokens: 120,
            prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 40 }),
        };
        let r = UsageRecord::from_usage(2, "anthropic/claude-opus-4-8", &usage, 1_700_000_000_000);
        assert_eq!(r.turn, 2);
        assert_eq!(r.model, "anthropic/claude-opus-4-8");
        assert_eq!(r.prompt_tokens, 100);
        assert_eq!(r.completion_tokens, 20);
        assert_eq!(r.total_tokens, 120);
        assert_eq!(r.cached_tokens, Some(40));
        assert_eq!(r.timestamp_ms, 1_700_000_000_000);
    }

    #[test]
    fn from_usage_with_no_cache_details_is_none() {
        let usage = Usage {
            prompt_tokens: 10,
            completion_tokens: 5,
            total_tokens: 15,
            prompt_tokens_details: None,
        };
        let r = UsageRecord::from_usage(0, "m", &usage, 0);
        assert_eq!(r.cached_tokens, None);
    }

    #[test]
    fn jsonl_round_trip_is_lossless() {
        let records = vec![
            UsageRecord {
                turn: 0,
                model: "anthropic/claude-opus-4-8".to_string(),
                prompt_tokens: 1000,
                completion_tokens: 50,
                total_tokens: 1050,
                cached_tokens: Some(200),
                timestamp_ms: 1_700_000_000_000,
            },
            UsageRecord {
                turn: 1,
                model: "anthropic/claude-haiku-4-5".to_string(),
                prompt_tokens: 2000,
                completion_tokens: 75,
                total_tokens: 2075,
                cached_tokens: None,
                timestamp_ms: 1_700_000_005_000,
            },
        ];
        let jsonl = to_jsonl(&records).unwrap();
        let round_tripped = from_jsonl(&jsonl).unwrap();
        assert_eq!(records, round_tripped);
    }

    #[test]
    fn empty_records_round_trip_to_empty() {
        assert_eq!(to_jsonl(&[]).unwrap(), "");
        assert_eq!(from_jsonl("").unwrap(), Vec::<UsageRecord>::new());
    }

    #[test]
    fn from_jsonl_skips_blank_lines() {
        let text = "\n\n";
        assert_eq!(from_jsonl(text).unwrap(), Vec::<UsageRecord>::new());
    }
}