1use serde_json::{Value, json};
6
7use forge_primitives::messages::Extras;
8use forge_primitives::{AssistantEnvelope, ContentBlock, Message, UserEnvelope};
9
10pub 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 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 "system" => match serde_json::from_value::<Message>(message_value.clone()) {
128 Ok(frame) => out.push(frame),
129 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
155fn 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
189fn 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
220fn 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 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 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 #[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 #[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 json!({ "type": "assistant", "message": { "role": "assistant" } }),
493 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}