diff --git a/README.md b/README.md index 19c9b20..0fd99e5 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,23 @@ coval agents create \ # Create a test set coval test-sets create \ --name "Customer Support Scenarios" \ - --type SCENARIO + --type SCENARIO \ + --tags regression,voice + +# Change an agent's identity and routing fields +coval agents update \ + --customer-agent-id crm-42 \ + --language en-US \ + --attributes '{"tier":"gold"}' \ + --tags prod,voice + +# Clear one of them. A flag can only set a value, so clearing needs an explicit null. +coval agents update --input-json '{"attributes":null}' + +# Add metadata to an already-submitted conversation (additive; a key with a value is rejected) +coval uploaded-conversations patch \ + --metadata csat_bucket=promoter \ + --metadata called_back=yes # Create a test case coval test-cases create \ diff --git a/api-coverage-report.md b/api-coverage-report.md index c173e10..be72e35 100644 --- a/api-coverage-report.md +++ b/api-coverage-report.md @@ -18,8 +18,8 @@ only when coverage actually changes. | Reviewed gaps | 62 | | Client operations | 143 | | Published request fields on covered operations | 363 | -| Request fields modeled by the CLI | 343 | -| Reviewed request-field gaps | 17 | +| Request fields modeled by the CLI | 354 | +| Reviewed request-field gaps | 6 | Catalog: https://api.coval.dev/v1/openapi @@ -50,7 +50,7 @@ Catalog: https://api.coval.dev/v1/openapi ## Coverage snapshot mismatches - `published_request_fields: recorded 360, current 363` -- `cli_modeled_request_fields: recorded 313, current 343` +- `cli_modeled_request_fields: recorded 313, current 354` ## Client-only operations @@ -141,23 +141,12 @@ Catalog: https://api.coval.dev/v1/openapi ## All current request-field gaps -- `PATCH /agents/{id} attributes` -- `PATCH /agents/{id} customer_agent_id` -- `PATCH /agents/{id} language` -- `PATCH /agents/{id} tags` -- `PATCH /agents/{id} workflows` -- `PATCH /conversations/uploaded/{id} metadata` - `PATCH /metrics/{id} ivr_flow` - `PATCH /run-templates/{id} agent_id` - `PATCH /run-templates/{id} persona_id` -- `PATCH /run-templates/{id} tags` - `PATCH /run-templates/{id} test_set_id` -- `PATCH /test-sets/{id} tags` -- `POST /conversations/uploaded:submit tags` - `POST /metrics ivr_flow` - `POST /run-templates agent_id` - `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 b11ccf6..4dbe4a4 100644 --- a/api-coverage.toml +++ b/api-coverage.toml @@ -391,36 +391,6 @@ reason = "Workspace management remains to be modeled under COVAL-2079." # Pydantic model; coval-ai/backend records the known divergence in # src/services/api/tests/v1/openapi_parity_baseline.txt. -[[known_field_gap]] -operation = "PATCH /agents/{agent_id}" -field = "attributes" -reason = "Served by the agent merge-patch and already modeled on create, so update is asymmetric; tracked under COVAL-5823." - -[[known_field_gap]] -operation = "PATCH /agents/{agent_id}" -field = "customer_agent_id" -reason = "Served by the agent merge-patch and already modeled on create, so update is asymmetric; tracked under COVAL-5823." - -[[known_field_gap]] -operation = "PATCH /agents/{agent_id}" -field = "language" -reason = "Served by the agent merge-patch and already modeled on create, so update is asymmetric; tracked under COVAL-5823." - -[[known_field_gap]] -operation = "PATCH /agents/{agent_id}" -field = "tags" -reason = "Resource tagging on write is served but unmodeled; tracked under COVAL-5823." - -[[known_field_gap]] -operation = "PATCH /agents/{agent_id}" -field = "workflows" -reason = "Served by the agent merge-patch and already modeled on create, so update is asymmetric; tracked under COVAL-5823." - -[[known_field_gap]] -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 /run-templates/{run_template_id}" field = "agent_id" @@ -431,26 +401,11 @@ operation = "PATCH /run-templates/{run_template_id}" field = "persona_id" reason = "Documented but not served: the transport forbids extra fields and takes the plural `persona_ids` array the CLI already sends, so modeling this name would break the call. Spec drift tracked under COVAL-5825." -[[known_field_gap]] -operation = "PATCH /run-templates/{run_template_id}" -field = "tags" -reason = "Resource tagging on write is served but unmodeled; tracked under COVAL-5823." - [[known_field_gap]] operation = "PATCH /run-templates/{run_template_id}" field = "test_set_id" reason = "Documented but not served: the transport forbids extra fields and takes the plural `test_set_ids` array the CLI already sends, so modeling this name would break the call. Spec drift tracked under COVAL-5825." -[[known_field_gap]] -operation = "PATCH /test-sets/{test_set_id}" -field = "tags" -reason = "Resource tagging on write is served but unmodeled; tracked under COVAL-5823." - -[[known_field_gap]] -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 /run-templates" field = "agent_id" @@ -461,21 +416,11 @@ operation = "POST /run-templates" field = "persona_id" reason = "Documented but not served: the transport forbids extra fields and takes the plural `persona_ids` array the CLI already sends, so modeling this name would break the call. Spec drift tracked under COVAL-5825." -[[known_field_gap]] -operation = "POST /run-templates" -field = "tags" -reason = "Resource tagging on write is served but unmodeled; tracked under COVAL-5823." - [[known_field_gap]] operation = "POST /run-templates" field = "test_set_id" reason = "Documented but not served: the transport forbids extra fields and takes the plural `test_set_ids` array the CLI already sends, so modeling this name would break the call. Spec drift tracked under COVAL-5825." -[[known_field_gap]] -operation = "POST /test-sets" -field = "tags" -reason = "Resource tagging on write is served but unmodeled; tracked under COVAL-5823." - # Fields the CLI sends that the published schema does not declare. Most are served # but undocumented, so removing them from the CLI would lose working behavior. diff --git a/src/client/models/agent.rs b/src/client/models/agent.rs index 5aeefef..e4254cc 100644 --- a/src/client/models/agent.rs +++ b/src/client/models/agent.rs @@ -141,6 +141,36 @@ pub struct UpdateAgentRequest { pub metric_ids: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub test_set_ids: Option>, + /// Not nullable: the API treats an omitted value as "leave it alone" and + /// rejects null outright. + #[serde(skip_serializing_if = "Option::is_none")] + pub customer_agent_id: Option, + // The merge patch writes only supplied columns and normalizes an explicit null + // to an empty value, so null is how a caller clears these. + #[serde( + default, + deserialize_with = "super::explicit_option", + skip_serializing_if = "Option::is_none" + )] + pub language: Option>, + #[serde( + default, + deserialize_with = "super::explicit_option", + skip_serializing_if = "Option::is_none" + )] + pub attributes: Option>, + #[serde( + default, + deserialize_with = "super::explicit_option", + skip_serializing_if = "Option::is_none" + )] + pub workflows: Option>, + #[serde( + default, + deserialize_with = "super::explicit_option", + skip_serializing_if = "Option::is_none" + )] + pub tags: Option>>, } #[derive(Debug, Deserialize)] diff --git a/src/client/models/conversation.rs b/src/client/models/conversation.rs index c845102..9dfac17 100644 --- a/src/client/models/conversation.rs +++ b/src/client/models/conversation.rs @@ -101,6 +101,8 @@ pub struct SubmitConversationRequest { pub occurred_at: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub agent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, } #[derive(Debug, Deserialize)] @@ -139,6 +141,10 @@ pub struct PatchConversationRequest { pub audio_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub audio_reference: Option, + /// Additive only: the API rejects a key that already has a value. Exactly one + /// of the four fields on this request may be supplied. + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, } #[derive(Debug, Deserialize)] diff --git a/src/client/models/run_template.rs b/src/client/models/run_template.rs index 7e8e5cd..d47cfe7 100644 --- a/src/client/models/run_template.rs +++ b/src/client/models/run_template.rs @@ -75,6 +75,9 @@ pub struct CreateRunTemplateRequest { pub sub_sample_seed: Option, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: 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)] @@ -103,6 +106,9 @@ pub struct UpdateRunTemplateRequest { pub sub_sample_seed: Option, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: 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)] diff --git a/src/client/models/test_set.rs b/src/client/models/test_set.rs index b664a62..43cb62d 100644 --- a/src/client/models/test_set.rs +++ b/src/client/models/test_set.rs @@ -39,6 +39,9 @@ pub struct CreateTestSetRequest { pub test_set_metadata: Option, #[serde(skip_serializing_if = "Option::is_none")] pub parameters: 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)] @@ -55,6 +58,9 @@ pub struct UpdateTestSetRequest { pub test_set_metadata: Option, #[serde(skip_serializing_if = "Option::is_none")] pub parameters: 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)] diff --git a/src/commands/agents.rs b/src/commands/agents.rs index cb88066..8ef5ba9 100644 --- a/src/commands/agents.rs +++ b/src/commands/agents.rs @@ -118,6 +118,21 @@ pub struct UpdateArgs { /// JSON string for metadata #[arg(long)] metadata: Option, + /// Your own stable identifier for the agent + #[arg(long)] + customer_agent_id: Option, + /// Primary agent language + #[arg(long)] + language: Option, + /// JSON object of free-form agent attributes + #[arg(long)] + attributes: Option, + /// JSON object containing workflow configuration + #[arg(long)] + workflows: Option, + /// Comma-separated tag names + #[arg(long, value_delimiter = ',')] + tags: Option>, } #[derive(Args)] @@ -174,11 +189,9 @@ pub async fn execute(cmd: AgentCommands, client: &CovalClient, ctx: &OutputConte } AgentCommands::Update(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, "model_type", args.r#type)?; @@ -188,6 +201,11 @@ pub async fn execute(cmd: AgentCommands, client: &CovalClient, ctx: &OutputConte input_json::insert(&mut input, "metadata", metadata)?; 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, "customer_agent_id", args.customer_agent_id)?; + input_json::insert(&mut input, "language", args.language)?; + input_json::insert(&mut input, "attributes", attributes)?; + input_json::insert(&mut input, "workflows", workflows)?; + input_json::insert(&mut input, "tags", args.tags)?; let req: UpdateAgentRequest = input_json::finish(input)?; let agent = client.agents().update(&args.agent_id, req).await?; emit_one_with_actions(ctx, "agents", operation, &agent, agent_actions(&agent.id)); diff --git a/src/commands/conversations.rs b/src/commands/conversations.rs index 95fc11c..69a623b 100644 --- a/src/commands/conversations.rs +++ b/src/commands/conversations.rs @@ -109,6 +109,9 @@ pub struct SubmitArgs { /// When the conversation occurred (ISO 8601, e.g. 2026-05-05T12:34:56Z) #[arg(long)] occurred_at: Option>, + /// Tag to apply to the conversation (repeat for multiple) + #[arg(long = "tag")] + tags: Vec, } fn parse_kv(raw: &str) -> Result<(String, String), String> { @@ -139,6 +142,10 @@ pub struct PatchArgs { audio_url: Option, #[arg(long)] audio_file: Option, + /// Metadata to add as key=value (repeat for multiple). Additive only: the API + /// rejects a key that already has a value. + #[arg(long = "metadata", value_parser = parse_kv)] + metadata: Vec<(String, String)>, } #[derive(Clone, Copy)] @@ -398,11 +405,22 @@ async fn execute_for( } ConversationCommands::Patch(args) => { use crate::client::models::PatchConversationRequest; - if args.audio_file.is_some() && args.audio_url.is_some() { - anyhow::bail!("--audio-file and --audio-url are mutually exclusive"); + // The API patches audio and metadata separately so a rejected metadata + // key can never leave audio half-attached, and accepts exactly one + // target per call. + let targets = [ + args.audio_file.is_some(), + args.audio_url.is_some(), + !args.metadata.is_empty(), + ] + .into_iter() + .filter(|supplied| *supplied) + .count(); + if targets > 1 { + anyhow::bail!("--audio-file, --audio-url, and --metadata are mutually exclusive"); } - if args.audio_file.is_none() && args.audio_url.is_none() { - anyhow::bail!("must provide at least one of: --audio-file, --audio-url"); + if targets == 0 { + anyhow::bail!("must provide exactly one of: --audio-file, --audio-url, --metadata"); } let audio_b64 = match args.audio_file { Some(path) => { @@ -413,10 +431,17 @@ async fn execute_for( } None => None, }; + let metadata = (!args.metadata.is_empty()).then(|| { + args.metadata + .into_iter() + .map(|(key, value)| (key, serde_json::Value::String(value))) + .collect() + }); let req = PatchConversationRequest { audio: audio_b64, audio_url: args.audio_url, audio_reference: None, + metadata, }; let result = match collection { ConversationCollection::Uploaded => { @@ -605,6 +630,11 @@ fn build_submit_request(args: SubmitArgs) -> Result { input_json::insert(&mut input, "external_conversation_id", args.external_id)?; input_json::insert(&mut input, "occurred_at", args.occurred_at)?; input_json::insert(&mut input, "agent_id", args.agent_id)?; + input_json::insert( + &mut input, + "tags", + (!args.tags.is_empty()).then_some(args.tags), + )?; input_json::finish(input) } diff --git a/src/commands/run_templates.rs b/src/commands/run_templates.rs index 8fa1aab..216b225 100644 --- a/src/commands/run_templates.rs +++ b/src/commands/run_templates.rs @@ -73,6 +73,9 @@ pub struct CreateArgs { sub_sample_size: Option, #[arg(long)] sub_sample_seed: Option, + /// Comma-separated tag names; pass an empty value to clear all tags + #[arg(long, value_delimiter = ',')] + tags: Option>, } #[derive(Args)] @@ -102,6 +105,9 @@ pub struct UpdateArgs { sub_sample_size: Option, #[arg(long)] sub_sample_seed: Option, + /// Comma-separated tag names; pass an empty value to clear all tags + #[arg(long, value_delimiter = ',')] + tags: Option>, } #[derive(Args)] @@ -164,6 +170,7 @@ pub async fn execute( input_json::insert(&mut input, "concurrency", args.concurrency)?; input_json::insert(&mut input, "sub_sample_size", args.sub_sample_size)?; input_json::insert(&mut input, "sub_sample_seed", args.sub_sample_seed)?; + input_json::insert(&mut input, "tags", args.tags)?; let req: CreateRunTemplateRequest = input_json::finish(input)?; let template = client.run_templates().create(req).await?; emit_one_with_actions( @@ -187,6 +194,7 @@ pub async fn execute( input_json::insert(&mut input, "concurrency", args.concurrency)?; input_json::insert(&mut input, "sub_sample_size", args.sub_sample_size)?; input_json::insert(&mut input, "sub_sample_seed", args.sub_sample_seed)?; + input_json::insert(&mut input, "tags", args.tags)?; let req: UpdateRunTemplateRequest = input_json::finish(input)?; let template = client .run_templates() diff --git a/src/commands/test_sets.rs b/src/commands/test_sets.rs index d55f6f8..7e09d68 100644 --- a/src/commands/test_sets.rs +++ b/src/commands/test_sets.rs @@ -67,6 +67,9 @@ pub struct CreateArgs { /// Test set type (e.g. DEFAULT, SCENARIO, TRANSCRIPT, WORKFLOW) #[arg(long)] r#type: Option, + /// Comma-separated tag names; pass an empty value to clear all tags + #[arg(long, value_delimiter = ',')] + tags: Option>, } #[derive(Args)] @@ -83,6 +86,9 @@ pub struct UpdateArgs { /// Human-readable description #[arg(long)] description: Option, + /// Comma-separated tag names; pass an empty value to clear all tags + #[arg(long, value_delimiter = ',')] + tags: Option>, } #[derive(Args)] @@ -137,6 +143,7 @@ pub async fn execute( input_json::insert(&mut input, "slug", args.slug)?; input_json::insert(&mut input, "description", args.description)?; input_json::insert(&mut input, "test_set_type", args.r#type)?; + input_json::insert(&mut input, "tags", args.tags)?; let req: CreateTestSetRequest = input_json::finish(input)?; let test_set = client.test_sets().create(req).await?; emit_one_with_actions( @@ -152,6 +159,7 @@ pub async fn execute( input_json::insert(&mut input, "display_name", args.name)?; input_json::insert(&mut input, "slug", args.slug)?; input_json::insert(&mut input, "description", args.description)?; + input_json::insert(&mut input, "tags", args.tags)?; let req: UpdateTestSetRequest = input_json::finish(input)?; let test_set = client.test_sets().update(&args.test_set_id, req).await?; emit_one_with_actions( diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 7b756df..f69f7aa 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -2957,6 +2957,335 @@ async fn test_mutations_list() { .stdout(predicate::str::contains("GPT-4 Fast")); } +fn agent_patch_response() -> Value { + json!({ + "agent": { + "id": "abc123", + "display_name": "Test Agent", + "model_type": "MODEL_TYPE_VOICE", + "create_time": "2026-09-02T10:30:00Z" + } + }) +} + +fn run_template_response() -> Value { + json!({ + "run_template": { + "id": "rt123", + "display_name": "My Template", + "metric_ids": [], + "mutation_ids": [], + "metadata": {}, + "create_time": "2026-09-02T10:30:00Z" + } + }) +} + +fn test_set_patch_response() -> Value { + json!({ + "test_set": { + "name": "testSets/ts123", + "id": "ts123", + "slug": "tagged", + "display_name": "Tagged", + "create_time": "2026-09-02T10:30:00Z" + } + }) +} + +#[tokio::test] +async fn test_agents_update_forwards_every_modeled_field() { + let mock_server = MockServer::start().await; + let capture = BodyCapture::default(); + + Mock::given(method("PATCH")) + .and(path("/v1/agents/abc123")) + .and(header("X-API-Key", "test_key")) + .and(capture.clone()) + .respond_with(ResponseTemplate::new(200).set_body_json(agent_patch_response())) + .mount(&mock_server) + .await; + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("agents") + .arg("update") + .arg("abc123") + .arg("--customer-agent-id") + .arg("crm-42") + .arg("--language") + .arg("en-US") + .arg("--attributes") + .arg(r#"{"tier":"gold"}"#) + .arg("--workflows") + .arg(r#"{"greeting":"short"}"#) + .arg("--tags") + .arg("prod,voice") + .assert() + .success(); + + let body = capture.take(); + assert_eq!(body["customer_agent_id"], "crm-42"); + assert_eq!(body["language"], "en-US"); + assert_eq!(body["attributes"], json!({"tier": "gold"})); + assert_eq!(body["workflows"], json!({"greeting": "short"})); + assert_eq!(body["tags"], json!(["prod", "voice"])); +} + +#[tokio::test] +async fn test_agents_update_forwards_an_explicit_null_to_clear() { + let mock_server = MockServer::start().await; + let capture = BodyCapture::default(); + + Mock::given(method("PATCH")) + .and(path("/v1/agents/abc123")) + .and(header("X-API-Key", "test_key")) + .and(capture.clone()) + .respond_with(ResponseTemplate::new(200).set_body_json(agent_patch_response())) + .mount(&mock_server) + .await; + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("agents") + .arg("update") + .arg("abc123") + .arg("--input-json") + .arg(r#"{"language":null,"attributes":null,"workflows":null,"tags":null}"#) + .assert() + .success(); + + // The merge patch writes only supplied columns and normalizes null to an empty + // value, so dropping the key would turn a deliberate clear into a no-op. + let body = capture.take(); + for key in ["language", "attributes", "workflows", "tags"] { + 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_agents_update_omits_unset_fields() { + let mock_server = MockServer::start().await; + let capture = BodyCapture::default(); + + Mock::given(method("PATCH")) + .and(path("/v1/agents/abc123")) + .and(header("X-API-Key", "test_key")) + .and(capture.clone()) + .respond_with(ResponseTemplate::new(200).set_body_json(agent_patch_response())) + .mount(&mock_server) + .await; + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("agents") + .arg("update") + .arg("abc123") + .arg("--name") + .arg("Renamed") + .assert() + .success(); + + let body = capture.take(); + assert_eq!(body["display_name"], "Renamed"); + for key in [ + "customer_agent_id", + "language", + "attributes", + "workflows", + "tags", + ] { + assert!(body.get(key).is_none(), "unset {key} must be omitted"); + } +} + +#[tokio::test] +async fn test_test_sets_update_forwards_tags() { + let mock_server = MockServer::start().await; + let capture = BodyCapture::default(); + + Mock::given(method("PATCH")) + .and(path("/v1/test-sets/ts123")) + .and(header("X-API-Key", "test_key")) + .and(capture.clone()) + .respond_with(ResponseTemplate::new(200).set_body_json(test_set_patch_response())) + .mount(&mock_server) + .await; + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("test-sets") + .arg("update") + .arg("ts123") + .arg("--tags") + .arg("regression,voice") + .assert() + .success(); + + assert_eq!(capture.take()["tags"], json!(["regression", "voice"])); +} + +#[tokio::test] +async fn test_run_templates_update_forwards_tags() { + let mock_server = MockServer::start().await; + let capture = BodyCapture::default(); + + Mock::given(method("PATCH")) + .and(path("/v1/run-templates/rt123")) + .and(header("X-API-Key", "test_key")) + .and(capture.clone()) + .respond_with(ResponseTemplate::new(200).set_body_json(run_template_response())) + .mount(&mock_server) + .await; + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("run-templates") + .arg("update") + .arg("rt123") + .arg("--tags") + .arg("nightly") + .assert() + .success(); + + assert_eq!(capture.take()["tags"], json!(["nightly"])); +} + +#[tokio::test] +async fn test_uploaded_conversations_submit_forwards_tags() { + let mock_server = MockServer::start().await; + let capture = BodyCapture::default(); + + Mock::given(method("POST")) + .and(path("/v1/conversations/uploaded:submit")) + .and(header("X-API-Key", "test_key")) + .and(capture.clone()) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uploaded_conversation": { + "name": "conversations/uploaded123", + "conversation_id": "uploaded123", + "status": "COMPLETED", + "create_time": "2026-09-02T12:00:00Z", + "has_audio": false + } + }))) + .mount(&mock_server) + .await; + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("uploaded-conversations") + .arg("submit") + .arg("--audio-url") + .arg("https://example.com/call.wav") + .arg("--tag") + .arg("restaurant") + .arg("--tag") + .arg("support-tier-1") + .assert() + .success(); + + assert_eq!( + capture.take()["tags"], + json!(["restaurant", "support-tier-1"]) + ); +} + +#[tokio::test] +async fn test_uploaded_conversations_patch_forwards_metadata() { + let mock_server = MockServer::start().await; + let capture = BodyCapture::default(); + + Mock::given(method("PATCH")) + .and(path("/v1/conversations/uploaded/uploaded123")) + .and(header("X-API-Key", "test_key")) + .and(capture.clone()) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uploaded_conversation": { + "name": "conversations/uploaded123", + "conversation_id": "uploaded123", + "status": "COMPLETED", + "create_time": "2026-09-02T12:00:00Z", + "has_audio": true + } + }))) + .mount(&mock_server) + .await; + + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("uploaded-conversations") + .arg("patch") + .arg("uploaded123") + .arg("--metadata") + .arg("csat_bucket=promoter") + .arg("--metadata") + .arg("called_back=yes") + .assert() + .success(); + + let body = capture.take(); + assert_eq!( + body["metadata"], + json!({"csat_bucket": "promoter", "called_back": "yes"}) + ); + // The API patches audio and metadata separately, so neither audio key rides along. + assert!(body.get("audio").is_none()); + assert!(body.get("audio_url").is_none()); +} + +#[test] +fn test_uploaded_conversations_patch_rejects_audio_with_metadata() { + coval() + .arg("--api-key") + .arg("test_key") + .arg("uploaded-conversations") + .arg("patch") + .arg("uploaded123") + .arg("--audio-url") + .arg("https://example.com/call.wav") + .arg("--metadata") + .arg("csat_bucket=promoter") + .assert() + .failure() + .stderr(predicate::str::contains("mutually exclusive")); +} + +#[test] +fn test_uploaded_conversations_patch_requires_a_target() { + coval() + .arg("--api-key") + .arg("test_key") + .arg("uploaded-conversations") + .arg("patch") + .arg("uploaded123") + .assert() + .failure() + .stderr(predicate::str::contains("must provide exactly one of")); +} + #[tokio::test] async fn test_run_templates_list_hyphenated_path() { let mock_server = MockServer::start().await;