1use std::fmt;
8use std::io::ErrorKind;
9use std::net::SocketAddr;
10use std::path::{Path, PathBuf};
11use std::time::{Duration, Instant};
12
13use diffler_core::feedback;
14use diffler_core::model::{DiffModel, FileStatus};
15use diffler_core::session::{Comment, CommentStatus};
16use diffler_core::source::ReviewSource;
17use rmcp::handler::server::router::prompt::PromptRouter;
18use rmcp::handler::server::router::tool::ToolRouter;
19use rmcp::handler::server::wrapper::Parameters;
20use rmcp::model::{PromptMessage, Role, ServerCapabilities, ServerConfig};
21use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
22use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
23use rmcp::{
24 ErrorData, Json, ServerHandler, prompt, prompt_handler, prompt_router, schemars, tool,
25 tool_handler, tool_router,
26};
27use serde::Serialize;
28use tokio::sync::mpsc::UnboundedSender;
29use tokio::sync::{oneshot, watch};
30use tokio::task::JoinHandle;
31
32use crate::event::AppEvent;
33
34pub const AGENT_AUTHOR: &str = "agent";
36
37const DEFAULT_WAIT_SECONDS: u64 = 25;
40const MAX_WAIT_SECONDS: u64 = 55;
41pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
43
44pub struct McpRequest {
47 pub kind: McpRequestKind,
48 pub project: Option<String>,
49 pub reply: oneshot::Sender<McpResponse>,
50}
51
52impl fmt::Debug for McpRequest {
53 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54 f.debug_struct("McpRequest")
55 .field("kind", &self.kind)
56 .field("project", &self.project)
57 .finish_non_exhaustive()
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum McpRequestKind {
63 ReviewStatus,
64 GetDiff {
65 file: Option<String>,
66 },
67 GetComments {
68 status: Option<CommentStatus>,
69 },
70 ListReviews,
71 ReplyComment {
72 id: String,
73 body: String,
74 },
75 ProposeResolve {
76 id: String,
77 note: Option<String>,
78 },
79 MarkViewed {
80 file: String,
81 },
82 AddComment {
85 file: String,
86 line: u32,
87 line_end: Option<u32>,
88 body: String,
89 as_human: bool,
90 },
91 DeleteComment {
94 id: String,
95 },
96 EditComment {
98 id: String,
99 body: String,
100 },
101 Feedback,
103 PublishWalkthrough {
106 id: Option<String>,
107 title: String,
108 stops: Vec<StopParams>,
109 skipped: Option<String>,
110 summary: Option<String>,
111 },
112 GetWalkthrough {
114 id: Option<String>,
115 },
116 ReportActivity {
118 focus: String,
119 file: Option<String>,
120 },
121 OpenProject {
122 path: String,
123 },
124 Focus {
126 target: FocusTarget,
127 note: Option<String>,
128 },
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum FocusTarget {
133 Id(String),
135 Code {
136 file: String,
137 line: Option<u32>,
138 line_end: Option<u32>,
139 review: Option<String>,
140 },
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum McpResponse {
145 Status(ReviewStatusResponse),
146 Diff(String),
147 Comments(Vec<CommentInfo>),
148 Reviews(Vec<ReviewSummary>),
149 Replied {
150 status: String,
151 },
152 Ok,
153 Added {
154 id: String,
155 },
156 Error(String),
158 WalkthroughPublished(WalkthroughPublished),
159 Walkthrough(Option<WalkthroughInfo>),
160 Feedback {
162 comments: Vec<CommentInfo>,
163 },
164 ProjectOpened(ProjectInfo),
166 Focused(FocusResponse),
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
170#[serde(rename_all = "lowercase")]
171pub enum FocusView {
172 Diff,
173 File,
174 Waiting,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
180pub struct FocusResponse {
181 pub project: String,
182 #[serde(skip_serializing_if = "Option::is_none")]
185 pub review: Option<String>,
186 pub view: FocusView,
187 #[serde(skip_serializing_if = "Option::is_none")]
188 pub file: Option<String>,
189 #[serde(skip_serializing_if = "Option::is_none")]
190 #[schemars(with = "Option<Count>")]
191 pub line: Option<u32>,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
195pub struct ReviewStatusResponse {
196 pub repo: String,
197 pub branch: Option<String>,
198 pub oid7: String,
199 pub files_changed: Vec<FileEntry>,
200 #[schemars(with = "Count")]
201 pub open_comments: usize,
202 #[schemars(with = "Count")]
203 pub replied_comments: usize,
204 #[schemars(with = "Count")]
205 pub resolved_comments: usize,
206 #[schemars(with = "Count")]
207 pub feedback_epoch: u64,
208 pub reviews: Vec<ReviewSummary>,
211 pub walkthroughs: Vec<WalkthroughSummary>,
214 pub corrupt_reviews: Vec<String>,
219 pub projects: Vec<ProjectInfo>,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
226pub struct ProjectInfo {
227 pub name: String,
229 pub root: String,
230 pub active: bool,
232 #[schemars(with = "Count")]
233 pub open_comments: usize,
234 #[schemars(with = "Count")]
235 pub replied_comments: usize,
236}
237
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
241pub struct WalkthroughSummary {
242 pub id: String,
245 pub title: String,
246 #[schemars(with = "Count")]
247 pub stops: usize,
248 #[schemars(with = "Count")]
249 pub at: u64,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
255pub struct ReviewSummary {
256 #[serde(skip_serializing_if = "Option::is_none")]
258 pub project: Option<String>,
259 pub source: String,
261 pub label: String,
263 #[schemars(with = "Count")]
264 pub open_comments: usize,
265 #[schemars(with = "Count")]
266 pub replied_comments: usize,
267 #[schemars(with = "Count")]
268 pub resolved_comments: usize,
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
272pub struct FileEntry {
273 pub path: String,
274 pub status: String,
275 pub viewed: bool,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
279pub struct DiffResponse {
280 pub diff: String,
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
284pub struct CommentInfo {
285 pub id: String,
286 #[serde(skip_serializing_if = "Option::is_none")]
288 pub project: Option<String>,
289 pub source: String,
291 pub source_label: String,
293 pub file: String,
294 #[schemars(with = "Option<Count>")]
295 pub line: Option<u32>,
296 #[schemars(with = "Option<Count>")]
297 pub line_end: Option<u32>,
298 pub side: String,
300 pub body: String,
301 pub status: String,
302 pub author: String,
303 pub replies: Vec<ReplyInfo>,
304 pub outdated: bool,
306 pub context: Option<String>,
308}
309
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
311pub struct ReplyInfo {
312 pub author: String,
313 pub body: String,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
317pub struct CommentsResponse {
318 pub comments: Vec<CommentInfo>,
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
322pub struct ReviewsResponse {
323 pub reviews: Vec<ReviewSummary>,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
327pub struct ReplyResponse {
328 pub ok: bool,
329 pub status: String,
330}
331
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
340pub struct ReceiptInfo {
341 #[schemars(with = "Option<Count>")]
342 pub stop: Option<usize>,
343 pub code: String,
344 pub detail: String,
345}
346
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
348pub struct WalkthroughPublished {
349 pub id: String,
350 #[schemars(with = "Count")]
351 pub stops: usize,
352 pub receipts: Vec<ReceiptInfo>,
353 pub rev: Option<String>,
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
359pub struct StopInfo {
360 pub id: String,
363 pub title: String,
364 pub anchor: Option<String>,
365 pub body: String,
366 pub notes: Vec<NoteInfo>,
369}
370
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
372pub struct NoteInfo {
373 pub id: String,
374 pub anchor: Option<String>,
375 pub body: String,
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
379pub struct WalkthroughInfo {
380 pub id: String,
381 pub title: String,
382 pub author: String,
383 #[schemars(with = "Count")]
384 pub at: u64,
385 pub skipped: Option<String>,
386 pub summary: Option<String>,
389 pub rev: Option<String>,
392 pub stops: Vec<StopInfo>,
393}
394
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
399pub struct GetWalkthroughResponse {
400 pub walkthrough: Option<WalkthroughInfo>,
401}
402
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
404pub struct OkResponse {
405 pub ok: bool,
406}
407
408#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
409pub struct WaitForFeedbackResponse {
410 #[schemars(with = "Count")]
411 pub epoch: u64,
412 pub timed_out: bool,
413 pub comments: Vec<CommentInfo>,
414}
415
416#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
417pub struct GetDiffParams {
418 pub file: Option<String>,
420 pub project: Option<String>,
423}
424
425#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
426pub struct GetCommentsParams {
427 pub status: Option<String>,
429}
430
431#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
432pub struct ReplyCommentParams {
433 pub id: String,
434 pub body: String,
435}
436
437#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
438pub struct ProposeResolveParams {
439 pub id: String,
440 pub note: Option<String>,
442}
443
444#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
445pub struct MarkViewedParams {
446 pub file: String,
448 pub project: Option<String>,
451}
452
453#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
454pub struct AddCommentParams {
455 pub file: String,
457 #[schemars(with = "Count")]
459 pub line: u32,
460 #[schemars(with = "Option<Count>")]
462 pub line_end: Option<u32>,
463 pub body: String,
464 pub as_human: Option<bool>,
468 pub project: Option<String>,
471}
472
473#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
474pub struct AddCommentResponse {
475 pub id: String,
476}
477
478#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
479pub struct DeleteCommentParams {
480 pub id: String,
482}
483
484#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
485pub struct EditCommentParams {
486 pub id: String,
488 pub body: String,
489}
490
491#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, schemars::JsonSchema)]
492pub struct StopParams {
493 pub id: Option<String>,
496 pub title: String,
499 pub anchor: Option<String>,
503 pub body: String,
508 pub notes: Option<Vec<NoteParams>>,
512}
513
514#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, schemars::JsonSchema)]
515pub struct NoteParams {
516 pub id: Option<String>,
519 pub anchor: Option<String>,
522 pub body: String,
523}
524
525#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
526pub struct PublishWalkthroughParams {
527 pub id: Option<String>,
530 pub title: String,
532 pub stops: Vec<StopParams>,
533 pub skipped: Option<String>,
535 pub summary: Option<String>,
548 pub project: Option<String>,
551}
552
553#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
554pub struct OpenProjectParams {
555 pub path: String,
557}
558
559#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
560pub struct FocusParams {
561 pub id: Option<String>,
564 pub file: Option<String>,
566 #[schemars(with = "Option<Count>")]
568 pub line: Option<u32>,
569 #[schemars(with = "Option<Count>")]
571 pub line_end: Option<u32>,
572 pub review: Option<String>,
578 pub note: Option<String>,
581 pub project: Option<String>,
585}
586
587#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
588pub struct GetWalkthroughParams {
589 pub id: Option<String>,
591}
592
593#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
594pub struct ReportActivityParams {
595 pub focus: String,
599 pub file: Option<String>,
601}
602
603#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
604pub struct WaitForFeedbackParams {
605 #[schemars(with = "Option<Count>")]
608 pub since_epoch: Option<u64>,
609 #[schemars(with = "Option<Count>")]
612 pub timeout_seconds: Option<u64>,
613}
614
615struct Count;
619
620impl schemars::JsonSchema for Count {
621 fn inline_schema() -> bool {
622 true
623 }
624
625 fn schema_name() -> std::borrow::Cow<'static, str> {
626 "Count".into()
627 }
628
629 fn schema_id() -> std::borrow::Cow<'static, str> {
630 "diffler::mcp::Count".into()
631 }
632
633 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
634 schemars::json_schema!({ "type": "integer", "minimum": 0 })
635 }
636}
637
638pub const fn comment_status_name(status: CommentStatus) -> &'static str {
639 match status {
640 CommentStatus::Open => "open",
641 CommentStatus::Replied => "replied",
642 CommentStatus::Resolved => "resolved",
643 }
644}
645
646pub const fn file_status_name(status: FileStatus) -> &'static str {
647 match status {
648 FileStatus::Added => "added",
649 FileStatus::Modified => "modified",
650 FileStatus::Deleted => "deleted",
651 FileStatus::Renamed => "renamed",
652 FileStatus::Untracked => "untracked",
653 FileStatus::Unchanged => "unchanged",
654 }
655}
656
657pub fn render_unified(model: &DiffModel, file: Option<&str>) -> Result<String, String> {
658 use std::fmt::Write as _;
659
660 if let Some(path) = file
661 && !model.files.iter().any(|f| f.path == path)
662 {
663 return Err(format!("unknown file: {path}"));
664 }
665 let mut out = String::new();
666 for f in &model.files {
667 if file.is_some_and(|path| path != f.path) {
668 continue;
669 }
670 let old = match f.status {
671 FileStatus::Added | FileStatus::Untracked => "/dev/null".to_owned(),
672 _ => format!("a/{}", f.old_path.as_deref().unwrap_or(&f.path)),
673 };
674 let new = match f.status {
675 FileStatus::Deleted => "/dev/null".to_owned(),
676 _ => format!("b/{}", f.path),
677 };
678 let _ = writeln!(out, "--- {old}");
679 let _ = writeln!(out, "+++ {new}");
680 if f.binary {
681 out.push_str("Binary files differ\n");
682 continue;
683 }
684 for hunk in &f.hunks {
685 let _ = writeln!(out, "{}", hunk.header());
686 for line in &hunk.lines {
687 let _ = writeln!(out, "{}{}", line.kind.origin(), line.text);
688 }
689 }
690 }
691 Ok(out)
692}
693
694pub fn comment_info(comment: &Comment, model: &DiffModel, source: &ReviewSource) -> CommentInfo {
695 let anchor = &comment.anchor;
696 let context = anchor
699 .line
700 .and_then(|line| feedback::context_snippet(model, &anchor.file, line, anchor.on_old_side))
701 .map(|snippet| {
702 snippet
703 .iter()
704 .map(|(origin, text)| format!("{origin}{text}"))
705 .collect::<Vec<_>>()
706 .join("\n")
707 });
708 let outdated = anchor.is_outdated(model);
709 CommentInfo {
710 id: comment.id.clone(),
711 project: None,
712 source: source.key(),
713 source_label: source.label(),
714 file: anchor.file.clone(),
715 line: anchor.line,
716 line_end: anchor.line_end,
717 side: if anchor.on_old_side { "old" } else { "new" }.to_owned(),
718 body: comment.body.clone(),
719 status: comment_status_name(comment.status).to_owned(),
720 author: comment.author.clone(),
721 replies: comment
722 .replies
723 .iter()
724 .map(|r| ReplyInfo {
725 author: r.author.clone(),
726 body: r.body.clone(),
727 })
728 .collect(),
729 outdated,
730 context,
731 }
732}
733
734#[derive(Clone)]
737pub struct DifflerMcp {
738 tx: UnboundedSender<AppEvent>,
739 feedback_rx: watch::Receiver<u64>,
740 tool_router: ToolRouter<Self>,
741 prompt_router: PromptRouter<Self>,
742}
743
744fn mismatch() -> ErrorData {
745 ErrorData::internal_error("unexpected response from the diffler app", None)
746}
747
748fn parse_status(status: &str) -> Result<CommentStatus, ErrorData> {
749 match status {
750 "open" => Ok(CommentStatus::Open),
751 "replied" => Ok(CommentStatus::Replied),
752 "resolved" => Ok(CommentStatus::Resolved),
753 other => Err(ErrorData::invalid_params(
754 format!("unknown status {other:?} (expected open, replied, or resolved)"),
755 None,
756 )),
757 }
758}
759
760impl DifflerMcp {
761 pub fn new(tx: UnboundedSender<AppEvent>, feedback_rx: watch::Receiver<u64>) -> Self {
762 Self {
763 tx,
764 feedback_rx,
765 tool_router: Self::tool_router(),
766 prompt_router: Self::prompt_router(),
767 }
768 }
769
770 async fn request(&self, kind: McpRequestKind) -> Result<McpResponse, ErrorData> {
771 self.request_with_timeout(kind, None, REQUEST_TIMEOUT).await
772 }
773
774 async fn request_in(
776 &self,
777 project: Option<String>,
778 kind: McpRequestKind,
779 ) -> Result<McpResponse, ErrorData> {
780 self.request_with_timeout(kind, project, REQUEST_TIMEOUT)
781 .await
782 }
783
784 async fn request_with_timeout(
785 &self,
786 kind: McpRequestKind,
787 project: Option<String>,
788 timeout: Duration,
789 ) -> Result<McpResponse, ErrorData> {
790 let (reply, response) = oneshot::channel();
791 self.tx
792 .send(AppEvent::Mcp(McpRequest {
793 kind,
794 project,
795 reply,
796 }))
797 .map_err(|_| ErrorData::internal_error("the diffler TUI is not running", None))?;
798 match tokio::time::timeout(timeout, response).await {
799 Ok(Ok(McpResponse::Error(message))) => Err(ErrorData::invalid_params(message, None)),
800 Ok(Ok(response)) => Ok(response),
801 Ok(Err(_)) => Err(ErrorData::internal_error(
802 "the diffler TUI dropped the request",
803 None,
804 )),
805 Err(_) => Err(ErrorData::internal_error(
806 "diffler is busy (editor open or loop stalled), retry",
807 None,
808 )),
809 }
810 }
811}
812
813#[tool_router]
814impl DifflerMcp {
815 #[tool(
816 description = "Current review state: repo, branch, changed files with viewed marks, comment counts, and the feedback epoch for wait_for_feedback."
817 )]
818 async fn review_status(&self) -> Result<Json<ReviewStatusResponse>, ErrorData> {
819 match self.request(McpRequestKind::ReviewStatus).await? {
820 McpResponse::Status(status) => Ok(Json(status)),
821 _ => Err(mismatch()),
822 }
823 }
824
825 #[tool(
826 description = "Unified diff text of the working tree under review, optionally restricted to one file."
827 )]
828 async fn get_diff(
829 &self,
830 Parameters(params): Parameters<GetDiffParams>,
831 ) -> Result<Json<DiffResponse>, ErrorData> {
832 let kind = McpRequestKind::GetDiff { file: params.file };
833 match self.request_in(params.project, kind).await? {
834 McpResponse::Diff(diff) => Ok(Json(DiffResponse { diff })),
835 _ => Err(mismatch()),
836 }
837 }
838
839 #[tool(
840 description = "Review comments across every review (working tree, commits, ranges), each tagged with its source and source_label; anchors, diff context, and threads included. Optionally filtered by status (open, replied, resolved)."
841 )]
842 async fn get_comments(
843 &self,
844 Parameters(params): Parameters<GetCommentsParams>,
845 ) -> Result<Json<CommentsResponse>, ErrorData> {
846 let status = params.status.as_deref().map(parse_status).transpose()?;
847 match self.request(McpRequestKind::GetComments { status }).await? {
848 McpResponse::Comments(comments) => Ok(Json(CommentsResponse { comments })),
849 _ => Err(mismatch()),
850 }
851 }
852
853 #[tool(
854 description = "List every review the human has: the working tree, individual commits, and commit ranges, each with its comment counts, so you can tell where feedback came from."
855 )]
856 async fn list_reviews(&self) -> Result<Json<ReviewsResponse>, ErrorData> {
857 match self.request(McpRequestKind::ListReviews).await? {
858 McpResponse::Reviews(reviews) => Ok(Json(ReviewsResponse { reviews })),
859 _ => Err(mismatch()),
860 }
861 }
862
863 #[tool(description = "Answer a review comment in place; the human sees the reply immediately.")]
864 async fn reply_comment(
865 &self,
866 Parameters(params): Parameters<ReplyCommentParams>,
867 ) -> Result<Json<ReplyResponse>, ErrorData> {
868 let kind = McpRequestKind::ReplyComment {
869 id: params.id,
870 body: params.body,
871 };
872 match self.request(kind).await? {
873 McpResponse::Replied { status } => Ok(Json(ReplyResponse { ok: true, status })),
874 _ => Err(mismatch()),
875 }
876 }
877
878 #[tool(
879 description = "Flag a comment as addressed: marks it replied. Writes nothing into the thread, so call it after reply_comment rather than repeating the answer. The optional note is used only when you did not reply. Only the human can resolve it, in the TUI."
880 )]
881 async fn propose_resolve(
882 &self,
883 Parameters(params): Parameters<ProposeResolveParams>,
884 ) -> Result<Json<ReplyResponse>, ErrorData> {
885 let kind = McpRequestKind::ProposeResolve {
886 id: params.id,
887 note: params.note,
888 };
889 match self.request(kind).await? {
890 McpResponse::Replied { status } => Ok(Json(ReplyResponse { ok: true, status })),
891 _ => Err(mismatch()),
892 }
893 }
894
895 #[tool(
896 description = "Mark a file as viewed in the review the human is currently looking at (working tree, or the open commit/range diff)."
897 )]
898 async fn mark_viewed(
899 &self,
900 Parameters(params): Parameters<MarkViewedParams>,
901 ) -> Result<Json<OkResponse>, ErrorData> {
902 let kind = McpRequestKind::MarkViewed { file: params.file };
903 match self.request_in(params.project, kind).await? {
904 McpResponse::Ok => Ok(Json(OkResponse { ok: true })),
905 _ => Err(mismatch()),
906 }
907 }
908
909 #[tool(
910 description = "Write a review comment on a line or an inclusive line range in the review the human has open: the working tree, or the commit, range or PR on screen. To comment on another review, `focus` it first. The comment is anchored like the human's own, so it shows as outdated once its line changes. It is the agent's comment, for the human to answer in its thread; `as_human` makes it the human's own, sent with their next submitted review. Returns its `id`."
911 )]
912 async fn add_comment(
913 &self,
914 Parameters(params): Parameters<AddCommentParams>,
915 ) -> Result<Json<AddCommentResponse>, ErrorData> {
916 let kind = McpRequestKind::AddComment {
917 file: params.file,
918 line: params.line,
919 line_end: params.line_end,
920 body: params.body,
921 as_human: params.as_human.unwrap_or(false),
922 };
923 match self.request_in(params.project, kind).await? {
924 McpResponse::Added { id } => Ok(Json(AddCommentResponse { id })),
925 _ => Err(mismatch()),
926 }
927 }
928
929 #[tool(
930 description = "Delete a comment, yours or the human's. Refused for one already posted to the forge, for one someone else has replied to (edit it instead), and for a walkthrough stop or note, which `publish_walkthrough` revises."
931 )]
932 async fn delete_comment(
933 &self,
934 Parameters(params): Parameters<DeleteCommentParams>,
935 ) -> Result<Json<OkResponse>, ErrorData> {
936 let kind = McpRequestKind::DeleteComment { id: params.id };
937 match self.request(kind).await? {
938 McpResponse::Ok => Ok(Json(OkResponse { ok: true })),
939 _ => Err(mismatch()),
940 }
941 }
942
943 #[tool(
944 description = "Replace a comment's body, yours or the human's, keeping its author, status, replies and anchor. Refused for one already posted to the forge, and for a walkthrough stop or note, which `publish_walkthrough` revises."
945 )]
946 async fn edit_comment(
947 &self,
948 Parameters(params): Parameters<EditCommentParams>,
949 ) -> Result<Json<OkResponse>, ErrorData> {
950 let kind = McpRequestKind::EditComment {
951 id: params.id,
952 body: params.body,
953 };
954 match self.request(kind).await? {
955 McpResponse::Ok => Ok(Json(OkResponse { ok: true })),
956 _ => Err(mismatch()),
957 }
958 }
959
960 #[tool(
961 description = "Publish a walkthrough: the order a reader should take through a change. Use one stop per real decision, as few as the change needs (five is common; more than ten means the change wants splitting). Each stop says why, never what the diff already shows, and becomes an agent comment the human answers in its thread. It describes the review open now, pinned to the commit checked out. Pass `id` to revise one in place, with each surviving stop's and note's `id` from `get_walkthrough`; any left out are deleted. Returns the walkthrough's `id` and receipts; a refusal names what to fix. Limits: 20 stops, 8 KB per body, 64 KB in all."
962 )]
963 async fn publish_walkthrough(
964 &self,
965 Parameters(params): Parameters<PublishWalkthroughParams>,
966 ) -> Result<Json<WalkthroughPublished>, ErrorData> {
967 let kind = McpRequestKind::PublishWalkthrough {
968 id: params.id,
969 title: params.title,
970 stops: params.stops,
971 skipped: params.skipped,
972 summary: params.summary,
973 };
974 match self.request_in(params.project, kind).await? {
975 McpResponse::WalkthroughPublished(published) => Ok(Json(published)),
976 _ => Err(mismatch()),
977 }
978 }
979
980 #[tool(
981 description = "The walkthrough `id` names (from review_status), or the newest one when `id` is omitted, or null when the review has none."
982 )]
983 async fn get_walkthrough(
984 &self,
985 Parameters(params): Parameters<GetWalkthroughParams>,
986 ) -> Result<Json<GetWalkthroughResponse>, ErrorData> {
987 match self
988 .request(McpRequestKind::GetWalkthrough { id: params.id })
989 .await?
990 {
991 McpResponse::Walkthrough(walkthrough) => {
992 Ok(Json(GetWalkthroughResponse { walkthrough }))
993 }
994 _ => Err(mismatch()),
995 }
996 }
997
998 #[tool(
999 description = "Show what you are doing in the human's status bar: a few words (\"writing the walkthrough\") and the file it is about, if any. Every other tool call already shows on its own, so call this before work no call covers, such as reading code or writing tests, or when a call's own label says too little."
1000 )]
1001 async fn report_activity(
1002 &self,
1003 Parameters(params): Parameters<ReportActivityParams>,
1004 ) -> Result<Json<OkResponse>, ErrorData> {
1005 let kind = McpRequestKind::ReportActivity {
1006 focus: params.focus,
1007 file: params.file,
1008 };
1009 match self.request(kind).await? {
1010 McpResponse::Ok => Ok(Json(OkResponse { ok: true })),
1011 _ => Err(mismatch()),
1012 }
1013 }
1014
1015 #[tool(
1016 description = "Open a git repository as a project tab in the human's diffler, so they can review its changes beside the others. Call it for every repository you changed outside the one diffler started in. Comments and feedback then cover it too, and `project` targets a call at it."
1017 )]
1018 async fn open_project(
1019 &self,
1020 Parameters(params): Parameters<OpenProjectParams>,
1021 ) -> Result<Json<ProjectInfo>, ErrorData> {
1022 let kind = McpRequestKind::OpenProject { path: params.path };
1023 match self.request(kind).await? {
1024 McpResponse::ProjectOpened(project) => Ok(Json(project)),
1025 _ => Err(mismatch()),
1026 }
1027 }
1028
1029 #[tool(
1030 description = "Take the human's diffler to a place: a comment or walkthrough by `id`, or a `file` and line range in any review. It switches to the project's tab and opens that review with the cursor on the place. Use it when the human asks where something is, and after `publish_walkthrough` to open the walkthrough. To point at code with a remark, call `add_comment`, then focus the `id` it returns. Refused while the human is typing a comment or answering a dialog; a pull request that still has to load answers `waiting` and opens once it does."
1031 )]
1032 async fn focus(
1033 &self,
1034 Parameters(params): Parameters<FocusParams>,
1035 ) -> Result<Json<FocusResponse>, ErrorData> {
1036 let target = match (params.id, params.file) {
1037 (Some(id), None) => FocusTarget::Id(id),
1038 (None, Some(file)) => FocusTarget::Code {
1039 file,
1040 line: params.line,
1041 line_end: params.line_end,
1042 review: params.review,
1043 },
1044 _ => {
1045 return Err(ErrorData::invalid_params("pass either id or file", None));
1046 }
1047 };
1048 let kind = McpRequestKind::Focus {
1049 target,
1050 note: params.note,
1051 };
1052 match self.request_in(params.project, kind).await? {
1053 McpResponse::Focused(focused) => Ok(Json(focused)),
1054 _ => Err(mismatch()),
1055 }
1056 }
1057
1058 #[tool(
1059 description = "Wait until the human sends feedback: a comment, a reply, or the send key. Returns the new epoch and every open or replied comment, or `timed_out` while they are still reviewing; call again with the returned epoch to keep waiting. A reply on a walkthrough stop arrives as a reply on that stop's comment, so its id names the stop."
1060 )]
1061 async fn wait_for_feedback(
1062 &self,
1063 Parameters(params): Parameters<WaitForFeedbackParams>,
1064 ) -> Result<Json<WaitForFeedbackResponse>, ErrorData> {
1065 let mut rx = self.feedback_rx.clone();
1066 let since = params.since_epoch.unwrap_or_else(|| *rx.borrow());
1067 let timeout = Duration::from_secs(
1068 params
1069 .timeout_seconds
1070 .unwrap_or(DEFAULT_WAIT_SECONDS)
1071 .min(MAX_WAIT_SECONDS),
1072 );
1073 let _ = self.tx.send(AppEvent::McpWaiting {
1076 until: Instant::now() + timeout,
1077 });
1078 let waited = tokio::time::timeout(timeout, rx.wait_for(|epoch| *epoch > since))
1079 .await
1080 .map(|result| result.map(|epoch| *epoch));
1082 let (epoch, timed_out) = match waited {
1083 Ok(Ok(epoch)) => (epoch, false),
1084 Ok(Err(_)) => {
1085 return Err(ErrorData::internal_error(
1086 "the diffler TUI is not running",
1087 None,
1088 ));
1089 }
1090 Err(_) => (*rx.borrow(), true),
1091 };
1092 if timed_out {
1093 return Ok(Json(WaitForFeedbackResponse {
1094 epoch,
1095 timed_out: true,
1096 comments: Vec::new(),
1097 }));
1098 }
1099 match self.request(McpRequestKind::Feedback).await? {
1100 McpResponse::Feedback { comments } => Ok(Json(WaitForFeedbackResponse {
1101 epoch,
1102 timed_out: false,
1103 comments,
1104 })),
1105 _ => Err(mismatch()),
1106 }
1107 }
1108}
1109
1110fn skill_body(doc: &str) -> String {
1114 let doc = doc.replace("\r\n", "\n");
1115 let body = doc
1116 .strip_prefix("---\n")
1117 .and_then(|rest| rest.split_once("\n---\n"))
1118 .map_or(doc.as_str(), |(_, body)| body);
1119 body.trim().to_owned()
1120}
1121
1122const DF_SKILL: &str = include_str!("../prompts/df.md");
1125
1126const DFA_SKILL: &str = include_str!("../prompts/dfa.md");
1127
1128const DFR_SKILL: &str = include_str!("../prompts/dfr.md");
1129
1130#[prompt_router]
1131impl DifflerMcp {
1132 #[prompt(
1133 name = "review",
1134 description = "Check the diffler review: read the human's open comments, address them in code, reply, and wait for the next round."
1135 )]
1136 async fn review(&self) -> Vec<PromptMessage> {
1137 vec![PromptMessage::new_text(Role::User, skill_body(DF_SKILL))]
1138 }
1139
1140 #[prompt(
1141 name = "walkthrough",
1142 description = "Walk the human through a change: publish a walkthrough, one stop per real decision, and answer their comments."
1143 )]
1144 async fn walkthrough(&self) -> Vec<PromptMessage> {
1145 vec![PromptMessage::new_text(Role::User, skill_body(DFA_SKILL))]
1146 }
1147
1148 #[prompt(
1149 name = "critique",
1150 description = "Review a change in diffler: read the diff and leave comments on real problems, one per issue, and never submit."
1151 )]
1152 async fn critique(&self) -> Vec<PromptMessage> {
1153 vec![PromptMessage::new_text(Role::User, skill_body(DFR_SKILL))]
1154 }
1155}
1156
1157#[tool_handler(router = self.tool_router)]
1158#[prompt_handler(router = self.prompt_router)]
1159impl ServerHandler for DifflerMcp {
1160 fn get_info(&self) -> ServerConfig {
1161 ServerConfig::new(
1162 ServerCapabilities::builder()
1163 .enable_tools()
1164 .enable_prompts()
1165 .build(),
1166 )
1167 .with_instructions(
1168 "diffler code review: get_comments reads the human's diff comments, \
1169 reply_comment answers them in place, propose_resolve flags an \
1170 answered one as addressed without adding text, and \
1171 wait_for_feedback long-polls until the human sends \
1172 new feedback. open_project opens another repository you \
1173 changed as a tab in the same diffler; comments and feedback \
1174 cover every open project, each tagged with its project. focus \
1175 takes the human to a comment, a walkthrough, or a file and \
1176 line in any review. The review prompt packages that loop as a \
1177 command.",
1178 )
1179 }
1180}
1181
1182pub struct McpHandle {
1183 pub port: u16,
1185 pub handle: JoinHandle<()>,
1186}
1187
1188fn bind_reusable(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
1192 let socket = tokio::net::TcpSocket::new_v4()?;
1193 #[cfg(unix)]
1194 socket.set_reuseaddr(true)?;
1195 socket.bind(addr)?;
1196 socket.listen(1024)
1197}
1198
1199pub fn spawn_mcp(
1202 tx: UnboundedSender<AppEvent>,
1203 feedback_rx: watch::Receiver<u64>,
1204 port: u16,
1205) -> std::io::Result<McpHandle> {
1206 let listener = match bind_reusable(SocketAddr::from(([127, 0, 0, 1], port))) {
1207 Ok(listener) => listener,
1208 Err(e) if e.kind() == ErrorKind::AddrInUse => {
1209 bind_reusable(SocketAddr::from(([127, 0, 0, 1], 0)))?
1210 }
1211 Err(e) => return Err(e),
1212 };
1213 let port = listener.local_addr()?.port();
1214 let service: StreamableHttpService<DifflerMcp, LocalSessionManager> =
1215 StreamableHttpService::new(
1216 move || Ok(DifflerMcp::new(tx.clone(), feedback_rx.clone())),
1217 std::sync::Arc::default(),
1218 StreamableHttpServerConfig::default(),
1219 );
1220 let router = axum::Router::new().nest_service("/mcp", service);
1221 let handle = tokio::spawn(async move {
1222 let _ = axum::serve(listener, router).await;
1224 });
1225 Ok(McpHandle { port, handle })
1226}
1227
1228const ENDPOINT_FILE: &str = "mcp.json";
1229
1230fn endpoint_path(repo_root: &Path) -> PathBuf {
1231 repo_root.join(".diffler").join(ENDPOINT_FILE)
1232}
1233
1234#[derive(Serialize)]
1235struct EndpointFile {
1236 port: u16,
1237 url: String,
1238 pid: u32,
1239}
1240
1241pub fn write_endpoint(repo_root: &Path, port: u16) -> std::io::Result<()> {
1244 let pid = std::process::id();
1245 let body = serde_json::to_string_pretty(&EndpointFile {
1246 port,
1247 url: format!("http://127.0.0.1:{port}/mcp"),
1248 pid,
1249 })
1250 .map_err(std::io::Error::other)?;
1251 diffler_core::store::write_file(repo_root, ENDPOINT_FILE, &body)?;
1252 write_registry_entry(repo_root, port, pid);
1253 Ok(())
1254}
1255
1256pub fn clear_endpoint(repo_root: &Path, port: u16) {
1259 let path = endpoint_path(repo_root);
1260 if let Ok(body) = std::fs::read_to_string(&path) {
1261 let current_owner = serde_json::from_str::<serde_json::Value>(&body)
1262 .ok()
1263 .and_then(|v| v.get("port").and_then(serde_json::Value::as_u64));
1264 if current_owner == Some(u64::from(port)) {
1265 let _ = std::fs::remove_file(&path);
1266 }
1267 }
1268 clear_registry_entry(repo_root, port);
1269}
1270
1271#[derive(Serialize)]
1272struct RegistryEntry<'a> {
1273 repo: &'a str,
1274 port: u16,
1275 pid: u32,
1276 url: String,
1277}
1278
1279fn registry_dir() -> Option<PathBuf> {
1282 let base = if let Some(dir) = non_empty_env("DIFFLER_STATE_DIR") {
1283 PathBuf::from(dir)
1284 } else if let Some(dir) = non_empty_env("XDG_STATE_HOME") {
1285 PathBuf::from(dir)
1286 } else {
1287 PathBuf::from(non_empty_env("HOME")?)
1288 .join(".local")
1289 .join("state")
1290 };
1291 Some(base.join("diffler").join("instances"))
1292}
1293
1294fn non_empty_env(key: &str) -> Option<std::ffi::OsString> {
1295 std::env::var_os(key).filter(|v| !v.is_empty())
1296}
1297
1298fn write_registry_entry(repo_root: &Path, port: u16, pid: u32) {
1301 let Some(dir) = registry_dir() else {
1302 return;
1303 };
1304 if std::fs::create_dir_all(&dir).is_err() {
1305 return;
1306 }
1307 let repo = canonical_repo(repo_root);
1308 let Some(repo) = repo.to_str() else {
1309 return;
1310 };
1311 let Ok(body) = serde_json::to_string_pretty(&RegistryEntry {
1312 repo,
1313 port,
1314 pid,
1315 url: format!("http://127.0.0.1:{port}/mcp"),
1316 }) else {
1317 return;
1318 };
1319 let _ = std::fs::write(dir.join(registry_file(repo_root, port)), body);
1320}
1321
1322pub(crate) fn canonical_repo(repo_root: &Path) -> PathBuf {
1323 repo_root
1324 .canonicalize()
1325 .unwrap_or_else(|_| repo_root.to_path_buf())
1326}
1327
1328fn registry_file(repo_root: &Path, port: u16) -> String {
1330 let mut hasher = std::hash::DefaultHasher::new();
1331 std::hash::Hash::hash(&canonical_repo(repo_root), &mut hasher);
1332 format!("{port}-{:016x}.json", std::hash::Hasher::finish(&hasher))
1333}
1334
1335fn clear_registry_entry(repo_root: &Path, port: u16) {
1337 let Some(dir) = registry_dir() else {
1338 return;
1339 };
1340 let path = dir.join(registry_file(repo_root, port));
1341 let Ok(body) = std::fs::read_to_string(&path) else {
1342 return;
1343 };
1344 let owner_port = serde_json::from_str::<serde_json::Value>(&body)
1345 .ok()
1346 .and_then(|v| v.get("port").and_then(serde_json::Value::as_u64));
1347 if owner_port == Some(u64::from(port)) {
1348 let _ = std::fs::remove_file(&path);
1349 }
1350}
1351
1352#[cfg(test)]
1353mod tests {
1354 use diffler_core::model::{DiffLine, FileDiff, Hunk, HunkId, LineKind};
1355 use diffler_core::session::{Anchor, Session};
1356
1357 use super::*;
1358
1359 fn diff_line(kind: LineKind, old_no: Option<u32>, new_no: Option<u32>, text: &str) -> DiffLine {
1360 DiffLine::new(kind, old_no, new_no, text.to_owned())
1361 }
1362
1363 fn sample_model() -> DiffModel {
1364 DiffModel {
1365 files: vec![
1366 FileDiff {
1367 path: "src/auth.py".into(),
1368 old_path: None,
1369 status: FileStatus::Modified,
1370 binary: false,
1371 old_text: None,
1372 new_text: Some("one\nTWO\nthree\n".into()),
1373 hunks: vec![Hunk {
1374 id: HunkId("h1".into()),
1375 old_start: 1,
1376 old_lines: 3,
1377 new_start: 1,
1378 new_lines: 3,
1379 context: String::new(),
1380 lines: vec![
1381 diff_line(LineKind::Context, Some(1), Some(1), "one"),
1382 diff_line(LineKind::Deleted, Some(2), None, "two"),
1383 diff_line(LineKind::Added, None, Some(2), "TWO"),
1384 diff_line(LineKind::Context, Some(3), Some(3), "three"),
1385 ],
1386 }],
1387 hashes: diffler_core::model::HashCache::default(),
1388 blobs: diffler_core::model::BlobIds::default(),
1389 },
1390 FileDiff {
1391 path: "logo.png".into(),
1392 old_path: None,
1393 status: FileStatus::Added,
1394 binary: true,
1395 old_text: None,
1396 new_text: None,
1397 hunks: vec![],
1398 hashes: diffler_core::model::HashCache::default(),
1399 blobs: diffler_core::model::BlobIds::default(),
1400 },
1401 ],
1402 }
1403 }
1404
1405 fn anchor(file: &str, line: Option<u32>) -> Anchor {
1406 Anchor {
1407 file: file.to_owned(),
1408 line,
1409 line_end: None,
1410 on_old_side: false,
1411 line_text: None,
1412 }
1413 }
1414
1415 #[test]
1416 fn render_unified_emits_headers_hunks_and_origins() {
1417 let text = render_unified(&sample_model(), None).unwrap();
1418 assert!(text.contains("--- a/src/auth.py\n+++ b/src/auth.py\n"));
1419 assert!(text.contains("@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n"));
1420 assert!(text.contains("--- /dev/null\n+++ b/logo.png\nBinary files differ\n"));
1421 }
1422
1423 #[test]
1424 fn render_unified_filters_to_one_file() {
1425 let text = render_unified(&sample_model(), Some("src/auth.py")).unwrap();
1426 assert!(text.contains("src/auth.py"));
1427 assert!(!text.contains("logo.png"));
1428 }
1429
1430 #[test]
1431 fn render_unified_unknown_file_is_an_error() {
1432 let err = render_unified(&sample_model(), Some("nope.rs")).unwrap_err();
1433 assert!(err.contains("nope.rs"));
1434 }
1435
1436 #[test]
1437 fn render_unified_marks_deleted_files_with_dev_null() {
1438 let mut model = sample_model();
1439 model.files[0].status = FileStatus::Deleted;
1440 let text = render_unified(&model, Some("src/auth.py")).unwrap();
1441 assert!(text.contains("--- a/src/auth.py\n+++ /dev/null\n"));
1442 }
1443
1444 #[test]
1445 fn render_unified_renamed_file_pairs_old_and_new_paths() {
1446 let mut model = sample_model();
1447 model.files[0].status = FileStatus::Renamed;
1448 model.files[0].old_path = Some("src/auth_v1.py".to_owned());
1449 let text = render_unified(&model, Some("src/auth.py")).unwrap();
1450 assert!(
1451 text.contains("--- a/src/auth_v1.py\n+++ b/src/auth.py\n"),
1452 "rename header must pair both paths: {text}"
1453 );
1454 }
1455
1456 #[test]
1457 fn render_unified_empty_model_is_an_empty_diff() {
1458 let model = DiffModel { files: vec![] };
1459 assert_eq!(render_unified(&model, None).unwrap(), "");
1460 }
1461
1462 #[test]
1463 fn comment_info_old_side_anchor_reports_side_and_context() {
1464 let mut session = Session::default();
1465 let mut a = anchor("src/auth.py", Some(2));
1466 a.on_old_side = true;
1467 a.line_text = Some("two".to_owned());
1468 session.add_comment(a, "human", "what was wrong with two?");
1469 let info = comment_info(
1470 &session.comments[0],
1471 &sample_model(),
1472 &ReviewSource::WorkingTree,
1473 );
1474 assert_eq!(info.side, "old");
1475 assert_eq!(info.line, Some(2));
1476 assert_eq!(info.line_end, None);
1477 assert!(!info.outdated, "the deleted line is still in the diff");
1478 assert_eq!(info.context.as_deref(), Some(" one\n-two\n+TWO"));
1479 }
1480
1481 #[test]
1482 fn comment_info_carries_context_and_thread() {
1483 let mut session = Session::default();
1484 let mut a = anchor("src/auth.py", Some(2));
1485 a.line_text = Some("TWO".to_owned());
1486 let id = session.add_comment(a, "human", "why uppercase?").id.clone();
1487 session.reply(&id, AGENT_AUTHOR, "legacy API");
1488 let info = comment_info(
1489 &session.comments[0],
1490 &sample_model(),
1491 &ReviewSource::WorkingTree,
1492 );
1493 assert_eq!(info.file, "src/auth.py");
1494 assert_eq!(info.line, Some(2));
1495 assert_eq!(info.side, "new");
1496 assert_eq!(info.status, "replied");
1497 assert_eq!(info.context.as_deref(), Some("-two\n+TWO\n three"));
1498 assert!(!info.outdated);
1499 assert_eq!(info.replies.len(), 1);
1500 assert_eq!(info.replies[0].author, AGENT_AUTHOR);
1501 }
1502
1503 #[test]
1504 fn comment_info_flags_departed_lines_outdated() {
1505 let mut session = Session::default();
1506 session.add_comment(anchor("src/auth.py", Some(99)), "human", "moved on");
1507 let info = comment_info(
1508 &session.comments[0],
1509 &sample_model(),
1510 &ReviewSource::WorkingTree,
1511 );
1512 assert!(info.outdated);
1513 assert_eq!(info.context, None);
1514 }
1515
1516 #[test]
1517 fn comment_info_flags_drifted_line_text_outdated() {
1518 let mut session = Session::default();
1519 let mut a = anchor("src/auth.py", Some(2));
1520 a.line_text = Some("old text".to_owned());
1521 session.add_comment(a, "human", "stale");
1522 let info = comment_info(
1523 &session.comments[0],
1524 &sample_model(),
1525 &ReviewSource::WorkingTree,
1526 );
1527 assert!(info.outdated);
1528 assert!(info.context.is_some(), "context still renders");
1529 }
1530
1531 #[test]
1532 fn file_level_comment_outdated_only_when_file_left_the_diff() {
1533 let mut session = Session::default();
1534 session.add_comment(anchor("src/auth.py", None), "human", "overall");
1535 session.add_comment(anchor("gone.py", None), "human", "gone");
1536 let model = sample_model();
1537 assert!(!comment_info(&session.comments[0], &model, &ReviewSource::WorkingTree).outdated);
1538 assert!(comment_info(&session.comments[1], &model, &ReviewSource::WorkingTree).outdated);
1539 }
1540
1541 #[test]
1542 fn range_comment_not_outdated_when_end_line_matches() {
1543 let mut session = Session::default();
1544 let mut a = Anchor {
1546 file: "src/auth.py".to_owned(),
1547 line: Some(1),
1548 line_end: Some(3),
1549 on_old_side: false,
1550 line_text: Some("three".to_owned()),
1551 };
1552 assert_ne!(a.line_text.as_deref(), Some("one"));
1553 session.add_comment(a.clone(), "human", "range comment");
1554 let info = comment_info(
1555 &session.comments[0],
1556 &sample_model(),
1557 &ReviewSource::WorkingTree,
1558 );
1559 assert!(
1560 !info.outdated,
1561 "end line text matches snapshot: must NOT be outdated"
1562 );
1563 a.line_text = Some("changed".to_owned());
1564 session.comments[0].anchor = a;
1565 let info2 = comment_info(
1566 &session.comments[0],
1567 &sample_model(),
1568 &ReviewSource::WorkingTree,
1569 );
1570 assert!(info2.outdated, "end line text drifted: must be outdated");
1571 }
1572
1573 #[tokio::test]
1574 async fn request_times_out_when_app_does_not_answer() {
1575 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1576 let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
1577 let handler = DifflerMcp::new(tx, feedback_rx);
1578 let err = handler
1579 .request_with_timeout(
1580 McpRequestKind::ReviewStatus,
1581 None,
1582 Duration::from_millis(50),
1583 )
1584 .await
1585 .unwrap_err();
1586 assert!(
1587 err.message.contains("busy"),
1588 "error should mention busy: {err:?}"
1589 );
1590 }
1591
1592 #[tokio::test(start_paused = true)]
1594 async fn wait_for_feedback_clamps_the_timeout_to_the_cap() {
1595 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1596 let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
1597 let handler = DifflerMcp::new(tx, feedback_rx);
1598 let started = tokio::time::Instant::now();
1599 let Json(response) = handler
1600 .wait_for_feedback(Parameters(WaitForFeedbackParams {
1601 since_epoch: None,
1602 timeout_seconds: Some(MAX_WAIT_SECONDS * 10),
1603 }))
1604 .await
1605 .unwrap();
1606 assert!(response.timed_out, "no feedback ever arrives");
1607 assert_eq!(
1608 started.elapsed(),
1609 Duration::from_secs(MAX_WAIT_SECONDS),
1610 "a timeout above the cap must clamp to {MAX_WAIT_SECONDS}s"
1611 );
1612 }
1613
1614 #[tokio::test(start_paused = true)]
1615 async fn wait_for_feedback_defaults_inside_the_client_request_timeout() {
1616 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1617 let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
1618 let handler = DifflerMcp::new(tx, feedback_rx);
1619 let started = tokio::time::Instant::now();
1620 let Json(response) = handler
1621 .wait_for_feedback(Parameters(WaitForFeedbackParams {
1622 since_epoch: None,
1623 timeout_seconds: None,
1624 }))
1625 .await
1626 .unwrap();
1627 assert!(response.timed_out, "no feedback ever arrives");
1628 assert!(
1629 started.elapsed() + REQUEST_TIMEOUT < Duration::from_secs(120),
1630 "the wait plus the reply it still owes must beat the client's 120 s cut"
1631 );
1632 }
1633
1634 #[test]
1635 fn a_capped_wait_and_its_round_trip_fit_the_client_ceiling() {
1636 assert!(MAX_WAIT_SECONDS + REQUEST_TIMEOUT.as_secs() < 120);
1637 }
1638
1639 #[tokio::test]
1640 async fn wait_for_feedback_answers_with_the_reviews_comments() {
1641 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1642 let (feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
1643 feedback_tx.send_modify(|epoch| *epoch += 1);
1644 let handler = DifflerMcp::new(tx, feedback_rx);
1645 let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1646 let counted = requests.clone();
1647 tokio::spawn(async move {
1648 while let Some(event) = rx.recv().await {
1649 let AppEvent::Mcp(request) = event else {
1650 continue;
1651 };
1652 counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1653 let response = match request.kind {
1654 McpRequestKind::Feedback => McpResponse::Feedback {
1655 comments: Vec::new(),
1656 },
1657 _ => McpResponse::Ok,
1658 };
1659 let _ = request.reply.send(response);
1660 }
1661 });
1662 let Json(response) = handler
1663 .wait_for_feedback(Parameters(WaitForFeedbackParams {
1664 since_epoch: Some(0),
1665 timeout_seconds: None,
1666 }))
1667 .await
1668 .unwrap();
1669 assert!(!response.timed_out);
1670 assert_eq!(
1671 requests.load(std::sync::atomic::Ordering::SeqCst),
1672 1,
1673 "the success path makes exactly one app request after the wait"
1674 );
1675 }
1676
1677 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1679
1680 #[allow(unsafe_code)]
1681 fn with_state_dir<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
1682 let _guard = ENV_LOCK.lock().unwrap();
1683 unsafe { std::env::set_var("DIFFLER_STATE_DIR", dir) };
1685 let result = f();
1686 unsafe { std::env::remove_var("DIFFLER_STATE_DIR") };
1687 result
1688 }
1689
1690 #[test]
1691 fn endpoint_file_publishes_the_port_and_clears() {
1692 let dir = tempfile::tempdir().expect("tempdir");
1693 let state = tempfile::tempdir().expect("tempdir");
1694 with_state_dir(state.path(), || {
1695 write_endpoint(dir.path(), 8417).expect("write");
1696 let path = dir.path().join(".diffler/mcp.json");
1697 let body = std::fs::read_to_string(&path).expect("read");
1698 assert!(body.contains("\"port\": 8417"), "{body}");
1699 assert!(body.contains("http://127.0.0.1:8417/mcp"), "{body}");
1700 assert!(body.contains("\"pid\""), "{body}");
1701 let gitignore =
1703 std::fs::read_to_string(dir.path().join(".diffler/.gitignore")).expect("gitignore");
1704 assert_eq!(gitignore, "*\n");
1705 clear_endpoint(dir.path(), 8417);
1706 assert!(!path.exists(), "endpoint file removed on shutdown");
1707 });
1708 }
1709
1710 #[test]
1711 fn clear_endpoint_leaves_a_newer_owner_alone() {
1712 let dir = tempfile::tempdir().expect("tempdir");
1713 let state = tempfile::tempdir().expect("tempdir");
1714 with_state_dir(state.path(), || {
1715 write_endpoint(dir.path(), 1111).expect("write first instance");
1716 write_endpoint(dir.path(), 2222).expect("write second instance");
1718
1719 clear_endpoint(dir.path(), 1111);
1721
1722 let path = dir.path().join(".diffler/mcp.json");
1723 let body = std::fs::read_to_string(&path)
1724 .expect("file survives: it names the still-running instance");
1725 assert!(body.contains("\"port\": 2222"), "{body}");
1726 });
1727 }
1728
1729 #[test]
1730 fn write_endpoint_registers_the_instance_for_cross_repo_discovery() {
1731 let dir = tempfile::tempdir().expect("tempdir");
1732 let state = tempfile::tempdir().expect("tempdir");
1733 with_state_dir(state.path(), || {
1734 write_endpoint(dir.path(), 8417).expect("write");
1735 let entry_path = state
1736 .path()
1737 .join("diffler/instances")
1738 .join(registry_file(dir.path(), 8417));
1739 let body = std::fs::read_to_string(&entry_path).expect("registry entry written");
1740 let entry: serde_json::Value =
1742 serde_json::from_str(&body).expect("registry entry is json");
1743 let repo = dir.path().canonicalize().expect("canonicalize");
1744 assert_eq!(entry["repo"].as_str(), repo.to_str(), "{body}");
1745 assert_eq!(entry["port"].as_u64(), Some(8417), "{body}");
1746 assert_eq!(
1747 entry["pid"].as_u64(),
1748 Some(u64::from(std::process::id())),
1749 "{body}"
1750 );
1751 assert_eq!(
1752 entry["url"].as_str(),
1753 Some("http://127.0.0.1:8417/mcp"),
1754 "{body}"
1755 );
1756 });
1757 }
1758
1759 #[test]
1760 fn clear_endpoint_removes_only_its_own_registry_entry() {
1761 let dir = tempfile::tempdir().expect("tempdir");
1762 let state = tempfile::tempdir().expect("tempdir");
1763 with_state_dir(state.path(), || {
1764 write_endpoint(dir.path(), 1111).expect("write first instance");
1765 write_endpoint(dir.path(), 2222).expect("write second instance");
1766 let instances = state.path().join("diffler/instances");
1767
1768 clear_endpoint(dir.path(), 1111);
1769
1770 assert!(
1771 !instances.join(registry_file(dir.path(), 1111)).exists(),
1772 "the cleared instance's own registry entry is gone"
1773 );
1774 assert!(
1775 instances.join(registry_file(dir.path(), 2222)).exists(),
1776 "a different instance's registry entry survives"
1777 );
1778 });
1779 }
1780
1781 #[test]
1782 fn one_diffler_registers_every_project_it_serves() {
1783 let (first, second) = (
1784 tempfile::tempdir().expect("tempdir"),
1785 tempfile::tempdir().expect("tempdir"),
1786 );
1787 let state = tempfile::tempdir().expect("tempdir");
1788 with_state_dir(state.path(), || {
1789 write_endpoint(first.path(), 3333).expect("first project");
1790 write_endpoint(second.path(), 3333).expect("second project");
1791 let instances = state.path().join("diffler/instances");
1792 assert_eq!(std::fs::read_dir(&instances).expect("registry").count(), 2);
1793 clear_endpoint(second.path(), 3333);
1794 assert!(instances.join(registry_file(first.path(), 3333)).exists());
1795 assert!(!instances.join(registry_file(second.path(), 3333)).exists());
1796 });
1797 }
1798
1799 #[test]
1800 fn tool_schemas_use_no_unregistered_format() {
1801 for tool in DifflerMcp::tool_router().list_all() {
1802 for (part, schema) in [
1803 ("input", Some(&*tool.input_schema)),
1804 ("output", tool.output_schema.as_deref()),
1805 ] {
1806 let Some(schema) = schema else { continue };
1807 let text = serde_json::to_string(schema).expect("schema serializes");
1808 assert!(
1809 !text.contains("\"format\""),
1810 "{} {part} schema carries a format keyword: {text}",
1811 tool.name
1812 );
1813 }
1814 }
1815 }
1816
1817 #[tokio::test]
1818 async fn spawn_mcp_fallback_on_addr_in_use() {
1819 let holder = tokio::net::TcpListener::bind(("127.0.0.1", 0))
1820 .await
1821 .unwrap();
1822 let busy_port = holder.local_addr().unwrap().port();
1823 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1824 let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
1825 let handle = spawn_mcp(tx, feedback_rx, busy_port).unwrap();
1826 assert_ne!(
1827 handle.port, busy_port,
1828 "should have bound to a different ephemeral port"
1829 );
1830 handle.handle.abort();
1831 }
1832}
1833
1834#[cfg(test)]
1835mod agent_command_sync {
1836 fn body(doc: &str) -> String {
1837 let doc = doc.replace("\r\n", "\n");
1838 let after_frontmatter = doc
1839 .strip_prefix("---\n")
1840 .and_then(|rest| rest.split_once("\n---\n"))
1841 .map_or(doc.as_str(), |(_, body)| body);
1842 after_frontmatter.trim().to_owned()
1843 }
1844
1845 #[test]
1846 fn a_skill_loses_its_frontmatter_whatever_its_line_endings() {
1847 assert_eq!(
1848 super::skill_body("---\r\nname: df\r\n---\r\n\r\nthe body\r\n"),
1849 "the body"
1850 );
1851 assert_eq!(
1852 super::skill_body("---\nname: df\n---\n\nthe body\n"),
1853 "the body"
1854 );
1855 }
1856
1857 #[test]
1860 fn both_agent_command_files_teach_the_same_loop() {
1861 const DF_SKILL: &str = include_str!(concat!(
1862 env!("CARGO_MANIFEST_DIR"),
1863 "/../../skills/df/SKILL.md"
1864 ));
1865 const DF_OPENCODE: &str = include_str!(concat!(
1866 env!("CARGO_MANIFEST_DIR"),
1867 "/../../.opencode/commands/df.md"
1868 ));
1869 const DFA_SKILL: &str = include_str!(concat!(
1870 env!("CARGO_MANIFEST_DIR"),
1871 "/../../skills/dfa/SKILL.md"
1872 ));
1873 const DFA_OPENCODE: &str = include_str!(concat!(
1874 env!("CARGO_MANIFEST_DIR"),
1875 "/../../.opencode/commands/dfa.md"
1876 ));
1877 const DFR_SKILL: &str = include_str!(concat!(
1878 env!("CARGO_MANIFEST_DIR"),
1879 "/../../skills/dfr/SKILL.md"
1880 ));
1881 const DFR_OPENCODE: &str = include_str!(concat!(
1882 env!("CARGO_MANIFEST_DIR"),
1883 "/../../.opencode/commands/dfr.md"
1884 ));
1885 for (name, skill, opencode, prompt) in [
1886 ("df", DF_SKILL, DF_OPENCODE, super::DF_SKILL),
1887 ("dfa", DFA_SKILL, DFA_OPENCODE, super::DFA_SKILL),
1888 ("dfr", DFR_SKILL, DFR_OPENCODE, super::DFR_SKILL),
1889 ] {
1890 assert_eq!(
1891 body(skill),
1892 body(opencode),
1893 "skills/{name}/SKILL.md and .opencode/commands/{name}.md have drifted; \
1894 edit both or neither"
1895 );
1896 assert_eq!(
1897 body(skill),
1898 body(prompt),
1899 "skills/{name}/SKILL.md and crates/diffler/prompts/{name}.md have drifted; \
1900 the crate ships its own copy because a published crate carries only \
1901 its own directory"
1902 );
1903 }
1904 }
1905
1906 fn write_for_the_card(doc: &str) -> &str {
1907 const HEADING: &str = "## Write for the card";
1908 let Some(start) = doc.find(HEADING) else {
1909 return "";
1910 };
1911 let after = &doc[start..];
1912 let end = after[HEADING.len()..]
1913 .find("\n## ")
1914 .map_or(after.len(), |offset| offset + HEADING.len());
1915 after[..end].trim()
1916 }
1917
1918 #[test]
1920 fn every_skill_writes_the_card_the_same_way() {
1921 const DF_SKILL: &str = include_str!(concat!(
1922 env!("CARGO_MANIFEST_DIR"),
1923 "/../../skills/df/SKILL.md"
1924 ));
1925 const DFA_SKILL: &str = include_str!(concat!(
1926 env!("CARGO_MANIFEST_DIR"),
1927 "/../../skills/dfa/SKILL.md"
1928 ));
1929 const DFR_SKILL: &str = include_str!(concat!(
1930 env!("CARGO_MANIFEST_DIR"),
1931 "/../../skills/dfr/SKILL.md"
1932 ));
1933 let df = write_for_the_card(DF_SKILL);
1934 let dfa = write_for_the_card(DFA_SKILL);
1935 let dfr = write_for_the_card(DFR_SKILL);
1936 assert!(
1937 !df.is_empty(),
1938 "df must have a '## Write for the card' section"
1939 );
1940 assert_eq!(
1941 df, dfa,
1942 "the '## Write for the card' section must be identical in df and dfa"
1943 );
1944 assert_eq!(
1945 df, dfr,
1946 "the '## Write for the card' section must be identical in df and dfr"
1947 );
1948 }
1949
1950 #[tokio::test]
1952 async fn the_prompts_are_their_skills_bodies() {
1953 const DF_SKILL: &str = include_str!(concat!(
1954 env!("CARGO_MANIFEST_DIR"),
1955 "/../../skills/df/SKILL.md"
1956 ));
1957 const DFA_SKILL: &str = include_str!(concat!(
1958 env!("CARGO_MANIFEST_DIR"),
1959 "/../../skills/dfa/SKILL.md"
1960 ));
1961 const DFR_SKILL: &str = include_str!(concat!(
1962 env!("CARGO_MANIFEST_DIR"),
1963 "/../../skills/dfr/SKILL.md"
1964 ));
1965 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1966 let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
1967 let handler = super::DifflerMcp::new(tx, feedback_rx);
1968 for (messages, skill) in [
1969 (handler.review().await, DF_SKILL),
1970 (handler.walkthrough().await, DFA_SKILL),
1971 (handler.critique().await, DFR_SKILL),
1972 ] {
1973 let text = &messages[0]
1974 .content
1975 .as_text()
1976 .expect("prompt carries text")
1977 .text;
1978 assert_eq!(*text, body(skill));
1979 }
1980 }
1981}