Skip to main content

forge_agent/
replay.rs

1//! Resume-history synthesizer: converts on-disk transcript JSONL into the
2//! `forge_primitives::Message` envelopes the live wire stream produces, so
3//! consumers read replay and live messages identically.
4
5use serde_json::{Value, json};
6
7use forge_primitives::messages::Extras;
8use forge_primitives::{AssistantEnvelope, ContentBlock, Message, UserEnvelope};
9
10/// Synthesise on-disk transcript JSONL into the `forge_primitives::Message`
11/// envelopes the live wire stream produces. Consumers read these
12/// identically to live messages, unifying replay + live code paths.
13///
14/// Replay-specific transforms applied here:
15/// - An image or document block keeps its mime and the size its payload
16///   weighed, and loses that payload: the read draws the same attachment row
17///   the live path draws for it, and that row shows no bytes. A queued
18///   prompt's own block list is walked the same way - its attachments draw the
19///   same row, and a payload left there rides every page read.
20///
21///   **A tool result's nested image is passed through untouched** - payload and
22///   all - because a call row draws a result's image from its own data URL, and
23///   nothing here should change what that gets.
24///
25/// **Thinking blocks are kept.** A thinking block is the row the chat draws
26/// for a thought, and the live path draws it (a `thinking` block in an
27/// `assistant` frame), so a read that dropped the block drew a different
28/// conversation from the same turn - the read and live paths have to fold
29/// the same frames (rule 25).
30///
31/// The `session_id` field on each Message is left empty; the caller
32/// (`forge_sdk_worker::spawn_session`) is responsible for stamping the
33/// correct id before emitting `AgentEvent::Connected`. The row's `uuid`
34/// is carried across when it has one: it is the id the same message
35/// arrives under on the wire, so a reader can tell the two apart.
36pub fn synthesize_replay_messages(messages: &[Value]) -> Vec<Message> {
37    let mut out: Vec<Message> = Vec::new();
38
39    for entry in messages {
40        let Some(entry_record) = entry.as_object() else {
41            continue;
42        };
43        let entry_type = entry_record.get("type").and_then(Value::as_str).unwrap_or("");
44        let parent_tool_use_id =
45            entry_record.get("parent_tool_use_id").and_then(Value::as_str).map(str::to_owned);
46        let uuid = entry_record
47            .get("uuid")
48            .and_then(Value::as_str)
49            .filter(|id| !id.is_empty())
50            .map(str::to_owned);
51        let timestamp = entry_record.get("timestamp").and_then(Value::as_str).map(str::to_owned);
52        let Some(message_value) = entry_record.get("message") else {
53            continue;
54        };
55        let Some(message_record) = message_value.as_object() else {
56            continue;
57        };
58
59        // Treat the inner `message` as Anthropic-API-shaped: the on-disk
60        // JSONL nests the same `{id, role, model, content, …}` envelope
61        // the live CLI emits, so deserialize-then-transform is the
62        // most direct path. Bail on any entry that fails to decode -
63        // missing/malformed inner envelopes shouldn't crash a session
64        // resume.
65        let role = message_record.get("role").and_then(Value::as_str).unwrap_or(entry_type);
66
67        match role {
68            "assistant" => {
69                let Ok(mut envelope) =
70                    serde_json::from_value::<AssistantEnvelope>(message_value.clone())
71                else {
72                    tracing::warn!(
73                        target: "agent.history",
74                        ?message_value,
75                        "synthesize_replay_messages: failed to decode AssistantEnvelope; entry skipped",
76                    );
77                    continue;
78                };
79                envelope.content = transform_replay_content(envelope.content);
80                out.push(Message::Assistant {
81                    message: envelope,
82                    session_id: String::new(),
83                    parent_tool_use_id,
84                    error: None,
85                    uuid: uuid.clone(),
86                    timestamp,
87                    extras: forge_primitives::messages::Extras::new(),
88                });
89            }
90            "user" => {
91                let Ok(mut envelope) =
92                    serde_json::from_value::<UserEnvelope>(message_value.clone())
93                else {
94                    tracing::warn!(
95                        target: "agent.history",
96                        ?message_value,
97                        "synthesize_replay_messages: failed to decode UserEnvelope; entry skipped",
98                    );
99                    continue;
100                };
101                envelope.content = transform_replay_content(envelope.content);
102                out.push(Message::User {
103                    message: envelope,
104                    session_id: String::new(),
105                    parent_tool_use_id,
106                    uuid,
107                    tool_use_result: entry_record
108                        .get("tool_use_result")
109                        .filter(|result| !result.is_null())
110                        .cloned(),
111                    timestamp,
112                    synthetic: entry_record
113                        .get("synthetic")
114                        .and_then(Value::as_bool)
115                        .unwrap_or(false),
116                    extras: forge_primitives::messages::Extras::new(),
117                });
118            }
119            // A system frame has no inner envelope: the scan hands the row
120            // whole, and it is already the shape the wire sends, so the
121            // same decoder turns it into the frame a reader draws. A row
122            // missing a typed field decodes as a generic `Message::System`
123            // rather than failing - a frame a view draws only where it has an
124            // arm for the subtype, and the boundary row draws itself bare or
125            // half-filled from the facts it kept - so the Err arm is for a
126            // shape this cannot currently take.
127            "system" => match serde_json::from_value::<Message>(message_value.clone()) {
128                Ok(frame) => out.push(frame),
129                // **A top-level type this build has no variant for arrives as
130                // itself**, the way the live decoder surfaces an unmodelled
131                // stream type. A malformed frame of a known `system` subtype
132                // still warns and is skipped: that is a decode this build
133                // should fix, not a frame to draw bare.
134                Err(err) => match message_value.get("type").and_then(Value::as_str) {
135                    Some(type_str) if type_str != "system" => out.push(Message::Unknown {
136                        type_str: type_str.to_owned(),
137                        raw: message_value.clone(),
138                    }),
139                    _ => tracing::warn!(
140                        target: "agent.history",
141                        event_name = "replay_system_frame_undecoded",
142                        ?message_value,
143                        %err,
144                        "synthesize_replay_messages: failed to decode a system frame; entry skipped",
145                    ),
146                },
147            },
148            _ => {}
149        }
150    }
151
152    out
153}
154
155/// Apply the replay-specific content-block transform: an attachment block
156/// keeps its mime and the size its payload weighed, and loses the payload
157/// itself.
158///
159/// A queued prompt is walked too: its `prompt` is a content-block list, and an
160/// image or document inside it is the same attachment row a typed turn's block
161/// draws - `client/src/chat/units.ts` reads both through `attachedFile`.
162/// Other variants pass through unchanged.
163fn transform_replay_content(content: Vec<ContentBlock>) -> Vec<ContentBlock> {
164    content.into_iter().map(transform_block).collect()
165}
166
167fn transform_block(block: ContentBlock) -> ContentBlock {
168    match block {
169        ContentBlock::Image { source, extras } => {
170            let (source, extras) = without_payload(source, extras);
171            ContentBlock::Image { source, extras }
172        }
173        ContentBlock::Document { source, extras } => {
174            let (source, extras) = without_payload(source, extras);
175            ContentBlock::Document { source, extras }
176        }
177        ContentBlock::QueuedCommand { prompt, command_mode, source_uuid, extras } => {
178            ContentBlock::QueuedCommand {
179                prompt: transform_prompt(prompt),
180                command_mode,
181                source_uuid,
182                extras,
183            }
184        }
185        other => other,
186    }
187}
188
189/// A queued prompt's block list with each nested attachment stripped.
190///
191/// **Only a list is walked.** A prompt that reached the transcript as a string
192/// carries no block and no payload; anything else is left as the CLI wrote it.
193fn transform_prompt(prompt: Value) -> Value {
194    let Value::Array(blocks) = prompt else {
195        return prompt;
196    };
197    Value::Array(blocks.into_iter().map(strip_nested_attachment).collect())
198}
199
200fn strip_nested_attachment(block: Value) -> Value {
201    let Value::Object(mut fields) = block else {
202        return block;
203    };
204    let is_attachment =
205        matches!(fields.get("type").and_then(Value::as_str), Some("image" | "document"));
206    if !is_attachment {
207        return Value::Object(fields);
208    }
209    let size = fields
210        .get_mut("source")
211        .and_then(Value::as_object_mut)
212        .and_then(|source| source.remove("data"))
213        .and_then(|data| data.as_str().map(forge_primitives::payload_bytes));
214    if let Some(size) = size {
215        fields.insert("forge_bytes".to_owned(), json!(size));
216    }
217    Value::Object(fields)
218}
219
220/// An attachment block as the read path keeps it.
221///
222/// The block stays the kind it was so the view's own attachment row draws it,
223/// as the live path already does. The payload dropped here is the block's OWN:
224/// the attachment row prints the size and nothing else, so those bytes would
225/// ride to a page that never draws them. What the block carries instead is
226/// that size, under the `forge_bytes` name - forge's own, so it cannot be read
227/// as a field the CLI wrote.
228fn without_payload(source: Value, extras: Extras) -> (Value, Extras) {
229    let Some(data) = source.get("data").and_then(Value::as_str) else {
230        return (source, extras);
231    };
232    let size = forge_primitives::payload_bytes(data);
233    let mut keeps = source;
234    if let Some(fields) = keeps.as_object_mut() {
235        fields.remove("data");
236    }
237    let mut kept = extras;
238    kept.insert("forge_bytes".to_owned(), json!(size));
239    (keeps, kept)
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use serde_json::json;
246
247    #[test]
248    fn synthesize_simple_text_history() {
249        let messages = vec![
250            json!({
251                "type": "user",
252                "message": {
253                    "role": "user",
254                    "content": [{"type":"text","text":"hi"}]
255                }
256            }),
257            json!({
258                "type": "assistant",
259                "message": {
260                    "id": "msg_01",
261                    "role": "assistant",
262                    "model": "claude-opus-4-5",
263                    "content": [{"type":"text","text":"hello"}]
264                }
265            }),
266        ];
267        let synthesized = synthesize_replay_messages(&messages);
268        assert_eq!(synthesized.len(), 2);
269        let Message::User { message, session_id, .. } = &synthesized[0] else {
270            panic!("expected first synthesized message to be Message::User");
271        };
272        assert!(session_id.is_empty(), "session_id must be left empty for the caller to stamp");
273        assert_eq!(message.content.len(), 1);
274        assert!(matches!(&message.content[0], ContentBlock::Text { text, .. } if text == "hi"));
275        let Message::Assistant { message, session_id, .. } = &synthesized[1] else {
276            panic!("expected second synthesized message to be Message::Assistant");
277        };
278        assert!(session_id.is_empty(), "session_id must be left empty for the caller to stamp");
279        assert_eq!(message.id, "msg_01");
280        assert_eq!(message.model, "claude-opus-4-5");
281        assert_eq!(message.content.len(), 1);
282        assert!(matches!(&message.content[0], ContentBlock::Text { text, .. } if text == "hello"));
283    }
284
285    #[test]
286    fn synthesize_thinking_blocks_survive_the_read() {
287        let messages = vec![json!({
288            "type": "assistant",
289            "message": {
290                "id": "msg_02",
291                "role": "assistant",
292                "model": "claude-opus-4-5",
293                "content": [
294                    {"type":"thinking","thinking":"reasoning…","signature":"sig"},
295                    {"type":"text","text":"after thought"},
296                ]
297            }
298        })];
299        let synthesized = synthesize_replay_messages(&messages);
300        assert_eq!(synthesized.len(), 1);
301        let Message::Assistant { message, .. } = &synthesized[0] else {
302            panic!("expected Message::Assistant");
303        };
304        assert_eq!(
305            message.content.len(),
306            2,
307            "the thinking block rides the read: the live path draws a row for it, so dropping \
308             it here draws the same turn two ways",
309        );
310        assert!(
311            matches!(
312                &message.content[0],
313                ContentBlock::Thinking { thinking, .. } if thinking == "reasoning…"
314            ),
315            "the thought's own words stay where the assistant frame carried them - a block kept \
316             with its text blanked draws no row either",
317        );
318        assert!(
319            matches!(&message.content[1], ContentBlock::Text { text, .. } if text == "after thought"),
320            "and the text after it is untouched",
321        );
322    }
323
324    #[test]
325    fn synthesize_keeps_an_attachment_the_live_path_draws() {
326        // The block stays an image: the view's attachment row is what the live
327        // path draws from the same block, and a text `[image]` drew the words
328        // instead of it. The payload itself does not ride to the page - the row
329        // prints the size, not the picture - so the block carries the size this
330        // side measured.
331        let data = "A".repeat(400);
332        let messages = vec![json!({
333            "type": "user",
334            "message": {
335                "role": "user",
336                "content": [
337                    {"type":"text","text":"the mock I mean"},
338                    {"type":"image","source":{"type":"base64","media_type":"image/png","data": data}}
339                ]
340            }
341        })];
342        let synthesized = synthesize_replay_messages(&messages);
343        assert_eq!(synthesized.len(), 1);
344        let Message::User { message, .. } = &synthesized[0] else {
345            panic!("expected Message::User");
346        };
347        assert_eq!(message.content.len(), 2);
348        let ContentBlock::Image { source, extras } = &message.content[1] else {
349            panic!("expected the image block to stay an image");
350        };
351        assert_eq!(
352            source.get("media_type").and_then(Value::as_str),
353            Some("image/png"),
354            "the row names the mime the block carried",
355        );
356        assert_eq!(
357            source.get("data"),
358            None,
359            "the payload does not ride to a page that draws no bytes",
360        );
361        assert_eq!(
362            extras.get("forge_bytes").and_then(Value::as_u64),
363            Some(300),
364            "the size the block weighed is what the row prints",
365        );
366    }
367
368    #[test]
369    fn synthesize_leaves_an_attachment_it_weighed_nothing_for_alone() {
370        // A source with no base64 payload - a URL, or a block the CLI wrote
371        // without data - has no size this side can measure, and the row draws
372        // what it does have.
373        let messages = vec![json!({
374            "type": "user",
375            "message": {
376                "role": "user",
377                "content": [{"type":"image","source":{"type":"url","url":"https://example.test/a.png"}}]
378            }
379        })];
380        let synthesized = synthesize_replay_messages(&messages);
381        let Message::User { message, .. } = &synthesized[0] else {
382            panic!("expected Message::User");
383        };
384        let ContentBlock::Image { source, extras } = &message.content[0] else {
385            panic!("expected the image block to stay an image");
386        };
387        assert_eq!(
388            source.get("url").and_then(Value::as_str),
389            Some("https://example.test/a.png"),
390            "a source with no payload is left as it came",
391        );
392        assert!(extras.is_empty(), "and nothing is invented for it");
393    }
394
395    /// **A queued prompt's attachment rides inside its `prompt` array**, and
396    /// the page draws the same attachment row a typed turn's block draws. Left
397    /// whole, a multi-megabyte screenshot queued mid-turn rides every page
398    /// read of the transcript - the cost the top-level transform exists to
399    /// avoid, on the shape it never walked.
400    #[test]
401    fn synthesize_strips_an_attachment_nested_in_a_queued_prompt() {
402        let data = "A".repeat(400);
403        let messages = vec![json!({
404            "type": "user",
405            "message": {
406                "role": "user",
407                "content": [{
408                    "type": "queued_command",
409                    "prompt": [
410                        {"type":"text","text":"look at this"},
411                        {"type":"image","source":{"type":"base64","media_type":"image/png","data": data}},
412                        {"type":"document","source":{"type":"base64","media_type":"application/pdf","data": data}}
413                    ],
414                    "commandMode": "prompt",
415                    "source_uuid": "u-1"
416                }]
417            }
418        })];
419        let synthesized = synthesize_replay_messages(&messages);
420        let Message::User { message, .. } = &synthesized[0] else {
421            panic!("expected Message::User");
422        };
423        let ContentBlock::QueuedCommand { prompt, command_mode, source_uuid, .. } =
424            &message.content[0]
425        else {
426            panic!("expected the queued prompt to stay a queued prompt");
427        };
428        assert_eq!(command_mode.as_deref(), Some("prompt"), "the mode survives the walk");
429        assert_eq!(source_uuid.as_deref(), Some("u-1"), "and so does the source uuid");
430        let blocks = prompt.as_array().expect("the prompt is a block list");
431        assert_eq!(
432            blocks[0].get("text").and_then(Value::as_str),
433            Some("look at this"),
434            "a text block inside the prompt is left as it came",
435        );
436        for (at, kind) in [(1usize, "image"), (2, "document")] {
437            let block = &blocks[at];
438            assert_eq!(block.get("type").and_then(Value::as_str), Some(kind), "still {kind}");
439            assert_eq!(
440                block.get("source").and_then(|source| source.get("data")),
441                None,
442                "a queued {kind}'s payload does not ride to a page that draws no bytes",
443            );
444            assert_eq!(
445                block.get("forge_bytes").and_then(Value::as_u64),
446                Some(300),
447                "the size the queued {kind} weighed is what its row prints",
448            );
449        }
450    }
451
452    /// **A document is an attachment row like an image.** The client draws
453    /// both through the same row, from the mime and the measured size, so a
454    /// top-level document's payload is the same cost as a top-level image's
455    /// and loses it the same way.
456    #[test]
457    fn synthesize_strips_a_top_level_document_payload() {
458        let data = "B".repeat(400);
459        let messages = vec![json!({
460            "type": "user",
461            "message": {
462                "role": "user",
463                "content": [
464                    {"type":"document","source":{"type":"base64","media_type":"application/pdf","data": data}}
465                ]
466            }
467        })];
468        let synthesized = synthesize_replay_messages(&messages);
469        let Message::User { message, .. } = &synthesized[0] else {
470            panic!("expected Message::User");
471        };
472        let ContentBlock::Document { source, extras } = &message.content[0] else {
473            panic!("expected the document block to stay a document");
474        };
475        assert_eq!(
476            source.get("media_type").and_then(Value::as_str),
477            Some("application/pdf"),
478            "the row names the mime the block carried",
479        );
480        assert_eq!(source.get("data"), None, "the payload does not ride to a page");
481        assert_eq!(
482            extras.get("forge_bytes").and_then(Value::as_u64),
483            Some(300),
484            "the size the document weighed is what the row prints",
485        );
486    }
487
488    #[test]
489    fn synthesize_malformed_entry_is_skipped() {
490        let messages = vec![
491            // Missing required AssistantEnvelope fields (id/model) - must skip.
492            json!({ "type": "assistant", "message": { "role": "assistant" } }),
493            // Valid user message - must pass through.
494            json!({
495                "type": "user",
496                "message": { "role": "user", "content": [{"type":"text","text":"valid"}] }
497            }),
498        ];
499        let synthesized = synthesize_replay_messages(&messages);
500        assert_eq!(synthesized.len(), 1, "malformed entry must be skipped");
501        let Message::User { message, .. } = &synthesized[0] else {
502            panic!("surviving entry must be the user one");
503        };
504        assert!(matches!(&message.content[0], ContentBlock::Text { text, .. } if text == "valid"));
505    }
506}