1use std::fmt;
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use crate::QuestionItem;
9
10pub(crate) const MAX_QUESTIONS: usize = 5;
18const QUESTIONS_FIELD_DESCRIPTION_TEMPLATE: &str =
19 include_str!("template/questions_field_description.md");
20
21pub(crate) fn questions_field_description() -> String {
30 QUESTIONS_FIELD_DESCRIPTION_TEMPLATE
31 .trim_end()
32 .replace("{{ max_questions }}", &MAX_QUESTIONS.to_string())
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44#[non_exhaustive]
45pub enum ProtocolRequestProfile {
46 SessionTurn,
48 UtilityPrompt,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
58#[schemars(
59 title = "AgentResponseSummary",
60 description = "Structured session summary block emitted alongside protocol messages instead \
61 of embedding the change summary inside `answer` markdown on session-discussion \
62 turns."
63)]
64pub struct AgentResponseSummary {
65 #[schemars(
67 title = "session",
68 description = "Cumulative summary of active changes on the current session branch."
69 )]
70 pub session: String,
71 #[schemars(
73 title = "turn",
74 description = "Concise summary of only the work completed in the current turn."
75 )]
76 pub turn: String,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
84#[schemars(
85 title = "AgentResponse",
86 description = "Wire-format protocol payload used for schema-driven provider output. Return \
87 this object as the entire assistant response payload. Providers that support \
88 output schemas (for example, Codex app-server) are asked to emit this object \
89 directly."
90)]
91pub struct AgentResponse {
92 #[serde(default)]
94 #[schemars(
95 title = "answer",
96 description = "Markdown answer text for delivered work, status updates, or concise \
97 completion notes. Keep clarification requests out of this field and emit \
98 them through `questions` instead."
99 )]
100 pub answer: String,
101 #[serde(default)]
109 #[schemars(title = "questions")]
110 pub questions: Vec<QuestionItem>,
111 #[serde(default)]
114 #[schemars(
115 title = "summary",
116 description = "Structured summary for session-discussion turns, kept outside `answer` \
117 markdown. Use `null` for one-shot prompts and legacy payloads."
118 )]
119 pub summary: Option<AgentResponseSummary>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum AgentResponseParseError {
125 Empty,
127 InvalidFormat { reason: String },
130}
131
132impl fmt::Display for AgentResponseParseError {
133 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
134 match self {
135 Self::Empty => write!(formatter, "response is empty"),
136 Self::InvalidFormat { reason } => {
137 write!(formatter, "response is not valid protocol JSON: {reason}")
138 }
139 }
140 }
141}
142
143impl std::error::Error for AgentResponseParseError {}
144
145impl AgentResponse {
146 pub fn plain(text: impl Into<String>) -> Self {
148 Self {
149 answer: text.into(),
150 questions: Vec::new(),
151 summary: None,
152 }
153 }
154
155 pub fn to_display_text(&self) -> String {
158 let mut display_messages = Vec::new();
159 push_display_message(&mut display_messages, &self.answer);
160 push_question_display_messages(&mut display_messages, &self.questions);
161
162 display_messages.join("\n\n")
163 }
164
165 pub fn to_answer_display_text(&self) -> String {
168 let mut display_messages = Vec::new();
169 push_display_message(&mut display_messages, &self.answer);
170
171 display_messages.join("\n\n")
172 }
173
174 pub fn answers(&self) -> Vec<String> {
176 let answer = self.to_answer_display_text();
177 if answer.is_empty() {
178 return Vec::new();
179 }
180
181 vec![answer]
182 }
183
184 pub fn question_items(&self) -> Vec<QuestionItem> {
187 self.questions.iter().take(MAX_QUESTIONS).cloned().collect()
188 }
189}
190
191fn push_question_display_messages(display_messages: &mut Vec<String>, questions: &[QuestionItem]) {
193 for question in questions {
194 push_display_message(display_messages, &question.text);
195 }
196}
197
198fn push_display_message(display_messages: &mut Vec<String>, text: &str) {
200 if text.trim().is_empty() {
201 return;
202 }
203
204 display_messages.push(text.to_string());
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn test_agent_response_to_display_text_joins_answer_and_questions() {
215 let response = AgentResponse {
217 answer: "Primary answer".to_string(),
218 questions: vec![QuestionItem::new("Need one clarification.")],
219 summary: None,
220 };
221
222 let display_text = response.to_display_text();
224
225 assert_eq!(display_text, "Primary answer\n\nNeed one clarification.");
227 }
228
229 #[test]
230 fn test_agent_response_question_items_applies_question_cap() {
232 let response = AgentResponse {
234 answer: String::new(),
235 questions: (0..=MAX_QUESTIONS)
236 .map(|index| QuestionItem::new(format!("Question {index}")))
237 .collect(),
238 summary: None,
239 };
240
241 let questions = response.question_items();
243
244 assert_eq!(questions.len(), MAX_QUESTIONS);
246 }
247
248 #[test]
249 fn test_questions_field_description_renders_template_limit() {
252 let description = questions_field_description();
254 let normalized_description = description.split_whitespace().collect::<Vec<_>>().join(" ");
255
256 assert!(normalized_description.contains("Emit at most 5 items"));
258 assert!(normalized_description.contains("Execute the agreed work"));
259 assert!(!description.contains("{{ max_questions }}"));
260 }
261}