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    /// Explicitly `null` on Muse Code 1.0.1 when no profile applies
214    /// (0.2.1 always sent a string). Serialized as `null`, not omitted,
215    /// to round-trip the 1.0.1 wire.
216    #[serde(default)]
217    pub profile_id: Option<String>,
218    pub provider_id: String,
219    /// How the model was chosen (`startup` observed).
220    pub source: String,
221    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
222    pub extra: serde_json::Map<String, Value>,
223}
224
225/// `tool.result` — outcome of one tool invocation (live providers only).
226///
227/// **No `task_id`**, but the wire models each tool call as its own task
228/// (`task_kind: tool.<tool_name>`) and `correlation_facts.tool_name`
229/// names it — match on that, latest-first. Recency-of-running-tasks
230/// heuristics mis-attribute: the issuing tool task has already completed
231/// when this record lands. `call_id` is the provider's call id, not a
232/// task handle — see the README's known-wire-gaps section.
233///
234/// `correlation_facts` is absent on some tool results (e.g. compact `bash`
235/// results like `{"items":5,"ok":true,"revision":4}` observed on
236/// `3035c77c-efca...`).
237///
238/// `text` is opaque for most tools (prose, e.g. `write_file` → `"wrote 6 bytes …"`),
239/// but the **`bash`/`command` tool packs a structured JSON object into `text`**
240/// (see [`CommandResult`]). Use [`ToolResult::command_result`] to get a typed
241/// view when that shape is present. The same JSON is also emitted as a
242/// `task.lifecycle.output` chunk — consumers that render both channels should
243/// de-dupe (the `tool.result` record is authoritative).
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245pub struct ToolResult {
246    pub kind: String,
247    pub command_id: String,
248    pub run_stream: StreamRef,
249    /// Provider call id this result answers.
250    pub call_id: String,
251    /// Result text as shown to the model (including failure prose).
252    /// For the `bash`/`command` tool this is a JSON string of a [`CommandResult`];
253    /// see [`ToolResult::command_result`] and [`ToolResult::try_command_result`].
254    pub text: String,
255    /// Correlation summary — typed over the observed `{outcome, tool_name}`
256    /// shape; unknown keys round-trip through `extra`. Absent on some
257    /// results; treat as `None` when missing.
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub correlation_facts: Option<ToolCorrelationFacts>,
260    /// Populated for file-editing tools; open-shaped.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub edit_facts: Option<Value>,
263    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
264    pub extra: serde_json::Map<String, Value>,
265}
266
267/// Typed view of `tool.result`'s `correlation_facts` — the fields consumers
268/// key on: `tool_name` drives the `tool.<name>`-task attribution match, and
269/// `outcome` classifies the result. Every field is optional and unknown
270/// keys round-trip via `extra`, so a wire addition widens rather than
271/// breaks.
272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
273pub struct ToolCorrelationFacts {
274    /// Tool that produced this result (matches the `tool.<tool_name>` task
275    /// kind). Observed values: `write_file`, `read_file`, `bash`.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub tool_name: Option<String>,
278    /// `"success"` or `"failure"` as observed on the wire.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub outcome: Option<String>,
281    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
282    pub extra: serde_json::Map<String, Value>,
283}
284
285impl ToolResult {
286    /// The result's `correlation_facts.outcome`, when carried.
287    pub fn outcome(&self) -> Option<&str> {
288        self.correlation_facts
289            .as_ref()
290            .and_then(|f| f.outcome.as_deref())
291    }
292
293    /// The result's `correlation_facts.tool_name`, when carried.
294    pub fn tool_name(&self) -> Option<&str> {
295        self.correlation_facts
296            .as_ref()
297            .and_then(|f| f.tool_name.as_deref())
298    }
299}
300
301impl ToolResult {
302    /// Whether this result came from the `bash`/`command` tool, via
303    /// `correlation_facts.tool_name == "bash" | "command"`.
304    pub fn is_command_tool(&self) -> bool {
305        matches!(self.tool_name(), Some("bash" | "command"))
306    }
307
308    /// Try to parse `text` as a structured [`CommandResult`] (the `bash` tool
309    /// shape described in #294). Returns `None` if `text` is not valid JSON
310    /// for that shape. Checks `is_command_tool()` first but also accepts any
311    /// JSON object that deserializes as `CommandResult` — so compact results
312    /// without `correlation_facts` (observed on #299) still parse.
313    pub fn command_result(&self) -> Option<CommandResult> {
314        serde_json::from_str(&self.text).ok()
315    }
316
317    /// Fallible parse of `text` as [`CommandResult`], preserving the serde error.
318    pub fn try_command_result(&self) -> Result<CommandResult, serde_json::Error> {
319        serde_json::from_str(&self.text)
320    }
321}
322
323/// Structured result packed into [`ToolResult::text`] for the `bash`/`command`
324/// tool. Real capture from #294:
325///
326/// ```json
327/// {
328///   "chunk_id": "exec-12-1",
329///   "command": "curl -s https://example.com | jq .",
330///   "description": "Test muse registration",
331///   "exit_code": 0,
332///   "terminal_status": "completed",
333///   "output": "{\\n  \"ok\": true\\n}",
334///   "original_output_bytes": 394,
335///   "original_output_tokens": 99,
336///   "truncated": false
337/// }
338/// ```
339///
340/// Field notes: `command`/`description` are the shell line and Muse's
341/// one-line rationale; `output` is combined stdout/stderr already truncated
342/// to budget; `original_output_*` are pre-truncation sizes; `truncated`
343/// signals truncation. Extra fields survive in `extra`.
344#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
345pub struct CommandResult {
346    pub chunk_id: String,
347    pub command: String,
348    pub description: String,
349    pub exit_code: i32,
350    pub terminal_status: String,
351    pub output: String,
352    pub original_output_bytes: u64,
353    pub original_output_tokens: u64,
354    pub truncated: bool,
355    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
356    pub extra: serde_json::Map<String, Value>,
357}
358
359/// `run.terminal.*` — the run reached a terminal state. `terminal` carries
360/// the state (`completed` observed); `text` the final output; `reason` is
361/// populated on abnormal endings.
362#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
363pub struct RunTerminal {
364    pub kind: String,
365    pub command_id: String,
366    pub run_stream: StreamRef,
367    pub terminal: String,
368    pub reason: Option<String>,
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub text: Option<String>,
371    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
372    pub extra: serde_json::Map<String, Value>,
373}
374
375/// `task.stream.linked` — a task stream was attached to a run.
376#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
377pub struct TaskStreamLinked {
378    pub kind: String,
379    pub command_id: String,
380    pub run_stream: StreamRef,
381    pub task_id: String,
382    pub task_stream: StreamRef,
383    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
384    pub extra: serde_json::Map<String, Value>,
385}
386
387/// `task.lifecycle.*` — one step in a task's lifecycle state machine.
388#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
389pub struct TaskLifecycle {
390    pub kind: String,
391    pub command_id: String,
392    pub run_stream: StreamRef,
393    pub task_id: String,
394    pub task_stream: StreamRef,
395    pub event: TaskLifecycleEvent,
396    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
397    pub extra: serde_json::Map<String, Value>,
398}
399
400/// The `event` member of [`TaskLifecycle`], tagged by `kind`.
401///
402/// Observed lifecycle: `proposed → accepted → started → (scheduled →
403/// side_effect_intent →) completed | failed`.
404#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
405#[serde(tag = "kind", rename_all = "snake_case")]
406pub enum TaskLifecycleEvent {
407    Proposed {
408        task_id: String,
409        /// Dotted task class, e.g. `model.unknown.response` or
410        /// `reminder.agent.plugin:<plugin>:<name>`.
411        task_kind: String,
412    },
413    Accepted {
414        task_id: String,
415    },
416    Started {
417        task_id: String,
418        /// Tracing span id (live providers attach one; echo does not).
419        #[serde(default, skip_serializing_if = "Option::is_none")]
420        span_id: Option<String>,
421    },
422    Scheduled {
423        task_id: String,
424        idempotency_key: String,
425    },
426    SideEffectIntent {
427        task_id: String,
428        idempotency_key: String,
429        operation: String,
430        policy_decision: String,
431        parent_task_id: Option<String>,
432        cancellation_handle: Option<Value>,
433    },
434    /// Free-form progress (`message` + faceted `details`), e.g. model
435    /// stream attempts.
436    Status {
437        task_id: String,
438        message: String,
439        details: Value,
440    },
441    /// Streamed task output chunk (e.g. tool stdout summaries).
442    Output {
443        task_id: String,
444        chunk: String,
445    },
446    Completed {
447        task_id: String,
448    },
449    Cancelled {
450        task_id: String,
451        reason: String,
452    },
453    Rejected {
454        task_id: String,
455        reason: String,
456    },
457    Failed {
458        task_id: String,
459        reason: String,
460    },
461    /// A lifecycle kind not yet known to this crate.
462    #[serde(untagged)]
463    Unknown(Value),
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use serde_json::json;
470
471    #[test]
472    fn unknown_payload_type_is_preserved_not_error() {
473        let p = MusePayload::from_parts("subagent.lifecycle.spawned", json!({"x": 1})).unwrap();
474        match p {
475            MusePayload::Unknown {
476                payload_type,
477                payload,
478            } => {
479                assert_eq!(payload_type, "subagent.lifecycle.spawned");
480                assert_eq!(payload, json!({"x": 1}));
481            }
482            other => panic!("expected Unknown, got {other:?}"),
483        }
484    }
485
486    #[test]
487    fn task_lifecycle_failed_carries_reason() {
488        let e: TaskLifecycleEvent = serde_json::from_value(json!({
489            "kind": "failed",
490            "task_id": "t1",
491            "reason": "provider does not support base instructions"
492        }))
493        .unwrap();
494        assert!(matches!(e, TaskLifecycleEvent::Failed { ref reason, .. }
495            if reason.contains("base instructions")));
496    }
497
498    #[test]
499    fn command_result_parses_real_wire_shape_and_preserves_extensions() {
500        let result: ToolResult = serde_json::from_value(json!({
501            "kind": "tool_result",
502            "command_id": "cmd-1",
503            "run_stream": { "id": "run-1", "kind": "run" },
504            "call_id": "call-1",
505            "correlation_facts": { "outcome": "success", "tool_name": "bash" },
506            "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}"#
507        }))
508        .unwrap();
509
510        assert!(result.is_command_tool());
511        let command = result.command_result().expect("typed command result");
512        assert_eq!(command.command, "printf ok");
513        assert_eq!(command.output, "ok");
514        assert_eq!(command.exit_code, 0);
515        assert_eq!(command.extra["provider_extension"], true);
516        assert_eq!(
517            serde_json::to_value(command).unwrap()["provider_extension"],
518            true
519        );
520    }
521
522    #[test]
523    fn command_result_rejects_prose_and_recognizes_command_alias() {
524        let mut result: ToolResult = serde_json::from_value(json!({
525            "kind": "tool_result",
526            "command_id": "cmd-1",
527            "run_stream": { "id": "run-1", "kind": "run" },
528            "call_id": "call-1",
529            "correlation_facts": { "outcome": "failure", "tool_name": "command" },
530            "text": "tool failed before the command started"
531        }))
532        .unwrap();
533
534        assert!(result.is_command_tool());
535        assert!(result.command_result().is_none());
536        assert!(result.try_command_result().is_err());
537
538        result.correlation_facts = Some(ToolCorrelationFacts {
539            tool_name: Some("write_file".into()),
540            ..Default::default()
541        });
542        assert!(!result.is_command_tool());
543    }
544}