Skip to main content

stakpak_shared/models/
async_manifest.rs

1//! Async agent manifest types for subagent JSON output parsing.
2//!
3//! These types represent the JSON output produced by async agent runs
4//! and provide formatting for human/LLM consumption.
5
6use crate::models::integrations::openai::ToolCall;
7use crate::models::llm::LLMTokenUsage;
8use serde::{Deserialize, Serialize};
9
10/// Why an async agent paused execution.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12#[serde(tag = "type")]
13pub enum PauseReason {
14    /// One or more tool calls require approval before execution.
15    #[serde(rename = "tool_approval_required")]
16    ToolApprovalRequired {
17        pending_tool_calls: Vec<PendingToolCall>,
18    },
19    /// The agent responded with text only (asking a question or requesting input).
20    #[serde(rename = "input_required")]
21    InputRequired,
22}
23
24/// A tool call pending approval.
25#[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/// Unified JSON output for async agent runs (both pause and completion).
46/// All fields are always present for consistent parsing.
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct AsyncManifest {
49    /// "paused" or "completed"
50    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    /// Model ID used for this execution (e.g., "claude-sonnet-4-5-20250929").
56    #[serde(default)]
57    pub model: String,
58    /// The agent's text response (if any) in this execution.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub agent_message: Option<String>,
61    /// Steps taken in this execution (current run only).
62    #[serde(default)]
63    pub steps: usize,
64    /// Total steps across all executions in this session (including resumed runs).
65    #[serde(default)]
66    pub total_steps: usize,
67    /// Token usage for this execution only.
68    #[serde(default)]
69    pub usage: LLMTokenUsage,
70    /// Present when outcome is "paused" — why the agent paused.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub pause_reason: Option<PauseReason>,
73    /// Present when outcome is "paused" — CLI command hint to resume.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub resume_hint: Option<String>,
76}
77
78impl AsyncManifest {
79    /// Try to parse a string as an AsyncManifest.
80    /// Returns None if the string is not valid JSON or doesn't match the manifest structure.
81    pub fn try_parse(output: &str) -> Option<Self> {
82        let trimmed = output.trim();
83
84        // Direct parse attempt
85        if let Ok(manifest) = serde_json::from_str::<AsyncManifest>(trimmed) {
86            return Some(manifest);
87        }
88
89        // Try to find JSON object in the output
90        if let Some(start) = trimmed.find('{')
91            && let Some(end) = trimmed.rfind('}')
92            && end > start
93        {
94            // find/rfind of ASCII '{' '}' on same string — always valid char boundaries
95            #[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        // Status line with icon
109        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        // Execution stats (compact)
118        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        // Main content: agent message
128        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        // Pause-specific information
135        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                                        // Find a valid UTF-8 boundary near 100 chars
150                                        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                                        // truncate_at from char_indices() — always a valid boundary
157                                        #[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        // Should NOT contain checkpoint/session IDs (those are metadata)
210        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")); // Should strip stakpak__ prefix
239        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()); // Missing required fields
274    }
275
276    #[test]
277    fn test_json_structure_for_pause_reason() {
278        // Verify the JSON structure matches what local_tools.rs expects to parse
279        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        // Verify the structure that local_tools.rs expects
302        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        // With serde(tag = "type"), the type field should be present
309        assert_eq!(
310            pause_reason.get("type").unwrap().as_str().unwrap(),
311            "tool_approval_required"
312        );
313
314        // pending_tool_calls should be accessible directly under pause_reason
315        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        // String with multi-byte UTF-8 characters (emoji are 4 bytes each)
331        // This tests that truncation doesn't panic on character boundaries
332        let long_value = "🎉".repeat(50); // 50 emoji = 200 bytes, but only 50 chars
333
334        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        // Should not panic
354        let output = manifest.to_string();
355        assert!(output.contains("data:"));
356        assert!(output.contains("...")); // Should be truncated
357    }
358}