Skip to main content

locode_protocol/
lib.rs

1//! locode-protocol — shared, provider-neutral types with no I/O.
2//!
3//! Two concerns live here:
4//! - the **conversation model** (4-role, Anthropic-shaped content blocks — [ADR-0013]),
5//!   which the loop accumulates and hands to a `Provider`; and
6//! - the **report envelope** ([ADR-0009]), the single JSON artifact `locode-exec` prints.
7//!
8//! Types are Rust-native and serialize with `serde` for our own persistence/reporting;
9//! conversion to a specific provider wire (Anthropic, OpenAI) lives in each `Provider`
10//! impl, not here.
11//!
12//! [ADR-0013]: https://github.com/Luolc/locode-core/blob/main/docs/decisions/ADR-0013-conversation-protocol.md
13//! [ADR-0009]: https://github.com/Luolc/locode-core/blob/main/docs/decisions/ADR-0009-headless-io-contract.md
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18// ============================ Conversation model ============================
19
20/// A full conversation: one uniform stream of role-tagged messages (ADR-0013).
21///
22/// There is no separate `system` field — a [`Role::System`] message *is* the base
23/// prompt; the Anthropic wire hoists leading System messages into its top-level
24/// `system` param.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct Conversation {
27    /// The ordered turns of the conversation.
28    pub messages: Vec<Message>,
29}
30
31/// One message: a role plus an ordered list of content blocks.
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct Message {
34    /// Who this message is from.
35    pub role: Role,
36    /// The message's content, as typed blocks.
37    pub content: Vec<ContentBlock>,
38}
39
40/// The author of a message (ADR-0013).
41///
42/// `System` is the static base identity; `Developer` is client-injected instructions
43/// and dynamic context. On the wire, `System` maps to Anthropic's top-level `system`
44/// (or an OpenAI `system` message), while `Developer` maps to an Anthropic
45/// mid-conversation `system` message (or an OpenAI `developer` message).
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum Role {
49    /// Immutable base identity and safety/policy — the "constitution".
50    System,
51    /// App-author instructions and dynamically injected operational context.
52    Developer,
53    /// The human's turns; also carries [`ContentBlock::ToolResult`] blocks.
54    User,
55    /// The model's turns: text, thinking, and tool-use blocks.
56    Assistant,
57}
58
59/// A typed piece of message content, modeled on Anthropic's content blocks.
60///
61/// `#[non_exhaustive]` so new block kinds can be added without a breaking change;
62/// only `Text`, `ToolUse`, and `ToolResult` are exercised in v0.
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(tag = "type", rename_all = "snake_case")]
65#[non_exhaustive]
66pub enum ContentBlock {
67    /// Plain text.
68    Text {
69        /// The text content.
70        text: String,
71    },
72    /// An image (multimodal).
73    Image {
74        /// Where the image bytes come from.
75        source: ImageSource,
76    },
77    /// Assistant reasoning, unified across wires (ADR-0013 amendment
78    /// 2026-07-19; replaces the earlier `Thinking`/`RedactedThinking` pair).
79    ///
80    /// [`ReasoningFormat`] selects the replay contract; each wire's build
81    /// replays only its own format(s) and **drops foreign formats** (a session
82    /// never crosses wires, so nothing is lost).
83    Reasoning {
84        /// Which encoding/replay contract this data follows.
85        format: ReasoningFormat,
86        /// Human-readable reasoning: the full text (`anthropic`), empty
87        /// (`anthropic_redacted`), the summary (`openai_responses`), or the
88        /// captured text (`text_only`).
89        text: String,
90        /// Anthropic's validator over `text` (`anthropic` format only).
91        signature: Option<String>,
92        /// The wire's opaque replay payload, replayed verbatim and never
93        /// interpreted: the whole Responses reasoning item
94        /// (`openai_responses`) or Anthropic's redacted-thinking data
95        /// (`anthropic_redacted`).
96        payload: Option<Value>,
97    },
98    /// A tool call emitted by the assistant.
99    ToolUse {
100        /// Provider-assigned id, paired with a later [`ContentBlock::ToolResult`].
101        id: String,
102        /// The client-facing tool name (the harness pack's name).
103        name: String,
104        /// The tool arguments as a JSON value.
105        input: Value,
106    },
107    /// The result of a tool call, carried in a [`Role::User`] message.
108    ToolResult {
109        /// The id of the [`ContentBlock::ToolUse`] this answers.
110        tool_use_id: String,
111        /// The result content (text and/or images).
112        content: Vec<ResultChunk>,
113        /// Whether the tool call failed (a soft error the model can recover from).
114        is_error: bool,
115    },
116}
117
118/// A single chunk of a tool result (a restricted set of block kinds).
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120#[serde(tag = "type", rename_all = "snake_case")]
121pub enum ResultChunk {
122    /// Text output.
123    Text {
124        /// The text content.
125        text: String,
126    },
127    /// Image output (e.g. a screenshot).
128    Image {
129        /// Where the image bytes come from.
130        source: ImageSource,
131    },
132}
133
134/// The source of an image block.
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136#[serde(tag = "type", rename_all = "snake_case")]
137pub enum ImageSource {
138    /// Inline base64-encoded bytes.
139    Base64 {
140        /// The MIME type, e.g. `image/png`.
141        media_type: String,
142        /// The base64-encoded image data.
143        data: String,
144    },
145    /// A URL the provider fetches.
146    Url {
147        /// The image URL.
148        url: String,
149    },
150}
151
152/// The encoding/replay contract of a [`ContentBlock::Reasoning`] block.
153///
154/// Named after the wire's own vocabulary (Responses reasoning items tag
155/// themselves `format: "openai-responses-v1"`). Serialized values deliberately
156/// echo the `api_schema` strings so a trace reader maps block → wire at a
157/// glance.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160#[non_exhaustive]
161pub enum ReasoningFormat {
162    /// Anthropic extended thinking: full `text` + `signature` validator.
163    Anthropic,
164    /// Anthropic `redacted_thinking`: encrypted `payload`, empty `text`.
165    AnthropicRedacted,
166    /// OpenAI Responses reasoning item: summary in `text`, the WHOLE item in
167    /// `payload` (id + summary + `encrypted_content` + `format` + future fields).
168    OpenAiResponses,
169    /// Capture-only reasoning with no replay contract (e.g. Chat Completions
170    /// gateway extensions). Never replayed by any wire.
171    TextOnly,
172}
173
174// ============================== Report envelope ==============================
175
176/// The single JSON document `locode-exec` prints to stdout (ADR-0009).
177///
178/// `schema_version` is frozen at `1`; changing the envelope shape is a deliberate,
179/// versioned change (see the golden test).
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181pub struct Report {
182    /// Envelope schema version. Always `1` for this contract.
183    pub schema_version: u32,
184    /// The terminal state of the run.
185    pub status: Status,
186    /// The harness pack the run used (e.g. `grok`).
187    pub harness: String,
188    /// The wire schema the run used (e.g. `anthropic`) — the provider's `api_schema()`.
189    /// Names the request/response protocol shape, not a gateway/endpoint.
190    pub api_schema: String,
191    /// The assistant's final text message, if the run completed with one.
192    pub final_message: Option<String>,
193    /// A schema-constrained task answer, if one was requested (`--json-schema`).
194    pub structured_output: Option<Value>,
195    /// How many sample→dispatch→append turns the loop ran.
196    pub turns: u32,
197    /// A record of every tool call made during the run.
198    pub tool_calls: Vec<ToolCallRecord>,
199    /// Token accounting for the run.
200    pub usage: Usage,
201    /// The session identifier.
202    pub session_id: String,
203    /// The final model stop reason (`"end_turn"`, `"max_tokens"`, …), when a
204    /// completion was received (ADR-0009 amendment 2026-07-19): lets an eval
205    /// pipeline distinguish "model finished" from "model got truncated"
206    /// without re-reading the trace.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub stop_reason: Option<String>,
209    /// A fatal error message, if the run ended in `status == error`/`model_error`.
210    pub error: Option<String>,
211}
212
213/// The terminal state of a run. Serializes to the exact strings in ADR-0009.
214///
215/// **Envelope evolution policy at `schema_version: 1` (ADR-0018):** additions
216/// — new status values, new optional record/report fields — are
217/// **non-breaking** and do not bump `schema_version`; renames and removals
218/// are breaking and would. JSON consumers should therefore treat an
219/// unrecognized status string as "unknown terminal state", not a parse
220/// error; Rust consumers get the same discipline from `#[non_exhaustive]`
221/// (match with a wildcard arm — `locode-exec` maps unknown statuses to
222/// exit 1).
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(rename_all = "snake_case")]
225#[non_exhaustive]
226pub enum Status {
227    /// The model finished with a text answer and no further tool calls.
228    Completed,
229    /// The max-turns ceiling was hit.
230    MaxTurns,
231    /// A provider/network error after bounded retry.
232    ModelError,
233    /// A fatal (`Tool`/host) error aborted the run.
234    Error,
235    /// The run was cancelled through the session's cancel handle (Esc, a
236    /// SIGTERM-driven timeout, …) — a **structured** terminal state, distinct
237    /// from failure (ADR-0018): partial work is preserved and the report is
238    /// still emitted.
239    Cancelled,
240}
241
242/// A report-side record of one tool call (distinct from the in-conversation
243/// [`ContentBlock::ToolUse`]): the structured `output` view, not the model-facing text.
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245pub struct ToolCallRecord {
246    /// The tool-call id (matches the conversation's `tool_use` id).
247    pub id: String,
248    /// The client-facing tool name the model called.
249    pub name: String,
250    /// The canonical `ToolKind` tag for cross-pack A/B alignment (e.g. `shell`).
251    pub kind: String,
252    /// The arguments the model supplied.
253    pub args: Value,
254    /// Whether the call succeeded.
255    pub ok: bool,
256    /// The structured output of the call (the report view, not `prompt_text`).
257    pub output: Value,
258    /// The approver's reason, iff this call was **denied by the approval seam**
259    /// (ADR-0017). Set only from the approver-deny path — never reused for
260    /// other failures, and cancellation synthetics never carry it — so deny
261    /// stays structurally separable from failure and from cancel (the
262    /// codex-`Declined` / grok-taxonomy lesson).
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub denial_reason: Option<String>,
265}
266
267/// Token accounting parsed from the provider's terminal usage event.
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
269pub struct Usage {
270    /// Input (prompt) tokens.
271    pub input_tokens: u64,
272    /// Output (completion) tokens.
273    pub output_tokens: u64,
274    /// Tokens served from the prompt cache. **`None` = this wire/provider does
275    /// not report the counter; `Some(0)` = reported as zero** (a real signal:
276    /// no cache hit). ADR-0009 amendment 2026-07-19 — zero-as-N/A rejected.
277    pub cache_read_tokens: Option<u64>,
278    /// Tokens written to the prompt cache (`None` on wires that never report
279    /// writes, e.g. the OpenAI family).
280    pub cache_creation_tokens: Option<u64>,
281    /// Reasoning/thinking tokens (`None` on wires that fold them into
282    /// `output_tokens`, e.g. Anthropic).
283    pub reasoning_tokens: Option<u64>,
284}
285
286/// `Some+Some` sums; `None` is the identity — a run total is `None` only if no
287/// turn ever reported the counter.
288fn add_opt(a: Option<u64>, b: Option<u64>) -> Option<u64> {
289    match (a, b) {
290        (Some(x), Some(y)) => Some(x + y),
291        (Some(x), None) | (None, Some(x)) => Some(x),
292        (None, None) => None,
293    }
294}
295
296impl std::ops::AddAssign for Usage {
297    /// Accumulate another turn's usage field-wise (the engine sums across turns).
298    fn add_assign(&mut self, rhs: Self) {
299        self.input_tokens += rhs.input_tokens;
300        self.output_tokens += rhs.output_tokens;
301        self.cache_read_tokens = add_opt(self.cache_read_tokens, rhs.cache_read_tokens);
302        self.cache_creation_tokens = add_opt(self.cache_creation_tokens, rhs.cache_creation_tokens);
303        self.reasoning_tokens = add_opt(self.reasoning_tokens, rhs.reasoning_tokens);
304    }
305}
306
307// ================================= Tool spec =================================
308
309/// A provider-neutral tool spec: name + description + args JSON Schema.
310///
311/// This is the wire-agnostic representation a harness pack produces (from a
312/// `Registry`) and a `Provider` wire maps onto its own tool format (e.g. Anthropic's
313/// `{name, description, input_schema}` vs OpenAI's `{type:"function", function:{…}}`).
314/// It lives in `locode-protocol` because both `locode-tools` (which builds it) and
315/// `locode-provider` (which consumes it via `ConversationRequest`) need it, and the
316/// dependency graph forbids `provider → tools`.
317#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
318pub struct ToolSpec {
319    /// The model-facing wire name (the harness pack's name for the tool).
320    pub name: String,
321    /// The tool description offered to the model.
322    pub description: String,
323    /// How the tool's input is specified to the model (ADR-0003 amendment
324    /// 2026-07-19; replaces the bare `parameters: Value`).
325    pub input: ToolInputFormat,
326}
327
328/// How a tool's input is specified: a JSON-schema function tool, or a freeform
329/// tool whose raw-text input is constrained by a server-side grammar (OpenAI
330/// Responses `custom` tools — codex's `apply_patch`). Exactly one of the two —
331/// an enum, not optional fields, so invalid states are unrepresentable.
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333#[serde(tag = "type", rename_all = "snake_case")]
334pub enum ToolInputFormat {
335    /// A JSON-schema function tool (every tool today).
336    JsonSchema {
337        /// The derived JSON Schema for the tool's arguments.
338        parameters: Value,
339    },
340    /// A freeform tool: raw text constrained by a grammar. On wires without
341    /// custom-tool support it degrades to a `{"input": string}` function tool;
342    /// the raw text reaches the tool identically either way.
343    Freeform {
344        /// The grammar language.
345        syntax: GrammarSyntax,
346        /// The grammar source, verbatim.
347        definition: String,
348    },
349}
350
351/// The grammar language of a [`ToolInputFormat::Freeform`] tool.
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "snake_case")]
354pub enum GrammarSyntax {
355    /// A Lark grammar (codex `apply_patch.lark`).
356    Lark,
357    /// A regular expression.
358    Regex,
359}
360
361// ========================= Streaming events (stream-json) =========================
362
363/// One event in the `stream-json` trajectory (one JSON object per line).
364///
365/// The stream is a **self-sufficient, replayable source of the whole run**: `Init`
366/// carries the base prompt + tool specs + model, and each [`Event::Message`] carries a
367/// full turn — so [`reconstruct_conversation`] rebuilds the entire history with nothing
368/// else. (Claude Code's stream omits `system`/`tools`, forcing a proxy capture to
369/// recover them; `Init` closes that gap.) `#[non_exhaustive]` so events can be added
370/// (e.g. per-turn markers) without a breaking change.
371#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
372#[serde(tag = "type", rename_all = "snake_case")]
373#[non_exhaustive]
374pub enum Event {
375    /// Emitted once at the start — everything needed to reconstruct context.
376    Init {
377        /// The session identifier.
378        session_id: String,
379        /// The harness pack in use (e.g. `grok`).
380        harness: String,
381        /// The wire schema in use (e.g. `anthropic`) — the provider's `api_schema()`.
382        api_schema: String,
383        /// The model id.
384        model: String,
385        /// The working directory.
386        cwd: String,
387        /// The max-turns ceiling; absent = unlimited (the default — ADR-0005
388        /// amendment 2026-07-18).
389        #[serde(default, skip_serializing_if = "Option::is_none")]
390        max_turns: Option<u32>,
391        /// The base `System` + `Developer` messages (prompt + capabilities).
392        preamble: Vec<Message>,
393        /// The tool specs offered to the model (name + JSON schema), as JSON values.
394        tools: Vec<Value>,
395    },
396    /// A full message appended to the history (the trace): role + content blocks.
397    Message {
398        /// The appended message.
399        message: Message,
400    },
401    /// The terminal event: the final report (identical to `--output-format json`).
402    Result {
403        /// The run's report envelope.
404        report: Report,
405    },
406    /// A non-terminal error note (e.g. a retry); terminal errors ride in [`Event::Result`].
407    Error {
408        /// A human-readable message.
409        message: String,
410    },
411    /// An approver resolution at the dispatch gate (ADR-0017) — grok's journal
412    /// shape (`PermissionResolved`). Emitted for **every** consulted call,
413    /// allowed or denied, so interactive traces are complete; `wait_ms` (human
414    /// decision latency) is unrecoverable from any other artifact.
415    Approval {
416        /// The `tool_use` id the decision applies to.
417        tool_use_id: String,
418        /// The client-facing tool name.
419        tool_name: String,
420        /// The resolution: `"allow"` or `"deny"`.
421        decision: String,
422        /// Milliseconds spent awaiting the approver (human decision latency).
423        wait_ms: u64,
424    },
425}
426
427/// Reconstruct the full [`Conversation`] from a `stream-json` event trajectory.
428///
429/// `Init.preamble` seeds the `System`/`Developer` prompt and each [`Event::Message`]
430/// appends a turn; `Result`/`Error` events are run metadata, not part of the history.
431/// This is the inverse of what `locode-exec` emits — the stream is a complete source.
432#[must_use]
433pub fn reconstruct_conversation(events: &[Event]) -> Conversation {
434    let mut messages = Vec::new();
435    for event in events {
436        match event {
437            Event::Init { preamble, .. } => messages.extend(preamble.iter().cloned()),
438            Event::Message { message } => messages.push(message.clone()),
439            Event::Result { .. } | Event::Error { .. } | Event::Approval { .. } => {}
440        }
441    }
442    Conversation { messages }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use serde_json::json;
449
450    #[test]
451    fn conversation_round_trips_all_roles_and_tool_pairing() {
452        let call_id = "call_42";
453        let conversation = Conversation {
454            messages: vec![
455                Message {
456                    role: Role::System,
457                    content: vec![ContentBlock::Text {
458                        text: "You are locode.".into(),
459                    }],
460                },
461                Message {
462                    role: Role::Developer,
463                    content: vec![ContentBlock::Text {
464                        text: "Available tools: run_terminal_command.".into(),
465                    }],
466                },
467                Message {
468                    role: Role::User,
469                    content: vec![ContentBlock::Text {
470                        text: "run echo hi".into(),
471                    }],
472                },
473                Message {
474                    role: Role::Assistant,
475                    content: vec![
476                        ContentBlock::Text {
477                            text: "Sure.".into(),
478                        },
479                        ContentBlock::ToolUse {
480                            id: call_id.into(),
481                            name: "run_terminal_command".into(),
482                            input: json!({ "command": "echo hi" }),
483                        },
484                    ],
485                },
486                Message {
487                    role: Role::User,
488                    content: vec![ContentBlock::ToolResult {
489                        tool_use_id: call_id.into(),
490                        content: vec![ResultChunk::Text {
491                            text: "hi\n".into(),
492                        }],
493                        is_error: false,
494                    }],
495                },
496            ],
497        };
498
499        let wire = serde_json::to_string(&conversation).expect("serialize");
500        let back: Conversation = serde_json::from_str(&wire).expect("deserialize");
501        assert_eq!(
502            conversation, back,
503            "conversation did not round-trip losslessly"
504        );
505
506        // The tool_use id and tool_result tool_use_id are the pairing link (ADR-0004).
507        let ContentBlock::ToolUse { id, .. } = &conversation.messages[3].content[1] else {
508            panic!("expected a tool_use block");
509        };
510        let ContentBlock::ToolResult { tool_use_id, .. } = &conversation.messages[4].content[0]
511        else {
512            panic!("expected a tool_result block");
513        };
514        assert_eq!(id, tool_use_id);
515    }
516
517    #[test]
518    fn content_block_uses_anthropic_style_type_tags() {
519        let block = ContentBlock::Text { text: "hi".into() };
520        assert_eq!(
521            serde_json::to_value(&block).unwrap(),
522            json!({ "type": "text", "text": "hi" })
523        );
524    }
525
526    #[test]
527    fn status_serializes_to_adr_0009_strings() {
528        let cases = [
529            (Status::Completed, "completed"),
530            (Status::MaxTurns, "max_turns"),
531            (Status::ModelError, "model_error"),
532            (Status::Error, "error"),
533        ];
534        for (status, want) in cases {
535            assert_eq!(serde_json::to_value(status).unwrap(), json!(want));
536        }
537    }
538
539    fn minimal_report() -> Report {
540        Report {
541            schema_version: 1,
542            status: Status::Completed,
543            harness: "grok".into(),
544            api_schema: "anthropic".into(),
545            final_message: Some("done".into()),
546            structured_output: None,
547            turns: 1,
548            tool_calls: vec![],
549            usage: Usage::default(),
550            session_id: "sess-1".into(),
551            stop_reason: None,
552            error: None,
553        }
554    }
555
556    /// The JSONL event stream is a self-sufficient source: `init.preamble` + every
557    /// `message` event reconstruct the entire conversation (system/developer included).
558    #[test]
559    fn events_reconstruct_full_conversation() {
560        let system = Message {
561            role: Role::System,
562            content: vec![ContentBlock::Text {
563                text: "base".into(),
564            }],
565        };
566        let developer = Message {
567            role: Role::Developer,
568            content: vec![ContentBlock::Text {
569                text: "capabilities".into(),
570            }],
571        };
572        let user = Message {
573            role: Role::User,
574            content: vec![ContentBlock::Text {
575                text: "run echo hi".into(),
576            }],
577        };
578        let assistant = Message {
579            role: Role::Assistant,
580            content: vec![ContentBlock::ToolUse {
581                id: "c1".into(),
582                name: "run_terminal_command".into(),
583                input: json!({ "command": "echo hi" }),
584            }],
585        };
586        let tool_result = Message {
587            role: Role::User,
588            content: vec![ContentBlock::ToolResult {
589                tool_use_id: "c1".into(),
590                content: vec![ResultChunk::Text {
591                    text: "hi\n".into(),
592                }],
593                is_error: false,
594            }],
595        };
596
597        let events = vec![
598            Event::Init {
599                session_id: "sess-1".into(),
600                harness: "grok".into(),
601                api_schema: "anthropic".into(),
602                model: "claude-opus-4-8".into(),
603                cwd: "/repo".into(),
604                max_turns: Some(30),
605                preamble: vec![system.clone(), developer.clone()],
606                tools: vec![json!({ "name": "run_terminal_command" })],
607            },
608            Event::Message {
609                message: user.clone(),
610            },
611            Event::Message {
612                message: assistant.clone(),
613            },
614            Event::Message {
615                message: tool_result.clone(),
616            },
617            Event::Result {
618                report: minimal_report(),
619            },
620        ];
621
622        // JSONL round-trip: one JSON object per line, parsed back losslessly.
623        let jsonl = events
624            .iter()
625            .map(|e| serde_json::to_string(e).unwrap())
626            .collect::<Vec<_>>()
627            .join("\n");
628        let parsed: Vec<Event> = jsonl
629            .lines()
630            .map(|l| serde_json::from_str(l).unwrap())
631            .collect();
632        assert_eq!(parsed, events, "events did not round-trip through JSONL");
633
634        // Reconstruction yields the FULL history, system/developer included.
635        let rebuilt = reconstruct_conversation(&parsed);
636        assert_eq!(
637            rebuilt,
638            Conversation {
639                messages: vec![system, developer, user, assistant, tool_result]
640            }
641        );
642    }
643
644    #[test]
645    fn event_uses_snake_case_type_tags() {
646        let event = Event::Message {
647            message: Message {
648                role: Role::User,
649                content: vec![],
650            },
651        };
652        assert_eq!(
653            serde_json::to_value(&event).unwrap()["type"],
654            json!("message")
655        );
656    }
657
658    /// `denial_reason` (ADR-0017) is additive at `schema_version: 1`: absent
659    /// from the wire when `None`, round-trips when set, and pre-field JSON
660    /// still deserializes.
661    #[test]
662    fn tool_call_record_denial_reason_is_additive() {
663        let record = ToolCallRecord {
664            id: "c1".into(),
665            name: "shell".into(),
666            kind: "shell".into(),
667            args: json!({}),
668            ok: false,
669            output: Value::Null,
670            denial_reason: None,
671        };
672        let value = serde_json::to_value(&record).unwrap();
673        assert!(
674            !value.as_object().unwrap().contains_key("denial_reason"),
675            "None must not appear on the wire: {value}"
676        );
677
678        let denied = ToolCallRecord {
679            denial_reason: Some("not allowed".into()),
680            ..record
681        };
682        let value = serde_json::to_value(&denied).unwrap();
683        assert_eq!(value["denial_reason"], json!("not allowed"));
684        let back: ToolCallRecord = serde_json::from_value(value).unwrap();
685        assert_eq!(back, denied);
686
687        // A record serialized before the field existed still parses.
688        let old = json!({
689            "id": "c1", "name": "shell", "kind": "shell",
690            "args": {}, "ok": true, "output": null
691        });
692        let back: ToolCallRecord = serde_json::from_value(old).unwrap();
693        assert_eq!(back.denial_reason, None);
694    }
695
696    /// `Status::Cancelled` (ADR-0018) rides the wire as `"cancelled"` — an
697    /// additive value at `schema_version: 1` per the documented policy.
698    #[test]
699    fn cancelled_status_wire_string() {
700        assert_eq!(
701            serde_json::to_value(Status::Cancelled).unwrap(),
702            json!("cancelled")
703        );
704        let back: Status = serde_json::from_value(json!("cancelled")).unwrap();
705        assert_eq!(back, Status::Cancelled);
706    }
707
708    /// `Event::Approval` (ADR-0017): grok's journal shape, `snake_case`
709    /// tagged, round-trips, and reconstruction ignores it.
710    #[test]
711    fn approval_event_shape_and_reconstruction() {
712        let event = Event::Approval {
713            tool_use_id: "c1".into(),
714            tool_name: "run_terminal_cmd".into(),
715            decision: "deny".into(),
716            wait_ms: 1234,
717        };
718        let value = serde_json::to_value(&event).unwrap();
719        assert_eq!(
720            value,
721            json!({
722                "type": "approval",
723                "tool_use_id": "c1",
724                "tool_name": "run_terminal_cmd",
725                "decision": "deny",
726                "wait_ms": 1234
727            })
728        );
729        let back: Event = serde_json::from_value(value).unwrap();
730        assert_eq!(back, event);
731
732        let conversation = reconstruct_conversation(&[event]);
733        assert!(
734            conversation.messages.is_empty(),
735            "approval events are run metadata, not history"
736        );
737    }
738}