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.
204 pub usage: Usage,
205 /// The session identifier.
206 pub session_id: String,
207 /// The final model stop reason (`"end_turn"`, `"max_tokens"`, …), when a
208 /// completion was received (ADR-0009 amendment 2026-07-19): lets an eval
209 /// pipeline distinguish "model finished" from "model got truncated"
210 /// without re-reading the trace.
211 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub stop_reason: Option<String>,
213 /// A fatal error message, if the run ended in `status == error`/`model_error`.
214 pub error: Option<String>,
215}
216
217/// The terminal state of a run. Serializes to the exact strings in ADR-0009.
218///
219/// **Envelope evolution policy at `schema_version: 1` (ADR-0018):** additions
220/// — new status values, new optional record/report fields — are
221/// **non-breaking** and do not bump `schema_version`; renames and removals
222/// are breaking and would. JSON consumers should therefore treat an
223/// unrecognized status string as "unknown terminal state", not a parse
224/// error; Rust consumers get the same discipline from `#[non_exhaustive]`
225/// (match with a wildcard arm — `locode-exec` maps unknown statuses to
226/// exit 1).
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(rename_all = "snake_case")]
229#[non_exhaustive]
230pub enum Status {
231 /// The model finished with a text answer and no further tool calls.
232 Completed,
233 /// The max-turns ceiling was hit.
234 MaxTurns,
235 /// A provider/network error after bounded retry.
236 ModelError,
237 /// A fatal (`Tool`/host) error aborted the run.
238 Error,
239 /// The run was cancelled through the session's cancel handle (Esc, a
240 /// SIGTERM-driven timeout, …) — a **structured** terminal state, distinct
241 /// from failure (ADR-0018): partial work is preserved and the report is
242 /// still emitted.
243 Cancelled,
244}
245
246/// A report-side record of one tool call (distinct from the in-conversation
247/// [`ContentBlock::ToolUse`]): the structured `output` view, not the model-facing text.
248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
249pub struct ToolCallRecord {
250 /// The tool-call id (matches the conversation's `tool_use` id).
251 pub id: String,
252 /// The client-facing tool name the model called.
253 pub name: String,
254 /// The canonical `ToolKind` tag for cross-pack A/B alignment (e.g. `shell`).
255 pub kind: String,
256 /// The arguments the model supplied.
257 pub args: Value,
258 /// Whether the call succeeded.
259 pub ok: bool,
260 /// The structured output of the call (the report view, not `prompt_text`).
261 pub output: Value,
262 /// The approver's reason, iff this call was **denied by the approval seam**
263 /// (ADR-0017). Set only from the approver-deny path — never reused for
264 /// other failures, and cancellation synthetics never carry it — so deny
265 /// stays structurally separable from failure and from cancel (the
266 /// codex-`Declined` / grok-taxonomy lesson).
267 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub denial_reason: Option<String>,
269}
270
271/// Token accounting parsed from the provider's terminal usage event.
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
273pub struct Usage {
274 /// Input (prompt) tokens.
275 pub input_tokens: u64,
276 /// Output (completion) tokens.
277 pub output_tokens: u64,
278 /// Tokens served from the prompt cache. **`None` = this wire/provider does
279 /// not report the counter; `Some(0)` = reported as zero** (a real signal:
280 /// no cache hit). ADR-0009 amendment 2026-07-19 — zero-as-N/A rejected.
281 pub cache_read_tokens: Option<u64>,
282 /// Tokens written to the prompt cache (`None` on wires that never report
283 /// writes, e.g. the OpenAI family).
284 pub cache_creation_tokens: Option<u64>,
285 /// Reasoning/thinking tokens (`None` on wires that fold them into
286 /// `output_tokens`, e.g. Anthropic).
287 pub reasoning_tokens: Option<u64>,
288}
289
290/// `Some+Some` sums; `None` is the identity — a run total is `None` only if no
291/// turn ever reported the counter.
292fn add_opt(a: Option<u64>, b: Option<u64>) -> Option<u64> {
293 match (a, b) {
294 (Some(x), Some(y)) => Some(x + y),
295 (Some(x), None) | (None, Some(x)) => Some(x),
296 (None, None) => None,
297 }
298}
299
300impl std::ops::AddAssign for Usage {
301 /// Accumulate another turn's usage field-wise (the engine sums across turns).
302 fn add_assign(&mut self, rhs: Self) {
303 self.input_tokens += rhs.input_tokens;
304 self.output_tokens += rhs.output_tokens;
305 self.cache_read_tokens = add_opt(self.cache_read_tokens, rhs.cache_read_tokens);
306 self.cache_creation_tokens = add_opt(self.cache_creation_tokens, rhs.cache_creation_tokens);
307 self.reasoning_tokens = add_opt(self.reasoning_tokens, rhs.reasoning_tokens);
308 }
309}
310
311// ================================= Tool spec =================================
312
313/// A provider-neutral tool spec: name + description + args JSON Schema.
314///
315/// This is the wire-agnostic representation a harness pack produces (from a
316/// `Registry`) and a `Provider` wire maps onto its own tool format (e.g. Anthropic's
317/// `{name, description, input_schema}` vs OpenAI's `{type:"function", function:{…}}`).
318/// It lives in `locode-protocol` because both `locode-tools` (which builds it) and
319/// `locode-provider` (which consumes it via `ConversationRequest`) need it, and the
320/// dependency graph forbids `provider → tools`.
321#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
322pub struct ToolSpec {
323 /// The model-facing wire name (the harness pack's name for the tool).
324 pub name: String,
325 /// The tool description offered to the model.
326 pub description: String,
327 /// How the tool's input is specified to the model (ADR-0003 amendment
328 /// 2026-07-19; replaces the bare `parameters: Value`).
329 pub input: ToolInputFormat,
330}
331
332/// How a tool's input is specified: a JSON-schema function tool, or a freeform
333/// tool whose raw-text input is constrained by a server-side grammar (OpenAI
334/// Responses `custom` tools — codex's `apply_patch`). Exactly one of the two —
335/// an enum, not optional fields, so invalid states are unrepresentable.
336#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
337#[serde(tag = "type", rename_all = "snake_case")]
338pub enum ToolInputFormat {
339 /// A JSON-schema function tool (every tool today).
340 JsonSchema {
341 /// The derived JSON Schema for the tool's arguments.
342 parameters: Value,
343 },
344 /// A freeform tool: raw text constrained by a grammar. On wires without
345 /// custom-tool support it degrades to a `{"input": string}` function tool;
346 /// the raw text reaches the tool identically either way.
347 Freeform {
348 /// The grammar language.
349 syntax: GrammarSyntax,
350 /// The grammar source, verbatim.
351 definition: String,
352 },
353}
354
355/// The grammar language of a [`ToolInputFormat::Freeform`] tool.
356#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
357#[serde(rename_all = "snake_case")]
358pub enum GrammarSyntax {
359 /// A Lark grammar (codex `apply_patch.lark`).
360 Lark,
361 /// A regular expression.
362 Regex,
363}
364
365// ========================= Streaming events (stream-json) =========================
366
367/// One event in the `stream-json` trajectory (one JSON object per line).
368///
369/// The stream is a **self-sufficient, replayable source of the whole run**: `Init`
370/// carries the base prompt + tool specs + model, and each [`Event::Message`] carries a
371/// full turn — so [`reconstruct_conversation`] rebuilds the entire history with nothing
372/// else. (Claude Code's stream omits `system`/`tools`, forcing a proxy capture to
373/// recover them; `Init` closes that gap.) `#[non_exhaustive]` so events can be added
374/// (e.g. per-turn markers) without a breaking change.
375#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
376#[serde(tag = "type", rename_all = "snake_case")]
377#[non_exhaustive]
378pub enum Event {
379 /// Emitted once at the start — everything needed to reconstruct context.
380 Init {
381 /// The session identifier.
382 session_id: String,
383 /// The harness pack in use (e.g. `grok`).
384 harness: String,
385 /// The wire schema in use (e.g. `anthropic`) — the provider's `api_schema()`.
386 api_schema: String,
387 /// The model id.
388 model: String,
389 /// The working directory.
390 cwd: String,
391 /// The max-turns ceiling; absent = unlimited (the default — ADR-0005
392 /// amendment 2026-07-18).
393 #[serde(default, skip_serializing_if = "Option::is_none")]
394 max_turns: Option<u32>,
395 /// The base `System` + `Developer` messages (prompt + capabilities).
396 preamble: Vec<Message>,
397 /// The tool specs offered to the model (name + JSON schema), as JSON values.
398 tools: Vec<Value>,
399 },
400 /// A full message appended to the history (the trace): role + content blocks.
401 Message {
402 /// The appended message.
403 message: Message,
404 },
405 /// An incremental assistant-text fragment during a **streaming** turn
406 /// (ADR-0021). **Display-only**: the whole [`Message`] is still appended at
407 /// turn end, so deltas are *not* part of the reconstructed history (the trace
408 /// stays whole-message — Q1). Emitted only when the engine runs in `streaming`
409 /// mode; consumers that want a whole-message trace drop this variant.
410 MessageDelta {
411 /// One assistant-text fragment — append to the live buffer.
412 text: String,
413 },
414 /// The terminal event: the final report (identical to `--output-format json`).
415 Result {
416 /// The run's report envelope.
417 report: Report,
418 },
419 /// A non-terminal error note (e.g. a retry); terminal errors ride in [`Event::Result`].
420 Error {
421 /// A human-readable message.
422 message: String,
423 },
424 /// An approver resolution at the dispatch gate (ADR-0017) — grok's journal
425 /// shape (`PermissionResolved`). Emitted for **every** consulted call,
426 /// allowed or denied, so interactive traces are complete; `wait_ms` (human
427 /// decision latency) is unrecoverable from any other artifact.
428 Approval {
429 /// The `tool_use` id the decision applies to.
430 tool_use_id: String,
431 /// The client-facing tool name.
432 tool_name: String,
433 /// The resolution: `"allow"` or `"deny"`.
434 decision: String,
435 /// Milliseconds spent awaiting the approver (human decision latency).
436 wait_ms: u64,
437 },
438}
439
440/// Reconstruct the full [`Conversation`] from a `stream-json` event trajectory.
441///
442/// `Init.preamble` seeds the `System`/`Developer` prompt and each [`Event::Message`]
443/// appends a turn; `Result`/`Error` events are run metadata, not part of the history.
444/// This is the inverse of what `locode-exec` emits — the stream is a complete source.
445#[must_use]
446pub fn reconstruct_conversation(events: &[Event]) -> Conversation {
447 let mut messages = Vec::new();
448 for event in events {
449 match event {
450 Event::Init { preamble, .. } => messages.extend(preamble.iter().cloned()),
451 Event::Message { message } => messages.push(message.clone()),
452 // Deltas are display-only — the whole `Message` is appended at turn
453 // end, so skipping them here keeps reconstruction whole-message (Q1).
454 Event::MessageDelta { .. }
455 | Event::Result { .. }
456 | Event::Error { .. }
457 | Event::Approval { .. } => {}
458 }
459 }
460 Conversation { messages }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use serde_json::json;
467
468 #[test]
469 fn conversation_round_trips_all_roles_and_tool_pairing() {
470 let call_id = "call_42";
471 let conversation = Conversation {
472 messages: vec![
473 Message {
474 role: Role::System,
475 content: vec![ContentBlock::Text {
476 text: "You are locode.".into(),
477 }],
478 },
479 Message {
480 role: Role::Developer,
481 content: vec![ContentBlock::Text {
482 text: "Available tools: run_terminal_command.".into(),
483 }],
484 },
485 Message {
486 role: Role::User,
487 content: vec![ContentBlock::Text {
488 text: "run echo hi".into(),
489 }],
490 },
491 Message {
492 role: Role::Assistant,
493 content: vec![
494 ContentBlock::Text {
495 text: "Sure.".into(),
496 },
497 ContentBlock::ToolUse {
498 id: call_id.into(),
499 name: "run_terminal_command".into(),
500 input: json!({ "command": "echo hi" }),
501 },
502 ],
503 },
504 Message {
505 role: Role::User,
506 content: vec![ContentBlock::ToolResult {
507 tool_use_id: call_id.into(),
508 content: vec![ResultChunk::Text {
509 text: "hi\n".into(),
510 }],
511 is_error: false,
512 }],
513 },
514 ],
515 };
516
517 let wire = serde_json::to_string(&conversation).expect("serialize");
518 let back: Conversation = serde_json::from_str(&wire).expect("deserialize");
519 assert_eq!(
520 conversation, back,
521 "conversation did not round-trip losslessly"
522 );
523
524 // The tool_use id and tool_result tool_use_id are the pairing link (ADR-0004).
525 let ContentBlock::ToolUse { id, .. } = &conversation.messages[3].content[1] else {
526 panic!("expected a tool_use block");
527 };
528 let ContentBlock::ToolResult { tool_use_id, .. } = &conversation.messages[4].content[0]
529 else {
530 panic!("expected a tool_result block");
531 };
532 assert_eq!(id, tool_use_id);
533 }
534
535 #[test]
536 fn content_block_uses_anthropic_style_type_tags() {
537 let block = ContentBlock::Text { text: "hi".into() };
538 assert_eq!(
539 serde_json::to_value(&block).unwrap(),
540 json!({ "type": "text", "text": "hi" })
541 );
542 }
543
544 #[test]
545 fn status_serializes_to_adr_0009_strings() {
546 let cases = [
547 (Status::Completed, "completed"),
548 (Status::MaxTurns, "max_turns"),
549 (Status::ModelError, "model_error"),
550 (Status::Error, "error"),
551 ];
552 for (status, want) in cases {
553 assert_eq!(serde_json::to_value(status).unwrap(), json!(want));
554 }
555 }
556
557 fn minimal_report() -> Report {
558 Report {
559 schema_version: 1,
560 status: Status::Completed,
561 harness: "grok".into(),
562 api_schema: "anthropic".into(),
563 final_message: Some("done".into()),
564 structured_output: None,
565 turns: 1,
566 tool_calls: vec![],
567 usage: Usage::default(),
568 session_id: "sess-1".into(),
569 stop_reason: None,
570 error: None,
571 }
572 }
573
574 /// The JSONL event stream is a self-sufficient source: `init.preamble` + every
575 /// `message` event reconstruct the entire conversation (system/developer included).
576 #[test]
577 fn events_reconstruct_full_conversation() {
578 let system = Message {
579 role: Role::System,
580 content: vec![ContentBlock::Text {
581 text: "base".into(),
582 }],
583 };
584 let developer = Message {
585 role: Role::Developer,
586 content: vec![ContentBlock::Text {
587 text: "capabilities".into(),
588 }],
589 };
590 let user = Message {
591 role: Role::User,
592 content: vec![ContentBlock::Text {
593 text: "run echo hi".into(),
594 }],
595 };
596 let assistant = Message {
597 role: Role::Assistant,
598 content: vec![ContentBlock::ToolUse {
599 id: "c1".into(),
600 name: "run_terminal_command".into(),
601 input: json!({ "command": "echo hi" }),
602 }],
603 };
604 let tool_result = Message {
605 role: Role::User,
606 content: vec![ContentBlock::ToolResult {
607 tool_use_id: "c1".into(),
608 content: vec![ResultChunk::Text {
609 text: "hi\n".into(),
610 }],
611 is_error: false,
612 }],
613 };
614
615 let events = vec![
616 Event::Init {
617 session_id: "sess-1".into(),
618 harness: "grok".into(),
619 api_schema: "anthropic".into(),
620 model: "claude-opus-4-8".into(),
621 cwd: "/repo".into(),
622 max_turns: Some(30),
623 preamble: vec![system.clone(), developer.clone()],
624 tools: vec![json!({ "name": "run_terminal_command" })],
625 },
626 Event::Message {
627 message: user.clone(),
628 },
629 Event::Message {
630 message: assistant.clone(),
631 },
632 Event::Message {
633 message: tool_result.clone(),
634 },
635 Event::Result {
636 report: minimal_report(),
637 },
638 ];
639
640 // JSONL round-trip: one JSON object per line, parsed back losslessly.
641 let jsonl = events
642 .iter()
643 .map(|e| serde_json::to_string(e).unwrap())
644 .collect::<Vec<_>>()
645 .join("\n");
646 let parsed: Vec<Event> = jsonl
647 .lines()
648 .map(|l| serde_json::from_str(l).unwrap())
649 .collect();
650 assert_eq!(parsed, events, "events did not round-trip through JSONL");
651
652 // Reconstruction yields the FULL history, system/developer included.
653 let rebuilt = reconstruct_conversation(&parsed);
654 assert_eq!(
655 rebuilt,
656 Conversation {
657 messages: vec![system, developer, user, assistant, tool_result]
658 }
659 );
660 }
661
662 #[test]
663 fn event_uses_snake_case_type_tags() {
664 let event = Event::Message {
665 message: Message {
666 role: Role::User,
667 content: vec![],
668 },
669 };
670 assert_eq!(
671 serde_json::to_value(&event).unwrap()["type"],
672 json!("message")
673 );
674 }
675
676 #[test]
677 fn message_delta_round_trips_as_jsonl() {
678 let event = Event::MessageDelta {
679 text: "hello ".into(),
680 };
681 let value = serde_json::to_value(&event).unwrap();
682 assert_eq!(value["type"], json!("message_delta"), "{value}");
683 assert_eq!(value["text"], json!("hello "));
684 let back: Event = serde_json::from_value(value).unwrap();
685 assert_eq!(back, event);
686 }
687
688 #[test]
689 fn message_delta_is_not_part_of_reconstructed_history() {
690 // A streaming turn: deltas then the whole Message. Reconstruction must
691 // ignore the deltas (the trace stays whole-message — ADR-0021 Q1).
692 let assistant = Message {
693 role: Role::Assistant,
694 content: vec![ContentBlock::Text {
695 text: "hello world".into(),
696 }],
697 };
698 let with_deltas = vec![
699 Event::MessageDelta {
700 text: "hello ".into(),
701 },
702 Event::MessageDelta {
703 text: "world".into(),
704 },
705 Event::Message {
706 message: assistant.clone(),
707 },
708 ];
709 let without_deltas = vec![Event::Message {
710 message: assistant.clone(),
711 }];
712 assert_eq!(
713 reconstruct_conversation(&with_deltas),
714 reconstruct_conversation(&without_deltas),
715 "deltas must not affect reconstruction"
716 );
717 // And the reconstructed history is exactly the one whole message.
718 assert_eq!(
719 reconstruct_conversation(&with_deltas).messages,
720 vec![assistant]
721 );
722 }
723
724 /// `denial_reason` (ADR-0017) is additive at `schema_version: 1`: absent
725 /// from the wire when `None`, round-trips when set, and pre-field JSON
726 /// still deserializes.
727 #[test]
728 fn tool_call_record_denial_reason_is_additive() {
729 let record = ToolCallRecord {
730 id: "c1".into(),
731 name: "shell".into(),
732 kind: "shell".into(),
733 args: json!({}),
734 ok: false,
735 output: Value::Null,
736 denial_reason: None,
737 };
738 let value = serde_json::to_value(&record).unwrap();
739 assert!(
740 !value.as_object().unwrap().contains_key("denial_reason"),
741 "None must not appear on the wire: {value}"
742 );
743
744 let denied = ToolCallRecord {
745 denial_reason: Some("not allowed".into()),
746 ..record
747 };
748 let value = serde_json::to_value(&denied).unwrap();
749 assert_eq!(value["denial_reason"], json!("not allowed"));
750 let back: ToolCallRecord = serde_json::from_value(value).unwrap();
751 assert_eq!(back, denied);
752
753 // A record serialized before the field existed still parses.
754 let old = json!({
755 "id": "c1", "name": "shell", "kind": "shell",
756 "args": {}, "ok": true, "output": null
757 });
758 let back: ToolCallRecord = serde_json::from_value(old).unwrap();
759 assert_eq!(back.denial_reason, None);
760 }
761
762 /// `Status::Cancelled` (ADR-0018) rides the wire as `"cancelled"` — an
763 /// additive value at `schema_version: 1` per the documented policy.
764 #[test]
765 fn cancelled_status_wire_string() {
766 assert_eq!(
767 serde_json::to_value(Status::Cancelled).unwrap(),
768 json!("cancelled")
769 );
770 let back: Status = serde_json::from_value(json!("cancelled")).unwrap();
771 assert_eq!(back, Status::Cancelled);
772 }
773
774 /// `Event::Approval` (ADR-0017): grok's journal shape, `snake_case`
775 /// tagged, round-trips, and reconstruction ignores it.
776 #[test]
777 fn approval_event_shape_and_reconstruction() {
778 let event = Event::Approval {
779 tool_use_id: "c1".into(),
780 tool_name: "run_terminal_cmd".into(),
781 decision: "deny".into(),
782 wait_ms: 1234,
783 };
784 let value = serde_json::to_value(&event).unwrap();
785 assert_eq!(
786 value,
787 json!({
788 "type": "approval",
789 "tool_use_id": "c1",
790 "tool_name": "run_terminal_cmd",
791 "decision": "deny",
792 "wait_ms": 1234
793 })
794 );
795 let back: Event = serde_json::from_value(value).unwrap();
796 assert_eq!(back, event);
797
798 let conversation = reconstruct_conversation(&[event]);
799 assert!(
800 conversation.messages.is_empty(),
801 "approval events are run metadata, not history"
802 );
803 }
804}