use std::fmt;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::QuestionItem;
pub(crate) const MAX_QUESTIONS: usize = 5;
const QUESTIONS_FIELD_DESCRIPTION_TEMPLATE: &str =
include_str!("template/questions_field_description.md");
pub(crate) fn questions_field_description() -> String {
QUESTIONS_FIELD_DESCRIPTION_TEMPLATE
.trim_end()
.replace("{{ max_questions }}", &MAX_QUESTIONS.to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ProtocolRequestProfile {
SessionTurn,
UtilityPrompt,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[schemars(
title = "AgentResponseSummary",
description = "Structured session summary block emitted alongside protocol messages instead \
of embedding the change summary inside `answer` markdown on session-discussion \
turns."
)]
pub struct AgentResponseSummary {
#[schemars(
title = "session",
description = "Cumulative summary of active changes on the current session branch."
)]
pub session: String,
#[schemars(
title = "turn",
description = "Concise summary of only the work completed in the current turn."
)]
pub turn: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[schemars(
title = "AgentResponse",
description = "Wire-format protocol payload used for schema-driven provider output. Return \
this object as the entire assistant response payload. Providers that support \
output schemas (for example, Codex app-server) are asked to emit this object \
directly."
)]
pub struct AgentResponse {
#[serde(default)]
#[schemars(
title = "answer",
description = "Markdown answer text for delivered work, status updates, or concise \
completion notes. Keep clarification requests out of this field and emit \
them through `questions` instead."
)]
pub answer: String,
#[serde(default)]
#[schemars(title = "questions")]
pub questions: Vec<QuestionItem>,
#[serde(default)]
#[schemars(
title = "summary",
description = "Structured summary for session-discussion turns, kept outside `answer` \
markdown. Use `null` for one-shot prompts and legacy payloads."
)]
pub summary: Option<AgentResponseSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentResponseParseError {
Empty,
InvalidFormat { reason: String },
}
impl fmt::Display for AgentResponseParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(formatter, "response is empty"),
Self::InvalidFormat { reason } => {
write!(formatter, "response is not valid protocol JSON: {reason}")
}
}
}
}
impl std::error::Error for AgentResponseParseError {}
impl AgentResponse {
pub fn plain(text: impl Into<String>) -> Self {
Self {
answer: text.into(),
questions: Vec::new(),
summary: None,
}
}
pub fn to_display_text(&self) -> String {
let mut display_messages = Vec::new();
push_display_message(&mut display_messages, &self.answer);
push_question_display_messages(&mut display_messages, &self.questions);
display_messages.join("\n\n")
}
pub fn to_answer_display_text(&self) -> String {
let mut display_messages = Vec::new();
push_display_message(&mut display_messages, &self.answer);
display_messages.join("\n\n")
}
pub fn answers(&self) -> Vec<String> {
let answer = self.to_answer_display_text();
if answer.is_empty() {
return Vec::new();
}
vec![answer]
}
pub fn question_items(&self) -> Vec<QuestionItem> {
self.questions.iter().take(MAX_QUESTIONS).cloned().collect()
}
}
fn push_question_display_messages(display_messages: &mut Vec<String>, questions: &[QuestionItem]) {
for question in questions {
push_display_message(display_messages, &question.text);
}
}
fn push_display_message(display_messages: &mut Vec<String>, text: &str) {
if text.trim().is_empty() {
return;
}
display_messages.push(text.to_string());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_agent_response_to_display_text_joins_answer_and_questions() {
let response = AgentResponse {
answer: "Primary answer".to_string(),
questions: vec![QuestionItem::new("Need one clarification.")],
summary: None,
};
let display_text = response.to_display_text();
assert_eq!(display_text, "Primary answer\n\nNeed one clarification.");
}
#[test]
fn test_agent_response_question_items_applies_question_cap() {
let response = AgentResponse {
answer: String::new(),
questions: (0..=MAX_QUESTIONS)
.map(|index| QuestionItem::new(format!("Question {index}")))
.collect(),
summary: None,
};
let questions = response.question_items();
assert_eq!(questions.len(), MAX_QUESTIONS);
}
#[test]
fn test_questions_field_description_renders_template_limit() {
let description = questions_field_description();
let normalized_description = description.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(normalized_description.contains("Emit at most 5 items"));
assert!(normalized_description.contains("Execute the agreed work"));
assert!(!description.contains("{{ max_questions }}"));
}
}