Skip to main content

forge_workspace/mcp/
review.rs

1//! Review-conversation tools (Phase 2).
2//!
3//! Worker-facing MCP tools that turn a submitted review set into a
4//! two-way conversation. The reviewer submits comments via `/diff`; the
5//! worker reads them, replies, and resolves through
6//! `mcp__forge__review__*`, and each reply appends to the comment thread
7//! and flips it `Open` -> `Addressed`.
8//!
9//! This module holds the JSON view shapes the tools return plus the pure
10//! mappers from the durable [`forge_primitives::review`] types. The store
11//! I/O lives in [`crate::store::review`]; the `Workspace::review_*`
12//! wrappers surface it; the `Tool` impls + caller resolution live beside
13//! this (the `facade` submodule and the tool structs).
14
15use std::sync::Arc;
16
17use serde::{Deserialize, Serialize};
18
19#[cfg(test)]
20use forge_sdk::mcp::server::McpServer;
21use forge_sdk::mcp::server::McpServerBuilder;
22use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput, ToolOutputBlock};
23
24use forge_primitives::review::{
25    ReviewAuthor, ReviewSet, ReviewSide, ReviewStatus, ReviewThread, is_doc_scope, is_tree_scope,
26    tree_scope_ref,
27};
28
29use crate::SessionSlot;
30use crate::mcp::review::facade::{ReviewFacade, ReviewScope};
31
32pub mod facade;
33
34/// One row of `review__list`: a submitted review plus its member-comment
35/// tally by state.
36#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
37pub struct ReviewSummary {
38    pub review_id: String,
39    pub number: u32,
40    pub summary: Option<String>,
41    pub created_at: String,
42    pub comment_count: usize,
43    pub open: usize,
44    pub addressed: usize,
45    pub resolved: usize,
46    pub outdated: usize,
47}
48
49/// `review__get`: a review's overview plus the comments filed under it.
50#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
51pub struct ReviewDetail {
52    pub review_id: String,
53    pub number: u32,
54    pub summary: Option<String>,
55    pub comments: Vec<ReviewCommentView>,
56}
57
58/// One comment in `review__get`, carrying the anchored code so the worker
59/// can locate the spot regardless of the comment's diff scope (a
60/// commit-scoped comment's line may not match the current working tree).
61#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
62pub struct ReviewCommentView {
63    pub comment_id: String,
64    pub file: String,
65    pub line: u32,
66    pub side: &'static str,
67    pub status: &'static str,
68    /// The captured lines around the anchored spot
69    /// ([`forge_primitives::review::ReviewAnchor::context`]), so a shifted
70    /// line number still locates the code the comment refers to.
71    pub context: Vec<String>,
72    pub thread: Vec<ReviewTurnView>,
73}
74
75/// One turn in a comment thread: `author` is `"you"` for the reviewer or
76/// the agent's label for a worker reply. `review` is the number of the
77/// review that sealed the turn, so a thread spanning several rounds shows
78/// which turns belong to which; `None` on agent replies and on a turn no
79/// review has sealed yet.
80#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
81pub struct ReviewTurnView {
82    pub author: String,
83    pub text: String,
84    pub at: String,
85    pub review: Option<u32>,
86}
87
88/// What `review__reply` and `review__resolve` answer with: where the
89/// comment is anchored and the review round its latest turn belongs to,
90/// alongside the state it now holds. `number` is the review's 1-based
91/// ordinal, null while the thread is filed in no round.
92#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
93pub struct CommentAnchorView {
94    pub comment_id: String,
95    pub file: String,
96    pub line: u32,
97    pub side: &'static str,
98    pub status: &'static str,
99    pub number: Option<u32>,
100}
101
102/// Build the anchor echo for `thread`, whose state just became `status`.
103pub(crate) fn comment_anchor(
104    thread: &ReviewThread,
105    reviews: &[ReviewSet],
106    status: ReviewStatus,
107) -> CommentAnchorView {
108    CommentAnchorView {
109        comment_id: thread.id.clone(),
110        file: thread.anchor.path.clone(),
111        line: thread.anchor.line,
112        side: side_str(thread.anchor.side),
113        status: status_str(status),
114        number: thread
115            .latest_review()
116            .and_then(|id| reviews.iter().find(|r| r.id == id).map(|r| r.number)),
117    }
118}
119
120/// One worker review action recorded during a turn, accumulated per
121/// caller and drained at the turn's end into a single notice per review.
122#[derive(Debug, PartialEq, Eq)]
123pub(crate) struct ReviewActivity {
124    pub project: String,
125    pub branch: String,
126    pub review_id: String,
127    /// `true` for a reply, `false` for a resolve.
128    pub replied: bool,
129}
130
131/// The reviewer-facing one-line summary a worker's review turn produces:
132/// `worker addressed review #N - A replied, B resolved, C open. Open /diff.`
133/// `open` is the review's current open-comment count after the turn, and
134/// `scope` names the review itself, because **each scope's comments open in
135/// a door of their own**: the document viewer, the files page, or the diff
136/// overlay (the lead's gate, 2026-10-17: a tree scope pointed at `/diff`,
137/// which never carries one - the same hole the doc scope's door had closed).
138pub(crate) fn notice_message(
139    number: u32,
140    replied: usize,
141    resolved: usize,
142    open: usize,
143    scope: &str,
144) -> String {
145    let door = if forge_primitives::review::is_doc_scope(scope) {
146        "Open the document."
147    } else if forge_primitives::review::is_tree_scope(scope) {
148        "Open the files page."
149    } else {
150        "Open /diff."
151    };
152    format!(
153        "worker addressed review #{number} - {replied} replied, {resolved} resolved, \
154         {open} open. {door}"
155    )
156}
157
158fn side_str(side: ReviewSide) -> &'static str {
159    match side {
160        ReviewSide::Old => "old",
161        ReviewSide::New => "new",
162    }
163}
164
165fn status_str(status: ReviewStatus) -> &'static str {
166    match status {
167        ReviewStatus::Open => "open",
168        ReviewStatus::Addressed => "addressed",
169        ReviewStatus::Resolved => "resolved",
170        ReviewStatus::Outdated => "outdated",
171    }
172}
173
174fn author_str(author: &ReviewAuthor) -> String {
175    match author {
176        ReviewAuthor::User => "you".to_owned(),
177        ReviewAuthor::Agent { label } => label.clone(),
178    }
179}
180
181/// Build the `review__list` rows for `reviews` given every thread on the
182/// branch, newest review first. A review's members are the threads with a
183/// turn filed into it, so a thread the reviewer replied on across rounds
184/// counts under each of them.
185pub(crate) fn summarize(reviews: &[ReviewSet], threads: &[ReviewThread]) -> Vec<ReviewSummary> {
186    reviews
187        .iter()
188        .rev()
189        .map(|review| {
190            let (mut open, mut addressed, mut resolved, mut outdated) = (0, 0, 0, 0);
191            let mut count = 0;
192            for t in threads.iter().filter(|t| t.is_in_review(&review.id)) {
193                count += 1;
194                match t.status {
195                    ReviewStatus::Open => open += 1,
196                    ReviewStatus::Addressed => addressed += 1,
197                    ReviewStatus::Resolved => resolved += 1,
198                    ReviewStatus::Outdated => outdated += 1,
199                }
200            }
201            ReviewSummary {
202                review_id: review.id.clone(),
203                number: review.number,
204                summary: review.summary.clone(),
205                created_at: review.created_at.clone(),
206                comment_count: count,
207                open,
208                addressed,
209                resolved,
210                outdated,
211            }
212        })
213        .collect()
214}
215
216/// Build the `review__get` detail for `review_id`, or `None` when no such
217/// review exists on the branch.
218pub(crate) fn detail(
219    reviews: &[ReviewSet],
220    threads: &[ReviewThread],
221    review_id: &str,
222) -> Option<ReviewDetail> {
223    let review = reviews.iter().find(|r| r.id == review_id)?;
224    let comments = threads
225        .iter()
226        .filter(|t| t.is_in_review(review_id))
227        .map(|t| comment_view(t, reviews))
228        .collect();
229    Some(ReviewDetail {
230        review_id: review.id.clone(),
231        number: review.number,
232        summary: review.summary.clone(),
233        comments,
234    })
235}
236
237/// Map one thread to its `review__get` view. The whole conversation is
238/// carried, not just the requested review's turns, so the worker reads a
239/// later round with the earlier exchange as context; each turn's `review`
240/// says which round it came from.
241fn comment_view(thread: &ReviewThread, reviews: &[ReviewSet]) -> ReviewCommentView {
242    ReviewCommentView {
243        comment_id: thread.id.clone(),
244        file: thread.anchor.path.clone(),
245        line: thread.anchor.line,
246        side: side_str(thread.anchor.side),
247        status: status_str(thread.status),
248        context: thread.anchor.context.clone(),
249        thread: thread
250            .comments
251            .iter()
252            .map(|c| ReviewTurnView {
253                author: author_str(&c.author),
254                text: c.text.clone(),
255                at: c.at.clone(),
256                review: c
257                    .review_id
258                    .as_deref()
259                    .and_then(|id| reviews.iter().find(|r| r.id == id).map(|r| r.number)),
260            })
261            .collect(),
262    }
263}
264
265/// Build a standalone `forge` MCP server carrying only the four
266/// review-conversation tools. Test-only; production shares one `forge`
267/// server via [`crate::mcp::build_forge_server`].
268#[cfg(test)]
269pub fn build_server(facade: Arc<dyn ReviewFacade>, slot: SessionSlot) -> McpServer {
270    add_tools(McpServerBuilder::new("forge", env!("CARGO_PKG_VERSION")), facade, slot).build()
271}
272
273/// Attach the four review-conversation tools to an existing builder. The
274/// parent module's `build_forge_server` calls this so they share the
275/// `forge` server name with the agents tools.
276pub(crate) fn add_tools(
277    builder: McpServerBuilder,
278    facade: Arc<dyn ReviewFacade>,
279    slot: SessionSlot,
280) -> McpServerBuilder {
281    builder
282        .tool(ReviewList { facade: facade.clone(), slot: slot.clone() })
283        .tool(ReviewGet { facade: facade.clone(), slot: slot.clone() })
284        .tool(ReviewReply { facade: facade.clone(), slot: slot.clone() })
285        .tool(ReviewResolve { facade, slot })
286}
287
288fn tool_error(text: String) -> ToolOutput {
289    ToolOutput::error(text)
290}
291
292fn json_or_error<T: Serialize>(value: &T) -> ToolOutput {
293    match serde_json::to_string_pretty(value) {
294        Ok(json) => ToolOutput::text(json),
295        Err(err) => tool_error(format!("response serialization failed: {err}")),
296    }
297}
298
299/// RFC3339 timestamp for the current instant, matching the peers helper.
300fn rfc3339_now() -> String {
301    use time::OffsetDateTime;
302    use time::format_description::well_known::Rfc3339;
303    OffsetDateTime::now_utc().format(&Rfc3339).unwrap_or_else(|err| {
304        tracing::warn!(error = %err, "rfc3339 format failed; emitting epoch sentinel");
305        "1970-01-01T00:00:00Z".to_owned()
306    })
307}
308
309/// Resolve the caller's review scope or a ready-to-return tool error
310/// naming the step that failed (see [`ScopeError`](facade::ScopeError)).
311async fn scope_or_error(
312    facade: &Arc<dyn ReviewFacade>,
313    slot: &SessionSlot,
314    scope: Option<&str>,
315) -> Result<ReviewScope, ToolOutput> {
316    facade.resolve_scope(slot, scope).await.map_err(|err| tool_error(err.message()))
317}
318
319/// The caller's seat-tree scope, derived from its resolved branch scope:
320/// project, author label and caller carry over; the key is the seat's own
321/// tree (a worker's worktree, the lead's checkout), which is where the
322/// files page's reviews live. The tools consult it beside the branch on
323/// their default (no `scope` argument) path.
324fn seat_tree_scope(scope: &ReviewScope) -> ReviewScope {
325    ReviewScope {
326        project: scope.project.clone(),
327        branch: tree_scope_ref(&scope.caller),
328        author_label: scope.author_label.clone(),
329        caller: scope.caller.clone(),
330    }
331}
332
333/// Branch rows and seat-tree rows as one list, newest first. Review ids are
334/// uuids, so none collide across the two scopes; `number` is per-scope and
335/// CAN repeat, which is why the tree's rows come with the scope note
336/// beside them.
337fn merge_newest(
338    branch_rows: Vec<ReviewSummary>,
339    tree_rows: Vec<ReviewSummary>,
340) -> Vec<ReviewSummary> {
341    let mut rows = branch_rows;
342    rows.extend(tree_rows);
343    rows.sort_by(|a, b| b.created_at.cmp(&a.created_at));
344    rows
345}
346
347/// `review__list` - the submitted reviews on the caller's (project,
348/// branch), or on an explicit `scope`.
349pub(crate) struct ReviewList {
350    pub(crate) facade: Arc<dyn ReviewFacade>,
351    pub(crate) slot: SessionSlot,
352}
353
354#[derive(Deserialize)]
355struct ListArgs {
356    scope: Option<String>,
357}
358
359#[async_trait::async_trait]
360impl Tool for ReviewList {
361    fn name(&self) -> &'static str {
362        "review__list"
363    }
364
365    fn description(&self) -> &'static str {
366        "List the review sets the reviewer submitted on your current branch, \
367         newest first - plus any filed against your seat's own file tree \
368         (the files page's comments). Each entry has a review_id, its 1-based \
369         number, the optional overview summary, when it was created, and a \
370         per-state tally of its comments (open / addressed / resolved / \
371         outdated). Start here when you get a 'review #N ready' nudge, then \
372         call review__get with a review_id to read the individual comments. \
373         Pass `scope` to address a review filed elsewhere: one left on a \
374         document hand-off is filed under the document's own scope, \
375         doc:<resolved path>, which the hand-off answer names."
376    }
377
378    fn input_schema(&self) -> serde_json::Value {
379        serde_json::json!({
380            "type": "object",
381            "properties": {
382                "scope": {
383                    "type": "string",
384                    "description": "Address a scope other than your branch; a document's \
385                                    review is filed under doc:<resolved path>, which the \
386                                    hand-off that opened it names.",
387                },
388            },
389            "additionalProperties": false,
390        })
391    }
392
393    async fn call(&self, input: ToolInput) -> ToolOutput {
394        let args: ListArgs = match serde_json::from_value(input.value) {
395            Ok(a) => a,
396            Err(err) => return tool_error(format!("invalid arguments: {err}")),
397        };
398        let scope = match scope_or_error(&self.facade, &self.slot, args.scope.as_deref()).await {
399            Ok(s) => s,
400            Err(out) => return out,
401        };
402        match self.facade.list(&scope) {
403            Ok(rows) => {
404                // The seat's tree rides beside the branch (files-page
405                // reviews follow the seat), unless an explicit scope asked
406                // for one scope alone. A tree read that failed never folds
407                // into an empty answer: the branch rows still return, with
408                // the failure stated beside them.
409                let mut note = None;
410                let rows = match args.scope.is_none().then(|| seat_tree_scope(&scope)) {
411                    Some(tree) => match self.facade.list(&tree) {
412                        Ok(tree_rows) => {
413                            if !tree_rows.is_empty() {
414                                note = Some(format!(
415                                    "your seat tree's reviews are included (scope {}).",
416                                    tree.branch,
417                                ));
418                            }
419                            merge_newest(rows, tree_rows)
420                        }
421                        Err(err) => {
422                            note =
423                                Some(format!("your seat tree's reviews could not be read: {err}"));
424                            rows
425                        }
426                    },
427                    None => rows,
428                };
429                let mut out = list_output(&self.facade, &scope, &rows);
430                if !out.is_error
431                    && let Some(note) = note
432                {
433                    out.blocks.push(ToolOutputBlock::Text { text: note });
434                }
435                out
436            }
437            Err(err) => tool_error(err),
438        }
439    }
440}
441
442/// The caller's rows, plus the project's other review-bearing branches
443/// when there are any. A branch-scoped list is indistinguishable from the
444/// project's whole set, and a review on the caller's own branch is no
445/// reason to leave the one beside it unseen.
446fn list_output(
447    facade: &Arc<dyn ReviewFacade>,
448    scope: &ReviewScope,
449    rows: &[ReviewSummary],
450) -> ToolOutput {
451    let others: Vec<String> = facade
452        .branches_with_reviews(scope)
453        .into_iter()
454        // Scope keys stored in the branch column are not branches. A tree
455        // scope (the seat's own) or a document's would read as something
456        // to check out; the tree's reviews already ride the list above,
457        // and a document's are addressed through its hand-off.
458        .filter(|branch| *branch != scope.branch && !is_tree_scope(branch) && !is_doc_scope(branch))
459        .collect();
460    if others.is_empty() {
461        return json_or_error(&rows);
462    }
463    if rows.is_empty() {
464        return tool_error(format!(
465            "no reviews on {}; this project has reviews on: {}.",
466            scope.branch,
467            others.join(", "),
468        ));
469    }
470    let mut out = json_or_error(&rows);
471    if out.is_error {
472        return out;
473    }
474    out.blocks.push(ToolOutputBlock::Text {
475        text: format!("this project also has reviews on: {}.", others.join(", ")),
476    });
477    out
478}
479
480/// `review__get` - the comments of one review, with anchored code.
481pub(crate) struct ReviewGet {
482    pub(crate) facade: Arc<dyn ReviewFacade>,
483    pub(crate) slot: SessionSlot,
484}
485
486#[derive(Deserialize)]
487struct GetArgs {
488    review_id: String,
489    scope: Option<String>,
490}
491
492#[async_trait::async_trait]
493impl Tool for ReviewGet {
494    fn name(&self) -> &'static str {
495        "review__get"
496    }
497
498    fn description(&self) -> &'static str {
499        "Read one review's overview and its comments. Returns the review's \
500         summary plus a comment array; each comment has a comment_id (use \
501         it with review__reply / review__resolve), its file, line, and \
502         side, its current status, the captured `context` lines of code \
503         around the anchored spot, and the thread of turns so far (author \
504         'you' is the reviewer, otherwise a worker reply). Prefer the \
505         `context` code snippet over the line number to locate the spot - \
506         a comment may be scoped to a commit whose line no longer matches \
507         your working tree. Pass the review_id from review__list, plus \
508         `scope` for a review filed under a document's scope rather than \
509         your branch."
510    }
511
512    fn input_schema(&self) -> serde_json::Value {
513        serde_json::json!({
514            "type": "object",
515            "properties": {
516                "review_id": {
517                    "type": "string",
518                    "description": "The review_id from review__list.",
519                },
520                "scope": {
521                    "type": "string",
522                    "description": "A scope other than your branch; a document's review is \
523                                    filed under doc:<resolved path>, which the hand-off \
524                                    that opened it names.",
525                },
526            },
527            "required": ["review_id"],
528            "additionalProperties": false,
529        })
530    }
531
532    async fn call(&self, input: ToolInput) -> ToolOutput {
533        let args: GetArgs = match serde_json::from_value(input.value) {
534            Ok(a) => a,
535            Err(err) => return tool_error(format!("invalid arguments: {err}")),
536        };
537        let scope = match scope_or_error(&self.facade, &self.slot, args.scope.as_deref()).await {
538            Ok(s) => s,
539            Err(out) => return out,
540        };
541        match self.facade.get(&scope, &args.review_id) {
542            Ok(Some(detail)) => json_or_error(&detail),
543            Ok(None) => {
544                // An id the branch does not hold may live on the seat's
545                // tree (the files page); the fallback runs only on the
546                // default path, so an explicit scope still means that
547                // scope alone.
548                match args.scope.is_none().then(|| seat_tree_scope(&scope)) {
549                    Some(tree) => match self.facade.get(&tree, &args.review_id) {
550                        Ok(Some(detail)) => json_or_error(&detail),
551                        Ok(None) => tool_error(format!(
552                            "no review {} on your branch or your seat tree - call \
553                             review__list for the valid ids.",
554                            args.review_id,
555                        )),
556                        Err(err) => tool_error(err),
557                    },
558                    None => tool_error(format!(
559                        "no review {} on your branch - call review__list for the valid ids.",
560                        args.review_id,
561                    )),
562                }
563            }
564            Err(err) => tool_error(err),
565        }
566    }
567}
568
569/// `review__reply` - append a reply to a comment thread; flips it
570/// Open -> Addressed.
571pub(crate) struct ReviewReply {
572    pub(crate) facade: Arc<dyn ReviewFacade>,
573    pub(crate) slot: SessionSlot,
574}
575
576#[derive(Deserialize)]
577struct ReplyArgs {
578    comment_id: String,
579    text: String,
580    scope: Option<String>,
581}
582
583#[async_trait::async_trait]
584impl Tool for ReviewReply {
585    fn name(&self) -> &'static str {
586        "review__reply"
587    }
588
589    fn description(&self) -> &'static str {
590        "Reply to one review comment. Appends your message to that comment's \
591         thread and flips it from open to addressed so the reviewer sees you \
592         acted on it (a comment already resolved stays resolved). Use this to \
593         say what you changed, ask a clarifying question, or push back. Pass \
594         the comment_id from review__get. Returns the comment's new status \
595         alongside the file, line and side it is anchored to. \
596         Reply per comment, then use review__resolve for the ones you \
597         consider done, or leave them addressed for the reviewer to resolve. \
598         For a review filed under a document's scope rather than your \
599         branch, pass `scope`."
600    }
601
602    fn input_schema(&self) -> serde_json::Value {
603        serde_json::json!({
604            "type": "object",
605            "properties": {
606                "comment_id": {
607                    "type": "string",
608                    "description": "The comment_id from review__get.",
609                },
610                "text": {
611                    "type": "string",
612                    "description": "Your reply, addressed to the reviewer.",
613                },
614                "scope": {
615                    "type": "string",
616                    "description": "A scope other than your branch; a document's review is \
617                                    filed under doc:<resolved path>, which the hand-off \
618                                    that opened it names.",
619                },
620            },
621            "required": ["comment_id", "text"],
622            "additionalProperties": false,
623        })
624    }
625
626    async fn call(&self, input: ToolInput) -> ToolOutput {
627        let args: ReplyArgs = match serde_json::from_value(input.value) {
628            Ok(a) => a,
629            Err(err) => return tool_error(format!("invalid arguments: {err}")),
630        };
631        let scope = match scope_or_error(&self.facade, &self.slot, args.scope.as_deref()).await {
632            Ok(s) => s,
633            Err(out) => return out,
634        };
635        match self.facade.reply(&scope, &args.comment_id, &args.text, &rfc3339_now()) {
636            Ok(anchor) => json_or_error(&anchor),
637            Err(err) => {
638                // A comment the branch's scope does not hold may live on
639                // the seat's tree (the files page); the branch's own error
640                // is what returns when the tree does not hold it either.
641                if let Some(tree) = args.scope.is_none().then(|| seat_tree_scope(&scope))
642                    && let Ok(anchor) =
643                        self.facade.reply(&tree, &args.comment_id, &args.text, &rfc3339_now())
644                {
645                    return json_or_error(&anchor);
646                }
647                tool_error(err)
648            }
649        }
650    }
651}
652
653/// `review__resolve` - mark a comment resolved.
654pub(crate) struct ReviewResolve {
655    pub(crate) facade: Arc<dyn ReviewFacade>,
656    pub(crate) slot: SessionSlot,
657}
658
659#[derive(Deserialize)]
660struct ResolveArgs {
661    comment_id: String,
662    scope: Option<String>,
663}
664
665#[async_trait::async_trait]
666impl Tool for ReviewResolve {
667    fn name(&self) -> &'static str {
668        "review__resolve"
669    }
670
671    fn description(&self) -> &'static str {
672        "Mark one review comment resolved - you consider it done. Prefer \
673         replying first (review__reply) to say what you changed, then \
674         resolve; or leave a comment addressed and let the reviewer resolve \
675         it themselves. Pass the comment_id from review__get. Returns the \
676         comment's new status alongside the file, line and side it is \
677         anchored to. For a review filed under a document's scope rather \
678         than your branch, pass `scope`."
679    }
680
681    fn input_schema(&self) -> serde_json::Value {
682        serde_json::json!({
683            "type": "object",
684            "properties": {
685                "comment_id": {
686                    "type": "string",
687                    "description": "The comment_id from review__get.",
688                },
689                "scope": {
690                    "type": "string",
691                    "description": "A scope other than your branch; a document's review is \
692                                    filed under doc:<resolved path>, which the hand-off \
693                                    that opened it names.",
694                },
695            },
696            "required": ["comment_id"],
697            "additionalProperties": false,
698        })
699    }
700
701    async fn call(&self, input: ToolInput) -> ToolOutput {
702        let args: ResolveArgs = match serde_json::from_value(input.value) {
703            Ok(a) => a,
704            Err(err) => return tool_error(format!("invalid arguments: {err}")),
705        };
706        let scope = match scope_or_error(&self.facade, &self.slot, args.scope.as_deref()).await {
707            Ok(s) => s,
708            Err(out) => return out,
709        };
710        match self.facade.resolve(&scope, &args.comment_id) {
711            Ok(anchor) => json_or_error(&anchor),
712            Err(err) => {
713                // The seat-tree fallback, as in review__reply: the
714                // branch's own error returns when the tree does not hold
715                // the comment either.
716                if let Some(tree) = args.scope.is_none().then(|| seat_tree_scope(&scope))
717                    && let Ok(anchor) = self.facade.resolve(&tree, &args.comment_id)
718                {
719                    return json_or_error(&anchor);
720                }
721                tool_error(err)
722            }
723        }
724    }
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730    use crate::mcp::test_support::{block_text, text_of};
731    use forge_primitives::review::{ReviewAnchor, ReviewComment};
732
733    fn review(id: &str, number: u32, summary: Option<&str>) -> ReviewSet {
734        ReviewSet {
735            id: id.to_owned(),
736            number,
737            summary: summary.map(str::to_owned),
738            created_at: "2026-07-23T10:00:00Z".to_owned(),
739        }
740    }
741
742    fn thread(id: &str, review_id: Option<&str>, status: ReviewStatus) -> ReviewThread {
743        ReviewThread {
744            id: id.to_owned(),
745            anchor: ReviewAnchor {
746                path: "src/x.rs".to_owned(),
747                side: ReviewSide::New,
748                line: 42,
749                content_hash: 7,
750                context: vec!["fn f() {".to_owned(), "    todo!()".to_owned()],
751                base_ref: "main".to_owned(),
752            },
753            comments: vec![ReviewComment {
754                author: ReviewAuthor::User,
755                text: format!("look at {id}"),
756                at: "2026-07-23T10:00:00Z".to_owned(),
757                review_id: review_id.map(str::to_owned),
758            }],
759            status,
760            created_at: "2026-07-23T10:00:00Z".to_owned(),
761            updated_at: "2026-07-23T10:00:00Z".to_owned(),
762            commit: None,
763        }
764    }
765
766    #[test]
767    fn summarize_tallies_members_newest_first() {
768        let reviews = vec![review("r1", 1, Some("first")), review("r2", 2, None)];
769        let threads = vec![
770            thread("a", Some("r1"), ReviewStatus::Open),
771            thread("b", Some("r1"), ReviewStatus::Addressed),
772            thread("c", Some("r1"), ReviewStatus::Resolved),
773            thread("d", Some("r2"), ReviewStatus::Outdated),
774            thread("e", None, ReviewStatus::Open),
775        ];
776        let rows = summarize(&reviews, &threads);
777        assert_eq!(rows.len(), 2);
778        assert_eq!(rows[0].review_id, "r2", "newest review leads");
779        assert_eq!(rows[0].comment_count, 1);
780        assert_eq!(rows[0].outdated, 1);
781        assert_eq!(rows[1].review_id, "r1");
782        assert_eq!(rows[1].comment_count, 3, "the unfiled thread does not tally into r1");
783        assert_eq!(rows[1].open, 1);
784        assert_eq!(rows[1].addressed, 1);
785        assert_eq!(rows[1].resolved, 1);
786        assert_eq!(rows[1].summary.as_deref(), Some("first"));
787    }
788
789    #[test]
790    fn detail_maps_comments_with_anchor_context() {
791        let reviews = vec![review("r1", 1, Some("overview"))];
792        let mut t = thread("a", Some("r1"), ReviewStatus::Addressed);
793        t.anchor.side = ReviewSide::Old;
794        t.comments.push(ReviewComment {
795            author: ReviewAuthor::Agent { label: "implementer".to_owned() },
796            text: "done".to_owned(),
797            at: "2026-07-23T11:00:00Z".to_owned(),
798            review_id: None,
799        });
800        let got = detail(&reviews, &[t], "r1").expect("review found");
801        assert_eq!(got.number, 1);
802        assert_eq!(got.summary.as_deref(), Some("overview"));
803        assert_eq!(got.comments.len(), 1);
804        let c = &got.comments[0];
805        assert_eq!(c.comment_id, "a");
806        assert_eq!(c.file, "src/x.rs");
807        assert_eq!(c.line, 42);
808        assert_eq!(c.side, "old");
809        assert_eq!(c.status, "addressed");
810        assert_eq!(c.context, vec!["fn f() {".to_owned(), "    todo!()".to_owned()]);
811        assert_eq!(c.thread.len(), 2, "user comment + agent reply");
812        assert_eq!(c.thread[0].author, "you");
813        assert_eq!(c.thread[1].author, "implementer");
814        assert_eq!(c.thread[1].text, "done");
815    }
816
817    /// A thread the reviewer replied on after the agent answered belongs to
818    /// both rounds, and each round's view says which turn is new.
819    #[test]
820    fn detail_shows_a_multi_round_thread_under_every_review() {
821        let reviews = vec![review("r1", 1, None), review("r2", 2, Some("round two"))];
822        let mut t = thread("a", Some("r1"), ReviewStatus::Open);
823        t.comments.push(ReviewComment {
824            author: ReviewAuthor::Agent { label: "implementer".to_owned() },
825            text: "done".to_owned(),
826            at: "2026-07-23T11:00:00Z".to_owned(),
827            review_id: None,
828        });
829        t.comments.push(ReviewComment {
830            author: ReviewAuthor::User,
831            text: "still not right".to_owned(),
832            at: "2026-07-23T12:00:00Z".to_owned(),
833            review_id: Some("r2".to_owned()),
834        });
835        let threads = [t];
836
837        for (id, number) in [("r1", 1), ("r2", 2)] {
838            let got = detail(&reviews, &threads, id).expect("review found");
839            assert_eq!(got.number, number);
840            assert_eq!(got.comments.len(), 1, "the thread is a member of {id}");
841            assert_eq!(
842                got.comments[0].thread.iter().map(|t| t.review).collect::<Vec<_>>(),
843                vec![Some(1), None, Some(2)],
844                "the whole conversation is carried, tagged by round",
845            );
846        }
847
848        let rows = summarize(&reviews, &threads);
849        assert_eq!(rows[0].comment_count, 1, "r2 counts it");
850        assert_eq!(rows[1].comment_count, 1, "and so does r1");
851    }
852
853    #[test]
854    fn detail_is_none_for_unknown_review() {
855        let reviews = vec![review("r1", 1, None)];
856        assert!(detail(&reviews, &[], "nope").is_none());
857    }
858
859    #[test]
860    fn notice_message_reads_as_a_tally_line() {
861        assert_eq!(
862            notice_message(3, 2, 1, 4, "feat"),
863            "worker addressed review #3 - 2 replied, 1 resolved, 4 open. Open /diff.",
864        );
865    }
866
867    /// A document's review names the viewer as its door; `/diff` carries no
868    /// doc scope, so pointing there would send the reader nowhere.
869    #[test]
870    fn notice_message_for_a_document_names_the_viewer() {
871        let message = notice_message(1, 1, 0, 1, "doc:/proj/plan.md");
872        assert!(message.contains("Open the document."), "{message}");
873        assert!(!message.contains("/diff"), "{message}");
874    }
875
876    /// **A seat's tree has its own door too** (the lead's gate, 2026-10-17):
877    /// the files page carries the tree scope, and `/diff` never does.
878    #[test]
879    fn notice_message_for_a_seat_tree_names_the_files_page() {
880        let message = notice_message(1, 1, 0, 1, "tree:Busytools/forge/lead");
881        assert!(message.contains("Open the files page."), "{message}");
882        assert!(!message.contains("/diff"), "{message}");
883    }
884
885    use crate::mcp::review::facade::{MockReviewFacade, ProdReviewFacade, ScopeError};
886
887    fn caller_slot() -> SessionSlot {
888        SessionSlot::from_str_for_test("caller")
889    }
890
891    fn summary(review_id: &str, number: u32) -> ReviewSummary {
892        ReviewSummary {
893            review_id: review_id.to_owned(),
894            number,
895            summary: Some("overview".to_owned()),
896            created_at: "2026-07-23T10:00:00Z".to_owned(),
897            comment_count: 2,
898            open: 1,
899            addressed: 1,
900            resolved: 0,
901            outdated: 0,
902        }
903    }
904
905    #[tokio::test]
906    async fn review_list_returns_summaries_as_json() {
907        let mock = Arc::new(MockReviewFacade::new());
908        mock.summaries.lock().push(summary("r1", 1));
909        let facade: Arc<dyn ReviewFacade> = mock;
910        let tool = ReviewList { facade, slot: caller_slot() };
911        let out = tool.call(ToolInput { value: serde_json::json!({}) }).await;
912        assert!(!out.is_error, "list happy path: {:?}", out.blocks);
913        let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
914        assert_eq!(parsed[0]["review_id"], "r1");
915        assert_eq!(parsed[0]["addressed"], 1);
916    }
917
918    #[tokio::test]
919    async fn review_list_unresolved_scope_surfaces_the_failing_step() {
920        let mock = Arc::new(MockReviewFacade::new());
921        *mock.scope.lock() = Err(ScopeError::SessionCwdUnknown);
922        let facade: Arc<dyn ReviewFacade> = mock;
923        let tool = ReviewList { facade, slot: caller_slot() };
924        let out = tool.call(ToolInput { value: serde_json::json!({}) }).await;
925        assert!(out.is_error, "an unresolved scope must surface as an error");
926        assert_eq!(text_of(&out), ScopeError::SessionCwdUnknown.message());
927        assert!(
928            !text_of(&out).contains("detached"),
929            "a non-HEAD failure must not claim a detached HEAD: {}",
930            text_of(&out),
931        );
932    }
933
934    #[tokio::test]
935    async fn review_list_empty_names_the_projects_other_branches() {
936        let mock = Arc::new(MockReviewFacade::new());
937        *mock.review_branches.lock() =
938            vec!["feat".to_owned(), "main".to_owned(), "worktree-impl".to_owned()];
939        let facade: Arc<dyn ReviewFacade> = mock;
940        let tool = ReviewList { facade, slot: caller_slot() };
941        let out = tool.call(ToolInput { value: serde_json::json!({}) }).await;
942        assert!(out.is_error, "a review filed against another branch must not read as 'none'");
943        let text = text_of(&out);
944        assert!(text.contains("no reviews on feat"), "{text}");
945        assert!(text.contains("main") && text.contains("worktree-impl"), "{text}");
946        assert!(!text.contains(": feat"), "the caller's own branch is not listed back: {text}");
947    }
948
949    #[tokio::test]
950    async fn review_list_names_other_branches_alongside_a_non_empty_list() {
951        // The cross-branch split this hint exists to catch is exactly
952        // the case where the caller's own branch has a review too - a
953        // lead reading its own review while the worker's sits beside it.
954        let mock = Arc::new(MockReviewFacade::new());
955        mock.summaries.lock().push(summary("r1", 1));
956        *mock.review_branches.lock() = vec!["feat".to_owned(), "worktree-impl".to_owned()];
957        let facade: Arc<dyn ReviewFacade> = mock;
958        let tool = ReviewList { facade, slot: caller_slot() };
959        let out = tool.call(ToolInput { value: serde_json::json!({}) }).await;
960        assert!(!out.is_error, "a populated list is not an error: {:?}", out.blocks);
961        let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
962        assert_eq!(parsed[0]["review_id"], "r1", "the rows stay the first block, still json");
963        let hint = out.blocks.get(1).map_or("", block_text);
964        assert!(hint.contains("worktree-impl"), "{hint}");
965        assert!(!hint.contains("feat"), "the caller's own branch is not listed back: {hint}");
966    }
967
968    /// The seat's tree rides the list beside the branch: files-page
969    /// reviews follow the seat, so one call returns both - the branch
970    /// scope first, then the tree - with the tree's scope named so its
971    /// rows can be told apart (`number` repeats across scopes; ids do
972    /// not).
973    #[tokio::test]
974    async fn review_list_includes_the_seat_trees_reviews_beside_the_branchs() {
975        let mock = Arc::new(MockReviewFacade::new());
976        mock.summaries.lock().push(summary("branch-r1", 1));
977        mock.tree_summaries.lock().push(summary("tree-r1", 1));
978        let facade: Arc<dyn ReviewFacade> = mock.clone();
979        let tool = ReviewList { facade, slot: caller_slot() };
980        let out = tool.call(ToolInput { value: serde_json::json!({}) }).await;
981        assert!(!out.is_error, "both scopes' rows are a healthy list: {:?}", out.blocks);
982        let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
983        let ids: Vec<&str> = parsed
984            .as_array()
985            .expect("rows")
986            .iter()
987            .filter_map(|row| row["review_id"].as_str())
988            .collect();
989        assert!(
990            ids.contains(&"branch-r1") && ids.contains(&"tree-r1"),
991            "both scopes' rows are present: {ids:?}",
992        );
993        assert_eq!(
994            mock.scopes_seen.lock().clone(),
995            vec!["feat".to_owned(), "tree:TestOrg/test-project/caller".to_owned()],
996            "the branch scope first, then the seat's tree",
997        );
998        let note = out.blocks.get(1).map_or("", block_text);
999        assert!(note.contains("tree:TestOrg/test-project/caller"), "{note}");
1000    }
1001
1002    /// An explicit scope means that scope alone: a document's hand-off
1003    /// asks for the document, never the branch-and-tree pair.
1004    #[tokio::test]
1005    async fn review_list_with_an_explicit_scope_does_not_consult_the_seat_tree() {
1006        let mock = Arc::new(MockReviewFacade::new());
1007        let facade: Arc<dyn ReviewFacade> = mock.clone();
1008        let tool = ReviewList { facade, slot: caller_slot() };
1009        let out = tool
1010            .call(ToolInput { value: serde_json::json!({ "scope": "doc:/proj/plan.md" }) })
1011            .await;
1012        assert!(!out.is_error, "an empty document scope is a healthy empty list");
1013        assert_eq!(
1014            mock.scopes_seen.lock().clone(),
1015            vec!["doc:/proj/plan.md".to_owned()],
1016            "the tree is not consulted beside an explicit scope",
1017        );
1018    }
1019
1020    /// A tree read that failed never folds into an empty answer: the
1021    /// branch's rows still return, with the failure stated beside them.
1022    #[tokio::test]
1023    async fn review_list_states_a_failed_tree_read_beside_the_branch_rows() {
1024        let mock = Arc::new(MockReviewFacade::new());
1025        mock.summaries.lock().push(summary("branch-r1", 1));
1026        *mock.tree_list_error.lock() = Some("the store row would not decode".to_owned());
1027        let facade: Arc<dyn ReviewFacade> = mock;
1028        let tool = ReviewList { facade, slot: caller_slot() };
1029        let out = tool.call(ToolInput { value: serde_json::json!({}) }).await;
1030        assert!(!out.is_error, "the branch's rows still return: {:?}", out.blocks);
1031        let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
1032        assert_eq!(parsed[0]["review_id"], "branch-r1");
1033        let note = out.blocks.get(1).map_or("", block_text);
1034        assert!(note.contains("could not be read"), "{note}");
1035        assert!(note.contains("would not decode"), "{note}");
1036    }
1037
1038    /// A review the branch does not hold is found on the seat's tree.
1039    #[tokio::test]
1040    async fn review_get_falls_back_to_the_seat_tree() {
1041        let mock = Arc::new(MockReviewFacade::new());
1042        *mock.tree_detail.lock() = detail(&[review("tree-r1", 1, None)], &[], "tree-r1");
1043        let facade: Arc<dyn ReviewFacade> = mock.clone();
1044        let tool = ReviewGet { facade, slot: caller_slot() };
1045        let out =
1046            tool.call(ToolInput { value: serde_json::json!({ "review_id": "tree-r1" }) }).await;
1047        assert!(!out.is_error, "the tree's review is found: {:?}", out.blocks);
1048        assert!(text_of(&out).contains("tree-r1"), "{}", text_of(&out));
1049        assert_eq!(
1050            mock.scopes_seen.lock().clone(),
1051            vec!["feat".to_owned(), "tree:TestOrg/test-project/caller".to_owned()],
1052            "the branch is tried first, then the tree",
1053        );
1054    }
1055
1056    /// Not on the branch AND not on the tree names both, so the absence
1057    /// is stated rather than guessed at.
1058    #[tokio::test]
1059    async fn review_get_not_found_names_branch_and_tree() {
1060        let mock = Arc::new(MockReviewFacade::new());
1061        let facade: Arc<dyn ReviewFacade> = mock;
1062        let tool = ReviewGet { facade, slot: caller_slot() };
1063        let out = tool.call(ToolInput { value: serde_json::json!({ "review_id": "nope" }) }).await;
1064        assert!(out.is_error);
1065        assert!(text_of(&out).contains("or your seat tree"), "{}", text_of(&out));
1066    }
1067
1068    /// A reply to a comment the branch's scope does not hold lands on the
1069    /// seat's tree instead - the files page's comments are answered the
1070    /// same way the branch's are.
1071    #[tokio::test]
1072    async fn review_reply_falls_back_to_the_seat_tree() {
1073        let mock = Arc::new(MockReviewFacade::new());
1074        *mock.fail_non_tree.lock() = true;
1075        let facade: Arc<dyn ReviewFacade> = mock.clone();
1076        let tool = ReviewReply { facade, slot: caller_slot() };
1077        let out = tool
1078            .call(ToolInput { value: serde_json::json!({ "comment_id": "c1", "text": "done" }) })
1079            .await;
1080        assert!(!out.is_error, "the tree holds the comment: {:?}", out.blocks);
1081        assert_eq!(mock.reply_calls.lock().clone(), vec![("c1".to_owned(), "done".to_owned())],);
1082        assert_eq!(
1083            mock.scopes_seen.lock().clone(),
1084            vec!["feat".to_owned(), "tree:TestOrg/test-project/caller".to_owned()],
1085        );
1086    }
1087
1088    /// review__resolve gets the same fallback, and a comment held by
1089    /// neither scope still answers with the branch's own error.
1090    #[tokio::test]
1091    async fn review_resolve_falls_back_and_keeps_the_branch_error_otherwise() {
1092        let mock = Arc::new(MockReviewFacade::new());
1093        *mock.fail_non_tree.lock() = true;
1094        let facade: Arc<dyn ReviewFacade> = mock.clone();
1095        let tool = ReviewResolve { facade, slot: caller_slot() };
1096        let out = tool.call(ToolInput { value: serde_json::json!({ "comment_id": "c1" }) }).await;
1097        assert!(!out.is_error, "the tree holds the comment: {:?}", out.blocks);
1098        assert_eq!(mock.resolve_calls.lock().clone(), vec!["c1".to_owned()]);
1099
1100        // A second mock where NEITHER scope holds it: the error returns.
1101        let mock = Arc::new(MockReviewFacade::new());
1102        mock.force_error.lock().replace("comment c9 is not in this scope".to_owned());
1103        let facade: Arc<dyn ReviewFacade> = mock;
1104        let tool = ReviewResolve { facade, slot: caller_slot() };
1105        let out = tool.call(ToolInput { value: serde_json::json!({ "comment_id": "c9" }) }).await;
1106        assert!(out.is_error);
1107        assert!(text_of(&out).contains("not in this scope"), "{}", text_of(&out));
1108    }
1109
1110    /// The other-branches hint never lists scope keys as branches: a tree
1111    /// or document scope in it would read as something to check out.
1112    #[tokio::test]
1113    async fn review_list_hint_never_lists_scope_keys_as_branches() {
1114        let mock = Arc::new(MockReviewFacade::new());
1115        *mock.review_branches.lock() = vec![
1116            "feat".to_owned(),
1117            "tree:TestOrg/test-project/caller".to_owned(),
1118            "doc:/proj/plan.md".to_owned(),
1119            "worktree-impl".to_owned(),
1120        ];
1121        let facade: Arc<dyn ReviewFacade> = mock;
1122        let tool = ReviewList { facade, slot: caller_slot() };
1123        let out = tool.call(ToolInput { value: serde_json::json!({}) }).await;
1124        let text = text_of(&out);
1125        assert!(text.contains("worktree-impl"), "{text}");
1126        assert!(!text.contains("tree:"), "a scope key is not a branch: {text}");
1127        assert!(!text.contains("doc:"), "a scope key is not a branch: {text}");
1128    }
1129
1130    #[tokio::test]
1131    async fn review_list_non_empty_says_nothing_when_no_other_branch_has_reviews() {
1132        let mock = Arc::new(MockReviewFacade::new());
1133        mock.summaries.lock().push(summary("r1", 1));
1134        *mock.review_branches.lock() = vec!["feat".to_owned()];
1135        let facade: Arc<dyn ReviewFacade> = mock;
1136        let tool = ReviewList { facade, slot: caller_slot() };
1137        let out = tool.call(ToolInput { value: serde_json::json!({}) }).await;
1138        assert_eq!(out.blocks.len(), 1, "a normal list stays one json block: {:?}", out.blocks);
1139    }
1140
1141    #[tokio::test]
1142    async fn review_list_empty_stays_an_empty_list_with_nothing_elsewhere() {
1143        let mock = Arc::new(MockReviewFacade::new());
1144        let facade: Arc<dyn ReviewFacade> = mock;
1145        let tool = ReviewList { facade, slot: caller_slot() };
1146        let out = tool.call(ToolInput { value: serde_json::json!({}) }).await;
1147        assert!(!out.is_error, "a project with no reviews anywhere is not an error");
1148        let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
1149        assert_eq!(parsed, serde_json::json!([]));
1150    }
1151
1152    #[tokio::test]
1153    async fn review_get_returns_detail_then_not_found() {
1154        let mock = Arc::new(MockReviewFacade::new());
1155        *mock.detail.lock() = Some(ReviewDetail {
1156            review_id: "r1".to_owned(),
1157            number: 1,
1158            summary: Some("overview".to_owned()),
1159            comments: Vec::new(),
1160        });
1161        let facade: Arc<dyn ReviewFacade> = mock;
1162        let tool = ReviewGet { facade, slot: caller_slot() };
1163        let hit = tool.call(ToolInput { value: serde_json::json!({ "review_id": "r1" }) }).await;
1164        assert!(!hit.is_error);
1165        let parsed: serde_json::Value = serde_json::from_str(text_of(&hit)).expect("json");
1166        assert_eq!(parsed["review_id"], "r1");
1167        let miss = tool.call(ToolInput { value: serde_json::json!({ "review_id": "r9" }) }).await;
1168        assert!(miss.is_error, "an unknown review_id is an error");
1169    }
1170
1171    #[tokio::test]
1172    async fn review_reply_captures_and_returns_status() {
1173        let mock = Arc::new(MockReviewFacade::new());
1174        let facade: Arc<dyn ReviewFacade> = mock.clone();
1175        let tool = ReviewReply { facade, slot: caller_slot() };
1176        let out = tool
1177            .call(ToolInput {
1178                value: serde_json::json!({ "comment_id": "c1", "text": "fixed it" }),
1179            })
1180            .await;
1181        assert!(!out.is_error, "reply happy path: {:?}", out.blocks);
1182        let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
1183        assert_eq!(parsed["status"], "addressed");
1184        assert_eq!(parsed["comment_id"], "c1");
1185        let calls = mock.reply_calls.lock();
1186        assert_eq!(calls.len(), 1);
1187        assert_eq!(calls[0], ("c1".to_owned(), "fixed it".to_owned()));
1188    }
1189
1190    /// The reply result carries the anchor, so the caller can say which
1191    /// spot the reply landed on; `{comment_id, status}` could only print
1192    /// an opaque id where the code's own location belongs.
1193    #[tokio::test]
1194    async fn review_reply_echoes_the_anchor() {
1195        let mock = Arc::new(MockReviewFacade::new());
1196        *mock.reply_anchor.lock() = CommentAnchorView {
1197            comment_id: "c-71".to_owned(),
1198            file: "src/chat/units.ts".to_owned(),
1199            line: 919,
1200            side: "new",
1201            status: "addressed",
1202            number: Some(3),
1203        };
1204        let facade: Arc<dyn ReviewFacade> = mock.clone();
1205        let tool = ReviewReply { facade, slot: caller_slot() };
1206
1207        let out = tool
1208            .call(ToolInput { value: serde_json::json!({ "comment_id": "c-71", "text": "fixed" }) })
1209            .await;
1210        assert!(!out.is_error, "reply happy path: {:?}", out.blocks);
1211        assert_eq!(out.blocks.len(), 1, "the anchor stays one text block: {out:?}");
1212        let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
1213        assert_eq!(parsed["comment_id"], "c-71");
1214        assert_eq!(parsed["file"], "src/chat/units.ts");
1215        assert_eq!(parsed["line"], 919);
1216        assert_eq!(parsed["side"], "new");
1217        assert_eq!(parsed["number"], 3);
1218        assert_eq!(parsed["status"], "addressed");
1219    }
1220
1221    /// A comment filed in no round yet carries a null `number`: the key is
1222    /// always there, so a reader never has to tell an absent key from an
1223    /// unfiled comment.
1224    #[tokio::test]
1225    async fn a_comment_in_no_round_reports_a_null_number() {
1226        let mock = Arc::new(MockReviewFacade::new());
1227        *mock.reply_anchor.lock() = CommentAnchorView {
1228            comment_id: "c-9".to_owned(),
1229            file: "src/x.rs".to_owned(),
1230            line: 1,
1231            side: "new",
1232            status: "addressed",
1233            number: None,
1234        };
1235        let facade: Arc<dyn ReviewFacade> = mock.clone();
1236        let tool = ReviewReply { facade, slot: caller_slot() };
1237
1238        let out = tool
1239            .call(ToolInput { value: serde_json::json!({ "comment_id": "c-9", "text": "x" }) })
1240            .await;
1241        assert!(!out.is_error, "reply happy path: {:?}", out.blocks);
1242        let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
1243        assert_eq!(parsed["number"], serde_json::Value::Null, "the key is present and null");
1244        assert!(parsed.get("number").is_some(), "not dropped from the shape: {parsed}");
1245    }
1246
1247    /// A resolve echoes the same anchor: the row says which comment, on
1248    /// which spot, is now resolved.
1249    #[tokio::test]
1250    async fn review_resolve_echoes_the_anchor() {
1251        let mock = Arc::new(MockReviewFacade::new());
1252        *mock.resolve_anchor.lock() = CommentAnchorView {
1253            comment_id: "c-68".to_owned(),
1254            file: "src/chat/Inbound.svelte".to_owned(),
1255            line: 48,
1256            side: "new",
1257            status: "resolved",
1258            number: Some(3),
1259        };
1260        let facade: Arc<dyn ReviewFacade> = mock.clone();
1261        let tool = ReviewResolve { facade, slot: caller_slot() };
1262
1263        let out = tool.call(ToolInput { value: serde_json::json!({ "comment_id": "c-68" }) }).await;
1264        assert!(!out.is_error, "resolve happy path: {:?}", out.blocks);
1265        assert_eq!(out.blocks.len(), 1, "the anchor stays one text block: {out:?}");
1266        let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
1267        assert_eq!(parsed["comment_id"], "c-68");
1268        assert_eq!(parsed["file"], "src/chat/Inbound.svelte");
1269        assert_eq!(parsed["line"], 48);
1270        assert_eq!(parsed["side"], "new");
1271        assert_eq!(parsed["number"], 3);
1272        assert_eq!(parsed["status"], "resolved");
1273    }
1274
1275    #[tokio::test]
1276    async fn review_reply_scope_rejection_surfaces_error() {
1277        let mock = Arc::new(MockReviewFacade::new());
1278        *mock.force_error.lock() = Some("no review comment c1 on (forge, feat)".to_owned());
1279        let facade: Arc<dyn ReviewFacade> = mock.clone();
1280        let tool = ReviewReply { facade, slot: caller_slot() };
1281        let out = tool
1282            .call(ToolInput { value: serde_json::json!({ "comment_id": "c1", "text": "x" }) })
1283            .await;
1284        assert!(out.is_error, "a cross-scope / unknown comment id must error");
1285        assert!(text_of(&out).contains("no review comment"));
1286        assert_eq!(mock.reply_calls.lock().len(), 0, "a rejected reply captures no call");
1287    }
1288
1289    #[tokio::test]
1290    async fn review_resolve_captures_comment_id() {
1291        let mock = Arc::new(MockReviewFacade::new());
1292        let facade: Arc<dyn ReviewFacade> = mock.clone();
1293        let tool = ReviewResolve { facade, slot: caller_slot() };
1294        let out = tool.call(ToolInput { value: serde_json::json!({ "comment_id": "c2" }) }).await;
1295        assert!(!out.is_error, "resolve happy path: {:?}", out.blocks);
1296        let parsed: serde_json::Value = serde_json::from_str(text_of(&out)).expect("json");
1297        assert_eq!(parsed["status"], "resolved");
1298        assert_eq!(*mock.resolve_calls.lock(), vec!["c2".to_owned()]);
1299    }
1300
1301    /// The read-back for a document's review: `scope` on any of the four
1302    /// tools addresses that scope instead of the caller's branch, so a
1303    /// review filed under `doc:<path>` is listable, readable, answerable
1304    /// and resolvable from the session that wrote the document.
1305    #[tokio::test]
1306    async fn the_scope_argument_addresses_all_four_tools() {
1307        let mock = Arc::new(MockReviewFacade::new());
1308        *mock.detail.lock() = Some(ReviewDetail {
1309            review_id: "r1".to_owned(),
1310            number: 1,
1311            summary: None,
1312            comments: Vec::new(),
1313        });
1314        let facade: Arc<dyn ReviewFacade> = mock.clone();
1315        let scope = "doc:/proj/plan.md";
1316
1317        let outputs = vec![
1318            ReviewList { facade: Arc::clone(&facade), slot: caller_slot() }
1319                .call(ToolInput { value: serde_json::json!({ "scope": scope }) })
1320                .await,
1321            ReviewGet { facade: Arc::clone(&facade), slot: caller_slot() }
1322                .call(ToolInput {
1323                    value: serde_json::json!({ "review_id": "r1", "scope": scope }),
1324                })
1325                .await,
1326            ReviewReply { facade: Arc::clone(&facade), slot: caller_slot() }
1327                .call(ToolInput {
1328                    value: serde_json::json!({ "comment_id": "c1", "text": "done", "scope": scope }),
1329                })
1330                .await,
1331            ReviewResolve { facade: Arc::clone(&facade), slot: caller_slot() }
1332                .call(ToolInput { value: serde_json::json!({ "comment_id": "c2", "scope": scope }) })
1333                .await,
1334        ];
1335        for out in &outputs {
1336            assert!(!out.is_error, "{:?}", out.blocks);
1337        }
1338        assert_eq!(
1339            mock.scopes_seen.lock().as_slice(),
1340            [scope, scope, scope, scope],
1341            "every tool addressed the explicit scope rather than the caller's branch",
1342        );
1343    }
1344
1345    #[tokio::test]
1346    async fn review_reply_invalid_args_is_error() {
1347        let mock = Arc::new(MockReviewFacade::new());
1348        let facade: Arc<dyn ReviewFacade> = mock;
1349        let tool = ReviewReply { facade, slot: caller_slot() };
1350        let out = tool.call(ToolInput { value: serde_json::json!({ "comment_id": "c1" }) }).await;
1351        assert!(out.is_error, "missing 'text' is an error");
1352        assert!(text_of(&out).to_lowercase().contains("invalid"));
1353    }
1354
1355    /// The document read-back loop, asserted by OUTCOME: with the
1356    /// document's scope as the only address, the four tools list, read,
1357    /// reply and resolve the review the viewer filed, and the STORE is
1358    /// what must show it - the terminal status on disk, not a tool call
1359    /// having appeared.
1360    #[tokio::test]
1361    async fn a_documents_review_reads_back_by_its_scope_to_a_resolved_thread() {
1362        use crate::workspace::Workspace;
1363
1364        let dir = tempfile::tempdir().expect("tempdir");
1365        let mut config = crate::config::LoadedConfig::empty_for_test();
1366        config.projects.push(crate::config::LoadedProject {
1367            name: "myproj".to_owned(),
1368            path: dir.path().to_path_buf(),
1369            display_path: dir.path().to_string_lossy().into_owned(),
1370            org: "TestOrg".to_owned(),
1371            accounts: Vec::new(),
1372            fallback_accounts: Vec::new(),
1373            model: None,
1374            env: std::collections::HashMap::new(),
1375            env_file: None,
1376            max_workers: None,
1377            permission_mode: forge_primitives::permission::PermissionMode::Auto,
1378            document_roots: Vec::new(),
1379            handoff: None,
1380            browser_profile: forge_primitives::browser::ProfileKey::of("TestOrg", "default")
1381                .expect("a profile key"),
1382            slack_workspace: None,
1383        });
1384        let (ws, _rx) = Workspace::testing_stub_with_config(dir.path().to_path_buf(), config)
1385            .expect("the stub config is well-formed");
1386        ws.install_db_for_test(
1387            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1388        );
1389
1390        // The viewer's side: a comment on a line of the document, filed
1391        // as one review under the document's own scope.
1392        let scope = "doc:/proj/plan.md";
1393        let mut t = thread("a", None, ReviewStatus::Open);
1394        t.anchor.base_ref = scope.to_owned();
1395        assert!(ws.upsert_review_thread("myproj", scope, t), "the thread lands");
1396        let reviewer = SessionSlot::from_str_for_test("reviewer-uuid");
1397        ws.submit_review("myproj", scope, Some("overview".to_owned()), &["a".to_owned()], reviewer)
1398            .expect("the review is sealed");
1399
1400        // The agent's side, through the real tools over the real store.
1401        let facade: Arc<dyn ReviewFacade> = ProdReviewFacade::from_arc(&ws);
1402        let slot = SessionSlot::lead("TestOrg", "myproj");
1403
1404        let list = ReviewList { facade: Arc::clone(&facade), slot: slot.clone() }
1405            .call(ToolInput { value: serde_json::json!({ "scope": scope }) })
1406            .await;
1407        assert!(!list.is_error, "{:?}", list.blocks);
1408        let rows: serde_json::Value = serde_json::from_str(text_of(&list)).expect("list json");
1409        let review_id = rows[0]["review_id"].as_str().expect("a review id").to_owned();
1410
1411        let get = ReviewGet { facade: Arc::clone(&facade), slot: slot.clone() }
1412            .call(ToolInput {
1413                value: serde_json::json!({ "review_id": review_id, "scope": scope }),
1414            })
1415            .await;
1416        assert!(!get.is_error, "{:?}", get.blocks);
1417        let detail: serde_json::Value = serde_json::from_str(text_of(&get)).expect("get json");
1418        let comment_id =
1419            detail["comments"][0]["comment_id"].as_str().expect("a comment id").to_owned();
1420        assert_eq!(detail["comments"][0]["status"], "open", "the viewer's comment starts open");
1421
1422        let reply = ReviewReply { facade: Arc::clone(&facade), slot: slot.clone() }
1423            .call(ToolInput {
1424                value: serde_json::json!({ "comment_id": comment_id, "text": "done", "scope": scope }),
1425            })
1426            .await;
1427        assert!(!reply.is_error, "{:?}", reply.blocks);
1428        let echoed: serde_json::Value = serde_json::from_str(text_of(&reply)).expect("reply json");
1429        assert_eq!(echoed["status"], "addressed", "a reply flips the comment");
1430
1431        let resolve = ReviewResolve { facade: Arc::clone(&facade), slot }
1432            .call(ToolInput {
1433                value: serde_json::json!({ "comment_id": comment_id, "scope": scope }),
1434            })
1435            .await;
1436        assert!(!resolve.is_error, "{:?}", resolve.blocks);
1437
1438        // The outcome, read from the store where it landed.
1439        let stored = ws.load_review_threads("myproj", scope).expect("threads read back");
1440        assert_eq!(
1441            stored[0].status,
1442            ReviewStatus::Resolved,
1443            "the stored thread is the assertion; a call that failed cannot read as resolved",
1444        );
1445    }
1446
1447    #[test]
1448    fn build_server_registers_all_four_tools() {
1449        let facade = MockReviewFacade::new().into_arc();
1450        let server = build_server(facade, caller_slot());
1451        let debug = format!("{server:?}");
1452        for expected in ["review__list", "review__get", "review__reply", "review__resolve"] {
1453            assert!(debug.contains(expected), "build_server must include {expected}; {debug}");
1454        }
1455    }
1456}