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