travelagent-core 1.10.2

Core library for travelagent code review tool
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};

/// Who authored a comment: the human typing in the TUI, or an MCP agent.
///
/// Prior to Phase B (session schema 1.4) MCP-authored comments were tagged by
/// appending the body suffix `\n\n_(via MCP agent)_`. That polluted the comment
/// body and forced every export site to carry the suffix. `AuthorKind` replaces
/// the suffix with structured metadata so renderers can opt in or out of the
/// attribution explicitly. Legacy comments loaded from 1.3-era sessions are
/// migrated on load: the suffix is stripped and `author_kind` is set to
/// [`AuthorKind::McpAgent`]. See `persistence::storage::load_session`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AuthorKind {
    /// Typed by the human in the TUI.
    #[default]
    Human,
    /// Added via an MCP tool call (`trv_add_comment`).
    McpAgent,
}

/// Which side of the diff a line comment belongs to
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum LineSide {
    /// Comment on a deleted line (keyed by old_lineno)
    Old,
    /// Comment on an added or context line (keyed by new_lineno)
    #[default]
    New,
}

/// A range of lines for a comment (inclusive)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct LineRange {
    pub start: u32,
    pub end: u32,
}

impl LineRange {
    /// Create a new line range
    pub fn new(start: u32, end: u32) -> Self {
        Self {
            start: start.min(end),
            end: start.max(end),
        }
    }

    /// Create a single-line range
    pub fn single(line: u32) -> Self {
        Self {
            start: line,
            end: line,
        }
    }

    /// Check if this is a single-line range
    pub fn is_single(&self) -> bool {
        self.start == self.end
    }

