diff --git a/CHANGELOG.md b/CHANGELOG.md index a402dc1..94624bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ limit while another key's limit is used up. A key's limit stops only that key, as the status line already took it, but the notification was dropped as if the whole account were still stopped. +- **Handoff:** a read by OpenCode's `read` or Claude Code's `NotebookRead` + is named by its file, not by its input as JSON. So is a failed OpenCode + `edit` or `write`, or Claude Code's `NotebookEdit`, under "What failed", + and it no longer shows there once a later one of the same file passed, + as a failed Claude Code `Edit` already didn't. ## 0.1.4 diff --git a/src/sessions/handoff.rs b/src/sessions/handoff.rs index 84067e0..7de7aff 100644 --- a/src/sessions/handoff.rs +++ b/src/sessions/handoff.rs @@ -312,6 +312,9 @@ fn tasks(entries: &[Entry], share: usize) -> String { ) } +/// The keys a tool's input names a file under, as each agent's tools do. +const PATHS: [&str; 4] = ["file_path", "filePath", "notebook_path", "path"]; + /// The files the work was in from `since` on, as a handoff's section: those /// its tools read or changed, and those its commands named that are in its /// folder `cwd`, the latest first, with how often. Up to 15, within @@ -355,7 +358,7 @@ fn files(entries: &[Entry], since: usize, cwd: Option<&str>, share: usize) -> St let Ok(input) = serde_json::from_str::(&tool.input) else { continue; }; - for key in ["file_path", "filePath", "notebook_path", "path"] { + for key in PATHS { if let Some(file) = input[key].as_str() { let changed = tool.files.iter().any(|known| known == file); if !changed { @@ -643,29 +646,21 @@ fn gist(tool: &Tool) -> String { let said = serde_json::from_str::(&tool.input) .ok() .and_then(|input| { - [ - "description", - "command", - "cmd", - "file_path", - "path", - "pattern", - "query", - "url", - "prompt", - ] - .iter() - .find_map(|key| match input.get(key)? { - serde_json::Value::String(text) => Some(text.clone()), - serde_json::Value::Array(words) => Some( - words - .iter() - .filter_map(|word| word.as_str()) - .collect::>() - .join(" "), - ), - _ => None, - }) + ["description", "command", "cmd"] + .iter() + .chain(&PATHS) + .chain(&["pattern", "query", "url", "prompt"]) + .find_map(|key| match input.get(key)? { + serde_json::Value::String(text) => Some(text.clone()), + serde_json::Value::Array(words) => Some( + words + .iter() + .filter_map(|word| word.as_str()) + .collect::>() + .join(" "), + ), + _ => None, + }) }) .unwrap_or_else(|| match commands(tool).as_slice() { [] => tool.input.clone(), @@ -1114,6 +1109,38 @@ mod tests { } } + /// A read is named by its path under whichever key its agent's tool + /// takes it: OpenCode's `read` takes `filePath`, Claude Code's + /// `NotebookRead` `notebook_path`. + #[test] + fn a_read_is_named_by_its_path() { + for (name, key) in [("read", "filePath"), ("NotebookRead", "notebook_path")] { + let entries = [ + said(Kind::User, 0, "Fix the CSV parser."), + said(Kind::Assistant, 1, "Reading the parser."), + call( + name, + serde_json::json!({ key: "src/csv.rs" }), + "", + false, + Role::Read, + ), + ]; + let handoff = brief( + &session("CSV", 2 * MINUTE), + &entries, + BUDGET, + Cite::Tool, + 3 * MINUTE, + ); + let line = format!("#2 tool `{name}`: \"src/csv.rs\""); + assert!( + handoff.lines().any(|shown| shown == line), + "{line} is missing:\n{handoff}" + ); + } + } + #[test] fn a_session_of_one_entry_says_entry() { let entries = [said(Kind::User, 0, "Fix the CSV parser.")];