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