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}