supercode-harness 0.4.18

The optional native Supercode agent and tool harness
Documentation
//! BP-7 (catalog §4a "Turn/step bracketing records", "Interrupt/abort with
//! state preserved", "Auto-retry on transient provider errors"): the
//! persisted per-round-trip marker log — cc's `turn_duration`/`api_retry`
//! system records and cx's `turn_context`/`turn_aborted`/`responses_retry`
//! rows, in one typed shape.
//!
//! **Where it lands.** `<session>.events.jsonl`, the sidecar-family member
//! [`crate::store::SessionStore`] has always reserved and swept
//! (archive/delete) but never had a writer for. So this is not a new store:
//! it is the log that slot was cut for, filled in — beside the transcript,
//! the reduction log, the usage log and the git-metadata record, exactly
//! like every other family member (§1.13's "typed session data, never a
//! lossy display-only channel").
//!
//! **Relationship to the usage log.** [`crate::usage_log::UsageRecord`] is
//! the ACCOUNTING projection: one row per round-trip, aggregatable by a
//! cost dashboard. This is the BRACKETING projection: what the request was
//! built over (`Context`), what it cost (`Usage`), how the round-trip ended
//! (`Finish`), and the two things that happen *between* round-trips
//! (`Retry`, `Aborted`). They are written from the same points in
//! `Agent::run_loop` and never disagree; keeping them separate keeps a
//! usage-log reader from having to skip four record kinds it does not care
//! about.

use serde::{Deserialize, Serialize};

/// Why a model round-trip (or a whole `send` loop) ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FinishReason {
    /// The assistant asked for tool calls; the loop continues.
    ToolCalls,
    /// The assistant produced a final answer and the loop returned.
    EndTurn,
    /// `core.max_iterations` was exhausted.
    MaxIterations,
    /// `core.max_total_output_tokens` was reached.
    OutputTokenBudget,
    /// `core.max_budget_usd` was reached.
    SpendBudget,
    /// `core.max_steps` was reached.
    StepBudget,
}

impl FinishReason {
    /// The wire spelling, for a reader that renders these without serde.
    pub fn label(self) -> &'static str {
        match self {
            FinishReason::ToolCalls => "tool_calls",
            FinishReason::EndTurn => "end_turn",
            FinishReason::MaxIterations => "max_iterations",
            FinishReason::OutputTokenBudget => "output_token_budget",
            FinishReason::SpendBudget => "spend_budget",
            FinishReason::StepBudget => "step_budget",
        }
    }
}

/// One bracketing marker. The `marker` tag is the record's kind.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "marker", rename_all = "snake_case")]
pub enum TurnMarker {
    /// Opens a round-trip: the shape of the context the request was built
    /// over, captured BEFORE the request is issued (so it survives a
    /// request that never returns).
    Context {
        /// Messages in the request.
        messages: usize,
        /// Tool schemas advertised on the request.
        tools: usize,
        /// Estimated prompt tokens (`crate::tokens`' own estimator — the
        /// same one the context guard uses, so the two never disagree).
        estimated_tokens: u64,
    },
    /// The round-trip's provider-reported token accounting, plus its dollar
    /// cost when the model is priceable ([`crate::pricing`]).
    Usage {
        /// Input tokens.
        prompt_tokens: u64,
        /// Output tokens.
        completion_tokens: u64,
        /// Provider-reported total.
        total_tokens: u64,
        /// Prompt tokens served from the provider's cache, if reported.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        cached_tokens: Option<u64>,
        /// Dollar cost, `None` when this build cannot price the model.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        cost_usd: Option<f64>,
    },
    /// Closes a round-trip (or the loop).
    Finish {
        /// Why it ended.
        reason: FinishReason,
    },
    /// A transient provider failure was retried with backoff. Written from
    /// the notices the transport's own retry loop records, so a retried
    /// request is visible in the log instead of being invisible the way the
    /// ledger's `auto-retry-on-transient-provider-errors` row described.
    Retry {
        /// 0-based attempt index that FAILED (attempt 0 is the first try).
        attempt: u32,
        /// Backoff slept before the next attempt, milliseconds.
        delay_ms: u64,
        /// One-line reason (HTTP status or transport error).
        reason: String,
    },
    /// The turn was interrupted (Ctrl-C / a cancelled `send` future). The
    /// partial work already appended to the transcript stands; this marker
    /// is what makes the interruption a FACT on reload rather than
    /// something a reader has to infer from a dangling tool call.
    Aborted {
        /// Where the interruption came from (`"ctrl_c"`, `"cancelled"`, …).
        source: String,
        /// Messages in the agent's history at the moment of the abort.
        messages: usize,
    },
    /// Reasoning effort changed mid-session (`/effort`), the extended-
    /// thinking analog of the `model_change` log.
    Effort {
        /// Effort before the change (`None` = thinking off).
        #[serde(default, skip_serializing_if = "Option::is_none")]
        from: Option<String>,
        /// Effort after the change (`None` = thinking off).
        #[serde(default, skip_serializing_if = "Option::is_none")]
        to: Option<String>,
    },
    /// The session's persistent objective was set, changed, or cleared
    /// (`/goal`). The goal itself lives in `<session>.goal.json`; this is
    /// the audit trail of when it moved.
    Goal {
        /// The objective after the change; empty means cleared.
        objective: String,
    },
}

