1use anyhow::{Context, Result};
14use chrono::{DateTime, TimeZone, Utc};
15use serde::Deserialize;
16use std::fs;
17use std::path::{Path, PathBuf};
18use uuid::Uuid;
19
20use crate::storage::models::{ContentBlock, Message, MessageContent, MessageRole, Session};
21
22use super::{Watcher, WatcherInfo};
23
24pub struct AmpWatcher;
29
30impl Watcher for AmpWatcher {
31 fn info(&self) -> WatcherInfo {
32 WatcherInfo {
33 name: "amp",
34 description: "Amp CLI (Sourcegraph)",
35 default_paths: vec![amp_threads_dir()],
36 }
37 }
38
39 fn is_available(&self) -> bool {
40 amp_threads_dir().exists()
41 }
42
43 fn find_sources(&self) -> Result<Vec<PathBuf>> {
44 find_amp_session_files()
45 }
46
47 fn parse_source(&self, path: &Path) -> Result<Vec<(Session, Vec<Message>)>> {
48 let parsed = parse_amp_session_file(path)?;
49 if parsed.messages.is_empty() {
50 return Ok(vec![]);
51 }
52 let (session, messages) = parsed.to_storage_models();
53 Ok(vec![(session, messages)])
54 }
55
56 fn watch_paths(&self) -> Vec<PathBuf> {
57 vec![amp_threads_dir()]
58 }
59}
60
61fn amp_threads_dir() -> PathBuf {
65 dirs::home_dir()
66 .unwrap_or_else(|| PathBuf::from("."))
67 .join(".local")
68 .join("share")
69 .join("amp")
70 .join("threads")
71}
72
73#[derive(Debug, Deserialize)]
75#[serde(rename_all = "camelCase")]
76struct RawAmpSession {
77 id: String,
78 created: i64,
79 #[serde(default)]
80 title: Option<String>,
81 #[serde(default)]
82 messages: Vec<RawAmpMessage>,
83 #[serde(default)]
84 env: Option<RawAmpEnv>,
85}
86
87#[derive(Debug, Deserialize)]
89struct RawAmpEnv {
90 #[serde(default)]
91 initial: Option<RawAmpInitialEnv>,
92}
93
94#[derive(Debug, Deserialize)]
96struct RawAmpInitialEnv {
97 #[serde(default)]
98 trees: Vec<RawAmpTree>,
99}
100
101#[derive(Debug, Deserialize)]
103#[serde(rename_all = "camelCase")]
104struct RawAmpTree {
105 #[serde(default)]
107 #[allow(dead_code)]
108 display_name: Option<String>,
109 #[serde(default)]
110 uri: Option<String>,
111 #[serde(default)]
112 repository: Option<RawAmpRepository>,
113}
114
115#[derive(Debug, Deserialize)]
117#[serde(rename_all = "camelCase")]
118struct RawAmpRepository {
119 #[serde(rename = "ref")]
120 #[serde(default)]
121 git_ref: Option<String>,
122}
123
124#[derive(Debug, Deserialize)]
126#[serde(rename_all = "camelCase")]
127struct RawAmpMessage {
128 role: String,
129 #[serde(default)]
130 message_id: Option<i64>,
131 #[serde(default)]
132 content: Vec<RawAmpContentBlock>,
133 #[serde(default)]
134 meta: Option<RawAmpMessageMeta>,
135 #[serde(default)]
136 usage: Option<RawAmpUsage>,
137}
138
139#[derive(Debug, Deserialize)]
141#[serde(rename_all = "camelCase")]
142struct RawAmpMessageMeta {
143 #[serde(default)]
144 sent_at: Option<i64>,
145}
146
147#[derive(Debug, Deserialize)]
149struct RawAmpUsage {
150 #[serde(default)]
151 model: Option<String>,
152}
153
154#[derive(Debug, Deserialize)]
156#[serde(tag = "type", rename_all = "snake_case")]
157enum RawAmpContentBlock {
158 Text {
159 text: String,
160 },
161 Thinking {
162 thinking: String,
163 },
164 #[serde(other)]
165 Unknown,
166}
167
168pub fn parse_amp_session_file(path: &Path) -> Result<ParsedAmpSession> {
176 let content = fs::read_to_string(path).context("Failed to read Amp session file")?;
177 let raw: RawAmpSession =
178 serde_json::from_str(&content).context("Failed to parse Amp session JSON")?;
179
180 let session_id = raw.id.strip_prefix("T-").unwrap_or(&raw.id).to_string();
182
183 let created_at = Utc.timestamp_millis_opt(raw.created).single();
185
186 let working_directory = raw
188 .env
189 .as_ref()
190 .and_then(|e| e.initial.as_ref())
191 .and_then(|i| i.trees.first())
192 .and_then(|t| t.uri.as_ref())
193 .and_then(|uri| uri.strip_prefix("file://"))
194 .map(String::from);
195
196 let git_branch = raw
198 .env
199 .as_ref()
200 .and_then(|e| e.initial.as_ref())
201 .and_then(|i| i.trees.first())
202 .and_then(|t| t.repository.as_ref())
203 .and_then(|r| r.git_ref.as_ref())
204 .and_then(|r| r.strip_prefix("refs/heads/"))
205 .map(String::from);
206
207 let mut model: Option<String> = None;
209 let messages: Vec<ParsedAmpMessage> = raw
210 .messages
211 .iter()
212 .filter_map(|m| {
213 let role = match m.role.as_str() {
214 "user" => MessageRole::User,
215 "assistant" => MessageRole::Assistant,
216 "system" => MessageRole::System,
217 _ => return None,
218 };
219
220 let mut text_parts: Vec<String> = Vec::new();
222 let mut content_blocks: Vec<ContentBlock> = Vec::new();
223 let mut has_thinking = false;
224
225 for block in &m.content {
226 match block {
227 RawAmpContentBlock::Text { text } => {
228 text_parts.push(text.clone());
229 content_blocks.push(ContentBlock::Text { text: text.clone() });
230 }
231 RawAmpContentBlock::Thinking { thinking } => {
232 has_thinking = true;
233 content_blocks.push(ContentBlock::Thinking {
234 thinking: thinking.clone(),
235 });
236 }
237 RawAmpContentBlock::Unknown => {}
238 }
239 }
240
241 if text_parts.is_empty() && !has_thinking {
243 return None;
244 }
245
246 if model.is_none() && role == MessageRole::Assistant {
248 model = m.usage.as_ref().and_then(|u| u.model.clone());
249 }
250
251 let content = if has_thinking || content_blocks.len() > 1 {
253 MessageContent::Blocks(content_blocks)
254 } else {
255 MessageContent::Text(text_parts.join("\n"))
256 };
257
258 let timestamp = m
260 .meta
261 .as_ref()
262 .and_then(|meta| meta.sent_at)
263 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
264 .or(created_at)
265 .unwrap_or_else(Utc::now);
266
267 Some(ParsedAmpMessage {
268 message_id: m.message_id,
269 timestamp,
270 role,
271 content,
272 model: m.usage.as_ref().and_then(|u| u.model.clone()),
273 })
274 })
275 .collect();
276
277 Ok(ParsedAmpSession {
278 session_id,
279 title: raw.title,
280 created_at,
281 working_directory: working_directory.unwrap_or_else(|| ".".to_string()),
282 git_branch,
283 model,
284 messages,
285 source_path: path.to_string_lossy().to_string(),
286 })
287}
288
289#[derive(Debug)]
291pub struct ParsedAmpSession {
292 pub session_id: String,
293 #[allow(dead_code)]
295 pub title: Option<String>,
296 pub created_at: Option<DateTime<Utc>>,
297 pub working_directory: String,
298 pub git_branch: Option<String>,
299 pub model: Option<String>,
300 pub messages: Vec<ParsedAmpMessage>,
301 pub source_path: String,
302}
303
304impl ParsedAmpSession {
305 pub fn to_storage_models(&self) -> (Session, Vec<Message>) {
307 let session_uuid = Uuid::parse_str(&self.session_id).unwrap_or_else(|_| Uuid::new_v4());
308
309 let started_at = self
310 .created_at
311 .or_else(|| self.messages.first().map(|m| m.timestamp))
312 .unwrap_or_else(Utc::now);
313
314 let ended_at = self.messages.last().map(|m| m.timestamp);
315
316 let session = Session {
317 id: session_uuid,
318 tool: "amp".to_string(),
319 tool_version: None,
320 started_at,
321 ended_at,
322 model: self.model.clone(),
323 working_directory: self.working_directory.clone(),
324 git_branch: self.git_branch.clone(),
325 source_path: Some(self.source_path.clone()),
326 message_count: self.messages.len() as i32,
327 machine_id: crate::storage::get_machine_id(),
328 };
329
330 let messages: Vec<Message> = self
331 .messages
332 .iter()
333 .enumerate()
334 .map(|(idx, m)| {
335 let id = Uuid::new_v4();
336
337 Message {
338 id,
339 session_id: session_uuid,
340 parent_id: None,
341 index: idx as i32,
342 timestamp: m.timestamp,
343 role: m.role.clone(),
344 content: m.content.clone(),
345 model: m.model.clone(),
346 git_branch: None,
347 cwd: None,
348 }
349 })
350 .collect();
351
352 (session, messages)
353 }
354}
355
356#[derive(Debug)]
358pub struct ParsedAmpMessage {
359 #[allow(dead_code)]
361 pub message_id: Option<i64>,
362 pub timestamp: DateTime<Utc>,
363 pub role: MessageRole,
364 pub content: MessageContent,
365 pub model: Option<String>,
366}
367
368pub fn find_amp_session_files() -> Result<Vec<PathBuf>> {
372 let threads_dir = amp_threads_dir();
373
374 if !threads_dir.exists() {
375 return Ok(Vec::new());
376 }
377
378 let mut files = Vec::new();
379
380 for entry in fs::read_dir(&threads_dir)? {
381 let entry = entry?;
382 let path = entry.path();
383
384 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
385 if name.starts_with("T-") && name.ends_with(".json") {
386 files.push(path);
387 }
388 }
389 }
390
391 Ok(files)
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use std::io::Write;
398 use tempfile::NamedTempFile;
399
400 fn create_temp_session_file(content: &str) -> NamedTempFile {
402 let mut file = NamedTempFile::with_suffix(".json").expect("Failed to create temp file");
403 file.write_all(content.as_bytes())
404 .expect("Failed to write content");
405 file.flush().expect("Failed to flush");
406 file
407 }
408
409 fn make_session_json(
411 id: &str,
412 created: i64,
413 title: Option<&str>,
414 messages_json: &str,
415 env_json: Option<&str>,
416 ) -> String {
417 let title_str = title
418 .map(|t| format!(r#""title": "{t}","#))
419 .unwrap_or_default();
420 let env_str = env_json
421 .map(|e| format!(r#""env": {e},"#))
422 .unwrap_or_default();
423 format!(
424 r#"{{
425 "v": 235,
426 "id": "{id}",
427 "created": {created},
428 {title_str}
429 {env_str}
430 "messages": {messages_json}
431 }}"#
432 )
433 }
434
435 fn make_env_json(uri: &str, git_ref: Option<&str>) -> String {
436 let repo_str = git_ref
437 .map(|r| format!(r#", "repository": {{"type": "git", "ref": "{r}"}}"#))
438 .unwrap_or_default();
439 format!(
440 r#"{{
441 "initial": {{
442 "trees": [{{
443 "displayName": "project",
444 "uri": "{uri}"{repo_str}
445 }}]
446 }}
447 }}"#
448 )
449 }
450
451 #[test]
456 fn test_parse_simple_session() {
457 let json = make_session_json(
458 "T-019b4d26-22b6-744d-8d30-d6bf43d6b520",
459 1766525903546,
460 Some("Test Session"),
461 r#"[
462 {
463 "role": "user",
464 "messageId": 0,
465 "content": [{"type": "text", "text": "Hello"}],
466 "meta": {"sentAt": 1766525916428}
467 },
468 {
469 "role": "assistant",
470 "messageId": 1,
471 "content": [{"type": "text", "text": "Hi there!"}],
472 "usage": {"model": "claude-opus-4-5-20251101", "inputTokens": 9, "outputTokens": 417}
473 }
474 ]"#,
475 None,
476 );
477
478 let file = create_temp_session_file(&json);
479 let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
480
481 assert_eq!(parsed.session_id, "019b4d26-22b6-744d-8d30-d6bf43d6b520");
482 assert_eq!(parsed.title, Some("Test Session".to_string()));
483 assert_eq!(parsed.messages.len(), 2);
484 assert_eq!(parsed.messages[0].role, MessageRole::User);
485 assert_eq!(parsed.messages[1].role, MessageRole::Assistant);
486 assert_eq!(parsed.model, Some("claude-opus-4-5-20251101".to_string()));
487 }
488
489 #[test]
490 fn test_parse_session_id_strips_prefix() {
491 let json = make_session_json(
492 "T-550e8400-e29b-41d4-a716-446655440000",
493 1766525903546,
494 None,
495 r#"[{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]"#,
496 None,
497 );
498
499 let file = create_temp_session_file(&json);
500 let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
501
502 assert_eq!(parsed.session_id, "550e8400-e29b-41d4-a716-446655440000");
503 }
504
505 #[test]
506 fn test_parse_user_message() {
507 let json = make_session_json(
508 "T-test-session",
509 1766525903546,
510 None,
511 r#"[{"role": "user", "content": [{"type": "text", "text": "What is Rust?"}]}]"#,
512 None,
513 );
514
515 let file = create_temp_session_file(&json);
516 let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
517
518 assert_eq!(parsed.messages.len(), 1);
519 assert_eq!(parsed.messages[0].role, MessageRole::User);
520 assert_eq!(parsed.messages[0].content.text(), "What is Rust?");
521 }
522
523 #[test]
524 fn test_parse_assistant_message_with_model() {
525 let json = make_session_json(
526 "T-test-session",
527 1766525903546,
528 None,
529 r#"[{
530 "role": "assistant",
531 "content": [{"type": "text", "text": "Rust is a systems programming language."}],
532 "usage": {"model": "claude-opus-4-5-20251101"}
533 }]"#,
534 None,
535 );
536
537 let file = create_temp_session_file(&json);
538 let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
539
540 assert_eq!(parsed.messages.len(), 1);
541 assert_eq!(parsed.messages[0].role, MessageRole::Assistant);
542 assert_eq!(
543 parsed.messages[0].model,
544 Some("claude-opus-4-5-20251101".to_string())
545 );
546 }
547
548 #[test]
549 fn test_parse_thinking_blocks() {
550 let json = make_session_json(
551 "T-test-session",
552 1766525903546,
553 None,
554 r#"[{
555 "role": "assistant",
556 "content": [
557 {"type": "thinking", "thinking": "Let me analyze this..."},
558 {"type": "text", "text": "Here is my answer"}
559 ]
560 }]"#,
561 None,
562 );
563
564 let file = create_temp_session_file(&json);
565 let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
566
567 assert_eq!(parsed.messages.len(), 1);
568 if let MessageContent::Blocks(blocks) = &parsed.messages[0].content {
569 assert_eq!(blocks.len(), 2);
570 assert!(
571 matches!(&blocks[0], ContentBlock::Thinking { thinking } if thinking == "Let me analyze this...")
572 );
573 assert!(
574 matches!(&blocks[1], ContentBlock::Text { text } if text == "Here is my answer")
575 );
576 } else {
577 panic!("Expected Blocks content");
578 }
579 }
580
581 #[test]
582 fn test_parse_working_directory_from_env() {
583 let env = make_env_json("file:///Users/franzer/projects/redactyl", None);
584 let json = make_session_json(
585 "T-test-session",
586 1766525903546,
587 None,
588 r#"[{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]"#,
589 Some(&env),
590 );
591
592 let file = create_temp_session_file(&json);
593 let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
594
595 assert_eq!(parsed.working_directory, "/Users/franzer/projects/redactyl");
596 }
597
598 #[test]
599 fn test_parse_git_branch_from_env() {
600 let env = make_env_json(
601 "file:///Users/franzer/projects/redactyl",
602 Some("refs/heads/main"),
603 );
604 let json = make_session_json(
605 "T-test-session",
606 1766525903546,
607 None,
608 r#"[{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]"#,
609 Some(&env),
610 );
611
612 let file = create_temp_session_file(&json);
613 let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
614
615 assert_eq!(parsed.git_branch, Some("main".to_string()));
616 }
617
618 #[test]
619 fn test_parse_message_timestamp_from_meta() {
620 let json = make_session_json(
621 "T-test-session",
622 1766525903546,
623 None,
624 r#"[{
625 "role": "user",
626 "content": [{"type": "text", "text": "Hello"}],
627 "meta": {"sentAt": 1766525916428}
628 }]"#,
629 None,
630 );
631
632 let file = create_temp_session_file(&json);
633 let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
634
635 assert!(parsed.messages[0].timestamp.timestamp_millis() > 0);
637 }
638
639 #[test]
640 fn test_unknown_message_role_skipped() {
641 let json = make_session_json(
642 "T-test-session",
643 1766525903546,
644 None,
645 r#"[
646 {"role": "user", "content": [{"type": "text", "text": "Hello"}]},
647 {"role": "unknown", "content": [{"type": "text", "text": "Should be skipped"}]},
648 {"role": "assistant", "content": [{"type": "text", "text": "Hi!"}]}
649 ]"#,
650 None,
651 );
652
653 let file = create_temp_session_file(&json);
654 let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
655
656 assert_eq!(parsed.messages.len(), 2);
657 assert_eq!(parsed.messages[0].role, MessageRole::User);
658 assert_eq!(parsed.messages[1].role, MessageRole::Assistant);
659 }
660
661 #[test]
662 fn test_empty_content_skipped() {
663 let json = make_session_json(
664 "T-test-session",
665 1766525903546,
666 None,
667 r#"[
668 {"role": "user", "content": [{"type": "text", "text": "Hello"}]},
669 {"role": "assistant", "content": []},
670 {"role": "user", "content": [{"type": "text", "text": "Goodbye"}]}
671 ]"#,
672 None,
673 );
674
675 let file = create_temp_session_file(&json);
676 let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
677
678 assert_eq!(parsed.messages.len(), 2);
679 }
680
681 #[test]
682 fn test_to_storage_models() {
683 let env = make_env_json(
684 "file:///Users/franzer/projects/test",
685 Some("refs/heads/feature"),
686 );
687 let json = make_session_json(
688 "T-550e8400-e29b-41d4-a716-446655440000",
689 1766525903546,
690 Some("Test Title"),
691 r#"[
692 {"role": "user", "content": [{"type": "text", "text": "Hello"}]},
693 {"role": "assistant", "content": [{"type": "text", "text": "Hi!"}], "usage": {"model": "claude-opus-4"}}
694 ]"#,
695 Some(&env),
696 );
697
698 let file = create_temp_session_file(&json);
699 let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
700 let (session, messages) = parsed.to_storage_models();
701
702 assert_eq!(session.tool, "amp");
703 assert_eq!(
704 session.id.to_string(),
705 "550e8400-e29b-41d4-a716-446655440000"
706 );
707 assert_eq!(session.working_directory, "/Users/franzer/projects/test");
708 assert_eq!(session.git_branch, Some("feature".to_string()));
709 assert_eq!(session.model, Some("claude-opus-4".to_string()));
710 assert_eq!(session.message_count, 2);
711
712 assert_eq!(messages.len(), 2);
713 assert_eq!(messages[0].role, MessageRole::User);
714 assert_eq!(messages[0].index, 0);
715 assert_eq!(messages[1].role, MessageRole::Assistant);
716 assert_eq!(messages[1].index, 1);
717 }
718
719 #[test]
720 fn test_empty_messages_array() {
721 let json = make_session_json("T-test-session", 1766525903546, None, "[]", None);
722
723 let file = create_temp_session_file(&json);
724 let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
725
726 assert!(parsed.messages.is_empty());
727 }
728
729 #[test]
730 fn test_watcher_parse_source() {
731 let watcher = AmpWatcher;
732 let json = make_session_json(
733 "T-test-session",
734 1766525903546,
735 None,
736 r#"[{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]"#,
737 None,
738 );
739
740 let file = create_temp_session_file(&json);
741 let result = watcher
742 .parse_source(file.path())
743 .expect("Should parse successfully");
744
745 assert_eq!(result.len(), 1);
746 let (session, messages) = &result[0];
747 assert_eq!(session.tool, "amp");
748 assert_eq!(messages.len(), 1);
749 }
750
751 #[test]
752 fn test_watcher_parse_source_empty_session() {
753 let watcher = AmpWatcher;
754 let json = make_session_json("T-test-session", 1766525903546, None, "[]", None);
755
756 let file = create_temp_session_file(&json);
757 let result = watcher
758 .parse_source(file.path())
759 .expect("Should parse successfully");
760
761 assert!(result.is_empty());
762 }
763
764 #[test]
765 fn test_invalid_uuid_generates_new() {
766 let json = make_session_json(
767 "T-not-a-valid-uuid",
768 1766525903546,
769 None,
770 r#"[{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]"#,
771 None,
772 );
773
774 let file = create_temp_session_file(&json);
775 let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
776 let (session, _) = parsed.to_storage_models();
777
778 assert!(!session.id.is_nil());
780 }
781
782 #[test]
783 fn test_default_working_directory() {
784 let json = make_session_json(
785 "T-test-session",
786 1766525903546,
787 None,
788 r#"[{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]"#,
789 None,
790 );
791
792 let file = create_temp_session_file(&json);
793 let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
794
795 assert_eq!(parsed.working_directory, ".");
796 }
797
798 #[test]
799 fn test_created_timestamp_parsing() {
800 let json = make_session_json(
801 "T-test-session",
802 1766525903546,
803 None,
804 r#"[{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]"#,
805 None,
806 );
807
808 let file = create_temp_session_file(&json);
809 let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
810
811 assert!(parsed.created_at.is_some());
812 assert!(parsed.created_at.unwrap().timestamp_millis() > 0);
813 }
814
815 #[test]
816 fn test_system_message() {
817 let json = make_session_json(
818 "T-test-session",
819 1766525903546,
820 None,
821 r#"[{"role": "system", "content": [{"type": "text", "text": "You are a helpful assistant."}]}]"#,
822 None,
823 );
824
825 let file = create_temp_session_file(&json);
826 let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
827
828 assert_eq!(parsed.messages.len(), 1);
829 assert_eq!(parsed.messages[0].role, MessageRole::System);
830 }
831
832 #[test]
833 fn test_unknown_content_block_type_skipped() {
834 let json = make_session_json(
835 "T-test-session",
836 1766525903546,
837 None,
838 r#"[{
839 "role": "assistant",
840 "content": [
841 {"type": "text", "text": "Hello"},
842 {"type": "tool_use", "id": "123", "name": "Bash"},
843 {"type": "text", "text": "World"}
844 ]
845 }]"#,
846 None,
847 );
848
849 let file = create_temp_session_file(&json);
850 let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
851
852 assert_eq!(parsed.messages.len(), 1);
854 if let MessageContent::Blocks(blocks) = &parsed.messages[0].content {
855 assert_eq!(blocks.len(), 2);
857 } else {
858 panic!("Expected Blocks content");
859 }
860 }
861}