    /// Check if this range contains a given line
    pub fn contains(&self, line: u32) -> bool {
        line >= self.start && line <= self.end
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
pub enum CommentType {
    #[default]
    Note,
    Suggestion,
    Issue,
    Praise,
    Question,
    /// Phase I4: a natural-language test specification attached to a
    /// diff line. The agent's `trv_write_test_from_spec` MCP verb
    /// reads the comment body, proposes a test file, and the human
    /// confirms before it lands on disk. Spec comments keep the
    /// original text as provenance (the eventual generated test
    /// carries a `Spec:` commit trailer plus a `Generated-From-Spec:`
    /// header referencing it).
    Spec,
    Custom(String),
}

impl CommentType {
    pub fn from_id(id: &str) -> Self {
        match id.to_ascii_lowercase().as_str() {
            "note" => Self::Note,
            "suggestion" => Self::Suggestion,
            "issue" => Self::Issue,
            "praise" => Self::Praise,
            "question" => Self::Question,
            "spec" => Self::Spec,
            _ => Self::Custom(id.to_string()),
        }
    }

    #[must_use]
    pub fn id(&self) -> &str {
        match self {
            CommentType::Note => "note",
            CommentType::Suggestion => "suggestion",
            CommentType::Issue => "issue",
            CommentType::Praise => "praise",
            CommentType::Question => "question",
            CommentType::Spec => "spec",
            CommentType::Custom(id) => id.as_str(),
        }
    }

    #[must_use]
    pub fn to_label(&self) -> String {
        self.id().to_ascii_uppercase()
    }

    /// Deprecated: use `to_label()` instead
    #[deprecated(note = "use to_label() instead")]
    pub fn as_str(&self) -> String {
        self.to_label()
    }
}

impl Serialize for CommentType {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        serializer.serialize_str(self.id())
    }
}

impl<'de> Deserialize<'de> for CommentType {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let value = String::deserialize(deserializer)?;
        Ok(Self::from_id(&value))
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineContext {
    pub new_line: Option<u32>,
    pub old_line: Option<u32>,
    pub content: String,
}

/// Where a comment is anchored after a live rescan. `Anchored` means the
/// comment still maps to a live line in the current diff; `Orphaned` means
/// the underlying line disappeared and we only remember what it looked like.
///
/// This is additive metadata on top of the existing
/// [`FileReview.line_comments`](crate::model::review::FileReview::line_comments)
/// keying — legacy comments loaded from disk without an explicit anchor have
/// `None` and callers should infer the state from the HashMap key.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum AnchorState {
    Anchored {
        line: u32,
        side: LineSide,
        /// When an orphan was re-anchored back to a line. `None` for comments
        /// that have always been anchored (i.e. never went through the
        /// orphan bucket). Agents polling with a `since` cursor use this to
        /// surface comments whose anchor flipped from `Orphaned` back to
        /// `Anchored` since their last poll — without it, the agent has no
        /// way to notice that a previously-orphaned comment is now live
        /// again. Backward-compat via `#[serde(default)]`.
        #[serde(default)]
        reanchored_at: Option<DateTime<Utc>>,
    },
    Orphaned {
        was_line: u32,
        was_side: LineSide,
        last_seen_content: String,
        /// When the anchor flipped to `Orphaned` — stamped by the re-anchor
        /// pass. `None` for orphaned comments loaded from L2-era sessions
        /// (backward compat via `#[serde(default)]`).
        #[serde(default)]
        orphaned_at: Option<DateTime<Utc>>,
    },
}

impl Default for AnchorState {
    fn default() -> Self {
        Self::Anchored {
            line: 0,
            side: LineSide::New,
            reanchored_at: None,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comment {
    pub id: String,
    pub content: String,
    pub comment_type: CommentType,
    pub created_at: DateTime<Utc>,
    pub line_context: Option<LineContext>,
    /// Which side of the diff this comment belongs to (for line comments)
    /// None for file-level comments, defaults to New for backward compatibility
    #[serde(default)]
    pub side: Option<LineSide>,
    /// Line range for multi-line comments (for line comments)
    /// None for file-level comments or single-line comments (backward compatibility)
    #[serde(default)]
    pub line_range: Option<LineRange>,
    /// Remote forge comment ID (set after posting to GitHub/GitLab)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub remote_id: Option<u64>,
    /// Where the comment is anchored after a live rescan. `None` means the
    /// comment pre-dates the L2 schema — callers should infer the anchor from
    /// the enclosing `FileReview.line_comments` HashMap key.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub anchor: Option<AnchorState>,
    /// Who authored this comment. Introduced in session schema 1.4; legacy
    /// comments without the field load as [`AuthorKind::Human`] via
    /// `serde(default)`, and a migration step in `load_session` promotes any
    /// comment whose body ends with the legacy `\n\n_(via MCP agent)_` suffix
    /// to [`AuthorKind::McpAgent`].
    #[serde(default)]
    pub author_kind: AuthorKind,
    /// Phase I5-2b: for `CommentType::Spec`, marks the spec as
    /// "reconciled — reviewer accepted the generated test". Resolved
    /// specs are hidden from the Sparring panel's active list, from
    /// `[spar: L/N]` counters, and from `trv_list_spec_comments`'s
    /// default output — they persist so the reviewer's decision isn't
    /// lost across restarts, but they don't pay rent in the live
    /// workflow. Not meaningful on non-spec comments; always false for
    /// them. Introduced in session schema 1.6.
    #[serde(default, skip_serializing_if = "is_false")]
    pub resolved: bool,
}

#[inline]
fn is_false(b: &bool) -> bool {
    !*b
}

impl Comment {
    #[must_use]
    pub fn new(content: String, comment_type: CommentType, side: Option<LineSide>) -> Self {
        Self {
            id: uuid::Uuid::new_v4().to_string(),
            content,
            comment_type,
            created_at: Utc::now(),
            line_context: None,
            side,
            line_range: None,
            remote_id: None,
            anchor: None,
            author_kind: AuthorKind::Human,
            resolved: false,
        }
    }

    /// Create a comment with an explicit [`AuthorKind`]. Used by the MCP
    /// bridge to tag agent-authored comments without mutating the body.
    #[must_use]
    pub fn new_with_author_kind(
        content: String,
        comment_type: CommentType,
        side: Option<LineSide>,
        author_kind: AuthorKind,
    ) -> Self {
        Self {
            author_kind,
            ..Self::new(content, comment_type, side)
        }
    }

    /// Create a new comment with a line range
    pub fn new_with_range(
        content: String,
        comment_type: CommentType,
        side: Option<LineSide>,
        line_range: LineRange,
    ) -> Self {
        Self {
            id: uuid::Uuid::new_v4().to_string(),
            content,
            comment_type,
            created_at: Utc::now(),
            line_context: None,
            side,
            line_range: Some(line_range),
            remote_id: None,
            anchor: None,
            author_kind: AuthorKind::Human,
            resolved: false,
        }
    }
}

/// Legacy body suffix appended to every MCP-authored comment in sessions
/// written by schema < 1.4. [`migrate_legacy_mcp_author_marker`] strips this
/// and promotes the comment's [`AuthorKind`] to [`AuthorKind::McpAgent`].
const LEGACY_MCP_AUTHOR_MARKER: &str = "\n\n_(via MCP agent)_";

/// Strip the legacy MCP marker from `comment.content` and promote its
/// [`AuthorKind`] to [`AuthorKind::McpAgent`] if found. Comments without the
/// marker are left untouched. Idempotent.
///
/// Called by `persistence::storage::load_session` when rolling a 1.3-era
/// session forward to 1.4. Agent-authored comments in 1.3 had the marker in
/// their body; 1.4 tracks provenance in the structured `author_kind` field
/// instead. See module docs on [`AuthorKind`].
///
/// **Matching rule.** The 1.3 bridge built tagged bodies as
/// `format!("{body}{MARKER}{tour_tag}")`, where `tour_tag` was either empty
/// or `"\n\n_(tour stop N/M @ short_sha)_"`. So the marker lives either at
/// the true end of the string or immediately before a tour-tag suffix. To
/// migrate correctly without corrupting a legitimate body that mentions the
/// marker text inline (e.g. `"see _(via MCP agent)_ in the logs"`), we match
/// only when the marker is followed by a tour-tag-shaped suffix or
/// end-of-string.
pub fn migrate_legacy_mcp_author_marker(comment: &mut Comment) {
    let content = &comment.content;
    let Some(marker_start) = content.rfind(LEGACY_MCP_AUTHOR_MARKER) else {
        return;
    };
    let marker_end = marker_start + LEGACY_MCP_AUTHOR_MARKER.len();
    let tail = &content[marker_end..];
    // Accept: nothing after the marker, or a tour-tag-shaped suffix
    // (`\n\n_(tour stop ...)_`). Anything else means the marker text is
    // embedded mid-body — don't touch it.
    let is_tail_empty = tail.is_empty();
    let is_tail_tour_tag = tail.starts_with("\n\n_(tour stop ") && tail.ends_with(")_");
    if !is_tail_empty && !is_tail_tour_tag {
        return;
    }
    // Preserve any tour-tag that lived after the marker — agents still want
    // to see the stop provenance.
    let mut new_content = String::with_capacity(content.len());
    new_content.push_str(&content[..marker_start]);
    new_content.push_str(tail);
    comment.content = new_content;
    comment.author_kind = AuthorKind::McpAgent;
}

#[cfg(test)]
mod tests {
    use super::*;

    mod line_range_tests {
        use super::*;

        #[test]
        fn new_creates_range_with_correct_bounds() {
            let range = LineRange::new(10, 20);
            assert_eq!(range.start, 10);
            assert_eq!(range.end, 20);
        }

        #[test]
        fn new_normalizes_reversed_bounds() {
            // When start > end, new() should normalize them
            let range = LineRange::new(20, 10);
            assert_eq!(range.start, 10);
            assert_eq!(range.end, 20);
        }

        #[test]
        fn single_creates_single_line_range() {
            let range = LineRange::single(42);
            assert_eq!(range.start, 42);
            assert_eq!(range.end, 42);
        }

        #[test]
        fn is_single_returns_true_for_single_line() {
            let range = LineRange::single(10);
            assert!(range.is_single());
        }

        #[test]
        fn is_single_returns_false_for_multi_line() {
            let range = LineRange::new(10, 15);
            assert!(!range.is_single());
        }

        #[test]
        fn contains_returns_true_for_start_line() {
            let range = LineRange::new(10, 20);
            assert!(range.contains(10));
        }

        #[test]
        fn contains_returns_true_for_end_line() {
            let range = LineRange::new(10, 20);
            assert!(range.contains(20));
        }

        #[test]
        fn contains_returns_true_for_middle_line() {
            let range = LineRange::new(10, 20);
            assert!(range.contains(15));
        }

        #[test]
        fn contains_returns_false_for_line_before_range() {
            let range = LineRange::new(10, 20);
            assert!(!range.contains(5));
        }

        #[test]
        fn contains_returns_false_for_line_after_range() {
            let range = LineRange::new(10, 20);
            assert!(!range.contains(25));
        }

        #[test]
        fn single_line_range_contains_only_that_line() {
            let range = LineRange::single(42);
            assert!(!range.contains(41));
            assert!(range.contains(42));
            assert!(!range.contains(43));
        }

        #[test]
        fn line_range_serializes_correctly() {
            let range = LineRange::new(10, 20);
            let json = serde_json::to_string(&range).unwrap();
            assert!(json.contains("\"start\":10"));
            assert!(json.contains("\"end\":20"));
        }

        #[test]
        fn line_range_deserializes_correctly() {
            let json = r#"{"start":10,"end":20}"#;
            let range: LineRange = serde_json::from_str(json).unwrap();
            assert_eq!(range.start, 10);
            assert_eq!(range.end, 20);
        }
    }

    mod comment_tests {
        use super::*;

        #[test]
        fn comment_type_serializes_question_type_as_string() {
            let comment_type = CommentType::from_id("question");
            assert_eq!(comment_type, CommentType::Question);
            let json = serde_json::to_string(&comment_type).unwrap();
            assert_eq!(json, "\"question\"");
        }

        #[test]
        fn comment_type_deserializes_question_type_from_string() {
            let json = "\"question\"";
            let comment_type: CommentType = serde_json::from_str(json).unwrap();
            assert_eq!(comment_type, CommentType::Question);
            assert_eq!(comment_type.id(), "question");
        }

        // ── CommentType::Spec (Phase I4) ──

        #[test]
        fn comment_type_roundtrips_spec() {
            let t = CommentType::Spec;
            assert_eq!(t.id(), "spec");
            let json = serde_json::to_string(&t).unwrap();
            assert_eq!(json, "\"spec\"");
            let parsed: CommentType = serde_json::from_str(&json).unwrap();
            assert_eq!(parsed, CommentType::Spec);
        }

        #[test]
        fn comment_type_from_id_spec_case_insensitive() {
            // `from_id` lowercases before matching, so "SPEC" / "Spec"
            // both resolve to the canonical Spec variant (not Custom).
            assert_eq!(CommentType::from_id("Spec"), CommentType::Spec);
            assert_eq!(CommentType::from_id("SPEC"), CommentType::Spec);
        }

        #[test]
        fn new_creates_comment_without_line_range() {
            let comment = Comment::new(
                "Test comment".to_string(),
                CommentType::Note,
                Some(LineSide::New),
            );
            assert!(comment.line_range.is_none());
            assert_eq!(comment.content, "Test comment");
            assert_eq!(comment.comment_type, CommentType::Note);
            assert_eq!(comment.side, Some(LineSide::New));
        }

        #[test]
        fn new_with_range_creates_comment_with_line_range() {
            let range = LineRange::new(10, 15);
            let comment = Comment::new_with_range(
                "Range comment".to_string(),
                CommentType::Issue,
                Some(LineSide::Old),
                range,
            );
            assert!(comment.line_range.is_some());
            let stored_range = comment.line_range.unwrap();
            assert_eq!(stored_range.start, 10);
            assert_eq!(stored_range.end, 15);
            assert_eq!(comment.side, Some(LineSide::Old));
        }

        #[test]
        fn comment_with_line_range_serializes_correctly() {
            let range = LineRange::new(10, 15);
            let comment = Comment::new_with_range(
                "Test".to_string(),
                CommentType::Note,
                Some(LineSide::New),
                range,
            );
            let json = serde_json::to_string(&comment).unwrap();
            assert!(json.contains("\"line_range\""));
            assert!(json.contains("\"start\":10"));
            assert!(json.contains("\"end\":15"));
        }

        #[test]
        fn comment_without_line_range_deserializes_with_none() {
            // Simulate old format without line_range field
            let json = r#"{
                "id": "test-id",
                "content": "Test comment",
                "comment_type": "note",
                "created_at": "2024-01-01T00:00:00Z",
                "line_context": null,
                "side": "new"
            }"#;
            let comment: Comment = serde_json::from_str(json).unwrap();
            assert!(comment.line_range.is_none());
            assert_eq!(comment.content, "Test comment");
        }

        #[test]
        fn comment_with_line_range_deserializes_correctly() {
            let json = r#"{
                "id": "test-id",
                "content": "Range comment",
                "comment_type": "issue",
                "created_at": "2024-01-01T00:00:00Z",
                "line_context": null,
                "side": "old",
                "line_range": {"start": 10, "end": 15}
            }"#;
            let comment: Comment = serde_json::from_str(json).unwrap();
            assert!(comment.line_range.is_some());
            let range = comment.line_range.unwrap();
            assert_eq!(range.start, 10);
            assert_eq!(range.end, 15);
        }
    }

    mod anchor_state_tests {
        use super::*;

        #[test]
        fn anchor_state_anchored_deserializes_without_reanchored_at() {
            // L2-era sessions were saved before `reanchored_at` existed.
            // `#[serde(default)]` must let them round-trip with `None`.
            let json = r#"{"state":"anchored","line":42,"side":"new"}"#;
            let anchor: AnchorState = serde_json::from_str(json).unwrap();
            match anchor {
                AnchorState::Anchored {
                    line,
                    side,
                    reanchored_at,
                } => {
                    assert_eq!(line, 42);
                    assert_eq!(side, LineSide::New);
                    assert_eq!(reanchored_at, None);
                }
                _ => panic!("expected Anchored"),
            }
        }

        #[test]
        fn anchor_state_orphaned_deserializes_without_orphaned_at() {
            // L2-era orphans were written before `orphaned_at` existed.
            let json = r#"{
                "state":"orphaned",
                "was_line":7,
                "was_side":"old",
                "last_seen_content":"let x = 1;"
            }"#;
            let anchor: AnchorState = serde_json::from_str(json).unwrap();
            match anchor {
                AnchorState::Orphaned {
                    was_line,
                    was_side,
                    last_seen_content,
                    orphaned_at,
                } => {
                    assert_eq!(was_line, 7);
                    assert_eq!(was_side, LineSide::Old);
                    assert_eq!(last_seen_content, "let x = 1;");
                    assert_eq!(orphaned_at, None);
                }
                _ => panic!("expected Orphaned"),
            }
        }

        #[test]
        fn anchor_state_tag_uses_snake_case_stable_names() {
            // The `state` discriminator is the on-disk contract with every
            // existing session file. Changing it silently would strand old
            // comments; pin both variants.
            let anchored = AnchorState::Anchored {
                line: 1,
                side: LineSide::New,
                reanchored_at: None,
            };
            let json = serde_json::to_string(&anchored).unwrap();
            assert!(
                json.contains(r#""state":"anchored""#),
                "tag must be `anchored`, got {json}"
            );

            let orphaned = AnchorState::Orphaned {
                was_line: 2,
                was_side: LineSide::New,
                last_seen_content: "code".to_string(),
                orphaned_at: None,
            };
            let json = serde_json::to_string(&orphaned).unwrap();
            assert!(
                json.contains(r#""state":"orphaned""#),
                "tag must be `orphaned`, got {json}"
            );
        }
    }

    mod author_kind_tests {
        use super::*;

        #[test]
        fn comment_without_author_kind_field_defaults_to_human() {
            // Legacy 1.3 JSON: `author_kind` field absent.
            let json = r#"{
                "id": "test-id",
                "content": "legacy",
                "comment_type": "note",
                "created_at": "2024-01-01T00:00:00Z",
                "line_context": null,
                "side": "new"
            }"#;
            let comment: Comment = serde_json::from_str(json).unwrap();
            assert_eq!(comment.author_kind, AuthorKind::Human);
        }

        #[test]
        fn author_kind_round_trips_through_json() {
            let comment = Comment::new_with_author_kind(
                "agent said".to_string(),
                CommentType::Note,
                Some(LineSide::New),
                AuthorKind::McpAgent,
            );
            let json = serde_json::to_string(&comment).unwrap();
            assert!(json.contains(r#""author_kind":"mcp_agent""#));
            let back: Comment = serde_json::from_str(&json).unwrap();
            assert_eq!(back.author_kind, AuthorKind::McpAgent);
        }

        #[test]
        fn migrate_legacy_mcp_marker_strips_suffix_and_promotes_kind() {
            let mut comment = Comment::new(
                "agent said\n\n_(via MCP agent)_".to_string(),
                CommentType::Note,
                Some(LineSide::New),
            );
            assert_eq!(comment.author_kind, AuthorKind::Human);
            migrate_legacy_mcp_author_marker(&mut comment);
            assert_eq!(comment.content, "agent said");
            assert_eq!(comment.author_kind, AuthorKind::McpAgent);
        }

        #[test]
        fn migrate_legacy_mcp_marker_leaves_human_comments_untouched() {
            let original = "plain human comment".to_string();
            let mut comment =
                Comment::new(original.clone(), CommentType::Note, Some(LineSide::New));
            migrate_legacy_mcp_author_marker(&mut comment);
            assert_eq!(comment.content, original);
            assert_eq!(comment.author_kind, AuthorKind::Human);
        }

        #[test]
        fn migrate_legacy_mcp_marker_is_idempotent_on_already_migrated_agent_comment() {
            let mut comment = Comment::new_with_author_kind(
                "already migrated".to_string(),
                CommentType::Note,
                Some(LineSide::New),
                AuthorKind::McpAgent,
            );
            migrate_legacy_mcp_author_marker(&mut comment);
            assert_eq!(comment.content, "already migrated");
            assert_eq!(comment.author_kind, AuthorKind::McpAgent);
        }

        #[test]
        fn migrate_legacy_mcp_marker_only_matches_exact_suffix() {
            // A body mentioning the marker but not ending with it must not be
            // mutated — that would corrupt the first half of the comment.
            let mut comment = Comment::new(
                "see _(via MCP agent)_ in the logs".to_string(),
                CommentType::Note,
                Some(LineSide::New),
            );
            migrate_legacy_mcp_author_marker(&mut comment);
            assert_eq!(comment.content, "see _(via MCP agent)_ in the logs");
            assert_eq!(comment.author_kind, AuthorKind::Human);
        }

        #[test]
        fn migrate_legacy_mcp_marker_handles_tour_tag_after_marker() {
            // Regression: pre-1.4 the bridge built tagged bodies as
            // `{body}{MARKER}{tour_tag}`, so for agents commenting during an
            // active tour the marker was NOT the suffix. `strip_suffix` would
            // miss these and they'd load as `Human` with the marker still
            // visible in the body. The migration must peel the marker out
            // while preserving the tour-tag.
            let mut comment = Comment::new(
                "agent said\n\n_(via MCP agent)_\n\n_(tour stop 1/3 @ abcdef1)_".to_string(),
                CommentType::Note,
                Some(LineSide::New),
            );
            migrate_legacy_mcp_author_marker(&mut comment);
            assert_eq!(
                comment.content, "agent said\n\n_(tour stop 1/3 @ abcdef1)_",
                "tour-tag must survive marker removal",
            );
            assert_eq!(comment.author_kind, AuthorKind::McpAgent);
        }

        #[test]
        fn migrate_legacy_mcp_marker_does_not_corrupt_body_mentioning_marker_mid_sentence() {
            // Stress the "only suffix or marker+tour-tag" rule: a body that
            // starts with the marker text but continues with unrelated prose
            // must not be mutated.
            let body = "_(via MCP agent)_ was the old way to tag\n\nNow we have author_kind.";
            let mut comment =
                Comment::new(body.to_string(), CommentType::Note, Some(LineSide::New));
            migrate_legacy_mcp_author_marker(&mut comment);
            assert_eq!(comment.content, body);
            assert_eq!(comment.author_kind, AuthorKind::Human);
        }
    }
}