use axum::http::HeaderMap;
use serde_json::json;
use super::*;
use crate::adapters::{claude_code, codex, cursor, hermes};
#[test]
fn maps_claude_canonical_tool_payload() {
let headers = HeaderMap::new();
let outcome = claude_code::adapt(
json!({
"session_id": "claude-session",
"transcript_path": "/tmp/transcript.jsonl",
"cwd": "/workspace",
"hook_event_name": "PreToolUse",
"tool_use_id": "toolu-1",
"tool_name": "Read",
"tool_input": { "file_path": "README.md" }
}),
&headers,
);
match &outcome.events[0] {
NormalizedEvent::ToolStarted(event) => {
assert_eq!(event.session_id, "claude-session");
assert_eq!(event.tool_call_id, "toolu-1");
assert_eq!(event.tool_name, "Read");
assert_eq!(event.arguments, json!({ "file_path": "README.md" }));
assert_eq!(
event.metadata["transcript_path"],
json!("/tmp/transcript.jsonl")
);
}
event => panic!("unexpected event: {event:?}"),
}
assert_eq!(outcome.response["continue"], json!(true));
assert_eq!(
outcome.response["hookSpecificOutput"],
json!({
"hookEventName": "PreToolUse",
"permissionDecision": "allow"
})
);
}
#[test]
fn maps_claude_post_tool_failure_with_canonical_fields() {
let headers = HeaderMap::new();
let outcome = claude_code::adapt(
json!({
"session_id": "claude-session",
"hook_event_name": "PostToolUseFailure",
"tool_use_id": "toolu-1",
"tool_name": "Bash",
"tool_input": { "command": "false" },
"error": "failed",
"is_interrupt": false,
"duration_ms": 12
}),
&headers,
);
match &outcome.events[0] {
NormalizedEvent::ToolEnded(event) => {
assert_eq!(event.tool_call_id, "toolu-1");
assert_eq!(event.tool_name, "Bash");
assert_eq!(
event.result,
json!({ "error": "failed", "is_interrupt": false, "duration_ms": 12 })
);
assert_eq!(event.status.as_deref(), Some("error"));
}
event => panic!("unexpected event: {event:?}"),
}
}
#[test]
fn maps_claude_permission_denied_as_tool_end() {
let headers = HeaderMap::new();
let outcome = claude_code::adapt(
json!({
"session_id": "claude-session",
"hook_event_name": "PermissionDenied",
"tool_use_id": "toolu-denied",
"tool_name": "Bash",
"tool_input": { "command": "rm -rf /tmp/project" },
"reason": "policy"
}),
&headers,
);
match &outcome.events[0] {
NormalizedEvent::ToolEnded(event) => {
assert_eq!(event.tool_call_id, "toolu-denied");
assert_eq!(event.status.as_deref(), Some("denied"));
assert_eq!(event.result, json!({ "reason": "policy" }));
}
event => panic!("unexpected event: {event:?}"),
}
}
#[test]
fn maps_claude_subagent_canonical_agent_id() {
let headers = HeaderMap::new();
let outcome = claude_code::adapt(
json!({
"session_id": "claude-session",
"hook_event_name": "SubagentStart",
"agent_id": "agent-worker-1",
"agent_type": "general-purpose"
}),
&headers,
);
match &outcome.events[0] {
NormalizedEvent::SubagentStarted(event) => {
assert_eq!(event.subagent_id, "agent-worker-1");
assert_eq!(event.metadata["agent_type"], json!("general-purpose"));
}
event => panic!("unexpected event: {event:?}"),
}
}
#[test]
fn maps_claude_subagent_stop() {
let outcome = claude_code::adapt(
json!({
"session_id": "claude-session",
"hook_event_name": "SubagentStop",
"agent_id": "agent-worker-1"
}),
&HeaderMap::new(),
);
match &outcome.events[0] {
NormalizedEvent::SubagentEnded(event) => {
assert_eq!(event.subagent_id, "agent-worker-1");
}
event => panic!("unexpected event: {event:?}"),
}
}
#[test]
fn maps_claude_stop_response_shape() {
let outcome = claude_code::adapt(
json!({
"session_id": "claude-session",
"hook_event_name": "Stop"
}),
&HeaderMap::new(),
);
assert_eq!(outcome.response, json!({ "continue": true }));
assert!(
outcome.response.get("stopReason").is_none(),
"stopReason must not appear in the response (Claude rejects null)"
);
}
#[test]
fn stop_hook_emits_turn_ended_for_codex() {
let outcome = codex::adapt(
json!({ "session_id": "codex-session", "hook_event_name": "Stop" }),
&HeaderMap::new(),
);
assert!(
outcome
.events
.iter()
.any(|e| matches!(e, NormalizedEvent::TurnEnded(_))),
"codex Stop must produce a TurnEnded event for ATIF snapshot. events: {:?}",
outcome.events
);
}
#[test]
fn stop_hook_emits_turn_ended_for_claude() {
let outcome = claude_code::adapt(
json!({ "session_id": "claude-session", "hook_event_name": "Stop" }),
&HeaderMap::new(),
);
assert!(
outcome
.events
.iter()
.any(|e| matches!(e, NormalizedEvent::TurnEnded(_))),
"claude Stop must produce a TurnEnded event for ATIF snapshot"
);
}
#[test]
fn stop_hook_does_not_double_emit_for_cursor_agent_end() {
let outcome = cursor::adapt(
json!({ "session_id": "cursor-session", "hook_event_name": "stop" }),
&HeaderMap::new(),
);
assert!(
matches!(outcome.events.first(), Some(NormalizedEvent::AgentEnded(_))),
"cursor stop must classify as AgentEnded"
);
assert!(
!outcome
.events
.iter()
.any(|e| matches!(e, NormalizedEvent::TurnEnded(_))),
"cursor stop must NOT also produce TurnEnded — would double-write ATIF then wipe it"
);
}
#[test]
fn adapter_string_lookup_accepts_scalar_values_only() {
let payload = json!({
"number": 7,
"boolean": false,
"object": { "nested": true }
});
assert_eq!(string_at(&payload, &["number"]).as_deref(), Some("7"));
assert_eq!(string_at(&payload, &["boolean"]).as_deref(), Some("false"));
assert_eq!(string_at(&payload, &["object"]), None);
}
#[test]
fn maps_cursor_subagent_and_permission_response() {
let headers = HeaderMap::new();
let outcome = cursor::adapt(
json!({
"session_id": "cursor-session",
"project_dir": "/repo",
"user_email": "dev@example.com",
"hook_event_name": "beforeShellExecution",
"subagent": { "id": "worker" },
"tool_call_id": "shell-1",
"tool_name": "shell",
"input": { "command": "cargo test" }
}),
&headers,
);
match &outcome.events[0] {
NormalizedEvent::ToolStarted(event) => {
assert_eq!(event.session_id, "cursor-session");
assert_eq!(event.subagent_id.as_deref(), Some("worker"));
assert_eq!(event.metadata["project_dir"], json!("/repo"));
assert_eq!(event.metadata["user_email"], json!("dev@example.com"));
}
event => panic!("unexpected event: {event:?}"),
}
assert_eq!(outcome.response["permission"], json!("allow"));
assert!(outcome.response.get("user_message").is_none());
assert!(outcome.response.get("agent_message").is_none());
}
#[test]
fn keeps_codex_response_unwrapped() {
let headers = HeaderMap::new();
let outcome = codex::adapt(
json!({
"session_id": "codex-session",
"hook_event_name": "sessionStart"
}),
&headers,
);
assert!(matches!(
outcome.events[0],
NormalizedEvent::AgentStarted(_)
));
assert_eq!(outcome.response, json!({}));
}
#[test]
fn maps_hermes_shell_hook_tool_payload() {
let headers = HeaderMap::new();
let outcome = hermes::adapt(
json!({
"hook_event_name": "pre_tool_call",
"tool_name": "terminal",
"tool_input": { "command": "pwd" },
"session_id": "",
"extra": {
"task_id": "hermes-session",
"tool_call_id": "tool-1"
}
}),
&headers,
);
match &outcome.events[0] {
NormalizedEvent::ToolStarted(event) => {
assert_eq!(event.agent_kind, AgentKind::Hermes);
assert_eq!(event.session_id, "hermes-session");
assert_eq!(event.tool_call_id, "tool-1");
assert_eq!(event.tool_name, "terminal");
assert_eq!(event.arguments, json!({ "command": "pwd" }));
}
event => panic!("unexpected event: {event:?}"),
}
assert_eq!(outcome.response, json!({}));
}
#[test]
fn maps_hermes_real_session_boundary_without_closing_per_turn_end() {
let headers = HeaderMap::new();
let per_turn = hermes::adapt(
json!({
"hook_event_name": "on_session_end",
"session_id": "hermes-session"
}),
&headers,
);
assert_eq!(per_turn.events.len(), 1);
assert!(matches!(per_turn.events[0], NormalizedEvent::TurnEnded(_)));
let finalized = hermes::adapt(
json!({
"hook_event_name": "on_session_finalize",
"session_id": "hermes-session"
}),
&headers,
);
assert_eq!(finalized.events.len(), 1);
assert!(matches!(
finalized.events[0],
NormalizedEvent::AgentEnded(_)
));
}
#[test]
fn maps_hermes_hook_event_name_and_subagent_from_extra_payload() {
let outcome = hermes::adapt(
json!({
"session_id": "hermes-session",
"extra": {
"hook_event_name": "subagent_stop",
"subagent_id": "worker-1"
}
}),
&HeaderMap::new(),
);
match &outcome.events[0] {
NormalizedEvent::SubagentEnded(event) => {
assert_eq!(event.event_name, "subagent_stop");
assert_eq!(event.subagent_id, "worker-1");
assert_eq!(event.session_id, "hermes-session");
}
event => panic!("unexpected event: {event:?}"),
}
}
#[test]
fn maps_hermes_api_hooks_to_llm_lifecycle() {
let headers = HeaderMap::new();
let started = hermes::adapt(
json!({
"hook_event_name": "pre_api_request",
"session_id": "hermes-session",
"extra": {
"task_id": "task-1",
"api_call_count": 2,
"model": "qwen",
"provider": "custom",
"base_url": "http://localhost:11434/v1",
"api_mode": "chat_completions",
"message_count": 3,
"tool_count": 1,
"approx_input_tokens": 12,
"request_char_count": 456,
"max_tokens": 1024
}
}),
&headers,
);
match &started.events[0] {
NormalizedEvent::LlmStarted(event) => {
assert_eq!(event.session_id, "hermes-session");
assert_eq!(event.api_call_id, "hermes-session:task-1:2");
assert_eq!(event.provider, "custom");
assert_eq!(event.model_name.as_deref(), Some("qwen"));
assert_eq!(event.request["message_count"], json!(3));
assert_eq!(
event.request["fidelity"]["provider_payload_exact"],
json!(false)
);
assert_eq!(event.metadata["provider_payload_exact"], json!(false));
}
event => panic!("unexpected event: {event:?}"),
}
let ended = hermes::adapt(
json!({
"hook_event_name": "post_api_request",
"session_id": "hermes-session",
"extra": {
"task_id": "task-1",
"api_call_count": 2,
"model": "qwen",
"response_model": "qwen",
"provider": "custom",
"api_duration": 0.25,
"finish_reason": "stop",
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"prompt_tokens_details": { "cached_tokens": 3 }
}
}
}),
&headers,
);
match &ended.events[0] {
NormalizedEvent::LlmEnded(event) => {
assert_eq!(event.api_call_id, "hermes-session:task-1:2");
assert_eq!(event.response["usage"]["prompt_tokens"], json!(10));
assert_eq!(event.response["usage"]["completion_tokens"], json!(5));
}
event => panic!("unexpected event: {event:?}"),
}
}
#[test]
fn maps_hermes_exact_api_hook_payloads_to_llm_lifecycle() {
let headers = HeaderMap::new();
let started = hermes::adapt(
json!({
"hook_event_name": "pre_api_request",
"session_id": "hermes-session",
"extra": {
"task_id": "task-1",
"api_request_id": "turn-1:api:2",
"api_call_count": 2,
"model": "qwen",
"provider": "custom",
"request": {
"method": "POST",
"body": {
"model": "qwen",
"messages": [
{ "role": "user", "content": "hello" }
],
"tools": [
{ "type": "function", "function": { "name": "search_files" } }
]
}
}
}
}),
&headers,
);
match &started.events[0] {
NormalizedEvent::LlmStarted(event) => {
assert_eq!(event.api_call_id, "turn-1:api:2");
assert_eq!(event.request["messages"][0]["content"], json!("hello"));
assert_eq!(
event.request["tools"][0]["function"]["name"],
json!("search_files")
);
assert_eq!(event.metadata["provider_payload_exact"], json!(true));
assert_eq!(
event.metadata["fidelity_source"],
json!("hermes_api_hooks_sanitized")
);
}
event => panic!("unexpected event: {event:?}"),
}
let ended = hermes::adapt(
json!({
"hook_event_name": "post_api_request",
"session_id": "hermes-session",
"extra": {
"task_id": "task-1",
"api_request_id": "turn-1:api:2",
"api_call_count": 2,
"model": "qwen",
"response": {
"model": "qwen",
"finish_reason": "tool_calls",
"assistant_message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call-1",
"type": "function",
"function": {
"name": "search_files",
"arguments": "{\"query\":\"needle\"}"
}
}
]
},
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5
}
}
}
}),
&headers,
);
match &ended.events[0] {
NormalizedEvent::LlmEnded(event) => {
assert_eq!(event.api_call_id, "turn-1:api:2");
assert_eq!(event.response["tool_calls"][0]["id"], json!("call-1"));
assert_eq!(event.response["usage"]["prompt_tokens"], json!(10));
assert_eq!(event.metadata["provider_payload_exact"], json!(true));
}
event => panic!("unexpected event: {event:?}"),
}
}
#[test]
fn maps_hermes_api_request_error_to_llm_end() {
let outcome = hermes::adapt(
json!({
"hook_event_name": "api_request_error",
"session_id": "hermes-session",
"extra": {
"task_id": "task-1",
"api_request_id": "turn-1:api:3",
"api_call_count": 3,
"model": "qwen",
"provider": "custom",
"status_code": 502,
"retry_count": 1,
"max_retries": 2,
"retryable": true,
"reason": "upstream",
"error": {
"type": "BadGateway",
"message": "gateway upstream error"
}
}
}),
&HeaderMap::new(),
);
match &outcome.events[0] {
NormalizedEvent::LlmEnded(event) => {
assert_eq!(event.api_call_id, "turn-1:api:3");
assert_eq!(event.response["status_code"], json!(502));
assert_eq!(
event.response["error"]["message"],
json!("gateway upstream error")
);
assert_eq!(event.metadata["provider_payload_exact"], json!(false));
}
event => panic!("unexpected event: {event:?}"),
}
}
#[test]
fn maps_hermes_null_request_as_lossy_summary() {
let outcome = hermes::adapt(
json!({
"hook_event_name": "pre_api_request",
"session_id": "hermes-session",
"extra": {
"task_id": "task-1",
"api_call_count": 4,
"model": "qwen",
"provider": "custom",
"request": null,
"message_count": 2
}
}),
&HeaderMap::new(),
);
match &outcome.events[0] {
NormalizedEvent::LlmStarted(event) => {
assert_eq!(event.api_call_id, "hermes-session:task-1:4");
assert_eq!(event.request["message_count"], json!(2));
assert_eq!(
event.request["fidelity"]["provider_payload_exact"],
json!(false)
);
assert_eq!(event.metadata["provider_payload_exact"], json!(false));
}
event => panic!("unexpected event: {event:?}"),
}
}
#[test]
fn normalizes_mark_style_events_and_header_session_ids() {
let mut headers = HeaderMap::new();
headers.insert("x-nemo-relay-session-id", "header-session".parse().unwrap());
headers.insert("x-nemo-relay-config-profile", "coverage".parse().unwrap());
for (event_name, expected) in [
("UserPromptSubmit", "prompt"),
("afterAgentResponse", "response"),
("PreCompact", "compact"),
("Notification", "notification"),
("Unrecognized.Event", "hook"),
] {
let outcome = cursor::adapt(
json!({
"eventName": event_name,
"model": "model-a",
"cwd": "/repo"
}),
&headers,
);
let (session_id, metadata) = match &outcome.events[0] {
NormalizedEvent::PromptSubmitted(event) if expected == "prompt" => {
(event.session_id.as_str(), &event.metadata)
}
NormalizedEvent::LlmHint(event) if expected == "response" => {
(event.session_id.as_str(), &event.metadata)
}
NormalizedEvent::Compaction(event) if expected == "compact" => {
(event.session_id.as_str(), &event.metadata)
}
NormalizedEvent::Notification(event) if expected == "notification" => {
(event.session_id.as_str(), &event.metadata)
}
NormalizedEvent::HookMark(event) if expected == "hook" => {
(event.session_id.as_str(), &event.metadata)
}
event => panic!("unexpected event for {event_name}: {event:?}"),
};
if expected == "prompt" {
assert!(
matches!(outcome.events.get(1), Some(NormalizedEvent::LlmHint(_))),
"prompt hooks should also emit a private LLM hint"
);
}
assert_eq!(session_id, "header-session");
assert_eq!(metadata["model"], json!("model-a"));
assert_eq!(metadata["cwd"], json!("/repo"));
assert_eq!(metadata["gateway_config_profile"], json!("coverage"));
}
}
#[test]
fn maps_hermes_llm_hooks_to_private_hints() {
let headers = HeaderMap::new();
let outcome = hermes::adapt(
json!({
"hook_event_name": "pre_llm_call",
"session_id": "hermes-session",
"model": "anthropic/claude-sonnet",
"request_id": "req-1"
}),
&headers,
);
match &outcome.events[0] {
NormalizedEvent::LlmHint(event) => {
assert_eq!(event.session_id, "hermes-session");
assert_eq!(event.event_name, "pre_llm_call");
assert_eq!(event.model.as_deref(), Some("anthropic/claude-sonnet"));
assert_eq!(event.request_id.as_deref(), Some("req-1"));
}
event => panic!("unexpected event: {event:?}"),
}
}
#[test]
fn extracts_tool_fields_from_fallback_payload_shapes() {
let headers = HeaderMap::new();
let outcome = codex::adapt(
json!({
"conversationId": "conversation-1",
"event": "toolEnded",
"tool": { "id": "tool-id", "name": "Shell" },
"arguments": { "cmd": "pwd" },
"result": { "stdout": "/repo" },
"permission": "allow"
}),
&headers,
);
match &outcome.events[0] {
NormalizedEvent::ToolEnded(event) => {
assert_eq!(event.session_id, "conversation-1");
assert_eq!(event.tool_call_id, "tool-id");
assert_eq!(event.tool_name, "Shell");
assert_eq!(event.arguments, json!({ "cmd": "pwd" }));
assert_eq!(event.result, json!({ "stdout": "/repo" }));
assert_eq!(event.status.as_deref(), Some("allow"));
}
event => panic!("unexpected event: {event:?}"),
}
}
#[test]
fn generated_ids_are_used_when_payload_omits_identifiers() {
let headers = HeaderMap::new();
let outcome = claude_code::adapt(
json!({
"hook_event_name": "PreToolUse",
"tool_input": { "name": "Read", "file_path": "Cargo.toml" }
}),
&headers,
);
match &outcome.events[0] {
NormalizedEvent::ToolStarted(event) => {
assert!(event.session_id.starts_with("hook-"));
assert!(event.tool_call_id.starts_with("tool-"));
assert_eq!(event.tool_name, "Read");
}
event => panic!("unexpected event: {event:?}"),
}
}
#[test]
fn stop_responses_preserve_vendor_shapes() {
let headers = HeaderMap::new();
let claude = claude_code::adapt(
json!({
"session_id": "claude-session",
"hook_event_name": "Stop"
}),
&headers,
);
assert!(matches!(claude.events[0], NormalizedEvent::LlmHint(_)));
assert!(
claude.response.get("stopReason").is_none(),
"stopReason must not be present (Claude rejects null per its hook schema)"
);
let codex = codex::adapt(
json!({
"session_id": "codex-session",
"hook_event_name": "stop"
}),
&headers,
);
assert!(matches!(codex.events[0], NormalizedEvent::LlmHint(_)));
assert_eq!(codex.response, json!({}));
let cursor = cursor::adapt(
json!({
"session_id": "cursor-session",
"hook_event_name": "stop"
}),
&headers,
);
assert!(matches!(cursor.events[0], NormalizedEvent::AgentEnded(_)));
assert_eq!(cursor.response, json!({ "continue": true }));
}