1use crate::models::integrations::openai::ToolCall;
7use crate::models::llm::LLMTokenUsage;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12#[serde(tag = "type")]
13pub enum PauseReason {
14 #[serde(rename = "tool_approval_required")]
16 ToolApprovalRequired {
17 pending_tool_calls: Vec<PendingToolCall>,
18 },
19 #[serde(rename = "input_required")]
21 InputRequired,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub struct PendingToolCall {
27 pub id: String,
28 pub name: String,
29 #[serde(default)]
30 pub arguments: serde_json::Value,
31}
32
33impl From<&ToolCall> for PendingToolCall {
34 fn from(tc: &ToolCall) -> Self {
35 let arguments = serde_json::from_str(&tc.function.arguments)
36 .unwrap_or(serde_json::Value::String(tc.function.arguments.clone()));
37 PendingToolCall {
38 id: tc.id.clone(),
39 name: tc.function.name.clone(),
40 arguments,
41 }
42 }
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct AsyncManifest {
49 pub outcome: String,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub checkpoint_id: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub session_id: Option<String>,
55 #[serde(default)]
57 pub model: String,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub agent_message: Option<String>,
61 #[serde(default)]
63 pub steps: usize,
64 #[serde(default)]
66 pub total_steps: usize,
67 #[serde(default)]
69 pub usage: LLMTokenUsage,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub pause_reason: Option<PauseReason>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub resume_hint: Option<String>,
76}
77
78impl AsyncManifest {
79 pub fn try_parse(output: &str) -> Option<Self> {
82 let trimmed = output.trim();
83
84 if let Ok(manifest) = serde_json::from_str::<AsyncManifest>(trimmed) {
86 return Some(manifest);
87 }
88
89 if let Some(start) = trimmed.find('{')
91 && let Some(end) = trimmed.rfind('}')
92 && end > start
93 {
94 #[allow(clippy::string_slice)]
96 let json_str = &trimmed[start..=end];
97 if let Ok(manifest) = serde_json::from_str::<AsyncManifest>(json_str) {
98 return Some(manifest);
99 }
100 }
101
102 None
103 }
104}
105
106impl std::fmt::Display for AsyncManifest {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 let (status_icon, status_text) = match self.outcome.as_str() {
110 "completed" => ("✓", "Completed"),
111 "paused" => ("⏸", "Paused"),
112 _ => ("✗", "Failed"),
113 };
114
115 writeln!(f, "## Subagent Result: {} {}\n", status_icon, status_text)?;
116
117 write!(f, "**Steps**: {}", self.steps)?;
119 if self.total_steps > self.steps {
120 write!(f, " (total: {})", self.total_steps)?;
121 }
122 if !self.model.is_empty() {
123 write!(f, " | **Model**: {}", self.model)?;
124 }
125 writeln!(f, "\n")?;
126
127 if let Some(ref message) = self.agent_message
129 && !message.trim().is_empty()
130 {
131 writeln!(f, "### Response:\n{}\n", message.trim())?;
132 }
133
134 if let Some(ref pause_reason) = self.pause_reason {
136 match pause_reason {
137 PauseReason::ToolApprovalRequired { pending_tool_calls } => {
138 writeln!(f, "### Pending Tool Calls (awaiting approval):")?;
139 for tc in pending_tool_calls {
140 let display_name = tc.name.split("__").last().unwrap_or(&tc.name);
141 writeln!(f, "- {} (id: `{}`)", display_name, tc.id)?;
142
143 if !tc.arguments.is_null()
144 && let Some(obj) = tc.arguments.as_object()
145 {
146 for (key, value) in obj {
147 let value_str = match value {
148 serde_json::Value::String(s) if s.len() > 100 => {
149 let truncate_at = s
151 .char_indices()
152 .take_while(|(i, _)| *i < 100)
153 .last()
154 .map(|(i, c)| i + c.len_utf8())
155 .unwrap_or(0);
156 #[allow(clippy::string_slice)]
158 let truncated = &s[..truncate_at];
159 format!("\"{}...\"", truncated)
160 }
161 serde_json::Value::String(s) => format!("\"{}\"", s),
162 _ => value.to_string(),
163 };
164 writeln!(f, " - {}: {}", key, value_str)?;
165 }
166 }
167 }
168 writeln!(f)?;
169 }
170 PauseReason::InputRequired => {
171 writeln!(f, "### Status: Awaiting Input")?;
172 writeln!(f, "The subagent is waiting for user input to continue.\n")?;
173 }
174 }
175
176 if let Some(ref hint) = self.resume_hint {
177 writeln!(f, "**Resume hint**: `{}`", hint)?;
178 }
179 }
180
181 Ok(())
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn test_display_completed() {
191 let manifest = AsyncManifest {
192 outcome: "completed".to_string(),
193 checkpoint_id: Some("abc123".to_string()),
194 session_id: Some("sess456".to_string()),
195 model: "claude-haiku-4-5".to_string(),
196 agent_message: Some("Found 3 config files in /etc".to_string()),
197 steps: 5,
198 total_steps: 5,
199 usage: LLMTokenUsage::default(),
200 pause_reason: None,
201 resume_hint: None,
202 };
203
204 let output = manifest.to_string();
205 assert!(output.contains("✓ Completed"));
206 assert!(output.contains("**Steps**: 5"));
207 assert!(output.contains("claude-haiku-4-5"));
208 assert!(output.contains("Found 3 config files"));
209 assert!(!output.contains("abc123"));
211 assert!(!output.contains("sess456"));
212 }
213
214 #[test]
215 fn test_display_paused() {
216 let manifest = AsyncManifest {
217 outcome: "paused".to_string(),
218 checkpoint_id: Some("abc123".to_string()),
219 session_id: None,
220 model: "claude-haiku-4-5".to_string(),
221 agent_message: Some("I need to run a command".to_string()),
222 steps: 3,
223 total_steps: 3,
224 usage: LLMTokenUsage::default(),
225 pause_reason: Some(PauseReason::ToolApprovalRequired {
226 pending_tool_calls: vec![PendingToolCall {
227 id: "tc_001".to_string(),
228 name: "stakpak__run_command".to_string(),
229 arguments: serde_json::json!({"command": "ls -la"}),
230 }],
231 }),
232 resume_hint: Some("stakpak -c abc123 --approve tc_001".to_string()),
233 };
234
235 let output = manifest.to_string();
236 assert!(output.contains("⏸ Paused"));
237 assert!(output.contains("Pending Tool Calls"));
238 assert!(output.contains("run_command")); assert!(output.contains("tc_001"));
240 assert!(output.contains("Resume hint"));
241 }
242
243 #[test]
244 fn test_try_parse() {
245 let json = r#"{
246 "outcome": "completed",
247 "model": "claude-haiku-4-5",
248 "agent_message": "Done!",
249 "steps": 2,
250 "total_steps": 2,
251 "usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}
252 }"#;
253
254 let manifest = AsyncManifest::try_parse(json).expect("Should parse valid JSON");
255 assert_eq!(manifest.outcome, "completed");
256 assert_eq!(manifest.steps, 2);
257 assert_eq!(manifest.agent_message, Some("Done!".to_string()));
258 }
259
260 #[test]
261 fn test_try_parse_with_surrounding_text() {
262 let output = r#"Some log output here
263{"outcome": "completed", "model": "test", "steps": 1, "total_steps": 1, "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}}
264More text after"#;
265
266 let manifest = AsyncManifest::try_parse(output).expect("Should find JSON in text");
267 assert_eq!(manifest.outcome, "completed");
268 }
269
270 #[test]
271 fn test_try_parse_invalid() {
272 assert!(AsyncManifest::try_parse("not json").is_none());
273 assert!(AsyncManifest::try_parse("{}").is_none()); }
275
276 #[test]
277 fn test_json_structure_for_pause_reason() {
278 let manifest = AsyncManifest {
280 outcome: "paused".to_string(),
281 checkpoint_id: Some("test123".to_string()),
282 session_id: None,
283 model: "test".to_string(),
284 agent_message: Some("Testing".to_string()),
285 steps: 1,
286 total_steps: 1,
287 usage: LLMTokenUsage::default(),
288 pause_reason: Some(PauseReason::ToolApprovalRequired {
289 pending_tool_calls: vec![PendingToolCall {
290 id: "tc_001".to_string(),
291 name: "run_command".to_string(),
292 arguments: serde_json::json!({"command": "ls"}),
293 }],
294 }),
295 resume_hint: None,
296 };
297
298 let json_str = serde_json::to_string(&manifest).unwrap();
299 let json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
300
301 assert_eq!(
303 json.get("agent_message").unwrap().as_str().unwrap(),
304 "Testing"
305 );
306
307 let pause_reason = json.get("pause_reason").unwrap();
308 assert_eq!(
310 pause_reason.get("type").unwrap().as_str().unwrap(),
311 "tool_approval_required"
312 );
313
314 let pending = pause_reason
316 .get("pending_tool_calls")
317 .unwrap()
318 .as_array()
319 .unwrap();
320 assert_eq!(pending.len(), 1);
321 assert_eq!(pending[0].get("id").unwrap().as_str().unwrap(), "tc_001");
322 assert_eq!(
323 pending[0].get("name").unwrap().as_str().unwrap(),
324 "run_command"
325 );
326 }
327
328 #[test]
329 fn test_display_truncates_multibyte_safely() {
330 let long_value = "🎉".repeat(50); let manifest = AsyncManifest {
335 outcome: "paused".to_string(),
336 checkpoint_id: None,
337 session_id: None,
338 model: "test".to_string(),
339 agent_message: None,
340 steps: 1,
341 total_steps: 1,
342 usage: LLMTokenUsage::default(),
343 pause_reason: Some(PauseReason::ToolApprovalRequired {
344 pending_tool_calls: vec![PendingToolCall {
345 id: "tc_001".to_string(),
346 name: "test_tool".to_string(),
347 arguments: serde_json::json!({"data": long_value}),
348 }],
349 }),
350 resume_hint: None,
351 };
352
353 let output = manifest.to_string();
355 assert!(output.contains("data:"));
356 assert!(output.contains("...")); }
358}