From 75b0d0ecad8da0d62394ede268d595a7b37a365b Mon Sep 17 00:00:00 2001 From: Callum Reid Date: Fri, 31 Jul 2026 11:20:18 -0700 Subject: [PATCH] [COVAL-4339] Complete CLI agent creation parity --- README.md | 6 +++ src/client/models/agent.rs | 71 ++++++++++++++++++++++++++++- src/commands/agents.rs | 38 +++++++++++++--- tests/cli_tests.rs | 92 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 17a6c11..c1f7990 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,12 @@ coval agents create \ --type voice \ --phone-number "+15551234567" +# Create a LiveKit agent for CI +coval agents create \ + --name "Language Tutor" \ + --type livekit \ + --metadata '{"generate_token_endpoint":"https://api.example.com/livekit/token","livekit_url":"wss://example.livekit.cloud","livekit_agent_name":"language-tutor"}' + # Create a test set coval test-sets create \ --name "Customer Support Scenarios" \ diff --git a/src/client/models/agent.rs b/src/client/models/agent.rs index d88d9f8..5aeefef 100644 --- a/src/client/models/agent.rs +++ b/src/client/models/agent.rs @@ -32,7 +32,7 @@ pub struct Agent { pub extra: serde_json::Map, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)] pub enum AgentType { #[serde(rename = "MODEL_TYPE_VOICE")] #[value(name = "voice")] @@ -43,12 +43,33 @@ pub enum AgentType { #[serde(rename = "MODEL_TYPE_CHAT")] #[value(name = "chat")] Chat, + #[serde(rename = "MODEL_TYPE_CHAT_A2A")] + #[value(name = "chat-a2a")] + ChatA2a, + #[serde(rename = "MODEL_TYPE_CHAT_WEBSOCKET")] + #[value(name = "chat-websocket")] + ChatWebsocket, #[serde(rename = "MODEL_TYPE_SMS")] #[value(name = "sms")] Sms, #[serde(rename = "MODEL_TYPE_WEBSOCKET")] #[value(name = "websocket")] Websocket, + #[serde(rename = "MODEL_TYPE_LIVEKIT")] + #[value(name = "livekit")] + Livekit, + #[serde(rename = "MODEL_TYPE_DAILY")] + #[value(name = "pipecat", alias = "daily")] + Pipecat, + #[serde(rename = "MODEL_TYPE_OPENAI_REALTIME")] + #[value(name = "openai-realtime")] + OpenAiRealtime, + #[serde(rename = "MODEL_TYPE_GEMINI_REALTIME")] + #[value(name = "gemini-realtime")] + GeminiRealtime, + #[serde(rename = "MODEL_TYPE_GROK_REALTIME")] + #[value(name = "grok-realtime")] + GrokRealtime, #[serde(other)] #[value(skip)] Unknown, @@ -60,8 +81,15 @@ impl std::fmt::Display for AgentType { Self::Voice => write!(f, "VOICE"), Self::OutboundVoice => write!(f, "OUTBOUND"), Self::Chat => write!(f, "CHAT"), + Self::ChatA2a => write!(f, "CHAT_A2A"), + Self::ChatWebsocket => write!(f, "CHAT_WEBSOCKET"), Self::Sms => write!(f, "SMS"), Self::Websocket => write!(f, "WEBSOCKET"), + Self::Livekit => write!(f, "LIVEKIT"), + Self::Pipecat => write!(f, "PIPECAT"), + Self::OpenAiRealtime => write!(f, "OPENAI_REALTIME"), + Self::GeminiRealtime => write!(f, "GEMINI_REALTIME"), + Self::GrokRealtime => write!(f, "GROK_REALTIME"), Self::Unknown => write!(f, "UNKNOWN"), } } @@ -69,6 +97,8 @@ impl std::fmt::Display for AgentType { #[derive(Debug, Serialize, Deserialize)] pub struct CreateAgentRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub customer_agent_id: Option, pub display_name: String, pub model_type: AgentType, #[serde(skip_serializing_if = "Option::is_none")] @@ -78,11 +108,19 @@ pub struct CreateAgentRequest { #[serde(skip_serializing_if = "Option::is_none")] pub prompt: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub language: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub attributes: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub workflows: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub metric_ids: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub test_set_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, } #[derive(Debug, Default, Serialize, Deserialize)] @@ -149,3 +187,34 @@ fn truncate(s: &str, max: usize) -> String { format!("{}...", end) } } + +#[cfg(test)] +mod tests { + use super::AgentType; + + #[test] + fn serializes_every_creatable_agent_type() { + let cases = [ + (AgentType::Voice, "MODEL_TYPE_VOICE"), + (AgentType::OutboundVoice, "MODEL_TYPE_OUTBOUND_VOICE"), + (AgentType::Chat, "MODEL_TYPE_CHAT"), + (AgentType::ChatA2a, "MODEL_TYPE_CHAT_A2A"), + (AgentType::ChatWebsocket, "MODEL_TYPE_CHAT_WEBSOCKET"), + (AgentType::Sms, "MODEL_TYPE_SMS"), + (AgentType::Websocket, "MODEL_TYPE_WEBSOCKET"), + (AgentType::Livekit, "MODEL_TYPE_LIVEKIT"), + (AgentType::Pipecat, "MODEL_TYPE_DAILY"), + (AgentType::OpenAiRealtime, "MODEL_TYPE_OPENAI_REALTIME"), + (AgentType::GeminiRealtime, "MODEL_TYPE_GEMINI_REALTIME"), + (AgentType::GrokRealtime, "MODEL_TYPE_GROK_REALTIME"), + ]; + + for (agent_type, model_type) in cases { + assert_eq!(serde_json::to_value(agent_type).unwrap(), model_type); + assert_eq!( + serde_json::from_value::(model_type.into()).unwrap(), + agent_type + ); + } + } +} diff --git a/src/commands/agents.rs b/src/commands/agents.rs index 1883b4c..cb88066 100644 --- a/src/commands/agents.rs +++ b/src/commands/agents.rs @@ -50,7 +50,7 @@ pub struct GetArgs { #[derive(Args)] #[command( - after_help = "Required fields by agent type:\n voice --phone-number (E.164, e.g. +12345678901)\n outbound-voice --endpoint (webhook URL)\n chat --metadata '{\"chat_endpoint\": \"https://...\"}'\n sms --phone-number (E.164)\n websocket --metadata '{\"endpoint\": \"wss://...\", \"initialization_json\": \"...\"}'" + after_help = "Required fields by agent type:\n voice --phone-number (E.164 or SIP)\n outbound-voice --endpoint (webhook URL)\n chat --metadata '{\"chat_endpoint\":\"https://...\"}'\n chat-a2a --metadata '{\"chat_endpoint\":\"https://...\"}'\n chat-websocket --metadata '{\"endpoint\":\"wss://...\"}'\n sms --phone-number (E.164)\n websocket --metadata '{\"endpoint\":\"wss://...\"}'\n livekit --metadata '{\"generate_token_endpoint\":\"https://...\",\"livekit_url\":\"wss://...\"}'\n pipecat --metadata '{\"pipecat_api_key\":\"...\",\"agent_name\":\"...\"}'\n openai-realtime --metadata '{\"openai_realtime_api_key\":\"...\"}'\n gemini-realtime --metadata '{\"gemini_realtime_api_key\":\"...\"}'\n grok-realtime --metadata '{\"grok_realtime_api_key\":\"...\"}'" )] pub struct CreateArgs { #[command(flatten)] @@ -58,6 +58,9 @@ pub struct CreateArgs { /// Human-readable agent name #[arg(long)] name: Option, + /// Your own stable identifier for the agent + #[arg(long)] + customer_agent_id: Option, /// Agent type (determines required fields, see below) #[arg(long, value_enum)] r#type: Option, @@ -70,6 +73,12 @@ pub struct CreateArgs { /// Agent instructions / system prompt #[arg(long)] prompt: Option, + /// Primary agent language + #[arg(long)] + language: Option, + /// JSON object of free-form agent attributes + #[arg(long)] + attributes: Option, /// Comma-separated metric IDs to attach #[arg(long, value_delimiter = ',')] metric_ids: Option>, @@ -79,6 +88,12 @@ pub struct CreateArgs { /// JSON string for type-specific config (see required fields below) #[arg(long)] metadata: Option, + /// JSON object containing workflow configuration + #[arg(long)] + workflows: Option, + /// Comma-separated tag names + #[arg(long, value_delimiter = ',')] + tags: Option>, } #[derive(Args)] @@ -136,20 +151,23 @@ pub async fn execute(cmd: AgentCommands, client: &CovalClient, ctx: &OutputConte } AgentCommands::Create(args) => { let mut input = args.input_json.object()?; - let metadata: Option = args - .metadata - .map(|s| serde_json::from_str(&s)) - .transpose() - .map_err(|e| anyhow::anyhow!("Invalid JSON for --metadata: {e}"))?; + let metadata = parse_json_argument(args.metadata, "metadata")?; + let attributes = parse_json_argument(args.attributes, "attributes")?; + let workflows = parse_json_argument(args.workflows, "workflows")?; input_json::insert(&mut input, "display_name", args.name)?; + input_json::insert(&mut input, "customer_agent_id", args.customer_agent_id)?; input_json::insert(&mut input, "model_type", args.r#type)?; input_json::insert(&mut input, "phone_number", args.phone_number)?; input_json::insert(&mut input, "endpoint", args.endpoint)?; input_json::insert(&mut input, "prompt", args.prompt)?; + input_json::insert(&mut input, "language", args.language)?; + input_json::insert(&mut input, "attributes", attributes)?; input_json::insert(&mut input, "metadata", metadata)?; + input_json::insert(&mut input, "workflows", workflows)?; input_json::insert(&mut input, "metric_ids", args.metric_ids)?; input_json::insert(&mut input, "test_set_ids", args.test_set_ids)?; + input_json::insert(&mut input, "tags", args.tags)?; let req: CreateAgentRequest = input_json::finish(input)?; let agent = client.agents().create(req).await?; emit_one_with_actions(ctx, "agents", operation, &agent, agent_actions(&agent.id)); @@ -207,3 +225,11 @@ fn agent_actions(agent_id: &str) -> Vec { next_actions::context("agents"), ] } + +fn parse_json_argument(raw: Option, flag_name: &str) -> Result> { + raw.map(|value| { + serde_json::from_str(&value) + .map_err(|error| anyhow::anyhow!("Invalid JSON for --{flag_name}: {error}")) + }) + .transpose() +} diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 370fe06..6330661 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -827,6 +827,23 @@ fn test_agents_help() { .stdout(predicate::str::contains("create")); } +#[test] +fn test_agents_create_help_lists_all_agent_types() { + coval() + .arg("agents") + .arg("create") + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("chat-a2a")) + .stdout(predicate::str::contains("chat-websocket")) + .stdout(predicate::str::contains("livekit")) + .stdout(predicate::str::contains("pipecat")) + .stdout(predicate::str::contains("openai-realtime")) + .stdout(predicate::str::contains("gemini-realtime")) + .stdout(predicate::str::contains("grok-realtime")); +} + #[tokio::test] async fn test_agents_list() { let mock_server = MockServer::start().await; @@ -2670,6 +2687,81 @@ async fn test_agents_create_with_metadata() { .stdout(predicate::str::contains("new123")); } +#[tokio::test] +async fn test_agents_create_livekit_with_all_common_fields() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/agents")) + .and(header("X-API-Key", "test_key")) + .and(body_partial_json(json!({ + "customer_agent_id": "speak-language-tutor", + "display_name": "Language Tutor", + "model_type": "MODEL_TYPE_LIVEKIT", + "prompt": "Teach conversational English.", + "language": "en", + "attributes": {"customer": "Speak"}, + "metadata": { + "generate_token_endpoint": "https://api.example.com/livekit/token", + "livekit_url": "wss://example.livekit.cloud", + "livekit_agent_name": "language-tutor" + }, + "workflows": {"dispatch": {"enabled": true}}, + "metric_ids": ["metric-one", "metric-two"], + "test_set_ids": ["pilot-suite"], + "tags": ["pilot", "livekit"] + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "agent": { + "id": "livekit123", + "display_name": "Language Tutor", + "model_type": "MODEL_TYPE_LIVEKIT", + "metadata": { + "generate_token_endpoint": "https://api.example.com/livekit/token", + "livekit_url": "wss://example.livekit.cloud" + }, + "create_time": "2026-07-31T18:00:00Z" + } + }))) + .mount(&mock_server) + .await; + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("agents") + .arg("create") + .arg("--customer-agent-id") + .arg("speak-language-tutor") + .arg("--name") + .arg("Language Tutor") + .arg("--type") + .arg("livekit") + .arg("--prompt") + .arg("Teach conversational English.") + .arg("--language") + .arg("en") + .arg("--attributes") + .arg(r#"{"customer":"Speak"}"#) + .arg("--metadata") + .arg( + r#"{"generate_token_endpoint":"https://api.example.com/livekit/token","livekit_url":"wss://example.livekit.cloud","livekit_agent_name":"language-tutor"}"#, + ) + .arg("--workflows") + .arg(r#"{"dispatch":{"enabled":true}}"#) + .arg("--metric-ids") + .arg("metric-one,metric-two") + .arg("--test-set-ids") + .arg("pilot-suite") + .arg("--tags") + .arg("pilot,livekit") + .assert() + .success() + .stdout(predicate::str::contains("livekit123")); +} + #[tokio::test] async fn test_agents_create_with_input_json() { let mock_server = MockServer::start().await;