1use std::fmt;
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use super::question::QuestionItem;
9use super::subtask::SubtaskItem;
10use super::verification::VerificationVerdictItem;
11
12pub(crate) const MAX_QUESTIONS: usize = 5;
20pub(crate) const MAX_SUBTASKS: usize = 8;
25const QUESTIONS_FIELD_DESCRIPTION_TEMPLATE: &str =
26 include_str!("template/questions_field_description.md");
27const SUBTASKS_FIELD_DESCRIPTION_TEMPLATE: &str =
28 include_str!("template/subtasks_field_description.md");
29
30pub(crate) fn questions_field_description() -> String {
39 render_field_description_template(
40 QUESTIONS_FIELD_DESCRIPTION_TEMPLATE,
41 "{{ max_questions }}",
42 MAX_QUESTIONS,
43 )
44}
45
46pub(crate) fn subtasks_field_description() -> String {
53 render_field_description_template(
54 SUBTASKS_FIELD_DESCRIPTION_TEMPLATE,
55 "{{ max_subtasks }}",
56 MAX_SUBTASKS,
57 )
58}
59
60fn render_field_description_template(template: &str, placeholder: &str, value: usize) -> String {
68 let mut rendered = String::with_capacity(template.len());
69 let mut remaining = template.trim_end();
70
71 while let Some(open_index) = remaining.find("{{") {
72 let after_open = &remaining[open_index..];
73 let Some(close_end) = after_open.find("}}").map(|index| index + "}}".len()) else {
74 break;
75 };
76
77 rendered.push_str(&remaining[..open_index]);
78 rendered.push_str(&collapse_whitespace(&after_open[..close_end]));
79 remaining = &after_open[close_end..];
80 }
81 rendered.push_str(remaining);
82
83 rendered.replace(placeholder, &value.to_string())
84}
85
86fn collapse_whitespace(text: &str) -> String {
88 text.split_whitespace().collect::<Vec<_>>().join(" ")
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100#[non_exhaustive]
101pub enum ProtocolRequestProfile {
102 SessionTurn,
104 UtilityPrompt,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
114#[schemars(
115 title = "AgentResponseSummary",
116 description = "Structured session summary block emitted alongside protocol messages instead \
117 of embedding the change summary inside `answer` markdown on session-discussion \
118 turns."
119)]
120pub struct AgentResponseSummary {
121 #[schemars(
123 title = "session",
124 description = "Cumulative summary of active changes on the current session branch."
125 )]
126 pub session: String,
127 #[schemars(
129 title = "turn",
130 description = "Concise summary of only the work completed in the current turn."
131 )]
132 pub turn: String,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
137#[serde(rename_all = "snake_case")]
138#[schemars(
139 title = "ReviewCommentResolution",
140 description = "Disposition reported for one targeted forge review thread."
141)]
142pub enum ReviewCommentResolution {
143 Fixed,
146 NoChangeNeeded,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
153#[schemars(
154 title = "ReviewCommentOutcome",
155 description = "Structured outcome for one forge review thread explicitly included in the turn \
156 prompt."
157)]
158pub struct ReviewCommentOutcome {
159 #[schemars(
161 title = "reply",
162 description = "Concise reply suitable for posting to the forge review thread."
163 )]
164 pub reply: String,
165 #[schemars(
167 title = "resolution",
168 description = "Whether the targeted thread was fixed or required no change."
169 )]
170 pub resolution: ReviewCommentResolution,
171 #[schemars(
173 title = "thread_id",
174 description = "Opaque forge thread identifier copied exactly from the turn prompt."
175 )]
176 pub thread_id: String,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
184#[schemars(
185 title = "AgentResponse",
186 description = "Wire-format protocol payload used for schema-driven provider output. Return \
187 this object as the entire assistant response payload. Providers that support \
188 output schemas (for example, Codex app-server) are asked to emit this object \
189 directly."
190)]
191pub struct AgentResponse {
192 #[serde(default)]
194 #[schemars(
195 title = "answer",
196 description = "Markdown answer text for delivered work, status updates, or concise \
197 completion notes. Keep clarification requests out of this field and emit \
198 them through `questions` instead."
199 )]
200 pub answer: String,
201 #[serde(default)]
209 #[schemars(title = "questions")]
210 pub questions: Vec<QuestionItem>,
211 #[serde(default)]
217 #[schemars(
218 title = "review_comment_outcomes",
219 description = "Per-thread outcomes for an agent-driven forge comment-resolution turn. \
220 Emit an empty array unless the prompt explicitly supplies forge thread \
221 IDs. Copy each reported `thread_id` exactly from the prompt."
222 )]
223 pub review_comment_outcomes: Vec<ReviewCommentOutcome>,
224 #[serde(default)]
232 #[schemars(title = "subtasks")]
233 pub subtasks: Vec<SubtaskItem>,
234 #[serde(default)]
237 #[schemars(
238 title = "summary",
239 description = "Structured summary for session-discussion turns, kept outside `answer` \
240 markdown. Use `null` for one-shot prompts and legacy payloads."
241 )]
242 pub summary: Option<AgentResponseSummary>,
243 #[serde(default)]
249 #[schemars(
250 title = "verification_verdicts",
251 description = "Per-task decisions for an orchestration verification turn. Emit one item \
252 for every task in the verification envelope, and use an empty array for \
253 ordinary turns."
254 )]
255 pub verification_verdicts: Vec<VerificationVerdictItem>,
256}
257
258impl AgentResponse {
259 pub fn plain(text: impl Into<String>) -> Self {
261 Self {
262 answer: text.into(),
263 questions: Vec::new(),
264 review_comment_outcomes: Vec::new(),
265 subtasks: Vec::new(),
266 summary: None,
267 verification_verdicts: Vec::new(),
268 }
269 }
270
271 pub fn to_display_text(&self) -> String {
274 let mut display_messages = Vec::new();
275 push_display_message(&mut display_messages, &self.answer);
276 push_question_display_messages(&mut display_messages, &self.questions);
277
278 display_messages.join("\n\n")
279 }
280
281 pub fn to_answer_display_text(&self) -> String {
284 let mut display_messages = Vec::new();
285 push_display_message(&mut display_messages, &self.answer);
286
287 display_messages.join("\n\n")
288 }
289
290 pub fn answers(&self) -> Vec<String> {
292 let answer = self.to_answer_display_text();
293 if answer.is_empty() {
294 return Vec::new();
295 }
296
297 vec![answer]
298 }
299
300 pub fn question_items(&self) -> Vec<QuestionItem> {
303 self.questions.iter().take(MAX_QUESTIONS).cloned().collect()
304 }
305
306 pub fn subtask_items(&self) -> Vec<SubtaskItem> {
311 self.subtasks.iter().take(MAX_SUBTASKS).cloned().collect()
312 }
313
314 pub fn verification_verdict_items(&self) -> Vec<VerificationVerdictItem> {
317 self.verification_verdicts
318 .iter()
319 .take(MAX_SUBTASKS)
320 .cloned()
321 .collect()
322 }
323}
324
325#[derive(Debug, Clone, PartialEq, Eq)]
327pub enum AgentResponseParseError {
328 Empty,
330 InvalidFormat {
333 reason: String,
335 },
336}
337
338impl fmt::Display for AgentResponseParseError {
339 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
340 match self {
341 Self::Empty => write!(formatter, "response is empty"),
342 Self::InvalidFormat { reason } => {
343 write!(formatter, "response is not valid protocol JSON: {reason}")
344 }
345 }
346 }
347}
348
349impl std::error::Error for AgentResponseParseError {}
350
351fn push_display_message(display_messages: &mut Vec<String>, text: &str) {
353 if text.trim().is_empty() {
354 return;
355 }
356
357 display_messages.push(text.to_string());
358}
359
360fn push_question_display_messages(display_messages: &mut Vec<String>, questions: &[QuestionItem]) {
362 for question in questions {
363 push_display_message(display_messages, &question.text);
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370
371 #[test]
372 fn test_questions_field_description_renders_template_limit() {
375 let expected_limit = format!("Emit at most {MAX_QUESTIONS} items");
377
378 let description = questions_field_description();
380 let normalized_description = description.split_whitespace().collect::<Vec<_>>().join(" ");
381
382 assert!(normalized_description.contains(&expected_limit));
384 assert!(normalized_description.contains("Emit an empty array when no input is required"));
385 assert!(normalized_description.contains("field defaults to an empty array when omitted"));
386 assert!(normalized_description.contains("genuinely ambiguous requirement"));
387 assert!(normalized_description.contains("Never request permission for agreed work"));
388 assert!(normalized_description.contains("ask for satisfaction or sign-off"));
389 assert!(normalized_description.contains("Execute agreed work"));
390 assert!(!description.contains("{{ max_questions }}"));
391 }
392
393 #[test]
394 fn test_agent_response_to_display_text_joins_answer_and_questions() {
397 let response = AgentResponse {
399 answer: "Primary answer".to_string(),
400 questions: vec![QuestionItem::new("Need one clarification.")],
401 review_comment_outcomes: Vec::new(),
402 subtasks: Vec::new(),
403 summary: None,
404 verification_verdicts: Vec::new(),
405 };
406
407 let display_text = response.to_display_text();
409
410 assert_eq!(display_text, "Primary answer\n\nNeed one clarification.");
412 }
413
414 #[test]
415 fn test_agent_response_review_comment_outcomes_round_trip() {
417 let response = AgentResponse {
419 answer: "Addressed the comment.".to_string(),
420 questions: Vec::new(),
421 review_comment_outcomes: vec![ReviewCommentOutcome {
422 reply: "Added the missing validation.".to_string(),
423 resolution: ReviewCommentResolution::Fixed,
424 thread_id: "thread-42".to_string(),
425 }],
426 subtasks: Vec::new(),
427 verification_verdicts: Vec::new(),
428 summary: None,
429 };
430
431 let serialized = serde_json::to_string(&response).expect("response should serialize");
433 let deserialized = serde_json::from_str::<AgentResponse>(&serialized)
434 .expect("response should deserialize");
435
436 assert_eq!(deserialized, response);
438 assert!(serialized.contains(r#""resolution":"fixed""#));
439 }
440
441 #[test]
442 fn test_agent_response_question_items_applies_question_cap() {
444 let response = AgentResponse {
446 answer: String::new(),
447 questions: (0..=MAX_QUESTIONS)
448 .map(|index| QuestionItem::new(format!("Question {index}")))
449 .collect(),
450 review_comment_outcomes: Vec::new(),
451 subtasks: Vec::new(),
452 verification_verdicts: Vec::new(),
453 summary: None,
454 };
455
456 let questions = response.question_items();
458
459 assert_eq!(questions.len(), MAX_QUESTIONS);
461 }
462
463 #[test]
464 fn test_agent_response_subtask_items_applies_subtask_cap() {
467 let response = AgentResponse {
469 answer: String::new(),
470 questions: Vec::new(),
471 review_comment_outcomes: Vec::new(),
472 subtasks: (0..=MAX_SUBTASKS).map(test_subtask).collect(),
473 verification_verdicts: Vec::new(),
474 summary: None,
475 };
476
477 let subtasks = response.subtask_items();
479
480 assert_eq!(subtasks.len(), MAX_SUBTASKS);
482 assert_eq!(subtasks[0].task_key, "task-0");
483 }
484
485 #[test]
486 fn test_agent_response_subtasks_round_trip() {
489 let response = AgentResponse {
491 answer: "Proposed a plan.".to_string(),
492 questions: Vec::new(),
493 review_comment_outcomes: Vec::new(),
494 subtasks: vec![test_subtask(1)],
495 verification_verdicts: Vec::new(),
496 summary: None,
497 };
498
499 let serialized = serde_json::to_string(&response).expect("response should serialize");
501 let deserialized = serde_json::from_str::<AgentResponse>(&serialized)
502 .expect("response should deserialize");
503
504 assert_eq!(deserialized, response);
506 assert!(serialized.contains(r#""task_key":"task-1""#));
507 assert_eq!(
508 AgentResponse::plain("no plan").subtask_items(),
509 [] as [crate::subtask::SubtaskItem; 0]
510 );
511 }
512
513 #[test]
514 fn test_agent_response_verification_verdicts_round_trip_and_cap() {
517 let response = AgentResponse {
519 answer: "Verified the settled tasks.".to_string(),
520 questions: Vec::new(),
521 review_comment_outcomes: Vec::new(),
522 subtasks: Vec::new(),
523 summary: None,
524 verification_verdicts: (0..=MAX_SUBTASKS)
525 .map(|index| VerificationVerdictItem {
526 reason: format!("Evidence {index}"),
527 task_key: format!("task-{index}"),
528 verdict: crate::VerificationVerdict::Pass,
529 })
530 .collect(),
531 };
532
533 let serialized = serde_json::to_string(&response).expect("response should serialize");
535 let deserialized = serde_json::from_str::<AgentResponse>(&serialized)
536 .expect("response should deserialize");
537 let verdicts = deserialized.verification_verdict_items();
538
539 assert_eq!(verdicts.len(), MAX_SUBTASKS);
541 assert_eq!(verdicts[0].task_key, "task-0");
542 assert!(serialized.contains(r#""verdict":"pass""#));
543 }
544
545 #[test]
546 fn test_subtask_item_defaults_touched_areas() {
549 let raw = r#"{"prompt":"Do the work","task_key":"task-1","title":"Work"}"#;
551
552 let subtask =
554 serde_json::from_str::<SubtaskItem>(raw).expect("subtask should parse without areas");
555
556 assert_eq!(subtask.kind, crate::SubtaskKind::Implementation);
558 assert_eq!(subtask.touched_areas, [] as [std::string::String; 0]);
559 }
560
561 #[test]
562 fn test_subtasks_field_description_reports_the_subtask_cap() {
565 let expected_limit = format!("at most {MAX_SUBTASKS} items");
567
568 let description = subtasks_field_description();
570 let normalized_description = description.split_whitespace().collect::<Vec<_>>().join(" ");
571
572 assert!(description.contains(&expected_limit));
574 assert!(
575 normalized_description
576 .contains("Emit an empty array when no decomposition was requested")
577 );
578 assert!(normalized_description.contains("field defaults to an empty array when omitted"));
579 assert!(normalized_description.contains("Ordinary session and utility turns"));
580 assert!(normalized_description.contains("unattended in its own worktree"));
581 assert!(normalized_description.contains("independently completable"));
582 assert!(normalized_description.contains("without wildcards"));
583 assert!(description.contains("Areas may overlap"));
584 assert!(normalized_description.contains("fewer than two independent subtasks"));
585 assert!(!description.contains("{{"));
586 }
587
588 #[test]
589 fn test_field_description_template_survives_a_wrapped_placeholder() {
593 let template = "Emit at most {{\nmax_items }} items, and no more.\n";
595
596 let rendered = render_field_description_template(template, "{{ max_items }}", 4);
598
599 assert_eq!(rendered, "Emit at most 4 items, and no more.");
601 }
602
603 #[test]
604 fn test_field_description_template_keeps_unterminated_placeholder_text() {
607 let template = "Emit at most {{ max_items items.";
609
610 let rendered = render_field_description_template(template, "{{ max_items }}", 4);
612
613 assert_eq!(rendered, "Emit at most {{ max_items items.");
615 }
616
617 fn test_subtask(index: usize) -> SubtaskItem {
619 SubtaskItem {
620 acceptance_criteria: vec![format!("Work item {index} is complete")],
621 kind: crate::SubtaskKind::Implementation,
622 prompt: format!("Complete work item {index}"),
623 task_key: format!("task-{index}"),
624 title: format!("Work item {index}"),
625 touched_areas: vec![format!("crates/area-{index}/")],
626 }
627 }
628}