supercode-runtime 0.4.8

Optional native model and tool runtime for Supercode
Documentation
use supercode_interchange::ToolCall;

use crate::Usage;

/// Streaming events emitted by a native runtime agent as a turn unfolds.
///
/// Attach an [`EventSink`] to a runtime configuration to observe these live — for
/// example to render tokens to a terminal as they arrive, or to surface tool
/// activity in a UI.
///
/// `#[non_exhaustive]` because new event kinds will be added over time; match
/// with a `_` arm so a new variant is not a breaking change.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum AgentEvent {
    /// A chunk of assistant text was produced.
    TextDelta(String),

    /// The assistant finished a text/tool turn (one round-trip to the model).
    TurnCompleted,

    /// The model requested a tool call (fired before the tool runs).
    ToolCallStarted {
        /// Provider-assigned call id.
        id: String,
        /// Tool name.
        name: String,
        /// Raw JSON argument string as sent by the model.
        arguments: String,
    },

    /// A tool finished running.
    ToolCallCompleted {
        /// Provider-assigned call id.
        id: String,
        /// Tool name.
        name: String,
        /// The tool's textual output (truncated for display upstream if needed).
        output: String,
        /// Whether the tool reported an error.
        is_error: bool,
    },

    /// UX-26 (B7-warn): the turn that just completed likely paid a
    /// full-price prompt-cache miss despite reuse being expected under
    /// an imported-prefix cache plan. Emitted at most once per turn, only when
    /// cache warnings are enabled (default on) and reuse was genuinely
    /// expected (never on a
    /// first/establishing request, a same-turn tool-schema-tier bust, or
    /// under a disabled cache plan — so this never fires as a false
    /// positive on a cold-by-design request).
    CacheWarning {
        /// Ready-to-print, human-readable warning line (no trailing newline).
        message: String,
    },

    /// UX-23: token accounting for the request that just completed (one per
    /// model round-trip — a multi-tool-call turn emits one of these per
    /// round-trip, same cadence as [`AgentEvent::TurnCompleted`], which this
    /// is always emitted immediately before). Reuses the provider
    /// completion's usage return rather than introducing a
    /// second accounting path, so `--trace`/`stream-json` consumers see
    /// exactly the numbers the provider reported — never a derived estimate.
    Usage(Usage),

    /// P5-6 (COMPOSABLE-HARNESS-DESIGN.md §2 module 4 `tools.background`,
    /// D1 "monitor/event feed"): new output a background job (spawned via
    /// the `background_exec` intrinsic) has produced since the last
    /// `background_status` poll — the "event feed" `capabilities.
    /// tools_background` promises. Emitted from
    /// the runtime agent's background-status operation, at most once per poll,
    /// only when there IS new output (an idle poll of a still-running job
    /// with nothing new to report emits nothing).
    BackgroundOutput {
        /// The job id `background_exec` returned.
        job_id: String,
        /// The newly captured text since the previous poll (never a repeat
        /// of already-emitted output).
        chunk: String,
        /// Whether this job's RETAINED capture has hit
        /// `capabilities.tools_background.max_output_bytes` — `chunk`
        /// itself is never truncated mid-character, but once this is
        /// `true` no further output from this job will ever be retained or
        /// emitted, even though the process may still be producing it.
        truncated: bool,
    },
}

impl AgentEvent {
    /// Build the event emitted immediately before a tool call executes.
    pub fn tool_started(call: &ToolCall) -> Self {
        AgentEvent::ToolCallStarted {
            id: call.id.clone(),
            name: call.function.name.clone(),
            arguments: call.function.arguments.clone(),
        }
    }

