From 610baf274664eb6479ecd796b532573127cf9ec4 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 27 Oct 2026 22:17:29 -0400 Subject: [PATCH] fix(last): count replies for pick, read agy's shell markers, match windows process names - every host reader except codex counted prompts toward `n`, so a prompt still waiting for its reply took the only slot of `--pick 1`, and discovery chose a just-started conversation with no reply over the previous one. `n` now counts assistant replies, and discovery requires one (antigravity, claude ladder, pi, omp, command code); copilot, droid, hermes and opencode get the same counting. - `ANTIGRAVITY_AGENT` / `ANTIGRAVITY_CONVERSATION_ID` select the antigravity host, and the exported conversation id is read exactly, as `CODEX_THREAD_ID` is for codex. - herdr's process fallback matches `agy.exe` and `\`-separated paths, for every known agent and for the reported group leader. refs plannotator/herdr-annotate#70 --- .../plannotator-tui-hosts/src/antigravity.rs | 25 +++++---- .../src/claude/ladder.rs | 12 ++--- .../src/claude/messages.rs | 10 ++-- crates/plannotator-tui-hosts/src/copilot.rs | 53 +++++++++---------- crates/plannotator-tui-hosts/src/droid.rs | 2 +- crates/plannotator-tui-hosts/src/hermes.rs | 12 +++-- crates/plannotator-tui-hosts/src/lib.rs | 26 +++++++++ crates/plannotator-tui-hosts/src/opencode.rs | 14 +++-- crates/plannotator-tui-hosts/src/pi.rs | 20 +++---- .../tests/antigravity.rs | 31 +++++++++-- crates/plannotator-tui-hosts/tests/claude.rs | 20 +++++-- .../tests/commandcode.rs | 10 +++- crates/plannotator-tui-hosts/tests/detect.rs | 22 ++++++++ crates/plannotator-tui-hosts/tests/hermes.rs | 10 ++++ .../plannotator-tui-hosts/tests/opencode.rs | 5 +- crates/plannotator-tui-hosts/tests/pi.rs | 5 +- crates/plannotator-tui/src/herdr/launch.rs | 14 ++++- .../plannotator-tui/src/herdr/launch/tests.rs | 23 ++++++++ crates/plannotator-tui/src/last/fallback.rs | 14 +++-- crates/plannotator-tui/tests/last.rs | 49 +++++++++++++++++ docs/decisions.md | 3 +- 21 files changed, 292 insertions(+), 88 deletions(-) diff --git a/crates/plannotator-tui-hosts/src/antigravity.rs b/crates/plannotator-tui-hosts/src/antigravity.rs index cf38900..2e47ab4 100644 --- a/crates/plannotator-tui-hosts/src/antigravity.rs +++ b/crates/plannotator-tui-hosts/src/antigravity.rs @@ -28,6 +28,10 @@ pub const DEFAULT_APP_DATA_DIR: &str = ".gemini/antigravity-cli"; pub const SUMMARIES_DB: &str = "conversation_summaries.db"; /// One line per prompt, relative to the app data directory. pub const HISTORY_FILE: &str = "history.jsonl"; +/// Set to `1` in the shells `agy` runs. +pub const AGENT_ENV: &str = "ANTIGRAVITY_AGENT"; +/// The conversation id of the `agy` session that runs the shell. +pub const CONVERSATION_ENV: &str = "ANTIGRAVITY_CONVERSATION_ID"; const WHAT: &str = "Antigravity"; @@ -58,7 +62,7 @@ pub fn conversation_of(transcript: &Path) -> Option { .flatten() } -/// The conversation for `cwd` with the newest transcript that holds a message: one whose +/// The conversation for `cwd` with the newest transcript that holds a reply: one whose /// workspace is `cwd`, else one whose workspace contains it. Workspaces come from the summaries /// database and the prompt history together; when the database cannot be read the error says /// so, and the history alone decides. @@ -136,7 +140,7 @@ fn history_workspaces(history: &Path) -> Vec<(String, String)> { .collect() } -/// The last-written transcript among `ids` that holds at least one message. Ids that are not +/// The last-written transcript among `ids` that holds at least one reply. Ids that are not /// one safe path component are skipped, whatever wrote them. fn newest_with_messages<'a>(app_data: &Path, ids: impl Iterator) -> Option { let mut ranked: Vec<(Option, PathBuf)> = ids @@ -147,18 +151,17 @@ fn newest_with_messages<'a>(app_data: &Path, ids: impl Iterator) .collect(); ranked.sort_by(|(a_time, a), (b_time, b)| b_time.cmp(a_time).then_with(|| a.cmp(b))); ranked.dedup_by(|(_, a), (_, b)| a == b); - ranked - .into_iter() - .map(|(_, path)| path) - .find(|path| std::fs::read_to_string(path).is_ok_and(|text| !parse_messages(&text, 1).is_empty())) + ranked.into_iter().map(|(_, path)| path).find(|path| { + std::fs::read_to_string(path).is_ok_and(|text| crate::has_reply(&parse_messages(&text, 1))) + }) } -/// The newest `n` prompts and replies, newest first. A reply is a `PLANNER_RESPONSE` step's -/// `content` as written; a prompt is a `USER_INPUT` step's `content`, wrappers and all (only -/// replies are shown). A line that does not parse, such as one still being written, is -/// skipped. +/// Prompts and replies, newest first, through the `n`th newest reply. A reply is a +/// `PLANNER_RESPONSE` step's `content` as written; a prompt is a `USER_INPUT` step's +/// `content`, wrappers and all (only replies are shown). A line that does not parse, such as +/// one still being written, is skipped. pub fn parse_messages(jsonl: &str, n: usize) -> Vec { - jsonl.lines().rev().filter_map(step_message).take(n).collect() + crate::through_replies(jsonl.lines().rev().filter_map(step_message), n) } fn step_message(line: &str) -> Option { diff --git a/crates/plannotator-tui-hosts/src/claude/ladder.rs b/crates/plannotator-tui-hosts/src/claude/ladder.rs index 4799fa0..581b1b8 100644 --- a/crates/plannotator-tui-hosts/src/claude/ladder.rs +++ b/crates/plannotator-tui-hosts/src/claude/ladder.rs @@ -9,7 +9,7 @@ use crate::{Match, SessionMeta}; const MAX_ANCESTOR_HOPS: usize = 8; -/// The ladder, most precise first; the first candidate that yields a message wins, and +/// The ladder, most precise first; the first candidate that yields a reply wins, and /// the returned [`Match`] says which rung it came from: /// 1. `sessions/.json` for `start_pid` and up to eight of its ancestors — with a ghost /// check: a newer transcript in the same project dir that no running session claims is a @@ -76,7 +76,7 @@ fn transcript_for_session( { return Some(ghost); } - (own_mtime.is_some() && has_messages(&own)).then_some(own) + (own_mtime.is_some() && holds_reply(&own)).then_some(own) } /// The project dir for `cwd`, exact name first, then case-insensitively (Windows lowercases). @@ -91,7 +91,7 @@ pub(super) fn project_dir(projects_dir: &Path, cwd: &Path) -> Option { }) } -/// Newest transcript in `dir` that renders at least one message. With `unregistered`, only +/// Newest transcript in `dir` that renders at least one reply. With `unregistered`, only /// transcripts whose session id is not in that set count. fn newest_with_messages(dir: &Path, unregistered: Option<&HashSet<&str>>) -> Option { let mut files: Vec<(SystemTime, PathBuf)> = std::fs::read_dir(dir) @@ -106,11 +106,11 @@ fn newest_with_messages(dir: &Path, unregistered: Option<&HashSet<&str>>) -> Opt .filter_map(|p| Some((mtime(&p)?, p))) .collect(); files.sort_by_key(|(when, _)| std::cmp::Reverse(*when)); - files.into_iter().map(|(_, p)| p).find(|p| has_messages(p)) + files.into_iter().map(|(_, p)| p).find(|p| holds_reply(p)) } -fn has_messages(path: &Path) -> bool { - std::fs::read_to_string(path).is_ok_and(|text| !parse_messages(&text, 1).is_empty()) +fn holds_reply(path: &Path) -> bool { + std::fs::read_to_string(path).is_ok_and(|text| crate::has_reply(&parse_messages(&text, 1))) } fn mtime(path: &Path) -> Option { diff --git a/crates/plannotator-tui-hosts/src/claude/messages.rs b/crates/plannotator-tui-hosts/src/claude/messages.rs index 13de3ff..9fec0e4 100644 --- a/crates/plannotator-tui-hosts/src/claude/messages.rs +++ b/crates/plannotator-tui-hosts/src/claude/messages.rs @@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet}; use super::entries::Entry; use crate::{Message, Role}; -/// The newest `n` rendered messages, newest first. +/// Rendered messages, newest first, through the `n`th newest reply. /// /// The active branch is the `parentUuid` chain from the newest entry that has a uuid. /// File order is used instead when that chain cannot be trusted (missing ids, a dangling @@ -25,14 +25,14 @@ pub fn parse_messages(jsonl: &str, n: usize) -> Vec { } None => render_all(&entries), }; - rendered.into_iter().rev().take(n).collect() + crate::through_replies(rendered.into_iter().rev(), n) } -/// The newest `n` rendered messages in plain file order, newest first — for transcripts -/// in Claude's shape that have no rewind tree (Droid). +/// Rendered messages in plain file order, newest first, through the `n`th newest reply — for +/// transcripts in Claude's shape that have no rewind tree (Droid). pub fn parse_messages_file_order(jsonl: &str, n: usize) -> Vec { let entries: Vec = jsonl.lines().filter_map(Entry::parse).collect(); - render_all(&entries).into_iter().rev().take(n).collect() + crate::through_replies(render_all(&entries).into_iter().rev(), n) } fn render_all(entries: &[Entry]) -> Vec { diff --git a/crates/plannotator-tui-hosts/src/copilot.rs b/crates/plannotator-tui-hosts/src/copilot.rs index 7f511c7..5271df8 100644 --- a/crates/plannotator-tui-hosts/src/copilot.rs +++ b/crates/plannotator-tui-hosts/src/copilot.rs @@ -66,36 +66,31 @@ pub fn find_session( .or_else(|| sessions.first().map(|s| pick(s, Match::Newest))) } -/// Human prompts and assistant replies from `events.jsonl`, newest first, at most `n`. -/// `assistant.message` carries its text as a plain string; empty ones are skipped. +/// Human prompts and assistant replies from `events.jsonl`, newest first, through the `n`th +/// newest reply. `assistant.message` carries its text as a plain string; empty ones are +/// skipped. pub fn parse_messages(events_jsonl: &str, n: usize) -> Vec { - let mut out: Vec = Vec::new(); - for line in events_jsonl.lines().rev() { - if out.len() >= n { - break; - } - let Ok(event) = serde_json::from_str::(line.trim()) else { continue }; - let kind = event.get("type").and_then(Value::as_str).unwrap_or(""); - let role = match kind { - "assistant.message" => Role::Assistant, - "user.message" => Role::Human, - _ => continue, - }; - let data = event.get("data"); - let Some(text) = data.and_then(|d| d.get("content")).and_then(Value::as_str) else { continue }; - if text.trim().is_empty() { - continue; - } - let id = data - .and_then(|d| d.get("messageId")) - .or_else(|| event.get("id")) - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(); - let at = event.get("timestamp").and_then(Value::as_str).map(str::to_owned); - out.push(Message { id, role, text: text.to_owned(), at }); - } - out + crate::through_replies(events_jsonl.lines().rev().filter_map(event_message), n) +} + +fn event_message(line: &str) -> Option { + let event = serde_json::from_str::(line.trim()).ok()?; + let role = match event.get("type").and_then(Value::as_str)? { + "assistant.message" => Role::Assistant, + "user.message" => Role::Human, + _ => return None, + }; + let data = event.get("data"); + let text = + data.and_then(|d| d.get("content")).and_then(Value::as_str).filter(|t| !t.trim().is_empty())?; + let id = data + .and_then(|d| d.get("messageId")) + .or_else(|| event.get("id")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let at = event.get("timestamp").and_then(Value::as_str).map(str::to_owned); + Some(Message { id, role, text: text.to_owned(), at }) } struct Session { diff --git a/crates/plannotator-tui-hosts/src/droid.rs b/crates/plannotator-tui-hosts/src/droid.rs index 375f7bc..36a2481 100644 --- a/crates/plannotator-tui-hosts/src/droid.rs +++ b/crates/plannotator-tui-hosts/src/droid.rs @@ -45,7 +45,7 @@ pub fn find_transcript(factory_dir: &Path, cwd: &Path) -> Option { cwd.ancestors().find_map(|dir| newest_log(&slug_dir(&sessions_dir, dir)?)) } -/// The newest `n` assistant and human messages, newest first, in file order. +/// Assistant and human messages, newest first, in file order, through the `n`th newest reply. pub fn parse_messages(jsonl: &str, n: usize) -> Vec { parse_messages_file_order(jsonl, n) } diff --git a/crates/plannotator-tui-hosts/src/hermes.rs b/crates/plannotator-tui-hosts/src/hermes.rs index baadf50..24027c1 100644 --- a/crates/plannotator-tui-hosts/src/hermes.rs +++ b/crates/plannotator-tui-hosts/src/hermes.rs @@ -15,7 +15,8 @@ pub const DB_FILE: &str = "state.db"; const WHAT: &str = "Hermes"; -/// The newest `n` user and assistant messages of `session_id`, newest first. +/// The user and assistant messages of `session_id`, newest first, through the `n`th newest +/// assistant reply. /// /// The database is opened read-only and never written. A plain read-only open sees the /// live WAL; when the shared-memory index cannot be opened, the read falls back to @@ -28,11 +29,11 @@ pub fn messages_for_session(db: &Path, session_id: &str, n: usize) -> Result '' \ - ORDER BY timestamp DESC, id DESC LIMIT ?2", + ORDER BY timestamp DESC, id DESC", ) .map_err(|e| sql_error(WHAT, &e))?; let rows = statement - .query_map(params![session_id, n as i64], |row| { + .query_map(params![session_id], |row| { let id: i64 = row.get(0)?; let role: String = row.get(1)?; let content: Option = row.get(2)?; @@ -41,7 +42,11 @@ pub fn messages_for_session(db: &Path, session_id: &str, n: usize) -> Result= n { + break; + } let (id, role, content, timestamp) = row.map_err(|e| sql_error(WHAT, &e))?; let Some(text) = content.filter(|c| !c.trim().is_empty()) else { continue }; let role = match role.as_str() { @@ -49,6 +54,7 @@ pub fn messages_for_session(db: &Path, session_id: &str, n: usize) -> Result Role::Human, _ => continue, }; + replies += usize::from(role == Role::Assistant); messages.push(Message { id: id.to_string(), role, text, at: timestamp.map(iso) }); } if messages.is_empty() { diff --git a/crates/plannotator-tui-hosts/src/lib.rs b/crates/plannotator-tui-hosts/src/lib.rs index bc39ee2..5d7804f 100644 --- a/crates/plannotator-tui-hosts/src/lib.rs +++ b/crates/plannotator-tui-hosts/src/lib.rs @@ -124,6 +124,28 @@ pub struct Message { pub at: Option, } +/// Newest-first messages up to and including the `n`th assistant reply. `n` counts replies, +/// never prompts: `last` offers replies, so a prompt still waiting for its answer must not +/// crowd out the reply before it. Prompts newer than that reply are kept for context. +pub(crate) fn through_replies(newest_first: impl IntoIterator, n: usize) -> Vec { + let mut out = Vec::new(); + let mut replies = 0; + for message in newest_first { + if replies >= n { + break; + } + replies += usize::from(message.role == Role::Assistant); + out.push(message); + } + out +} + +/// Does `messages` hold an assistant reply? A transcript with prompts only (a conversation +/// just started) has nothing to annotate, so discovery passes over it. +pub(crate) fn has_reply(messages: &[Message]) -> bool { + messages.iter().any(|message| message.role == Role::Assistant) +} + /// Which rung of a discovery ladder chose a session, so a caller can say whether the /// result identifies the agent's own session or is the best guess for its folder. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -243,6 +265,10 @@ pub fn detect_host(env: impl Fn(&str) -> Option) -> Result Result Result, Ho let (id, message_data, part_data) = row.map_err(|e| sql_error(WHAT, &e))?; if current.as_ref().is_none_or(|c| c.0 != id) { flush(&mut current, &mut messages); - if messages.len() >= n { + if replies(&messages) >= n { break; } let Some((role, at)) = message_meta(&message_data) else { continue }; @@ -185,7 +186,7 @@ fn messages_v1(db: &Path, session_id: &str, n: usize) -> Result, Ho parts.push(text); } } - if messages.len() < n { + if replies(&messages) < n { flush(&mut current, &mut messages); } if messages.is_empty() { @@ -219,7 +220,7 @@ fn messages_v2(db: &Path, session_id: &str, n: usize) -> Result, Ho .map_err(|e| sql_error(WHAT, &e))?; let mut messages = Vec::new(); for row in rows { - if messages.len() >= n { + if replies(&messages) >= n { break; } let (id, kind, data, created) = row.map_err(|e| sql_error(WHAT, &e))?; @@ -262,6 +263,11 @@ fn messages_v2(db: &Path, session_id: &str, n: usize) -> Result, Ho Ok(messages) } +/// How many of `messages` are assistant replies: `n` counts those, not prompts. +fn replies(messages: &[Message]) -> usize { + messages.iter().filter(|message| message.role == Role::Assistant).count() +} + /// Role and creation time from a `message.data` payload; `None` for roles we do not render. fn message_meta(data: &str) -> Option<(Role, Option)> { let json: serde_json::Value = serde_json::from_str(data).ok()?; diff --git a/crates/plannotator-tui-hosts/src/pi.rs b/crates/plannotator-tui-hosts/src/pi.rs index 3249e31..3f97211 100644 --- a/crates/plannotator-tui-hosts/src/pi.rs +++ b/crates/plannotator-tui-hosts/src/pi.rs @@ -101,7 +101,7 @@ pub fn find_transcript(sessions_dir: &Path, cwd: &Path) -> Option { newest_with_messages(all) } -/// The last-written candidate that holds at least one message. Ranked by modification +/// The last-written candidate that holds at least one reply. Ranked by modification /// time, newest first; a file whose metadata cannot be read ranks after every file that /// has one, and ties fall back to the timestamp in the filename, so a directory whose /// files all share an mtime keeps the old ordering. @@ -116,7 +116,7 @@ pub(crate) fn newest_with_messages(mut files: Vec) -> Option { ranked .into_iter() .map(|(_, path)| path) - .find(|p| std::fs::read_to_string(p).is_ok_and(|text| !parse_messages(&text, 1).is_empty())) + .find(|p| std::fs::read_to_string(p).is_ok_and(|text| crate::has_reply(&parse_messages(&text, 1)))) } fn modified(path: &Path) -> Option { @@ -200,18 +200,15 @@ fn iso_timestamp(value: &Value) -> Option { } } -/// The newest `n` messages on the active branch, newest first. The branch is the -/// `parentId` chain from the newest entry with an id to the root — what pi's own +/// Messages on the active branch, newest first, through the `n`th newest reply. The branch +/// is the `parentId` chain from the newest entry with an id to the root — what pi's own /// `getBranch()` returns; entries orphaned by a rewind are absent. A chain that cannot be /// reconstructed (missing parent, cycle) yields nothing rather than the wrong messages. pub fn parse_messages(jsonl: &str, n: usize) -> Vec { let entries: Vec = jsonl.lines().filter_map(Entry::parse).collect(); let Some(active) = active_branch(&entries) else { return Vec::new() }; - entries - .iter() - .rev() - .filter(|e| e.id.as_ref().is_some_and(|i| active.contains(i))) - .filter_map(|e| { + let messages = + entries.iter().rev().filter(|e| e.id.as_ref().is_some_and(|i| active.contains(i))).filter_map(|e| { let (role, text) = e.message.as_ref()?; let role = match role.as_str() { "assistant" => Role::Assistant, @@ -224,9 +221,8 @@ pub fn parse_messages(jsonl: &str, n: usize) -> Vec { text: text.clone(), at: e.timestamp.clone(), }) - }) - .take(n) - .collect() + }); + crate::through_replies(messages, n) } fn active_branch(entries: &[Entry]) -> Option> { diff --git a/crates/plannotator-tui-hosts/tests/antigravity.rs b/crates/plannotator-tui-hosts/tests/antigravity.rs index 66454d2..3499a5d 100644 --- a/crates/plannotator-tui-hosts/tests/antigravity.rs +++ b/crates/plannotator-tui-hosts/tests/antigravity.rs @@ -92,7 +92,22 @@ fn replies_are_planner_responses_with_content_newest_first() { let prompts = messages.iter().filter(|m| m.role == Role::Human).count(); assert_eq!(prompts, 2); assert_eq!(messages.first().and_then(|m| m.at.as_deref()), Some("2026-10-21T06:01:15Z")); - assert_eq!(antigravity::parse_messages(&text, 1).len(), 1, "pick caps the count"); + assert_eq!(antigravity::parse_messages(&text, 1).len(), 1, "pick caps the replies"); +} + +/// The newest step is a prompt still being answered: one pick is still the reply before it. +#[test] +fn n_counts_replies_so_a_waiting_prompt_does_not_hide_the_last_reply() { + let path = antigravity::transcript_path(&fixtures(), FIXTURE_ID); + let text = std::fs::read_to_string(&path).expect("fixture"); + let waiting = format!("{text}{}\n", r#"{"step_index":8,"type":"USER_INPUT","content":"and the audio?"}"#); + let newest = antigravity::parse_messages(&waiting, 1); + let roles: Vec = newest.iter().map(|m| m.role).collect(); + assert_eq!(roles, [Role::Human, Role::Assistant]); + assert_eq!( + newest.last().map(|m| m.text.as_str()), + Some("Done: both settings are applied in `retroarch.cfg`.") + ); } #[test] @@ -159,14 +174,24 @@ fn an_exact_workspace_beats_a_newer_one_that_merely_contains_the_cwd() { std::fs::remove_dir_all(&app).expect("cleanup"); } +/// An empty transcript, or a conversation just started whose only step is the prompt, has +/// nothing to annotate: the previous conversation in the folder is the answer. #[test] -fn a_conversation_without_messages_is_passed_over() { +fn a_conversation_without_a_reply_is_passed_over() { let app = temp_dir("empty"); conversation(&app, OLD, "older reply", 1_000); let empty = antigravity::transcript_path(&app, NEW); std::fs::create_dir_all(empty.parent().expect("logs")).expect("logs dir"); std::fs::write(&empty, "").expect("empty transcript"); - summaries(&app, &[(OLD, r#"["file:///w/p"]"#, ""), (NEW, r#"["file:///w/p"]"#, "")]); + conversation(&app, OTHER, "", 3_000); + summaries( + &app, + &[ + (OLD, r#"["file:///w/p"]"#, ""), + (NEW, r#"["file:///w/p"]"#, ""), + (OTHER, r#"["file:///w/p"]"#, ""), + ], + ); let found = antigravity::find_transcript(&app, Path::new("/w/p")).expect("found"); assert_eq!(reply(&found), "older reply"); std::fs::remove_dir_all(&app).expect("cleanup"); diff --git a/crates/plannotator-tui-hosts/tests/claude.rs b/crates/plannotator-tui-hosts/tests/claude.rs index 641c279..3284d67 100644 --- a/crates/plannotator-tui-hosts/tests/claude.rs +++ b/crates/plannotator-tui-hosts/tests/claude.rs @@ -51,11 +51,25 @@ fn machinery_meta_and_sidechain_entries_are_not_human_prompts() { } #[test] -fn newest_first_and_n_is_honoured() { +fn newest_first_and_n_counts_replies_not_prompts() { let two = parse_messages(TRANSCRIPT, 2); - assert_eq!(two.len(), 2); + let roles: Vec = two.iter().map(|m| m.role).collect(); + assert_eq!(roles, [Role::Assistant, Role::Human, Role::Assistant]); assert_eq!(two[0].text, "assistant text 3"); - assert_eq!((two[1].role, two[1].text.as_str()), (Role::Human, "user prompt 3")); + assert_eq!(two[1].text, "user prompt 3"); +} + +#[test] +fn a_prompt_still_waiting_for_its_reply_does_not_crowd_out_the_reply_before_it() { + let waiting = concat!( + r#"{"parentUuid":"s7","isSidechain":false,"type":"user","#, + r#""message":{"role":"user","content":"a prompt not answered yet"},"uuid":"u9","#, + r#""timestamp":"2026-08-28T10:09:00.000Z"}"#, + ); + let newest = parse_messages(&format!("{TRANSCRIPT}\n{waiting}\n"), 1); + let replies: Vec<&str> = + newest.iter().filter(|m| m.role == Role::Assistant).map(|m| m.text.as_str()).collect(); + assert_eq!(replies, ["assistant text 3"], "{newest:?}"); } #[test] diff --git a/crates/plannotator-tui-hosts/tests/commandcode.rs b/crates/plannotator-tui-hosts/tests/commandcode.rs index e38f62a..e3e63c9 100644 --- a/crates/plannotator-tui-hosts/tests/commandcode.rs +++ b/crates/plannotator-tui-hosts/tests/commandcode.rs @@ -11,6 +11,7 @@ const FIXTURE_ID: &str = "0198f0a1-7c2e-7b10-9a4d-3c5e8f1a2b3c"; const OLD: &str = "11111111-1111-4111-8111-111111111111"; const NEW: &str = "22222222-2222-4222-8222-222222222222"; const EMPTY: &str = "33333333-3333-4333-8333-333333333333"; +const PROMPT_ONLY: &str = "44444444-4444-4444-8444-444444444444"; fn fixtures() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/commandcode") @@ -101,8 +102,15 @@ fn the_last_written_session_with_a_reply_wins() { session(&dir, OLD, "/w/proj", "resumed", 1_000_060); session(&dir, NEW, "/w/proj", "idle", 1_000_000); session(&dir, EMPTY, "/w/proj", "", 1_000_120); + // Just started: the prompt is written, the reply is not. Nothing here to annotate yet. + let waiting = session(&dir, PROMPT_ONLY, "/w/proj", "", 1_000_180); + let prompt = r#"{"type":"message","id":"u1","parentId":null,"message":{"role":"user","content":[{"type":"text","text":"go"}]}}"#; + let header = std::fs::read_to_string(&waiting).expect("header"); + std::fs::write(&waiting, format!("{header}{prompt}\n")).expect("prompt"); + let at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_180); + std::fs::File::options().write(true).open(&waiting).expect("open").set_modified(at).expect("mtime"); let found = commandcode::find_transcript(&root, Path::new("/w/proj")).expect("session"); - assert_eq!(newest_reply(&found), "resumed", "last write wins and a session without messages is skipped"); + assert_eq!(newest_reply(&found), "resumed", "last write wins and a session without a reply is skipped"); std::fs::remove_dir_all(&root).expect("cleanup"); } diff --git a/crates/plannotator-tui-hosts/tests/detect.rs b/crates/plannotator-tui-hosts/tests/detect.rs index 9324c04..c7d314f 100644 --- a/crates/plannotator-tui-hosts/tests/detect.rs +++ b/crates/plannotator-tui-hosts/tests/detect.rs @@ -71,6 +71,28 @@ fn antigravity_is_selected_by_herdrs_label_or_its_names() { } } +/// `agy` exports `ANTIGRAVITY_AGENT=1` and the conversation id into the shells it runs. +#[test] +fn antigravitys_shell_markers_select_it_ahead_of_markers_that_leak_into_every_shell() { + let id = "4f1c9e2a-7b3d-4c8e-9a10-2b6d8e0f1a3c"; + assert_eq!(detect_host(env(&[("ANTIGRAVITY_AGENT", "1")])).expect("host"), Host::Antigravity); + assert_eq!(detect_host(env(&[("ANTIGRAVITY_CONVERSATION_ID", id)])).expect("host"), Host::Antigravity); + assert_eq!( + detect_host(env(&[("ANTIGRAVITY_AGENT", "1"), ("GEMINI_CLI", "1"), ("OMPCODE", "1")])).expect("host"), + Host::Antigravity + ); + assert_eq!( + detect_host(env(&[("ANTIGRAVITY_AGENT", " ")])).expect("host"), + Host::ClaudeCode, + "blank is unset" + ); + assert_eq!( + detect_host(env(&[("PLANNOTATOR_TUI_HOST", "codex"), ("ANTIGRAVITY_AGENT", "1")])).expect("host"), + Host::Codex, + "the override still wins" + ); +} + #[test] fn the_copilot_marker_selects_copilot_and_droid_needs_the_override() { assert_eq!(detect_host(env(&[("COPILOT_CLI", "1")])).expect("host"), Host::Copilot); diff --git a/crates/plannotator-tui-hosts/tests/hermes.rs b/crates/plannotator-tui-hosts/tests/hermes.rs index 4be482f..3a29e1d 100644 --- a/crates/plannotator-tui-hosts/tests/hermes.rs +++ b/crates/plannotator-tui-hosts/tests/hermes.rs @@ -76,6 +76,16 @@ fn newest_active_assistant_reply_comes_first_and_n_is_honoured() { assert_eq!(messages[3].role, Role::Human); assert_eq!(messages[0].at.as_deref(), Some("1970-01-01T00:05:00.000Z")); assert_eq!(hermes::messages_for_session(&db, "s1", 2).expect("two").len(), 2); + // A prompt still waiting for its reply does not use up the one reply asked for. + writer + .execute( + "INSERT INTO messages (session_id, role, content, timestamp) VALUES ('s1', 'user', 'and now?', 600.0)", + [], + ) + .expect("prompt"); + let waiting = hermes::messages_for_session(&db, "s1", 1).expect("one reply"); + let texts: Vec<&str> = waiting.iter().map(|m| m.text.as_str()).collect(); + assert_eq!(texts, ["and now?", "newest reply"]); // Windows cannot delete a database another handle still holds open. drop(writer); std::fs::remove_dir_all(&dir).expect("cleanup"); diff --git a/crates/plannotator-tui-hosts/tests/opencode.rs b/crates/plannotator-tui-hosts/tests/opencode.rs index eedddd6..43337e8 100644 --- a/crates/plannotator-tui-hosts/tests/opencode.rs +++ b/crates/plannotator-tui-hosts/tests/opencode.rs @@ -280,9 +280,10 @@ fn text_parts_join_in_order_and_injected_or_empty_ones_are_skipped() { assert_eq!(messages[0].id, "msg_5"); assert_eq!(messages[0].at.as_deref(), Some("1970-01-01T00:00:00.050Z")); assert_eq!(messages[1].role, Role::Human); - // n counts messages with text; the aborted assistant turn does not consume a slot. + // n counts replies with text; the prompt between them and the aborted assistant turn + // do not consume a slot. let two = opencode::messages_for_session(&db, "ses_main", Schema::V1, 2).expect("two"); - assert_eq!(two.iter().map(|m| m.id.as_str()).collect::>(), ["msg_5", "msg_4"]); + assert_eq!(two.iter().map(|m| m.id.as_str()).collect::>(), ["msg_5", "msg_4", "msg_2"]); match opencode::messages_for_session(&db, "ses_empty", Schema::V1, 25) { Err(HostError::NoMessages(msg)) => assert!(msg.contains("ses_empty"), "{msg}"), other => panic!("expected NoMessages, got {other:?}"), diff --git a/crates/plannotator-tui-hosts/tests/pi.rs b/crates/plannotator-tui-hosts/tests/pi.rs index cfcb4eb..0458ab8 100644 --- a/crates/plannotator-tui-hosts/tests/pi.rs +++ b/crates/plannotator-tui-hosts/tests/pi.rs @@ -90,9 +90,10 @@ fn an_unreconstructable_chain_yields_nothing_rather_than_the_wrong_messages() { } #[test] -fn n_caps_the_total_and_numeric_timestamps_become_iso() { +fn n_caps_the_replies_and_numeric_timestamps_become_iso() { let messages = parse_messages(&fixture("pi.jsonl"), 2); - assert_eq!(messages.len(), 2); + assert_eq!(messages.iter().filter(|m| m.role == Role::Assistant).count(), 2); + assert_eq!(messages.last().map(|m| m.role), Some(Role::Assistant), "nothing older than the 2nd reply"); let line = r#"{"type":"message","id":"a1","parentId":null,"timestamp":1787938040000,"message":{"role":"assistant","content":[{"type":"text","text":"x"}]}}"#; assert_eq!(parse_messages(line, 1)[0].at.as_deref(), Some("2026-08-28T17:27:20.000Z")); let string_content = r#"{"type":"message","id":"a2","parentId":null,"timestamp":"t","message":{"role":"assistant","content":"plain string"}}"#; diff --git a/crates/plannotator-tui/src/herdr/launch.rs b/crates/plannotator-tui/src/herdr/launch.rs index d56479e..be3975c 100644 --- a/crates/plannotator-tui/src/herdr/launch.rs +++ b/crates/plannotator-tui/src/herdr/launch.rs @@ -114,13 +114,23 @@ pub(crate) fn agent_pid(process_info_json: &str) -> Option<(u32, String)> { let leader = info.get("foreground_process_group_id").and_then(serde_json::Value::as_u64)?; let process = processes.iter().find(|p| pid_of(p) == Some(leader as u32))?; let name = name_of(process); - let host = name.rsplit('/').next().unwrap_or(&name).to_owned(); + let host = program_name(&name).to_owned(); pid_of(process).map(|pid| (pid, if host.is_empty() { "claude".to_owned() } else { host })) } +/// A process name without its directory (`/` or `\`) or Windows `.exe` suffix: +/// `C:\tools\agy.exe` and `/usr/bin/agy` are both `agy`. +fn program_name(name: &str) -> &str { + let base = name.rsplit(['/', '\\']).next().unwrap_or(name); + match base.rsplit_once('.') { + Some((stem, extension)) if extension.eq_ignore_ascii_case("exe") => stem, + _ => base, + } +} + /// Agents whose transcripts the hosts crate can read, by process name. fn known_host(name: &str) -> Option<&'static str> { - match name.rsplit('/').next().unwrap_or(name) { + match program_name(name) { "claude" => Some("claude"), "codex" => Some("codex"), "pi" => Some("pi"), diff --git a/crates/plannotator-tui/src/herdr/launch/tests.rs b/crates/plannotator-tui/src/herdr/launch/tests.rs index 0a0fcac..e862d93 100644 --- a/crates/plannotator-tui/src/herdr/launch/tests.rs +++ b/crates/plannotator-tui/src/herdr/launch/tests.rs @@ -211,6 +211,29 @@ fn the_agent_pid_is_the_named_agent_process_else_the_group_leader() { assert_eq!(agent_pid("{}"), None); } +/// Windows reports `agy.exe`, sometimes with a `\`-separated path; every known agent and the +/// group leader's reported name are matched without either. +#[test] +fn windows_process_names_match_without_their_directory_or_exe_suffix() { + for (name, host) in [ + ("agy.exe", "agy"), + (r"C:\Users\me\AppData\Local\agy\agy.exe", "agy"), + ("/usr/local/bin/agy", "agy"), + ("claude.EXE", "claude"), + (r"C:\tools\codex.exe", "codex"), + ("opencode.exe", "opencode"), + ("pi.exe", "pi"), + ] { + let info = + PROCESS_INFO.replace(r#""name":"claude""#, &format!(r#""name":{}"#, serde_json::json!(name))); + assert_eq!(agent_pid(&info), Some((91279, host.into())), "{name}"); + } + let leader = PROCESS_INFO.replace(r#""name":"claude""#, r#""name":"C:\\bin\\aider.exe""#); + assert_eq!(agent_pid(&leader), Some((91279, "aider".into())), "an unknown leader is named the same way"); + let dotted = PROCESS_INFO.replace(r#""name":"claude""#, r#""name":"agy.sh""#); + assert_eq!(agent_pid(&dotted), Some((91279, "agy.sh".into())), "only .exe is a suffix to drop"); +} + #[test] fn a_last_launch_carries_the_pid_and_host_instead_of_a_file() { let context = HerdrContext { diff --git a/crates/plannotator-tui/src/last/fallback.rs b/crates/plannotator-tui/src/last/fallback.rs index aeeecb5..7f72c79 100644 --- a/crates/plannotator-tui/src/last/fallback.rs +++ b/crates/plannotator-tui/src/last/fallback.rs @@ -17,9 +17,9 @@ use super::roots::Roots; /// How a transcript was chosen, so the UI can say when nothing identified it exactly. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Discovery { - /// An explicit path, an exact session id, a thread id, the session registered for the - /// agent's pid, or the file the agent process itself has open: this transcript is the - /// session, not a guess. + /// An explicit path, an exact session id, a thread or conversation id, the session + /// registered for the agent's pid, or the file the agent process itself has open: this + /// transcript is the session, not a guess. Exact, /// The newest transcript filed under the agent's working directory. Sessions that share /// one directory are indistinguishable here. @@ -74,6 +74,14 @@ pub(super) fn read( Ok((path, messages, Discovery::Folder)) } Host::Antigravity => { + // Inside an `agy` shell its exported conversation id is exact, as Codex's thread id is. + let conversation = + std::env::var(antigravity::CONVERSATION_ENV).ok().filter(|id| !id.trim().is_empty()); + if let Some(id) = conversation { + let session = exact::resolve(host, &id, cwd, roots)?; + let (path, messages) = readers::exact(host, &id, session, pick)?; + return Ok((path, messages, Discovery::Exact)); + } let path = antigravity::find_transcript(&roots.antigravity_home, cwd)?; let messages = readers::antigravity_messages(&path, pick)?; Ok((path, messages, Discovery::Folder)) diff --git a/crates/plannotator-tui/tests/last.rs b/crates/plannotator-tui/tests/last.rs index 39462de..f6b931f 100644 --- a/crates/plannotator-tui/tests/last.rs +++ b/crates/plannotator-tui/tests/last.rs @@ -340,6 +340,55 @@ fn antigravity_reads_the_conversation_herdr_names_or_the_one_started_in_the_cwd( std::fs::remove_dir_all(&app).expect("cleanup"); } +/// Run from a shell `agy` started, its exported conversation id picks the transcript exactly, +/// with no host named and a newer conversation in the same folder. +#[test] +fn inside_an_agy_shell_the_exported_conversation_id_is_read() { + let id = "4f1c9e2a-7b3d-4c8e-9a10-2b6d8e0f1a3c"; + let newer = "22222222-2222-4222-8222-222222222222"; + let app = temp_dir("agy shell"); + let relative = |id: &str| format!("brain/{id}/.system_generated/logs/transcript_full.jsonl"); + let target = app.join(relative(id)); + std::fs::create_dir_all(target.parent().expect("logs")).expect("logs dir"); + std::fs::copy(fixtures().join("antigravity").join(relative(id)), &target).expect("copy"); + let other = app.join(relative(newer)); + std::fs::create_dir_all(other.parent().expect("logs")).expect("logs dir"); + std::fs::write(&other, "{\"step_index\":1,\"type\":\"PLANNER_RESPONSE\",\"content\":\"NEWER\"}\n") + .expect("newer"); + std::fs::write( + app.join("history.jsonl"), + format!( + "{{\"workspace\":\"/w/proj\",\"conversationId\":\"{id}\"}}\n{{\"workspace\":\"/w/proj\",\"conversationId\":\"{newer}\"}}\n" + ), + ) + .expect("history"); + let run = |conversation: &str| { + bin() + .env_remove("PLANNOTATOR_TUI_HOST") + .env_remove("CODEX_THREAD_ID") + .env_remove("COPILOT_CLI") + .env_remove("ANTIGRAVITY_CLI_HOME") + .env("ANTIGRAVITY_APP_DATA_DIR", &app) + .env("ANTIGRAVITY_AGENT", "1") + .env("ANTIGRAVITY_CONVERSATION_ID", conversation) + .env("PLANNOTATOR_TUI_CWD", "/w/proj") + .args(["last", "--print"]) + .output() + .expect("runs") + }; + let exact = run(id); + assert_eq!( + String::from_utf8_lossy(&exact.stdout).trim_end(), + "Done: both settings are applied in `retroarch.cfg`.", + "{}", + String::from_utf8_lossy(&exact.stderr) + ); + let missing = run("33333333-3333-4333-8333-333333333333"); + assert_eq!(String::from_utf8_lossy(&missing.stdout), "", "an unknown id never falls back to the folder"); + assert!(String::from_utf8_lossy(&missing.stderr).contains("no Antigravity conversation 33333333")); + std::fs::remove_dir_all(&app).expect("cleanup"); +} + #[test] fn hermes_reads_the_session_named_by_id_from_hermes_home() { let home = std::env::temp_dir().join(format!("plannotator-tui-hermes-home-{}", std::process::id())); diff --git a/docs/decisions.md b/docs/decisions.md index 850d95c..b52732c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -100,7 +100,8 @@ reference for `plannotator-tui-hosts`; knowledge copied, not code. **Host detection is an env-var chain, then a fallback.** `PLANNOTATOR_ORIGIN` override (validated) > `CODEX_THREAD_ID` > `COPILOT_CLI` > `OPENCODE` > `GEMINI_CLI` > `OMPCODE` (last: OMP exports it into every shell it spawns) > default Claude Code. We mirror it with -`PLANNOTATOR_TUI_HOST` as the override. `cwd` is never identity. +`PLANNOTATOR_TUI_HOST` as the override, and add `ANTIGRAVITY_AGENT` / `ANTIGRAVITY_CONVERSATION_ID` +(exported into `agy`'s shells) right after `COPILOT_CLI`. `cwd` is never identity. **Claude Code session resolution — the deterministic part.** Claude Code writes `~/.claude/sessions/.json` per running session: `{pid, sessionId, cwd, startedAt, …}`