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            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        // Status line with icon
107        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        // Execution stats (compact)
116        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        // Main content: agent message
126        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        // Pause-specific information
133        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                                        // Find a valid UTF-8 boundary near 100 chars
148                                        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        // Should NOT contain checkpoint/session IDs (those are metadata)
205        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")); // Should strip stakpak__ prefix
234        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()); // Missing required fields
269    }
270
271    #[test]
272    fn test_json_structure_for_pause_reason() {
273        // Verify the JSON structure matches what local_tools.rs expects to parse
274        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        // Verify the structure that local_tools.rs expects
297        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        // With serde(tag = "type"), the type field should be present
304        assert_eq!(
305            pause_reason.get("type").unwrap().as_str().unwrap(),
306            "tool_approval_required"
307        );
308
309        // pending_tool_calls should be accessible directly under pause_reason
310        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        // String with multi-byte UTF-8 characters (emoji are 4 bytes each)
326        // This tests that truncation doesn't panic on character boundaries
327        let long_value = "🎉".repeat(50); // 50 emoji = 200 bytes, but only 50 chars
328
329        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        // Should not panic
349        let output = manifest.to_string();
350        assert!(output.contains("data:"));
351        assert!(output.contains("...")); // Should be truncated
352    }
353}