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(),
}
);
}
}
}