use crate::{
ActorId, ContextFragment, Error, HarnessEvent, Message, ModelName, PolicyDecision,
RecordedEvent, RunPhase, RunState, ToolCall, ToolCallId, ToolResult, TurnId,
};
#[derive(Clone, Debug, PartialEq)]
pub struct RunReadback {
pub entries: Vec<ReadbackEntry>,
pub final_phase: RunPhase,
pub next_seq: u64,
}
impl RunReadback {
pub fn from_events(events: &[RecordedEvent]) -> Result<Self, Error> {
let mut state = RunState::new();
let mut entries = Vec::new();
for record in events {
state.apply(record)?;
collect_entry(&record.event, &mut entries);
}
Ok(Self {
entries,
final_phase: state.phase().clone(),
next_seq: state.next_seq(),
})
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum ReadbackEntry {
ContextCompacted {
turn_id: TurnId,
estimated_tokens_before: u32,
estimated_tokens_after: u32,
dropped_turn_start: u64,
dropped_turn_end_exclusive: u64,
},
ContextFragment {
turn_id: TurnId,
fragment: ContextFragment,
},
ModelMessage {
turn_id: TurnId,
message: Message,
served_model: Option<ModelName>,
},
ModelFailed {
turn_id: TurnId,
step: u32,
reason: String,
},
ToolProposalsRejected {
turn_id: TurnId,
reason: String,
},
ToolCall {
turn_id: TurnId,
call: ToolCall,
},
ToolResult {
result: ToolResult,
},
PolicyDenied {
call_id: ToolCallId,
reason: String,
},
ApprovalGranted {
call_id: ToolCallId,
actor_id: ActorId,
},
ApprovalDenied {
call_id: ToolCallId,
actor_id: ActorId,
reason: String,
},
ToolFailed {
call_id: ToolCallId,
reason: String,
},
}
fn collect_entry(event: &HarnessEvent, entries: &mut Vec<ReadbackEntry>) {
match event {
HarnessEvent::ContextCompacted {
turn_id,
estimated_tokens_before,
estimated_tokens_after,
dropped_turn_start,
dropped_turn_end_exclusive,
..
} => {
entries.push(ReadbackEntry::ContextCompacted {
turn_id: turn_id.clone(),
estimated_tokens_before: *estimated_tokens_before,
estimated_tokens_after: *estimated_tokens_after,
dropped_turn_start: *dropped_turn_start,
dropped_turn_end_exclusive: *dropped_turn_end_exclusive,
});
}
HarnessEvent::ContextBuilt {
turn_id, context, ..
} => {
entries.extend(context.fragments.iter().map(|fragment| {
ReadbackEntry::ContextFragment {
turn_id: turn_id.clone(),
fragment: fragment.clone(),
}
}));
}
HarnessEvent::ModelResponded {
turn_id,
output,
served_model,
..
} => {
entries.push(ReadbackEntry::ModelMessage {
turn_id: turn_id.clone(),
message: output.clone(),
served_model: served_model.clone(),
});
}
HarnessEvent::ModelFailed {
turn_id,
step,
reason,
..
} => {
entries.push(ReadbackEntry::ModelFailed {
turn_id: turn_id.clone(),
step: *step,
reason: reason.clone(),
});
}
HarnessEvent::ToolProposalsRejected {
turn_id, reason, ..
} => {
entries.push(ReadbackEntry::ToolProposalsRejected {
turn_id: turn_id.clone(),
reason: reason.clone(),
});
}
HarnessEvent::ToolCallProposed { turn_id, call, .. } => {
entries.push(ReadbackEntry::ToolCall {
turn_id: turn_id.clone(),
call: call.clone(),
});
}
HarnessEvent::ToolFinished { result, .. } => {
entries.push(ReadbackEntry::ToolResult {
result: result.clone(),
});
}
HarnessEvent::PolicyEvaluated {
call_id,
decision: PolicyDecision::Deny { reason },
..
} => {
entries.push(ReadbackEntry::PolicyDenied {
call_id: call_id.clone(),
reason: reason.clone(),
});
}
HarnessEvent::ApprovalGranted {
call_id, actor_id, ..
} => {
entries.push(ReadbackEntry::ApprovalGranted {
call_id: call_id.clone(),
actor_id: actor_id.clone(),
});
}
HarnessEvent::ApprovalDenied {
call_id,
actor_id,
reason,
..
} => {
entries.push(ReadbackEntry::ApprovalDenied {
call_id: call_id.clone(),
actor_id: actor_id.clone(),
reason: reason.clone(),
});
}
HarnessEvent::ToolFailed {
call_id, reason, ..
} => {
entries.push(ReadbackEntry::ToolFailed {
call_id: call_id.clone(),
reason: reason.clone(),
});
}
HarnessEvent::RunStarted { .. }
| HarnessEvent::ModelRequested { .. }
| HarnessEvent::PolicyEvaluated {
decision: PolicyDecision::Allow | PolicyDecision::RequireApproval { .. },
..
}
| HarnessEvent::ToolStarted { .. }
| HarnessEvent::RunFinished { .. }
| HarnessEvent::RunFailed { .. } => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
AgentId, ContextFragment, ContextLane, ContextPack, EffectClass, MessageRole, ModelName,
ModelUsage, ResultVisibility, RunId, ToolName, ToolProposal,
};
use serde_json::json;
fn run_id() -> RunId {
RunId::new("run_1").unwrap()
}
fn agent_id() -> AgentId {
AgentId::new("agent_1").unwrap()
}
fn turn_id() -> TurnId {
TurnId::new("turn_1").unwrap()
}
fn second_turn_id() -> TurnId {
TurnId::new("turn_2").unwrap()
}
fn call_id() -> ToolCallId {
ToolCallId::new("call_1").unwrap()
}
fn actor_id() -> ActorId {
ActorId::new("human_1").unwrap()
}
fn usage() -> Option<ModelUsage> {
Some(ModelUsage {
input_tokens: 20,
output_tokens: 8,
})
}
fn rec(seq: u64, event: HarnessEvent) -> RecordedEvent {
RecordedEvent {
seq,
occurred_at_ms: 1_700_000_000_000 + seq,
event,
}
}
fn context(turn_id: TurnId, content: &str) -> HarnessEvent {
HarnessEvent::ContextBuilt {
run_id: run_id(),
turn_id,
context: ContextPack {
token_budget: 100,
fragments: vec![ContextFragment {
lane: ContextLane::CurrentTask,
source: "user".into(),
content: content.into(),
estimated_tokens: 10,
}],
},
}
}
fn model_requested(turn_id: TurnId, step: u32) -> HarnessEvent {
HarnessEvent::ModelRequested {
run_id: run_id(),
turn_id,
step,
model: ModelName::new("claude-fable-5").unwrap(),
}
}
fn model_failed(turn_id: TurnId, step: u32) -> HarnessEvent {
HarnessEvent::ModelFailed {
run_id: run_id(),
turn_id,
step,
reason: "provider unavailable".into(),
}
}
fn model_responded(
turn_id: TurnId,
step: u32,
content: &str,
proposed_calls: Vec<ToolProposal>,
) -> HarnessEvent {
model_responded_with_served_model(turn_id, step, content, proposed_calls, None)
}
fn model_responded_with_served_model(
turn_id: TurnId,
step: u32,
content: &str,
proposed_calls: Vec<ToolProposal>,
served_model: Option<ModelName>,
) -> HarnessEvent {
HarnessEvent::ModelResponded {
run_id: run_id(),
turn_id,
step,
output: Message {
role: MessageRole::Assistant,
content: content.into(),
},
proposed_calls,
served_model,
usage: usage(),
}
}
fn proposal() -> ToolProposal {
ToolProposal {
tool: ToolName::new("file.read").unwrap(),
input: json!({ "path": "README.md" }),
}
}
fn call() -> ToolCall {
ToolCall {
id: call_id(),
tool: ToolName::new("file.read").unwrap(),
effect: EffectClass::ReadOnly,
input: json!({ "path": "README.md" }),
}
}
fn result() -> ToolResult {
ToolResult {
call_id: call_id(),
summary: "read README".into(),
data: json!({ "bytes": 123 }),
artifacts: vec![],
visibility: ResultVisibility::Both,
}
}
fn start_event(seq: u64) -> RecordedEvent {
rec(
seq,
HarnessEvent::RunStarted {
run_id: run_id(),
agent_id: agent_id(),
},
)
}
#[test]
fn compaction_precedes_its_context_fragments_in_readback() {
let events = vec![
start_event(0),
rec(
1,
HarnessEvent::ContextCompacted {
run_id: run_id(),
turn_id: turn_id(),
estimated_tokens_before: 160,
estimated_tokens_after: 80,
dropped_turn_start: 0,
dropped_turn_end_exclusive: 2,
},
),
rec(2, context(turn_id(), "Keep recent turns")),
rec(3, model_requested(turn_id(), 0)),
rec(4, model_responded(turn_id(), 0, "done", vec![])),
rec(5, HarnessEvent::RunFinished { run_id: run_id() }),
];
let readback = RunReadback::from_events(&events).unwrap();
assert_eq!(readback.final_phase, RunPhase::Finished);
assert_eq!(readback.next_seq, 6);
assert_eq!(
readback.entries,
vec![
ReadbackEntry::ContextCompacted {
turn_id: turn_id(),
estimated_tokens_before: 160,
estimated_tokens_after: 80,
dropped_turn_start: 0,
dropped_turn_end_exclusive: 2,
},
ReadbackEntry::ContextFragment {
turn_id: turn_id(),
fragment: ContextFragment {
lane: ContextLane::CurrentTask,
source: "user".into(),
content: "Keep recent turns".into(),
estimated_tokens: 10,
},
},
ReadbackEntry::ModelMessage {
turn_id: turn_id(),
message: Message {
role: MessageRole::Assistant,
content: "done".into(),
},
served_model: None,
},
]
);
}
#[test]
fn pre_field_v0_3_0_response_replays_as_unknown_served_model() {
let pre_field_response = serde_json::from_value(json!({
"event": "model_responded",
"run_id": "run_1",
"turn_id": "turn_1",
"step": 0,
"output": {
"role": "assistant",
"content": "It is a README."
},
"proposed_calls": [],
"usage": {
"input_tokens": 20,
"output_tokens": 8
}
}))
.unwrap();
let events = vec![
start_event(0),
rec(1, context(turn_id(), "What is in README?")),
rec(2, model_requested(turn_id(), 0)),
rec(3, pre_field_response),
rec(4, HarnessEvent::RunFinished { run_id: run_id() }),
];
let readback = RunReadback::from_events(&events).unwrap();
assert_eq!(readback.final_phase, RunPhase::Finished);
assert_eq!(readback.next_seq, 5);
assert_eq!(
readback.entries,
vec![
ReadbackEntry::ContextFragment {
turn_id: turn_id(),
fragment: ContextFragment {
lane: ContextLane::CurrentTask,
source: "user".into(),
content: "What is in README?".into(),
estimated_tokens: 10,
},
},
ReadbackEntry::ModelMessage {
turn_id: turn_id(),
message: Message {
role: MessageRole::Assistant,
content: "It is a README.".into(),
},
served_model: None,
},
]
);
}
#[test]
fn request_alias_and_served_model_remain_distinct_and_visible() {
let request_alias = ModelName::new("~openai/gpt-latest").unwrap();
let served_model = ModelName::new("openai/gpt-5.2-2026-07-31").unwrap();
let events = vec![
start_event(0),
rec(1, context(turn_id(), "Answer the question")),
rec(
2,
HarnessEvent::ModelRequested {
run_id: run_id(),
turn_id: turn_id(),
step: 0,
model: request_alias.clone(),
},
),
rec(
3,
model_responded_with_served_model(
turn_id(),
0,
"Done.",
vec![],
Some(served_model.clone()),
),
),
rec(4, HarnessEvent::RunFinished { run_id: run_id() }),
];
assert!(matches!(
&events[2].event,
HarnessEvent::ModelRequested { model, .. } if model == &request_alias
));
let readback = RunReadback::from_events(&events).unwrap();
assert_eq!(
readback.entries.last().cloned(),
Some(ReadbackEntry::ModelMessage {
turn_id: turn_id(),
message: Message {
role: MessageRole::Assistant,
content: "Done.".into(),
},
served_model: Some(served_model),
})
);
}
#[test]
fn model_failure_is_replay_visible_before_a_successful_retry() {
let events = vec![
start_event(0),
rec(1, context(turn_id(), "What is in README?")),
rec(2, model_requested(turn_id(), 0)),
rec(3, model_failed(turn_id(), 0)),
rec(4, model_requested(turn_id(), 0)),
rec(5, model_responded(turn_id(), 0, "It is a README.", vec![])),
rec(6, HarnessEvent::RunFinished { run_id: run_id() }),
];
let readback = RunReadback::from_events(&events).unwrap();
assert_eq!(readback.final_phase, RunPhase::Finished);
assert_eq!(readback.next_seq, 7);
assert_eq!(
readback.entries,
vec![
ReadbackEntry::ContextFragment {
turn_id: turn_id(),
fragment: ContextFragment {
lane: ContextLane::CurrentTask,
source: "user".into(),
content: "What is in README?".into(),
estimated_tokens: 10,
},
},
ReadbackEntry::ModelFailed {
turn_id: turn_id(),
step: 0,
reason: "provider unavailable".into(),
},
ReadbackEntry::ModelMessage {
turn_id: turn_id(),
message: Message {
role: MessageRole::Assistant,
content: "It is a README.".into(),
},
served_model: None,
},
]
);
}
#[test]
fn two_turn_ledger_projects_tool_result_continuation() {
let events = vec![
start_event(0),
rec(1, context(turn_id(), "Read README")),
rec(2, model_requested(turn_id(), 0)),
rec(
3,
model_responded(turn_id(), 0, "I will read it.", vec![proposal()]),
),
rec(
4,
HarnessEvent::ToolCallProposed {
run_id: run_id(),
turn_id: turn_id(),
call: call(),
},
),
rec(
5,
HarnessEvent::PolicyEvaluated {
run_id: run_id(),
call_id: call_id(),
decision: PolicyDecision::Allow,
},
),
rec(
6,
HarnessEvent::ToolStarted {
run_id: run_id(),
call_id: call_id(),
},
),
rec(
7,
HarnessEvent::ToolFinished {
run_id: run_id(),
result: result(),
},
),
rec(8, context(second_turn_id(), "Tool result: read README")),
rec(9, model_requested(second_turn_id(), 1)),
rec(
10,
model_responded(second_turn_id(), 1, "README was read.", vec![]),
),
rec(11, HarnessEvent::RunFinished { run_id: run_id() }),
];
let readback = RunReadback::from_events(&events).unwrap();
assert_eq!(readback.final_phase, RunPhase::Finished);
assert_eq!(readback.next_seq, 12);
assert_eq!(
readback.entries,
vec![
ReadbackEntry::ContextFragment {
turn_id: turn_id(),
fragment: ContextFragment {
lane: ContextLane::CurrentTask,
source: "user".into(),
content: "Read README".into(),
estimated_tokens: 10,
},
},
ReadbackEntry::ModelMessage {
turn_id: turn_id(),
message: Message {
role: MessageRole::Assistant,
content: "I will read it.".into(),
},
served_model: None,
},
ReadbackEntry::ToolCall {
turn_id: turn_id(),
call: call(),
},
ReadbackEntry::ToolResult { result: result() },
ReadbackEntry::ContextFragment {
turn_id: second_turn_id(),
fragment: ContextFragment {
lane: ContextLane::CurrentTask,
source: "user".into(),
content: "Tool result: read README".into(),
estimated_tokens: 10,
},
},
ReadbackEntry::ModelMessage {
turn_id: second_turn_id(),
message: Message {
role: MessageRole::Assistant,
content: "README was read.".into(),
},
served_model: None,
},
]
);
}
#[test]
fn proposal_batch_rejection_is_replay_visible_and_allows_a_later_turn() {
let events = vec![
start_event(0),
rec(1, context(turn_id(), "Read README")),
rec(2, model_requested(turn_id(), 0)),
rec(
3,
model_responded(turn_id(), 0, "I will read it.", vec![proposal()]),
),
rec(
4,
HarnessEvent::ToolProposalsRejected {
run_id: run_id(),
turn_id: turn_id(),
reason: "proposal schema invalid".into(),
},
),
rec(
5,
context(second_turn_id(), "The proposed call was invalid"),
),
rec(6, model_requested(second_turn_id(), 1)),
rec(
7,
model_responded(second_turn_id(), 1, "Understood.", vec![]),
),
rec(8, HarnessEvent::RunFinished { run_id: run_id() }),
];
let readback = RunReadback::from_events(&events).unwrap();
assert_eq!(readback.final_phase, RunPhase::Finished);
assert_eq!(readback.next_seq, 9);
assert_eq!(
readback.entries,
vec![
ReadbackEntry::ContextFragment {
turn_id: turn_id(),
fragment: ContextFragment {
lane: ContextLane::CurrentTask,
source: "user".into(),
content: "Read README".into(),
estimated_tokens: 10,
},
},
ReadbackEntry::ModelMessage {
turn_id: turn_id(),
message: Message {
role: MessageRole::Assistant,
content: "I will read it.".into(),
},
served_model: None,
},
ReadbackEntry::ToolProposalsRejected {
turn_id: turn_id(),
reason: "proposal schema invalid".into(),
},
ReadbackEntry::ContextFragment {
turn_id: second_turn_id(),
fragment: ContextFragment {
lane: ContextLane::CurrentTask,
source: "user".into(),
content: "The proposed call was invalid".into(),
estimated_tokens: 10,
},
},
ReadbackEntry::ModelMessage {
turn_id: second_turn_id(),
message: Message {
role: MessageRole::Assistant,
content: "Understood.".into(),
},
served_model: None,
},
]
);
}
#[test]
fn denials_and_failures_are_projected_without_tool_results() {
let policy_denied = vec![
start_event(0),
rec(1, context(turn_id(), "Read secret")),
rec(2, model_requested(turn_id(), 0)),
rec(
3,
model_responded(turn_id(), 0, "I will read it.", vec![proposal()]),
),
rec(
4,
HarnessEvent::ToolCallProposed {
run_id: run_id(),
turn_id: turn_id(),
call: call(),
},
),
rec(
5,
HarnessEvent::PolicyEvaluated {
run_id: run_id(),
call_id: call_id(),
decision: PolicyDecision::Deny {
reason: "not allowed".into(),
},
},
),
];
let policy_readback = RunReadback::from_events(&policy_denied).unwrap();
assert!(
policy_readback
.entries
.contains(&ReadbackEntry::PolicyDenied {
call_id: call_id(),
reason: "not allowed".into(),
})
);
assert!(
!policy_readback
.entries
.iter()
.any(|entry| matches!(entry, ReadbackEntry::ToolResult { .. }))
);
let approval_denied = vec![
start_event(0),
rec(1, context(turn_id(), "Write README")),
rec(2, model_requested(turn_id(), 0)),
rec(
3,
model_responded(turn_id(), 0, "I will write it.", vec![proposal()]),
),
rec(
4,
HarnessEvent::ToolCallProposed {
run_id: run_id(),
turn_id: turn_id(),
call: call(),
},
),
rec(
5,
HarnessEvent::PolicyEvaluated {
run_id: run_id(),
call_id: call_id(),
decision: PolicyDecision::RequireApproval {
reason: "approval needed".into(),
},
},
),
rec(
6,
HarnessEvent::ApprovalDenied {
run_id: run_id(),
call_id: call_id(),
actor_id: actor_id(),
reason: "no".into(),
},
),
];
let approval_readback = RunReadback::from_events(&approval_denied).unwrap();
assert!(
approval_readback
.entries
.contains(&ReadbackEntry::ApprovalDenied {
call_id: call_id(),
actor_id: actor_id(),
reason: "no".into(),
})
);
assert!(
!approval_readback
.entries
.iter()
.any(|entry| matches!(entry, ReadbackEntry::ToolResult { .. }))
);
let tool_failed = vec![
start_event(0),
rec(1, context(turn_id(), "Read README")),
rec(2, model_requested(turn_id(), 0)),
rec(
3,
model_responded(turn_id(), 0, "I will read it.", vec![proposal()]),
),
rec(
4,
HarnessEvent::ToolCallProposed {
run_id: run_id(),
turn_id: turn_id(),
call: call(),
},
),
rec(
5,
HarnessEvent::PolicyEvaluated {
run_id: run_id(),
call_id: call_id(),
decision: PolicyDecision::Allow,
},
),
rec(
6,
HarnessEvent::ToolStarted {
run_id: run_id(),
call_id: call_id(),
},
),
rec(
7,
HarnessEvent::ToolFailed {
run_id: run_id(),
call_id: call_id(),
reason: "tool crashed".into(),
},
),
];
let failure_readback = RunReadback::from_events(&tool_failed).unwrap();
assert!(
failure_readback
.entries
.contains(&ReadbackEntry::ToolFailed {
call_id: call_id(),
reason: "tool crashed".into(),
})
);
assert!(
!failure_readback
.entries
.iter()
.any(|entry| matches!(entry, ReadbackEntry::ToolResult { .. }))
);
}
#[test]
fn approval_grants_are_projected_with_actor() {
let events = vec![
start_event(0),
rec(1, context(turn_id(), "Read README")),
rec(2, model_requested(turn_id(), 0)),
rec(
3,
model_responded(turn_id(), 0, "I will read it.", vec![proposal()]),
),
rec(
4,
HarnessEvent::ToolCallProposed {
run_id: run_id(),
turn_id: turn_id(),
call: call(),
},
),
rec(
5,
HarnessEvent::PolicyEvaluated {
run_id: run_id(),
call_id: call_id(),
decision: PolicyDecision::RequireApproval {
reason: "approval needed".into(),
},
},
),
rec(
6,
HarnessEvent::ApprovalGranted {
run_id: run_id(),
call_id: call_id(),
actor_id: actor_id(),
},
),
];
let readback = RunReadback::from_events(&events).unwrap();
assert_eq!(
readback.final_phase,
RunPhase::ReadyToExecuteTool { call: call() }
);
assert!(readback.entries.contains(&ReadbackEntry::ApprovalGranted {
call_id: call_id(),
actor_id: actor_id(),
}));
}
#[test]
fn replay_rejects_nonadjacent_turn_id_reuse() {
let events = vec![
start_event(0),
rec(1, context(turn_id(), "First turn")),
rec(2, model_requested(turn_id(), 0)),
rec(3, model_responded(turn_id(), 0, "done", vec![])),
rec(4, context(second_turn_id(), "Second turn")),
rec(5, model_requested(second_turn_id(), 1)),
rec(6, model_responded(second_turn_id(), 1, "done", vec![])),
rec(7, context(turn_id(), "Reused first turn")),
];
assert_eq!(
RunReadback::from_events(&events).unwrap_err(),
Error::TurnReused {
turn_id: "turn_1".into()
}
);
}
#[test]
fn replay_rejects_tool_call_id_reuse_in_a_later_turn() {
let events = vec![
start_event(0),
rec(1, context(turn_id(), "First read")),
rec(2, model_requested(turn_id(), 0)),
rec(
3,
model_responded(turn_id(), 0, "I will read it.", vec![proposal()]),
),
rec(
4,
HarnessEvent::ToolCallProposed {
run_id: run_id(),
turn_id: turn_id(),
call: call(),
},
),
rec(
5,
HarnessEvent::PolicyEvaluated {
run_id: run_id(),
call_id: call_id(),
decision: PolicyDecision::Deny {
reason: "first call denied".into(),
},
},
),
rec(6, context(second_turn_id(), "Second read")),
rec(7, model_requested(second_turn_id(), 1)),
rec(
8,
model_responded(
second_turn_id(),
1,
"I will read it again.",
vec![proposal()],
),
),
rec(
9,
HarnessEvent::ToolCallProposed {
run_id: run_id(),
turn_id: second_turn_id(),
call: call(),
},
),
];
assert_eq!(
RunReadback::from_events(&events).unwrap_err(),
Error::ToolCallReused {
call_id: "call_1".into()
}
);
}
#[test]
fn invalid_ledger_returns_replay_error() {
let events = vec![start_event(1)];
let err = RunReadback::from_events(&events).unwrap_err();
assert_eq!(
err,
Error::SequenceMismatch {
expected: 0,
actual: 1
}
);
}
}