use atm_core::{LifecycleEvent, NeedsInputReason, Tool};
use serde_json::Value;
use crate::event::PiEventType;
use crate::wire::RawPiEvent;
fn extract_context_usage(messages: Option<&Value>) -> (Option<u64>, Option<f64>) {
let Some(arr) = messages.and_then(Value::as_array) else {
return (None, None);
};
for msg in arr.iter().rev() {
let Some(usage) = msg.get("usage") else {
continue;
};
let tokens = usage
.get("totalTokens")
.and_then(Value::as_u64)
.or_else(|| {
let i = usage.get("input").and_then(Value::as_u64).unwrap_or(0);
let o = usage.get("output").and_then(Value::as_u64).unwrap_or(0);
let sum = i + o;
if sum > 0 {
Some(sum)
} else {
None
}
});
let cost_usd = usage
.get("cost")
.and_then(|c| c.get("total"))
.and_then(Value::as_f64);
if tokens.is_some() || cost_usd.is_some() {
return (tokens, cost_usd);
}
}
(None, None)
}
impl RawPiEvent {
pub fn to_lifecycle_event(&self) -> Option<LifecycleEvent> {
let p = &self.payload;
Some(match self.event {
PiEventType::SessionStart => LifecycleEvent::SessionStart {
source: p.reason.clone(),
},
PiEventType::SessionShutdown => LifecycleEvent::SessionEnd {
reason: p.reason.clone(),
},
PiEventType::AgentStart => LifecycleEvent::WorkingStart,
PiEventType::AgentEnd => LifecycleEvent::WorkingEnd,
PiEventType::Input => match p.source.as_deref() {
Some("interactive") => LifecycleEvent::PromptSubmit {
prompt: p.text.clone(),
},
_ => return None,
},
PiEventType::ToolExecutionStart => return None,
PiEventType::ToolCall => {
let tool_name = p.tool_name.as_deref().filter(|s| !s.is_empty())?;
let tool = Tool::from(tool_name);
if p.needs_user_input.unwrap_or(false) {
LifecycleEvent::NeedsInput {
reason: NeedsInputReason::PermissionGate { tool },
}
} else {
LifecycleEvent::ToolCallStart {
name: tool,
tool_use_id: p.tool_call_id.clone(),
input: p.input.clone(),
}
}
}
PiEventType::ToolExecutionEnd => {
let tool_name = p.tool_name.as_deref().filter(|s| !s.is_empty())?;
LifecycleEvent::ToolCallEnd {
name: Tool::from(tool_name),
tool_use_id: p.tool_call_id.clone(),
is_error: p.is_error.unwrap_or(false),
}
}
PiEventType::ToolResult => return None,
PiEventType::SessionBeforeCompact => {
LifecycleEvent::ContextCompactStart { trigger: None }
}
PiEventType::ModelSelect => LifecycleEvent::ProviderModelChange {
provider: p.provider.clone(),
model: p.model.clone(),
},
PiEventType::AtmNeedsInputOpen => LifecycleEvent::NeedsInput {
reason: atm_core::NeedsInputReason::Notification {
kind: atm_core::NotificationKind::PermissionPrompt,
label: p.title.clone(),
},
},
PiEventType::AtmNeedsInputResolved => LifecycleEvent::WorkingStart,
PiEventType::Context => {
let (tokens, cost_usd) = extract_context_usage(p.messages.as_ref());
if tokens.is_none() && cost_usd.is_none() {
return None;
}
LifecycleEvent::ContextUpdate { tokens, cost_usd }
}
PiEventType::BeforeProviderRequest
| PiEventType::AfterProviderResponse
| PiEventType::BeforeAgentStart
| PiEventType::TurnStart
| PiEventType::TurnEnd
| PiEventType::MessageStart
| PiEventType::MessageUpdate
| PiEventType::MessageEnd
| PiEventType::ToolExecutionUpdate
| PiEventType::SessionBeforeSwitch
| PiEventType::SessionBeforeFork
| PiEventType::SessionCompact
| PiEventType::SessionBeforeTree
| PiEventType::SessionTree
| PiEventType::ResourcesDiscover
| PiEventType::UserBash
| PiEventType::Other(_) => return None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wire::PiPayload;
fn raw(event: PiEventType, payload: PiPayload) -> RawPiEvent {
RawPiEvent {
event,
payload,
session_id: None,
pid: None,
tmux_pane: None,
}
}
#[test]
fn atm_needs_input_open_becomes_needs_input_permission_prompt() {
let e = raw(PiEventType::AtmNeedsInputOpen, PiPayload::default());
assert_eq!(
e.to_lifecycle_event(),
Some(LifecycleEvent::NeedsInput {
reason: atm_core::NeedsInputReason::Notification {
kind: atm_core::NotificationKind::PermissionPrompt,
label: None,
}
})
);
}
#[test]
fn atm_needs_input_open_plumbs_title_into_label() {
let payload = PiPayload {
title: Some("Allow `rm -rf /tmp/cache`?".to_string()),
..PiPayload::default()
};
let e = raw(PiEventType::AtmNeedsInputOpen, payload);
assert_eq!(
e.to_lifecycle_event(),
Some(LifecycleEvent::NeedsInput {
reason: atm_core::NeedsInputReason::Notification {
kind: atm_core::NotificationKind::PermissionPrompt,
label: Some("Allow `rm -rf /tmp/cache`?".to_string()),
}
})
);
}
#[test]
fn context_event_extracts_latest_usage_into_context_update() {
let messages = serde_json::json!([
{"role": "user", "content": "Use ls to list /tmp"},
{
"role": "assistant",
"content": [{"type": "toolCall"}],
"api": "openai-codex-responses",
"provider": "openai-codex",
"model": "gpt-5.5",
"usage": {
"input": 1088,
"output": 55,
"totalTokens": 1143,
"cost": {"input": 0.00544, "output": 0.00165, "total": 0.00709}
},
"stopReason": "toolUse"
}
]);
let e = raw(
PiEventType::Context,
PiPayload {
messages: Some(messages),
..Default::default()
},
);
match e.to_lifecycle_event() {
Some(LifecycleEvent::ContextUpdate { tokens, cost_usd }) => {
assert_eq!(tokens, Some(1143));
assert_eq!(cost_usd, Some(0.00709));
}
other => panic!("expected ContextUpdate, got {other:?}"),
}
}
#[test]
fn context_event_with_no_usage_returns_none() {
let messages = serde_json::json!([
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"} ]);
let e = raw(
PiEventType::Context,
PiPayload {
messages: Some(messages),
..Default::default()
},
);
assert_eq!(e.to_lifecycle_event(), None);
}
#[test]
fn atm_needs_input_resolved_becomes_working_start() {
let e = raw(PiEventType::AtmNeedsInputResolved, PiPayload::default());
assert_eq!(e.to_lifecycle_event(), Some(LifecycleEvent::WorkingStart));
}
#[test]
fn session_start_carries_reason_as_source() {
let e = raw(
PiEventType::SessionStart,
PiPayload {
reason: Some("startup".into()),
..Default::default()
},
);
assert_eq!(
e.to_lifecycle_event(),
Some(LifecycleEvent::SessionStart {
source: Some("startup".into())
})
);
}
#[test]
fn session_shutdown_carries_reason() {
let e = raw(
PiEventType::SessionShutdown,
PiPayload {
reason: Some("quit".into()),
..Default::default()
},
);
assert_eq!(
e.to_lifecycle_event(),
Some(LifecycleEvent::SessionEnd {
reason: Some("quit".into())
})
);
}
#[test]
fn agent_start_and_end_map_to_working_boundary() {
assert_eq!(
raw(PiEventType::AgentStart, PiPayload::default()).to_lifecycle_event(),
Some(LifecycleEvent::WorkingStart)
);
assert_eq!(
raw(PiEventType::AgentEnd, PiPayload::default()).to_lifecycle_event(),
Some(LifecycleEvent::WorkingEnd)
);
}
#[test]
fn interactive_input_becomes_prompt_submit() {
let e = raw(
PiEventType::Input,
PiPayload {
source: Some("interactive".into()),
text: Some("hello pi".into()),
..Default::default()
},
);
assert_eq!(
e.to_lifecycle_event(),
Some(LifecycleEvent::PromptSubmit {
prompt: Some("hello pi".into())
})
);
}
#[test]
fn non_interactive_input_is_suppressed() {
let e = raw(
PiEventType::Input,
PiPayload {
source: Some("internal".into()),
..Default::default()
},
);
assert_eq!(e.to_lifecycle_event(), None);
}
#[test]
fn tool_execution_start_is_suppressed_in_favor_of_tool_call() {
let e = raw(
PiEventType::ToolExecutionStart,
PiPayload {
tool_name: Some("ls".into()),
tool_call_id: Some("call_xyz".into()),
..Default::default()
},
);
assert_eq!(e.to_lifecycle_event(), None);
}
#[test]
fn tool_call_becomes_tool_call_start_with_correlation_id() {
let e = raw(
PiEventType::ToolCall,
PiPayload {
tool_name: Some("ls".into()),
tool_call_id: Some("call_xyz".into()),
input: Some(serde_json::json!({"path":"/tmp"})),
..Default::default()
},
);
assert_eq!(
e.to_lifecycle_event(),
Some(LifecycleEvent::ToolCallStart {
name: Tool::Other("ls".into()),
tool_use_id: Some("call_xyz".into()),
input: Some(serde_json::json!({"path":"/tmp"})),
})
);
}
#[test]
fn tool_call_with_known_tool_uses_canonical_variant() {
let e = raw(
PiEventType::ToolCall,
PiPayload {
tool_name: Some("Bash".into()),
tool_call_id: Some("call_42".into()),
..Default::default()
},
);
match e.to_lifecycle_event() {
Some(LifecycleEvent::ToolCallStart { name, .. }) => {
assert_eq!(name, Tool::Bash);
}
other => panic!("expected ToolCallStart, got {other:?}"),
}
}
#[test]
fn tool_call_with_needs_user_input_becomes_permission_gate() {
let e = raw(
PiEventType::ToolCall,
PiPayload {
tool_name: Some("bash".into()),
tool_call_id: Some("call_dangerous".into()),
needs_user_input: Some(true),
..Default::default()
},
);
assert_eq!(
e.to_lifecycle_event(),
Some(LifecycleEvent::NeedsInput {
reason: NeedsInputReason::PermissionGate {
tool: Tool::Other("bash".into())
}
})
);
}
#[test]
fn tool_shaped_event_without_tool_name_returns_none() {
for ev in [PiEventType::ToolCall, PiEventType::ToolExecutionEnd] {
assert_eq!(
raw(ev.clone(), PiPayload::default()).to_lifecycle_event(),
None,
"{ev:?} with tool_name=None should drop"
);
assert_eq!(
raw(
ev.clone(),
PiPayload {
tool_name: Some(String::new()),
..Default::default()
}
)
.to_lifecycle_event(),
None,
"{ev:?} with empty tool_name should drop"
);
}
}
#[test]
fn tool_execution_end_carries_error_flag_and_correlation() {
let ok = raw(
PiEventType::ToolExecutionEnd,
PiPayload {
tool_name: Some("ls".into()),
tool_call_id: Some("call_xyz".into()),
is_error: Some(false),
..Default::default()
},
);
let err = raw(
PiEventType::ToolExecutionEnd,
PiPayload {
tool_name: Some("ls".into()),
tool_call_id: Some("call_xyz".into()),
is_error: Some(true),
..Default::default()
},
);
assert_eq!(
ok.to_lifecycle_event(),
Some(LifecycleEvent::ToolCallEnd {
name: Tool::Other("ls".into()),
tool_use_id: Some("call_xyz".into()),
is_error: false,
})
);
assert_eq!(
err.to_lifecycle_event(),
Some(LifecycleEvent::ToolCallEnd {
name: Tool::Other("ls".into()),
tool_use_id: Some("call_xyz".into()),
is_error: true,
})
);
}
#[test]
fn tool_result_is_suppressed_in_favor_of_tool_execution_end() {
let e = raw(
PiEventType::ToolResult,
PiPayload {
tool_name: Some("ls".into()),
..Default::default()
},
);
assert_eq!(e.to_lifecycle_event(), None);
}
#[test]
fn model_select_becomes_provider_model_change() {
let e = raw(
PiEventType::ModelSelect,
PiPayload {
provider: Some("openai-codex".into()),
model: Some("gpt-5.5".into()),
..Default::default()
},
);
assert_eq!(
e.to_lifecycle_event(),
Some(LifecycleEvent::ProviderModelChange {
provider: Some("openai-codex".into()),
model: Some("gpt-5.5".into()),
})
);
}
#[test]
fn session_before_compact_becomes_context_compact_start() {
assert_eq!(
raw(PiEventType::SessionBeforeCompact, PiPayload::default()).to_lifecycle_event(),
Some(LifecycleEvent::ContextCompactStart { trigger: None })
);
}
#[test]
fn unknown_event_returns_none() {
let e = raw(
PiEventType::Other("hypothetical_future".into()),
PiPayload::default(),
);
assert_eq!(e.to_lifecycle_event(), None);
}
#[test]
fn parity_session_lifecycle_matches_claude_shape() {
let pi_start = raw(
PiEventType::SessionStart,
PiPayload {
reason: Some("startup".into()),
..Default::default()
},
);
let expected = LifecycleEvent::SessionStart {
source: Some("startup".into()),
};
assert_eq!(pi_start.to_lifecycle_event(), Some(expected));
}
#[test]
fn parity_tool_invocation_produces_same_shape() {
let pi_tool = raw(
PiEventType::ToolCall,
PiPayload {
tool_name: Some("Bash".into()),
tool_call_id: Some("toolu_abc".into()),
input: Some(serde_json::json!({"command":"ls"})),
..Default::default()
},
);
let expected = LifecycleEvent::ToolCallStart {
name: Tool::Bash,
tool_use_id: Some("toolu_abc".into()),
input: Some(serde_json::json!({"command":"ls"})),
};
assert_eq!(pi_tool.to_lifecycle_event(), Some(expected));
}
}