1use crate::AgentPath;
2use crate::ResponseItemId;
3use crate::ThreadId;
4use crate::dynamic_tools::DynamicToolCallOutputContentItem;
5use crate::mcp::CallToolResult;
6use crate::memory_citation::MemoryCitation;
7use crate::models::ContentItem;
8use crate::models::ImageDetail;
9use crate::models::MessagePhase;
10use crate::models::ResponseItem;
11use crate::models::WebSearchAction;
12use crate::openai_models::ReasoningEffort as ReasoningEffortConfig;
13use crate::parse_command::ParsedCommand;
14use crate::protocol::AgentStatus;
15use crate::protocol::CollabAgentRef;
16use crate::protocol::ExecCommandSource;
17use crate::protocol::ExecCommandStatus;
18use crate::protocol::FileChange;
19use crate::protocol::PatchApplyStatus;
20use crate::protocol::ReviewOutputEvent;
21use crate::protocol::ReviewTarget;
22use crate::protocol::SubAgentActivityKind;
23use crate::user_input::ByteRange;
24use crate::user_input::TextElement;
25use crate::user_input::UserInput;
26use codex_extension_items::ExtensionItem;
27use codex_utils_absolute_path::AbsolutePathBuf;
28use codex_utils_path_uri::PathUri;
29use quick_xml::de::from_str as from_xml_str;
30use quick_xml::se::to_string as to_xml_string;
31use schemars::JsonSchema;
32use serde::Deserialize;
33use serde::Serialize;
34use serde_json::Value as JsonValue;
35use std::collections::HashMap;
36use std::path::PathBuf;
37use std::time::Duration;
38use ts_rs::TS;
39
40#[allow(clippy::large_enum_variant)]
41#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
42#[serde(tag = "type")]
43#[ts(tag = "type")]
44pub enum TurnItem {
45 UserMessage(UserMessageItem),
46 HookPrompt(HookPromptItem),
47 AgentMessage(AgentMessageItem),
48 Plan(PlanItem),
49 Reasoning(ReasoningItem),
50 CommandExecution(CommandExecutionItem),
51 DynamicToolCall(DynamicToolCallItem),
52 CollabAgentToolCall(CollabAgentToolCallItem),
53 SubAgentActivity(SubAgentActivityItem),
54 WebSearch(WebSearchItem),
59 ImageView(ImageViewItem),
60 Extension(ExtensionItem),
65 ImageGeneration(ImageGenerationItem),
70 EnteredReviewMode(EnteredReviewModeItem),
71 ExitedReviewMode(ExitedReviewModeItem),
72 FileChange(FileChangeItem),
73 McpToolCall(McpToolCallItem),
74 ContextCompaction(ContextCompactionItem),
75}
76
77#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
78pub struct UserMessageItem {
79 pub id: String,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 #[ts(optional)]
82 pub client_id: Option<String>,
83 pub content: Vec<UserInput>,
84}
85
86#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
87pub struct HookPromptItem {
88 pub id: String,
89 pub fragments: Vec<HookPromptFragment>,
90}
91
92#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
93#[serde(rename_all = "camelCase")]
94#[ts(rename_all = "camelCase")]
95pub struct HookPromptFragment {
96 pub text: String,
97 pub hook_run_id: String,
98}
99
100#[derive(Debug, Deserialize, Serialize)]
101#[serde(rename = "hook_prompt")]
102struct HookPromptXml {
103 #[serde(rename = "@hook_run_id")]
104 hook_run_id: String,
105 #[serde(rename = "$text")]
106 text: String,
107}
108
109#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
110#[serde(tag = "type")]
111#[ts(tag = "type")]
112pub enum AgentMessageContent {
113 Text { text: String },
114}
115
116#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
117pub struct AgentMessageItem {
123 pub id: String,
124 pub content: Vec<AgentMessageContent>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
130 #[ts(optional)]
131 pub phase: Option<MessagePhase>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 #[ts(optional)]
134 pub memory_citation: Option<MemoryCitation>,
135}
136
137#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
138pub struct EnteredReviewModeItem {
139 pub id: String,
140 pub target: ReviewTarget,
141 pub user_facing_hint: String,
142}
143
144#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
145pub struct ExitedReviewModeItem {
146 pub id: String,
147 pub review_output: Option<ReviewOutputEvent>,
148}
149
150#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
151pub struct PlanItem {
152 pub id: String,
153 pub text: String,
154}
155
156#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
157pub struct ReasoningItem {
158 pub id: String,
159 pub summary_text: Vec<String>,
160 #[serde(default)]
161 pub raw_content: Vec<String>,
162}
163
164#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
165#[serde(rename_all = "snake_case")]
166pub enum CommandExecutionStatus {
167 InProgress,
168 Completed,
169 Failed,
170 Declined,
171}
172
173impl From<ExecCommandStatus> for CommandExecutionStatus {
174 fn from(value: ExecCommandStatus) -> Self {
175 match value {
176 ExecCommandStatus::Completed => Self::Completed,
177 ExecCommandStatus::Failed => Self::Failed,
178 ExecCommandStatus::Declined => Self::Declined,
179 }
180 }
181}
182
183#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
184pub struct CommandExecutionItem {
185 pub id: String,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 #[ts(optional)]
188 pub process_id: Option<String>,
189 pub command: Vec<String>,
190 pub cwd: PathUri,
191 pub parsed_cmd: Vec<ParsedCommand>,
192 pub source: ExecCommandSource,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 #[ts(optional)]
195 pub interaction_input: Option<String>,
196 pub status: CommandExecutionStatus,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 #[ts(optional)]
199 pub stdout: Option<String>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 #[ts(optional)]
202 pub stderr: Option<String>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 #[ts(optional)]
205 pub aggregated_output: Option<String>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 #[ts(optional)]
208 pub exit_code: Option<i32>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
210 #[ts(type = "string", optional)]
211 pub duration: Option<Duration>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 #[ts(optional)]
214 pub formatted_output: Option<String>,
215}
216
217#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
218#[serde(rename_all = "snake_case")]
219pub enum DynamicToolCallStatus {
220 InProgress,
221 Completed,
222 Failed,
223}
224
225#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
226pub struct DynamicToolCallItem {
227 pub id: String,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 #[ts(optional)]
230 pub namespace: Option<String>,
231 pub tool: String,
232 pub arguments: serde_json::Value,
233 pub status: DynamicToolCallStatus,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 #[ts(optional)]
236 pub content_items: Option<Vec<DynamicToolCallOutputContentItem>>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 #[ts(optional)]
239 pub success: Option<bool>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 #[ts(optional)]
242 pub error: Option<String>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 #[ts(type = "string", optional)]
245 pub duration: Option<Duration>,
246}
247
248#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
249#[serde(rename_all = "snake_case")]
250pub enum CollabAgentTool {
251 SpawnAgent,
252 SendInput,
253 ResumeAgent,
254 Wait,
255 CloseAgent,
256}
257
258#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
259#[serde(rename_all = "snake_case")]
260pub enum CollabAgentToolCallStatus {
261 InProgress,
262 Completed,
263 Failed,
264}
265
266#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
267pub struct CollabAgentToolCallItem {
268 pub id: String,
269 pub tool: CollabAgentTool,
270 pub status: CollabAgentToolCallStatus,
271 pub sender_thread_id: ThreadId,
272 #[serde(default)]
273 pub receiver_thread_ids: Vec<ThreadId>,
274 #[serde(default)]
275 pub receiver_agents: Vec<CollabAgentRef>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 #[ts(optional)]
278 pub prompt: Option<String>,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
280 #[ts(optional)]
281 pub model: Option<String>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 #[ts(optional)]
284 pub reasoning_effort: Option<ReasoningEffortConfig>,
285 #[serde(default)]
286 pub agents_states: HashMap<ThreadId, AgentStatus>,
287}
288
289#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
290pub struct SubAgentActivityItem {
291 pub id: String,
292 pub kind: SubAgentActivityKind,
293 pub agent_thread_id: ThreadId,
294 pub agent_path: AgentPath,
295}
296
297#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
298pub struct WebSearchItem {
299 pub id: String,
300 pub query: String,
301 pub action: WebSearchAction,
302 #[serde(default, skip_serializing_if = "Option::is_none")]
308 #[ts(optional)]
309 pub results: Option<Vec<JsonValue>>,
310}
311
312#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
313pub struct ImageViewItem {
314 pub id: String,
315 pub path: PathUri,
320}
321
322#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
323pub struct ImageGenerationItem {
324 pub id: String,
325 pub status: String,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
327 #[ts(optional)]
328 pub revised_prompt: Option<String>,
329 pub result: String,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 #[ts(optional)]
332 pub saved_path: Option<AbsolutePathBuf>,
333}
334
335#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
336pub struct FileChangeItem {
337 pub id: String,
338 pub changes: HashMap<PathBuf, FileChange>,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
340 #[ts(optional)]
341 pub status: Option<PatchApplyStatus>,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
343 #[ts(optional)]
344 pub auto_approved: Option<bool>,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
346 #[ts(optional)]
347 pub stdout: Option<String>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
349 #[ts(optional)]
350 pub stderr: Option<String>,
351}
352
353#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
354#[serde(rename_all = "camelCase")]
355#[ts(rename_all = "camelCase")]
356pub struct McpToolCallItem {
357 pub id: String,
358 pub server: String,
359 pub tool: String,
360 pub arguments: serde_json::Value,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
362 #[ts(optional)]
363 pub connector_id: Option<String>,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 #[ts(optional)]
366 pub mcp_app_resource_uri: Option<String>,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
368 #[ts(optional)]
369 pub link_id: Option<String>,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 #[ts(optional)]
372 pub app_name: Option<String>,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
374 #[ts(optional)]
375 pub action_name: Option<String>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
377 #[ts(optional)]
378 pub plugin_id: Option<String>,
379 pub status: McpToolCallStatus,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 #[ts(optional)]
382 pub result: Option<CallToolResult>,
383 #[serde(default, skip_serializing_if = "Option::is_none")]
384 #[ts(optional)]
385 pub error: Option<McpToolCallError>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 #[ts(type = "string", optional)]
388 pub duration: Option<Duration>,
389}
390
391#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
392#[serde(rename_all = "camelCase")]
393#[ts(rename_all = "camelCase")]
394pub enum McpToolCallStatus {
395 InProgress,
396 Completed,
397 Failed,
398}
399
400#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
401#[serde(rename_all = "camelCase")]
402#[ts(rename_all = "camelCase")]
403pub struct McpToolCallError {
404 pub message: String,
405}
406
407#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
408pub struct ContextCompactionItem {
409 pub id: String,
410}
411
412fn new_item_id() -> String {
413 uuid::Uuid::now_v7().to_string()
414}
415
416impl ContextCompactionItem {
417 pub fn new() -> Self {
418 Self { id: new_item_id() }
419 }
420}
421
422impl Default for ContextCompactionItem {
423 fn default() -> Self {
424 Self::new()
425 }
426}
427
428impl UserMessageItem {
429 pub fn new(content: &[UserInput]) -> Self {
430 Self {
431 id: new_item_id(),
432 client_id: None,
433 content: content.to_vec(),
434 }
435 }
436
437 pub fn message(&self) -> String {
438 self.content
439 .iter()
440 .map(|c| match c {
441 UserInput::Text { text, .. } => text.clone(),
442 _ => String::new(),
443 })
444 .collect::<Vec<String>>()
445 .join("")
446 }
447
448 pub fn text_elements(&self) -> Vec<TextElement> {
449 let mut out = Vec::new();
450 let mut offset = 0usize;
451 for input in &self.content {
452 if let UserInput::Text {
453 text,
454 text_elements,
455 ..
456 } = input
457 {
458 for elem in text_elements {
461 let byte_range = ByteRange {
462 start: offset + elem.byte_range.start,
463 end: offset + elem.byte_range.end,
464 };
465 out.push(TextElement::new(
466 byte_range,
467 elem.placeholder(text).map(str::to_string),
468 ));
469 }
470 offset += text.len();
471 }
472 }
473 out
474 }
475
476 pub fn image_urls(&self) -> Vec<String> {
477 self.content
478 .iter()
479 .filter_map(|c| match c {
480 UserInput::Image { image_url, .. } => Some(image_url.clone()),
481 _ => None,
482 })
483 .collect()
484 }
485
486 pub fn image_details(&self) -> Vec<Option<ImageDetail>> {
487 trim_trailing_default_image_details(
488 self.content
489 .iter()
490 .filter_map(|c| match c {
491 UserInput::Image { detail, .. } => Some(*detail),
492 _ => None,
493 })
494 .collect(),
495 )
496 }
497
498 pub fn local_image_paths(&self) -> Vec<std::path::PathBuf> {
499 self.content
500 .iter()
501 .filter_map(|c| match c {
502 UserInput::LocalImage { path, .. } => Some(path.clone()),
503 _ => None,
504 })
505 .collect()
506 }
507
508 pub fn local_image_details(&self) -> Vec<Option<ImageDetail>> {
509 trim_trailing_default_image_details(
510 self.content
511 .iter()
512 .filter_map(|c| match c {
513 UserInput::LocalImage { detail, .. } => Some(*detail),
514 _ => None,
515 })
516 .collect(),
517 )
518 }
519
520 pub fn audio_urls(&self) -> Vec<String> {
521 self.content
522 .iter()
523 .filter_map(|c| match c {
524 UserInput::Audio { audio_url } => Some(audio_url.clone()),
525 _ => None,
526 })
527 .collect()
528 }
529
530 pub fn local_audio_paths(&self) -> Vec<std::path::PathBuf> {
531 self.content
532 .iter()
533 .filter_map(|c| match c {
534 UserInput::LocalAudio { path } => Some(path.clone()),
535 _ => None,
536 })
537 .collect()
538 }
539}
540
541fn trim_trailing_default_image_details(
542 mut details: Vec<Option<ImageDetail>>,
543) -> Vec<Option<ImageDetail>> {
544 while matches!(details.last(), Some(None)) {
545 details.pop();
546 }
547 details
548}
549
550impl HookPromptItem {
551 pub fn from_fragments(id: Option<&str>, fragments: Vec<HookPromptFragment>) -> Self {
552 Self {
553 id: id.map(str::to_string).unwrap_or_else(new_item_id),
554 fragments,
555 }
556 }
557}
558
559impl HookPromptFragment {
560 pub fn from_single_hook(text: impl Into<String>, hook_run_id: impl Into<String>) -> Self {
561 Self {
562 text: text.into(),
563 hook_run_id: hook_run_id.into(),
564 }
565 }
566}
567
568pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option<ResponseItem> {
569 let content = fragments
570 .iter()
571 .filter(|fragment| !fragment.hook_run_id.trim().is_empty())
572 .filter_map(|fragment| {
573 serialize_hook_prompt_fragment(&fragment.text, &fragment.hook_run_id)
574 .map(|text| ContentItem::InputText { text })
575 })
576 .collect::<Vec<_>>();
577
578 if content.is_empty() {
579 return None;
580 }
581
582 Some(ResponseItem::Message {
583 id: Some(ResponseItemId::new("msg")),
584 role: "user".to_string(),
585 content,
586 phase: None,
587 internal_chat_message_metadata_passthrough: None,
588 })
589}
590
591pub fn parse_hook_prompt_message(
592 id: Option<&str>,
593 content: &[ContentItem],
594) -> Option<HookPromptItem> {
595 let fragments = content
596 .iter()
597 .map(|content_item| {
598 let ContentItem::InputText { text } = content_item else {
599 return None;
600 };
601 parse_hook_prompt_fragment(text)
602 })
603 .collect::<Option<Vec<_>>>()?;
604
605 if fragments.is_empty() {
606 return None;
607 }
608
609 Some(HookPromptItem::from_fragments(id, fragments))
610}
611
612pub fn parse_hook_prompt_fragment(text: &str) -> Option<HookPromptFragment> {
613 let trimmed = text.trim();
614 let HookPromptXml { text, hook_run_id } = from_xml_str::<HookPromptXml>(trimmed).ok()?;
615 if hook_run_id.trim().is_empty() {
616 return None;
617 }
618
619 Some(HookPromptFragment { text, hook_run_id })
620}
621
622fn serialize_hook_prompt_fragment(text: &str, hook_run_id: &str) -> Option<String> {
623 if hook_run_id.trim().is_empty() {
624 return None;
625 }
626 to_xml_string(&HookPromptXml {
627 text: text.to_string(),
628 hook_run_id: hook_run_id.to_string(),
629 })
630 .ok()
631}
632
633impl TurnItem {
634 pub fn id(&self) -> String {
635 match self {
636 TurnItem::UserMessage(item) => item.id.clone(),
637 TurnItem::HookPrompt(item) => item.id.clone(),
638 TurnItem::AgentMessage(item) => item.id.clone(),
639 TurnItem::Plan(item) => item.id.clone(),
640 TurnItem::Reasoning(item) => item.id.clone(),
641 TurnItem::CommandExecution(item) => item.id.clone(),
642 TurnItem::DynamicToolCall(item) => item.id.clone(),
643 TurnItem::CollabAgentToolCall(item) => item.id.clone(),
644 TurnItem::SubAgentActivity(item) => item.id.clone(),
645 TurnItem::WebSearch(item) => item.id.clone(),
646 TurnItem::ImageView(item) => item.id.clone(),
647 TurnItem::Extension(item) => item.id().to_string(),
648 TurnItem::ImageGeneration(item) => item.id.clone(),
649 TurnItem::EnteredReviewMode(item) => item.id.clone(),
650 TurnItem::ExitedReviewMode(item) => item.id.clone(),
651 TurnItem::FileChange(item) => item.id.clone(),
652 TurnItem::McpToolCall(item) => item.id.clone(),
653 TurnItem::ContextCompaction(item) => item.id.clone(),
654 }
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use codex_extension_items::sleep::SleepItem;
662 use pretty_assertions::assert_eq;
663 use serde_json::json;
664
665 #[test]
666 fn sleep_extension_item_preserves_type_and_kind() {
667 let item = TurnItem::Extension(ExtensionItem::Sleep(SleepItem {
668 id: "sleep-1".to_string(),
669 duration_ms: 1_000,
670 }));
671
672 assert_eq!(
673 serde_json::to_value(item).expect("serialize sleep extension item"),
674 json!({
675 "type": "Extension",
676 "kind": "clock.sleep",
677 "id": "sleep-1",
678 "durationMs": 1_000,
679 })
680 );
681 }
682
683 #[test]
684 fn user_message_item_extracts_audio_attachments() {
685 let item = UserMessageItem::new(&[
686 UserInput::Text {
687 text: "transcribe these".to_string(),
688 text_elements: Vec::new(),
689 },
690 UserInput::Audio {
691 audio_url: "https://example.com/remote.mp3".to_string(),
692 },
693 UserInput::LocalAudio {
694 path: std::path::PathBuf::from("local.wav"),
695 },
696 ]);
697
698 assert_eq!(
699 (item.audio_urls(), item.local_audio_paths()),
700 (
701 vec!["https://example.com/remote.mp3".to_string()],
702 vec![std::path::PathBuf::from("local.wav")],
703 )
704 );
705 }
706
707 #[test]
708 fn hook_prompt_roundtrips_multiple_fragments() {
709 let original = vec![
710 HookPromptFragment::from_single_hook("Retry with care & joy.", "hook-run-1"),
711 HookPromptFragment::from_single_hook("Then summarize cleanly.", "hook-run-2"),
712 ];
713 let message = build_hook_prompt_message(&original).expect("hook prompt");
714
715 let ResponseItem::Message { id, content, .. } = message else {
716 panic!("expected hook prompt message");
717 };
718 assert!(id.is_some_and(|id| id.starts_with("msg_")));
719
720 let parsed = parse_hook_prompt_message(None, &content).expect("parsed hook prompt");
721 assert_eq!(parsed.fragments, original);
722 }
723
724 #[test]
725 fn hook_prompt_parses_legacy_single_hook_run_id() {
726 let parsed = parse_hook_prompt_fragment(
727 r#"<hook_prompt hook_run_id="hook-run-1">Retry with tests.</hook_prompt>"#,
728 )
729 .expect("legacy hook prompt");
730
731 assert_eq!(
732 parsed,
733 HookPromptFragment {
734 text: "Retry with tests.".to_string(),
735 hook_run_id: "hook-run-1".to_string(),
736 }
737 );
738 }
739}