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 description = questions_field_description();
377 let normalized_description = description.split_whitespace().collect::<Vec<_>>().join(" ");
378
379 assert!(normalized_description.contains("Emit at most 5 items"));
381 assert!(normalized_description.contains("Execute the agreed work"));
382 assert!(!description.contains("{{ max_questions }}"));
383 }
384
385 #[test]
386 fn test_agent_response_to_display_text_joins_answer_and_questions() {
389 let response = AgentResponse {
391 answer: "Primary answer".to_string(),
392 questions: vec![QuestionItem::new("Need one clarification.")],
393 review_comment_outcomes: Vec::new(),
394 subtasks: Vec::new(),
395 summary: None,
396 verification_verdicts: Vec::new(),
397 };
398
399 let display_text = response.to_display_text();
401
402 assert_eq!(display_text, "Primary answer\n\nNeed one clarification.");
404 }
405
406 #[test]
407 fn test_agent_response_review_comment_outcomes_round_trip() {
409 let response = AgentResponse {
411 answer: "Addressed the comment.".to_string(),
412 questions: Vec::new(),
413 review_comment_outcomes: vec![ReviewCommentOutcome {
414 reply: "Added the missing validation.".to_string(),
415 resolution: ReviewCommentResolution::Fixed,
416 thread_id: "thread-42".to_string(),
417 }],
418 subtasks: Vec::new(),
419 verification_verdicts: Vec::new(),
420 summary: None,
421 };
422
423 let serialized = serde_json::to_string(&response).expect("response should serialize");
425 let deserialized = serde_json::from_str::<AgentResponse>(&serialized)
426 .expect("response should deserialize");
427
428 assert_eq!(deserialized, response);
430 assert!(serialized.contains(r#""resolution":"fixed""#));
431 }
432
433 #[test]
434 fn test_agent_response_question_items_applies_question_cap() {
436 let response = AgentResponse {
438 answer: String::new(),
439 questions: (0..=MAX_QUESTIONS)
440 .map(|index| QuestionItem::new(format!("Question {index}")))
441 .collect(),
442 review_comment_outcomes: Vec::new(),
443 subtasks: Vec::new(),
444 verification_verdicts: Vec::new(),
445 summary: None,
446 };
447
448 let questions = response.question_items();
450
451 assert_eq!(questions.len(), MAX_QUESTIONS);
453 }
454
455 #[test]
456 fn test_agent_response_subtask_items_applies_subtask_cap() {
459 let response = AgentResponse {
461 answer: String::new(),
462 questions: Vec::new(),
463 review_comment_outcomes: Vec::new(),
464 subtasks: (0..=MAX_SUBTASKS).map(test_subtask).collect(),
465 verification_verdicts: Vec::new(),
466 summary: None,
467 };
468
469 let subtasks = response.subtask_items();
471
472 assert_eq!(subtasks.len(), MAX_SUBTASKS);
474 assert_eq!(subtasks[0].task_key, "task-0");
475 }
476
477 #[test]
478 fn test_agent_response_subtasks_round_trip() {
481 let response = AgentResponse {
483 answer: "Proposed a plan.".to_string(),
484 questions: Vec::new(),
485 review_comment_outcomes: Vec::new(),
486 subtasks: vec![test_subtask(1)],
487 verification_verdicts: Vec::new(),
488 summary: None,
489 };
490
491 let serialized = serde_json::to_string(&response).expect("response should serialize");
493 let deserialized = serde_json::from_str::<AgentResponse>(&serialized)
494 .expect("response should deserialize");
495
496 assert_eq!(deserialized, response);
498 assert!(serialized.contains(r#""task_key":"task-1""#));
499 assert!(AgentResponse::plain("no plan").subtask_items().is_empty());
500 }
501
502 #[test]
503 fn test_agent_response_verification_verdicts_round_trip_and_cap() {
506 let response = AgentResponse {
508 answer: "Verified the settled tasks.".to_string(),
509 questions: Vec::new(),
510 review_comment_outcomes: Vec::new(),
511 subtasks: Vec::new(),
512 summary: None,
513 verification_verdicts: (0..=MAX_SUBTASKS)
514 .map(|index| VerificationVerdictItem {
515 reason: format!("Evidence {index}"),
516 task_key: format!("task-{index}"),
517 verdict: crate::VerificationVerdict::Pass,
518 })
519 .collect(),
520 };
521
522 let serialized = serde_json::to_string(&response).expect("response should serialize");
524 let deserialized = serde_json::from_str::<AgentResponse>(&serialized)
525 .expect("response should deserialize");
526 let verdicts = deserialized.verification_verdict_items();
527
528 assert_eq!(verdicts.len(), MAX_SUBTASKS);
530 assert_eq!(verdicts[0].task_key, "task-0");
531 assert!(serialized.contains(r#""verdict":"pass""#));
532 }
533
534 #[test]
535 fn test_subtask_item_defaults_touched_areas() {
538 let raw = r#"{"prompt":"Do the work","task_key":"task-1","title":"Work"}"#;
540
541 let subtask =
543 serde_json::from_str::<SubtaskItem>(raw).expect("subtask should parse without areas");
544
545 assert!(subtask.touched_areas.is_empty());
547 }
548
549 #[test]
550 fn test_subtasks_field_description_reports_the_subtask_cap() {
553 let description = subtasks_field_description();
555 let normalized_description = description.split_whitespace().collect::<Vec<_>>().join(" ");
556
557 assert!(description.contains(&format!("at most {MAX_SUBTASKS} items")));
559 assert!(normalized_description.contains("without wildcard patterns"));
560 assert!(description.contains("Areas may overlap"));
561 assert!(!description.contains("{{"));
562 }
563
564 #[test]
565 fn test_field_description_template_survives_a_wrapped_placeholder() {
569 let template = "Emit at most {{\nmax_items }} items, and no more.\n";
571
572 let rendered = render_field_description_template(template, "{{ max_items }}", 4);
574
575 assert_eq!(rendered, "Emit at most 4 items, and no more.");
577 }
578
579 #[test]
580 fn test_field_description_template_keeps_unterminated_placeholder_text() {
583 let template = "Emit at most {{ max_items items.";
585
586 let rendered = render_field_description_template(template, "{{ max_items }}", 4);
588
589 assert_eq!(rendered, "Emit at most {{ max_items items.");
591 }
592
593 fn test_subtask(index: usize) -> SubtaskItem {
595 SubtaskItem {
596 acceptance_criteria: vec![format!("Work item {index} is complete")],
597 prompt: format!("Complete work item {index}"),
598 task_key: format!("task-{index}"),
599 title: format!("Work item {index}"),
600 touched_areas: vec![format!("crates/area-{index}/")],
601 }
602 }
603}