1use crate::{
4 ActorId, ContextFragment, Error, HarnessEvent, Message, ModelName, PolicyDecision,
5 RecordedEvent, RunPhase, RunState, ToolCall, ToolCallId, ToolResult, TurnId,
6};
7
8#[derive(Clone, Debug, PartialEq)]
13pub struct RunReadback {
14 pub entries: Vec<ReadbackEntry>,
16 pub final_phase: RunPhase,
18 pub next_seq: u64,
20}
21
22impl RunReadback {
23 pub fn from_events(events: &[RecordedEvent]) -> Result<Self, Error> {
25 let mut state = RunState::new();
26 let mut entries = Vec::new();
27
28 for record in events {
29 state.apply(record)?;
30 collect_entry(&record.event, &mut entries);
31 }
32
33 Ok(Self {
34 entries,
35 final_phase: state.phase().clone(),
36 next_seq: state.next_seq(),
37 })
38 }
39}
40
41#[derive(Clone, Debug, PartialEq)]
43pub enum ReadbackEntry {
44 ContextCompacted {
46 turn_id: TurnId,
48 estimated_tokens_before: u32,
50 estimated_tokens_after: u32,
52 dropped_turn_start: u64,
54 dropped_turn_end_exclusive: u64,
56 },
57 ContextFragment {
59 turn_id: TurnId,
61 fragment: ContextFragment,
63 },
64 ModelMessage {
66 turn_id: TurnId,
68 message: Message,
70 served_model: Option<ModelName>,
72 },
73 ModelFailed {
75 turn_id: TurnId,
77 step: u32,
79 reason: String,
81 },
82 ToolProposalsRejected {
84 turn_id: TurnId,
86 reason: String,
88 },
89 ToolCall {
91 turn_id: TurnId,
93 call: ToolCall,
95 },
96 ToolResult {
98 result: ToolResult,
100 },
101 PolicyDenied {
103 call_id: ToolCallId,
105 reason: String,
107 },
108 ApprovalGranted {
110 call_id: ToolCallId,
112 actor_id: ActorId,
114 },
115 ApprovalDenied {
117 call_id: ToolCallId,
119 actor_id: ActorId,
121 reason: String,
123 },
124 ToolFailed {
126 call_id: ToolCallId,
128 reason: String,
130 },
131}
132
133fn collect_entry(event: &HarnessEvent, entries: &mut Vec<ReadbackEntry>) {
134 match event {
135 HarnessEvent::ContextCompacted {
136 turn_id,
137 estimated_tokens_before,
138 estimated_tokens_after,
139 dropped_turn_start,
140 dropped_turn_end_exclusive,
141 ..
142 } => {
143 entries.push(ReadbackEntry::ContextCompacted {
144 turn_id: turn_id.clone(),
145 estimated_tokens_before: *estimated_tokens_before,
146 estimated_tokens_after: *estimated_tokens_after,
147 dropped_turn_start: *dropped_turn_start,
148 dropped_turn_end_exclusive: *dropped_turn_end_exclusive,
149 });
150 }
151 HarnessEvent::ContextBuilt {
152 turn_id, context, ..
153 } => {
154 entries.extend(context.fragments.iter().map(|fragment| {
155 ReadbackEntry::ContextFragment {
156 turn_id: turn_id.clone(),
157 fragment: fragment.clone(),
158 }
159 }));
160 }
161 HarnessEvent::ModelResponded {
162 turn_id,
163 output,
164 served_model,
165 ..
166 } => {
167 entries.push(ReadbackEntry::ModelMessage {
168 turn_id: turn_id.clone(),
169 message: output.clone(),
170 served_model: served_model.clone(),
171 });
172 }
173 HarnessEvent::ModelFailed {
174 turn_id,
175 step,
176 reason,
177 ..
178 } => {
179 entries.push(ReadbackEntry::ModelFailed {
180 turn_id: turn_id.clone(),
181 step: *step,
182 reason: reason.clone(),
183 });
184 }
185 HarnessEvent::ToolProposalsRejected {
186 turn_id, reason, ..
187 } => {
188 entries.push(ReadbackEntry::ToolProposalsRejected {
189 turn_id: turn_id.clone(),
190 reason: reason.clone(),
191 });
192 }
193 HarnessEvent::ToolCallProposed { turn_id, call, .. } => {
194 entries.push(ReadbackEntry::ToolCall {
195 turn_id: turn_id.clone(),
196 call: call.clone(),
197 });
198 }
199 HarnessEvent::ToolFinished { result, .. } => {
200 entries.push(ReadbackEntry::ToolResult {
201 result: result.clone(),
202 });
203 }
204 HarnessEvent::PolicyEvaluated {
205 call_id,
206 decision: PolicyDecision::Deny { reason },
207 ..
208 } => {
209 entries.push(ReadbackEntry::PolicyDenied {
210 call_id: call_id.clone(),
211 reason: reason.clone(),
212 });
213 }
214 HarnessEvent::ApprovalGranted {
215 call_id, actor_id, ..
216 } => {
217 entries.push(ReadbackEntry::ApprovalGranted {
218 call_id: call_id.clone(),
219 actor_id: actor_id.clone(),
220 });
221 }
222 HarnessEvent::ApprovalDenied {
223 call_id,
224 actor_id,
225 reason,
226 ..
227 } => {
228 entries.push(ReadbackEntry::ApprovalDenied {
229 call_id: call_id.clone(),
230 actor_id: actor_id.clone(),
231 reason: reason.clone(),
232 });
233 }
234 HarnessEvent::ToolFailed {
235 call_id, reason, ..
236 } => {
237 entries.push(ReadbackEntry::ToolFailed {
238 call_id: call_id.clone(),
239 reason: reason.clone(),
240 });
241 }
242 HarnessEvent::RunStarted { .. }
243 | HarnessEvent::ModelRequested { .. }
244 | HarnessEvent::PolicyEvaluated {
245 decision: PolicyDecision::Allow | PolicyDecision::RequireApproval { .. },
246 ..
247 }
248 | HarnessEvent::ToolStarted { .. }
249 | HarnessEvent::RunFinished { .. }
250 | HarnessEvent::RunFailed { .. } => {}
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::{
258 AgentId, ContextFragment, ContextLane, ContextPack, EffectClass, MessageRole, ModelName,
259 ModelUsage, ResultVisibility, RunId, ToolName, ToolProposal,
260 };
261 use serde_json::json;
262
263 fn run_id() -> RunId {
264 RunId::new("run_1").unwrap()
265 }
266
267 fn agent_id() -> AgentId {
268 AgentId::new("agent_1").unwrap()
269 }
270
271 fn turn_id() -> TurnId {
272 TurnId::new("turn_1").unwrap()
273 }
274
275 fn second_turn_id() -> TurnId {
276 TurnId::new("turn_2").unwrap()
277 }
278
279 fn call_id() -> ToolCallId {
280 ToolCallId::new("call_1").unwrap()
281 }
282
283 fn actor_id() -> ActorId {
284 ActorId::new("human_1").unwrap()
285 }
286
287 fn usage() -> Option<ModelUsage> {
288 Some(ModelUsage {
289 input_tokens: 20,
290 output_tokens: 8,
291 })
292 }
293
294 fn rec(seq: u64, event: HarnessEvent) -> RecordedEvent {
295 RecordedEvent {
296 seq,
297 occurred_at_ms: 1_700_000_000_000 + seq,
298 event,
299 }
300 }
301
302 fn context(turn_id: TurnId, content: &str) -> HarnessEvent {
303 HarnessEvent::ContextBuilt {
304 run_id: run_id(),
305 turn_id,
306 context: ContextPack {
307 token_budget: 100,
308 fragments: vec![ContextFragment {
309 lane: ContextLane::CurrentTask,
310 source: "user".into(),
311 content: content.into(),
312 estimated_tokens: 10,
313 }],
314 },
315 }
316 }
317
318 fn model_requested(turn_id: TurnId, step: u32) -> HarnessEvent {
319 HarnessEvent::ModelRequested {
320 run_id: run_id(),
321 turn_id,
322 step,
323 model: ModelName::new("claude-fable-5").unwrap(),
324 }
325 }
326
327 fn model_failed(turn_id: TurnId, step: u32) -> HarnessEvent {
328 HarnessEvent::ModelFailed {
329 run_id: run_id(),
330 turn_id,
331 step,
332 reason: "provider unavailable".into(),
333 }
334 }
335
336 fn model_responded(
337 turn_id: TurnId,
338 step: u32,
339 content: &str,
340 proposed_calls: Vec<ToolProposal>,
341 ) -> HarnessEvent {
342 model_responded_with_served_model(turn_id, step, content, proposed_calls, None)
343 }
344
345 fn model_responded_with_served_model(
346 turn_id: TurnId,
347 step: u32,
348 content: &str,
349 proposed_calls: Vec<ToolProposal>,
350 served_model: Option<ModelName>,
351 ) -> HarnessEvent {
352 HarnessEvent::ModelResponded {
353 run_id: run_id(),
354 turn_id,
355 step,
356 output: Message {
357 role: MessageRole::Assistant,
358 content: content.into(),
359 },
360 proposed_calls,
361 served_model,
362 usage: usage(),
363 }
364 }
365
366 fn proposal() -> ToolProposal {
367 ToolProposal {
368 tool: ToolName::new("file.read").unwrap(),
369 input: json!({ "path": "README.md" }),
370 }
371 }
372
373 fn call() -> ToolCall {
374 ToolCall {
375 id: call_id(),
376 tool: ToolName::new("file.read").unwrap(),
377 effect: EffectClass::ReadOnly,
378 input: json!({ "path": "README.md" }),
379 }
380 }
381
382 fn result() -> ToolResult {
383 ToolResult {
384 call_id: call_id(),
385 summary: "read README".into(),
386 data: json!({ "bytes": 123 }),
387 artifacts: vec![],
388 visibility: ResultVisibility::Both,
389 }
390 }
391
392 fn start_event(seq: u64) -> RecordedEvent {
393 rec(
394 seq,
395 HarnessEvent::RunStarted {
396 run_id: run_id(),
397 agent_id: agent_id(),
398 },
399 )
400 }
401
402 #[test]
403 fn compaction_precedes_its_context_fragments_in_readback() {
404 let events = vec![
405 start_event(0),
406 rec(
407 1,
408 HarnessEvent::ContextCompacted {
409 run_id: run_id(),
410 turn_id: turn_id(),
411 estimated_tokens_before: 160,
412 estimated_tokens_after: 80,
413 dropped_turn_start: 0,
414 dropped_turn_end_exclusive: 2,
415 },
416 ),
417 rec(2, context(turn_id(), "Keep recent turns")),
418 rec(3, model_requested(turn_id(), 0)),
419 rec(4, model_responded(turn_id(), 0, "done", vec![])),
420 rec(5, HarnessEvent::RunFinished { run_id: run_id() }),
421 ];
422
423 let readback = RunReadback::from_events(&events).unwrap();
424
425 assert_eq!(readback.final_phase, RunPhase::Finished);
426 assert_eq!(readback.next_seq, 6);
427 assert_eq!(
428 readback.entries,
429 vec![
430 ReadbackEntry::ContextCompacted {
431 turn_id: turn_id(),
432 estimated_tokens_before: 160,
433 estimated_tokens_after: 80,
434 dropped_turn_start: 0,
435 dropped_turn_end_exclusive: 2,
436 },
437 ReadbackEntry::ContextFragment {
438 turn_id: turn_id(),
439 fragment: ContextFragment {
440 lane: ContextLane::CurrentTask,
441 source: "user".into(),
442 content: "Keep recent turns".into(),
443 estimated_tokens: 10,
444 },
445 },
446 ReadbackEntry::ModelMessage {
447 turn_id: turn_id(),
448 message: Message {
449 role: MessageRole::Assistant,
450 content: "done".into(),
451 },
452 served_model: None,
453 },
454 ]
455 );
456 }
457
458 #[test]
459 fn pre_field_v0_3_0_response_replays_as_unknown_served_model() {
460 let pre_field_response = serde_json::from_value(json!({
461 "event": "model_responded",
462 "run_id": "run_1",
463 "turn_id": "turn_1",
464 "step": 0,
465 "output": {
466 "role": "assistant",
467 "content": "It is a README."
468 },
469 "proposed_calls": [],
470 "usage": {
471 "input_tokens": 20,
472 "output_tokens": 8
473 }
474 }))
475 .unwrap();
476 let events = vec![
477 start_event(0),
478 rec(1, context(turn_id(), "What is in README?")),
479 rec(2, model_requested(turn_id(), 0)),
480 rec(3, pre_field_response),
481 rec(4, HarnessEvent::RunFinished { run_id: run_id() }),
482 ];
483
484 let readback = RunReadback::from_events(&events).unwrap();
485
486 assert_eq!(readback.final_phase, RunPhase::Finished);
487 assert_eq!(readback.next_seq, 5);
488 assert_eq!(
489 readback.entries,
490 vec![
491 ReadbackEntry::ContextFragment {
492 turn_id: turn_id(),
493 fragment: ContextFragment {
494 lane: ContextLane::CurrentTask,
495 source: "user".into(),
496 content: "What is in README?".into(),
497 estimated_tokens: 10,
498 },
499 },
500 ReadbackEntry::ModelMessage {
501 turn_id: turn_id(),
502 message: Message {
503 role: MessageRole::Assistant,
504 content: "It is a README.".into(),
505 },
506 served_model: None,
507 },
508 ]
509 );
510 }
511
512 #[test]
513 fn request_alias_and_served_model_remain_distinct_and_visible() {
514 let request_alias = ModelName::new("~openai/gpt-latest").unwrap();
515 let served_model = ModelName::new("openai/gpt-5.2-2026-07-31").unwrap();
516 let events = vec![
517 start_event(0),
518 rec(1, context(turn_id(), "Answer the question")),
519 rec(
520 2,
521 HarnessEvent::ModelRequested {
522 run_id: run_id(),
523 turn_id: turn_id(),
524 step: 0,
525 model: request_alias.clone(),
526 },
527 ),
528 rec(
529 3,
530 model_responded_with_served_model(
531 turn_id(),
532 0,
533 "Done.",
534 vec![],
535 Some(served_model.clone()),
536 ),
537 ),
538 rec(4, HarnessEvent::RunFinished { run_id: run_id() }),
539 ];
540
541 assert!(matches!(
542 &events[2].event,
543 HarnessEvent::ModelRequested { model, .. } if model == &request_alias
544 ));
545
546 let readback = RunReadback::from_events(&events).unwrap();
547 assert_eq!(
548 readback.entries.last().cloned(),
549 Some(ReadbackEntry::ModelMessage {
550 turn_id: turn_id(),
551 message: Message {
552 role: MessageRole::Assistant,
553 content: "Done.".into(),
554 },
555 served_model: Some(served_model),
556 })
557 );
558 }
559
560 #[test]
561 fn model_failure_is_replay_visible_before_a_successful_retry() {
562 let events = vec![
563 start_event(0),
564 rec(1, context(turn_id(), "What is in README?")),
565 rec(2, model_requested(turn_id(), 0)),
566 rec(3, model_failed(turn_id(), 0)),
567 rec(4, model_requested(turn_id(), 0)),
568 rec(5, model_responded(turn_id(), 0, "It is a README.", vec![])),
569 rec(6, HarnessEvent::RunFinished { run_id: run_id() }),
570 ];
571
572 let readback = RunReadback::from_events(&events).unwrap();
573
574 assert_eq!(readback.final_phase, RunPhase::Finished);
575 assert_eq!(readback.next_seq, 7);
576 assert_eq!(
577 readback.entries,
578 vec![
579 ReadbackEntry::ContextFragment {
580 turn_id: turn_id(),
581 fragment: ContextFragment {
582 lane: ContextLane::CurrentTask,
583 source: "user".into(),
584 content: "What is in README?".into(),
585 estimated_tokens: 10,
586 },
587 },
588 ReadbackEntry::ModelFailed {
589 turn_id: turn_id(),
590 step: 0,
591 reason: "provider unavailable".into(),
592 },
593 ReadbackEntry::ModelMessage {
594 turn_id: turn_id(),
595 message: Message {
596 role: MessageRole::Assistant,
597 content: "It is a README.".into(),
598 },
599 served_model: None,
600 },
601 ]
602 );
603 }
604
605 #[test]
606 fn two_turn_ledger_projects_tool_result_continuation() {
607 let events = vec![
608 start_event(0),
609 rec(1, context(turn_id(), "Read README")),
610 rec(2, model_requested(turn_id(), 0)),
611 rec(
612 3,
613 model_responded(turn_id(), 0, "I will read it.", vec![proposal()]),
614 ),
615 rec(
616 4,
617 HarnessEvent::ToolCallProposed {
618 run_id: run_id(),
619 turn_id: turn_id(),
620 call: call(),
621 },
622 ),
623 rec(
624 5,
625 HarnessEvent::PolicyEvaluated {
626 run_id: run_id(),
627 call_id: call_id(),
628 decision: PolicyDecision::Allow,
629 },
630 ),
631 rec(
632 6,
633 HarnessEvent::ToolStarted {
634 run_id: run_id(),
635 call_id: call_id(),
636 },
637 ),
638 rec(
639 7,
640 HarnessEvent::ToolFinished {
641 run_id: run_id(),
642 result: result(),
643 },
644 ),
645 rec(8, context(second_turn_id(), "Tool result: read README")),
646 rec(9, model_requested(second_turn_id(), 1)),
647 rec(
648 10,
649 model_responded(second_turn_id(), 1, "README was read.", vec![]),
650 ),
651 rec(11, HarnessEvent::RunFinished { run_id: run_id() }),
652 ];
653
654 let readback = RunReadback::from_events(&events).unwrap();
655
656 assert_eq!(readback.final_phase, RunPhase::Finished);
657 assert_eq!(readback.next_seq, 12);
658 assert_eq!(
659 readback.entries,
660 vec![
661 ReadbackEntry::ContextFragment {
662 turn_id: turn_id(),
663 fragment: ContextFragment {
664 lane: ContextLane::CurrentTask,
665 source: "user".into(),
666 content: "Read README".into(),
667 estimated_tokens: 10,
668 },
669 },
670 ReadbackEntry::ModelMessage {
671 turn_id: turn_id(),
672 message: Message {
673 role: MessageRole::Assistant,
674 content: "I will read it.".into(),
675 },
676 served_model: None,
677 },
678 ReadbackEntry::ToolCall {
679 turn_id: turn_id(),
680 call: call(),
681 },
682 ReadbackEntry::ToolResult { result: result() },
683 ReadbackEntry::ContextFragment {
684 turn_id: second_turn_id(),
685 fragment: ContextFragment {
686 lane: ContextLane::CurrentTask,
687 source: "user".into(),
688 content: "Tool result: read README".into(),
689 estimated_tokens: 10,
690 },
691 },
692 ReadbackEntry::ModelMessage {
693 turn_id: second_turn_id(),
694 message: Message {
695 role: MessageRole::Assistant,
696 content: "README was read.".into(),
697 },
698 served_model: None,
699 },
700 ]
701 );
702 }
703
704 #[test]
705 fn proposal_batch_rejection_is_replay_visible_and_allows_a_later_turn() {
706 let events = vec![
707 start_event(0),
708 rec(1, context(turn_id(), "Read README")),
709 rec(2, model_requested(turn_id(), 0)),
710 rec(
711 3,
712 model_responded(turn_id(), 0, "I will read it.", vec![proposal()]),
713 ),
714 rec(
715 4,
716 HarnessEvent::ToolProposalsRejected {
717 run_id: run_id(),
718 turn_id: turn_id(),
719 reason: "proposal schema invalid".into(),
720 },
721 ),
722 rec(
723 5,
724 context(second_turn_id(), "The proposed call was invalid"),
725 ),
726 rec(6, model_requested(second_turn_id(), 1)),
727 rec(
728 7,
729 model_responded(second_turn_id(), 1, "Understood.", vec![]),
730 ),
731 rec(8, HarnessEvent::RunFinished { run_id: run_id() }),
732 ];
733
734 let readback = RunReadback::from_events(&events).unwrap();
735
736 assert_eq!(readback.final_phase, RunPhase::Finished);
737 assert_eq!(readback.next_seq, 9);
738 assert_eq!(
739 readback.entries,
740 vec![
741 ReadbackEntry::ContextFragment {
742 turn_id: turn_id(),
743 fragment: ContextFragment {
744 lane: ContextLane::CurrentTask,
745 source: "user".into(),
746 content: "Read README".into(),
747 estimated_tokens: 10,
748 },
749 },
750 ReadbackEntry::ModelMessage {
751 turn_id: turn_id(),
752 message: Message {
753 role: MessageRole::Assistant,
754 content: "I will read it.".into(),
755 },
756 served_model: None,
757 },
758 ReadbackEntry::ToolProposalsRejected {
759 turn_id: turn_id(),
760 reason: "proposal schema invalid".into(),
761 },
762 ReadbackEntry::ContextFragment {
763 turn_id: second_turn_id(),
764 fragment: ContextFragment {
765 lane: ContextLane::CurrentTask,
766 source: "user".into(),
767 content: "The proposed call was invalid".into(),
768 estimated_tokens: 10,
769 },
770 },
771 ReadbackEntry::ModelMessage {
772 turn_id: second_turn_id(),
773 message: Message {
774 role: MessageRole::Assistant,
775 content: "Understood.".into(),
776 },
777 served_model: None,
778 },
779 ]
780 );
781 }
782
783 #[test]
784 fn denials_and_failures_are_projected_without_tool_results() {
785 let policy_denied = vec![
786 start_event(0),
787 rec(1, context(turn_id(), "Read secret")),
788 rec(2, model_requested(turn_id(), 0)),
789 rec(
790 3,
791 model_responded(turn_id(), 0, "I will read it.", vec![proposal()]),
792 ),
793 rec(
794 4,
795 HarnessEvent::ToolCallProposed {
796 run_id: run_id(),
797 turn_id: turn_id(),
798 call: call(),
799 },
800 ),
801 rec(
802 5,
803 HarnessEvent::PolicyEvaluated {
804 run_id: run_id(),
805 call_id: call_id(),
806 decision: PolicyDecision::Deny {
807 reason: "not allowed".into(),
808 },
809 },
810 ),
811 ];
812 let policy_readback = RunReadback::from_events(&policy_denied).unwrap();
813 assert!(
814 policy_readback
815 .entries
816 .contains(&ReadbackEntry::PolicyDenied {
817 call_id: call_id(),
818 reason: "not allowed".into(),
819 })
820 );
821 assert!(
822 !policy_readback
823 .entries
824 .iter()
825 .any(|entry| matches!(entry, ReadbackEntry::ToolResult { .. }))
826 );
827
828 let approval_denied = vec![
829 start_event(0),
830 rec(1, context(turn_id(), "Write README")),
831 rec(2, model_requested(turn_id(), 0)),
832 rec(
833 3,
834 model_responded(turn_id(), 0, "I will write it.", vec![proposal()]),
835 ),
836 rec(
837 4,
838 HarnessEvent::ToolCallProposed {
839 run_id: run_id(),
840 turn_id: turn_id(),
841 call: call(),
842 },
843 ),
844 rec(
845 5,
846 HarnessEvent::PolicyEvaluated {
847 run_id: run_id(),
848 call_id: call_id(),
849 decision: PolicyDecision::RequireApproval {
850 reason: "approval needed".into(),
851 },
852 },
853 ),
854 rec(
855 6,
856 HarnessEvent::ApprovalDenied {
857 run_id: run_id(),
858 call_id: call_id(),
859 actor_id: actor_id(),
860 reason: "no".into(),
861 },
862 ),
863 ];
864 let approval_readback = RunReadback::from_events(&approval_denied).unwrap();
865 assert!(
866 approval_readback
867 .entries
868 .contains(&ReadbackEntry::ApprovalDenied {
869 call_id: call_id(),
870 actor_id: actor_id(),
871 reason: "no".into(),
872 })
873 );
874 assert!(
875 !approval_readback
876 .entries
877 .iter()
878 .any(|entry| matches!(entry, ReadbackEntry::ToolResult { .. }))
879 );
880
881 let tool_failed = vec![
882 start_event(0),
883 rec(1, context(turn_id(), "Read README")),
884 rec(2, model_requested(turn_id(), 0)),
885 rec(
886 3,
887 model_responded(turn_id(), 0, "I will read it.", vec![proposal()]),
888 ),
889 rec(
890 4,
891 HarnessEvent::ToolCallProposed {
892 run_id: run_id(),
893 turn_id: turn_id(),
894 call: call(),
895 },
896 ),
897 rec(
898 5,
899 HarnessEvent::PolicyEvaluated {
900 run_id: run_id(),
901 call_id: call_id(),
902 decision: PolicyDecision::Allow,
903 },
904 ),
905 rec(
906 6,
907 HarnessEvent::ToolStarted {
908 run_id: run_id(),
909 call_id: call_id(),
910 },
911 ),
912 rec(
913 7,
914 HarnessEvent::ToolFailed {
915 run_id: run_id(),
916 call_id: call_id(),
917 reason: "tool crashed".into(),
918 },
919 ),
920 ];
921 let failure_readback = RunReadback::from_events(&tool_failed).unwrap();
922 assert!(
923 failure_readback
924 .entries
925 .contains(&ReadbackEntry::ToolFailed {
926 call_id: call_id(),
927 reason: "tool crashed".into(),
928 })
929 );
930 assert!(
931 !failure_readback
932 .entries
933 .iter()
934 .any(|entry| matches!(entry, ReadbackEntry::ToolResult { .. }))
935 );
936 }
937
938 #[test]
939 fn approval_grants_are_projected_with_actor() {
940 let events = vec![
941 start_event(0),
942 rec(1, context(turn_id(), "Read README")),
943 rec(2, model_requested(turn_id(), 0)),
944 rec(
945 3,
946 model_responded(turn_id(), 0, "I will read it.", vec![proposal()]),
947 ),
948 rec(
949 4,
950 HarnessEvent::ToolCallProposed {
951 run_id: run_id(),
952 turn_id: turn_id(),
953 call: call(),
954 },
955 ),
956 rec(
957 5,
958 HarnessEvent::PolicyEvaluated {
959 run_id: run_id(),
960 call_id: call_id(),
961 decision: PolicyDecision::RequireApproval {
962 reason: "approval needed".into(),
963 },
964 },
965 ),
966 rec(
967 6,
968 HarnessEvent::ApprovalGranted {
969 run_id: run_id(),
970 call_id: call_id(),
971 actor_id: actor_id(),
972 },
973 ),
974 ];
975
976 let readback = RunReadback::from_events(&events).unwrap();
977
978 assert_eq!(
979 readback.final_phase,
980 RunPhase::ReadyToExecuteTool { call: call() }
981 );
982 assert!(readback.entries.contains(&ReadbackEntry::ApprovalGranted {
983 call_id: call_id(),
984 actor_id: actor_id(),
985 }));
986 }
987
988 #[test]
989 fn replay_rejects_nonadjacent_turn_id_reuse() {
990 let events = vec![
991 start_event(0),
992 rec(1, context(turn_id(), "First turn")),
993 rec(2, model_requested(turn_id(), 0)),
994 rec(3, model_responded(turn_id(), 0, "done", vec![])),
995 rec(4, context(second_turn_id(), "Second turn")),
996 rec(5, model_requested(second_turn_id(), 1)),
997 rec(6, model_responded(second_turn_id(), 1, "done", vec![])),
998 rec(7, context(turn_id(), "Reused first turn")),
999 ];
1000
1001 assert_eq!(
1002 RunReadback::from_events(&events).unwrap_err(),
1003 Error::TurnReused {
1004 turn_id: "turn_1".into()
1005 }
1006 );
1007 }
1008
1009 #[test]
1010 fn replay_rejects_tool_call_id_reuse_in_a_later_turn() {
1011 let events = vec![
1012 start_event(0),
1013 rec(1, context(turn_id(), "First read")),
1014 rec(2, model_requested(turn_id(), 0)),
1015 rec(
1016 3,
1017 model_responded(turn_id(), 0, "I will read it.", vec![proposal()]),
1018 ),
1019 rec(
1020 4,
1021 HarnessEvent::ToolCallProposed {
1022 run_id: run_id(),
1023 turn_id: turn_id(),
1024 call: call(),
1025 },
1026 ),
1027 rec(
1028 5,
1029 HarnessEvent::PolicyEvaluated {
1030 run_id: run_id(),
1031 call_id: call_id(),
1032 decision: PolicyDecision::Deny {
1033 reason: "first call denied".into(),
1034 },
1035 },
1036 ),
1037 rec(6, context(second_turn_id(), "Second read")),
1038 rec(7, model_requested(second_turn_id(), 1)),
1039 rec(
1040 8,
1041 model_responded(
1042 second_turn_id(),
1043 1,
1044 "I will read it again.",
1045 vec![proposal()],
1046 ),
1047 ),
1048 rec(
1049 9,
1050 HarnessEvent::ToolCallProposed {
1051 run_id: run_id(),
1052 turn_id: second_turn_id(),
1053 call: call(),
1054 },
1055 ),
1056 ];
1057
1058 assert_eq!(
1059 RunReadback::from_events(&events).unwrap_err(),
1060 Error::ToolCallReused {
1061 call_id: "call_1".into()
1062 }
1063 );
1064 }
1065
1066 #[test]
1067 fn invalid_ledger_returns_replay_error() {
1068 let events = vec![start_event(1)];
1069
1070 let err = RunReadback::from_events(&events).unwrap_err();
1071 assert_eq!(
1072 err,
1073 Error::SequenceMismatch {
1074 expected: 0,
1075 actual: 1
1076 }
1077 );
1078 }
1079}