    /// P5-8 (§2 module 31 `server`, completing Obligation 9's "partial"
    /// core commitment): the canonical `{"type": ..., ...}` JSONL
    /// projection of this event — field names mirror the enum's own
    /// (`id`/`name`/`arguments`/`output`/`is_error`/`prompt_tokens`/…)
    /// rather than a hand-maintained parallel vocabulary, so the wire shape
    /// can never silently drift from the enum it projects.
    ///
    /// Shared by the CLI's `--output-format stream-json` sink (UX-23) and
    /// the `server` module's RPC/SSE event-notification channel, so both
    /// out-of-process surfaces stay byte-identical for the same event
    /// instead of maintaining two hand-written projections that could
    /// silently diverge.
    ///
    /// Match is exhaustive with NO wildcard arm on purpose: `#[non_exhaustive]`
    /// only affects callers OUTSIDE this crate (it forced the CLI's old,
    /// external copy of this projection to carry a `{"type":"unknown"}`
    /// fallback arm) — from INSIDE the crate that defines the enum, adding a
    /// future `AgentEvent` variant makes this fail to COMPILE until it's
    /// given a real projection here, which is strictly safer than silently
    /// falling back to an opaque `"unknown"` line for a new event kind.
    pub fn to_json(&self) -> serde_json::Value {
        match self {
            AgentEvent::TextDelta(text) => {
                serde_json::json!({"type": "text_delta", "text": text})
            }
            AgentEvent::TurnCompleted => serde_json::json!({"type": "turn_completed"}),
            AgentEvent::ToolCallStarted {
                id,
                name,
                arguments,
            } => {
                serde_json::json!({
                    "type": "tool_call_started",
                    "id": id,
                    "name": name,
                    "arguments": arguments,
                })
            }
            AgentEvent::ToolCallCompleted {
                id,
                name,
                output,
                is_error,
            } => {
                serde_json::json!({
                    "type": "tool_call_completed",
                    "id": id,
                    "name": name,
                    "output": output,
                    "is_error": is_error,
                })
            }
            AgentEvent::CacheWarning { message } => {
                serde_json::json!({"type": "cache_warning", "message": message})
            }
            AgentEvent::Usage(usage) => {
                serde_json::json!({
                    "type": "usage",
                    "prompt_tokens": usage.prompt_tokens,
                    "completion_tokens": usage.completion_tokens,
                    "total_tokens": usage.total_tokens,
                    "cached_tokens": usage.prompt_tokens_details.as_ref().map(|d| d.cached_tokens),
                })
            }
            // P5-8: the one event kind added since `stream_json_sink` was
            // first written (P5-6, module 4 `tools.background`) — it fell
            // into the generic "unknown" catch-all before this method
            // existed; giving it a real projection is part of "emit the
            // full event set" (this unit's ladder rung 1 completion).
            AgentEvent::BackgroundOutput {
                job_id,
                chunk,
                truncated,
            } => {
                serde_json::json!({
                    "type": "background_output",
                    "job_id": job_id,
                    "chunk": chunk,
                    "truncated": truncated,
                })
            }
        }
    }
}

/// A sink for [`AgentEvent`]s.
///
/// This is a boxed closure so callers can wire up whatever they like (printing,
/// channels, metrics) without the crate dictating a concurrency model.
pub type EventSink = Box<dyn Fn(AgentEvent) + Send + Sync>;

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

    #[test]
    fn text_delta_projects_type_and_text() {
        let v = AgentEvent::TextDelta("hi".to_string()).to_json();
        assert_eq!(v["type"], "text_delta");
        assert_eq!(v["text"], "hi");
    }

    #[test]
    fn turn_completed_projects_bare_type() {
        assert_eq!(
            AgentEvent::TurnCompleted.to_json(),
            serde_json::json!({"type": "turn_completed"})
        );
    }

    #[test]
    fn tool_call_started_projects_all_fields() {
        let v = AgentEvent::ToolCallStarted {
            id: "call_1".into(),
            name: "bash".into(),
            arguments: "{\"cmd\":\"ls\"}".into(),
        }
        .to_json();
        assert_eq!(v["type"], "tool_call_started");
        assert_eq!(v["id"], "call_1");
        assert_eq!(v["name"], "bash");
        assert_eq!(v["arguments"], "{\"cmd\":\"ls\"}");
    }

    #[test]
    fn tool_call_completed_projects_all_fields() {
        let v = AgentEvent::ToolCallCompleted {
            id: "call_1".into(),
            name: "bash".into(),
            output: "ok".into(),
            is_error: false,
        }
        .to_json();
        assert_eq!(v["type"], "tool_call_completed");
        assert_eq!(v["output"], "ok");
        assert_eq!(v["is_error"], false);
    }

    #[test]
    fn usage_projects_cached_tokens_when_present() {
        let v = AgentEvent::Usage(Usage {
            prompt_tokens: 10,
            completion_tokens: 5,
            total_tokens: 15,
            prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 4 }),
        })
        .to_json();
        assert_eq!(v["type"], "usage");
        assert_eq!(v["prompt_tokens"], 10);
        assert_eq!(v["cached_tokens"], 4);
    }

    #[test]
    fn usage_projects_null_cached_tokens_when_absent() {
        let v = AgentEvent::Usage(Usage {
            prompt_tokens: 10,
            completion_tokens: 5,
            total_tokens: 15,
            prompt_tokens_details: None,
        })
        .to_json();
        assert!(v["cached_tokens"].is_null());
    }

    #[test]
    fn background_output_projects_all_fields_not_unknown() {
        let v = AgentEvent::BackgroundOutput {
            job_id: "job_1".into(),
            chunk: "more output".into(),
            truncated: true,
        }
        .to_json();
        assert_eq!(v["type"], "background_output");
        assert_eq!(v["job_id"], "job_1");
        assert_eq!(v["chunk"], "more output");
        assert_eq!(v["truncated"], true);
    }
}