use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use agent_client_protocol::schema::v1::{SessionUpdate, ToolCallStatus};
use crate::clock::epoch_millis;
#[derive(Debug, Clone, PartialEq, Eq)]
enum StepSignature {
Message,
Thought,
Plan,
Tool { id: String, status: ToolCallStatus },
}
#[derive(Debug, Default)]
struct StepState {
started_at_ms: Option<i64>,
signature: Option<StepSignature>,
tool_statuses: BTreeMap<String, ToolCallStatus>,
}
#[derive(Debug, Clone, Default)]
pub struct StepClock(Arc<Mutex<StepState>>);
impl StepClock {
pub fn started_at_ms(&self) -> Option<i64> {
self.state().started_at_ms
}
pub fn begin_turn(&self) {
self.begin_turn_at(epoch_millis());
}
pub fn end_turn(&self) {
let mut state = self.state();
state.started_at_ms = None;
state.signature = None;
state.tool_statuses.clear();
}
pub fn begin_client_work(&self) {
self.begin_client_work_at(epoch_millis());
}
pub fn observe(&self, update: &SessionUpdate) {
self.observe_at(update, epoch_millis());
}
fn state(&self) -> std::sync::MutexGuard<'_, StepState> {
self.0.lock().expect("ACP step clock lock poisoned")
}
fn begin_turn_at(&self, now_ms: i64) {
let mut state = self.state();
state.started_at_ms = Some(now_ms);
state.signature = None;
state.tool_statuses.clear();
}
fn begin_client_work_at(&self, now_ms: i64) {
self.state().started_at_ms = Some(now_ms);
}
fn observe_at(&self, update: &SessionUpdate, now_ms: i64) {
let mut state = self.state();
let Some(signature) = signature_of(update, &mut state.tool_statuses) else {
return;
};
if state.signature.as_ref() == Some(&signature) {
return;
}
state.signature = Some(signature);
state.started_at_ms = Some(now_ms);
}
}
fn signature_of(
update: &SessionUpdate,
tool_statuses: &mut BTreeMap<String, ToolCallStatus>,
) -> Option<StepSignature> {
match update {
SessionUpdate::AgentMessageChunk(_) => Some(StepSignature::Message),
SessionUpdate::AgentThoughtChunk(_) => Some(StepSignature::Thought),
SessionUpdate::Plan(_) => Some(StepSignature::Plan),
SessionUpdate::ToolCall(call) => {
let id = call.tool_call_id.to_string();
tool_statuses.insert(id.clone(), call.status);
Some(StepSignature::Tool {
id,
status: call.status,
})
}
SessionUpdate::ToolCallUpdate(update) => {
let id = update.tool_call_id.to_string();
let status = match update.fields.status {
Some(status) => {
tool_statuses.insert(id.clone(), status);
status
}
None => tool_statuses.get(&id).copied().unwrap_or_default(),
};
Some(StepSignature::Tool { id, status })
}
SessionUpdate::UserMessageChunk(_) => None,
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(serde::Deserialize)]
struct Recorded {
at_ms: i64,
#[serde(default)]
update: Option<SessionUpdate>,
#[serde(default)]
client_request: Option<String>,
}
fn step_starts(recording: &str) -> Vec<i64> {
let clock = StepClock::default();
clock.begin_turn_at(0);
let mut starts = vec![0];
for line in recording.lines().filter(|line| !line.trim().is_empty()) {
let recorded: Recorded =
serde_json::from_str(line).expect("recorded ACP line should parse");
match (&recorded.update, &recorded.client_request) {
(Some(update), _) => clock.observe_at(update, recorded.at_ms),
(None, Some(_)) => clock.begin_client_work_at(recorded.at_ms),
(None, None) => panic!("recorded line has neither an update nor a request"),
}
let started = clock.started_at_ms().expect("a turn is open");
if starts.last() != Some(&started) {
starts.push(started);
}
}
starts
}
const CODEX: &str = include_str!("testdata/step_clock/codex.jsonl");
const CLAUDE: &str = include_str!("testdata/step_clock/claude.jsonl");
const KIMI: &str = include_str!("testdata/step_clock/kimi.jsonl");
const GROK: &str = include_str!("testdata/step_clock/grok.jsonl");
const DEEPSEEK: &str = include_str!("testdata/step_clock/deepseek.jsonl");
#[test]
fn codex_streams_a_message_and_a_tool_call_as_four_steps() {
assert_eq!(
step_starts(CODEX),
[0, 2918, 5190, 5193, 10161, 10186],
"codex step starts"
);
}
#[test]
fn claude_tool_output_and_usage_updates_do_not_restart_the_step() {
assert_eq!(
step_starts(CLAUDE),
[0, 1500, 1911, 3081, 7427, 7455, 8117, 16543],
"claude step starts"
);
}
#[test]
fn kimi_streaming_tool_input_does_not_restart_the_step() {
assert_eq!(
step_starts(KIMI),
[
0, 3239, 4221, 4309, 4838, 4859, 5271, 9546, 13079, 13081, 13082, 13728, 14078,
14463, 16883, 16936, 16937, 20625, 20627
],
"kimi step starts"
);
}
#[test]
fn grok_tool_calls_without_a_status_start_one_pending_step() {
assert_eq!(
step_starts(GROK),
[0, 9569, 9849, 10343, 10370, 10401],
"grok step starts"
);
}
#[test]
fn deepseek_runs_parallel_tool_calls_as_separate_steps() {
assert_eq!(
step_starts(DEEPSEEK),
[
0, 1633, 1678, 1709, 2437, 4601, 4602, 4603, 4613, 4619, 4621, 4623, 5636, 5788
],
"deepseek step starts"
);
}
#[test]
fn a_turn_that_ends_leaves_no_step_in_flight() {
let clock = StepClock::default();
clock.begin_turn_at(1_000);
assert_eq!(clock.started_at_ms(), Some(1_000));
clock.end_turn();
assert_eq!(clock.started_at_ms(), None);
}
#[test]
fn a_new_turn_reopens_a_step_of_the_kind_the_last_turn_ended_on() {
let chunk: SessionUpdate = serde_json::from_str(
r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hi"}}"#,
)
.expect("chunk parses");
let clock = StepClock::default();
clock.begin_turn_at(1_000);
clock.observe_at(&chunk, 1_100);
clock.end_turn();
clock.begin_turn_at(2_000);
clock.observe_at(&chunk, 2_100);
assert_eq!(
clock.started_at_ms(),
Some(2_100),
"the first message of a new turn opens a step even though the last turn ended on one"
);
}
}