/// One marker with the per-round-trip context every marker shares.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TurnRecord {
    /// 0-based index of the model round-trip this record brackets — the
    /// same counter [`crate::usage_log::UsageRecord::turn`] uses, so the
    /// two logs join on it.
    pub turn: usize,
    /// The model in effect when the marker was written.
    pub model: String,
    /// Unix-ms wall-clock time.
    pub timestamp_ms: i64,
    /// The marker itself.
    #[serde(flatten)]
    pub marker: TurnMarker,
}

impl TurnRecord {
    /// Build a record for `marker`.
    pub fn new(turn: usize, model: &str, timestamp_ms: i64, marker: TurnMarker) -> TurnRecord {
        TurnRecord {
            turn,
            model: model.to_string(),
            timestamp_ms,
            marker,
        }
    }
}

/// Serialize `records` as JSONL — the same shape every other append-log in
/// this crate uses.
pub fn to_jsonl(records: &[TurnRecord]) -> 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 marker log back into records — the exact inverse of
/// [`to_jsonl`]. Blank lines are skipped; a malformed line is a hard error
/// (this is an audit log, so a corrupt record should be visible, never
/// silently dropped — same posture as [`crate::usage_log::from_jsonl`]).
pub fn from_jsonl(text: &str) -> crate::Result<Vec<TurnRecord>> {
    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::*;

    fn sample() -> Vec<TurnRecord> {
        vec![
            TurnRecord::new(
                0,
                "anthropic/claude-opus-4-8",
                1_700_000_000_000,
                TurnMarker::Context {
                    messages: 3,
                    tools: 4,
                    estimated_tokens: 1200,
                },
            ),
            TurnRecord::new(
                0,
                "anthropic/claude-opus-4-8",
                1_700_000_001_000,
                TurnMarker::Usage {
                    prompt_tokens: 1000,
                    completion_tokens: 50,
                    total_tokens: 1050,
                    cached_tokens: Some(200),
                    cost_usd: Some(0.01875),
                },
            ),
            TurnRecord::new(
                0,
                "anthropic/claude-opus-4-8",
                1_700_000_001_100,
                TurnMarker::Finish {
                    reason: FinishReason::EndTurn,
                },
            ),
            TurnRecord::new(
                1,
                "anthropic/claude-opus-4-8",
                1_700_000_002_000,
                TurnMarker::Retry {
                    attempt: 0,
                    delay_ms: 500,
                    reason: "provider status 503".to_string(),
                },
            ),
            TurnRecord::new(
                1,
                "anthropic/claude-opus-4-8",
                1_700_000_003_000,
                TurnMarker::Aborted {
                    source: "ctrl_c".to_string(),
                    messages: 7,
                },
            ),
            TurnRecord::new(
                2,
                "anthropic/claude-opus-4-8",
                1_700_000_004_000,
                TurnMarker::Effort {
                    from: Some("medium".to_string()),
                    to: None,
                },
            ),
            TurnRecord::new(
                2,
                "anthropic/claude-opus-4-8",
                1_700_000_005_000,
                TurnMarker::Goal {
                    objective: "ship BP-7".to_string(),
                },
            ),
        ]
    }

    #[test]
    fn jsonl_round_trip_is_lossless_for_every_marker_kind() {
        let records = sample();
        let jsonl = to_jsonl(&records).unwrap();
        assert_eq!(jsonl.lines().count(), records.len());
        assert_eq!(from_jsonl(&jsonl).unwrap(), records);
    }

    #[test]
    fn the_marker_tag_names_the_kind_on_the_wire() {
        let jsonl = to_jsonl(&sample()).unwrap();
        let kinds: Vec<String> = jsonl
            .lines()
            .map(|l| {
                serde_json::from_str::<serde_json::Value>(l).unwrap()["marker"]
                    .as_str()
                    .unwrap()
                    .to_string()
            })
            .collect();
        assert_eq!(
            kinds,
            vec!["context", "usage", "finish", "retry", "aborted", "effort", "goal"]
        );
    }

    #[test]
    fn a_record_carries_the_turn_index_the_usage_log_joins_on() {
        let v: serde_json::Value =
            serde_json::from_str(to_jsonl(&sample()).unwrap().lines().next().unwrap()).unwrap();
        assert_eq!(v["turn"], 0);
        assert_eq!(v["model"], "anthropic/claude-opus-4-8");
        assert_eq!(v["messages"], 3);
    }

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

    #[test]
    fn a_malformed_line_is_an_error_not_a_silent_drop() {
        assert!(from_jsonl("{\"turn\":0}\n").is_err());
    }

    #[test]
    fn finish_reason_labels_match_the_wire_spelling() {
        for reason in [
            FinishReason::ToolCalls,
            FinishReason::EndTurn,
            FinishReason::MaxIterations,
            FinishReason::OutputTokenBudget,
            FinishReason::SpendBudget,
            FinishReason::StepBudget,
        ] {
            let v = serde_json::to_value(reason).unwrap();
            assert_eq!(v.as_str(), Some(reason.label()));
        }
    }
}