From 67450c02a4ed49cef3d5f6ebdfbff1318117211e Mon Sep 17 00:00:00 2001 From: Callum Reid Date: Wed, 2 Sep 2026 18:50:12 -0700 Subject: [PATCH 1/4] [COVAL-5821] Model the dropped persona request fields CreatePersonaRequest and UpdatePersonaRequest declared seven fewer fields than the API publishes and serves, so `coval personas create/update` discarded them in silence: background_sound_volume, voice_volume, voice_speed, hold_music_timeout_seconds, situate_speaker, audio_degradation, and tags. The background-sound create and update requests dropped acoustic_source_type the same way. Null does not mean the same thing for every one of these, so the update request does not treat them alike. shared_requests.py applies voice_volume, voice_speed, hold_music_timeout_seconds, situate_speaker, and audio_degradation through `model_fields_set`, deleting the stored value when the key is present and null; those get the explicit_option treatment so an intentional clear survives. It applies background_sound_volume through an `is not None` guard, and tags follow the "None means don't update; [] clears" contract, so both stay plain Option. Modeling all seven the same way would have made half the clears no-ops. acoustic_source_type is likewise cleared by an explicit null on update. explicit_option moves from test_case.rs to common.rs now that a second resource needs it. --audio-degradation takes a bare preset id or the full JSON object, so the common case needs no JSON on the command line. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 14 ++ src/client/models/common.rs | 12 ++ src/client/models/persona.rs | 68 +++++++ src/client/models/test_case.rs | 12 +- src/commands/personas.rs | 102 +++++++++++ tests/cli_tests.rs | 322 +++++++++++++++++++++++++++++++++ 8 files changed, 521 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d71e71d..41bf4a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -278,7 +278,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "coval" -version = "0.7.5" +version = "0.8.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index f6566fb..c6a2e8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "coval" -version = "0.7.5" +version = "0.8.0" edition = "2021" description = "CLI for Coval AI agent evaluation platform" license = "MIT" diff --git a/README.md b/README.md index 718dd5e..7de783f 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,20 @@ coval personas background-sounds upload ./lobby-noise.mp3 \ # Use the returned value, e.g. custom:bg123, on a persona coval personas update --background custom:bg123 +# Shape a persona's audio: placement, levels, and a hold-music cutoff +coval personas update \ + --situate-speaker speakerphone-hard \ + --voice-volume 1.4 \ + --voice-speed 0.9 \ + --background-sound-volume 0.3 \ + --hold-music-timeout-seconds 45 + +# Channel degradation instead of placement (the two are mutually exclusive) +coval personas update --audio-degradation cell-handoff + +# Clear a preset. A flag can only set a value, so clearing needs an explicit null. +coval personas update --input-json '{"situate_speaker":null}' + # Create a dashboard and make it the organization default coval dashboards create \ --name "Production Metrics" \ diff --git a/src/client/models/common.rs b/src/client/models/common.rs index 06700ab..3190348 100644 --- a/src/client/models/common.rs +++ b/src/client/models/common.rs @@ -49,3 +49,15 @@ impl ListParams { } } } + +/// Deserializes a present field into `Some(..)` even when its value is null, so +/// callers can tell "field omitted" from "field explicitly cleared". Pair it with +/// `Option>` on a PATCH request wherever the API treats null as a clear; +/// `skip_serializing_if` alone would turn a deliberate clear into a no-op. +pub(crate) fn explicit_option<'de, D, T>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer).map(Some) +} diff --git a/src/client/models/persona.rs b/src/client/models/persona.rs index 9aac64b..1028bc0 100644 --- a/src/client/models/persona.rs +++ b/src/client/models/persona.rs @@ -78,6 +78,24 @@ pub struct CreatePersonaRequest { pub conversation_initiation: Option, #[serde(alias = "multiLanguageStt", skip_serializing_if = "Option::is_none")] pub multi_language_stt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub background_sound_volume: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub voice_volume: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub voice_speed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hold_music_timeout_seconds: Option, + /// Mutually exclusive with `audio_degradation`. + #[serde(skip_serializing_if = "Option::is_none")] + pub situate_speaker: Option, + /// `{"preset": "...", "preset_version": "..."}`; mutually exclusive with + /// `situate_speaker`. + #[serde(skip_serializing_if = "Option::is_none")] + pub audio_degradation: Option, + /// Tag names. Omitted leaves tags unchanged; an empty list clears them. + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, } #[derive(Debug, Default, Serialize, Deserialize)] @@ -98,6 +116,45 @@ pub struct UpdatePersonaRequest { pub conversation_initiation: Option, #[serde(alias = "multiLanguageStt", skip_serializing_if = "Option::is_none")] pub multi_language_stt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub background_sound_volume: Option, + // The API deletes the stored value when these arrive as an explicit null, so + // absent and null have to stay distinguishable. + #[serde( + default, + deserialize_with = "super::explicit_option", + skip_serializing_if = "Option::is_none" + )] + pub voice_volume: Option>, + #[serde( + default, + deserialize_with = "super::explicit_option", + skip_serializing_if = "Option::is_none" + )] + pub voice_speed: Option>, + #[serde( + default, + deserialize_with = "super::explicit_option", + skip_serializing_if = "Option::is_none" + )] + pub hold_music_timeout_seconds: Option>, + /// Mutually exclusive with `audio_degradation`; an explicit null clears it. + #[serde( + default, + deserialize_with = "super::explicit_option", + skip_serializing_if = "Option::is_none" + )] + pub situate_speaker: Option>, + /// Mutually exclusive with `situate_speaker`; an explicit null clears it. + #[serde( + default, + deserialize_with = "super::explicit_option", + skip_serializing_if = "Option::is_none" + )] + pub audio_degradation: Option>, + /// Tag names. Omitted leaves tags unchanged; an empty list clears them. + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, } #[derive(Debug, Deserialize)] @@ -220,6 +277,10 @@ pub struct CreateBackgroundSoundRequest { pub content_type: String, #[serde(skip_serializing_if = "Option::is_none")] pub default_volume: Option, + /// `ambient` mixes the sound as room ambience; `point_source` renders it as a + /// located source. + #[serde(skip_serializing_if = "Option::is_none")] + pub acoustic_source_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option>, } @@ -251,6 +312,13 @@ pub struct UpdateBackgroundSoundRequest { pub display_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_volume: Option, + /// An explicit null clears the stored rendering behavior. + #[serde( + default, + deserialize_with = "super::explicit_option", + skip_serializing_if = "Option::is_none" + )] + pub acoustic_source_type: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, } diff --git a/src/client/models/test_case.rs b/src/client/models/test_case.rs index a08922b..97e6e65 100644 --- a/src/client/models/test_case.rs +++ b/src/client/models/test_case.rs @@ -79,7 +79,7 @@ pub struct UpdateTestCaseRequest { /// JSON null clears the stored turns, so absent and null must stay distinct. #[serde( default, - deserialize_with = "explicit_option", + deserialize_with = "super::explicit_option", skip_serializing_if = "Option::is_none" )] pub script_turns: Option>>, @@ -91,16 +91,6 @@ pub struct UpdateTestCaseRequest { pub user_notes: Option, } -/// Deserializes a present field into `Some(..)` even when its value is null, so -/// callers can tell "field omitted" from "field explicitly cleared". -fn explicit_option<'de, D, T>(deserializer: D) -> Result>, D::Error> -where - D: serde::Deserializer<'de>, - T: Deserialize<'de>, -{ - Option::::deserialize(deserializer).map(Some) -} - #[derive(Debug, Deserialize)] pub struct ListTestCasesResponse { pub test_cases: Vec, diff --git a/src/commands/personas.rs b/src/commands/personas.rs index e062972..e480742 100644 --- a/src/commands/personas.rs +++ b/src/commands/personas.rs @@ -112,6 +112,27 @@ pub struct CreateArgs { /// Enable multilingual speech-to-text #[arg(long, num_args = 0..=1, default_missing_value = "true")] multi_language_stt: Option, + /// Background sound volume multiplier (>= 0.0) + #[arg(long)] + background_sound_volume: Option, + /// Voice gain multiplier (0.0 silent, 1.0 unchanged, 2.0 double) + #[arg(long)] + voice_volume: Option, + /// Voice speed multiplier (0.25-2.0, 1.0 unchanged) + #[arg(long)] + voice_speed: Option, + /// Disconnect after this many seconds of no speech (5-300) + #[arg(long)] + hold_music_timeout_seconds: Option, + /// Placement preset (speakerphone-easy or speakerphone-hard); conflicts with --audio-degradation + #[arg(long)] + situate_speaker: Option, + /// Channel degradation preset id (landline, cell-poor, cell-handoff) or a JSON object; conflicts with --situate-speaker + #[arg(long)] + audio_degradation: Option, + /// Comma-separated tag names; pass an empty value to clear all tags + #[arg(long, value_delimiter = ',')] + tags: Option>, } #[derive(Args)] @@ -140,6 +161,27 @@ pub struct UpdateArgs { /// Enable or disable multilingual speech-to-text #[arg(long, num_args = 0..=1, default_missing_value = "true")] multi_language_stt: Option, + /// Background sound volume multiplier (>= 0.0) + #[arg(long)] + background_sound_volume: Option, + /// Voice gain multiplier (0.0 silent, 1.0 unchanged, 2.0 double) + #[arg(long)] + voice_volume: Option, + /// Voice speed multiplier (0.25-2.0, 1.0 unchanged) + #[arg(long)] + voice_speed: Option, + /// Disconnect after this many seconds of no speech (5-300) + #[arg(long)] + hold_music_timeout_seconds: Option, + /// Placement preset (speakerphone-easy or speakerphone-hard); conflicts with --audio-degradation + #[arg(long)] + situate_speaker: Option, + /// Channel degradation preset id (landline, cell-poor, cell-handoff) or a JSON object; conflicts with --situate-speaker + #[arg(long)] + audio_degradation: Option, + /// Comma-separated tag names; pass an empty value to clear all tags + #[arg(long, value_delimiter = ',')] + tags: Option>, } #[derive(Args)] @@ -167,6 +209,9 @@ pub struct BackgroundSoundUploadArgs { /// Default volume multiplier for simulations (0.0-1.0) #[arg(long)] default_volume: Option, + /// Acoustic rendering behavior (ambient or point_source) + #[arg(long)] + acoustic_source_type: Option, /// Metadata as key=value (repeat for multiple) #[arg(long = "metadata", value_parser = parse_metadata_kv)] metadata: Vec<(String, String)>, @@ -184,6 +229,9 @@ pub struct BackgroundSoundUpdateArgs { /// New default volume multiplier (0.0-1.0) #[arg(long)] default_volume: Option, + /// New acoustic rendering behavior (ambient or point_source) + #[arg(long)] + acoustic_source_type: Option, /// Archive or restore the custom background sound #[arg(long, value_enum)] status: Option, @@ -238,6 +286,25 @@ pub async fn execute( input_json::insert(&mut input, "background_sound", args.background)?; input_json::insert(&mut input, "wait_seconds", args.wait_seconds)?; input_json::insert(&mut input, "multi_language_stt", args.multi_language_stt)?; + input_json::insert( + &mut input, + "background_sound_volume", + args.background_sound_volume, + )?; + input_json::insert(&mut input, "voice_volume", args.voice_volume)?; + input_json::insert(&mut input, "voice_speed", args.voice_speed)?; + input_json::insert( + &mut input, + "hold_music_timeout_seconds", + args.hold_music_timeout_seconds, + )?; + input_json::insert(&mut input, "situate_speaker", args.situate_speaker)?; + input_json::insert( + &mut input, + "audio_degradation", + parse_audio_degradation(args.audio_degradation), + )?; + input_json::insert(&mut input, "tags", args.tags)?; let req: CreatePersonaRequest = input_json::finish(input)?; let persona = client.personas().create(req).await?; emit_one_with_actions( @@ -258,6 +325,25 @@ pub async fn execute( input_json::insert(&mut input, "background_sound", args.background)?; input_json::insert(&mut input, "wait_seconds", args.wait_seconds)?; input_json::insert(&mut input, "multi_language_stt", args.multi_language_stt)?; + input_json::insert( + &mut input, + "background_sound_volume", + args.background_sound_volume, + )?; + input_json::insert(&mut input, "voice_volume", args.voice_volume)?; + input_json::insert(&mut input, "voice_speed", args.voice_speed)?; + input_json::insert( + &mut input, + "hold_music_timeout_seconds", + args.hold_music_timeout_seconds, + )?; + input_json::insert(&mut input, "situate_speaker", args.situate_speaker)?; + input_json::insert( + &mut input, + "audio_degradation", + parse_audio_degradation(args.audio_degradation), + )?; + input_json::insert(&mut input, "tags", args.tags)?; let req: UpdatePersonaRequest = input_json::finish(input)?; let persona = client.personas().update(&args.persona_id, req).await?; emit_one_with_actions( @@ -359,6 +445,7 @@ async fn execute_background_sound( original_filename: original_filename.clone(), content_type: content_type.clone(), default_volume: args.default_volume, + acoustic_source_type: args.acoustic_source_type, metadata, }) .await?; @@ -408,6 +495,11 @@ async fn execute_background_sound( let mut input = args.input_json.object()?; input_json::insert(&mut input, "display_name", args.display_name)?; input_json::insert(&mut input, "default_volume", args.default_volume)?; + input_json::insert( + &mut input, + "acoustic_source_type", + args.acoustic_source_type, + )?; input_json::insert(&mut input, "status", args.status)?; let req: UpdateBackgroundSoundRequest = input_json::finish(input)?; let id = normalize_background_sound_id(&args.background_sound_id); @@ -424,6 +516,16 @@ async fn execute_background_sound( Ok(()) } +/// Accepts either a bare preset id or the full `{"preset": ...}` object the API +/// documents, so the common case does not need JSON on the command line. +fn parse_audio_degradation(raw: Option) -> Option { + let raw = raw?; + match serde_json::from_str::(&raw) { + Ok(value @ serde_json::Value::Object(_)) => Some(value), + _ => Some(serde_json::json!({ "preset": raw })), + } +} + fn parse_metadata_kv(raw: &str) -> Result<(String, String), String> { let (key, value) = raw .split_once('=') diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 39d4a01..68b3370 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1826,6 +1826,328 @@ async fn test_personas_background_sounds_update_accepts_custom_value() { .stdout(predicate::str::contains("archived")); } +/// Every request field the audit records as published for POST /v1/personas that +/// the CLI models, so a struct that stops declaring one fails here. +fn newly_modeled_persona_fields() -> Value { + json!({ + "background_sound_volume": 0.4, + "voice_volume": 1.5, + "voice_speed": 0.9, + "hold_music_timeout_seconds": 45.0, + "situate_speaker": "speakerphone-easy", + "tags": ["support", "noisy"] + }) +} + +#[tokio::test] +async fn test_personas_create_forwards_every_modeled_field() { + let mock_server = MockServer::start().await; + let capture = BodyCapture::default(); + + Mock::given(method("POST")) + .and(path("/v1/personas")) + .and(header("X-API-Key", "test_key")) + .and(capture.clone()) + .respond_with(ResponseTemplate::new(200).set_body_json(persona_response(false))) + .mount(&mock_server) + .await; + + let fields = newly_modeled_persona_fields(); + let mut input = fields.as_object().unwrap().clone(); + input.insert("name".into(), json!("Noisy caller")); + input.insert("voice_name".into(), json!("marina")); + input.insert("language_code".into(), json!("en-US")); + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("personas") + .arg("create") + .arg("--input-json") + .arg(Value::Object(input).to_string()) + .assert() + .success(); + + let body = capture.take(); + for (key, expected) in fields.as_object().unwrap() { + assert_eq!(&body[key], expected, "field {key} must reach the API"); + } +} + +#[tokio::test] +async fn test_personas_update_forwards_every_modeled_field() { + let mock_server = MockServer::start().await; + let capture = BodyCapture::default(); + + Mock::given(method("PATCH")) + .and(path("/v1/personas/persona1")) + .and(header("X-API-Key", "test_key")) + .and(capture.clone()) + .respond_with(ResponseTemplate::new(200).set_body_json(persona_response(false))) + .mount(&mock_server) + .await; + + let fields = newly_modeled_persona_fields(); + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("personas") + .arg("update") + .arg("persona1") + .arg("--input-json") + .arg(fields.to_string()) + .assert() + .success(); + + let body = capture.take(); + for (key, expected) in fields.as_object().unwrap() { + assert_eq!(&body[key], expected, "field {key} must reach the API"); + } +} + +#[tokio::test] +async fn test_personas_update_forwards_an_explicit_null_to_clear() { + let mock_server = MockServer::start().await; + let capture = BodyCapture::default(); + + Mock::given(method("PATCH")) + .and(path("/v1/personas/persona1")) + .and(header("X-API-Key", "test_key")) + .and(capture.clone()) + .respond_with(ResponseTemplate::new(200).set_body_json(persona_response(false))) + .mount(&mock_server) + .await; + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("personas") + .arg("update") + .arg("persona1") + .arg("--input-json") + .arg( + json!({ + "situate_speaker": null, + "audio_degradation": null, + "voice_volume": null, + "voice_speed": null, + "hold_music_timeout_seconds": null + }) + .to_string(), + ) + .assert() + .success(); + + // The API deletes a stored value only when the key is present and null, so an + // omitted key here would silently turn "clear this" into "leave it alone". + let body = capture.take(); + for key in [ + "situate_speaker", + "audio_degradation", + "voice_volume", + "voice_speed", + "hold_music_timeout_seconds", + ] { + assert!(body.get(key).is_some(), "{key} must be sent"); + assert!(body[key].is_null(), "{key} must be sent as null"); + } +} + +#[tokio::test] +async fn test_personas_update_omits_unset_clearable_fields() { + let mock_server = MockServer::start().await; + let capture = BodyCapture::default(); + + Mock::given(method("PATCH")) + .and(path("/v1/personas/persona1")) + .and(header("X-API-Key", "test_key")) + .and(capture.clone()) + .respond_with(ResponseTemplate::new(200).set_body_json(persona_response(false))) + .mount(&mock_server) + .await; + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("personas") + .arg("update") + .arg("persona1") + .arg("--name") + .arg("Renamed") + .assert() + .success(); + + let body = capture.take(); + assert_eq!(body["name"], "Renamed"); + for key in [ + "situate_speaker", + "audio_degradation", + "voice_volume", + "voice_speed", + "hold_music_timeout_seconds", + "tags", + ] { + assert!(body.get(key).is_none(), "unset {key} must be omitted"); + } +} + +#[tokio::test] +async fn test_personas_update_audio_degradation_flag_accepts_a_preset_id() { + let mock_server = MockServer::start().await; + let capture = BodyCapture::default(); + + Mock::given(method("PATCH")) + .and(path("/v1/personas/persona1")) + .and(header("X-API-Key", "test_key")) + .and(capture.clone()) + .respond_with(ResponseTemplate::new(200).set_body_json(persona_response(false))) + .mount(&mock_server) + .await; + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("personas") + .arg("update") + .arg("persona1") + .arg("--audio-degradation") + .arg("cell-handoff") + .assert() + .success(); + + assert_eq!( + capture.take()["audio_degradation"], + json!({"preset": "cell-handoff"}) + ); +} + +#[tokio::test] +async fn test_personas_update_audio_degradation_flag_accepts_a_json_object() { + let mock_server = MockServer::start().await; + let capture = BodyCapture::default(); + + Mock::given(method("PATCH")) + .and(path("/v1/personas/persona1")) + .and(header("X-API-Key", "test_key")) + .and(capture.clone()) + .respond_with(ResponseTemplate::new(200).set_body_json(persona_response(false))) + .mount(&mock_server) + .await; + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("personas") + .arg("update") + .arg("persona1") + .arg("--audio-degradation") + .arg(r#"{"preset":"landline","preset_version":"v2"}"#) + .assert() + .success(); + + assert_eq!( + capture.take()["audio_degradation"], + json!({"preset": "landline", "preset_version": "v2"}) + ); +} + +#[tokio::test] +async fn test_personas_background_sounds_update_forwards_acoustic_source_type() { + let mock_server = MockServer::start().await; + let capture = BodyCapture::default(); + + Mock::given(method("PATCH")) + .and(path("/v1/personas/background-sounds/sound1")) + .and(header("X-API-Key", "test_key")) + .and(capture.clone()) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "background_sound": { + "id": "sound1", + "value": "custom:sound1", + "source": "custom", + "display_name": "Lobby Noise", + "status": "active", + "default_volume": 0.42, + "content_type": "audio/mpeg", + "original_filename": "lobby-noise.mp3" + } + }))) + .mount(&mock_server) + .await; + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("personas") + .arg("background-sounds") + .arg("update") + .arg("custom:sound1") + .arg("--acoustic-source-type") + .arg("point_source") + .assert() + .success(); + + assert_eq!(capture.take()["acoustic_source_type"], "point_source"); +} + +#[tokio::test] +async fn test_personas_background_sounds_update_clears_acoustic_source_type() { + let mock_server = MockServer::start().await; + let capture = BodyCapture::default(); + + Mock::given(method("PATCH")) + .and(path("/v1/personas/background-sounds/sound1")) + .and(header("X-API-Key", "test_key")) + .and(capture.clone()) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "background_sound": { + "id": "sound1", + "value": "custom:sound1", + "source": "custom", + "display_name": "Lobby Noise", + "status": "active", + "default_volume": 0.42, + "content_type": "audio/mpeg", + "original_filename": "lobby-noise.mp3" + } + }))) + .mount(&mock_server) + .await; + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("personas") + .arg("background-sounds") + .arg("update") + .arg("custom:sound1") + .arg("--input-json") + .arg(r#"{"acoustic_source_type":null}"#) + .assert() + .success(); + + let body = capture.take(); + assert!(body.get("acoustic_source_type").is_some()); + assert!(body["acoustic_source_type"].is_null()); +} + #[tokio::test] async fn test_api_key_create_warning_agent_mode() { let mock_server = MockServer::start().await; From 50b3bfbf12e466731fcf7c85dfff3efb41e032c2 Mon Sep 17 00:00:00 2001 From: Callum Reid Date: Thu, 3 Sep 2026 15:33:52 -0700 Subject: [PATCH 2/4] [COVAL-5821] Re-apply the version bump on the moved base (0.8.2) --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f08ea45..54c5926 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -278,7 +278,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "coval" -version = "0.8.1" +version = "0.8.2" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 638fdd7..0732ea4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "coval" -version = "0.8.1" +version = "0.8.2" edition = "2021" description = "CLI for Coval AI agent evaluation platform" license = "MIT" From c9e843d62e9629771b52c9fcbb0e9133bcdc49f0 Mon Sep 17 00:00:00 2001 From: Callum Reid Date: Thu, 3 Sep 2026 15:33:52 -0700 Subject: [PATCH 3/4] [COVAL-5821] Drop 16 stale persona field gaps and refresh parity report --- api-coverage-report.md | 34 +++++++----------- api-coverage.toml | 80 ------------------------------------------ 2 files changed, 12 insertions(+), 102 deletions(-) diff --git a/api-coverage-report.md b/api-coverage-report.md index 1e25f19..659f9e9 100644 --- a/api-coverage-report.md +++ b/api-coverage-report.md @@ -12,14 +12,14 @@ only when coverage actually changes. | Metric | Value | | --- | ---: | -| Reconciliation status | PASS | +| Reconciliation status | ACTION REQUIRED | | Published operations | 181 | | First-class CLI operations | 119 | | Reviewed gaps | 62 | | Client operations | 143 | -| Published request fields on covered operations | 360 | -| Request fields modeled by the CLI | 313 | -| Reviewed request-field gaps | 47 | +| Published request fields on covered operations | 363 | +| Request fields modeled by the CLI | 329 | +| Reviewed request-field gaps | 31 | Catalog: https://api.coval.dev/v1/openapi @@ -49,7 +49,8 @@ Catalog: https://api.coval.dev/v1/openapi ## Coverage snapshot mismatches -- None. +- `published_request_fields: recorded 360, current 363` +- `cli_modeled_request_fields: recorded 313, current 329` ## Client-only operations @@ -122,7 +123,9 @@ Catalog: https://api.coval.dev/v1/openapi ## New published request fields the CLI drops -- None. +- `PATCH /metrics/{id} ivr_flow` +- `POST /metrics ivr_flow` +- `POST /runs config_overrides` ## Reviewed request-field gaps no longer present @@ -144,14 +147,7 @@ Catalog: https://api.coval.dev/v1/openapi - `PATCH /agents/{id} tags` - `PATCH /agents/{id} workflows` - `PATCH /conversations/uploaded/{id} metadata` -- `PATCH /personas/background-sounds/{id} acoustic_source_type` -- `PATCH /personas/{id} audio_degradation` -- `PATCH /personas/{id} background_sound_volume` -- `PATCH /personas/{id} hold_music_timeout_seconds` -- `PATCH /personas/{id} situate_speaker` -- `PATCH /personas/{id} tags` -- `PATCH /personas/{id} voice_speed` -- `PATCH /personas/{id} voice_volume` +- `PATCH /metrics/{id} ivr_flow` - `PATCH /reports/{id} simulation_output_ids` - `PATCH /reports/{id} source_human_review_project_id` - `PATCH /reports/{id} view_config` @@ -167,14 +163,7 @@ Catalog: https://api.coval.dev/v1/openapi - `PATCH /run-templates/{id} test_set_id` - `PATCH /test-sets/{id} tags` - `POST /conversations/uploaded:submit tags` -- `POST /personas audio_degradation` -- `POST /personas background_sound_volume` -- `POST /personas hold_music_timeout_seconds` -- `POST /personas situate_speaker` -- `POST /personas tags` -- `POST /personas voice_speed` -- `POST /personas voice_volume` -- `POST /personas/background-sounds acoustic_source_type` +- `POST /metrics ivr_flow` - `POST /reports simulation_output_ids` - `POST /reports source_human_review_project_id` - `POST /review-projects blind_labeling_shown_metric_ids` @@ -184,4 +173,5 @@ Catalog: https://api.coval.dev/v1/openapi - `POST /run-templates persona_id` - `POST /run-templates tags` - `POST /run-templates test_set_id` +- `POST /runs config_overrides` - `POST /test-sets tags` diff --git a/api-coverage.toml b/api-coverage.toml index a768bff..700e69a 100644 --- a/api-coverage.toml +++ b/api-coverage.toml @@ -421,46 +421,6 @@ operation = "PATCH /conversations/uploaded/{conversation_id}" field = "metadata" reason = "The additive customer-metadata patch path is served but unmodeled; tracked under COVAL-5823." -[[known_field_gap]] -operation = "PATCH /personas/background-sounds/{background_sound_id}" -field = "acoustic_source_type" -reason = "Background-sound acoustic source is served but unmodeled; tracked under COVAL-5821." - -[[known_field_gap]] -operation = "PATCH /personas/{persona_id}" -field = "audio_degradation" -reason = "Persona audio and placement fields are served but unmodeled; tracked under COVAL-5821." - -[[known_field_gap]] -operation = "PATCH /personas/{persona_id}" -field = "background_sound_volume" -reason = "Persona audio and placement fields are served but unmodeled; tracked under COVAL-5821." - -[[known_field_gap]] -operation = "PATCH /personas/{persona_id}" -field = "hold_music_timeout_seconds" -reason = "Persona audio and placement fields are served but unmodeled; tracked under COVAL-5821." - -[[known_field_gap]] -operation = "PATCH /personas/{persona_id}" -field = "situate_speaker" -reason = "Persona audio and placement fields are served but unmodeled; tracked under COVAL-5821." - -[[known_field_gap]] -operation = "PATCH /personas/{persona_id}" -field = "tags" -reason = "Resource tagging on write is served but unmodeled; tracked under COVAL-5823." - -[[known_field_gap]] -operation = "PATCH /personas/{persona_id}" -field = "voice_speed" -reason = "Persona audio and placement fields are served but unmodeled; tracked under COVAL-5821." - -[[known_field_gap]] -operation = "PATCH /personas/{persona_id}" -field = "voice_volume" -reason = "Persona audio and placement fields are served but unmodeled; tracked under COVAL-5821." - [[known_field_gap]] operation = "PATCH /reports/{report_id}" field = "simulation_output_ids" @@ -536,46 +496,6 @@ operation = "POST /conversations/uploaded:submit" field = "tags" reason = "Resource tagging on write is served but unmodeled; tracked under COVAL-5823." -[[known_field_gap]] -operation = "POST /personas" -field = "audio_degradation" -reason = "Persona audio and placement fields are served but unmodeled; tracked under COVAL-5821." - -[[known_field_gap]] -operation = "POST /personas" -field = "background_sound_volume" -reason = "Persona audio and placement fields are served but unmodeled; tracked under COVAL-5821." - -[[known_field_gap]] -operation = "POST /personas" -field = "hold_music_timeout_seconds" -reason = "Persona audio and placement fields are served but unmodeled; tracked under COVAL-5821." - -[[known_field_gap]] -operation = "POST /personas" -field = "situate_speaker" -reason = "Persona audio and placement fields are served but unmodeled; tracked under COVAL-5821." - -[[known_field_gap]] -operation = "POST /personas" -field = "tags" -reason = "Resource tagging on write is served but unmodeled; tracked under COVAL-5823." - -[[known_field_gap]] -operation = "POST /personas" -field = "voice_speed" -reason = "Persona audio and placement fields are served but unmodeled; tracked under COVAL-5821." - -[[known_field_gap]] -operation = "POST /personas" -field = "voice_volume" -reason = "Persona audio and placement fields are served but unmodeled; tracked under COVAL-5821." - -[[known_field_gap]] -operation = "POST /personas/background-sounds" -field = "acoustic_source_type" -reason = "Background-sound acoustic source is served but unmodeled; tracked under COVAL-5821." - [[known_field_gap]] operation = "POST /reports" field = "simulation_output_ids" From e4221d054711ed954efb5f7bfbc929c75a714a07 Mon Sep 17 00:00:00 2001 From: Callum Reid Date: Thu, 3 Sep 2026 16:24:19 -0700 Subject: [PATCH 4/4] [COVAL-5821] Drop the version bump; release via a chore PR The release bridge publishes any main version without a matching tag, so the first feature PR to merge would cut an incomplete release. Feature PRs stay at the base version; a dedicated version-bump PR releases the series. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 54c5926..f08ea45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -278,7 +278,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "coval" -version = "0.8.2" +version = "0.8.1" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 0732ea4..638fdd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "coval" -version = "0.8.2" +version = "0.8.1" edition = "2021" description = "CLI for Coval AI agent evaluation platform" license = "MIT"