kcode-k1-codex-conversation-values 0.1.0

Transport-independent Codex conversation values for K1
Documentation
use serde_json::Value;
use std::{
    collections::HashSet,
    fmt,
    path::PathBuf,
    sync::{Arc, Mutex, MutexGuard},
};

#[derive(Clone, Debug)]
pub struct Config {
    pub executable: PathBuf,
    pub working_directory: String,
    pub model: String,
    pub reasoning_effort: Option<String>,
    pub base_instructions: String,
    pub tools: Vec<DynamicTool>,
}

impl Config {
    pub fn validate(&self) -> Result<(), Error> {
        validate_config(self)
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct DynamicTool {
    pub name: String,
    pub description: String,
    pub input_schema: Value,
}

#[derive(Clone, Debug, PartialEq)]
pub struct ToolCall {
    pub call_id: String,
    pub name: String,
    pub arguments: Value,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ToolResult {
    pub success: bool,
    pub output: String,
}

#[derive(Clone, Debug, PartialEq)]
pub enum Event {
    TextDelta(String),
    ToolCall(ToolCall),
    Done,
    Error(Error),
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ErrorKind {
    Busy,
    Interrupted,
    InvalidToolResult,
    LaunchRejected,
    Protocol,
    Server,
    Unavailable,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Error {
    pub kind: ErrorKind,
    pub message: String,
    pub diagnostics: Vec<u8>,
}

impl Error {
    pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
        Self {
            kind,
            message: message.into(),
            diagnostics: Vec::new(),
        }
    }

    pub fn with_diagnostics(mut self, diagnostics: Vec<u8>) -> Self {
        self.diagnostics = diagnostics;
        self
    }

    pub fn server(value: &Value, diagnostics: &Diagnostics) -> Self {
        let detail = value
            .get("message")
            .and_then(Value::as_str)
            .or_else(|| value.pointer("/error/message").and_then(Value::as_str));
        diagnostics.error(
            ErrorKind::Server,
            detail.map_or_else(
                || "Codex app-server error".to_owned(),
                |text| format!("Codex app-server error: {text}"),
            ),
        )
    }
}

impl fmt::Display for Error {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.message)
    }
}

impl std::error::Error for Error {}

#[derive(Clone, Debug, Default)]
pub struct Diagnostics(Arc<Mutex<Vec<u8>>>);

impl Diagnostics {
    pub fn new(bytes: Vec<u8>) -> Self {
        Self(Arc::new(Mutex::new(bytes)))
    }

    fn lock(&self) -> MutexGuard<'_, Vec<u8>> {
        self.0
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
    }

    pub fn snapshot(&self) -> Vec<u8> {
        self.lock().clone()
    }

    pub fn replace(&self, bytes: Vec<u8>) {
        *self.lock() = bytes;
    }

    pub fn error(&self, kind: ErrorKind, message: impl Into<String>) -> Error {
        Error {
            kind,
            message: message.into(),
            diagnostics: self.snapshot(),
        }
    }
}

fn ensure(valid: bool, error: Error) -> Result<(), Error> {
    valid.then_some(()).ok_or(error)
}

fn protocol(message: impl Into<String>) -> Error {
    Error::new(ErrorKind::Protocol, message)
}

pub fn validate_config(config: &Config) -> Result<(), Error> {
    let mut names = HashSet::new();
    for tool in &config.tools {
        ensure(
            !tool.name.is_empty(),
            protocol("dynamic tool names must not be empty"),
        )?;
        ensure(
            names.insert(&tool.name),
            protocol(format!("duplicate dynamic tool name: {}", tool.name)),
        )?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn tool(name: &str) -> DynamicTool {
        DynamicTool {
            name: name.to_owned(),
            description: format!("Use {name}"),
            input_schema: json!({"type": "object"}),
        }
    }

    fn config(tools: Vec<DynamicTool>) -> Config {
        Config {
            executable: "codex".into(),
            working_directory: "/work".into(),
            model: "model".into(),
            reasoning_effort: Some("high".into()),
            base_instructions: "instructions".into(),
            tools,
        }
    }

    #[test]
    fn valid_config_is_accepted() {
        assert!(
            config(vec![tool("search"), tool("read")])
                .validate()
                .is_ok()
        );
        assert!(validate_config(&config(Vec::new())).is_ok());
    }

    #[test]
    fn empty_dynamic_tool_name_is_rejected() {
        assert_eq!(
            config(vec![tool("")]).validate().unwrap_err(),
            Error::new(ErrorKind::Protocol, "dynamic tool names must not be empty")
        );
    }

    #[test]
    fn duplicate_dynamic_tool_name_is_rejected() {
        assert_eq!(
            config(vec![tool("search"), tool("search")])
                .validate()
                .unwrap_err(),
            Error::new(ErrorKind::Protocol, "duplicate dynamic tool name: search")
        );
    }

    #[test]
    fn diagnostics_snapshot_replace_and_stamp_errors() {
        let diagnostics = Diagnostics::new(vec![1, 2]);
        let observer = diagnostics.clone();
        assert_eq!(diagnostics.snapshot(), vec![1, 2]);
        diagnostics.replace(vec![3, 4]);
        assert_eq!(observer.snapshot(), vec![3, 4]);
        assert_eq!(
            diagnostics.error(ErrorKind::Unavailable, "offline"),
            Error {
                kind: ErrorKind::Unavailable,
                message: "offline".into(),
                diagnostics: vec![3, 4],
            }
        );
    }

    #[test]
    fn with_diagnostics_preserves_error_and_display_behavior() {
        let error = Error::new(ErrorKind::Interrupted, "stopped").with_diagnostics(vec![5, 6]);
        assert_eq!(
            error,
            Error {
                kind: ErrorKind::Interrupted,
                message: "stopped".into(),
                diagnostics: vec![5, 6],
            }
        );
        assert_eq!(error.to_string(), "stopped");
        let _: &(dyn std::error::Error + 'static) = &error;
    }

    #[test]
    fn server_error_extracts_direct_nested_and_fallback_messages() {
        let diagnostics = Diagnostics::new(b"stderr".to_vec());
        for (value, message) in [
            (
                json!({"message": "direct", "error": {"message": "nested"}}),
                "Codex app-server error: direct",
            ),
            (
                json!({"error": {"message": "nested"}}),
                "Codex app-server error: nested",
            ),
            (json!({"error": {"message": 7}}), "Codex app-server error"),
        ] {
            assert_eq!(
                Error::server(&value, &diagnostics),
                Error {
                    kind: ErrorKind::Server,
                    message: message.into(),
                    diagnostics: b"stderr".to_vec(),
                }
            );
        }
    }
}