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 let json_str = &trimmed[start..=end];
95 if let Ok(manifest) = serde_json::from_str::<AsyncManifest>(json_str) {
96 return Some(manifest);
97 }
98 }
99
100 None
101 }
102}
103
104impl std::fmt::Display for AsyncManifest {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 let (status_icon, status_text) = match self.outcome.as_str() {
108 "completed" => ("✓", "Completed"),
109 "paused" => ("⏸", "Paused"),
110 _ => ("✗", "Failed"),
111 };
112
113 writeln!(f, "## Subagent Result: {} {}\n", status_icon, status_text)?;
114
115 write!(f, "**Steps**: {}", self.steps)?;
117 if self.total_steps > self.steps {
118 write!(f, " (total: {})", self.total_steps)?;
119 }
120 if !self.model.is_empty() {
121 write!(f, " | **Model**: {}", self.model)?;
122 }
123 writeln!(f, "\n")?;
124
125 if let Some(ref message) = self.agent_message
127 && !message.trim().is_empty()
128 {
129 writeln!(f, "### Response:\n{}\n", message.trim())?;
130 }
131
132 if let Some(ref pause_reason) = self.pause_reason {
134 match pause_reason {
135 PauseReason::ToolApprovalRequired { pending_tool_calls } => {
136 writeln!(f, "### Pending Tool Calls (awaiting approval):")?;
137 for tc in pending_tool_calls {
138 let display_name = tc.name.split("__").last().unwrap_or(&tc.name);
139 writeln!(f, "- {} (id: `{}`)", display_name, tc.id)?;
140
141 if !tc.arguments.is_null()
142 && let Some(obj) = tc.arguments.as_object()
143 {
144 for (key, value) in obj {
145 let value_str = match value {
146 serde_json::Value::String(s) if s.len() > 100 => {
147 let truncate_at = s
149 .char_indices()
150 .take_while(|(i, _)| *i < 100)
151 .last()
152 .map(|(i, c)| i + c.len_utf8())
153 .unwrap_or(0);
154 format!("\"{}...\"", &s[..truncate_at])
155 }
156 serde_json::Value::String(s) => format!("\"{}\"", s),
157 _ => value.to_string(),
158 };
159 writeln!(f, " - {}: {}", key, value_str)?;
160 }
161 }
162 }
163 writeln!(f)?;
164 }
165 PauseReason::InputRequired => {
166 writeln!(f, "### Status: Awaiting Input")?;
167 writeln!(f, "The subagent is waiting for user input to continue.\n")?;
168 }
169 }
170
171 if let Some(ref hint) = self.resume_hint {
172 writeln!(f, "**Resume hint**: `{}`", hint)?;
173 }
174 }
175
176 Ok(())
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn test_display_completed() {
186 let manifest = AsyncManifest {
187 outcome: "completed".to_string(),
188 checkpoint_id: Some("abc123".to_string()),
189 session_id: Some("sess456".to_string()),
190 model: "claude-haiku-4-5".to_string(),
191 agent_message: Some("Found 3 config files in /etc".to_string()),
192 steps: 5,
193 total_steps: 5,
194 usage: LLMTokenUsage::default(),
195 pause_reason: None,
196 resume_hint: None,
197 };
198
199 let output = manifest.to_string();
200 assert!(output.contains("✓ Completed"));
201 assert!(output.contains("**Steps**: 5"));
202 assert!(output.contains("claude-haiku-4-5"));
203 assert!(output.contains("Found 3 config files"));
204 assert!(!output.contains("abc123"));
206 assert!(!output.contains("sess456"));
207 }
208
209 #[test]
210 fn test_display_paused() {
211 let manifest = AsyncManifest {
212 outcome: "paused".to_string(),
213 checkpoint_id: Some("abc123".to_string()),
214 session_id: None,
215 model: "claude-haiku-4-5".to_string(),
216 agent_message: Some("I need to run a command".to_string()),
217 steps: 3,
218 total_steps: 3,
219 usage: LLMTokenUsage::default(),
220 pause_reason: Some(PauseReason::ToolApprovalRequired {
221 pending_tool_calls: vec![PendingToolCall {
222 id: "tc_001".to_string(),
223 name: "stakpak__run_command".to_string(),
224 arguments: serde_json::json!({"command": "ls -la"}),
225 }],
226 }),
227 resume_hint: Some("stakpak -c abc123 --approve tc_001".to_string()),
228 };
229
230 let output = manifest.to_string();
231 assert!(output.contains("⏸ Paused"));
232 assert!(output.contains("Pending Tool Calls"));
233 assert!(output.contains("run_command")); assert!(output.contains("tc_001"));
235 assert!(output.contains("Resume hint"));
236 }
237
238 #[test]
239 fn test_try_parse() {
240 let json = r#"{
241 "outcome": "completed",
242 "model": "claude-haiku-4-5",
243 "agent_message": "Done!",
244 "steps": 2,
245 "total_steps": 2,
246 "usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}
247 }"#;
248
249 let manifest = AsyncManifest::try_parse(json).expect("Should parse valid JSON");
250 assert_eq!(manifest.outcome, "completed");
251 assert_eq!(manifest.steps, 2);
252 assert_eq!(manifest.agent_message, Some("Done!".to_string()));
253 }
254
255 #[test]
256 fn test_try_parse_with_surrounding_text() {
257 let output = r#"Some log output here
258{"outcome": "completed", "model": "test", "steps": 1, "total_steps": 1, "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}}
259More text after"#;
260
261 let manifest = AsyncManifest::try_parse(output).expect("Should find JSON in text");
262 assert_eq!(manifest.outcome, "completed");
263 }
264
265 #[test]
266 fn test_try_parse_invalid() {
267 assert!(AsyncManifest::try_parse("not json").is_none());
268 assert!(AsyncManifest::try_parse("{}").is_none()); }
270
271 #[test]
272 fn test_json_structure_for_pause_reason() {
273 let manifest = AsyncManifest {
275 outcome: "paused".to_string(),
276 checkpoint_id: Some("test123".to_string()),
277 session_id: None,
278 model: "test".to_string(),
279 agent_message: Some("Testing".to_string()),
280 steps: 1,
281 total_steps: 1,
282 usage: LLMTokenUsage::default(),
283 pause_reason: Some(PauseReason::ToolApprovalRequired {
284 pending_tool_calls: vec![PendingToolCall {
285 id: "tc_001".to_string(),
286 name: "run_command".to_string(),
287 arguments: serde_json::json!({"command": "ls"}),
288 }],
289 }),
290 resume_hint: None,
291 };
292
293 let json_str = serde_json::to_string(&manifest).unwrap();
294 let json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
295
296 assert_eq!(
298 json.get("agent_message").unwrap().as_str().unwrap(),
299 "Testing"
300 );
301
302 let pause_reason = json.get("pause_reason").unwrap();
303 assert_eq!(
305 pause_reason.get("type").unwrap().as_str().unwrap(),
306 "tool_approval_required"
307 );
308
309 let pending = pause_reason
311 .get("pending_tool_calls")
312 .unwrap()
313 .as_array()
314 .unwrap();
315 assert_eq!(pending.len(), 1);
316 assert_eq!(pending[0].get("id").unwrap().as_str().unwrap(), "tc_001");
317 assert_eq!(
318 pending[0].get("name").unwrap().as_str().unwrap(),
319 "run_command"
320 );
321 }
322
323 #[test]
324 fn test_display_truncates_multibyte_safely() {
325 let long_value = "🎉".repeat(50); let manifest = AsyncManifest {
330 outcome: "paused".to_string(),
331 checkpoint_id: None,
332 session_id: None,
333 model: "test".to_string(),
334 agent_message: None,
335 steps: 1,
336 total_steps: 1,
337 usage: LLMTokenUsage::default(),
338 pause_reason: Some(PauseReason::ToolApprovalRequired {
339 pending_tool_calls: vec![PendingToolCall {
340 id: "tc_001".to_string(),
341 name: "test_tool".to_string(),
342 arguments: serde_json::json!({"data": long_value}),
343 }],
344 }),
345 resume_hint: None,
346 };
347
348 let output = manifest.to_string();
350 assert!(output.contains("data:"));
351 assert!(output.contains("...")); }
353}