1use std::fmt;
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use super::question::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, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
81#[serde(rename_all = "snake_case")]
82#[schemars(
83 title = "ReviewCommentResolution",
84 description = "Disposition reported for one targeted forge review thread."
85)]
86pub enum ReviewCommentResolution {
87 Fixed,
90 NoChangeNeeded,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
97#[schemars(
98 title = "ReviewCommentOutcome",
99 description = "Structured outcome for one forge review thread explicitly included in the turn \
100 prompt."
101)]
102pub struct ReviewCommentOutcome {
103 #[schemars(
105 title = "reply",
106 description = "Concise reply suitable for posting to the forge review thread."
107 )]
108 pub reply: String,
109 #[schemars(
111 title = "resolution",
112 description = "Whether the targeted thread was fixed or required no change."
113 )]
114 pub resolution: ReviewCommentResolution,
115 #[schemars(
117 title = "thread_id",
118 description = "Opaque forge thread identifier copied exactly from the turn prompt."
119 )]
120 pub thread_id: String,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
128#[schemars(
129 title = "AgentResponse",
130 description = "Wire-format protocol payload used for schema-driven provider output. Return \
131 this object as the entire assistant response payload. Providers that support \
132 output schemas (for example, Codex app-server) are asked to emit this object \
133 directly."
134)]
135pub struct AgentResponse {
136 #[serde(default)]
138 #[schemars(
139 title = "answer",
140 description = "Markdown answer text for delivered work, status updates, or concise \
141 completion notes. Keep clarification requests out of this field and emit \
142 them through `questions` instead."
143 )]
144 pub answer: String,
145 #[serde(default)]
153 #[schemars(title = "questions")]
154 pub questions: Vec<QuestionItem>,
155 #[serde(default)]
161 #[schemars(
162 title = "review_comment_outcomes",
163 description = "Per-thread outcomes for an agent-driven forge comment-resolution turn. \
164 Emit an empty array unless the prompt explicitly supplies forge thread \
165 IDs. Copy each reported `thread_id` exactly from the prompt."
166 )]
167 pub review_comment_outcomes: Vec<ReviewCommentOutcome>,
168 #[serde(default)]
171 #[schemars(
172 title = "summary",
173 description = "Structured summary for session-discussion turns, kept outside `answer` \
174 markdown. Use `null` for one-shot prompts and legacy payloads."
175 )]
176 pub summary: Option<AgentResponseSummary>,
177}
178
179impl AgentResponse {
180 pub fn plain(text: impl Into<String>) -> Self {
182 Self {
183 answer: text.into(),
184 questions: Vec::new(),
185 review_comment_outcomes: Vec::new(),
186 summary: None,
187 }
188 }
189
190 pub fn to_display_text(&self) -> String {
193 let mut display_messages = Vec::new();
194 push_display_message(&mut display_messages, &self.answer);
195 push_question_display_messages(&mut display_messages, &self.questions);
196
197 display_messages.join("\n\n")
198 }
199
200 pub fn to_answer_display_text(&self) -> String {
203 let mut display_messages = Vec::new();
204 push_display_message(&mut display_messages, &self.answer);
205
206 display_messages.join("\n\n")
207 }
208
209 pub fn answers(&self) -> Vec<String> {
211 let answer = self.to_answer_display_text();
212 if answer.is_empty() {
213 return Vec::new();
214 }
215
216 vec![answer]
217 }
218
219 pub fn question_items(&self) -> Vec<QuestionItem> {
222 self.questions.iter().take(MAX_QUESTIONS).cloned().collect()
223 }
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
228pub enum AgentResponseParseError {
229 Empty,
231 InvalidFormat {
234 reason: String,
236 },
237}
238
239impl fmt::Display for AgentResponseParseError {
240 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
241 match self {
242 Self::Empty => write!(formatter, "response is empty"),
243 Self::InvalidFormat { reason } => {
244 write!(formatter, "response is not valid protocol JSON: {reason}")
245 }
246 }
247 }
248}
249
250impl std::error::Error for AgentResponseParseError {}
251
252fn push_display_message(display_messages: &mut Vec<String>, text: &str) {
254 if text.trim().is_empty() {
255 return;
256 }
257
258 display_messages.push(text.to_string());
259}
260
261fn push_question_display_messages(display_messages: &mut Vec<String>, questions: &[QuestionItem]) {
263 for question in questions {
264 push_display_message(display_messages, &question.text);
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn test_questions_field_description_renders_template_limit() {
276 let description = questions_field_description();
278 let normalized_description = description.split_whitespace().collect::<Vec<_>>().join(" ");
279
280 assert!(normalized_description.contains("Emit at most 5 items"));
282 assert!(normalized_description.contains("Execute the agreed work"));
283 assert!(!description.contains("{{ max_questions }}"));
284 }
285
286 #[test]
287 fn test_agent_response_to_display_text_joins_answer_and_questions() {
290 let response = AgentResponse {
292 answer: "Primary answer".to_string(),
293 questions: vec![QuestionItem::new("Need one clarification.")],
294 review_comment_outcomes: Vec::new(),
295 summary: None,
296 };
297
298 let display_text = response.to_display_text();
300
301 assert_eq!(display_text, "Primary answer\n\nNeed one clarification.");
303 }
304
305 #[test]
306 fn test_agent_response_review_comment_outcomes_round_trip() {
308 let response = AgentResponse {
310 answer: "Addressed the comment.".to_string(),
311 questions: Vec::new(),
312 review_comment_outcomes: vec![ReviewCommentOutcome {
313 reply: "Added the missing validation.".to_string(),
314 resolution: ReviewCommentResolution::Fixed,
315 thread_id: "thread-42".to_string(),
316 }],
317 summary: None,
318 };
319
320 let serialized = serde_json::to_string(&response).expect("response should serialize");
322 let deserialized = serde_json::from_str::<AgentResponse>(&serialized)
323 .expect("response should deserialize");
324
325 assert_eq!(deserialized, response);
327 assert!(serialized.contains(r#""resolution":"fixed""#));
328 }
329
330 #[test]
331 fn test_agent_response_question_items_applies_question_cap() {
333 let response = AgentResponse {
335 answer: String::new(),
336 questions: (0..=MAX_QUESTIONS)
337 .map(|index| QuestionItem::new(format!("Question {index}")))
338 .collect(),
339 review_comment_outcomes: Vec::new(),
340 summary: None,
341 };
342
343 let questions = response.question_items();
345
346 assert_eq!(questions.len(), MAX_QUESTIONS);
348 }
349}