use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use crate::json::{self, Value};
pub const ANNOTATE_TIMEOUT: Duration = Duration::from_secs(180);
#[derive(Debug, Clone)]
pub struct AnnotateResult {
pub sidecar: PathBuf,
pub cast_path: Option<PathBuf>,
}
const MAX_CHAPTERS: usize = 100;
const MAX_TEXT_BYTES: usize = 4096;
#[derive(Debug, Clone, PartialEq)]
pub enum AnnotateError {
UnreadableCast(String),
EmptyTranscript,
ModelFailed(String),
UnparseableReply,
NoValidChapters,
WriteFailed(String),
}
impl std::fmt::Display for AnnotateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AnnotateError::UnreadableCast(e) => write!(f, "cannot read the cast file ({e})"),
AnnotateError::EmptyTranscript => write!(f, "the recording has no visible output to annotate"),
AnnotateError::ModelFailed(detail) => write!(f, "{detail}"),
AnnotateError::UnparseableReply => write!(f, "the model reply was not a valid annotation JSON object"),
AnnotateError::NoValidChapters => write!(f, "the model reply contained no valid chapters"),
AnnotateError::WriteFailed(e) => write!(f, "cannot write the chapters sidecar ({e})"),
}
}
}
impl AnnotateError {
pub fn hint(&self) -> &'static str {
match self {
AnnotateError::UnreadableCast(_) => "check the path and permissions, then re-run `scsh annotate-cast <cast>`",
AnnotateError::EmptyTranscript => "record a session that produces visible output; an empty cast has no chapters",
AnnotateError::ModelFailed(_) => {
"check `cursor-agent` login and network, then re-run `scsh annotate-cast <cast>`"
}
AnnotateError::UnparseableReply | AnnotateError::NoValidChapters => {
"re-run `scsh annotate-cast <cast>`; if it persists, try another model via SCSH_ANNOTATE_MODEL"
}
AnnotateError::WriteFailed(_) => "check write permissions in the directory next to the cast file",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum RunFailure {
TimedOut,
Failed,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CastAnnotation {
pub summary: String,
pub chapters: Vec<Chapter>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Chapter {
pub t: f64,
pub title: String,
}
impl CastAnnotation {
pub fn to_sidecar_json(&self) -> String {
let chapters: Vec<String> = self
.chapters
.iter()
.map(|c| format!("{{ \"t\": {}, \"title\": {} }}", fmt_secs(c.t), json::quote(&c.title)))
.collect();
format!("{{\n \"summary\": {},\n \"chapters\": [{}]\n}}\n", json::quote(&self.summary), chapters.join(", "))
}
}
fn fmt_secs(t: f64) -> String {
if !t.is_finite() || t < 0.0 {
return "0".to_string();
}
if t.fract() == 0.0 {
format!("{}", t as i64)
} else {
format!("{t:.1}")
}
}
pub fn strip_ansi(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == 0x1b {
match bytes.get(i + 1) {
Some(b'[') => {
i += 2;
while i < bytes.len() && !(0x40..=0x7e).contains(&bytes[i]) {
i += 1;
}
i += 1;
}
Some(b']') => {
i += 2;
while i < bytes.len() && bytes[i] != 0x07 && !(bytes[i] == 0x1b && bytes.get(i + 1) == Some(&b'\\')) {
i += 1;
}
i += if bytes.get(i) == Some(&0x1b) { 2 } else { 1 };
}
Some(_) => i += 2,
None => i += 1,
}
} else if b == b'\r' {
out.push('\n');
i += 1;
} else if b < 0x20 && b != b'\n' && b != b'\t' {
i += 1; } else {
out.push(b as char);
i += 1;
}
}
out
}
pub fn cast_transcript(cast_ndjson: &str, max_lines: usize) -> String {
let mut events: Vec<(f64, String)> = Vec::new();
let mut last = String::new();
let mut version = 3u8;
let mut abs_t = 0.0;
for line in cast_ndjson.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.starts_with('{') {
if let Ok(Value::Object(obj)) = json::parse(line) {
if let Some(Value::Number(n)) = obj.iter().find(|(k, _)| k == "version").map(|(_, v)| v) {
version = *n as u8;
}
}
continue;
}
let Ok(Value::Array(items)) = json::parse(line) else { continue };
let (Some(Value::Number(t)), Some(Value::String(code)), Some(Value::String(data))) =
(items.first(), items.get(1), items.get(2))
else {
continue;
};
if code != "o" {
if version == 3 {
abs_t += *t;
} else {
abs_t = abs_t.max(*t);
}
continue;
}
if version == 3 {
abs_t += *t;
} else {
abs_t = *t;
}
for raw in strip_ansi(data).split('\n') {
let text: String = raw.split_whitespace().collect::<Vec<_>>().join(" ");
if text.is_empty() || text == last {
continue;
}
last = text.clone();
let clipped: String = text.chars().take(200).collect();
events.push((abs_t, clipped));
}
}
let step = if events.len() > max_lines { events.len().div_ceil(max_lines) } else { 1 };
events.iter().step_by(step).map(|(t, text)| format!("[{t:.1}s] {text}")).collect::<Vec<_>>().join("\n")
}
fn has_duplicate_keys(fields: &[(String, Value)]) -> bool {
fields.iter().enumerate().any(|(i, (k, _))| fields[..i].iter().any(|(prev, _)| prev == k))
}
pub fn parse_annotation(reply: &str) -> Option<CastAnnotation> {
let start = reply.find('{')?;
let end = reply.rfind('}')?;
if end < start {
return None;
}
let obj = match json::parse(&reply[start..=end]).ok()? {
Value::Object(o) => o,
_ => return None,
};
if has_duplicate_keys(&obj) {
return None;
}
let summary = obj.iter().find(|(k, _)| k == "summary").and_then(|(_, v)| match v {
Value::String(s) => Some(s.trim().to_string()),
_ => None,
})?;
if summary.is_empty() || summary.len() > MAX_TEXT_BYTES {
return None;
}
let chapters_val = obj.iter().find(|(k, _)| k == "chapters").map(|(_, v)| v);
let mut chapters = Vec::new();
if let Some(Value::Array(arr)) = chapters_val {
if arr.len() > MAX_CHAPTERS {
return None;
}
for item in arr {
let Value::Object(fields) = item else { continue };
if has_duplicate_keys(fields) {
return None;
}
let t = fields.iter().find(|(k, _)| k == "t").and_then(|(_, v)| match v {
Value::Number(n) => Some(*n),
_ => None,
});
let title = fields.iter().find(|(k, _)| k == "title").and_then(|(_, v)| match v {
Value::String(s) => Some(s.trim().to_string()),
_ => None,
});
if let (Some(t), Some(title)) = (t, title) {
if title.len() > MAX_TEXT_BYTES {
return None;
}
if !title.is_empty() && t.is_finite() && t >= 0.0 {
chapters.push(Chapter { t, title });
}
}
}
}
chapters.sort_by(|a, b| a.t.partial_cmp(&b.t).unwrap_or(std::cmp::Ordering::Equal));
if let Some(first) = chapters.first_mut() {
first.t = 0.0;
}
chapters.dedup_by(|later, earlier| later.t <= earlier.t);
Some(CastAnnotation { summary, chapters })
}
fn annotation_prompt(transcript: &str) -> String {
format!(
"Below is a timestamped transcript of a terminal-session screen recording (an AI coding \
agent working). Produce a JSON object describing it.\n\n\
Output ONLY the JSON — no prose, no markdown code fence. Schema:\n\
{{\"summary\": \"<one sentence, what the session did>\", \
\"chapters\": [{{\"t\": <seconds into the recording, may be fractional e.g. 12.5>, \"title\": \"<3-6 word phase name>\"}}]}}\n\n\
Use between 3 and 8 chapters, in ascending time order. The FIRST chapter MUST start at t=0 \
(the beginning). Each chapter marks a distinct phase; keep titles terse.\n\n\
TRANSCRIPT:\n{transcript}"
)
}
fn annotation_prompt_file(transcript: &str) -> String {
format!(
"Below is a timestamped transcript of a terminal-session screen recording (an AI coding \
agent working). Produce a JSON object describing it and WRITE it to the file annotation.json \
in the current working directory (overwrite if present). Do not wrap it in markdown. After \
the file is written you are done.\n\n\
Schema:\n\
{{\"summary\": \"<one sentence, what the session did>\", \
\"chapters\": [{{\"t\": <seconds into the recording, may be fractional e.g. 12.5>, \"title\": \"<3-6 word phase name>\"}}]}}\n\n\
Use between 3 and 8 chapters, in ascending time order. The FIRST chapter MUST start at t=0 \
(the beginning). Each chapter marks a distinct phase; keep titles terse.\n\n\
TRANSCRIPT:\n{transcript}"
)
}
pub fn host_can_annotate() -> bool {
crate::runtime::which("cursor-agent").is_some() && crate::runtime::cursor_container_auth_ready()
}
pub fn can_record_annotate() -> bool {
crate::runtime::which("tmux").is_some() && crate::runtime::asciinema_available()
}
pub fn sidecar_is_stale(cast: &Path, sidecar: &Path) -> bool {
if !sidecar.exists() {
return true;
}
match (std::fs::metadata(cast).and_then(|m| m.modified()), std::fs::metadata(sidecar).and_then(|m| m.modified())) {
(Ok(cast_mtime), Ok(sidecar_mtime)) => cast_mtime > sidecar_mtime,
_ => false,
}
}
pub fn annotate_cast_with<R>(cast_path: &Path, model: &str, mut run: R) -> Result<std::path::PathBuf, AnnotateError>
where
R: FnMut(&str, &str) -> Result<String, RunFailure>,
{
let ndjson = std::fs::read_to_string(cast_path).map_err(|e| AnnotateError::UnreadableCast(e.to_string()))?;
let transcript = cast_transcript(&ndjson, 120);
if transcript.trim().is_empty() {
return Err(AnnotateError::EmptyTranscript);
}
let prompt = annotation_prompt(&transcript);
let reply = match run(model, &prompt) {
Ok(reply) => reply,
Err(RunFailure::Failed) => return Err(AnnotateError::ModelFailed("cursor-agent exited without a reply".into())),
Err(RunFailure::TimedOut) => match run(model, &prompt) {
Ok(reply) => reply,
Err(RunFailure::TimedOut) => {
return Err(AnnotateError::ModelFailed(format!(
"cursor-agent hit the {}s timeout twice (retried once after the first kill)",
ANNOTATE_TIMEOUT.as_secs()
)));
}
Err(RunFailure::Failed) => {
return Err(AnnotateError::ModelFailed("cursor-agent timed out, and the retry exited without a reply".into()));
}
},
};
let annotation = parse_annotation(&reply).ok_or(AnnotateError::UnparseableReply)?;
if annotation.chapters.is_empty() {
return Err(AnnotateError::NoValidChapters);
}
let sidecar = crate::daemon::chapters_sidecar_path(&cast_path.to_string_lossy())
.ok_or_else(|| AnnotateError::WriteFailed("not a .cast path, cannot derive the sidecar name".into()))?;
crate::atomic_write(&sidecar, annotation.to_sidecar_json().as_bytes())
.map_err(|e| AnnotateError::WriteFailed(e.to_string()))?;
Ok(sidecar)
}
pub fn annotate_cast(
cast_path: &Path, model: &str, record_cast: Option<&Path>,
) -> Result<AnnotateResult, AnnotateError> {
let recorded_reply = match record_cast {
Some(out) if can_record_annotate() => {
let ndjson = std::fs::read_to_string(cast_path).map_err(|e| AnnotateError::UnreadableCast(e.to_string()))?;
let transcript = cast_transcript(&ndjson, 120);
if transcript.trim().is_empty() {
return Err(AnnotateError::EmptyTranscript);
}
run_cursor_agent_recorded(model, &annotation_prompt_file(&transcript), out)
}
_ => None,
};
let recorded = || record_cast.filter(|p| p.is_file()).map(|p| p.to_path_buf());
if let Some(reply) = recorded_reply {
if let Some(annotation) = parse_annotation(&reply).filter(|a| !a.chapters.is_empty()) {
let sidecar = crate::daemon::chapters_sidecar_path(&cast_path.to_string_lossy())
.ok_or_else(|| AnnotateError::WriteFailed("not a .cast path, cannot derive the sidecar name".into()))?;
crate::atomic_write(&sidecar, annotation.to_sidecar_json().as_bytes())
.map_err(|e| AnnotateError::WriteFailed(e.to_string()))?;
return Ok(AnnotateResult { sidecar, cast_path: recorded() });
}
}
let sidecar = annotate_cast_with(cast_path, model, run_cursor_agent)?;
Ok(AnnotateResult { sidecar, cast_path: recorded() })
}
pub fn run_cursor_agent(model: &str, prompt: &str) -> Result<String, RunFailure> {
let dir = std::env::temp_dir().join(format!("scsh-annotate-{}", crate::runtime::random_nonce_6()));
std::fs::create_dir_all(&dir).map_err(|_| RunFailure::Failed)?;
let child = Command::new("cursor-agent")
.current_dir(&dir)
.args(["-p", "--force", "--output-format", "text", "--model", model, prompt])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn();
let result = match child {
Ok(c) => wait_capped(c, ANNOTATE_TIMEOUT),
Err(_) => Err(RunFailure::Failed),
};
let _ = std::fs::remove_dir_all(&dir);
result
}
pub fn run_cursor_agent_recorded(model: &str, prompt: &str, cast_out: &Path) -> Option<String> {
if let Some(parent) = cast_out.parent() {
std::fs::create_dir_all(parent).ok()?;
}
let dir = std::env::temp_dir().join(format!("scsh-annotate-rec-{}", crate::runtime::random_nonce_6()));
std::fs::create_dir_all(&dir).ok()?;
let term = crate::config::Terminal::default();
let session = format!("scsh-ann-{}", crate::runtime::random_nonce_6());
let agent = format!(
"cursor-agent --force --sandbox disabled --disable-auto-update --model {} {}",
crate::runtime::shell_quote(model),
crate::runtime::shell_quote(prompt),
);
let trust_slug = dir.to_string_lossy().trim_start_matches('/').replace('/', "-");
let script = format!(
r#"set -eu
cd {dir}
result=annotation.json
rm -f "$result"
mkdir -p "$HOME/.cursor/projects/{trust_slug}"
: > "$HOME/.cursor/projects/{trust_slug}/.workspace-trusted"
session={session}
# Interactive TUI — agent writes annotation.json (completion signal).
tmux -f /dev/null new-session -d -x {cols} -y {rows} -s "$session" {agent_q}
(
i=0
while [ "$i" -lt {secs} ]; do
if [ -f "$result" ]; then
sleep 2
tmux send-keys -t "$session" C-c 2>/dev/null || true
sleep 1
tmux send-keys -t "$session" C-c 2>/dev/null || true
sleep 2
tmux kill-session -t "$session" 2>/dev/null || true
exit 0
fi
tmux has-session -t "$session" 2>/dev/null || exit 0
sleep 1
i=$((i+1))
done
tmux kill-session -t "$session" 2>/dev/null || true
) >/dev/null 2>&1 &
asciinema rec -q --overwrite --return --headless -f asciicast-v3 \
--window-size {cols}x{rows} -c "tmux attach -r -t $session" {cast}
wait || true
tmux kill-session -t "$session" 2>/dev/null || true
"#,
dir = crate::runtime::shell_quote(&dir.to_string_lossy()),
trust_slug = trust_slug,
session = crate::runtime::shell_quote(&session),
cols = term.cols,
rows = term.rows,
agent_q = crate::runtime::shell_quote(&agent),
secs = ANNOTATE_TIMEOUT.as_secs(),
cast = crate::runtime::shell_quote(&cast_out.to_string_lossy()),
);
let child =
Command::new("sh").arg("-c").arg(&script).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn();
let ok = child.ok().and_then(|c| wait_capped_status(c, ANNOTATE_TIMEOUT + Duration::from_secs(30)));
let _ =
Command::new("tmux").args(["kill-session", "-t", &session]).stdout(Stdio::null()).stderr(Stdio::null()).status();
let result_path = dir.join("annotation.json");
let reply = if result_path.is_file() { std::fs::read_to_string(&result_path).ok() } else { None };
let _ = std::fs::remove_dir_all(&dir);
if ok.is_none() && reply.is_none() {
return None;
}
reply
}
fn wait_capped(mut child: Child, timeout: Duration) -> Result<String, RunFailure> {
let mut stdout = child.stdout.take().ok_or(RunFailure::Failed)?;
let reader = std::thread::spawn(move || {
let mut buf = String::new();
let _ = stdout.read_to_string(&mut buf);
buf
});
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => return Ok(reader.join().unwrap_or_default()),
Ok(Some(_)) => return Err(RunFailure::Failed),
Ok(None) if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
return Err(RunFailure::TimedOut);
}
Ok(None) => std::thread::sleep(Duration::from_millis(100)),
Err(_) => return Err(RunFailure::Failed),
}
}
}
fn wait_capped_status(mut child: Child, timeout: Duration) -> Option<()> {
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(_)) => return Some(()),
Ok(None) if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
return None;
}
Ok(None) => std::thread::sleep(Duration::from_millis(100)),
Err(_) => return None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_ansi_removes_csi_osc_and_control() {
assert_eq!(strip_ansi("\x1b[31mred\x1b[0m"), "red");
assert_eq!(strip_ansi("\x1b]0;title\x07text"), "text");
assert_eq!(strip_ansi("a\x1b[Kb"), "ab");
assert_eq!(strip_ansi("line1\r\nline2"), "line1\n\nline2");
}
#[test]
fn cast_transcript_dedups_and_timestamps() {
let cast = "{\"version\":3,\"term\":{\"cols\":80,\"rows\":24}}\n\
[0.5, \"o\", \"\\u001b[2Jhello\\r\\n\"]\n\
[0.5, \"o\", \"hello\\r\\n\"]\n\
[64.0, \"o\", \"done\\r\\n\"]\n";
let t = cast_transcript(cast, 120);
assert!(t.contains("[0.5s] hello"), "got: {t}");
assert!(!t.contains("[1.0s] hello"), "consecutive duplicate dropped: {t}");
assert!(t.contains("[65.0s] done"), "got: {t}");
}
#[test]
fn cast_transcript_still_reads_legacy_v2_absolute_times() {
let cast = "{\"version\":2,\"width\":80,\"height\":24}\n\
[0.5, \"o\", \"hello\\r\\n\"]\n\
[65.0, \"o\", \"done\\r\\n\"]\n";
let t = cast_transcript(cast, 120);
assert!(t.contains("[0.5s] hello"), "got: {t}");
assert!(t.contains("[65.0s] done"), "got: {t}");
}
#[test]
fn parse_annotation_sorts_pins_first_to_zero_and_keeps_floats() {
let reply = "Sure:\n{\"summary\": \"Ran a build.\", \
\"chapters\": [{\"t\": 8.5, \"title\": \"Finish\"}, {\"t\": 2.3, \"title\": \"Start\"}]}\ndone";
let a = parse_annotation(reply).unwrap();
assert_eq!(a.summary, "Ran a build.");
assert_eq!(a.chapters.len(), 2);
assert_eq!(a.chapters[0].title, "Start"); assert_eq!(a.chapters[0].t, 0.0); assert_eq!(a.chapters[1].t, 8.5); let json = a.to_sidecar_json();
assert!(json.contains("\"t\": 0,"), "got: {json}");
assert!(json.contains("\"t\": 8.5,"), "got: {json}");
}
#[test]
fn parse_annotation_rejects_missing_summary() {
assert!(parse_annotation("{\"chapters\": []}").is_none());
assert!(parse_annotation("no json here").is_none());
}
#[test]
fn parse_annotation_rejects_non_finite_times_and_sidecar_stays_valid_json() {
let reply = "{\"summary\": \"Ran.\", \"chapters\": [\
{\"t\": 1e400, \"title\": \"Overflow\"}, \
{\"t\": -1e400, \"title\": \"NegOverflow\"}, \
{\"t\": 3.5, \"title\": \"Real\"}]}";
let a = parse_annotation(reply).unwrap();
assert_eq!(a.chapters.len(), 1);
assert_eq!(a.chapters[0].title, "Real");
assert!(json::parse(&a.to_sidecar_json()).is_ok(), "sidecar must be valid JSON: {}", a.to_sidecar_json());
let bad = CastAnnotation {
summary: "s".into(),
chapters: vec![Chapter { t: f64::NAN, title: "a".into() }, Chapter { t: f64::INFINITY, title: "b".into() }],
};
let json_text = bad.to_sidecar_json();
assert!(json::parse(&json_text).is_ok(), "sidecar must be valid JSON: {json_text}");
assert!(!json_text.contains("inf") && !json_text.contains("NaN"), "got: {json_text}");
}
#[test]
fn parse_annotation_collapses_tied_times_to_strictly_ascending() {
let reply = "{\"summary\": \"Ran.\", \"chapters\": [\
{\"t\": 0, \"title\": \"A\"}, {\"t\": 0, \"title\": \"B\"}, \
{\"t\": 5, \"title\": \"C\"}, {\"t\": 5, \"title\": \"D\"}, {\"t\": 7, \"title\": \"E\"}]}";
let a = parse_annotation(reply).unwrap();
let titles: Vec<&str> = a.chapters.iter().map(|c| c.title.as_str()).collect();
assert_eq!(titles, vec!["A", "C", "E"]);
for pair in a.chapters.windows(2) {
assert!(pair[0].t < pair[1].t, "times must be strictly ascending: {:?}", a.chapters);
}
}
#[test]
fn parse_annotation_rejects_duplicate_keys() {
assert!(parse_annotation("{\"summary\": \"a\", \"summary\": \"b\", \"chapters\": []}").is_none());
let dup_chapter = "{\"summary\": \"s\", \"chapters\": [{\"t\": 1, \"title\": \"x\", \"t\": 2, \"title\": \"y\"}]}";
assert!(parse_annotation(dup_chapter).is_none());
}
#[test]
fn parse_annotation_rejects_oversized_replies() {
let many: Vec<String> = (0..=MAX_CHAPTERS).map(|i| format!("{{\"t\": {i}, \"title\": \"c{i}\"}}")).collect();
let reply = format!("{{\"summary\": \"s\", \"chapters\": [{}]}}", many.join(", "));
assert!(parse_annotation(&reply).is_none());
let long = "x".repeat(MAX_TEXT_BYTES + 1);
assert!(parse_annotation(&format!("{{\"summary\": \"{long}\", \"chapters\": []}}")).is_none());
assert!(parse_annotation(&format!("{{\"summary\": \"s\", \"chapters\": [{{\"t\": 0, \"title\": \"{long}\"}}]}}"))
.is_none());
}
#[test]
fn wait_capped_returns_output_and_kills_on_timeout() {
let quick =
Command::new("sh").args(["-c", "printf hello"]).stdin(Stdio::null()).stdout(Stdio::piped()).spawn().unwrap();
assert_eq!(wait_capped(quick, Duration::from_secs(10)).as_deref().ok(), Some("hello"));
let fails = Command::new("sh").args(["-c", "exit 3"]).stdin(Stdio::null()).stdout(Stdio::piped()).spawn().unwrap();
assert_eq!(wait_capped(fails, Duration::from_secs(10)), Err(RunFailure::Failed));
let slow = Command::new("sh").args(["-c", "sleep 30"]).stdin(Stdio::null()).stdout(Stdio::piped()).spawn().unwrap();
let start = Instant::now();
assert_eq!(wait_capped(slow, Duration::from_millis(300)), Err(RunFailure::TimedOut));
assert!(start.elapsed() < Duration::from_secs(5), "timed-out child must be killed promptly");
}
#[test]
fn annotate_cast_with_stubbed_runner_writes_sidecar() {
let dir = std::env::temp_dir().join(format!("scsh-annotate-test-{}", crate::runtime::random_nonce_6()));
std::fs::create_dir_all(&dir).unwrap();
let cast = dir.join("rec.cast");
std::fs::write(&cast, "{\"version\":3,\"term\":{\"cols\":80,\"rows\":24}}\n[0.1, \"o\", \"working\\r\\n\"]\n")
.unwrap();
let stub =
|_m: &str, _p: &str| Ok("{\"summary\":\"Did work.\",\"chapters\":[{\"t\":0,\"title\":\"Start\"}]}".to_string());
let side = annotate_cast_with(&cast, "composer-2.5-fast", stub).unwrap();
assert_eq!(side.file_name().unwrap().to_string_lossy(), "rec.chapters.json");
let written = std::fs::read_to_string(&side).unwrap();
assert!(written.contains("\"summary\": \"Did work.\""), "got: {written}");
assert!(written.contains("\"title\": \"Start\""), "got: {written}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn annotation_prompt_file_asks_for_annotation_json() {
let p = annotation_prompt_file("hello transcript");
assert!(p.contains("annotation.json"), "got: {p}");
assert!(p.contains("hello transcript"), "got: {p}");
}
#[test]
fn wait_capped_status_returns_and_times_out() {
let quick = Command::new("sh").args(["-c", "true"]).stdin(Stdio::null()).stdout(Stdio::null()).spawn().unwrap();
assert_eq!(wait_capped_status(quick, Duration::from_secs(10)), Some(()));
let slow = Command::new("sh").args(["-c", "sleep 30"]).stdin(Stdio::null()).stdout(Stdio::null()).spawn().unwrap();
let start = Instant::now();
assert_eq!(wait_capped_status(slow, Duration::from_millis(300)), None);
assert!(start.elapsed() < Duration::from_secs(5));
}
#[test]
fn annotate_cast_with_reports_distinct_failure_reasons() {
let dir = std::env::temp_dir().join(format!("scsh-annotate-test-{}", crate::runtime::random_nonce_6()));
std::fs::create_dir_all(&dir).unwrap();
let ok_reply = |_m: &str, _p: &str| Ok("irrelevant".to_string());
let missing = dir.join("missing.cast");
assert!(matches!(annotate_cast_with(&missing, "m", ok_reply), Err(AnnotateError::UnreadableCast(_))));
let empty = dir.join("empty.cast");
std::fs::write(&empty, "{\"version\":3,\"term\":{\"cols\":80,\"rows\":24}}\n").unwrap();
assert_eq!(annotate_cast_with(&empty, "m", ok_reply), Err(AnnotateError::EmptyTranscript));
let cast = dir.join("rec.cast");
std::fs::write(&cast, "{\"version\":3,\"term\":{\"cols\":80,\"rows\":24}}\n[0.1, \"o\", \"working\\r\\n\"]\n")
.unwrap();
let prose = |_m: &str, _p: &str| Ok("no json at all".to_string());
assert_eq!(annotate_cast_with(&cast, "m", prose), Err(AnnotateError::UnparseableReply));
let no_chapters = |_m: &str, _p: &str| Ok("{\"summary\": \"s\", \"chapters\": []}".to_string());
assert_eq!(annotate_cast_with(&cast, "m", no_chapters), Err(AnnotateError::NoValidChapters));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn annotate_cast_with_retries_once_after_a_timeout_kill() {
let dir = std::env::temp_dir().join(format!("scsh-annotate-test-{}", crate::runtime::random_nonce_6()));
std::fs::create_dir_all(&dir).unwrap();
let cast = dir.join("rec.cast");
std::fs::write(&cast, "{\"version\":3,\"term\":{\"cols\":80,\"rows\":24}}\n[0.1, \"o\", \"working\\r\\n\"]\n")
.unwrap();
let mut calls = 0;
let flaky = |_m: &str, _p: &str| {
calls += 1;
if calls == 1 {
Err(RunFailure::TimedOut)
} else {
Ok("{\"summary\":\"s\",\"chapters\":[{\"t\":0,\"title\":\"Start\"}]}".to_string())
}
};
assert!(annotate_cast_with(&cast, "m", flaky).is_ok());
assert_eq!(calls, 2, "a timeout kill must be retried exactly once");
let mut dead_calls = 0;
let dead = |_m: &str, _p: &str| {
dead_calls += 1;
Err(RunFailure::TimedOut)
};
let err = annotate_cast_with(&cast, "m", dead).unwrap_err();
assert_eq!(dead_calls, 2);
assert!(matches!(&err, AnnotateError::ModelFailed(d) if d.contains("retried once")), "got: {err}");
let mut plain_calls = 0;
let plain = |_m: &str, _p: &str| {
plain_calls += 1;
Err(RunFailure::Failed)
};
assert!(annotate_cast_with(&cast, "m", plain).is_err());
assert_eq!(plain_calls, 1, "a non-timeout failure must not be retried");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sidecar_is_stale_tracks_missing_and_outdated_sidecars() {
let dir = std::env::temp_dir().join(format!("scsh-annotate-test-{}", crate::runtime::random_nonce_6()));
std::fs::create_dir_all(&dir).unwrap();
let cast = dir.join("rec.cast");
let sidecar = dir.join("rec.chapters.json");
std::fs::write(&cast, "cast").unwrap();
assert!(sidecar_is_stale(&cast, &sidecar));
std::thread::sleep(Duration::from_millis(20));
std::fs::write(&sidecar, "{}").unwrap();
assert!(!sidecar_is_stale(&cast, &sidecar));
std::thread::sleep(Duration::from_millis(20));
std::fs::write(&cast, "cast v2").unwrap();
assert!(sidecar_is_stale(&cast, &sidecar));
let _ = std::fs::remove_dir_all(&dir);
}
}