Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <agent_id> \
--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 <agent_id> --input-json '{"attributes":null}'

# Add metadata to an already-submitted conversation (additive; a key with a value is rejected)
coval uploaded-conversations patch <conversation_id> \
--metadata csat_bucket=promoter \
--metadata called_back=yes

# Create a test case
coval test-cases create \
Expand Down
17 changes: 3 additions & 14 deletions api-coverage-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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`
55 changes: 0 additions & 55 deletions api-coverage.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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.

Expand Down
30 changes: 30 additions & 0 deletions src/client/models/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,36 @@ pub struct UpdateAgentRequest {
pub metric_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub test_set_ids: Option<Vec<String>>,
/// 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<String>,
// 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<Option<String>>,
#[serde(
default,
deserialize_with = "super::explicit_option",
skip_serializing_if = "Option::is_none"
)]
pub attributes: Option<Option<serde_json::Value>>,
#[serde(
default,
deserialize_with = "super::explicit_option",
skip_serializing_if = "Option::is_none"
)]
pub workflows: Option<Option<serde_json::Value>>,
#[serde(
default,
deserialize_with = "super::explicit_option",
skip_serializing_if = "Option::is_none"
)]
pub tags: Option<Option<Vec<String>>>,
}

#[derive(Debug, Deserialize)]
Expand Down
6 changes: 6 additions & 0 deletions src/client/models/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ pub struct SubmitConversationRequest {
pub occurred_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -139,6 +141,10 @@ pub struct PatchConversationRequest {
pub audio_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio_reference: Option<serde_json::Value>,
/// 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<std::collections::BTreeMap<String, serde_json::Value>>,
}

#[derive(Debug, Deserialize)]
Expand Down
6 changes: 6 additions & 0 deletions src/client/models/run_template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ pub struct CreateRunTemplateRequest {
pub sub_sample_seed: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
/// Tag names. Omitted leaves tags unchanged; an empty list clears them.
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
}

#[derive(Debug, Default, Serialize, Deserialize)]
Expand Down Expand Up @@ -103,6 +106,9 @@ pub struct UpdateRunTemplateRequest {
pub sub_sample_seed: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
/// Tag names. Omitted leaves tags unchanged; an empty list clears them.
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
}

#[derive(Debug, Deserialize)]
Expand Down
6 changes: 6 additions & 0 deletions src/client/models/test_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ pub struct CreateTestSetRequest {
pub test_set_metadata: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parameters: Option<serde_json::Value>,
/// Tag names. Omitted leaves tags unchanged; an empty list clears them.
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
}

#[derive(Debug, Default, Serialize, Deserialize)]
Expand All @@ -55,6 +58,9 @@ pub struct UpdateTestSetRequest {
pub test_set_metadata: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parameters: Option<serde_json::Value>,
/// Tag names. Omitted leaves tags unchanged; an empty list clears them.
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
}

#[derive(Debug, Deserialize)]
Expand Down
28 changes: 23 additions & 5 deletions src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,21 @@ pub struct UpdateArgs {
/// JSON string for metadata
#[arg(long)]
metadata: Option<String>,
/// Your own stable identifier for the agent
#[arg(long)]
customer_agent_id: Option<String>,
/// Primary agent language
#[arg(long)]
language: Option<String>,
/// JSON object of free-form agent attributes
#[arg(long)]
attributes: Option<String>,
/// JSON object containing workflow configuration
#[arg(long)]
workflows: Option<String>,
/// Comma-separated tag names
#[arg(long, value_delimiter = ',')]
tags: Option<Vec<String>>,
}

#[derive(Args)]
Expand Down Expand Up @@ -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<serde_json::Value> = 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)?;
Expand All @@ -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));
Expand Down
38 changes: 34 additions & 4 deletions src/commands/conversations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DateTime<Utc>>,
/// Tag to apply to the conversation (repeat for multiple)
#[arg(long = "tag")]
tags: Vec<String>,
}

fn parse_kv(raw: &str) -> Result<(String, String), String> {
Expand Down Expand Up @@ -139,6 +142,10 @@ pub struct PatchArgs {
audio_url: Option<String>,
#[arg(long)]
audio_file: Option<PathBuf>,
/// 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)]
Expand Down Expand Up @@ -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) => {
Expand All @@ -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 => {
Expand Down Expand Up @@ -605,6 +630,11 @@ fn build_submit_request(args: SubmitArgs) -> Result<SubmitConversationRequest> {
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)
}

Expand Down
Loading
Loading