Skip to main content

csd/detect/
jsonl.rs

1//! Session-transcript (JSONL) classification — the reliable signal for questions and normal turn
2//! completion (PoC §3.1–3.4). One JSON object per line.
3//!
4//! [`classify`] is a PURE function over already-parsed events so it can be unit-tested against
5//! captured fixtures. The JSONL lags live state by ~one tool-call (PoC gotcha #3), so the caller
6//! ([`crate::detect::combine`]) layers capture-pane + plan-file on top for TUI-interrupt gates.
7
8use std::fs;
9use std::path::Path;
10
11use serde_json::Value;
12
13use crate::error::{Error, Result};
14
15/// What the transcript alone says about the session.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum JsonlState {
18    /// Last assistant turn ended with a question and nothing has answered it.
19    AwaitingAnswer { question: String },
20    /// An `ExitPlanMode` tool_use is present (rare in the transcript — usually only post-approval).
21    PlanReady { plan: Option<String> },
22    /// A tool call is in flight (assistant emitted tool_use, no result yet).
23    ToolPending { tools: Vec<String> },
24    /// Assistant finished cleanly and is waiting for the human.
25    IdleDone { text: String },
26    /// Assistant is mid-stream or a tool result just returned — work is ongoing.
27    Working,
28    /// No assistant event yet, or shape not understood.
29    Unknown,
30}
31
32/// Parse a transcript file into one [`Value`] per non-empty line. Unparseable lines are skipped.
33pub 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
38/// Parse newline-delimited JSON, skipping blank and malformed lines.
39pub 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
46/// Classify a turn from the transcript events alone (PoC §3.4, with the later-user refinement).
47pub fn classify(events: &[Value]) -> JsonlState {
48    let Some(last_ai_idx) = events.iter().rposition(|e| event_type(e) == Some("assistant")) else {
49        // No assistant turn yet: a user event means a prompt was sent and the first reply is
50        // pending (Working); otherwise the session just started and awaits a prompt (Unknown).
51        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    // A plan tool_use is authoritative when present — but it usually only lands post-approval, so
63    // the real plan-ready detection happens via capture-pane + plan file in `detect::combine`.
64    if let Some(plan) = plan_tool_input(&blocks) {
65        return JsonlState::PlanReady { plan };
66    }
67
68    // A user event after the last assistant event means a tool result returned (or a new turn
69    // started) — the model is about to speak again. Not waiting on the human.
70    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    // stop is None / unrecognized with no tool calls → still generating.
88    JsonlState::Working
89}
90
91/// Whether an end-of-turn message reads as a question to the user (PoC §3.2, hardened).
92///
93/// A plain `ends_with('?')` is too strict: live clarifying questions trail clarifiers ("...the
94/// goal? (I don't see a task yet.)") or a polite closing line after the question. So we look for a
95/// `?` anywhere — but first drop backtick-delimited code spans/fences so a `?` inside code (regex,
96/// ternary, URL query) on a completion turn doesn't read as a question.
97fn 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
105/// Assistant `message.content` is an array of blocks; tolerate a bare string too.
106fn 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
133/// If a block is an `ExitPlanMode` tool_use, return its `input.plan` text (None if absent/empty).
134fn 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        // Observed live (claude v2.1.158): the '?' is not the last char.
150        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}