Skip to main content

muse_codes/io/
mod.rs

1//! Typed models of the `muse exec --json` JSONL event stream.
2//!
3//! Every stdout line is one [`MuseRecord`] — an event-sourced journal
4//! envelope with a lazily-typed payload. The envelope is fully typed; the
5//! payload stays raw JSON on the record (so round-trips are byte-faithful
6//! and unknown future payload types survive) and is lifted into a
7//! [`MusePayload`] on demand via [`MuseRecord::typed_payload`].
8//!
9//! Shapes in this module are derived from **captured real output** of
10//! Muse Code (see `test_cases/*.jsonl`), not from documentation — the wire
11//! is the contract. Payload types not yet observed (the journal also
12//! records approvals, edits, and subagent lifecycle under a live provider)
13//! deserialize as [`MusePayload::Unknown`] rather than failing.
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18/// One line of the `muse exec --json` stream: the journal envelope.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct MuseRecord {
21    /// Envelope schema version (observed: `1`).
22    pub schema_version: u32,
23    /// Unique record id (UUIDv7-style, monotonic within a stream).
24    pub id: String,
25    /// The stream this record belongs to.
26    pub stream: StreamRef,
27    /// 1-based position within `stream`.
28    pub sequence: u64,
29    /// Microseconds since the Unix epoch.
30    pub recorded_at: u64,
31    pub record_type: RecordType,
32    pub durability: Durability,
33    /// Id of the command that caused this record.
34    pub causation_id: String,
35    /// Dotted payload discriminator, e.g. `run.output.delta`.
36    pub payload_type: String,
37    /// Version of the payload's own schema (observed: `1`).
38    pub payload_schema_version: u32,
39    /// Raw payload — lift with [`MuseRecord::typed_payload`].
40    pub payload: Value,
41}
42
43impl MuseRecord {
44    /// Parse the payload into its typed form based on `payload_type`.
45    ///
46    /// Unknown payload types return [`MusePayload::Unknown`] carrying the
47    /// raw value; a payload that fails to match its expected shape is a
48    /// deserialization error (wire drift worth surfacing, not masking).
49    pub fn typed_payload(&self) -> serde_json::Result<MusePayload> {
50        MusePayload::from_parts(&self.payload_type, self.payload.clone())
51    }
52}
53
54/// Reference to a journal stream.
55#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
56pub struct StreamRef {
57    pub kind: StreamKind,
58    pub id: String,
59}
60
61/// Journal stream classes.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum StreamKind {
65    Session,
66    Run,
67    Task,
68}
69
70/// Journal record classes.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum RecordType {
74    /// Replay-exact state reconciliation (e.g. command acceptance).
75    Reconciliation,
76    /// Durable domain event.
77    Event,
78    /// Ephemeral progress/status (e.g. output deltas).
79    Status,
80}
81
82/// Whether the record survives restart/replay.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum Durability {
86    Durable,
87    Ephemeral,
88}
89
90/// Typed payload of a [`MuseRecord`], discriminated by `payload_type`.
91#[derive(Debug, Clone, PartialEq)]
92pub enum MusePayload {
93    /// `runtime.command.accepted`
94    CommandAccepted(CommandAccepted),
95    /// `session.run.linked`
96    SessionRunLinked(SessionRunLinked),
97    /// `turn.input.user`
98    TurnInputUser(TurnInputUser),
99    /// `run.lifecycle.started`
100    RunStarted(RunStarted),
101    /// `run.model.configured`
102    ModelConfigured(ModelConfigured),
103    /// `run.output.delta`
104    RunOutputDelta(RunOutputDelta),
105    /// `tool.result`
106    ToolResult(ToolResult),
107    /// `run.terminal.completed` (and any future `run.terminal.*`)
108    RunTerminal(RunTerminal),
109    /// `task.stream.linked`
110    TaskStreamLinked(TaskStreamLinked),
111    /// `task.lifecycle.*`
112    TaskLifecycle(TaskLifecycle),
113    /// A payload type not yet known to this crate — preserved verbatim.
114    Unknown {
115        payload_type: String,
116        payload: Value,
117    },
118}
119
120impl MusePayload {
121    pub fn from_parts(payload_type: &str, payload: Value) -> serde_json::Result<Self> {
122        Ok(match payload_type {
123            "runtime.command.accepted" => {
124                MusePayload::CommandAccepted(serde_json::from_value(payload)?)
125            }
126            "session.run.linked" => MusePayload::SessionRunLinked(serde_json::from_value(payload)?),
127            "turn.input.user" => MusePayload::TurnInputUser(serde_json::from_value(payload)?),
128            "run.lifecycle.started" => MusePayload::RunStarted(serde_json::from_value(payload)?),
129            "run.model.configured" => {
130                MusePayload::ModelConfigured(serde_json::from_value(payload)?)
131            }
132            "tool.result" => MusePayload::ToolResult(serde_json::from_value(payload)?),
133            "run.output.delta" => MusePayload::RunOutputDelta(serde_json::from_value(payload)?),
134            t if t.starts_with("run.terminal.") => {
135                MusePayload::RunTerminal(serde_json::from_value(payload)?)
136            }
137            "task.stream.linked" => MusePayload::TaskStreamLinked(serde_json::from_value(payload)?),
138            t if t.starts_with("task.lifecycle.") => {
139                MusePayload::TaskLifecycle(serde_json::from_value(payload)?)
140            }
141            other => MusePayload::Unknown {
142                payload_type: other.to_string(),
143                payload,
144            },
145        })
146    }
147}
148
149/// `runtime.command.accepted` — the runtime took ownership of a submitted
150/// command (`command_kind`, e.g. `turn.submit`).
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
152pub struct CommandAccepted {
153    pub kind: String,
154    pub command_id: String,
155    pub command_kind: String,
156    pub client_id: Option<String>,
157    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
158    pub extra: serde_json::Map<String, Value>,
159}
160
161/// `session.run.linked` — a run stream was attached to the session.
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163pub struct SessionRunLinked {
164    pub kind: String,
165    pub command_id: String,
166    pub run_stream: StreamRef,
167    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
168    pub extra: serde_json::Map<String, Value>,
169}
170
171/// `turn.input.user` — the user prompt as the runtime recorded it.
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173pub struct TurnInputUser {
174    pub kind: String,
175    pub command_id: String,
176    pub prompt: String,
177    pub run_stream: StreamRef,
178    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
179    pub extra: serde_json::Map<String, Value>,
180}
181
182/// `run.lifecycle.started`
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184pub struct RunStarted {
185    pub kind: String,
186    pub command_id: String,
187    pub prompt: String,
188    pub run_stream: StreamRef,
189    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
190    pub extra: serde_json::Map<String, Value>,
191}
192
193/// `run.output.delta` — streamed model/agent output text.
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195pub struct RunOutputDelta {
196    pub kind: String,
197    pub command_id: String,
198    pub run_stream: StreamRef,
199    pub text: String,
200    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
201    pub extra: serde_json::Map<String, Value>,
202}
203
204/// `run.model.configured` — which model/profile/provider the run resolved
205/// to (live providers only; the echo provider never emits it).
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207pub struct ModelConfigured {
208    pub kind: String,
209    pub command_id: String,
210    pub run_stream: StreamRef,
211    pub model_id: String,
212    pub display_label: String,
213    pub profile_id: String,
214    pub provider_id: String,
215    /// How the model was chosen (`startup` observed).
216    pub source: String,
217    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
218    pub extra: serde_json::Map<String, Value>,
219}
220
221/// `tool.result` — outcome of one tool invocation (live providers only).
222///
223/// **No `task_id`**, but the wire models each tool call as its own task
224/// (`task_kind: tool.<tool_name>`) and `correlation_facts.tool_name`
225/// names it — match on that, latest-first. Recency-of-running-tasks
226/// heuristics mis-attribute: the issuing tool task has already completed
227/// when this record lands. `call_id` is the provider's call id, not a
228/// task handle — see the README's known-wire-gaps section.
229///
230/// `correlation_facts` is absent on some tool results (e.g. compact `bash`
231/// results like `{"items":5,"ok":true,"revision":4}` observed on
232/// `3035c77c-efca...`).
233///
234/// `text` is opaque for most tools (prose, e.g. `write_file` → `"wrote 6 bytes …"`),
235/// but the **`bash`/`command` tool packs a structured JSON object into `text`**
236/// (see [`CommandResult`]). Use [`ToolResult::command_result`] to get a typed
237/// view when that shape is present. The same JSON is also emitted as a
238/// `task.lifecycle.output` chunk — consumers that render both channels should
239/// de-dupe (the `tool.result` record is authoritative).
240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
241pub struct ToolResult {
242    pub kind: String,
243    pub command_id: String,
244    pub run_stream: StreamRef,
245    /// Provider call id this result answers.
246    pub call_id: String,
247    /// Result text as shown to the model (including failure prose).
248    /// For the `bash`/`command` tool this is a JSON string of a [`CommandResult`];
249    /// see [`ToolResult::command_result`] and [`ToolResult::try_command_result`].
250    pub text: String,
251    /// Correlation summary — observed `{outcome, tool_name}`, open-shaped.
252    /// Absent on some results; treat as `None` when missing.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub correlation_facts: Option<Value>,
255    /// Populated for file-editing tools; open-shaped.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub edit_facts: Option<Value>,
258    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
259    pub extra: serde_json::Map<String, Value>,
260}
261
262impl ToolResult {
263    /// Whether this result came from the `bash`/`command` tool, via
264    /// `correlation_facts.tool_name == "bash" | "command"`.
265    pub fn is_command_tool(&self) -> bool {
266        matches!(
267            self.correlation_facts
268                .as_ref()
269                .and_then(|v| v.get("tool_name"))
270                .and_then(|v| v.as_str()),
271            Some("bash" | "command")
272        )
273    }
274
275    /// Try to parse `text` as a structured [`CommandResult`] (the `bash` tool
276    /// shape described in #294). Returns `None` if `text` is not valid JSON
277    /// for that shape. Checks `is_command_tool()` first but also accepts any
278    /// JSON object that deserializes as `CommandResult` — so compact results
279    /// without `correlation_facts` (observed on #299) still parse.
280    pub fn command_result(&self) -> Option<CommandResult> {
281        serde_json::from_str(&self.text).ok()
282    }
283
284    /// Fallible parse of `text` as [`CommandResult`], preserving the serde error.
285    pub fn try_command_result(&self) -> Result<CommandResult, serde_json::Error> {
286        serde_json::from_str(&self.text)
287    }
288}
289
290/// Structured result packed into [`ToolResult::text`] for the `bash`/`command`
291/// tool. Real capture from #294:
292///
293/// ```json
294/// {
295///   "chunk_id": "exec-12-1",
296///   "command": "curl -s https://example.com | jq .",
297///   "description": "Test muse registration",
298///   "exit_code": 0,
299///   "terminal_status": "completed",
300///   "output": "{\\n  \"ok\": true\\n}",
301///   "original_output_bytes": 394,
302///   "original_output_tokens": 99,
303///   "truncated": false
304/// }
305/// ```
306///
307/// Field notes: `command`/`description` are the shell line and Muse's
308/// one-line rationale; `output` is combined stdout/stderr already truncated
309/// to budget; `original_output_*` are pre-truncation sizes; `truncated`
310/// signals truncation. Extra fields survive in `extra`.
311#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
312pub struct CommandResult {
313    pub chunk_id: String,
314    pub command: String,
315    pub description: String,
316    pub exit_code: i32,
317    pub terminal_status: String,
318    pub output: String,
319    pub original_output_bytes: u64,
320    pub original_output_tokens: u64,
321    pub truncated: bool,
322    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
323    pub extra: serde_json::Map<String, Value>,
324}
325
326/// `run.terminal.*` — the run reached a terminal state. `terminal` carries
327/// the state (`completed` observed); `text` the final output; `reason` is
328/// populated on abnormal endings.
329#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
330pub struct RunTerminal {
331    pub kind: String,
332    pub command_id: String,
333    pub run_stream: StreamRef,
334    pub terminal: String,
335    pub reason: Option<String>,
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub text: Option<String>,
338    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
339    pub extra: serde_json::Map<String, Value>,
340}
341
342/// `task.stream.linked` — a task stream was attached to a run.
343#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
344pub struct TaskStreamLinked {
345    pub kind: String,
346    pub command_id: String,
347    pub run_stream: StreamRef,
348    pub task_id: String,
349    pub task_stream: StreamRef,
350    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
351    pub extra: serde_json::Map<String, Value>,
352}
353
354/// `task.lifecycle.*` — one step in a task's lifecycle state machine.
355#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
356pub struct TaskLifecycle {
357    pub kind: String,
358    pub command_id: String,
359    pub run_stream: StreamRef,
360    pub task_id: String,
361    pub task_stream: StreamRef,
362    pub event: TaskLifecycleEvent,
363    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
364    pub extra: serde_json::Map<String, Value>,
365}
366
367/// The `event` member of [`TaskLifecycle`], tagged by `kind`.
368///
369/// Observed lifecycle: `proposed → accepted → started → (scheduled →
370/// side_effect_intent →) completed | failed`.
371#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
372#[serde(tag = "kind", rename_all = "snake_case")]
373pub enum TaskLifecycleEvent {
374    Proposed {
375        task_id: String,
376        /// Dotted task class, e.g. `model.unknown.response` or
377        /// `reminder.agent.plugin:<plugin>:<name>`.
378        task_kind: String,
379    },
380    Accepted {
381        task_id: String,
382    },
383    Started {
384        task_id: String,
385        /// Tracing span id (live providers attach one; echo does not).
386        #[serde(default, skip_serializing_if = "Option::is_none")]
387        span_id: Option<String>,
388    },
389    Scheduled {
390        task_id: String,
391        idempotency_key: String,
392    },
393    SideEffectIntent {
394        task_id: String,
395        idempotency_key: String,
396        operation: String,
397        policy_decision: String,
398        parent_task_id: Option<String>,
399        cancellation_handle: Option<Value>,
400    },
401    /// Free-form progress (`message` + faceted `details`), e.g. model
402    /// stream attempts.
403    Status {
404        task_id: String,
405        message: String,
406        details: Value,
407    },
408    /// Streamed task output chunk (e.g. tool stdout summaries).
409    Output {
410        task_id: String,
411        chunk: String,
412    },
413    Completed {
414        task_id: String,
415    },
416    Cancelled {
417        task_id: String,
418        reason: String,
419    },
420    Rejected {
421        task_id: String,
422        reason: String,
423    },
424    Failed {
425        task_id: String,
426        reason: String,
427    },
428    /// A lifecycle kind not yet known to this crate.
429    #[serde(untagged)]
430    Unknown(Value),
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use serde_json::json;
437
438    #[test]
439    fn unknown_payload_type_is_preserved_not_error() {
440        let p = MusePayload::from_parts("subagent.lifecycle.spawned", json!({"x": 1})).unwrap();
441        match p {
442            MusePayload::Unknown {
443                payload_type,
444                payload,
445            } => {
446                assert_eq!(payload_type, "subagent.lifecycle.spawned");
447                assert_eq!(payload, json!({"x": 1}));
448            }
449            other => panic!("expected Unknown, got {other:?}"),
450        }
451    }
452
453    #[test]
454    fn task_lifecycle_failed_carries_reason() {
455        let e: TaskLifecycleEvent = serde_json::from_value(json!({
456            "kind": "failed",
457            "task_id": "t1",
458            "reason": "provider does not support base instructions"
459        }))
460        .unwrap();
461        assert!(matches!(e, TaskLifecycleEvent::Failed { ref reason, .. }
462            if reason.contains("base instructions")));
463    }
464
465    #[test]
466    fn command_result_parses_real_wire_shape_and_preserves_extensions() {
467        let result: ToolResult = serde_json::from_value(json!({
468            "kind": "tool_result",
469            "command_id": "cmd-1",
470            "run_stream": { "id": "run-1", "kind": "run" },
471            "call_id": "call-1",
472            "correlation_facts": { "outcome": "success", "tool_name": "bash" },
473            "text": r#"{"chunk_id":"exec-12-1","command":"printf ok","description":"Print a value","exit_code":0,"terminal_status":"completed","output":"ok","original_output_bytes":2,"original_output_tokens":1,"truncated":false,"provider_extension":true}"#
474        }))
475        .unwrap();
476
477        assert!(result.is_command_tool());
478        let command = result.command_result().expect("typed command result");
479        assert_eq!(command.command, "printf ok");
480        assert_eq!(command.output, "ok");
481        assert_eq!(command.exit_code, 0);
482        assert_eq!(command.extra["provider_extension"], true);
483        assert_eq!(
484            serde_json::to_value(command).unwrap()["provider_extension"],
485            true
486        );
487    }
488
489    #[test]
490    fn command_result_rejects_prose_and_recognizes_command_alias() {
491        let mut result: ToolResult = serde_json::from_value(json!({
492            "kind": "tool_result",
493            "command_id": "cmd-1",
494            "run_stream": { "id": "run-1", "kind": "run" },
495            "call_id": "call-1",
496            "correlation_facts": { "outcome": "failure", "tool_name": "command" },
497            "text": "tool failed before the command started"
498        }))
499        .unwrap();
500
501        assert!(result.is_command_tool());
502        assert!(result.command_result().is_none());
503        assert!(result.try_command_result().is_err());
504
505        result.correlation_facts = Some(json!({ "tool_name": "write_file" }));
506        assert!(!result.is_command_tool());
507    }
508}