use serde_json::Value;
use super::alias::{AliasTable, Resolved};
pub fn prompt_of(data: &Value) -> Option<&str> {
field(data, &["prompt"])
.and_then(Value::as_str)
.and_then(non_empty)
}
pub fn tool_name_of(data: &Value) -> Option<&str> {
field(data, &["tool_name", "toolName"])
.and_then(Value::as_str)
.and_then(non_empty)
}
pub const TOOL_INPUT_KEYS: &[&str] = &["tool_input", "parameters"];
pub const TOOL_RESULT_KEYS: &[&str] = &["tool_result", "result", "tool_response", "tool_output"];
pub fn tool_input_of(data: &Value) -> Option<&Value> {
field(data, TOOL_INPUT_KEYS)
}
pub fn tool_ok_of(data: &Value) -> Option<bool> {
if let Some(success) = field(data, &["success"]).and_then(Value::as_bool) {
return Some(success);
}
tool_result_of(data)?
.get("is_error")
.and_then(Value::as_bool)
.map(|is_error| !is_error)
}
pub fn duration_ms_of(data: &Value) -> Option<u64> {
field(data, &["duration_ms", "durationMs", "executionTimeMs"])?.as_u64()
}
pub fn native_event_of(data: &Value) -> Option<&str> {
field(data, &["hook_event_name", "hookName"])
.and_then(Value::as_str)
.and_then(non_empty)
}
pub fn turn_id_of(data: &Value) -> Option<&str> {
field(data, &["turn_id", "turnId", "generation_id"])
.and_then(Value::as_str)
.and_then(non_empty)
}
pub fn agent_version_of(data: &Value) -> Option<&str> {
field(data, &["agent_version", "clineVersion", "cursor_version"])
.and_then(Value::as_str)
.and_then(non_empty)
}
pub fn tool_result_of(data: &Value) -> Option<&Value> {
field(data, TOOL_RESULT_KEYS)
}
pub fn tool_use_id_of(data: &Value) -> Option<&str> {
field(data, &["tool_use_id", "toolUseId", "toolCallId"])
.or_else(|| data.pointer("/tool_call/id"))
.or_else(|| data.pointer("/tool_result/id"))
.and_then(Value::as_str)
.and_then(non_empty)
}
const TEXT_PARAMS: &[&str] = &[
"command",
"path",
"file_path",
"content",
"diff",
"patch",
"old_text",
"new_text",
"regex",
"file_pattern",
"url",
"uri",
"prompt",
"query",
"question",
"response",
"result",
"text",
"input",
"server_name",
"tool_name",
];
fn reparse_params(params: &Value) -> Value {
let Some(object) = params.as_object() else {
return params.clone();
};
let reparsed = object
.iter()
.map(|(key, value)| {
let parsed = match value.as_str() {
Some(raw) if !TEXT_PARAMS.contains(&key.as_str()) => {
let trimmed = raw.trim_start();
if trimmed.starts_with('{') || trimmed.starts_with('[') {
serde_json::from_str::<Value>(raw)
.ok()
.filter(|v| v.is_object() || v.is_array())
} else {
None
}
}
_ => None,
};
(key.clone(), parsed.unwrap_or_else(|| value.clone()))
})
.collect();
Value::Object(reparsed)
}
fn is_camel_tool_shape(data: &Value) -> bool {
matches!(
located(data, &["tool_name", "toolName"]),
Some((pointer, _)) if pointer.ends_with("/toolName")
)
}
fn camel_tool_input(data: &Value) -> Option<Value> {
["/tool_call/input", "/tool_result/input"]
.iter()
.find_map(|pointer| data.pointer(pointer).filter(|v| v.is_object()))
.cloned()
.or_else(|| tool_input_of(data).map(reparse_params))
}
pub type ToolCall = Resolved;
pub fn tool_call_of(data: &Value, table: Option<&AliasTable>) -> Option<ToolCall> {
let input = if is_camel_tool_shape(data) {
camel_tool_input(data)
} else {
tool_input_of(data).cloned()
};
let Some(table) = table else {
let name = tool_name_of(data)?;
return Some(ToolCall {
name: name.to_string(),
input,
native: name.to_string(),
mapped: true,
});
};
let server = table
.mcp_rule
.server_field
.and_then(|key| field(data, &[key]))
.and_then(Value::as_str)
.and_then(non_empty);
match tool_name_of(data) {
Some(native) => Some(table.resolve(native, input, server)),
None => table.resolve_event(native_event_of(data)?, data),
}
}
pub fn common_tool_of(data: &Value, table: Option<&AliasTable>) -> Option<(String, Option<Value>)> {
tool_call_of(data, table).map(|call| (call.name, call.input))
}
pub fn is_cursor_shape(data: &Value) -> bool {
data.get("cursor_version").is_some_and(Value::is_string)
}
pub fn agent_surface_of(data: &Value) -> Option<&'static str> {
let version = data.get("cursor_version")?.as_str()?;
let numbers = |part: &str, len: Option<usize>| {
!part.is_empty()
&& part.bytes().all(|b| b.is_ascii_digit())
&& len.is_none_or(|len| part.len() == len)
};
let dated = version.split_once('-').is_some_and(|(date, _)| {
let parts: Vec<&str> = date.split('.').collect();
parts.len() == 3
&& numbers(parts[0], Some(4))
&& numbers(parts[1], Some(2))
&& numbers(parts[2], Some(2))
});
let parts: Vec<&str> = version.split('.').collect();
let semver = matches!(parts.len(), 2 | 3) && parts.iter().all(|part| numbers(part, None));
Some(if dated {
"cli"
} else if semver {
"ide"
} else {
"unknown"
})
}
fn encoding_suspect(data: &Value, windows: bool) -> bool {
windows
&& is_cursor_shape(data)
&& [
data.get("command"),
data.pointer("/tool_input/command"),
data.get("prompt"),
]
.into_iter()
.flatten()
.filter_map(Value::as_str)
.any(|text| !text.is_ascii())
}
pub fn common_tool_response_of(data: &Value) -> Option<Value> {
if !is_camel_tool_shape(data) {
return tool_result_of(data).cloned();
}
let content = field(data, &["result"]).cloned();
let ok = field(data, &["success"]).and_then(Value::as_bool);
if content.is_none() && ok.is_none() {
return None;
}
let mut response = serde_json::Map::new();
response.insert("content".to_string(), content.unwrap_or(Value::Null));
if let Some(ok) = ok {
response.insert("is_error".to_string(), Value::Bool(!ok));
}
Some(Value::Object(response))
}
pub fn common_tool_use_id_of(data: &Value) -> Option<String> {
use sha2::{Digest, Sha256};
if let Some(id) = tool_use_id_of(data) {
return Some(id.to_string());
}
if !is_camel_tool_shape(data) {
return None;
}
let (name, input) = common_tool_of(data, None)?;
let key = Value::Array(vec![
Value::String(session_id_of(data).unwrap_or_default().to_string()),
Value::String(name),
input.unwrap_or(Value::Null),
]);
let canonical = serde_json_canonicalizer::to_string(&key).ok()?;
let digest = Sha256::digest(canonical.as_bytes());
let hex: String = digest[..12].iter().map(|b| format!("{b:02x}")).collect();
Some(format!("ol_{hex}"))
}
pub fn cwd_of(data: &Value) -> Option<&str> {
if let Some(cwd) = field(data, &["cwd"])
.and_then(Value::as_str)
.and_then(non_empty)
{
return Some(cwd);
}
field(data, &["workspaceRoots", "workspace_roots"])?
.as_array()?
.first()?
.as_str()
.and_then(non_empty)
}
pub fn declared_workspace_root_of(data: &Value) -> Option<&str> {
field(data, &["workspaceRoots"])?
.as_array()?
.first()?
.as_str()
.and_then(non_empty)
}
pub fn session_id_of(data: &Value) -> Option<&str> {
field(
data,
&["session_id", "sessionId", "taskId", "conversation_id"],
)
.and_then(Value::as_str)
.and_then(non_empty)
}
pub fn prompt_and_mode(data: &Value) -> (Option<&str>, Option<&str>) {
let Some(raw) = prompt_of(data) else {
return (None, None);
};
let Some(rest) = raw.strip_prefix("<user_input") else {
return (Some(raw), None);
};
let Some((attrs, body)) = rest.split_once('>') else {
return (Some(raw), None);
};
let Some(inner) = body.strip_suffix("</user_input>") else {
return (Some(raw), None);
};
let mode = attrs
.split_once("mode=\"")
.and_then(|(_, m)| m.split_once('"'))
.map(|(m, _)| m)
.and_then(non_empty);
(non_empty(inner.trim()).or(Some(raw)), mode)
}
pub const ENRICHMENT_PREFIX: &str = "openlatch.";
pub fn to_common(data: &Value, table: Option<&AliasTable>, binding: &str) -> Option<Value> {
let mut common = serde_json::Map::new();
let (prompt, mode) = prompt_and_mode(data);
let mut put = |key: &str, value: Option<Value>| {
if let Some(value) = value {
common.insert(key.to_string(), value);
}
};
let text = |v: Option<&str>| v.map(|s| Value::String(s.to_string()));
put("hook_event_name", text(native_event_of(data)));
put("session_id", text(session_id_of(data)));
put("cwd", text(cwd_of(data)));
put("prompt", text(prompt));
match tool_call_of(data, table) {
Some(call) => {
put("tool_name", Some(Value::String(call.key(binding))));
put("tool_input", call.input);
}
None => put("tool_input", tool_input_of(data).cloned()),
}
put("tool_response", common_tool_response_of(data));
put(
"tool_use_id",
common_tool_use_id_of(data).map(Value::String),
);
put("turn_id", text(turn_id_of(data)));
put("agent_version", text(agent_version_of(data)));
put("duration_ms", duration_ms_of(data).map(Value::from));
put("model", data.get("model").cloned());
put("agent_surface", text(agent_surface_of(data)));
if encoding_suspect(data, cfg!(windows)) {
put("encoding_suspect", Some(Value::Bool(true)));
}
if mode.is_none() && common.iter().all(|(k, v)| data.get(k) == Some(v)) {
return None;
}
let mut raw = data.clone();
if let Some(obj) = raw.as_object_mut() {
let ours: Vec<String> = obj
.keys()
.filter(|key| key.starts_with(ENRICHMENT_PREFIX))
.cloned()
.collect();
for key in ours {
if let Some(value) = obj.remove(&key) {
common.insert(key, value);
}
}
}
if let (Some(mode), Some(obj)) = (mode, raw.as_object_mut()) {
obj.insert("mode".to_string(), Value::String(mode.to_string()));
}
common.insert("raw_agent_payload".to_string(), raw);
Some(Value::Object(common))
}
fn field<'a>(data: &'a Value, keys: &[&str]) -> Option<&'a Value> {
located(data, keys).map(|(_, value)| value)
}
pub fn pointer_to(data: &Value, keys: &[&str]) -> Option<String> {
located(data, keys).map(|(pointer, _)| pointer)
}
fn located<'a>(data: &'a Value, keys: &[&str]) -> Option<(String, &'a Value)> {
for key in keys {
if let Some(found) = data.get(key) {
return Some((format!("/{}", escape(key)), found));
}
}
let object = data.as_object()?;
keys.iter().find_map(|key| {
object
.iter()
.filter(|(outer, _)| !is_argument_container(outer))
.find_map(|(outer, nested)| {
nested
.get(key)
.map(|found| (format!("/{}/{}", escape(outer), escape(key)), found))
})
})
}
fn is_argument_container(key: &str) -> bool {
TOOL_INPUT_KEYS.contains(&key) || TOOL_RESULT_KEYS.contains(&key)
}
fn escape(token: &str) -> String {
token.replace('~', "~0").replace('/', "~1")
}
pub fn model_of(data: &Value) -> (Option<&str>, Option<&str>) {
let Some(model) = data.get("model") else {
return (None, None);
};
if let Some(slug) = model.as_str() {
return (None, non_empty(slug));
}
(
model
.get("provider")
.and_then(Value::as_str)
.and_then(non_empty),
model
.get("slug")
.and_then(Value::as_str)
.and_then(non_empty),
)
}
fn non_empty(s: &str) -> Option<&str> {
let t = s.trim();
(!t.is_empty()).then_some(s)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hooks::bindings::{claude_code, cline as cline_binding, cursor as cursor_binding};
fn claude() -> Option<&'static AliasTable> {
Some(&claude_code::ALIASES)
}
fn cline() -> Option<&'static AliasTable> {
Some(&cline_binding::ALIASES)
}
fn cursor() -> Option<&'static AliasTable> {
Some(&cursor_binding::ALIASES)
}
use serde_json::json;
#[test]
fn the_prompt_is_found_by_shape_across_agents() {
assert_eq!(
prompt_of(&json!({"prompt": "hello", "session_id": "abc"})),
Some("hello")
);
assert_eq!(
prompt_of(&json!({
"hookName": "UserPromptSubmit",
"userPromptSubmit": {"prompt": "hey there !!"}
})),
Some("hey there !!")
);
assert_eq!(
prompt_of(&json!({"someFutureHook": {"prompt": "p"}})),
Some("p")
);
assert_eq!(prompt_of(&json!({"toolName": "Bash"})), None);
assert_eq!(prompt_of(&json!({"prompt": " "})), None);
}
#[test]
fn clines_nested_hook_shape_reads_the_same_as_a_flat_one() {
let nested = json!({
"taskId": "conv_1789665246332_em78z12",
"hookName": "PreToolUse",
"clineVersion": "4.1.17",
"workspaceRoots": ["/work/scratch"],
"preToolUse": {
"toolName": "execute_command",
"parameters": {"command": "rm -rf /tmp"}
}
});
assert_eq!(tool_name_of(&nested), Some("execute_command"));
assert_eq!(
tool_input_of(&nested),
Some(&json!({"command": "rm -rf /tmp"}))
);
assert_eq!(cwd_of(&nested), Some("/work/scratch"));
let post = json!({
"taskId": "conv_1",
"postToolUse": {"toolName": "execute_command", "result": "ok", "success": true}
});
assert_eq!(tool_result_of(&post), Some(&json!("ok")));
let flat = json!({"toolName": "execute_command", "parameters": {"command": "ls"}});
assert_eq!(tool_name_of(&flat), Some("execute_command"));
assert_eq!(tool_input_of(&flat), Some(&json!({"command": "ls"})));
}
#[test]
fn the_native_spelling_outranks_every_nested_one() {
let native = json!({
"session_id": "sess_a",
"cwd": "/repo",
"tool_name": "Bash",
"tool_input": {"command": "cargo test"},
"tool_use_id": "toolu_01",
"nested": {"tool_name": "NotThis", "tool_input": {"command": "nor this"}}
});
assert_eq!(tool_name_of(&native), Some("Bash"));
assert_eq!(
tool_input_of(&native),
Some(&json!({"command": "cargo test"}))
);
assert_eq!(tool_use_id_of(&native), Some("toolu_01"));
assert_eq!(cwd_of(&native), Some("/repo"));
let empty = json!({"hookName": "Stop", "taskId": "conv_1"});
assert_eq!(tool_name_of(&empty), None);
assert_eq!(tool_input_of(&empty), None);
assert_eq!(tool_result_of(&empty), None);
assert_eq!(tool_use_id_of(&empty), None);
assert_eq!(cwd_of(&empty), None);
}
#[test]
fn a_pointer_resolves_to_the_value_the_reader_returns() {
for data in [
json!({"tool_input": {"command": "cargo test"}}),
json!({"preToolUse": {"parameters": {"command": "cargo test"}}}),
json!({"parameters": {"command": "cargo test"}}),
] {
let pointer = pointer_to(&data, TOOL_INPUT_KEYS).expect("a pointer");
assert_eq!(
data.pointer(&pointer),
tool_input_of(&data),
"pointer {pointer} and reader disagree for {data}"
);
assert_eq!(
data.pointer(&pointer).and_then(|v| v.get("command")),
Some(&json!("cargo test"))
);
}
assert_eq!(pointer_to(&json!({"prompt": "hi"}), TOOL_INPUT_KEYS), None);
}
#[test]
fn pointer_tokens_are_escaped() {
let data = json!({"a/b": {"tool_input": 1}, "c~d": {"tool_input": 2}});
let pointer = pointer_to(&data, TOOL_INPUT_KEYS).expect("a pointer");
assert!(
pointer == "/a~1b/tool_input" || pointer == "/c~0d/tool_input",
"unexpected pointer: {pointer}"
);
assert!(data.pointer(&pointer).is_some(), "{pointer} must resolve");
}
#[test]
fn the_remaining_facts_read_across_agents() {
let cline_post = json!({
"taskId": "conv_1",
"hookName": "PostToolUse",
"clineVersion": "4.1.17",
"postToolUse": {
"toolName": "execute_command",
"result": "done",
"success": false,
"executionTimeMs": 1234
}
});
assert_eq!(tool_ok_of(&cline_post), Some(false));
assert_eq!(duration_ms_of(&cline_post), Some(1234));
assert_eq!(native_event_of(&cline_post), Some("PostToolUse"));
assert_eq!(agent_version_of(&cline_post), Some("4.1.17"));
assert_eq!(turn_id_of(&cline_post), None);
let claude_post = json!({
"hook_event_name": "PostToolUse",
"tool_name": "Bash",
"tool_response": {"is_error": true, "stdout": ""}
});
assert_eq!(tool_ok_of(&claude_post), Some(false));
assert_eq!(
tool_ok_of(&json!({"tool_response": {"is_error": false}})),
Some(true)
);
let codex = json!({"session_id": "s", "turn_id": "t_1", "hook_event_name": "PreToolUse"});
assert_eq!(turn_id_of(&codex), Some("t_1"));
assert_eq!(tool_ok_of(&json!({"tool_name": "Bash"})), None);
assert_eq!(duration_ms_of(&json!({"tool_name": "Bash"})), None);
assert_eq!(agent_version_of(&json!({"tool_name": "Bash"})), None);
}
#[test]
fn the_probe_never_descends_past_one_level() {
let deep = json!({"a": {"b": {"prompt": "buried", "tool_name": "Buried"}}});
assert_eq!(prompt_of(&deep), None);
assert_eq!(tool_name_of(&deep), None);
}
#[test]
fn the_model_is_read_without_inventing_the_missing_half() {
assert_eq!(
model_of(&json!({"model": {"provider": "ollama", "slug": "qwen2.5-coder:7b"}})),
(Some("ollama"), Some("qwen2.5-coder:7b"))
);
assert_eq!(
model_of(&json!({"model": "claude-opus-5"})),
(None, Some("claude-opus-5"))
);
assert_eq!(
model_of(&json!({"model": {"provider": "unknown", "slug": "unknown"}})),
(Some("unknown"), Some("unknown"))
);
assert_eq!(model_of(&json!({})), (None, None));
}
#[test]
fn clines_wrapper_is_removed_and_its_mode_kept() {
let data = json!({
"taskId": "conv_1",
"hookName": "UserPromptSubmit",
"userPromptSubmit": {"prompt": "<user_input mode=\"act\">hey y</user_input>"}
});
assert_eq!(prompt_and_mode(&data), (Some("hey y"), Some("act")));
let mapped =
to_common(&data, cline(), "cline_hook").expect("a wrapped prompt is a rewrite");
assert_eq!(mapped["prompt"], "hey y");
assert_eq!(mapped["raw_agent_payload"]["mode"], "act");
assert_eq!(
mapped["raw_agent_payload"]["userPromptSubmit"]["prompt"],
"<user_input mode=\"act\">hey y</user_input>"
);
}
#[test]
fn a_prompt_that_is_not_wrapped_is_untouched() {
for raw in [
"just a prompt",
"<user_input mode=\"act\">unterminated",
"look at <user_input mode=\"act\">this</user_input> inline",
] {
let data = json!({"prompt": raw});
let (prompt, _) = prompt_and_mode(&data);
assert_eq!(prompt, Some(raw), "must not rewrite {raw:?}");
}
let bare = json!({"prompt": "<user_input>hi</user_input>"});
assert_eq!(prompt_and_mode(&bare), (Some("hi"), None));
}
#[test]
fn clines_tools_map_onto_the_common_vocabulary() {
let common = |tool: &str, params: Value| {
common_tool_of(
&json!({"taskId": "t", "preToolUse": {"toolName": tool, "parameters": params}}),
cline(),
)
.expect("a tool")
};
let cases = [
(
"execute_command",
json!({"command": "ls", "requires_approval": "false"}),
"Bash",
json!({"command": "ls", "requires_approval": "false"}),
),
(
"run_commands",
json!({"commands": "[\"ls\",\"pwd\"]"}),
"Bash",
json!({"command": "ls\npwd"}),
),
(
"read_file",
json!({"path": "src/a.rs"}),
"Read",
json!({"file_path": "src/a.rs"}),
),
(
"write_to_file",
json!({"path": "p.json", "content": "{\"a\":1}"}),
"Write",
json!({"file_path": "p.json", "content": "{\"a\":1}"}),
),
(
"replace_in_file",
json!({"path": "a.rs", "diff": "---"}),
"Edit",
json!({"file_path": "a.rs", "diff": "---"}),
),
(
"apply_diff",
json!({"path": "a.rs", "diff": "---"}),
"Edit",
json!({"file_path": "a.rs", "diff": "---"}),
),
(
"search_files",
json!({"path": "src", "regex": "fn main", "file_pattern": "*.rs"}),
"Grep",
json!({"path": "src", "pattern": "fn main", "glob": "*.rs"}),
),
(
"list_files",
json!({"path": "src", "recursive": "true"}),
"Glob",
json!({"path": "src", "recursive": "true"}),
),
(
"use_mcp_tool",
json!({"server_name": "github", "tool_name": "create_issue", "arguments": "{\"title\":\"x\"}"}),
"mcp__github__create_issue",
json!({"title": "x"}),
),
(
"access_mcp_resource",
json!({"server_name": "docs", "uri": "file:///a"}),
"access_mcp_resource",
json!({"server_name": "docs", "uri": "file:///a"}),
),
(
"web_fetch",
json!({"url": "https://example.com"}),
"WebFetch",
json!({"url": "https://example.com"}),
),
(
"editor",
json!({"path": "new.rs", "new_text": "fn x() {}"}),
"Write",
json!({"file_path": "new.rs", "content": "fn x() {}"}),
),
(
"editor",
json!({"path": "a.rs", "old_text": "a", "new_text": "b"}),
"Edit",
json!({"file_path": "a.rs", "old_string": "a", "new_string": "b"}),
),
(
"read_files",
json!({"files": [{"path": "a.rs"}, {"path": "b.rs"}]}),
"Read",
json!({"file_path": ["a.rs", "b.rs"], "files": [{"path": "a.rs"}, {"path": "b.rs"}]}),
),
(
"fetch_web_content",
json!({"requests": [{"url": "https://e.com", "prompt": "p"}]}),
"WebFetch",
json!({"requests": [{"url": "https://e.com", "prompt": "p"}], "url": "https://e.com", "prompt": "p"}),
),
(
"search_codebase",
json!({"queries": ["fn main", "struct A"]}),
"Grep",
json!({"queries": ["fn main", "struct A"], "pattern": ["fn main", "struct A"]}),
),
(
"fetch_web_content",
json!({"requests": [{"url": "https://a.com"}, {"url": "https://b.com"}]}),
"WebFetch",
json!({"requests": [{"url": "https://a.com"}, {"url": "https://b.com"}], "url": ["https://a.com", "https://b.com"]}),
),
(
"github__create_issue",
json!({"title": "x"}),
"mcp__github__create_issue",
json!({"title": "x"}),
),
(
"browser_action",
json!({"action": "launch"}),
"browser_action",
json!({"action": "launch"}),
),
(
"attempt_completion",
json!({"result": "{\"done\":true}"}),
"attempt_completion",
json!({"result": "{\"done\":true}"}),
),
];
for (tool, params, name, input) in cases {
assert_eq!(
common(tool, params),
(name.to_string(), Some(input)),
"{tool}"
);
}
}
#[test]
fn a_tool_the_table_does_not_list_is_unmapped_not_guessed() {
let native = json!({"tool_name": "read_file", "tool_input": {"path": "a"}});
let call = tool_call_of(&native, claude()).expect("a call");
assert_eq!(call.name, "read_file");
assert_eq!(call.native, "read_file");
assert!(!call.mapped);
assert_eq!(call.input, Some(json!({"path": "a"})));
assert_eq!(common_tool_use_id_of(&native), None, "nothing derived");
let claude_payload =
json!({"tool_name": "Bash", "tool_input": {"command": "ls"}, "tool_use_id": "toolu_1"});
assert_eq!(
to_common(&claude_payload, claude(), "claude_hook"),
None,
"a native payload travels untouched"
);
let call = tool_call_of(&native, None).expect("a call");
assert_eq!((call.name.as_str(), call.mapped), ("read_file", true));
}
#[test]
fn the_sdk_record_supplies_the_input_and_the_id() {
let pre = json!({
"taskId": "conv_1",
"hookName": "tool_call",
"tool_call": {"id": "call_9", "name": "run_commands", "input": {"commands": ["ls"]}},
"preToolUse": {"toolName": "run_commands", "parameters": {"commands": "[\"ls\"]"}}
});
let post = json!({
"taskId": "conv_1",
"hookName": "tool_result",
"tool_result": {"id": "call_9", "name": "run_commands", "input": {"commands": ["ls"]}, "output": "x"},
"postToolUse": {"toolName": "run_commands", "parameters": {"commands": "[\"ls\"]"}, "result": "x", "success": true}
});
let plugin = json!({"taskId": "conv_1", "toolName": "run_commands", "parameters": {"commands": ["ls"]}, "toolCallId": "call_9"});
for data in [&pre, &post, &plugin] {
assert_eq!(
common_tool_use_id_of(data).as_deref(),
Some("call_9"),
"{data}"
);
assert_eq!(
common_tool_of(data, cline()),
Some(("Bash".to_string(), Some(json!({"command": "ls"})))),
"{data}"
);
}
assert_eq!(
common_tool_response_of(&post),
Some(json!({"content": "x", "is_error": false}))
);
let mapped = to_common(&post, cline(), "cline_hook").expect("remapped");
assert_eq!(mapped["tool_response"]["content"], "x");
assert_eq!(
mapped["raw_agent_payload"]["postToolUse"]["toolName"],
"run_commands"
);
}
#[test]
fn a_derived_id_pairs_a_pre_with_its_post() {
let pre = json!({"taskId": "t1", "preToolUse": {"toolName": "write_to_file", "parameters": {"path": "a", "content": "x"}}});
let post = json!({"taskId": "t1", "postToolUse": {"toolName": "write_to_file", "parameters": {"path": "a", "content": "x"}, "result": "ok", "success": true}});
let id = common_tool_use_id_of(&pre).expect("derived");
assert!(id.starts_with("ol_"), "{id}");
assert_eq!(common_tool_use_id_of(&post).as_deref(), Some(id.as_str()));
let other_session = json!({"taskId": "t2", "preToolUse": {"toolName": "write_to_file", "parameters": {"path": "a", "content": "x"}}});
assert_ne!(
common_tool_use_id_of(&other_session).as_deref(),
Some(id.as_str())
);
assert_eq!(tool_use_id_of(&pre), None);
}
#[test]
fn enrichment_stays_top_level_through_the_mapping() {
let enriched = json!({
"taskId": "conv_1",
"hookName": "TaskStart",
"workspaceRoots": ["/work"],
"taskStart": {"taskMetadata": {}},
"openlatch.declared_skills": [],
"openlatch.declared_mcp_sources": [{"source_id": "s"}],
"openlatch.declared_rules_hash": "abc",
"openlatch.cwd": "def"
});
let mapped =
to_common(&enriched, cline(), "cline_hook").expect("a Cline payload is remapped");
for key in [
"openlatch.declared_skills",
"openlatch.declared_mcp_sources",
"openlatch.declared_rules_hash",
"openlatch.cwd",
] {
assert_eq!(mapped[key], enriched[key], "{key} must be top-level");
assert!(
mapped["raw_agent_payload"].get(key).is_none(),
"{key} is not the agent's"
);
}
assert_eq!(mapped["raw_agent_payload"]["taskId"], "conv_1");
assert_eq!(declared_workspace_root_of(&enriched), Some("/work"));
assert_eq!(declared_workspace_root_of(&json!({"cwd": "/w"})), None);
}
#[test]
fn a_tool_argument_is_never_mistaken_for_the_prompt() {
let task = json!({
"hook_event_name": "PreToolUse",
"tool_name": "Task",
"tool_input": {"description": "review", "prompt": "SUBAGENT PROMPT"}
});
assert_eq!(prompt_of(&task), None);
let shadowed = json!({
"tool_name": "Task",
"tool_input": {"cwd": "/tmp/sub", "toolName": "Inner", "prompt": "x"}
});
assert_eq!(cwd_of(&shadowed), None);
assert_eq!(tool_name_of(&shadowed), Some("Task"));
let cline = json!({"userPromptSubmit": {"prompt": "the real prompt"}});
assert_eq!(prompt_of(&cline), Some("the real prompt"));
let flat = json!({"tool_input": {"command": "ls"}, "tool_result": "ok"});
assert_eq!(tool_input_of(&flat), Some(&json!({"command": "ls"})));
assert_eq!(tool_result_of(&flat), Some(&json!("ok")));
}
#[test]
fn cursor_shapes_map_to_common() {
let fixture = |raw: &str| serde_json::from_str::<Value>(raw).expect("fixture");
let ide = fixture(include_str!(
"../../../tests/fixtures/cursor/before-shell-ide.json"
));
let common = to_common(&ide, cursor(), "cursor_hook").expect("remapped");
assert_eq!(common["hook_event_name"], "beforeShellExecution");
assert_eq!(common["session_id"], "953caf59-f1d4-4188-812c-32f8ab9a9945");
assert_eq!(common["turn_id"], "1108e70f-7ae0-4501-9a40-8e8ffa3e834f");
assert_eq!(common["model"], "grok-4.6");
assert_eq!(common["agent_version"], "3.22.12");
assert_eq!(common["agent_surface"], "ide");
assert_eq!(common["tool_name"], "Bash");
assert_eq!(
common["tool_input"],
json!({"command": "rm -rf /tmp/ol-i1", "cwd": "", "sandbox": false})
);
assert!(common.get("cwd").is_none(), "no cwd is not an empty one");
assert!(common.get("encoding_suspect").is_none());
assert_eq!(common["raw_agent_payload"], ide);
let cli = fixture(include_str!(
"../../../tests/fixtures/cursor/before-shell-cli.json"
));
let common = to_common(&cli, cursor(), "cursor_hook").expect("remapped");
assert_eq!(common["agent_surface"], "cli");
assert_eq!(common["agent_version"], "2026.09.28-64d2043");
assert_eq!(common["cwd"], "/Users/dev/work");
assert_eq!(common["tool_name"], "Bash");
assert_eq!(common["tool_input"]["sandbox"], true);
let pre = fixture(include_str!(
"../../../tests/fixtures/cursor/pre-tool-shell-ide.json"
));
let common = to_common(&pre, cursor(), "cursor_hook").expect("remapped");
assert_eq!(common["tool_name"], "Bash");
assert_eq!(
common["tool_input"],
json!({"command": "rm -rf /tmp/ol-i1", "cwd": ""})
);
assert_eq!(
common["tool_use_id"],
"454c7fcf-fba9-48c8-9877-e1d8b11a351d"
);
assert_eq!(common["hook_event_name"], "preToolUse");
let post = fixture(
r#"{"conversation_id":"953caf59-f1d4-4188-812c-32f8ab9a9945","cursor_version":"3.22.12","cwd":"","duration":386.305,"generation_id":"1108e70f-7ae0-4501-9a40-8e8ffa3e834f","hook_event_name":"postToolUse","model":"grok-4.6","session_id":"953caf59-f1d4-4188-812c-32f8ab9a9945","tool_input":{"command":"rm -rf /tmp/ol-probe-p1","cwd":"","timeout":30000},"tool_name":"Shell","tool_output":"{\"output\":\"\",\"exitCode\":0}","tool_use_id":"07cd67eb-588c-4f14-8bfc-9b86506a7eb5"}"#,
);
let common = to_common(&post, cursor(), "cursor_hook").expect("remapped");
assert_eq!(common["tool_name"], "Bash");
assert_eq!(common["tool_response"], "{\"output\":\"\",\"exitCode\":0}");
assert_eq!(
common["tool_use_id"],
"07cd67eb-588c-4f14-8bfc-9b86506a7eb5"
);
let start = fixture(
r#"{"conversation_id":"953caf59-f1d4-4188-812c-32f8ab9a9945","generation_id":"","model":"cursor-grok-4.6-medium","model_id":"grok-4.6","model_params":[{"id":"effort","value":"medium"},{"id":"fast","value":"false"}],"is_background_agent":false,"composer_mode":"agent","session_id":"953caf59-f1d4-4188-812c-32f8ab9a9945","hook_event_name":"sessionStart","cursor_version":"3.22.12","workspace_roots":[],"user_email":"dev@example.com","transcript_path":null}"#,
);
let common = to_common(&start, cursor(), "cursor_hook").expect("remapped");
assert_eq!(common["hook_event_name"], "sessionStart");
assert_eq!(common["agent_surface"], "ide");
assert!(common.get("turn_id").is_none());
assert!(common.get("tool_name").is_none());
let stop = fixture(
r#"{"cache_read_tokens":19840,"cache_write_tokens":0,"conversation_id":"953caf59-f1d4-4188-812c-32f8ab9a9945","cursor_version":"3.22.12","generation_id":"1108e70f-7ae0-4501-9a40-8e8ffa3e834f","hook_event_name":"stop","input_tokens":36863,"loop_count":0,"model":"cursor-grok-4.6-medium","model_id":"grok-4.6","model_params":[{"id":"effort","value":"medium"},{"id":"fast","value":"false"}],"output_tokens":132,"session_id":"953caf59-f1d4-4188-812c-32f8ab9a9945","status":"completed"}"#,
);
let common = to_common(&stop, cursor(), "cursor_hook").expect("remapped");
assert_eq!(common["session_id"], "953caf59-f1d4-4188-812c-32f8ab9a9945");
assert_eq!(common["model"], "cursor-grok-4.6-medium");
let mcp = fixture(
r#"{"conversation_id":"c1","generation_id":"g1","model":"default","tool_name":"create_issue","tool_input":"{\"title\":\"x\"}","mcp_server_name":"github","url":"https://api.example.com/mcp","session_id":"c1","hook_event_name":"beforeMCPExecution","cursor_version":"3.22.12","workspace_roots":[]}"#,
);
let common = to_common(&mcp, cursor(), "cursor_hook").expect("remapped");
assert_eq!(common["tool_name"], "mcp__github__create_issue");
assert_eq!(common["tool_input"], json!({"title": "x"}));
let mut unparsed = mcp.clone();
unparsed["tool_input"] = json!("not json");
assert_eq!(
to_common(&unparsed, cursor(), "cursor_hook").unwrap()["tool_input"],
"not json"
);
let prompt = fixture(
r#"{"conversation_id":"c1","generation_id":"g1","model":"default","prompt":"hello","attachments":[],"session_id":"c1","hook_event_name":"beforeSubmitPrompt","cursor_version":"2026.09.28-64d2043","workspace_roots":[]}"#,
);
let common = to_common(&prompt, cursor(), "cursor_hook").expect("remapped");
assert_eq!(common["prompt"], "hello");
assert_eq!(common["agent_surface"], "cli");
let pre_mcp = json!({"cursor_version": "3.22.12", "tool_name": "MCP:create_issue", "tool_input": {"title": "x"}});
assert_eq!(
common_tool_of(&pre_mcp, cursor()),
Some((
"mcp__unknown__create_issue".to_string(),
Some(json!({"title": "x"}))
))
);
let read = json!({"cursor_version": "3.22.12", "tool_name": "Read", "tool_input": {"file_path": "a"}});
assert_eq!(
common_tool_of(&read, cursor()),
Some(("Read".to_string(), Some(json!({"file_path": "a"}))))
);
}
#[test]
fn the_surface_is_read_from_the_build_and_never_guessed() {
for (version, surface) in [
("3.22.12", "ide"),
("3.22", "ide"),
("2026.09.28-64d2043", "cli"),
("nightly", "unknown"),
("3.22.12-beta", "unknown"),
("2026.9.28-x", "unknown"),
] {
assert_eq!(
agent_surface_of(&json!({"cursor_version": version})),
Some(surface),
"{version}"
);
}
assert_eq!(agent_surface_of(&json!({"agent_version": "3.22.12"})), None);
assert_eq!(
common_tool_of(&json!({"cursor_version": 3, "command": "ls"}), cursor()),
None,
"not the Cursor shape, and no tool_name"
);
}
#[test]
fn a_non_ascii_cursor_command_is_suspect_on_windows_only() {
let cafe = json!({"cursor_version": "3.22.12", "hook_event_name": "beforeShellExecution", "command": "echo café"});
assert!(encoding_suspect(&cafe, true));
assert!(!encoding_suspect(&cafe, false));
assert!(!encoding_suspect(
&json!({"cursor_version": "3.22.12", "command": "echo cafe"}),
true
));
assert!(encoding_suspect(
&json!({"cursor_version": "3.22.12", "prompt": "résumé"}),
true
));
assert!(!encoding_suspect(&json!({"command": "echo café"}), true));
assert_eq!(
to_common(&cafe, cursor(), "cursor_hook").unwrap()["tool_input"]["command"],
"echo café"
);
}
}