use std::fs;
use std::path::Path;
use serde_json::Value;
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JsonlState {
AwaitingAnswer { question: String },
PlanReady { plan: Option<String> },
ToolPending { tools: Vec<String> },
IdleDone { text: String },
Working,
Unknown,
}
pub fn load(path: &Path) -> Result<Vec<Value>> {
let body = fs::read_to_string(path).map_err(|e| Error::io(path, e))?;
Ok(parse(&body))
}
pub fn parse(body: &str) -> Vec<Value> {
body.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect()
}
pub fn classify(events: &[Value]) -> JsonlState {
let Some(last_ai_idx) = events.iter().rposition(|e| event_type(e) == Some("assistant")) else {
return if events.iter().any(|e| event_type(e) == Some("user")) {
JsonlState::Working
} else {
JsonlState::Unknown
};
};
let last_ai = &events[last_ai_idx];
let message = &last_ai["message"];
let stop = message["stop_reason"].as_str();
let blocks = content_blocks(message);
if let Some(plan) = plan_tool_input(&blocks) {
return JsonlState::PlanReady { plan };
}
let later_user = events[last_ai_idx + 1..].iter().any(|e| event_type(e) == Some("user"));
if later_user {
return JsonlState::Working;
}
let text = text_of(&blocks);
let tools = tool_names(&blocks);
if stop == Some("end_turn") && tools.is_empty() && looks_like_question(&text) {
return JsonlState::AwaitingAnswer { question: text };
}
if !tools.is_empty() && matches!(stop, None | Some("tool_use")) {
return JsonlState::ToolPending { tools };
}
if stop == Some("end_turn") {
return JsonlState::IdleDone { text };
}
JsonlState::Working
}
fn looks_like_question(text: &str) -> bool {
text.split('`').step_by(2).any(|outside| outside.contains('?'))
}
fn event_type(event: &Value) -> Option<&str> {
event["type"].as_str()
}
fn content_blocks(message: &Value) -> Vec<Value> {
match &message["content"] {
Value::Array(blocks) => blocks.clone(),
Value::String(text) => vec![serde_json::json!({ "type": "text", "text": text })],
_ => Vec::new(),
}
}
fn text_of(blocks: &[Value]) -> String {
blocks
.iter()
.filter(|b| b["type"] == "text")
.filter_map(|b| b["text"].as_str())
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string()
}
fn tool_names(blocks: &[Value]) -> Vec<String> {
blocks
.iter()
.filter(|b| b["type"] == "tool_use")
.filter_map(|b| b["name"].as_str().map(String::from))
.collect()
}
fn plan_tool_input(blocks: &[Value]) -> Option<Option<String>> {
blocks
.iter()
.find(|b| {
b["type"] == "tool_use" && matches!(b["name"].as_str(), Some("ExitPlanMode") | Some("exit_plan_mode"))
})
.map(|b| b["input"]["plan"].as_str().map(String::from))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn question_with_trailing_parenthetical_is_detected() {
let text = "What would you like me to work on? (I don't see a task yet — tell me the goal.)";
assert!(looks_like_question(text));
}
#[test]
fn plain_question_is_detected() {
assert!(looks_like_question("What's the goal you'd like me to work on?"));
}
#[test]
fn question_followed_by_closing_line_is_detected() {
assert!(looks_like_question(
"What would you like me to work on?\nLet me know and I'll get started."
));
}
#[test]
fn completion_message_is_not_a_question() {
assert!(!looks_like_question("Done. The file was written and the tests pass."));
}
#[test]
fn question_mark_only_in_code_is_not_a_question() {
assert!(!looks_like_question(
"Updated the regex to `\\d+?` and reran the suite."
));
}
}