Skip to main content

platonic_core/
event.rs

1//! Durable harness event ledger types.
2
3use crate::{
4    ActorId, AgentId, ContextPack, Message, ModelName, PolicyDecision, RunId, ToolCall, ToolCallId,
5    ToolProposal, ToolResult, TurnId,
6};
7use serde::{Deserialize, Serialize};
8
9/// Token usage reported by a model provider.
10#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
11#[serde(deny_unknown_fields)]
12pub struct ModelUsage {
13    /// Prompt-side tokens used by the request.
14    pub input_tokens: u32,
15    /// Completion-side tokens emitted by the model.
16    pub output_tokens: u32,
17}
18
19/// One durable event with host-supplied ordering metadata.
20#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct RecordedEvent {
23    /// Per-run sequence number, contiguous from zero.
24    pub seq: u64,
25    /// Host-supplied wall-clock timestamp in milliseconds since Unix epoch.
26    pub occurred_at_ms: u64,
27    /// Recorded run fact.
28    pub event: HarnessEvent,
29}
30
31/// Durable event log entries. Transcript, metrics, replay, and audit views are derived from this log.
32///
33/// The tagged JSON schema rejects unknown fields rather than discarding durable data.
34#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "event")]
36pub enum HarnessEvent {
37    /// Binds a fresh state machine to one run and agent.
38    RunStarted {
39        /// Durable run identifier shared by every later event.
40        run_id: RunId,
41        /// Agent identity selected by the host for this run.
42        agent_id: AgentId,
43    },
44    /// Records the exact bounded context selected for a model turn.
45    ContextBuilt {
46        /// Run receiving the context.
47        run_id: RunId,
48        /// New turn identifier; concluded turn identifiers cannot be reused.
49        turn_id: TurnId,
50        /// Lane-labeled context that must pass budget validation.
51        context: ContextPack,
52    },
53    /// Records prior turns omitted from the next model context.
54    ContextCompacted {
55        /// Run receiving the compacted context.
56        run_id: RunId,
57        /// Turn whose context will reflect the compaction.
58        turn_id: TurnId,
59        /// Estimated tokens before prior turns were dropped.
60        estimated_tokens_before: u32,
61        /// Estimated tokens after prior turns were dropped.
62        estimated_tokens_after: u32,
63        /// Zero-based first dropped prior-turn position.
64        dropped_turn_start: u64,
65        /// Exclusive end of the dropped prior-turn range.
66        dropped_turn_end_exclusive: u64,
67    },
68    /// Records that a model request crossed the provider boundary.
69    ModelRequested {
70        /// Run making the request.
71        run_id: RunId,
72        /// Turn whose context is being submitted.
73        turn_id: TurnId,
74        /// Monotonic model step expected by the run state.
75        step: u32,
76        /// Host-selected model used for the request.
77        model: ModelName,
78    },
79    /// Records that the pending model request failed without terminating the run.
80    ModelFailed {
81        /// Run containing the pending request.
82        run_id: RunId,
83        /// Turn of the pending model request.
84        turn_id: TurnId,
85        /// Model step of the pending request.
86        step: u32,
87        /// Durable host-reported failure reason.
88        reason: String,
89    },
90    /// Records the normalized result returned by the pending model request.
91    ModelResponded {
92        /// Run receiving the response.
93        run_id: RunId,
94        /// Turn of the pending model request.
95        turn_id: TurnId,
96        /// Model step of the pending request.
97        step: u32,
98        /// Normalized model-authored message.
99        output: Message,
100        /// Unvalidated model proposals; an empty list concludes the turn.
101        proposed_calls: Vec<ToolProposal>,
102        /// Provider-reported model that served the response, when available.
103        ///
104        /// This is an auditable response fact. The host-selected request model
105        /// remains recorded separately on [`HarnessEvent::ModelRequested`].
106        /// Unknown values are omitted so pre-field 0.3.0 records round-trip
107        /// without gaining a field.
108        #[serde(default, skip_serializing_if = "Option::is_none")]
109        served_model: Option<ModelName>,
110        /// Provider-reported token usage, or `None` when usage is unknown.
111        ///
112        /// Reported zero counts remain known usage rather than `None`.
113        usage: Option<ModelUsage>,
114    },
115    /// Rejects every unvalidated tool proposal in the pending model response.
116    ToolProposalsRejected {
117        /// Run containing the pending proposals.
118        run_id: RunId,
119        /// Turn that produced the pending proposals.
120        turn_id: TurnId,
121        /// Non-empty host explanation for rejecting the whole proposal batch.
122        reason: String,
123    },
124    /// Records a model proposal after host validation and effect classification.
125    ToolCallProposed {
126        /// Run containing the proposal.
127        run_id: RunId,
128        /// Turn that produced the proposal.
129        turn_id: TurnId,
130        /// Host-validated call; its tool and input must match a pending proposal.
131        call: ToolCall,
132    },
133    /// Records the policy decision for the pending validated call.
134    PolicyEvaluated {
135        /// Run containing the call.
136        run_id: RunId,
137        /// Pending call evaluated by policy.
138        call_id: ToolCallId,
139        /// Durable allow, approval, or denial decision.
140        decision: PolicyDecision,
141    },
142    /// Records who granted a pending approval.
143    ApprovalGranted {
144        /// Run containing the call.
145        run_id: RunId,
146        /// Pending call approved for execution.
147        call_id: ToolCallId,
148        /// Human or host actor that granted approval.
149        actor_id: ActorId,
150    },
151    /// Records who denied a pending approval and why.
152    ApprovalDenied {
153        /// Run containing the call.
154        run_id: RunId,
155        /// Pending call denied before execution.
156        call_id: ToolCallId,
157        /// Human or host actor that denied approval.
158        actor_id: ActorId,
159        /// Durable denial reason for audit and continuation context.
160        reason: String,
161    },
162    /// Records that the host began executing the approved call.
163    ToolStarted {
164        /// Run containing the call.
165        run_id: RunId,
166        /// Approved call that crossed the execution boundary.
167        call_id: ToolCallId,
168    },
169    /// Records the structured result returned by the running call.
170    ToolFinished {
171        /// Run containing the call.
172        run_id: RunId,
173        /// Result whose call id must match the running call.
174        result: ToolResult,
175    },
176    /// Records that the running call failed without a result.
177    ToolFailed {
178        /// Run containing the call.
179        run_id: RunId,
180        /// Running call that failed.
181        call_id: ToolCallId,
182        /// Durable host-reported failure reason.
183        reason: String,
184    },
185    /// Terminates a concluded turn as a successful run.
186    RunFinished {
187        /// Run that completed.
188        run_id: RunId,
189    },
190    /// Terminates any started, nonterminal run as failed.
191    RunFailed {
192        /// Run that failed.
193        run_id: RunId,
194        /// Durable terminal failure reason.
195        reason: String,
196    },
197}
198
199impl HarnessEvent {
200    /// Returns the owning run id without validating event order or phase.
201    pub fn run_id(&self) -> &RunId {
202        match self {
203            Self::RunStarted { run_id, .. }
204            | Self::ContextBuilt { run_id, .. }
205            | Self::ContextCompacted { run_id, .. }
206            | Self::ModelRequested { run_id, .. }
207            | Self::ModelFailed { run_id, .. }
208            | Self::ModelResponded { run_id, .. }
209            | Self::ToolProposalsRejected { run_id, .. }
210            | Self::ToolCallProposed { run_id, .. }
211            | Self::PolicyEvaluated { run_id, .. }
212            | Self::ApprovalGranted { run_id, .. }
213            | Self::ApprovalDenied { run_id, .. }
214            | Self::ToolStarted { run_id, .. }
215            | Self::ToolFinished { run_id, .. }
216            | Self::ToolFailed { run_id, .. }
217            | Self::RunFinished { run_id }
218            | Self::RunFailed { run_id, .. } => run_id,
219        }
220    }
221
222    /// Returns the stable snake-case event name used in transition diagnostics.
223    pub fn name(&self) -> &'static str {
224        match self {
225            Self::RunStarted { .. } => "run_started",
226            Self::ContextBuilt { .. } => "context_built",
227            Self::ContextCompacted { .. } => "context_compacted",
228            Self::ModelRequested { .. } => "model_requested",
229            Self::ModelFailed { .. } => "model_failed",
230            Self::ModelResponded { .. } => "model_responded",
231            Self::ToolProposalsRejected { .. } => "tool_proposals_rejected",
232            Self::ToolCallProposed { .. } => "tool_call_proposed",
233            Self::PolicyEvaluated { .. } => "policy_evaluated",
234            Self::ApprovalGranted { .. } => "approval_granted",
235            Self::ApprovalDenied { .. } => "approval_denied",
236            Self::ToolStarted { .. } => "tool_started",
237            Self::ToolFinished { .. } => "tool_finished",
238            Self::ToolFailed { .. } => "tool_failed",
239            Self::RunFinished { .. } => "run_finished",
240            Self::RunFailed { .. } => "run_failed",
241        }
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::{HarnessEvent, ModelUsage, RecordedEvent};
248    use crate::{
249        ActorId, AgentId, ArtifactId, ContextFragment, ContextLane, ContextPack, EffectClass,
250        Message, MessageRole, ModelName, PolicyDecision, ResultVisibility, RunId, ToolCall,
251        ToolCallId, ToolName, ToolProposal, ToolResult, TurnId,
252    };
253    use serde_json::json;
254
255    const ACCEPTED_V0_2_0_HARNESS_EVENT_JSON: &str =
256        r#"{"event":"run_started","run_id":"run_1","agent_id":"agent_1"}"#;
257    const REJECTED_UNKNOWN_FIELD_HARNESS_EVENT_JSON: &str =
258        r#"{"event":"run_started","run_id":"run_1","agent_id":"agent_1","future_field":true}"#;
259    const PRE_FIELD_V0_3_0_MODEL_RESPONDED_JSON: &str = r#"{"seq":7,"occurred_at_ms":1700000000000,"event":{"event":"model_responded","run_id":"run_1","turn_id":"turn_1","step":1,"output":{"role":"assistant","content":"Done."},"proposed_calls":[],"usage":{"input_tokens":12,"output_tokens":4}}}"#;
260
261    #[test]
262    fn harness_event_json_schema_is_fail_closed_and_v0_2_0_compatible() {
263        let accepted: HarnessEvent =
264            serde_json::from_str(ACCEPTED_V0_2_0_HARNESS_EVENT_JSON).unwrap();
265        assert_eq!(
266            accepted,
267            HarnessEvent::RunStarted {
268                run_id: RunId::new("run_1").unwrap(),
269                agent_id: AgentId::new("agent_1").unwrap(),
270            }
271        );
272
273        let error = serde_json::from_str::<HarnessEvent>(REJECTED_UNKNOWN_FIELD_HARNESS_EVENT_JSON)
274            .unwrap_err();
275        assert!(error.to_string().contains("unknown field `future_field`"));
276    }
277
278    #[test]
279    fn recorded_event_json_fixtures_are_bidirectional() {
280        let run_id = RunId::new("run_1").unwrap();
281        let turn_id = TurnId::new("turn_1").unwrap();
282        let call_id = ToolCallId::new("call_1").unwrap();
283        let tool = ToolName::new("file.read").unwrap();
284
285        let events = [
286            HarnessEvent::RunStarted {
287                run_id: run_id.clone(),
288                agent_id: AgentId::new("agent_1").unwrap(),
289            },
290            HarnessEvent::ContextBuilt {
291                run_id: run_id.clone(),
292                turn_id: turn_id.clone(),
293                context: ContextPack {
294                    token_budget: 2_048,
295                    fragments: vec![
296                        ContextFragment {
297                            lane: ContextLane::CurrentTask,
298                            source: "user".into(),
299                            content: "Summarize README.md".into(),
300                            estimated_tokens: 5,
301                        },
302                        ContextFragment {
303                            lane: ContextLane::RetrievedContext,
304                            source: "README.md".into(),
305                            content: "Platonic core".into(),
306                            estimated_tokens: 3,
307                        },
308                    ],
309                },
310            },
311            HarnessEvent::ContextCompacted {
312                run_id: run_id.clone(),
313                turn_id: turn_id.clone(),
314                estimated_tokens_before: 4_096,
315                estimated_tokens_after: 2_048,
316                dropped_turn_start: 0,
317                dropped_turn_end_exclusive: 3,
318            },
319            HarnessEvent::ModelRequested {
320                run_id: run_id.clone(),
321                turn_id: turn_id.clone(),
322                step: 1,
323                model: ModelName::new("model_1").unwrap(),
324            },
325            HarnessEvent::ModelFailed {
326                run_id: run_id.clone(),
327                turn_id: turn_id.clone(),
328                step: 1,
329                reason: "provider unavailable".into(),
330            },
331            HarnessEvent::ModelResponded {
332                run_id: run_id.clone(),
333                turn_id: turn_id.clone(),
334                step: 1,
335                output: Message {
336                    role: MessageRole::Assistant,
337                    content: "Reading the file.".into(),
338                },
339                proposed_calls: vec![ToolProposal {
340                    tool: tool.clone(),
341                    input: json!({ "path": "README.md" }),
342                }],
343                served_model: Some(ModelName::new("provider/model_1-2026-07-31").unwrap()),
344                usage: Some(ModelUsage {
345                    input_tokens: 12,
346                    output_tokens: 4,
347                }),
348            },
349            HarnessEvent::ToolProposalsRejected {
350                run_id: run_id.clone(),
351                turn_id: turn_id.clone(),
352                reason: "proposal schema invalid".into(),
353            },
354            HarnessEvent::ToolCallProposed {
355                run_id: run_id.clone(),
356                turn_id: turn_id.clone(),
357                call: ToolCall {
358                    id: call_id.clone(),
359                    tool: tool.clone(),
360                    effect: EffectClass::ReadOnly,
361                    input: json!({ "path": "README.md" }),
362                },
363            },
364            HarnessEvent::PolicyEvaluated {
365                run_id: run_id.clone(),
366                call_id: call_id.clone(),
367                decision: PolicyDecision::RequireApproval {
368                    reason: "workspace write requires approval".into(),
369                },
370            },
371            HarnessEvent::ApprovalGranted {
372                run_id: run_id.clone(),
373                call_id: call_id.clone(),
374                actor_id: ActorId::new("actor_1").unwrap(),
375            },
376            HarnessEvent::ApprovalDenied {
377                run_id: run_id.clone(),
378                call_id: call_id.clone(),
379                actor_id: ActorId::new("actor_1").unwrap(),
380                reason: "not approved".into(),
381            },
382            HarnessEvent::ToolStarted {
383                run_id: run_id.clone(),
384                call_id: call_id.clone(),
385            },
386            HarnessEvent::ToolFinished {
387                run_id: run_id.clone(),
388                result: ToolResult {
389                    call_id: call_id.clone(),
390                    summary: "read 13 bytes".into(),
391                    data: json!({ "contents": "Platonic core" }),
392                    artifacts: vec![ArtifactId::new("artifact_1").unwrap()],
393                    visibility: ResultVisibility::Both,
394                },
395            },
396            HarnessEvent::ToolFailed {
397                run_id: run_id.clone(),
398                call_id,
399                reason: "file not found".into(),
400            },
401            HarnessEvent::RunFinished {
402                run_id: run_id.clone(),
403            },
404            HarnessEvent::RunFailed {
405                run_id,
406                reason: "model unavailable".into(),
407            },
408        ];
409
410        for event in events {
411            let name = event.name();
412            let fixture = match &event {
413                HarnessEvent::RunStarted { .. } => json!({
414                    "seq": 7,
415                    "occurred_at_ms": 1_700_000_000_000_u64,
416                    "event": {
417                        "event": "run_started",
418                        "run_id": "run_1",
419                        "agent_id": "agent_1"
420                    }
421                }),
422                HarnessEvent::ContextBuilt { .. } => json!({
423                    "seq": 7,
424                    "occurred_at_ms": 1_700_000_000_000_u64,
425                    "event": {
426                        "event": "context_built",
427                        "run_id": "run_1",
428                        "turn_id": "turn_1",
429                        "context": {
430                            "token_budget": 2_048,
431                            "fragments": [
432                                {
433                                    "lane": "current_task",
434                                    "source": "user",
435                                    "content": "Summarize README.md",
436                                    "estimated_tokens": 5
437                                },
438                                {
439                                    "lane": "retrieved_context",
440                                    "source": "README.md",
441                                    "content": "Platonic core",
442                                    "estimated_tokens": 3
443                                }
444                            ]
445                        }
446                    }
447                }),
448                HarnessEvent::ContextCompacted { .. } => json!({
449                    "seq": 7,
450                    "occurred_at_ms": 1_700_000_000_000_u64,
451                    "event": {
452                        "event": "context_compacted",
453                        "run_id": "run_1",
454                        "turn_id": "turn_1",
455                        "estimated_tokens_before": 4_096,
456                        "estimated_tokens_after": 2_048,
457                        "dropped_turn_start": 0,
458                        "dropped_turn_end_exclusive": 3
459                    }
460                }),
461                HarnessEvent::ModelRequested { .. } => json!({
462                    "seq": 7,
463                    "occurred_at_ms": 1_700_000_000_000_u64,
464                    "event": {
465                        "event": "model_requested",
466                        "run_id": "run_1",
467                        "turn_id": "turn_1",
468                        "step": 1,
469                        "model": "model_1"
470                    }
471                }),
472                HarnessEvent::ModelFailed { .. } => json!({
473                    "seq": 7,
474                    "occurred_at_ms": 1_700_000_000_000_u64,
475                    "event": {
476                        "event": "model_failed",
477                        "run_id": "run_1",
478                        "turn_id": "turn_1",
479                        "step": 1,
480                        "reason": "provider unavailable"
481                    }
482                }),
483                HarnessEvent::ModelResponded { .. } => json!({
484                    "seq": 7,
485                    "occurred_at_ms": 1_700_000_000_000_u64,
486                    "event": {
487                        "event": "model_responded",
488                        "run_id": "run_1",
489                        "turn_id": "turn_1",
490                        "step": 1,
491                        "output": {
492                            "role": "assistant",
493                            "content": "Reading the file."
494                        },
495                        "proposed_calls": [
496                            {
497                                "tool": "file.read",
498                                "input": { "path": "README.md" }
499                            }
500                        ],
501                        "served_model": "provider/model_1-2026-07-31",
502                        "usage": {
503                            "input_tokens": 12,
504                            "output_tokens": 4
505                        }
506                    }
507                }),
508                HarnessEvent::ToolProposalsRejected { .. } => json!({
509                    "seq": 7,
510                    "occurred_at_ms": 1_700_000_000_000_u64,
511                    "event": {
512                        "event": "tool_proposals_rejected",
513                        "run_id": "run_1",
514                        "turn_id": "turn_1",
515                        "reason": "proposal schema invalid"
516                    }
517                }),
518                HarnessEvent::ToolCallProposed { .. } => json!({
519                    "seq": 7,
520                    "occurred_at_ms": 1_700_000_000_000_u64,
521                    "event": {
522                        "event": "tool_call_proposed",
523                        "run_id": "run_1",
524                        "turn_id": "turn_1",
525                        "call": {
526                            "id": "call_1",
527                            "tool": "file.read",
528                            "effect": "read_only",
529                            "input": { "path": "README.md" }
530                        }
531                    }
532                }),
533                HarnessEvent::PolicyEvaluated { .. } => json!({
534                    "seq": 7,
535                    "occurred_at_ms": 1_700_000_000_000_u64,
536                    "event": {
537                        "event": "policy_evaluated",
538                        "run_id": "run_1",
539                        "call_id": "call_1",
540                        "decision": {
541                            "decision": "require_approval",
542                            "reason": "workspace write requires approval"
543                        }
544                    }
545                }),
546                HarnessEvent::ApprovalGranted { .. } => json!({
547                    "seq": 7,
548                    "occurred_at_ms": 1_700_000_000_000_u64,
549                    "event": {
550                        "event": "approval_granted",
551                        "run_id": "run_1",
552                        "call_id": "call_1",
553                        "actor_id": "actor_1"
554                    }
555                }),
556                HarnessEvent::ApprovalDenied { .. } => json!({
557                    "seq": 7,
558                    "occurred_at_ms": 1_700_000_000_000_u64,
559                    "event": {
560                        "event": "approval_denied",
561                        "run_id": "run_1",
562                        "call_id": "call_1",
563                        "actor_id": "actor_1",
564                        "reason": "not approved"
565                    }
566                }),
567                HarnessEvent::ToolStarted { .. } => json!({
568                    "seq": 7,
569                    "occurred_at_ms": 1_700_000_000_000_u64,
570                    "event": {
571                        "event": "tool_started",
572                        "run_id": "run_1",
573                        "call_id": "call_1"
574                    }
575                }),
576                HarnessEvent::ToolFinished { .. } => json!({
577                    "seq": 7,
578                    "occurred_at_ms": 1_700_000_000_000_u64,
579                    "event": {
580                        "event": "tool_finished",
581                        "run_id": "run_1",
582                        "result": {
583                            "call_id": "call_1",
584                            "summary": "read 13 bytes",
585                            "data": { "contents": "Platonic core" },
586                            "artifacts": ["artifact_1"],
587                            "visibility": "both"
588                        }
589                    }
590                }),
591                HarnessEvent::ToolFailed { .. } => json!({
592                    "seq": 7,
593                    "occurred_at_ms": 1_700_000_000_000_u64,
594                    "event": {
595                        "event": "tool_failed",
596                        "run_id": "run_1",
597                        "call_id": "call_1",
598                        "reason": "file not found"
599                    }
600                }),
601                HarnessEvent::RunFinished { .. } => json!({
602                    "seq": 7,
603                    "occurred_at_ms": 1_700_000_000_000_u64,
604                    "event": {
605                        "event": "run_finished",
606                        "run_id": "run_1"
607                    }
608                }),
609                HarnessEvent::RunFailed { .. } => json!({
610                    "seq": 7,
611                    "occurred_at_ms": 1_700_000_000_000_u64,
612                    "event": {
613                        "event": "run_failed",
614                        "run_id": "run_1",
615                        "reason": "model unavailable"
616                    }
617                }),
618            };
619
620            let expected = RecordedEvent {
621                seq: 7,
622                occurred_at_ms: 1_700_000_000_000,
623                event,
624            };
625            let decoded: RecordedEvent = serde_json::from_value(fixture.clone()).unwrap();
626
627            assert_eq!(decoded, expected, "failed to decode {name} fixture");
628            assert_eq!(
629                serde_json::to_value(&expected).unwrap(),
630                fixture,
631                "failed to encode {name} fixture"
632            );
633        }
634    }
635
636    #[test]
637    fn model_response_usage_json_fixtures_are_bidirectional() {
638        let fixtures = [
639            (
640                "known",
641                json!({
642                    "seq": 7,
643                    "occurred_at_ms": 1_700_000_000_000_u64,
644                    "event": {
645                        "event": "model_responded",
646                        "run_id": "run_1",
647                        "turn_id": "turn_1",
648                        "step": 1,
649                        "output": {
650                            "role": "assistant",
651                            "content": "Done."
652                        },
653                        "proposed_calls": [],
654                        "usage": {
655                            "input_tokens": 12,
656                            "output_tokens": 4
657                        }
658                    }
659                }),
660                Some(ModelUsage {
661                    input_tokens: 12,
662                    output_tokens: 4,
663                }),
664            ),
665            (
666                "zero",
667                json!({
668                    "seq": 7,
669                    "occurred_at_ms": 1_700_000_000_000_u64,
670                    "event": {
671                        "event": "model_responded",
672                        "run_id": "run_1",
673                        "turn_id": "turn_1",
674                        "step": 1,
675                        "output": {
676                            "role": "assistant",
677                            "content": "Done."
678                        },
679                        "proposed_calls": [],
680                        "usage": {
681                            "input_tokens": 0,
682                            "output_tokens": 0
683                        }
684                    }
685                }),
686                Some(ModelUsage {
687                    input_tokens: 0,
688                    output_tokens: 0,
689                }),
690            ),
691            (
692                "unknown",
693                json!({
694                    "seq": 7,
695                    "occurred_at_ms": 1_700_000_000_000_u64,
696                    "event": {
697                        "event": "model_responded",
698                        "run_id": "run_1",
699                        "turn_id": "turn_1",
700                        "step": 1,
701                        "output": {
702                            "role": "assistant",
703                            "content": "Done."
704                        },
705                        "proposed_calls": [],
706                        "usage": null
707                    }
708                }),
709                None,
710            ),
711        ];
712
713        for (name, fixture, usage) in fixtures {
714            let expected = RecordedEvent {
715                seq: 7,
716                occurred_at_ms: 1_700_000_000_000,
717                event: HarnessEvent::ModelResponded {
718                    run_id: RunId::new("run_1").unwrap(),
719                    turn_id: TurnId::new("turn_1").unwrap(),
720                    step: 1,
721                    output: Message {
722                        role: MessageRole::Assistant,
723                        content: "Done.".into(),
724                    },
725                    proposed_calls: vec![],
726                    served_model: None,
727                    usage,
728                },
729            };
730            let decoded: RecordedEvent = serde_json::from_value(fixture.clone()).unwrap();
731
732            assert_eq!(decoded, expected, "failed to decode {name} usage fixture");
733            assert_eq!(
734                serde_json::to_value(&expected).unwrap(),
735                fixture,
736                "failed to encode {name} usage fixture"
737            );
738        }
739    }
740
741    #[test]
742    fn served_model_json_fixtures_are_bidirectional_and_v0_3_0_compatible() {
743        let fixtures = [
744            (
745                "known",
746                json!({
747                    "seq": 7,
748                    "occurred_at_ms": 1_700_000_000_000_u64,
749                    "event": {
750                        "event": "model_responded",
751                        "run_id": "run_1",
752                        "turn_id": "turn_1",
753                        "step": 1,
754                        "output": {
755                            "role": "assistant",
756                            "content": "Done."
757                        },
758                        "proposed_calls": [],
759                        "served_model": "openai/gpt-5.2-2026-07-31",
760                        "usage": null
761                    }
762                }),
763                Some(ModelName::new("openai/gpt-5.2-2026-07-31").unwrap()),
764            ),
765            (
766                "unknown",
767                json!({
768                    "seq": 7,
769                    "occurred_at_ms": 1_700_000_000_000_u64,
770                    "event": {
771                        "event": "model_responded",
772                        "run_id": "run_1",
773                        "turn_id": "turn_1",
774                        "step": 1,
775                        "output": {
776                            "role": "assistant",
777                            "content": "Done."
778                        },
779                        "proposed_calls": [],
780                        "usage": null
781                    }
782                }),
783                None,
784            ),
785        ];
786
787        for (name, fixture, served_model) in fixtures {
788            let expected = RecordedEvent {
789                seq: 7,
790                occurred_at_ms: 1_700_000_000_000,
791                event: HarnessEvent::ModelResponded {
792                    run_id: RunId::new("run_1").unwrap(),
793                    turn_id: TurnId::new("turn_1").unwrap(),
794                    step: 1,
795                    output: Message {
796                        role: MessageRole::Assistant,
797                        content: "Done.".into(),
798                    },
799                    proposed_calls: vec![],
800                    served_model,
801                    usage: None,
802                },
803            };
804            let decoded: RecordedEvent = serde_json::from_value(fixture.clone()).unwrap();
805
806            assert_eq!(decoded, expected, "failed to decode {name} fixture");
807            assert_eq!(
808                serde_json::to_value(&expected).unwrap(),
809                fixture,
810                "failed to encode {name} fixture"
811            );
812        }
813
814        let pre_field_fixture =
815            serde_json::from_str::<serde_json::Value>(PRE_FIELD_V0_3_0_MODEL_RESPONDED_JSON)
816                .unwrap();
817        let decoded: RecordedEvent = serde_json::from_value(pre_field_fixture.clone()).unwrap();
818        let expected = RecordedEvent {
819            seq: 7,
820            occurred_at_ms: 1_700_000_000_000,
821            event: HarnessEvent::ModelResponded {
822                run_id: RunId::new("run_1").unwrap(),
823                turn_id: TurnId::new("turn_1").unwrap(),
824                step: 1,
825                output: Message {
826                    role: MessageRole::Assistant,
827                    content: "Done.".into(),
828                },
829                proposed_calls: vec![],
830                served_model: None,
831                usage: Some(ModelUsage {
832                    input_tokens: 12,
833                    output_tokens: 4,
834                }),
835            },
836        };
837
838        assert_eq!(decoded, expected);
839        assert_eq!(serde_json::to_value(expected).unwrap(), pre_field_fixture);
840    }
841
842    #[test]
843    fn served_model_reuses_model_name_validation() {
844        let mut fixture = json!({
845            "seq": 7,
846            "occurred_at_ms": 1_700_000_000_000_u64,
847            "event": {
848                "event": "model_responded",
849                "run_id": "run_1",
850                "turn_id": "turn_1",
851                "step": 1,
852                "output": {
853                    "role": "assistant",
854                    "content": "Done."
855                },
856                "proposed_calls": [],
857                "served_model": " ",
858                "usage": null
859            }
860        });
861        let error = serde_json::from_value::<RecordedEvent>(fixture.clone()).unwrap_err();
862
863        assert!(error.to_string().contains("ModelName cannot be empty"));
864
865        fixture["event"]["served_model"] = json!(7);
866        assert!(serde_json::from_value::<RecordedEvent>(fixture).is_err());
867    }
868}