1use std::fs;
9use std::path::Path;
10
11use serde_json::Value;
12
13use crate::error::{Error, Result};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum JsonlState {
18 AwaitingAnswer { question: String },
20 PlanReady { plan: Option<String> },
22 ToolPending { tools: Vec<String> },
24 IdleDone { text: String },
26 Working,
28 Unknown,
30}
31
32pub fn load(path: &Path) -> Result<Vec<Value>> {
34 let body = fs::read_to_string(path).map_err(|e| Error::io(path, e))?;
35 Ok(parse(&body))
36}
37
38pub fn parse(body: &str) -> Vec<Value> {
40 body.lines()
41 .filter(|l| !l.trim().is_empty())
42 .filter_map(|l| serde_json::from_str(l).ok())
43 .collect()
44}
45
46pub fn classify(events: &[Value]) -> JsonlState {
48 let Some(last_ai_idx) = events.iter().rposition(|e| event_type(e) == Some("assistant")) else {
49 return if events.iter().any(|e| event_type(e) == Some("user")) {
52 JsonlState::Working
53 } else {
54 JsonlState::Unknown
55 };
56 };
57 let last_ai = &events[last_ai_idx];
58 let message = &last_ai["message"];
59 let stop = message["stop_reason"].as_str();
60 let blocks = content_blocks(message);
61
62 if let Some(plan) = plan_tool_input(&blocks) {
65 return JsonlState::PlanReady { plan };
66 }
67
68 let later_user = events[last_ai_idx + 1..].iter().any(|e| event_type(e) == Some("user"));
71 if later_user {
72 return JsonlState::Working;
73 }
74
75 let text = text_of(&blocks);
76 let tools = tool_names(&blocks);
77
78 if stop == Some("end_turn") && tools.is_empty() && looks_like_question(&text) {
79 return JsonlState::AwaitingAnswer { question: text };
80 }
81 if !tools.is_empty() && matches!(stop, None | Some("tool_use")) {
82 return JsonlState::ToolPending { tools };
83 }
84 if stop == Some("end_turn") {
85 return JsonlState::IdleDone { text };
86 }
87 JsonlState::Working
89}
90
91fn looks_like_question(text: &str) -> bool {
98 text.split('`').step_by(2).any(|outside| outside.contains('?'))
99}
100
101fn event_type(event: &Value) -> Option<&str> {
102 event["type"].as_str()
103}
104
105fn content_blocks(message: &Value) -> Vec<Value> {
107 match &message["content"] {
108 Value::Array(blocks) => blocks.clone(),
109 Value::String(text) => vec![serde_json::json!({ "type": "text", "text": text })],
110 _ => Vec::new(),
111 }
112}
113
114fn text_of(blocks: &[Value]) -> String {
115 blocks
116 .iter()
117 .filter(|b| b["type"] == "text")
118 .filter_map(|b| b["text"].as_str())
119 .collect::<Vec<_>>()
120 .join("\n")
121 .trim()
122 .to_string()
123}
124
125fn tool_names(blocks: &[Value]) -> Vec<String> {
126 blocks
127 .iter()
128 .filter(|b| b["type"] == "tool_use")
129 .filter_map(|b| b["name"].as_str().map(String::from))
130 .collect()
131}
132
133fn plan_tool_input(blocks: &[Value]) -> Option<Option<String>> {
135 blocks
136 .iter()
137 .find(|b| {
138 b["type"] == "tool_use" && matches!(b["name"].as_str(), Some("ExitPlanMode") | Some("exit_plan_mode"))
139 })
140 .map(|b| b["input"]["plan"].as_str().map(String::from))
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn question_with_trailing_parenthetical_is_detected() {
149 let text = "What would you like me to work on? (I don't see a task yet — tell me the goal.)";
151 assert!(looks_like_question(text));
152 }
153
154 #[test]
155 fn plain_question_is_detected() {
156 assert!(looks_like_question("What's the goal you'd like me to work on?"));
157 }
158
159 #[test]
160 fn question_followed_by_closing_line_is_detected() {
161 assert!(looks_like_question(
162 "What would you like me to work on?\nLet me know and I'll get started."
163 ));
164 }
165
166 #[test]
167 fn completion_message_is_not_a_question() {
168 assert!(!looks_like_question("Done. The file was written and the tests pass."));
169 }
170
171 #[test]
172 fn question_mark_only_in_code_is_not_a_question() {
173 assert!(!looks_like_question(
174 "Updated the regex to `\\d+?` and reran the suite."
175 ));
176 }
177}