1use std::fmt;
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use super::question::QuestionItem;
9use super::subtask::SubtaskItem;
10
11pub(crate) const MAX_QUESTIONS: usize = 5;
19pub(crate) const MAX_SUBTASKS: usize = 8;
24const QUESTIONS_FIELD_DESCRIPTION_TEMPLATE: &str =
25 include_str!("template/questions_field_description.md");
26const SUBTASKS_FIELD_DESCRIPTION_TEMPLATE: &str =
27 include_str!("template/subtasks_field_description.md");
28
29pub(crate) fn questions_field_description() -> String {
38 render_field_description_template(
39 QUESTIONS_FIELD_DESCRIPTION_TEMPLATE,
40 "{{ max_questions }}",
41 MAX_QUESTIONS,
42 )
43}
44
45pub(crate) fn subtasks_field_description() -> String {
52 render_field_description_template(
53 SUBTASKS_FIELD_DESCRIPTION_TEMPLATE,
54 "{{ max_subtasks }}",
55 MAX_SUBTASKS,
56 )
57}
58
59fn render_field_description_template(template: &str, placeholder: &str, value: usize) -> String {
67 let mut rendered = String::with_capacity(template.len());
68 let mut remaining = template.trim_end();
69
70 while let Some(open_index) = remaining.find("{{") {
71 let after_open = &remaining[open_index..];
72 let Some(close_end) = after_open.find("}}").map(|index| index + "}}".len()) else {
73 break;
74 };
75
76 rendered.push_str(&remaining[..open_index]);
77 rendered.push_str(&collapse_whitespace(&after_open[..close_end]));
78 remaining = &after_open[close_end..];
79 }
80 rendered.push_str(remaining);
81
82 rendered.replace(placeholder, &value.to_string())
83}
84
85fn collapse_whitespace(text: &str) -> String {
87 text.split_whitespace().collect::<Vec<_>>().join(" ")
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99#[non_exhaustive]
100pub enum ProtocolRequestProfile {
101 SessionTurn,
103 UtilityPrompt,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
113#[schemars(
114 title = "AgentResponseSummary",
115 description = "Structured session summary block emitted alongside protocol messages instead \
116 of embedding the change summary inside `answer` markdown on session-discussion \
117 turns."
118)]
119pub struct AgentResponseSummary {
120 #[schemars(
122 title = "session",
123 description = "Cumulative summary of active changes on the current session branch."
124 )]
125 pub session: String,
126 #[schemars(
128 title = "turn",
129 description = "Concise summary of only the work completed in the current turn."
130 )]
131 pub turn: String,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
136#[serde(rename_all = "snake_case")]
137#[schemars(
138 title = "ReviewCommentResolution",
139 description = "Disposition reported for one targeted forge review thread."
140)]
141pub enum ReviewCommentResolution {
142 Fixed,
145 NoChangeNeeded,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
152#[schemars(
153 title = "ReviewCommentOutcome",
154 description = "Structured outcome for one forge review thread explicitly included in the turn \
155 prompt."
156)]
157pub struct ReviewCommentOutcome {
158 #[schemars(
160 title = "reply",
161 description = "Concise reply suitable for posting to the forge review thread."
162 )]
163 pub reply: String,
164 #[schemars(
166 title = "resolution",
167 description = "Whether the targeted thread was fixed or required no change."
168 )]
169 pub resolution: ReviewCommentResolution,
170 #[schemars(
172 title = "thread_id",
173 description = "Opaque forge thread identifier copied exactly from the turn prompt."
174 )]
175 pub thread_id: String,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
183#[schemars(
184 title = "AgentResponse",
185 description = "Wire-format protocol payload used for schema-driven provider output. Return \
186 this object as the entire assistant response payload. Providers that support \
187 output schemas (for example, Codex app-server) are asked to emit this object \
188 directly."
189)]
190pub struct AgentResponse {
191 #[serde(default)]
193 #[schemars(
194 title = "answer",
195 description = "Markdown answer text for delivered work, status updates, or concise \
196 completion notes. Keep clarification requests out of this field and emit \
197 them through `questions` instead."
198 )]
199 pub answer: String,
200 #[serde(default)]
208 #[schemars(title = "questions")]
209 pub questions: Vec<QuestionItem>,
210 #[serde(default)]
216 #[schemars(
217 title = "review_comment_outcomes",
218 description = "Per-thread outcomes for an agent-driven forge comment-resolution turn. \
219 Emit an empty array unless the prompt explicitly supplies forge thread \
220 IDs. Copy each reported `thread_id` exactly from the prompt."
221 )]
222 pub review_comment_outcomes: Vec<ReviewCommentOutcome>,
223 #[serde(default)]
231 #[schemars(title = "subtasks")]
232 pub subtasks: Vec<SubtaskItem>,
233 #[serde(default)]
236 #[schemars(
237 title = "summary",
238 description = "Structured summary for session-discussion turns, kept outside `answer` \
239 markdown. Use `null` for one-shot prompts and legacy payloads."
240 )]
241 pub summary: Option<AgentResponseSummary>,
242}
243
244impl AgentResponse {
245 pub fn plain(text: impl Into<String>) -> Self {
247 Self {
248 answer: text.into(),
249 questions: Vec::new(),
250 review_comment_outcomes: Vec::new(),
251 subtasks: Vec::new(),
252 summary: None,
253 }
254 }
255
256 pub fn to_display_text(&self) -> String {
259 let mut display_messages = Vec::new();
260 push_display_message(&mut display_messages, &self.answer);
261 push_question_display_messages(&mut display_messages, &self.questions);
262
263 display_messages.join("\n\n")
264 }
265
266 pub fn to_answer_display_text(&self) -> String {
269 let mut display_messages = Vec::new();
270 push_display_message(&mut display_messages, &self.answer);
271
272 display_messages.join("\n\n")
273 }
274
275 pub fn answers(&self) -> Vec<String> {
277 let answer = self.to_answer_display_text();
278 if answer.is_empty() {
279 return Vec::new();
280 }
281
282 vec![answer]
283 }
284
285 pub fn question_items(&self) -> Vec<QuestionItem> {
288 self.questions.iter().take(MAX_QUESTIONS).cloned().collect()
289 }
290
291 pub fn subtask_items(&self) -> Vec<SubtaskItem> {
296 self.subtasks.iter().take(MAX_SUBTASKS).cloned().collect()
297 }
298}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
302pub enum AgentResponseParseError {
303 Empty,
305 InvalidFormat {
308 reason: String,
310 },
311}
312
313impl fmt::Display for AgentResponseParseError {
314 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
315 match self {
316 Self::Empty => write!(formatter, "response is empty"),
317 Self::InvalidFormat { reason } => {
318 write!(formatter, "response is not valid protocol JSON: {reason}")
319 }
320 }
321 }
322}
323
324impl std::error::Error for AgentResponseParseError {}
325
326fn push_display_message(display_messages: &mut Vec<String>, text: &str) {
328 if text.trim().is_empty() {
329 return;
330 }
331
332 display_messages.push(text.to_string());
333}
334
335fn push_question_display_messages(display_messages: &mut Vec<String>, questions: &[QuestionItem]) {
337 for question in questions {
338 push_display_message(display_messages, &question.text);
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn test_questions_field_description_renders_template_limit() {
350 let description = questions_field_description();
352 let normalized_description = description.split_whitespace().collect::<Vec<_>>().join(" ");
353
354 assert!(normalized_description.contains("Emit at most 5 items"));
356 assert!(normalized_description.contains("Execute the agreed work"));
357 assert!(!description.contains("{{ max_questions }}"));
358 }
359
360 #[test]
361 fn test_agent_response_to_display_text_joins_answer_and_questions() {
364 let response = AgentResponse {
366 answer: "Primary answer".to_string(),
367 questions: vec![QuestionItem::new("Need one clarification.")],
368 review_comment_outcomes: Vec::new(),
369 subtasks: Vec::new(),
370 summary: None,
371 };
372
373 let display_text = response.to_display_text();
375
376 assert_eq!(display_text, "Primary answer\n\nNeed one clarification.");
378 }
379
380 #[test]
381 fn test_agent_response_review_comment_outcomes_round_trip() {
383 let response = AgentResponse {
385 answer: "Addressed the comment.".to_string(),
386 questions: Vec::new(),
387 review_comment_outcomes: vec![ReviewCommentOutcome {
388 reply: "Added the missing validation.".to_string(),
389 resolution: ReviewCommentResolution::Fixed,
390 thread_id: "thread-42".to_string(),
391 }],
392 subtasks: Vec::new(),
393 summary: None,
394 };
395
396 let serialized = serde_json::to_string(&response).expect("response should serialize");
398 let deserialized = serde_json::from_str::<AgentResponse>(&serialized)
399 .expect("response should deserialize");
400
401 assert_eq!(deserialized, response);
403 assert!(serialized.contains(r#""resolution":"fixed""#));
404 }
405
406 #[test]
407 fn test_agent_response_question_items_applies_question_cap() {
409 let response = AgentResponse {
411 answer: String::new(),
412 questions: (0..=MAX_QUESTIONS)
413 .map(|index| QuestionItem::new(format!("Question {index}")))
414 .collect(),
415 review_comment_outcomes: Vec::new(),
416 subtasks: Vec::new(),
417 summary: None,
418 };
419
420 let questions = response.question_items();
422
423 assert_eq!(questions.len(), MAX_QUESTIONS);
425 }
426
427 #[test]
428 fn test_agent_response_subtask_items_applies_subtask_cap() {
431 let response = AgentResponse {
433 answer: String::new(),
434 questions: Vec::new(),
435 review_comment_outcomes: Vec::new(),
436 subtasks: (0..=MAX_SUBTASKS).map(test_subtask).collect(),
437 summary: None,
438 };
439
440 let subtasks = response.subtask_items();
442
443 assert_eq!(subtasks.len(), MAX_SUBTASKS);
445 assert_eq!(subtasks[0].task_key, "task-0");
446 }
447
448 #[test]
449 fn test_agent_response_subtasks_round_trip() {
452 let response = AgentResponse {
454 answer: "Proposed a plan.".to_string(),
455 questions: Vec::new(),
456 review_comment_outcomes: Vec::new(),
457 subtasks: vec![test_subtask(1)],
458 summary: None,
459 };
460
461 let serialized = serde_json::to_string(&response).expect("response should serialize");
463 let deserialized = serde_json::from_str::<AgentResponse>(&serialized)
464 .expect("response should deserialize");
465
466 assert_eq!(deserialized, response);
468 assert!(serialized.contains(r#""task_key":"task-1""#));
469 assert!(AgentResponse::plain("no plan").subtask_items().is_empty());
470 }
471
472 #[test]
473 fn test_subtask_item_defaults_touched_areas() {
477 let raw = r#"{"prompt":"Do the work","task_key":"task-1","title":"Work"}"#;
479
480 let subtask =
482 serde_json::from_str::<SubtaskItem>(raw).expect("subtask should parse without areas");
483
484 assert!(subtask.touched_areas.is_empty());
486 }
487
488 #[test]
489 fn test_subtasks_field_description_reports_the_subtask_cap() {
492 let description = subtasks_field_description();
494
495 assert!(description.contains(&format!("at most {MAX_SUBTASKS} items")));
497 assert!(description.contains("without wildcard patterns"));
498 assert!(!description.contains("{{"));
499 }
500
501 #[test]
502 fn test_field_description_template_survives_a_wrapped_placeholder() {
506 let template = "Emit at most {{\nmax_items }} items, and no more.\n";
508
509 let rendered = render_field_description_template(template, "{{ max_items }}", 4);
511
512 assert_eq!(rendered, "Emit at most 4 items, and no more.");
514 }
515
516 #[test]
517 fn test_field_description_template_keeps_unterminated_placeholder_text() {
520 let template = "Emit at most {{ max_items items.";
522
523 let rendered = render_field_description_template(template, "{{ max_items }}", 4);
525
526 assert_eq!(rendered, "Emit at most {{ max_items items.");
528 }
529
530 fn test_subtask(index: usize) -> SubtaskItem {
532 SubtaskItem {
533 prompt: format!("Complete work item {index}"),
534 task_key: format!("task-{index}"),
535 title: format!("Work item {index}"),
536 touched_areas: vec![format!("crates/area-{index}/")],
537 }
538 }
539}