Skip to main content

forge_sdk/transport/
codec.rs

1//! Stream-json line encode / decode.
2
3use serde_json::{Value, json};
4
5use crate::Error;
6use crate::control::ControlRequest;
7use forge_primitives::Message;
8
9/// A single stream-json line from the subprocess - either a regular message
10/// or a control request.
11#[derive(Debug, Clone)]
12pub enum DecodedLine {
13    /// An assistant/user/system/result message.
14    ///
15    /// Boxed: a `Message` is 536 bytes and every other variant here is a
16    /// fraction of that (clippy::large_enum_variant). The value is decoded
17    /// once per line and moved straight into the reader, so the
18    /// indirection costs one allocation and saves the enum being 536
19    /// bytes wherever a line is passed around.
20    Message(Box<Message>),
21    /// A control request (e.g. permission check, MCP message, hook callback).
22    ///
23    /// Boxed for the same reason as [`Self::Message`]: it is 312 bytes of
24    /// its own and is dispatched once and dropped.
25    Control(Box<ControlRequest>),
26    /// The CLI has withdrawn a previously-issued `control_request` - the
27    /// handler matching `request_id` should be cancelled if still in flight.
28    /// Wire shape `{"type":"control_cancel_request","request_id":"..."}`
29    ///.
30    ControlCancel {
31        /// `request_id` of the `control_request` being withdrawn.
32        request_id: String,
33    },
34    /// The CLI is responding to an outbound `control_request` we sent
35    /// (initialize, interrupt, `set_permission_mode`, …). These arrive on
36    /// stdout and are normally consumed synchronously by the client's
37    /// outbound-control wait loop - the read-dispatch loop in
38    /// the events stream returned by [`Client::spawn`](crate::Client::spawn) never sees them.
39    /// Represented here so downstream tools (the wire-conformance replay
40    /// harness, debug captures) can recognise and categorise the frame
41    /// instead of mis-flagging it as `Unknown`.
42    ControlResponse {
43        /// `request_id` of the outbound `control_request` this responds to.
44        request_id: String,
45        /// Full JSON payload - useful for inspection and replay.
46        raw: Value,
47    },
48    /// Forward-compat fallback: the CLI emitted a frame with an unrecognised
49    /// top-level `type` field. Forge-sdk doesn't crash on these - it logs
50    /// a warning via `tracing::warn!` in the dispatch path and lets the
51    /// reader continue. Callers (like the wire-conformance harness) can
52    /// detect and report these explicitly by matching this variant.
53    ///
54    /// `type_str` is the raw `type` field value the CLI sent; `raw` is the
55    /// full JSON object for inspection / logging / replay. Neither is
56    /// typed - by definition, we don't know what this frame is.
57    Unknown {
58        /// The unrecognised `type` field value.
59        type_str: String,
60        /// Full JSON payload for later inspection.
61        raw: Value,
62    },
63    /// A line that failed to decode: not valid JSON, or valid JSON that
64    /// matched no message shape. Typed rather than an `Err` so the read
65    /// loop skips the line and continues - a per-line decode failure
66    /// must not end the session, the same rule `Unknown` applies to
67    /// unknown types. The reader warns with the line number, reason and
68    /// a 160-char excerpt, and answers a corrupt `control_request` that
69    /// carries a `request_id`.
70    Malformed {
71        /// 1-based number of the offending line.
72        line: u64,
73        /// Why the line failed to decode.
74        reason: String,
75    },
76}
77
78/// Parse one stream-json line into a [`Message`].
79///
80/// `line` must not include a trailing newline. `line_number` is used only
81/// for error reporting and should be 1-based.
82///
83/// # Errors
84///
85/// - [`Error::JsonDecode`] when the line is not valid JSON.
86/// - [`Error::MessageParse`] when the JSON parses but doesn't match any
87///   known message shape.
88pub fn decode_line(line: &str, line_number: u64) -> Result<Message, Error> {
89    let value: Value = serde_json::from_str(line)
90        .map_err(|source| Error::JsonDecode { line: line_number, source })?;
91    serde_json::from_value(value)
92        .map_err(|e| Error::message_parse(format!("line {line_number}: {e}")))
93}
94
95/// Decode one stream-json line, dispatching on the `type` field.
96///
97/// Returns one of [`DecodedLine`]'s variants and never fails. For
98/// forward-compat with future `claude` CLI releases, any top-level
99/// `type` value forge-sdk doesn't recognise lands in
100/// [`DecodedLine::Unknown`], and a line that cannot be decoded at all
101/// lands in [`DecodedLine::Malformed`] - callers (notably the
102/// wire-conformance harness) can detect both by matching the variant.
103/// The read loop in the events stream returned by
104/// [`Client::spawn`](crate::Client::spawn) warns on either and
105/// continues reading.
106pub fn decode_dispatch(line: &str, line_number: u64) -> DecodedLine {
107    let value: Value = match serde_json::from_str(line) {
108        Ok(value) => value,
109        Err(e) => {
110            return DecodedLine::Malformed {
111                line: line_number,
112                reason: format!("invalid JSON: {e}"),
113            };
114        }
115    };
116    let Some(ty) = value.get("type").and_then(|v| v.as_str()) else {
117        return DecodedLine::Malformed {
118            line: line_number,
119            reason: "missing `type` field".to_string(),
120        };
121    };
122    match ty {
123        "control_request" => match serde_json::from_value::<ControlRequest>(value) {
124            Ok(req) => DecodedLine::Control(Box::new(req)),
125            Err(e) => DecodedLine::Malformed { line: line_number, reason: e.to_string() },
126        },
127        "control_cancel_request" => {
128            let request_id = value.get("request_id").and_then(Value::as_str);
129            match request_id {
130                Some(rid) => DecodedLine::ControlCancel { request_id: rid.to_string() },
131                None => DecodedLine::Malformed {
132                    line: line_number,
133                    reason: "control_cancel_request missing `request_id`".to_string(),
134                },
135            }
136        }
137        "control_response" => {
138            // `response.request_id` is where the CLI echoes the
139            // request_id we originally sent. A `control_response` with
140            // no `request_id` is wire corruption - route it through
141            // `DecodedLine::Unknown` so the conformance harness counts
142            // it under `unknown_types` rather than as a "valid"
143            // ControlResponse. The runtime path (`send_control` /
144            // `next_event`) treats unknowns as warn-and-skip.
145            match value.pointer("/response/request_id").and_then(Value::as_str) {
146                Some(rid) => {
147                    DecodedLine::ControlResponse { request_id: rid.to_string(), raw: value }
148                }
149                None => DecodedLine::Unknown {
150                    type_str: "control_response (missing /response/request_id)".to_string(),
151                    raw: value,
152                },
153            }
154        }
155        "assistant" | "user" | "system" | "result" | "rate_limit_event" | "stream_event"
156        | "command_lifecycle" | "error" => match serde_json::from_value::<Message>(value) {
157            Ok(msg) => DecodedLine::Message(Box::new(msg)),
158            Err(e) => DecodedLine::Malformed { line: line_number, reason: e.to_string() },
159        },
160        "tool_progress" => {
161            // A heartbeat that fails to fit is an unrecognised shape,
162            // not a corrupt line: degrade to `Unknown` so the harness
163            // counts it there.
164            match serde_json::from_value::<Message>(value.clone()) {
165                Ok(msg) => DecodedLine::Message(Box::new(msg)),
166                Err(_) => DecodedLine::Unknown {
167                    type_str: "tool_progress (unparseable payload)".to_string(),
168                    raw: value,
169                },
170            }
171        }
172        other => DecodedLine::Unknown { type_str: other.to_string(), raw: value },
173    }
174}
175
176/// Encode a user prompt as one line of stream-json input.
177///
178/// Returns a string terminated by `\n` suitable for writing directly to the
179/// subprocess's stdin.
180///
181/// `uuid` is the prompt's own id, and it is stamped on every frame: the CLI
182/// emits that prompt's `command_lifecycle` frames (queued, started, …) only
183/// when the frame carries one (measured: zero frames without), and a reused id
184/// makes the CLI drop the prompt outright with no error anywhere. Mint a fresh
185/// one per send ([`crate::request_id::next_prompt_id`]).
186///
187/// # Errors
188///
189/// [`Error::MessageParse`] wrapping a JSON serialization failure
190/// (extraordinarily rare for string inputs; included for totality).
191pub fn encode_user_prompt(prompt: &str, session_id: &str, uuid: &str) -> Result<String, Error> {
192    // The CLI accepts both the bare-string and
193    // `[{"type":"text","text":prompt}]` shapes for `content` on
194    // user turns. forge-sdk emits the simpler bare-string form.
195    let payload = json!({
196        "type": "user",
197        "message": {"role": "user", "content": prompt},
198        "session_id": session_id,
199        "parent_tool_use_id": null,
200        "uuid": uuid,
201    });
202    let mut line = serde_json::to_string(&payload).map_err(|e| Error::encode("user prompt", e))?;
203    line.push('\n');
204    Ok(line)
205}
206
207/// Encode a user-turn line with structured content blocks (text +
208/// image) as the message body. Use this when the prompt includes
209/// images or other non-text blocks; for plain text use
210/// [`encode_user_prompt`] which emits the simpler bare-string form.
211///
212/// `content` is forwarded verbatim as the message body's `content`
213/// field - callers must build CLI-compatible block objects (e.g.
214/// `{"type":"text","text":"..."}`,
215/// `{"type":"image","source":{...}}`).
216///
217/// `uuid` is stamped as it is on the text form, for the same reason.
218///
219/// # Errors
220///
221/// [`Error::MessageParse`] wrapping a JSON serialization failure.
222pub fn encode_user_prompt_with_content(
223    content: &[Value],
224    session_id: &str,
225    uuid: &str,
226) -> Result<String, Error> {
227    let payload = json!({
228        "type": "user",
229        "message": {"role": "user", "content": content},
230        "session_id": session_id,
231        "parent_tool_use_id": null,
232        "uuid": uuid,
233    });
234    let mut line =
235        serde_json::to_string(&payload).map_err(|e| Error::encode("user prompt blocks", e))?;
236    line.push('\n');
237    Ok(line)
238}