use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{
AnnotateAble, CustomNotification, ErrorCode, ListResourceTemplatesResult, ListResourcesResult,
Notification, PaginatedRequestParams, RawResource, RawResourceTemplate,
ReadResourceRequestParams, ReadResourceResult, Resource, ResourceContents, ResourceTemplate,
ResourceUpdatedNotificationParam, ServerCapabilities, ServerInfo, ServerNotification,
SubscribeRequestParams, UnsubscribeRequestParams,
};
use rmcp::service::{NotificationContext, RequestContext};
use rmcp::{
ErrorData, Peer, RoleServer, ServerHandler, ServiceExt, schemars, tool, tool_handler,
tool_router,
};
use tokio::sync::Mutex as AsyncMutex;
use travelagent_core::mcp::dto::{
AddCommentRequest, EmptyRequest, GetCommentsRequest, GetDiffRequest, MarkReviewedRequest,
SetAiSummaryRequest,
};
use crate::app::{App, FLASH_MESSAGE_TTL};
fn error_json(message: impl Into<String>) -> String {
serde_json::to_string(&serde_json::json!({ "error": message.into() }))
.unwrap_or_else(|_| r#"{"error":"internal error"}"#.to_string())
}
pub struct McpCommand {
pub action: McpAction,
pub reply: Sender<String>,
}
pub enum McpAction {
ListFiles,
GetDiff {
file: String,
},
GetReviewStatus,
AddComment {
file: String,
line: u32,
side: String,
body: String,
comment_type: Option<String>,
},
GetComments {
file: Option<String>,
since: Option<i64>,
},
MarkReviewed {
file: String,
},
GetPrMetadata,
GetCommits,
SelectFile {
file: String,
},
ExportMarkdown,
TourResolveRevset {
revset: String,
},
TourSetPlan {
stops: Vec<TourStopRequest>,
},
TourGoto {
index: u32,
},
TourNext,
TourPrev,
TourEnd,
TourStatus,
TourListTourComments,
TourSetTriage {
comment_id: String,
verdict: String,
reasoning: String,
new_file: Option<String>,
new_line: Option<u32>,
},
TourTakeGranularityHint,
TourTakePendingRequest,
TourGetThreshold,
TourSetThreshold {
level: u8,
},
TourGetCommitRisk {
sha: String,
},
TourGetStopsWithRisk,
SetAiSummary {
markdown: String,
},
GetAiSummary,
ClearAiSummary,
CaptureView,
ReadResource {
uri: String,
},
ProposeForgeSubmitReview {
verdict: String,
body: String,
},
GetConfirmationStatus {
id: String,
},
CancelConfirmation {
id: String,
},
GetMentalModel,
ProposeSetMentalModel {
should_do: String,
shouldnt_do: String,
could_go_wrong: String,
assumptions: String,
},
ListSpecComments,
WriteTestFromSpec {
spec_id: String,
framework: Option<String>,
},
ProposeAcceptTest {
test_path: String,
test_body: String,
spec_id: String,
},
Quit,
}
#[derive(Debug, Clone, serde::Deserialize, schemars::JsonSchema)]
pub struct TourStopRequest {
#[schemars(
description = "Ordered commit SHAs (oldest→newest) that this stop represents. Length ≥ 1."
)]
pub commit_ids: Vec<String>,
#[schemars(description = "Agent-written summary of what changed in this stop.")]
pub summary: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SelectFileRequest {
#[schemars(description = "File path to navigate to in the TUI")]
pub file: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct TourResolveRevsetRequest {
#[schemars(description = "Revset to resolve (e.g. 'HEAD~10..HEAD' for git)")]
pub revset: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct TourSetPlanRequest {
#[schemars(
description = "Ordered stops. Each stop bundles 1+ commits (oldest→newest) and a summary."
)]
pub stops: Vec<TourStopRequest>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct TourGotoRequest {
#[schemars(description = "0-based stop index to jump to")]
pub index: u32,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct TourSetThresholdRequest {
#[schemars(
description = "Risk threshold in 0..=5. Stops scoring ≤ threshold may be batched together; stops above stand alone."
)]
pub level: u8,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct TourGetCommitRiskRequest {
#[schemars(description = "Commit SHA to score under the current RiskConfig")]
pub sha: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct TourSetTriageRequest {
#[schemars(description = "Comment id returned from a previous trv_add_comment call")]
pub comment_id: String,
#[schemars(description = "Verdict: 'live' | 'likely_obsolete' | 'moved'")]
pub verdict: String,
#[schemars(description = "Short reasoning shown in the exported markdown")]
pub reasoning: String,
#[schemars(description = "For verdict='moved': the new file path")]
pub new_file: Option<String>,
#[schemars(description = "For verdict='moved': the new 1-based line number")]
pub new_line: Option<u32>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ProposeForgeSubmitReviewRequest {
#[schemars(
description = "Verdict: 'comment' | 'approve' | 'request_changes' (GitHub). GitLab accepts 'comment' | 'approve'."
)]
pub verdict: String,
#[schemars(description = "Review body. Subject to the same size cap as trv_submit_review.")]
pub body: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ProposeSetMentalModelRequest {
#[schemars(description = "What the reviewer wants the diff to accomplish.")]
pub should_do: String,
#[schemars(description = "What the reviewer explicitly doesn't want to see.")]
pub shouldnt_do: String,
#[schemars(description = "Failure modes the reviewer expects to watch for.")]
pub could_go_wrong: String,
#[schemars(description = "Assumptions the reviewer is making about the surrounding system.")]
pub assumptions: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct WriteTestFromSpecRequest {
#[schemars(description = "Spec comment id from trv_list_spec_comments.")]
pub spec_id: String,
#[schemars(
description = "Optional test-framework hint (e.g. 'cargo-test', 'pytest', 'jest'). Passed through to the agent — trv doesn't dispatch on it."
)]
pub framework: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ProposeAcceptTestRequest {
#[schemars(
description = "Repo-relative, forward-slash path under the sparring working tree (e.g. 'crates/foo/tests/bar.rs'). Absolute paths and '..' components are refused."
)]
pub test_path: String,
#[schemars(
description = "Full test file content. Subject to the MCP_GENERATED_TEST_BODY_MAX byte cap."
)]
pub test_body: String,
#[schemars(description = "Spec comment id the generated test addresses.")]
pub spec_id: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ConfirmationIdRequest {
#[schemars(
description = "Confirmation id returned by trv_propose_forge_submit_review's 'confirmation_id' field."
)]
pub id: String,
}
const RECENT_NOTIFICATIONS_CAP: usize = 128;
#[derive(Clone, Default)]
pub(crate) struct PeerRegistry {
peers: Arc<AsyncMutex<HashMap<u64, Peer<RoleServer>>>>,
next_id: Arc<AtomicU64>,
insert_signal: Arc<tokio::sync::Notify>,
recent: Arc<AsyncMutex<VecDeque<crate::app::McpNotify>>>,
replay_tasks: Option<Arc<AsyncMutex<tokio::task::JoinSet<()>>>>,
}
impl PeerRegistry {
#[cfg(test)]
pub(crate) fn new() -> Self {
Self::default()
}
fn with_replay_tasks(replay_tasks: Arc<AsyncMutex<tokio::task::JoinSet<()>>>) -> Self {
Self {
replay_tasks: Some(replay_tasks),
..Self::default()
}
}
pub(crate) fn allocate_id(&self) -> u64 {
self.next_id.fetch_add(1, Ordering::Relaxed)
}
async fn insert(&self, id: u64, peer: Peer<RoleServer>) {
let buffered: Vec<crate::app::McpNotify> = {
let mut peers = self.peers.lock().await;
peers.insert(id, peer.clone());
let recent = self.recent.lock().await;
recent.iter().cloned().collect()
};
self.insert_signal.notify_waiters();
if buffered.is_empty() {
return;
}
let Some(replay_tasks) = self.replay_tasks.clone() else {
return;
};
let mut set = replay_tasks.lock().await;
while set.try_join_next().is_some() {}
for notify in buffered {
let peer = peer.clone();
let method = format!("notifications/trv/{}", notify.method_suffix());
let params = notify_to_params(¬ify);
let resource_uris = resource_uris_for(¬ify);
set.spawn(async move {
let custom = ServerNotification::CustomNotification(CustomNotification::new(
method,
Some(params),
));
let _ = peer.send_notification(custom).await;
for uri in resource_uris {
let updated = ServerNotification::ResourceUpdatedNotification(
Notification::new(ResourceUpdatedNotificationParam::new(uri)),
);
let _ = peer.send_notification(updated).await;
}
});
}
}
async fn push_and_snapshot(&self, notify: crate::app::McpNotify) -> Vec<Peer<RoleServer>> {
let peers = self.peers.lock().await;
let mut ring = self.recent.lock().await;
if ring.len() >= RECENT_NOTIFICATIONS_CAP {
ring.pop_front();
}
ring.push_back(notify);
peers.values().cloned().collect()
}
async fn peers_snapshot(&self) -> Vec<Peer<RoleServer>> {
self.peers.lock().await.values().cloned().collect()
}
#[cfg(test)]
pub(crate) async fn push_recent(&self, notify: crate::app::McpNotify) {
let mut ring = self.recent.lock().await;
if ring.len() >= RECENT_NOTIFICATIONS_CAP {
ring.pop_front();
}
ring.push_back(notify);
}
#[cfg(test)]
pub(crate) async fn snapshot(&self) -> Vec<Peer<RoleServer>> {
self.peers_snapshot().await
}
pub(crate) async fn remove(&self, id: u64) {
self.peers.lock().await.remove(&id);
}
pub(crate) async fn is_empty(&self) -> bool {
self.peers.lock().await.is_empty()
}
pub(crate) async fn len(&self) -> usize {
self.peers.lock().await.len()
}
#[cfg(test)]
pub(crate) async fn wait_for_peer_count(&self, n: usize) {
loop {
let notified = self.insert_signal.notified();
if self.peers.lock().await.len() >= n {
return;
}
notified.await;
}
}
#[cfg(test)]
async fn recent_snapshot(&self) -> Vec<crate::app::McpNotify> {
self.recent.lock().await.iter().cloned().collect()
}
}
pub struct McpBridgeServer {
tx: Sender<McpCommand>,
registry: PeerRegistry,
connection_id: u64,
}
impl McpBridgeServer {
pub(crate) fn new(tx: Sender<McpCommand>, registry: PeerRegistry, connection_id: u64) -> Self {
Self {
tx,
registry,
connection_id,
}
}
#[cfg(test)]
pub(crate) fn new_for_test(tx: Sender<McpCommand>) -> Self {
let registry = PeerRegistry::new();
let connection_id = registry.allocate_id();
Self::new(tx, registry, connection_id)
}
fn send(&self, action: McpAction) -> String {
let (reply_tx, reply_rx) = mpsc::channel();
let cmd = McpCommand {
action,
reply: reply_tx,
};
if self.tx.send(cmd).is_err() {
return r#"{"error":"TUI event loop is gone"}"#.to_string();
}
reply_rx
.recv()
.unwrap_or_else(|_| r#"{"error":"no reply from TUI"}"#.to_string())
}
}
#[tool_router]
impl McpBridgeServer {
#[tool(
description = "List all files in the current code review with their status and review progress"
)]
fn trv_list_files(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
self.send(McpAction::ListFiles)
}
#[tool(description = "Get the diff content for a specific file")]
fn trv_get_diff(&self, Parameters(req): Parameters<GetDiffRequest>) -> String {
self.send(McpAction::GetDiff { file: req.file })
}
#[tool(description = "Get the overall review progress")]
fn trv_get_review_status(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
self.send(McpAction::GetReviewStatus)
}
#[tool(description = "Add a review comment on a specific line of a file")]
fn trv_add_comment(&self, Parameters(req): Parameters<AddCommentRequest>) -> String {
if let Some(envelope) = travelagent_core::mcp::ingress::check_body_size_json(
"comment body",
&req.body,
travelagent_core::mcp_limits::MCP_COMMENT_BODY_MAX,
) {
return envelope;
}
self.send(McpAction::AddComment {
file: req.file,
line: req.line,
side: req.side,
body: req.body,
comment_type: req.comment_type,
})
}
#[tool(
description = "List all review comments, optionally filtered by file and by a `since` unix-ms cursor. When `since` is set, only comments created at/after the cursor (or whose anchor flipped to Orphaned at/after it) are returned, and the response is wrapped in `{\"comments\": [...], \"server_time\": <unix-ms>}`."
)]
fn trv_get_comments(&self, Parameters(req): Parameters<GetCommentsRequest>) -> String {
self.send(McpAction::GetComments {
file: req.file,
since: req.since,
})
}
#[tool(description = "Mark a file as reviewed")]
fn trv_mark_reviewed(&self, Parameters(req): Parameters<MarkReviewedRequest>) -> String {
self.send(McpAction::MarkReviewed { file: req.file })
}
#[tool(description = "Get PR metadata (remote mode only)")]
fn trv_get_pr_metadata(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
self.send(McpAction::GetPrMetadata)
}
#[tool(description = "Get the list of commits in the PR (remote mode only)")]
fn trv_get_commits(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
self.send(McpAction::GetCommits)
}
#[tool(
description = "Read the reviewer's pre-diff mental model (Phase I2). Returns \
{\"mental_model\": null} when the human hasn't captured one, \
or {\"mental_model\": {should_do, shouldnt_do, could_go_wrong, \
assumptions, created_at, updated_at}} when they have. Lets an \
agent condition its suggestions on what the reviewer explicitly \
said they want and don't want to see."
)]
fn trv_get_mental_model(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
self.send(McpAction::GetMentalModel)
}
#[tool(
description = "List every comment on the current session whose kind is `spec` — \
test specifications authored by the reviewer via `:spec` (Phase I4). \
Each entry includes id, scope (review/file/line/orphaned), file path \
where applicable, line + side for line-scoped specs, body text, and \
`created_at`. Agents read this to pick which specs to generate tests \
for; the generation itself is the agent's job. Returns {\"specs\": []} \
when none have been captured yet."
)]
fn trv_list_spec_comments(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
self.send(McpAction::ListSpecComments)
}
#[tool(
description = "Read-only spec-to-test context fetch (Phase I4c). Given a spec comment id from \
trv_list_spec_comments, returns the spec body + anchor (file/line/side) + the \
diff hunks of the anchored file so the agent can generate a test client-side. \
The agent-supplied `framework` hint is echoed back unchanged — trv doesn't \
dispatch on it. Returns {\"error\": \"...\"} with a specific reason when the \
spec id is unknown, the spec is review-scoped (no file anchor), or the anchored \
file is no longer in the diff. Use trv_propose_accept_test to land generated \
tests on the sparring branch."
)]
fn trv_write_test_from_spec(
&self,
Parameters(req): Parameters<WriteTestFromSpecRequest>,
) -> String {
self.send(McpAction::WriteTestFromSpec {
spec_id: req.spec_id,
framework: req.framework,
})
}
#[tool(
description = "Propose landing a generated test file on the sparring branch (Phase I4c). \
The TUI opens a modal; the human answers [y]es / [n]o / [Esc]. Approval \
writes the file under the repo root (no VCS commit — the human authors \
that). Returns {status: \"pending\", confirmation_id, timeout_seconds} \
on accept, {status: \"busy\", current_id} if another confirmation is in \
flight, or {error: \"...\"} for invalid input. Refused outside sparring \
mode so generated tests can't land on the main tree. Absolute paths and \
'..' components are refused. Poll with trv_get_confirmation_status(id) \
and/or subscribe to notifications/trv/agent_action_{proposed,decided}."
)]
fn trv_propose_accept_test(
&self,
Parameters(req): Parameters<ProposeAcceptTestRequest>,
) -> String {
if let Some(envelope) = travelagent_core::mcp::ingress::check_body_size_json(
"generated test body",
&req.test_body,
travelagent_core::mcp_limits::MCP_GENERATED_TEST_BODY_MAX,
) {
return envelope;
}
self.send(McpAction::ProposeAcceptTest {
test_path: req.test_path,
test_body: req.test_body,
spec_id: req.spec_id,
})
}
#[tool(description = "Navigate the TUI to a specific file so the human sees it on screen")]
fn trv_select_file(&self, Parameters(req): Parameters<SelectFileRequest>) -> String {
self.send(McpAction::SelectFile { file: req.file })
}
#[tool(description = "Export the review session as markdown")]
fn trv_export_markdown(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
self.send(McpAction::ExportMarkdown)
}
#[tool(
description = "Resolve a VCS revset to an ordered list of commit SHAs (oldest first). Use this before calling trv_tour_set_plan to see what commits the user wants to tour."
)]
fn trv_tour_resolve_revset(
&self,
Parameters(req): Parameters<TourResolveRevsetRequest>,
) -> String {
self.send(McpAction::TourResolveRevset { revset: req.revset })
}
#[tool(
description = "Start a tour guide session with the given plan and jump to stop 0. Each stop bundles one or more commits (oldest→newest) with an agent-written summary. Batching several commits into one stop is how you reduce granularity (e.g. group a run of README-only commits into one stop)."
)]
fn trv_tour_set_plan(&self, Parameters(req): Parameters<TourSetPlanRequest>) -> String {
self.send(McpAction::TourSetPlan { stops: req.stops })
}
#[tool(description = "Jump to a specific 0-based stop in the active tour")]
fn trv_tour_goto(&self, Parameters(req): Parameters<TourGotoRequest>) -> String {
self.send(McpAction::TourGoto { index: req.index })
}
#[tool(description = "Step forward one stop in the active tour")]
fn trv_tour_next(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
self.send(McpAction::TourNext)
}
#[tool(description = "Step backward one stop in the active tour")]
fn trv_tour_prev(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
self.send(McpAction::TourPrev)
}
#[tool(description = "End the active tour and return the TUI to normal browsing")]
fn trv_tour_end(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
self.send(McpAction::TourEnd)
}
#[tool(
description = "Report the current tour status: stop index, total stops, current stop's commits and summary"
)]
fn trv_tour_status(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
self.send(McpAction::TourStatus)
}
#[tool(
description = "List every comment that was added during a tour stop, with its stop index, commit SHAs, file, line, and any existing triage. Use this at the end of a tour to decide which comments are still live."
)]
fn trv_tour_list_tour_comments(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
self.send(McpAction::TourListTourComments)
}
#[tool(
description = "Record your triage verdict on a specific tour comment. Verdict is one of 'live' (still applies), 'likely_obsolete' (superseded by a later commit — will auto-resolve), or 'moved' (still applies but at a new location; requires new_file + new_line). Provide concise reasoning."
)]
fn trv_tour_set_triage(&self, Parameters(req): Parameters<TourSetTriageRequest>) -> String {
self.send(McpAction::TourSetTriage {
comment_id: req.comment_id,
verdict: req.verdict,
reasoning: req.reasoning,
new_file: req.new_file,
new_line: req.new_line,
})
}
#[tool(
description = "Take the current granularity hint from the human ('coarser' = batch more, 'finer' = split stops, or null). Consumes the hint — a subsequent call returns null until the human nudges again. When you see a hint, rebuild the plan via trv_tour_set_plan accordingly."
)]
fn trv_tour_take_granularity_hint(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
self.send(McpAction::TourTakeGranularityHint)
}
#[tool(
description = "Take the current tour request from the human (one-shot). Returns {commit_ids: [...]} when the human has invoked :tour from the commit picker since the last call, otherwise {commit_ids: null}. Consumes the request — a subsequent call returns null until the human nudges again. When you see a non-null result, plan a tour over those commits and install it via trv_tour_set_plan. Polling counterpart to the notifications/trv/tour_request event."
)]
fn trv_tour_take_pending_request(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
self.send(McpAction::TourTakePendingRequest)
}
#[tool(
description = "Get the current tour's risk threshold (0..=5) and the nearest preset name ('cautious' | 'balanced' | 'aggressive' | 'custom'). Returns {active:false} when no tour is active."
)]
fn trv_tour_get_threshold(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
self.send(McpAction::TourGetThreshold)
}
#[tool(
description = "Rebuild the active tour's stops at a specific threshold (0..=5). Returns the new stop count. Errors when no tour is active or level is out of range."
)]
fn trv_tour_set_threshold(
&self,
Parameters(req): Parameters<TourSetThresholdRequest>,
) -> String {
self.send(McpAction::TourSetThreshold { level: req.level })
}
#[tool(
description = "Return the risk score of a specific commit under the current [risk] config, along with the distinct change types that fed the score and the per-file risk breakdown."
)]
fn trv_tour_get_commit_risk(
&self,
Parameters(req): Parameters<TourGetCommitRiskRequest>,
) -> String {
self.send(McpAction::TourGetCommitRisk { sha: req.sha })
}
#[tool(
description = "Return the active tour's stops with their risk scores plus the threshold used to build them. Returns {active:false} when no tour is active."
)]
fn trv_tour_get_stops_with_risk(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
self.send(McpAction::TourGetStopsWithRisk)
}
#[tool(
description = "Write an AI-generated markdown summary that appears in the TUI's AI Summary panel (Ctrl+A). Overwrites any prior summary. Markdown is rendered with pulldown-cmark. 64KB cap."
)]
fn trv_set_ai_summary(&self, Parameters(req): Parameters<SetAiSummaryRequest>) -> String {
self.send(McpAction::SetAiSummary {
markdown: req.markdown,
})
}
#[tool(description = "Read the current AI summary set via trv_set_ai_summary, if any.")]
fn trv_get_ai_summary(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
self.send(McpAction::GetAiSummary)
}
#[tool(description = "Clear the AI summary and close its panel if open.")]
fn trv_clear_ai_summary(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
self.send(McpAction::ClearAiSummary)
}
#[tool(
description = "Snapshot what the human is currently looking at in the TUI: selected file, cursor file+line+side, visible logical line range, diff view mode (unified or side_by_side), session counters, and AI summary metadata. Read-only — does not mutate state or set a breadcrumb."
)]
fn trv_capture_view(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
self.send(McpAction::CaptureView)
}
#[tool(description = "Propose a forge submit_review for human confirmation. \
The TUI opens a modal; the human answers [y]es / [n]o / [Esc]. \
Returns {status: \"pending\", confirmation_id, timeout_seconds} on accept, \
or {status: \"busy\", current_id} if another confirmation is in flight. \
Poll with trv_get_confirmation_status(id) and/or subscribe to \
notifications/trv/agent_action_{proposed,decided}.")]
fn trv_propose_forge_submit_review(
&self,
Parameters(req): Parameters<ProposeForgeSubmitReviewRequest>,
) -> String {
if let Some(envelope) = travelagent_core::mcp::ingress::check_body_size_json(
"review body",
&req.body,
travelagent_core::mcp_limits::MCP_REVIEW_BODY_MAX,
) {
return envelope;
}
self.send(McpAction::ProposeForgeSubmitReview {
verdict: req.verdict,
body: req.body,
})
}
#[tool(
description = "Propose overwriting the reviewer's pre-diff mental model (Phase I2). \
The TUI opens a confirmation modal mirroring trv_propose_forge_submit_review; \
the human answers [y]es / [n]o / [Esc]. Returns \
{status: \"pending\", confirmation_id, timeout_seconds} on accept, or \
{status: \"busy\", current_id} if another confirmation is in flight. \
Poll with trv_get_confirmation_status(id). Per-field size is bounded \
by the same `[mental_model].byte_limit` config that bounds the TUI \
input path."
)]
fn trv_propose_set_mental_model(
&self,
Parameters(req): Parameters<ProposeSetMentalModelRequest>,
) -> String {
self.send(McpAction::ProposeSetMentalModel {
should_do: req.should_do,
shouldnt_do: req.shouldnt_do,
could_go_wrong: req.could_go_wrong,
assumptions: req.assumptions,
})
}
#[tool(
description = "Return the current status of a forge confirmation by id. \
Status is one of 'pending' | 'approved' | 'executing' | 'succeeded' | \
'rejected' | 'failed'. For rejections, includes a 'reason' field \
('user'|'timeout'|'agent_cancelled'|'already_pending'). For success, \
includes a 'result' object; for failure, an 'error' string."
)]
fn trv_get_confirmation_status(
&self,
Parameters(req): Parameters<ConfirmationIdRequest>,
) -> String {
self.send(McpAction::GetConfirmationStatus { id: req.id })
}
#[tool(
description = "Withdraw a still-pending forge confirmation. Transitions it to \
rejected(reason=agent_cancelled). Returns an error for unknown or \
already-decided ids."
)]
fn trv_cancel_confirmation(
&self,
Parameters(req): Parameters<ConfirmationIdRequest>,
) -> String {
self.send(McpAction::CancelConfirmation { id: req.id })
}
#[tool(
description = "Request that the TUI quit. By default this returns an error so the human \
stays in control of their session — quit must be initiated by the human \
in the TUI. Set the TRV_ALLOW_AGENT_QUIT environment variable to '1', \
'true', or 'yes' (case-insensitive) to allow agent-initiated quit."
)]
fn trv_request_quit(&self, Parameters(_): Parameters<EmptyRequest>) -> String {
if !agent_quit_allowed() {
return error_json(
"Quit must be initiated by the human in the TUI. \
Set TRV_ALLOW_AGENT_QUIT=1 to allow agent-initiated quit.",
);
}
self.send(McpAction::Quit)
}
}
#[tool_handler(router = Self::tool_router())]
impl ServerHandler for McpBridgeServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.enable_resources_subscribe()
.build(),
)
.with_server_info(rmcp::model::Implementation::new(
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION"),
))
}
async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
self.registry
.insert(self.connection_id, context.peer.clone())
.await;
}
async fn list_resources(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, ErrorData> {
Ok(ListResourcesResult::with_all_items(
bridge_static_resources(),
))
}
async fn list_resource_templates(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourceTemplatesResult, ErrorData> {
Ok(ListResourceTemplatesResult::with_all_items(vec![
bridge_diff_resource_template(),
]))
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<ReadResourceResult, ErrorData> {
let body = self.send(McpAction::ReadResource {
uri: request.uri.clone(),
});
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&body)
&& let Some(err) = v.get("error").and_then(|e| e.as_str())
{
let code = match v.get("kind").and_then(|k| k.as_str()) {
Some("resource_not_found") => ErrorCode::RESOURCE_NOT_FOUND,
Some("internal_error") => ErrorCode::INTERNAL_ERROR,
_ => ErrorCode::INVALID_PARAMS,
};
return Err(ErrorData::new(code, err.to_string(), None));
}
Ok(ReadResourceResult::new(vec![
ResourceContents::text(body, request.uri).with_mime_type("application/json"),
]))
}
async fn subscribe(
&self,
_request: SubscribeRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<(), ErrorData> {
Ok(())
}
async fn unsubscribe(
&self,
_request: UnsubscribeRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<(), ErrorData> {
Ok(())
}
}
fn bridge_static_resources() -> Vec<Resource> {
use travelagent_core::mcp::resources::{COMMENTS_URI, STATUS_URI};
vec![
RawResource::new(STATUS_URI, "review-status")
.with_title("Review status")
.with_description(
"Aggregate counts for the current review session: files total, files reviewed, comments count, whether a remote forge is attached.",
)
.with_mime_type("application/json")
.no_annotation(),
RawResource::new(COMMENTS_URI, "review-comments")
.with_title("Review comments")
.with_description(
"All review / file / line / orphaned / remote comments as a JSON array. Clients can post-filter by `file` or `scope`.",
)
.with_mime_type("application/json")
.no_annotation(),
]
}
fn bridge_diff_resource_template() -> ResourceTemplate {
use travelagent_core::mcp::resources::DIFF_URI_TEMPLATE;
RawResourceTemplate::new(DIFF_URI_TEMPLATE, "review-diff")
.with_title("Review diff for a file")
.with_description(
"Parsed diff for a single file in the current review. Matches the output of trv_get_diff.",
)
.with_mime_type("application/json")
.no_annotation()
}
const PRE_HANDSHAKE_BUFFER_CAP: usize = 32;
fn spawn_notify_drain(
runtime_handle: &tokio::runtime::Handle,
registry: PeerRegistry,
mut notify_rx: tokio::sync::mpsc::Receiver<crate::app::McpNotify>,
) -> tokio::task::JoinHandle<()> {
let insert_signal = registry.insert_signal.clone();
runtime_handle.spawn(async move {
let mut pending: VecDeque<crate::app::McpNotify> = VecDeque::new();
let mut in_flight: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
#[derive(Debug)]
enum Event {
Notify(crate::app::McpNotify),
PeerArrived,
Closed,
}
loop {
let peer_arrived = insert_signal.notified();
let has_backlog_without_peers =
!pending.is_empty() && registry.peers_snapshot().await.is_empty();
let event = if has_backlog_without_peers {
tokio::select! {
maybe = notify_rx.recv() => match maybe {
Some(n) => Event::Notify(n),
None => Event::Closed,
},
_ = peer_arrived => Event::PeerArrived,
}
} else {
drop(peer_arrived);
match notify_rx.recv().await {
Some(n) => Event::Notify(n),
None => Event::Closed,
}
};
match event {
Event::Closed => break,
Event::PeerArrived => {
for buffered in pending.drain(..) {
let peers = registry.push_and_snapshot(buffered.clone()).await;
if !peers.is_empty() {
fan_out(&peers, buffered, &mut in_flight);
}
}
}
Event::Notify(notify) => {
let peers_preview = registry.peers_snapshot().await;
if peers_preview.is_empty() {
if pending.len() >= PRE_HANDSHAKE_BUFFER_CAP {
pending.pop_front();
}
pending.push_back(notify);
continue;
}
for buffered in pending.drain(..) {
let peers = registry.push_and_snapshot(buffered.clone()).await;
fan_out(&peers, buffered, &mut in_flight);
}
let peers = registry.push_and_snapshot(notify.clone()).await;
fan_out(&peers, notify, &mut in_flight);
}
}
}
while in_flight.join_next().await.is_some() {}
})
}
fn fan_out(
peers: &[Peer<RoleServer>],
notify: crate::app::McpNotify,
in_flight: &mut tokio::task::JoinSet<()>,
) {
let method = format!("notifications/trv/{}", notify.method_suffix());
let params = notify_to_params(¬ify);
let resource_uris = resource_uris_for(¬ify);
for peer in peers {
let peer = peer.clone();
let method = method.clone();
let params = params.clone();
let uris = resource_uris.clone();
in_flight.spawn(async move {
let custom = ServerNotification::CustomNotification(CustomNotification::new(
method,
Some(params),
));
let _ = peer.send_notification(custom).await;
for uri in uris {
let updated = ServerNotification::ResourceUpdatedNotification(Notification::new(
ResourceUpdatedNotificationParam::new(uri),
));
let _ = peer.send_notification(updated).await;
}
});
}
}
fn resource_uris_for(notify: &crate::app::McpNotify) -> Vec<String> {
use crate::app::McpNotify;
use travelagent_core::mcp::resources::{ResourceNotify, STATUS_URI, resource_uris_for_notify};
let rn = match notify {
McpNotify::FileChanged { files } => ResourceNotify::FilesChanged { files },
McpNotify::CommentAdded { .. } => ResourceNotify::CommentAdded,
McpNotify::ReviewSubmitted { .. } => ResourceNotify::ReviewSubmitted,
McpNotify::AgentActionProposed { .. } | McpNotify::AgentActionDecided { .. } => {
return vec![STATUS_URI.to_string()];
}
McpNotify::Hangup { .. } => return Vec::new(),
McpNotify::TourRequest { .. } => return Vec::new(),
};
resource_uris_for_notify(&rn)
}
fn notify_to_params(notify: &crate::app::McpNotify) -> serde_json::Value {
use crate::app::McpNotify;
match notify {
McpNotify::FileChanged { files } => serde_json::json!({ "files": files }),
McpNotify::CommentAdded { file, line, author } => serde_json::json!({
"file": file,
"line": line,
"author": author,
}),
McpNotify::ReviewSubmitted { verdict, at } => serde_json::json!({
"verdict": verdict,
"at": at,
}),
McpNotify::AgentActionProposed { id, kind } => serde_json::json!({
"id": id,
"kind": kind,
}),
McpNotify::AgentActionDecided {
id,
decision,
reason,
} => serde_json::json!({
"id": id,
"decision": decision,
"reason": reason,
}),
McpNotify::Hangup {
deadline_ms,
reason,
} => serde_json::json!({
"deadline_ms": deadline_ms,
"reason": reason,
}),
McpNotify::TourRequest { commit_ids } => serde_json::json!({
"commit_ids": commit_ids,
}),
}
}
fn agent_quit_allowed() -> bool {
match std::env::var("TRV_ALLOW_AGENT_QUIT") {
Ok(v) => {
let v = v.trim().to_ascii_lowercase();
matches!(v.as_str(), "1" | "true" | "yes")
}
Err(_) => false,
}
}
const NOTIFY_CHANNEL_CAP: usize = 64;
pub struct McpHub {
pub(crate) registry: PeerRegistry,
pub notify_tx: tokio::sync::mpsc::Sender<crate::app::McpNotify>,
drain_handle: Option<tokio::task::JoinHandle<()>>,
replay_tasks: Arc<AsyncMutex<tokio::task::JoinSet<()>>>,
runtime_handle: tokio::runtime::Handle,
}
impl McpHub {
pub fn start(runtime_handle: &tokio::runtime::Handle) -> Self {
let replay_tasks = Arc::new(AsyncMutex::new(tokio::task::JoinSet::new()));
let registry = PeerRegistry::with_replay_tasks(replay_tasks.clone());
let (notify_tx, notify_rx) = tokio::sync::mpsc::channel(NOTIFY_CHANNEL_CAP);
let drain_handle = spawn_notify_drain(runtime_handle, registry.clone(), notify_rx);
Self {
registry,
notify_tx,
drain_handle: Some(drain_handle),
replay_tasks,
runtime_handle: runtime_handle.clone(),
}
}
pub fn shutdown(mut self, timeout: std::time::Duration) {
let (dummy_tx, _dummy_rx) = tokio::sync::mpsc::channel(1);
let real_tx = std::mem::replace(&mut self.notify_tx, dummy_tx);
drop(real_tx);
drop(self.notify_tx);
let Some(handle) = self.drain_handle.take() else {
return;
};
let rt = self.runtime_handle.clone();
let replay_tasks = self.replay_tasks.clone();
rt.block_on(async move {
let deadline = tokio::time::Instant::now() + timeout;
let _ = tokio::time::timeout_at(deadline, handle).await;
let _ = tokio::time::timeout_at(deadline, async {
let mut set = replay_tasks.lock().await;
while set.join_next().await.is_some() {}
})
.await;
});
}
}
pub fn start_mcp_alongside(
runtime_handle: tokio::runtime::Handle,
hub: &McpHub,
) -> Receiver<McpCommand> {
let (tx, rx) = mpsc::channel::<McpCommand>();
let connection_id = hub.registry.allocate_id();
let server = McpBridgeServer::new(tx, hub.registry.clone(), connection_id);
let registry = hub.registry.clone();
thread::spawn(move || {
runtime_handle.block_on(async move {
let (stdin, stdout) = rmcp::transport::io::stdio();
let service = match server.serve((stdin, stdout)).await {
Ok(svc) => svc,
Err(_) => {
return;
}
};
let _ = service.waiting().await;
registry.remove(connection_id).await;
});
});
rx
}
fn agent_action_breadcrumb(action: &McpAction, viewport_pinned: bool) -> Option<String> {
fn basename(path: &str) -> &str {
std::path::Path::new(path)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(path)
}
match action {
McpAction::SelectFile { file } if viewport_pinned => Some(format!(
"\u{1f916} agent: pointing at {} (pinned — Ctrl+G to follow)",
basename(file)
)),
McpAction::SelectFile { file } => {
Some(format!("\u{1f916} agent: jumped to {}", basename(file)))
}
McpAction::AddComment { file, line, .. } => Some(format!(
"\u{1f916} agent: commented on {}:{}",
basename(file),
line
)),
McpAction::MarkReviewed { file } => Some(format!(
"\u{1f916} agent: marked {} reviewed",
basename(file)
)),
McpAction::SetAiSummary { .. } => Some("\u{1f916} agent: updated AI summary".to_string()),
McpAction::ClearAiSummary => Some("\u{1f916} agent: cleared AI summary".to_string()),
McpAction::TourSetPlan { stops } => Some(format!(
"\u{1f916} agent: started tour ({} stops)",
stops.len()
)),
McpAction::TourGoto { index } => Some(format!("\u{1f916} agent: tour stop {}", index + 1)),
McpAction::TourNext => Some("\u{1f916} agent: tour next stop".to_string()),
McpAction::TourPrev => Some("\u{1f916} agent: tour previous stop".to_string()),
McpAction::TourEnd => Some("\u{1f916} agent: ended tour".to_string()),
McpAction::TourSetThreshold { level } => {
Some(format!("\u{1f916} agent: tour threshold {level}"))
}
McpAction::TourSetTriage { verdict, .. } => {
Some(format!("\u{1f916} agent: triaged comment ({verdict})"))
}
McpAction::ProposeForgeSubmitReview { verdict, .. } => Some(format!(
"\u{1f916} agent proposed submit_review ({verdict}) — awaiting confirmation"
)),
McpAction::ProposeSetMentalModel { .. } => {
Some("\u{1f916} agent proposed set_mental_model — awaiting confirmation".to_string())
}
McpAction::ProposeAcceptTest { test_path, .. } => Some(format!(
"\u{1f916} agent proposed accept_generated_test ({test_path}) — awaiting confirmation"
)),
McpAction::CancelConfirmation { .. } => {
Some("\u{1f916} agent: cancelled pending confirmation".to_string())
}
McpAction::ListFiles
| McpAction::GetDiff { .. }
| McpAction::GetReviewStatus
| McpAction::GetComments { .. }
| McpAction::GetPrMetadata
| McpAction::GetCommits
| McpAction::ExportMarkdown
| McpAction::TourResolveRevset { .. }
| McpAction::TourStatus
| McpAction::TourListTourComments
| McpAction::TourTakeGranularityHint
| McpAction::TourTakePendingRequest
| McpAction::TourGetThreshold
| McpAction::TourGetCommitRisk { .. }
| McpAction::TourGetStopsWithRisk
| McpAction::GetAiSummary
| McpAction::CaptureView
| McpAction::ReadResource { .. }
| McpAction::GetConfirmationStatus { .. }
| McpAction::GetMentalModel
| McpAction::ListSpecComments
| McpAction::WriteTestFromSpec { .. }
| McpAction::Quit => None,
}
}
pub fn process_mcp_command(app: &mut App, cmd: McpCommand) {
if let Some(breadcrumb) = agent_action_breadcrumb(&cmd.action, app.viewport_pinned) {
app.set_flash_message(breadcrumb, FLASH_MESSAGE_TTL);
}
let reply = match cmd.action {
McpAction::ListFiles => handle_list_files(app),
McpAction::GetDiff { file } => handle_get_diff(app, &file),
McpAction::GetReviewStatus => handle_get_review_status(app),
McpAction::AddComment {
file,
line,
side,
body,
comment_type,
} => handle_add_comment(app, &file, line, &side, &body, comment_type.as_deref()),
McpAction::GetComments { file, since } => handle_get_comments(app, file.as_deref(), since),
McpAction::MarkReviewed { file } => handle_mark_reviewed(app, &file),
McpAction::GetPrMetadata => handle_get_pr_metadata(app),
McpAction::GetCommits => handle_get_commits(app),
McpAction::SelectFile { file } => handle_select_file(app, &file),
McpAction::ExportMarkdown => handle_export_markdown(app),
McpAction::TourResolveRevset { revset } => handle_tour_resolve_revset(app, &revset),
McpAction::TourSetPlan { stops } => handle_tour_set_plan(app, stops),
McpAction::TourGoto { index } => handle_tour_goto(app, index as usize),
McpAction::TourNext => handle_tour_step(app, true),
McpAction::TourPrev => handle_tour_step(app, false),
McpAction::TourEnd => {
app.tour_end();
r#"{"ok":true}"#.to_string()
}
McpAction::TourStatus => handle_tour_status(app),
McpAction::TourListTourComments => handle_tour_list_tour_comments(app),
McpAction::TourSetTriage {
comment_id,
verdict,
reasoning,
new_file,
new_line,
} => handle_tour_set_triage(app, &comment_id, &verdict, reasoning, new_file, new_line),
McpAction::TourTakeGranularityHint => handle_tour_take_granularity_hint(app),
McpAction::TourTakePendingRequest => handle_tour_take_pending_request(app),
McpAction::TourGetThreshold => handle_tour_get_threshold(app),
McpAction::TourSetThreshold { level } => handle_tour_set_threshold(app, level),
McpAction::TourGetCommitRisk { sha } => handle_tour_get_commit_risk(app, &sha),
McpAction::TourGetStopsWithRisk => handle_tour_get_stops_with_risk(app),
McpAction::SetAiSummary { markdown } => handle_set_ai_summary(app, markdown),
McpAction::GetAiSummary => handle_get_ai_summary(app),
McpAction::ClearAiSummary => handle_clear_ai_summary(app),
McpAction::CaptureView => handle_capture_view(app),
McpAction::ReadResource { uri } => handle_read_resource(app, &uri),
McpAction::ProposeForgeSubmitReview { verdict, body } => {
handle_propose_forge_submit_review(app, &verdict, body)
}
McpAction::GetConfirmationStatus { id } => handle_get_confirmation_status(app, &id),
McpAction::CancelConfirmation { id } => handle_cancel_confirmation(app, &id),
McpAction::GetMentalModel => handle_get_mental_model(app),
McpAction::ProposeSetMentalModel {
should_do,
shouldnt_do,
could_go_wrong,
assumptions,
} => handle_propose_set_mental_model(
app,
should_do,
shouldnt_do,
could_go_wrong,
assumptions,
),
McpAction::ListSpecComments => handle_list_spec_comments(app),
McpAction::WriteTestFromSpec { spec_id, framework } => {
handle_write_test_from_spec(app, &spec_id, framework.as_deref())
}
McpAction::ProposeAcceptTest {
test_path,
test_body,
spec_id,
} => handle_propose_accept_test(app, test_path, test_body, spec_id),
McpAction::Quit => {
app.should_quit = true;
r#"{"ok":true}"#.to_string()
}
};
let _ = cmd.reply.send(reply);
}
fn handle_list_files(app: &App) -> String {
use travelagent_core::model::FileStatus;
let mut files = Vec::new();
for (i, file) in app.diff_files.iter().enumerate() {
let path = file.display_path_lossy().to_string_lossy().to_string();
let status = match file.status {
FileStatus::Added => "added",
FileStatus::Modified => "modified",
FileStatus::Deleted => "deleted",
FileStatus::Renamed => "renamed",
FileStatus::Copied => "copied",
};
let reviewed = app.engine.session().is_file_reviewed(&PathBuf::from(&path));
let comment_count = app
.engine
.session()
.files
.get(&PathBuf::from(&path))
.map_or(
0,
travelagent_core::model::review::FileReview::comment_count,
);
files.push(serde_json::json!({
"index": i,
"path": path,
"status": status,
"reviewed": reviewed,
"comment_count": comment_count,
"is_binary": file.is_binary,
}));
}
serde_json::to_string_pretty(&files).unwrap_or_else(|_| "[]".to_string())
}
fn handle_get_diff(app: &App, file_path: &str) -> String {
use travelagent_core::model::LineOrigin;
let file = app
.diff_files
.iter()
.find(|f| f.display_path_lossy().to_string_lossy() == file_path);
match file {
Some(f) => {
let hunks: Vec<serde_json::Value> = f
.hunks
.iter()
.map(|h| {
let lines: Vec<serde_json::Value> = h
.lines
.iter()
.map(|l| {
let origin = match l.origin {
LineOrigin::Context => "context",
LineOrigin::Addition => "addition",
LineOrigin::Deletion => "deletion",
};
serde_json::json!({
"origin": origin,
"content": l.content,
"old_line": l.old_lineno,
"new_line": l.new_lineno,
})
})
.collect();
serde_json::json!({
"header": h.header,
"lines": lines,
})
})
.collect();
serde_json::to_string_pretty(&serde_json::json!({
"file": file_path,
"hunks": hunks,
}))
.unwrap_or_else(|_| "{}".to_string())
}
None => error_json(format!("File not found: {file_path}")),
}
}
fn handle_get_review_status(app: &App) -> String {
use crate::app::ConfirmationStatus;
let total = app.diff_files.len();
let reviewed = app.engine.session().reviewed_count();
let comments: usize = app.engine.session().review_comments.len()
+ app
.engine
.session()
.files
.values()
.map(travelagent_core::model::review::FileReview::comment_count)
.sum::<usize>();
let pending = app.agent_action.pending().and_then(|a| {
if matches!(a.status, ConfirmationStatus::Pending) {
Some(serde_json::json!({
"id": a.id,
"kind": a.kind.wire_name(),
"proposed_at": a.proposed_at.to_rfc3339(),
}))
} else {
None
}
});
let last_decision = app.agent_action.last_decision().map(|d| {
let mut obj = serde_json::json!({
"id": d.id,
"decision": d.decision,
"decided_at": d.decided_at.to_rfc3339(),
});
if let Some(reason) = d.reason
&& let Some(map) = obj.as_object_mut()
{
map.insert(
"reason".to_string(),
serde_json::Value::String(reason.to_string()),
);
}
obj
});
serde_json::to_string_pretty(&serde_json::json!({
"files_total": total,
"files_reviewed": reviewed,
"comments_count": comments,
"has_forge": app.has_forge(),
"current_file": app
.current_file()
.map(|f| f.display_path_lossy().to_string_lossy().to_string()),
"agent_action": {
"pending": pending,
"last_decision": last_decision,
},
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn handle_add_comment(
app: &mut App,
file_path: &str,
line: u32,
side: &str,
body: &str,
comment_type_str: Option<&str>,
) -> String {
use travelagent_core::model::{Comment, CommentType, LineSide};
let side = match side {
"old" => LineSide::Old,
"new" => LineSide::New,
other => {
return error_json(format!("Unknown side '{other}'; expected 'old' or 'new'"));
}
};
let comment_type = match comment_type_str {
None => CommentType::Note,
Some("note") => CommentType::Note,
Some("suggestion") => CommentType::Suggestion,
Some("issue") => CommentType::Issue,
Some("praise") => CommentType::Praise,
Some("question") => CommentType::Question,
Some(other) => {
return error_json(format!(
"Unknown comment_type '{other}'; expected one of note, suggestion, issue, praise, question"
));
}
};
let status = match app
.diff_files
.iter()
.find(|f| f.display_path_lossy().to_string_lossy() == file_path)
.map(|f| f.status)
{
Some(s) => s,
None => return error_json(format!("File not in current diff: {file_path}")),
};
let path = PathBuf::from(file_path);
let tour_tag = match app.tour.plan.as_ref().and_then(|t| {
t.current()
.map(|s| (t.index + 1, t.stops.len(), s.last_sha().to_string()))
}) {
Some((stop_num, total, sha)) => {
let short = sha.get(..7).unwrap_or(&sha);
format!("\n\n_(tour stop {stop_num}/{total} @ {short})_")
}
None => String::new(),
};
let tagged_body = format!("{body}{tour_tag}");
if let Some(envelope) = travelagent_core::mcp::ingress::check_body_size_json(
"comment body (with tour tag)",
&tagged_body,
travelagent_core::mcp_limits::MCP_COMMENT_BODY_MAX,
) {
return envelope;
}
let comment = Comment::new_with_author_kind(
tagged_body,
comment_type,
Some(side),
travelagent_core::model::AuthorKind::McpAgent,
);
let comment_id = app
.engine
.add_line_comment(path.clone(), status, line, comment);
app.dirty = true;
app.tour_record_comment(comment_id.clone(), file_path.to_string(), line);
app.push_notify(crate::app::McpNotify::CommentAdded {
file: file_path.to_string(),
line: Some(line),
author: "agent",
});
serde_json::to_string_pretty(&serde_json::json!({
"comment_id": comment_id,
"file": file_path,
"line": line,
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn handle_get_comments(app: &App, filter: Option<&str>, since: Option<i64>) -> String {
use travelagent_core::model::{AnchorState, Comment, LineSide};
let since_dt: Option<chrono::DateTime<chrono::Utc>> = match since {
Some(ms) => match chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms) {
Some(dt) => Some(dt),
None => {
return error_json(format!("invalid `since` unix-ms cursor: {ms}"));
}
},
None => None,
};
let server_time_snapshot = chrono::Utc::now();
let include_by_since =
|c: &Comment| travelagent_core::mcp::since_filter::include_by_since(c, since_dt);
let mut comments = Vec::new();
if filter.is_none() {
for c in &app.engine.session().review_comments {
if !include_by_since(c) {
continue;
}
comments.push(serde_json::json!({
"id": c.id,
"scope": "review",
"body": c.content,
"type": c.comment_type.id(),
}));
}
}
for (path, review) in &app.engine.session().files {
let path_str = path.to_string_lossy().to_string();
if let Some(f) = filter
&& path_str != f
{
continue;
}
for c in &review.file_comments {
if !include_by_since(c) {
continue;
}
comments.push(serde_json::json!({
"id": c.id,
"scope": "file",
"file": path_str,
"body": c.content,
"type": c.comment_type.id(),
}));
}
for (line, line_comments) in &review.line_comments {
for c in line_comments {
if !include_by_since(c) {
continue;
}
comments.push(serde_json::json!({
"id": c.id,
"scope": "line",
"file": path_str,
"line": line,
"body": c.content,
"type": c.comment_type.id(),
}));
}
}
for c in &review.orphaned_comments {
if !include_by_since(c) {
continue;
}
let (was_line, was_side, last_seen, orphaned_at) = match c.anchor.as_ref() {
Some(AnchorState::Orphaned {
was_line,
was_side,
last_seen_content,
orphaned_at,
}) => (
Some(*was_line),
Some(match was_side {
LineSide::Old => "old",
LineSide::New => "new",
}),
Some(last_seen_content.as_str()),
orphaned_at.map(|ts| ts.to_rfc3339()),
),
_ => (None, None, None, None),
};
comments.push(serde_json::json!({
"id": c.id,
"scope": "orphaned",
"file": path_str,
"was_line": was_line,
"was_side": was_side,
"last_seen_content": last_seen,
"orphaned_at": orphaned_at,
"body": c.content,
"type": c.comment_type.id(),
}));
}
}
if let Some(r) = app.remote() {
for c in &r.remote_comments {
if let Some(f) = filter
&& c.path.as_deref() != Some(f)
{
continue;
}
if let Some(cutoff) = since_dt
&& c.created_at < cutoff
{
continue;
}
comments.push(serde_json::json!({
"id": c.id,
"scope": "remote",
"file": c.path,
"line": c.line,
"author": c.author,
"body": c.body,
}));
}
}
if since_dt.is_some() {
let server_time = server_time_snapshot.timestamp_millis();
serde_json::to_string_pretty(&serde_json::json!({
"comments": comments,
"server_time": server_time,
}))
.unwrap_or_else(|_| "{}".to_string())
} else {
serde_json::to_string_pretty(&comments).unwrap_or_else(|_| "[]".to_string())
}
}
fn handle_mark_reviewed(app: &mut App, file_path: &str) -> String {
let status = match app
.diff_files
.iter()
.find(|f| f.display_path_lossy().to_string_lossy() == file_path)
.map(|f| f.status)
{
Some(s) => s,
None => return error_json(format!("File not in current diff: {file_path}")),
};
let path = PathBuf::from(file_path);
app.engine.touch_file(path, status).reviewed = true;
app.dirty = true;
serde_json::to_string_pretty(&serde_json::json!({
"ok": true,
"file": file_path,
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn handle_get_pr_metadata(app: &App) -> String {
match app.remote().and_then(|r| r.pr_metadata.as_ref()) {
Some(meta) => serde_json::to_string_pretty(&serde_json::json!({
"title": meta.title,
"body": meta.body,
"author": meta.author,
"state": meta.state.display(),
"base_branch": meta.base_branch,
"head_branch": meta.head_branch,
"head_sha": meta.head_sha,
"is_draft": meta.is_draft,
"created_at": meta.created_at.to_rfc3339(),
}))
.unwrap_or_else(|_| "{}".to_string()),
None => error_json("Not in remote PR mode"),
}
}
fn handle_get_commits(app: &App) -> String {
let Some(r) = app.remote() else {
return error_json("No commits available");
};
if r.pr_commits.is_empty() {
return error_json("No commits available");
}
let commits: Vec<serde_json::Value> = r
.pr_commits
.iter()
.map(|c| {
serde_json::json!({
"sha": c.id,
"short_sha": c.short_id,
"message": c.summary,
"author": c.author,
"date": c.time.to_rfc3339(),
})
})
.collect();
serde_json::to_string_pretty(&commits).unwrap_or_else(|_| "[]".to_string())
}
fn handle_select_file(app: &mut App, file_path: &str) -> String {
let idx = app
.diff_files
.iter()
.position(|f| f.display_path_lossy().to_string_lossy() == file_path);
match idx {
Some(i) => {
if app.viewport_pinned {
app.record_agent_ghost(i);
serde_json::to_string_pretty(&serde_json::json!({
"ok": true,
"file": file_path,
"index": i,
"pinned": true,
}))
.unwrap_or_else(|_| "{}".to_string())
} else {
app.jump_to_file(i);
serde_json::to_string_pretty(&serde_json::json!({
"ok": true,
"file": file_path,
"index": i,
"pinned": false,
}))
.unwrap_or_else(|_| "{}".to_string())
}
}
None => error_json(format!("File not found: {file_path}")),
}
}
fn handle_export_markdown(app: &App) -> String {
match crate::output::generate_export_content(
app.engine.session(),
&app.diff_source,
&app.comment.types,
app.export_legend,
) {
Ok(mut content) => {
if let Some(ref tour) = app.tour.plan {
crate::output::append_tour_stops(
&mut content,
tour,
&app.tour.comment_meta,
&app.tour.triage,
);
}
crate::output::append_tour_triage(
&mut content,
&app.tour.comment_meta,
&app.tour.triage,
);
serde_json::to_string_pretty(&serde_json::json!({
"markdown": content,
}))
.unwrap_or_else(|_| "{}".to_string())
}
Err(e) => serde_json::to_string_pretty(&serde_json::json!({
"error": format!("{e}"),
}))
.unwrap_or_else(|_| "{}".to_string()),
}
}
fn handle_tour_resolve_revset(app: &App, revset: &str) -> String {
match app.tour_resolve_revset(revset) {
Ok(commits) => serde_json::to_string_pretty(&serde_json::json!({
"commits": commits,
}))
.unwrap_or_else(|_| "{}".to_string()),
Err(e) => serde_json::to_string_pretty(&serde_json::json!({
"error": format!("Failed to resolve revset '{revset}': {e}"),
}))
.unwrap_or_else(|_| "{}".to_string()),
}
}
fn handle_tour_set_plan(app: &mut App, stops: Vec<TourStopRequest>) -> String {
use travelagent_core::model::TourStop;
if stops.is_empty() {
return error_json("Tour plan must contain at least one stop");
}
let stops: Vec<TourStop> = stops
.into_iter()
.map(|s| TourStop {
commit_ids: s.commit_ids,
summary: s.summary,
risk: travelagent_core::risk::RiskScore::MIN,
})
.collect();
if stops.iter().any(|s| s.commit_ids.is_empty()) {
return error_json("every stop must have at least one commit_id");
}
match app.tour_start(stops) {
Ok(()) => tour_status_json(app),
Err(e) => error_json(format!("{e}")),
}
}
fn handle_tour_goto(app: &mut App, index: usize) -> String {
match app.tour_goto(index) {
Ok(()) => tour_status_json(app),
Err(e) => error_json(format!("{e}")),
}
}
fn handle_tour_step(app: &mut App, forward: bool) -> String {
let result = if forward {
app.tour_next()
} else {
app.tour_prev()
};
match result {
Ok(()) => tour_status_json(app),
Err(e) => error_json(format!("{e}")),
}
}
fn handle_tour_status(app: &App) -> String {
tour_status_json(app)
}
fn tour_status_json(app: &App) -> String {
let Some(tour) = app.tour.plan.as_ref() else {
return r#"{"active":false}"#.to_string();
};
let Some(stop) = tour.current() else {
return r#"{"active":false}"#.to_string();
};
serde_json::to_string_pretty(&serde_json::json!({
"active": true,
"index": tour.index,
"total": tour.stops.len(),
"commit_ids": stop.commit_ids,
"from_sha": stop.first_sha(),
"to_sha": stop.last_sha(),
"batched": stop.is_batched(),
"summary": stop.summary,
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn handle_tour_list_tour_comments(app: &App) -> String {
let mut by_id: std::collections::HashMap<&str, &travelagent_core::model::Comment> =
std::collections::HashMap::new();
for c in &app.engine.session().review_comments {
by_id.insert(&c.id, c);
}
for review in app.engine.session().files.values() {
for c in &review.file_comments {
by_id.insert(&c.id, c);
}
for cs in review.line_comments.values() {
for c in cs {
by_id.insert(&c.id, c);
}
}
for c in &review.orphaned_comments {
by_id.insert(&c.id, c);
}
}
let mut out = Vec::new();
for (comment_id, meta) in &app.tour.comment_meta {
let comment = by_id.get(comment_id.as_str());
let (body, ctype) = comment.map_or_else(
|| (String::new(), "note".to_string()),
|c| (c.content.clone(), c.comment_type.id().to_string()),
);
let triage = app.tour.triage.get(comment_id);
out.push(serde_json::json!({
"comment_id": comment_id,
"stop_index": meta.stop_index,
"stop_commit_shas": meta.stop_commit_shas,
"file": meta.file,
"line": meta.line,
"body": body,
"type": ctype,
"triage": triage.map(|t| serde_json::json!({
"verdict": t.verdict.id(),
"reasoning": t.reasoning,
"new_location": t.new_location.as_ref().map(|l| serde_json::json!({
"file": l.file,
"line": l.line,
})),
})),
}));
}
serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".to_string())
}
fn handle_tour_set_triage(
app: &mut App,
comment_id: &str,
verdict_str: &str,
reasoning: String,
new_file: Option<String>,
new_line: Option<u32>,
) -> String {
use travelagent_core::model::{NewCommentLocation, TourTriageVerdict};
let Some(verdict) = TourTriageVerdict::from_id(verdict_str) else {
return error_json(format!(
"Unknown verdict '{verdict_str}'; expected live | likely_obsolete | moved"
));
};
let new_location = match verdict {
TourTriageVerdict::Moved => match (new_file, new_line) {
(Some(file), Some(line)) => {
if line == 0 {
return error_json("new_line must be >= 1 (line numbers are 1-based)");
}
Some(NewCommentLocation { file, line })
}
_ => {
return error_json("Moved verdict requires both new_file and new_line");
}
},
_ => None,
};
match app.tour_set_triage(comment_id, verdict, reasoning, new_location) {
Ok(()) => {
let (live, obsolete, moved) = app.tour_triage_counts();
serde_json::to_string_pretty(&serde_json::json!({
"ok": true,
"comment_id": comment_id,
"verdict": verdict.id(),
"counts": { "live": live, "likely_obsolete": obsolete, "moved": moved },
}))
.unwrap_or_else(|_| "{}".to_string())
}
Err(e) => error_json(format!("{e}")),
}
}
fn handle_tour_take_granularity_hint(app: &mut App) -> String {
match app.tour_take_granularity_hint() {
Some(hint) => serde_json::to_string_pretty(&serde_json::json!({
"hint": hint.id(),
}))
.unwrap_or_else(|_| "{}".to_string()),
None => r#"{"hint":null}"#.to_string(),
}
}
fn handle_tour_take_pending_request(app: &mut App) -> String {
match app.pending_tour_request_poll.take() {
Some(commit_ids) => serde_json::to_string_pretty(&serde_json::json!({
"commit_ids": commit_ids,
}))
.unwrap_or_else(|_| "{}".to_string()),
None => r#"{"commit_ids":null}"#.to_string(),
}
}
fn handle_tour_get_threshold(app: &App) -> String {
use travelagent_core::model::TourAggressiveness;
match app.tour_get_threshold() {
Some(threshold) => {
let preset = TourAggressiveness::from_threshold(threshold).preset_id();
serde_json::to_string_pretty(&serde_json::json!({
"active": true,
"threshold": threshold.as_u8(),
"preset": preset,
}))
.unwrap_or_else(|_| "{}".to_string())
}
None => r#"{"active":false}"#.to_string(),
}
}
fn handle_tour_set_threshold(app: &mut App, level: u8) -> String {
match app.tour_set_threshold(level) {
Ok(stops) => serde_json::to_string_pretty(&serde_json::json!({
"ok": true,
"stops": stops,
"threshold": level,
}))
.unwrap_or_else(|_| "{}".to_string()),
Err(e) => error_json(format!("{e}")),
}
}
fn handle_tour_get_commit_risk(app: &App, sha: &str) -> String {
match app.tour_commit_risk_detail(sha) {
Ok(detail) => {
let change_types: Vec<&str> = detail.change_types.iter().map(|c| c.id()).collect();
let file_scores: Vec<serde_json::Value> = detail
.file_scores
.iter()
.map(|f| {
serde_json::json!({
"file": f.file,
"risk": f.risk.as_u8(),
})
})
.collect();
serde_json::to_string_pretty(&serde_json::json!({
"sha": detail.sha,
"risk": detail.risk.as_u8(),
"change_types": change_types,
"file_scores": file_scores,
}))
.unwrap_or_else(|_| "{}".to_string())
}
Err(e) => error_json(format!("{e}")),
}
}
fn handle_tour_get_stops_with_risk(app: &App) -> String {
let Some(tour) = app.tour.plan.as_ref() else {
return r#"{"active":false}"#.to_string();
};
let stops: Vec<serde_json::Value> = tour
.stops
.iter()
.map(|s| {
serde_json::json!({
"commit_ids": s.commit_ids,
"summary": s.summary,
"risk": s.risk.as_u8(),
})
})
.collect();
serde_json::to_string_pretty(&serde_json::json!({
"active": true,
"stops": stops,
"threshold": tour.threshold.as_u8(),
}))
.unwrap_or_else(|_| "{}".to_string())
}
const AI_SUMMARY_MAX_BYTES: usize = 64 * 1024;
fn handle_set_ai_summary(app: &mut App, markdown: String) -> String {
let markdown = if markdown.len() > AI_SUMMARY_MAX_BYTES {
let mut end = AI_SUMMARY_MAX_BYTES;
while end > 0 && !markdown.is_char_boundary(end) {
end -= 1;
}
let mut truncated = markdown[..end].to_string();
truncated.push_str("\n\n*(truncated at 64KB)*");
truncated
} else {
markdown
};
app.set_ai_summary(markdown);
r#"{"ok":true}"#.to_string()
}
fn handle_get_ai_summary(app: &App) -> String {
use crate::app::ai_summary::AiSummaryStaleness;
let staleness = app.ai_summary_staleness();
let is_stale = staleness.is_stale();
let stale_for = match &staleness {
AiSummaryStaleness::Stale { stored_short } => Some(stored_short.clone()),
_ => None,
};
serde_json::to_string_pretty(&serde_json::json!({
"markdown": app.ai.summary,
"updated_at": app.ai.updated_at.map(|ts| ts.to_rfc3339()),
"diff_sha": app.ai.diff_sha,
"is_stale": is_stale,
"stale_for": stale_for,
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn handle_clear_ai_summary(app: &mut App) -> String {
app.clear_ai_summary();
r#"{"ok":true}"#.to_string()
}
fn cursor_to_file_line(app: &App) -> Option<serde_json::Value> {
use crate::app::AnnotatedLine;
use travelagent_core::model::LineSide;
let annotation = app.line_annotations.get(app.diff_state.cursor_line)?;
let (file_idx, old_lineno, new_lineno) = match annotation {
AnnotatedLine::DiffLine {
file_idx,
old_lineno,
new_lineno,
..
}
| AnnotatedLine::SideBySideLine {
file_idx,
old_lineno,
new_lineno,
..
} => (*file_idx, *old_lineno, *new_lineno),
_ => return None,
};
let file = app.diff_files.get(file_idx)?;
let path = file.display_path_lossy().to_string_lossy().to_string();
let side = match (old_lineno, new_lineno) {
(_, Some(_)) => Some(LineSide::New),
(Some(_), None) => Some(LineSide::Old),
(None, None) => None,
};
Some(serde_json::json!({
"file": path,
"old_line": old_lineno,
"new_line": new_lineno,
"side": side.map(|s| match s {
LineSide::Old => "old",
LineSide::New => "new",
}),
}))
}
fn handle_capture_view(app: &App) -> String {
use crate::app::{DiffSource, DiffViewMode};
use travelagent_core::model::FileStatus;
let diff_view = match app.nav.diff_view_mode {
DiffViewMode::Unified => "unified",
DiffViewMode::SideBySide => "side_by_side",
};
let mode = match app.diff_source {
DiffSource::Remote { .. } => "remote",
_ => "local",
};
let current_file = app.current_file().map(|f| {
let path = f.display_path_lossy().to_string_lossy().to_string();
let status = match f.status {
FileStatus::Added => "added",
FileStatus::Modified => "modified",
FileStatus::Deleted => "deleted",
FileStatus::Renamed => "renamed",
FileStatus::Copied => "copied",
};
let reviewed = app.engine.session().is_file_reviewed(&PathBuf::from(&path));
serde_json::json!({
"index": app.diff_state.current_file_idx,
"path": path,
"status": status,
"reviewed": reviewed,
"is_binary": f.is_binary,
})
});
let cursor = cursor_to_file_line(app);
let viewport = app.diff_state.viewport_height;
let total = app.total_lines();
let visible = if total == 0 {
serde_json::json!({
"first_logical_line": serde_json::Value::Null,
"last_logical_line": serde_json::Value::Null,
"viewport_height": viewport,
"total_lines": 0,
"cursor_line": app.diff_state.cursor_line,
})
} else {
let first_visible = app.diff_state.scroll_offset.min(total - 1);
let last_visible = first_visible
.saturating_add(viewport.saturating_sub(1))
.min(total - 1);
serde_json::json!({
"first_logical_line": first_visible,
"last_logical_line": last_visible,
"viewport_height": viewport,
"total_lines": total,
"cursor_line": app.diff_state.cursor_line,
})
};
let session_comments = app.engine.session().review_comments.len()
+ app
.engine
.session()
.files
.values()
.map(travelagent_core::model::review::FileReview::comment_count)
.sum::<usize>();
let session = serde_json::json!({
"files_total": app.diff_files.len(),
"files_reviewed": app.engine.session().reviewed_count(),
"comments_count": session_comments,
"current_file_idx": app.diff_state.current_file_idx,
});
let ai_summary = serde_json::json!({
"present": app.ai.summary.is_some(),
"updated_at": app.ai.updated_at.map(|ts| ts.to_rfc3339()),
"unread": app.ai.unread,
"panel_open": app.ai.show_panel,
});
let cursor_ownership = serde_json::json!({
"viewport_pinned": app.viewport_pinned,
"agent_ghost": app.agent_ghost.as_ref().map(|g| {
serde_json::json!({
"file_idx": g.file_idx,
"path": g.path,
})
}),
});
serde_json::to_string_pretty(&serde_json::json!({
"mode": mode,
"diff_view": diff_view,
"current_file": current_file,
"cursor": cursor,
"visible": visible,
"session": session,
"ai_summary": ai_summary,
"cursor_ownership": cursor_ownership,
"input_mode": match app.nav.input_mode {
crate::app::InputMode::Normal => "normal",
crate::app::InputMode::Comment => "comment",
crate::app::InputMode::Command => "command",
crate::app::InputMode::Search => "search",
crate::app::InputMode::Help => "help",
crate::app::InputMode::Confirm => "confirm",
crate::app::InputMode::CommitSelect => "commit_select",
crate::app::InputMode::VisualSelect => "visual_select",
crate::app::InputMode::ReviewSubmit => "review_submit",
crate::app::InputMode::CommandPalette => "command_palette",
crate::app::InputMode::ReactionPicker => "reaction_picker",
crate::app::InputMode::CommentTemplatePicker => "comment_template_picker",
crate::app::InputMode::MentalModelEdit => "mental_model_edit",
},
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn handle_read_resource(app: &App, uri: &str) -> String {
use travelagent_core::mcp::resources::{ResourceKind, parse_resource_uri};
match parse_resource_uri(uri) {
Ok(ResourceKind::Status) => handle_get_review_status(app),
Ok(ResourceKind::Comments) => handle_get_comments(app, None, None),
Ok(ResourceKind::Diff { file }) => {
let body = handle_get_diff(app, &file);
if let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&body)
&& v.get("error").is_some()
&& v.get("kind").is_none()
{
if let Some(obj) = v.as_object_mut() {
obj.insert(
"kind".to_string(),
serde_json::Value::String("resource_not_found".to_string()),
);
}
return serde_json::to_string_pretty(&v).unwrap_or(body);
}
body
}
Err(e) => serde_json::to_string_pretty(&serde_json::json!({
"error": e.to_string(),
"kind": "invalid_params",
}))
.unwrap_or_else(|_| error_json(e.to_string())),
}
}
fn parse_review_verdict(s: &str) -> Result<travelagent_core::forge::ReviewVerdict, String> {
use travelagent_core::forge::ReviewVerdict;
match s {
"comment" => Ok(ReviewVerdict::Comment),
"approve" => Ok(ReviewVerdict::Approve),
"request_changes" => Ok(ReviewVerdict::RequestChanges),
"unapprove" => Ok(ReviewVerdict::Comment),
other => Err(format!(
"Unknown verdict '{other}'; expected one of comment | approve | request_changes"
)),
}
}
fn invalid_params_json(message: impl Into<String>) -> String {
serde_json::to_string_pretty(&serde_json::json!({
"error": message.into(),
"kind": "invalid_params",
}))
.unwrap_or_else(|_| r#"{"error":"internal error","kind":"invalid_params"}"#.to_string())
}
fn resource_not_found_json(message: impl Into<String>) -> String {
serde_json::to_string_pretty(&serde_json::json!({
"error": message.into(),
"kind": "resource_not_found",
}))
.unwrap_or_else(|_| r#"{"error":"internal error","kind":"resource_not_found"}"#.to_string())
}
fn agent_action_status_json(action: &crate::app::PendingAgentAction) -> serde_json::Value {
use crate::app::{ConfirmationStatus, RejectReason};
let (status, extra): (&str, serde_json::Value) = match &action.status {
ConfirmationStatus::Pending => ("pending", serde_json::json!({})),
ConfirmationStatus::Executing => ("executing", serde_json::json!({})),
ConfirmationStatus::Rejected { reason } => (
"rejected",
serde_json::json!({ "reason": RejectReason::as_str(*reason) }),
),
ConfirmationStatus::Succeeded { result_json } => {
let parsed: serde_json::Value = serde_json::from_str(result_json)
.unwrap_or_else(|_| serde_json::json!({ "raw": result_json }));
("succeeded", serde_json::json!({ "result": parsed }))
}
ConfirmationStatus::Failed { error } => ("failed", serde_json::json!({ "error": error })),
};
let mut out = serde_json::json!({
"id": action.id,
"kind": action.kind.wire_name(),
"status": status,
"proposed_at": action.proposed_at.to_rfc3339(),
});
if let Some(ts) = action.decided_at
&& let Some(obj) = out.as_object_mut()
{
obj.insert(
"decided_at".to_string(),
serde_json::Value::String(ts.to_rfc3339()),
);
}
if let (Some(obj), Some(ex)) = (out.as_object_mut(), extra.as_object()) {
for (k, v) in ex {
obj.insert(k.clone(), v.clone());
}
}
out
}
fn handle_propose_forge_submit_review(app: &mut App, verdict_str: &str, body: String) -> String {
use crate::app::{
AgentActionKind, ConfirmationStatus, LastAgentDecision, McpNotify, PendingAgentAction,
RejectReason,
};
if !app.has_forge() {
return invalid_params_json(
"trv_propose_forge_submit_review requires a remote forge session",
);
}
let verdict = match parse_review_verdict(verdict_str) {
Ok(v) => v,
Err(msg) => return invalid_params_json(msg),
};
if matches!(
verdict,
travelagent_core::forge::ReviewVerdict::RequestChanges
) && !app.supports_request_changes()
{
return invalid_params_json(
"verdict 'request_changes' not supported by active forge (GitLab \
has no equivalent — use 'comment' or 'approve')",
);
}
if let Some(existing) = app.agent_action.pending()
&& matches!(existing.status, ConfirmationStatus::Pending)
{
let existing_id = existing.id.clone();
let rejected_id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now();
let rejected_action = PendingAgentAction {
id: rejected_id.clone(),
kind: AgentActionKind::SubmitReview {
verdict,
body: body.clone(),
},
status: ConfirmationStatus::Rejected {
reason: RejectReason::AlreadyPending,
},
proposed_at: now,
proposed_at_monotonic: std::time::Instant::now(),
decided_at: Some(now),
};
app.agent_action.record_transient_rejection(
rejected_action,
LastAgentDecision {
id: rejected_id.clone(),
decision: "rejected",
reason: Some(RejectReason::AlreadyPending.as_str()),
decided_at: now,
},
);
app.push_notify(McpNotify::AgentActionDecided {
id: rejected_id,
decision: "rejected",
reason: Some(RejectReason::AlreadyPending.as_str()),
});
return serde_json::to_string_pretty(&serde_json::json!({
"status": "busy",
"current_id": existing_id,
}))
.unwrap_or_else(|_| "{}".to_string());
}
let id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now();
let action = PendingAgentAction {
id: id.clone(),
kind: AgentActionKind::SubmitReview { verdict, body },
status: ConfirmationStatus::Pending,
proposed_at: now,
proposed_at_monotonic: std::time::Instant::now(),
decided_at: None,
};
app.agent_action.arm_pending(action);
app.push_notify(McpNotify::AgentActionProposed {
id: id.clone(),
kind: "submit_review",
});
serde_json::to_string_pretty(&serde_json::json!({
"status": "pending",
"confirmation_id": id,
"timeout_seconds": crate::app::CONFIRMATION_TIMEOUT.as_secs(),
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn handle_propose_set_mental_model(
app: &mut App,
should_do: String,
shouldnt_do: String,
could_go_wrong: String,
assumptions: String,
) -> String {
use crate::app::{
AgentActionKind, ConfirmationStatus, LastAgentDecision, McpNotify, PendingAgentAction,
RejectReason,
};
use travelagent_core::model::MentalModel;
let limit = app.mental_model_byte_limit;
let now_for_check = chrono::Utc::now();
let probe = MentalModel {
should_do: should_do.clone(),
shouldnt_do: shouldnt_do.clone(),
could_go_wrong: could_go_wrong.clone(),
assumptions: assumptions.clone(),
created_at: now_for_check,
updated_at: now_for_check,
};
if let Some((label, actual)) = probe.oversized_field(limit) {
return invalid_params_json(format!(
"mental-model field '{label}' exceeds the configured byte_limit ({actual} > {limit} bytes)"
));
}
if let Some(existing) = app.agent_action.pending()
&& matches!(existing.status, ConfirmationStatus::Pending)
{
let existing_id = existing.id.clone();
let rejected_id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now();
let rejected_action = PendingAgentAction {
id: rejected_id.clone(),
kind: AgentActionKind::SetMentalModel {
mental_model: MentalModel {
should_do: should_do.clone(),
shouldnt_do: shouldnt_do.clone(),
could_go_wrong: could_go_wrong.clone(),
assumptions: assumptions.clone(),
created_at: now,
updated_at: now,
},
},
status: ConfirmationStatus::Rejected {
reason: RejectReason::AlreadyPending,
},
proposed_at: now,
proposed_at_monotonic: std::time::Instant::now(),
decided_at: Some(now),
};
app.agent_action.record_transient_rejection(
rejected_action,
LastAgentDecision {
id: rejected_id.clone(),
decision: "rejected",
reason: Some(RejectReason::AlreadyPending.as_str()),
decided_at: now,
},
);
app.push_notify(McpNotify::AgentActionDecided {
id: rejected_id,
decision: "rejected",
reason: Some(RejectReason::AlreadyPending.as_str()),
});
return serde_json::to_string_pretty(&serde_json::json!({
"status": "busy",
"current_id": existing_id,
}))
.unwrap_or_else(|_| "{}".to_string());
}
let now = chrono::Utc::now();
let created_at = app
.engine
.session()
.mental_model
.as_ref()
.map(|m| m.created_at)
.unwrap_or(now);
let mental_model = MentalModel {
should_do,
shouldnt_do,
could_go_wrong,
assumptions,
created_at,
updated_at: now,
};
let id = uuid::Uuid::new_v4().to_string();
let action = PendingAgentAction {
id: id.clone(),
kind: AgentActionKind::SetMentalModel { mental_model },
status: ConfirmationStatus::Pending,
proposed_at: now,
proposed_at_monotonic: std::time::Instant::now(),
decided_at: None,
};
app.agent_action.arm_pending(action);
app.push_notify(McpNotify::AgentActionProposed {
id: id.clone(),
kind: "set_mental_model",
});
serde_json::to_string_pretty(&serde_json::json!({
"status": "pending",
"confirmation_id": id,
"timeout_seconds": crate::app::CONFIRMATION_TIMEOUT.as_secs(),
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn handle_propose_accept_test(
app: &mut App,
test_path: String,
test_body: String,
spec_id: String,
) -> String {
use crate::app::{
AgentActionKind, ConfirmationStatus, LastAgentDecision, McpNotify, PendingAgentAction,
RejectReason,
};
if !app.spar_mode {
return error_json(
"trv_propose_accept_test requires sparring mode; enter with `:spar` or \
relaunch with --spar before proposing generated tests",
);
}
if test_path.trim().is_empty() {
return invalid_params_json("test_path must not be empty");
}
let rel = std::path::Path::new(&test_path);
if rel.is_absolute() {
return invalid_params_json(format!("test_path must be repo-relative: {test_path}"));
}
if rel.components().any(|c| {
matches!(
c,
std::path::Component::ParentDir | std::path::Component::RootDir
)
}) {
return invalid_params_json(format!(
"test_path must not traverse outside the repo: {test_path}"
));
}
if let Err(reason) =
travelagent_core::sparring::validate_spec_link(&test_path, &test_body, &spec_id)
{
return invalid_params_json(reason);
}
if let Some(existing) = app.agent_action.pending()
&& matches!(existing.status, ConfirmationStatus::Pending)
{
let existing_id = existing.id.clone();
let rejected_id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now();
let rejected_action = PendingAgentAction {
id: rejected_id.clone(),
kind: AgentActionKind::AcceptGeneratedTest {
test_path: test_path.clone(),
test_body: test_body.clone(),
spec_id: spec_id.clone(),
},
status: ConfirmationStatus::Rejected {
reason: RejectReason::AlreadyPending,
},
proposed_at: now,
proposed_at_monotonic: std::time::Instant::now(),
decided_at: Some(now),
};
app.agent_action.record_transient_rejection(
rejected_action,
LastAgentDecision {
id: rejected_id.clone(),
decision: "rejected",
reason: Some(RejectReason::AlreadyPending.as_str()),
decided_at: now,
},
);
app.push_notify(McpNotify::AgentActionDecided {
id: rejected_id,
decision: "rejected",
reason: Some(RejectReason::AlreadyPending.as_str()),
});
return serde_json::to_string_pretty(&serde_json::json!({
"status": "busy",
"current_id": existing_id,
}))
.unwrap_or_else(|_| "{}".to_string());
}
let now = chrono::Utc::now();
let id = uuid::Uuid::new_v4().to_string();
let action = PendingAgentAction {
id: id.clone(),
kind: AgentActionKind::AcceptGeneratedTest {
test_path,
test_body,
spec_id,
},
status: ConfirmationStatus::Pending,
proposed_at: now,
proposed_at_monotonic: std::time::Instant::now(),
decided_at: None,
};
app.agent_action.arm_pending(action);
app.push_notify(McpNotify::AgentActionProposed {
id: id.clone(),
kind: "accept_generated_test",
});
serde_json::to_string_pretty(&serde_json::json!({
"status": "pending",
"confirmation_id": id,
"timeout_seconds": crate::app::CONFIRMATION_TIMEOUT.as_secs(),
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn handle_get_mental_model(app: &App) -> String {
use travelagent_core::model::MentalModel;
let payload = app
.engine
.session()
.mental_model
.as_ref()
.map(MentalModel::to_mcp_payload)
.unwrap_or(serde_json::Value::Null);
serde_json::to_string_pretty(&serde_json::json!({ "mental_model": payload }))
.unwrap_or_else(|_| "{}".to_string())
}
fn handle_list_spec_comments(app: &App) -> String {
use travelagent_core::model::{AnchorState, Comment, CommentType};
let is_spec = |c: &Comment| matches!(c.comment_type, CommentType::Spec);
let mut specs = Vec::new();
for c in &app.engine.session().review_comments {
if !is_spec(c) {
continue;
}
specs.push(serde_json::json!({
"id": c.id,
"scope": "review",
"body": c.content,
"created_at": c.created_at.to_rfc3339(),
}));
}
for (path, review) in &app.engine.session().files {
let path_str = path.to_string_lossy().to_string();
for c in &review.file_comments {
if !is_spec(c) {
continue;
}
specs.push(serde_json::json!({
"id": c.id,
"scope": "file",
"file": path_str,
"body": c.content,
"created_at": c.created_at.to_rfc3339(),
}));
}
for (line, line_comments) in &review.line_comments {
for c in line_comments {
if !is_spec(c) {
continue;
}
let side = c
.side
.map(|s| match s {
travelagent_core::model::LineSide::Old => "old",
travelagent_core::model::LineSide::New => "new",
})
.unwrap_or("new");
specs.push(serde_json::json!({
"id": c.id,
"scope": "line",
"file": path_str,
"line": line,
"side": side,
"body": c.content,
"created_at": c.created_at.to_rfc3339(),
}));
}
}
for c in &review.orphaned_comments {
if !is_spec(c) {
continue;
}
let was_line = match c.anchor.as_ref() {
Some(AnchorState::Orphaned { was_line, .. }) => Some(*was_line),
_ => None,
};
specs.push(serde_json::json!({
"id": c.id,
"scope": "orphaned",
"file": path_str,
"was_line": was_line,
"body": c.content,
"created_at": c.created_at.to_rfc3339(),
}));
}
}
serde_json::to_string_pretty(&serde_json::json!({ "specs": specs }))
.unwrap_or_else(|_| "{}".to_string())
}
fn handle_write_test_from_spec(app: &App, spec_id: &str, framework: Option<&str>) -> String {
use travelagent_core::model::{Comment, CommentType};
let is_spec = |c: &Comment| matches!(c.comment_type, CommentType::Spec);
let session = app.engine.session();
for c in &session.review_comments {
if c.id == spec_id && is_spec(c) {
return error_json(
"Spec is review-scoped (no file anchor); trv_write_test_from_spec \
needs a file- or line-scoped spec to return diff context",
);
}
}
for (path, review) in &session.files {
let path_str = path.to_string_lossy().to_string();
for c in &review.file_comments {
if c.id == spec_id && is_spec(c) {
return spec_to_test_payload(app, c, &path_str, None, None, framework);
}
}
for (line, line_comments) in &review.line_comments {
for c in line_comments {
if c.id == spec_id && is_spec(c) {
let side = c
.side
.map(|s| match s {
travelagent_core::model::LineSide::Old => "old",
travelagent_core::model::LineSide::New => "new",
})
.unwrap_or("new");
return spec_to_test_payload(
app,
c,
&path_str,
Some(*line),
Some(side),
framework,
);
}
}
}
for c in &review.orphaned_comments {
if c.id == spec_id && is_spec(c) {
return error_json(
"Spec is orphaned (anchor went stale); generating tests against \
a missing target isn't meaningful — re-anchor the spec first",
);
}
}
}
error_json("Unknown spec id")
}
fn spec_to_test_payload(
app: &App,
spec: &travelagent_core::model::Comment,
file_path: &str,
line: Option<u32>,
side: Option<&'static str>,
framework: Option<&str>,
) -> String {
use travelagent_core::model::LineOrigin;
let Some(file) = app
.diff_files
.iter()
.find(|f| f.display_path_lossy().to_string_lossy() == file_path)
else {
return error_json(format!(
"Spec anchors {file_path} but that file is no longer in the diff"
));
};
let hunks: Vec<serde_json::Value> = file
.hunks
.iter()
.map(|h| {
let lines: Vec<serde_json::Value> = h
.lines
.iter()
.map(|l| {
let origin = match l.origin {
LineOrigin::Context => "context",
LineOrigin::Addition => "addition",
LineOrigin::Deletion => "deletion",
};
serde_json::json!({
"origin": origin,
"content": l.content,
"old_line": l.old_lineno,
"new_line": l.new_lineno,
})
})
.collect();
serde_json::json!({
"header": h.header,
"lines": lines,
})
})
.collect();
let scope = if line.is_some() { "line" } else { "file" };
let spec_json = serde_json::json!({
"id": spec.id,
"scope": scope,
"file": file_path,
"line": line,
"side": side,
"body": spec.content,
"created_at": spec.created_at.to_rfc3339(),
});
serde_json::to_string_pretty(&serde_json::json!({
"spec": spec_json,
"framework": framework,
"diff": {
"file": file_path,
"hunks": hunks,
},
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn handle_get_confirmation_status(app: &App, id: &str) -> String {
match app.find_forge_action(id) {
Some(action) => serde_json::to_string_pretty(&agent_action_status_json(action))
.unwrap_or_else(|_| "{}".to_string()),
None => resource_not_found_json("unknown confirmation id"),
}
}
fn handle_cancel_confirmation(app: &mut App, id: &str) -> String {
use crate::app::{ConfirmationStatus, LastAgentDecision, McpNotify, RejectReason};
let is_pending_match = matches!(
app.agent_action.pending(),
Some(a) if a.id == id && matches!(a.status, ConfirmationStatus::Pending)
);
if is_pending_match {
if let Some(mut action) = app.agent_action.take_pending() {
let now = chrono::Utc::now();
action.status = ConfirmationStatus::Rejected {
reason: RejectReason::AgentCancelled,
};
action.decided_at = Some(now);
let decided_id = action.id.clone();
app.agent_action.archive_with_decision(
action,
LastAgentDecision {
id: decided_id.clone(),
decision: "rejected",
reason: Some(RejectReason::AgentCancelled.as_str()),
decided_at: now,
},
);
app.push_notify(McpNotify::AgentActionDecided {
id: decided_id,
decision: "rejected",
reason: Some(RejectReason::AgentCancelled.as_str()),
});
}
return r#"{"ok": true}"#.to_string();
}
if app.find_forge_action(id).is_some() {
invalid_params_json("confirmation already decided")
} else {
resource_not_found_json("unknown confirmation id")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{DiffSource, InputMode};
use crate::theme::Theme;
use std::path::{Path, PathBuf};
use travelagent_core::error::{Result as CoreResult, TrvError};
use travelagent_core::model::{
DiffFile, DiffHunk, DiffLine, FileStatus, LineOrigin, ReviewSession, SessionDiffSource,
};
use travelagent_core::vcs::{CommitInfo, VcsBackend, VcsInfo, VcsType};
struct StubVcs {
info: VcsInfo,
}
impl VcsBackend for StubVcs {
fn info(&self) -> &VcsInfo {
&self.info
}
fn get_working_tree_diff(&self) -> CoreResult<Vec<DiffFile>> {
Err(TrvError::NoChanges)
}
fn fetch_context_lines(
&self,
_file_path: &Path,
_file_status: FileStatus,
_start_line: u32,
_end_line: u32,
) -> CoreResult<Vec<DiffLine>> {
Ok(Vec::new())
}
fn resolve_revisions(&self, _revisions: &str) -> CoreResult<Vec<String>> {
Ok(vec!["abc123".to_string()])
}
fn get_commit_range_diff(&self, _commit_ids: &[String]) -> CoreResult<Vec<DiffFile>> {
Ok(vec![make_test_file("touched.txt")])
}
fn get_commits_info(&self, ids: &[String]) -> CoreResult<Vec<CommitInfo>> {
Ok(ids
.iter()
.map(|id| CommitInfo {
id: id.clone(),
short_id: id.chars().take(7).collect(),
branch_name: None,
summary: "stub".to_string(),
body: None,
author: "stub".to_string(),
time: chrono::Utc::now(),
})
.collect())
}
}
fn make_test_file(path: &str) -> DiffFile {
DiffFile {
old_path: None,
new_path: Some(PathBuf::from(path)),
status: FileStatus::Modified,
hunks: vec![DiffHunk {
header: "@@ -1,0 +1,1 @@".to_string(),
lines: vec![DiffLine {
origin: LineOrigin::Addition,
content: "hello".to_string(),
old_lineno: None,
new_lineno: Some(1),
highlighted_spans: None,
}],
old_start: 1,
old_count: 0,
new_start: 1,
new_count: 1,
}],
is_binary: false,
is_too_large: false,
is_commit_message: false,
}
}
fn build_test_app(files: Vec<DiffFile>) -> App {
let vcs_info = VcsInfo {
root_path: PathBuf::from("/tmp/trv-test"),
head_commit: "head".to_string(),
branch_name: Some("main".to_string()),
vcs_type: VcsType::Git,
};
let session = ReviewSession::new(
vcs_info.root_path.clone(),
vcs_info.head_commit.clone(),
vcs_info.branch_name.clone(),
SessionDiffSource::WorkingTree,
);
App::build(
Box::new(StubVcs {
info: vcs_info.clone(),
}),
vcs_info,
Theme::dark(),
None,
false,
files,
session,
DiffSource::WorkingTree,
InputMode::Normal,
Vec::new(),
None,
crate::test_support::runtime_handle(),
crate::app::AppMode::Local(crate::app::LocalState::default()),
)
.expect("failed to build test app")
}
fn parse_json(s: &str) -> serde_json::Value {
serde_json::from_str(s)
.unwrap_or_else(|e| panic!("handler output isn't valid JSON: {e}\n{s}"))
}
fn is_error_response(s: &str) -> bool {
let v = parse_json(s);
v.get("error").is_some()
}
#[test]
fn add_comment_happy_path_returns_comment_id() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let out = handle_add_comment(&mut app, "foo.txt", 1, "new", "looks good", Some("note"));
let v = parse_json(&out);
assert!(v.get("error").is_none(), "expected success, got {out}");
assert!(v.get("comment_id").and_then(|s| s.as_str()).is_some());
assert_eq!(v["file"], "foo.txt");
assert_eq!(v["line"], 1);
}
#[test]
fn add_comment_rejects_unknown_side() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let out = handle_add_comment(&mut app, "foo.txt", 1, "LEFT", "body", None);
assert!(is_error_response(&out), "expected error, got {out}");
assert!(out.contains("Unknown side"), "unexpected error: {out}");
}
#[test]
fn add_comment_rejects_unknown_comment_type() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let out = handle_add_comment(
&mut app,
"foo.txt",
1,
"new",
"body",
Some("sugestion"), );
assert!(is_error_response(&out), "expected error, got {out}");
assert!(
out.contains("Unknown comment_type"),
"unexpected error: {out}"
);
}
#[test]
fn add_comment_rejects_unknown_file_path() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let out = handle_add_comment(&mut app, "missing.txt", 1, "new", "body", None);
assert!(is_error_response(&out), "expected error, got {out}");
assert!(
out.contains("File not in current diff"),
"unexpected error: {out}"
);
}
#[test]
fn add_comment_escapes_nasty_characters_in_error_payload() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let out = handle_add_comment(
&mut app,
"weird\"name\nwith\\breaks",
1,
"new",
"body",
None,
);
let v = parse_json(&out);
assert!(v.get("error").is_some());
}
#[test]
fn tour_set_plan_happy_path_two_stops() {
let mut app = build_test_app(vec![make_test_file("touched.txt")]);
let stops = vec![
TourStopRequest {
commit_ids: vec!["c1".to_string()],
summary: "first".to_string(),
},
TourStopRequest {
commit_ids: vec!["c2".to_string(), "c3".to_string()],
summary: "second batched".to_string(),
},
];
let out = handle_tour_set_plan(&mut app, stops);
let v = parse_json(&out);
assert!(v.get("error").is_none(), "expected success, got {out}");
assert_eq!(v["active"], true);
assert_eq!(v["total"], 2);
assert_eq!(v["index"], 0);
}
#[test]
fn tour_set_plan_rejects_empty_stops_list() {
let mut app = build_test_app(vec![make_test_file("touched.txt")]);
let out = handle_tour_set_plan(&mut app, Vec::new());
assert!(is_error_response(&out));
assert!(out.contains("at least one stop"), "unexpected: {out}");
}
#[test]
fn tour_set_plan_rejects_stop_with_zero_commits() {
let mut app = build_test_app(vec![make_test_file("touched.txt")]);
let stops = vec![TourStopRequest {
commit_ids: Vec::new(),
summary: "empty".to_string(),
}];
let out = handle_tour_set_plan(&mut app, stops);
assert!(is_error_response(&out));
assert!(out.contains("at least one commit_id"), "unexpected: {out}");
}
#[test]
fn tour_goto_valid_index_returns_ok() {
let mut app = build_test_app(vec![make_test_file("touched.txt")]);
handle_tour_set_plan(
&mut app,
vec![
TourStopRequest {
commit_ids: vec!["c1".to_string()],
summary: "a".to_string(),
},
TourStopRequest {
commit_ids: vec!["c2".to_string()],
summary: "b".to_string(),
},
],
);
let out = handle_tour_goto(&mut app, 1);
let v = parse_json(&out);
assert!(v.get("error").is_none(), "expected success, got {out}");
assert_eq!(v["index"], 1);
assert_eq!(v["active"], true);
}
#[test]
fn tour_goto_out_of_bounds_returns_error() {
let mut app = build_test_app(vec![make_test_file("touched.txt")]);
handle_tour_set_plan(
&mut app,
vec![TourStopRequest {
commit_ids: vec!["c1".to_string()],
summary: "a".to_string(),
}],
);
let out = handle_tour_goto(&mut app, 99);
assert!(is_error_response(&out), "expected error, got {out}");
}
fn setup_tour_with_comment() -> (App, String) {
let mut app = build_test_app(vec![make_test_file("touched.txt")]);
handle_tour_set_plan(
&mut app,
vec![TourStopRequest {
commit_ids: vec!["c1".to_string()],
summary: "s".to_string(),
}],
);
let add_out = handle_add_comment(&mut app, "touched.txt", 1, "new", "body", Some("note"));
let v = parse_json(&add_out);
let comment_id = v["comment_id"].as_str().unwrap().to_string();
(app, comment_id)
}
#[test]
fn tour_set_triage_live_verdict_happy_path() {
let (mut app, comment_id) = setup_tour_with_comment();
let out = handle_tour_set_triage(
&mut app,
&comment_id,
"live",
"still applies".to_string(),
None,
None,
);
let v = parse_json(&out);
assert!(v.get("error").is_none(), "expected success, got {out}");
assert_eq!(v["ok"], true);
assert_eq!(v["verdict"], "live");
}
#[test]
fn tour_set_triage_ignores_location_fields_for_non_moved_verdict() {
let (mut app, comment_id) = setup_tour_with_comment();
let out = handle_tour_set_triage(
&mut app,
&comment_id,
"live",
"still applies".to_string(),
Some("other.txt".to_string()),
Some(5),
);
assert!(!is_error_response(&out), "expected success, got {out}");
}
#[test]
fn tour_set_triage_moved_without_location_returns_error() {
let (mut app, comment_id) = setup_tour_with_comment();
let out = handle_tour_set_triage(
&mut app,
&comment_id,
"moved",
"code moved".to_string(),
None,
None,
);
assert!(is_error_response(&out), "expected error, got {out}");
assert!(
out.contains("Moved verdict requires both new_file and new_line"),
"unexpected error: {out}"
);
}
#[test]
fn tour_set_triage_moved_with_only_file_returns_error() {
let (mut app, comment_id) = setup_tour_with_comment();
let out = handle_tour_set_triage(
&mut app,
&comment_id,
"moved",
"code moved".to_string(),
Some("other.txt".to_string()),
None,
);
assert!(is_error_response(&out), "expected error, got {out}");
}
#[test]
fn tour_set_triage_moved_with_zero_line_returns_error() {
let (mut app, comment_id) = setup_tour_with_comment();
let out = handle_tour_set_triage(
&mut app,
&comment_id,
"moved",
"code moved".to_string(),
Some("other.txt".to_string()),
Some(0),
);
assert!(is_error_response(&out), "expected error, got {out}");
assert!(
out.contains("new_line must be >= 1"),
"unexpected error: {out}"
);
}
#[test]
fn tour_set_triage_unknown_verdict_returns_error() {
let (mut app, comment_id) = setup_tour_with_comment();
let out =
handle_tour_set_triage(&mut app, &comment_id, "bogus", "r".to_string(), None, None);
assert!(is_error_response(&out), "expected error, got {out}");
assert!(out.contains("Unknown verdict"), "unexpected error: {out}");
}
#[test]
fn mark_reviewed_unknown_file_path_returns_error() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let out = handle_mark_reviewed(&mut app, "ghost.txt");
assert!(is_error_response(&out), "expected error, got {out}");
assert!(
out.contains("File not in current diff"),
"unexpected error: {out}"
);
}
#[test]
fn mark_reviewed_happy_path() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let out = handle_mark_reviewed(&mut app, "foo.txt");
let v = parse_json(&out);
assert!(v.get("error").is_none(), "expected success, got {out}");
assert_eq!(v["ok"], true);
}
#[test]
fn tour_triage_verdict_from_id_valid_inputs() {
use travelagent_core::model::TourTriageVerdict;
let valid = [
("live", TourTriageVerdict::Live),
("likely_obsolete", TourTriageVerdict::LikelyObsolete),
("obsolete", TourTriageVerdict::LikelyObsolete),
("moved", TourTriageVerdict::Moved),
];
for (input, expected) in valid {
assert_eq!(
TourTriageVerdict::from_id(input),
Some(expected),
"input {input:?} should parse to {expected:?}"
);
}
}
#[test]
fn tour_triage_verdict_from_id_invalid_inputs() {
use travelagent_core::model::TourTriageVerdict;
for bad in ["", "LIVE", "Live", "unknown", "moved ", " moved", "maybe"] {
assert_eq!(
TourTriageVerdict::from_id(bad),
None,
"input {bad:?} should fail to parse"
);
}
}
#[test]
fn tour_get_threshold_without_tour_reports_inactive() {
let app = build_test_app(vec![make_test_file("touched.txt")]);
let out = handle_tour_get_threshold(&app);
let v = parse_json(&out);
assert_eq!(v["active"], false);
}
#[test]
fn tour_get_threshold_with_default_tour_is_custom_zero() {
let mut app = build_test_app(vec![make_test_file("touched.txt")]);
handle_tour_set_plan(
&mut app,
vec![TourStopRequest {
commit_ids: vec!["c1".to_string()],
summary: "only".to_string(),
}],
);
let out = handle_tour_get_threshold(&app);
let v = parse_json(&out);
assert_eq!(v["active"], true);
assert_eq!(v["threshold"], 0);
assert_eq!(v["preset"], "custom");
}
#[test]
fn tour_set_threshold_happy_path_returns_stop_count() {
let mut app = build_test_app(vec![make_test_file("touched.txt")]);
handle_tour_set_plan(
&mut app,
vec![
TourStopRequest {
commit_ids: vec!["c1".to_string()],
summary: "first".to_string(),
},
TourStopRequest {
commit_ids: vec!["c2".to_string()],
summary: "second".to_string(),
},
],
);
let out = handle_tour_set_threshold(&mut app, 5);
let v = parse_json(&out);
assert!(v.get("error").is_none(), "expected success, got {out}");
assert_eq!(v["ok"], true);
assert_eq!(v["threshold"], 5);
assert!(
v["stops"].as_u64().unwrap() >= 1,
"should return at least one stop"
);
}
#[test]
fn tour_set_threshold_rejects_out_of_range() {
let mut app = build_test_app(vec![make_test_file("touched.txt")]);
handle_tour_set_plan(
&mut app,
vec![TourStopRequest {
commit_ids: vec!["c1".to_string()],
summary: "only".to_string(),
}],
);
let out = handle_tour_set_threshold(&mut app, 99);
assert!(is_error_response(&out), "expected error, got {out}");
}
#[test]
fn tour_set_threshold_without_tour_errors() {
let mut app = build_test_app(vec![make_test_file("touched.txt")]);
let out = handle_tour_set_threshold(&mut app, 3);
assert!(is_error_response(&out), "expected error, got {out}");
}
#[test]
fn tour_get_commit_risk_returns_shape() {
let app = build_test_app(vec![make_test_file("touched.txt")]);
let out = handle_tour_get_commit_risk(&app, "abc123");
let v = parse_json(&out);
assert!(v.get("error").is_none(), "expected success, got {out}");
assert_eq!(v["sha"], "abc123");
assert!(v["risk"].is_number());
assert!(v["change_types"].is_array());
assert!(v["file_scores"].is_array());
}
#[test]
fn tour_get_stops_with_risk_without_tour_reports_inactive() {
let app = build_test_app(vec![make_test_file("touched.txt")]);
let out = handle_tour_get_stops_with_risk(&app);
let v = parse_json(&out);
assert_eq!(v["active"], false);
}
#[test]
fn tour_get_stops_with_risk_returns_stops_and_threshold() {
let mut app = build_test_app(vec![make_test_file("touched.txt")]);
handle_tour_set_plan(
&mut app,
vec![TourStopRequest {
commit_ids: vec!["c1".to_string()],
summary: "only".to_string(),
}],
);
let out = handle_tour_get_stops_with_risk(&app);
let v = parse_json(&out);
assert_eq!(v["active"], true);
assert_eq!(v["threshold"], 0);
let stops = v["stops"].as_array().expect("stops array");
assert_eq!(stops.len(), 1);
assert_eq!(stops[0]["summary"], "only");
assert!(stops[0]["risk"].is_number());
}
#[test]
fn error_json_escapes_special_characters() {
let msg = "quote\" back\\slash\nnewline и unicode";
let out = error_json(msg);
let v: serde_json::Value = serde_json::from_str(&out).expect("must be valid JSON");
assert_eq!(v["error"].as_str().unwrap(), msg);
}
static QUIT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn spawn_ok_responder() -> Sender<McpCommand> {
let (tx, rx) = mpsc::channel::<McpCommand>();
thread::spawn(move || {
while let Ok(cmd) = rx.recv() {
let _ = cmd.reply.send(r#"{"ok":true}"#.to_string());
}
});
tx
}
#[test]
fn request_quit_default_returns_gate_error() {
let _lock = QUIT_ENV_LOCK.lock().unwrap();
unsafe {
std::env::remove_var("TRV_ALLOW_AGENT_QUIT");
}
let tx = spawn_ok_responder();
let server = McpBridgeServer::new_for_test(tx);
let out = server.trv_request_quit(Parameters(EmptyRequest {}));
let v = parse_json(&out);
assert!(v.get("error").is_some(), "expected gate error, got {out}");
let msg = v["error"].as_str().unwrap();
assert!(
msg.contains("Quit must be initiated by the human in the TUI"),
"unexpected error text: {msg}"
);
assert!(
msg.contains("TRV_ALLOW_AGENT_QUIT=1"),
"error should mention the override env var: {msg}"
);
}
#[test]
fn request_quit_when_gate_env_enabled_forwards_to_tui() {
for value in ["1", "true", "yes", "YES", "True", " yes "] {
let _lock = QUIT_ENV_LOCK.lock().unwrap();
unsafe {
std::env::set_var("TRV_ALLOW_AGENT_QUIT", value);
}
let tx = spawn_ok_responder();
let server = McpBridgeServer::new_for_test(tx);
let out = server.trv_request_quit(Parameters(EmptyRequest {}));
let v = parse_json(&out);
assert!(
v.get("error").is_none(),
"value {value:?} should enable quit, got {out}"
);
assert_eq!(v["ok"], true, "value {value:?} should forward and succeed");
unsafe {
std::env::remove_var("TRV_ALLOW_AGENT_QUIT");
}
}
}
#[test]
fn add_comment_accepts_body_at_exactly_the_cap() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let body = "x".repeat(travelagent_core::mcp_limits::MCP_COMMENT_BODY_MAX);
let out = handle_add_comment(&mut app, "foo.txt", 1, "new", &body, Some("note"));
let v = parse_json(&out);
assert!(v.get("error").is_none(), "expected success, got {out}");
assert!(v.get("comment_id").and_then(|s| s.as_str()).is_some());
}
#[test]
fn add_comment_rejects_body_one_byte_over_limit() {
let tx = spawn_ok_responder();
let server = McpBridgeServer::new_for_test(tx);
let body = "x".repeat(travelagent_core::mcp_limits::MCP_COMMENT_BODY_MAX + 1);
let out = server.trv_add_comment(Parameters(AddCommentRequest {
file: "foo.txt".to_string(),
line: 1,
side: "new".to_string(),
body,
comment_type: None,
}));
let v = parse_json(&out);
let err = v
.get("error")
.and_then(|s| s.as_str())
.expect("expected error payload");
assert!(err.contains("Input exceeds comment body size limit"));
assert!(err.contains(&travelagent_core::mcp_limits::MCP_COMMENT_BODY_MAX.to_string()));
assert!(
err.contains(&(travelagent_core::mcp_limits::MCP_COMMENT_BODY_MAX + 1).to_string())
);
}
#[test]
fn add_comment_rejects_when_tour_tag_would_push_over_limit() {
use travelagent_core::model::{TourState, TourStop};
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let stop = TourStop {
commit_ids: vec!["deadbee1234567".to_string()],
summary: String::new(),
..Default::default()
};
app.tour.plan = Some(TourState::new(vec![stop]));
let body = "x".repeat(travelagent_core::mcp_limits::MCP_COMMENT_BODY_MAX);
let out = handle_add_comment(&mut app, "foo.txt", 1, "new", &body, Some("note"));
let v = parse_json(&out);
let err = v
.get("error")
.and_then(|s| s.as_str())
.expect("tagged body over cap must surface an error");
assert!(
err.contains("Input exceeds") && err.contains("comment body"),
"expected size-limit error, got {err}"
);
}
#[test]
fn add_comment_rejects_when_raw_body_over_limit_preserves_existing_error() {
let tx = spawn_ok_responder();
let server = McpBridgeServer::new_for_test(tx);
let body = "x".repeat(travelagent_core::mcp_limits::MCP_COMMENT_BODY_MAX + 1);
let out = server.trv_add_comment(Parameters(AddCommentRequest {
file: "foo.txt".to_string(),
line: 1,
side: "new".to_string(),
body,
comment_type: None,
}));
let v = parse_json(&out);
let err = v
.get("error")
.and_then(|s| s.as_str())
.expect("raw-body-over-limit must still be rejected");
assert!(err.contains("Input exceeds comment body size limit"));
}
#[test]
fn breadcrumb_select_file_uses_basename() {
let msg = agent_action_breadcrumb(
&McpAction::SelectFile {
file: "crates/foo/src/bar.rs".to_string(),
},
false,
)
.expect("state-mutating action should produce a breadcrumb");
assert!(msg.starts_with("\u{1f916} agent: jumped to "));
assert!(msg.ends_with(" bar.rs"), "expected basename, got {msg:?}");
assert!(!msg.contains("crates/foo/src"));
}
#[test]
fn breadcrumb_select_file_when_pinned_says_pointing() {
let msg = agent_action_breadcrumb(
&McpAction::SelectFile {
file: "a/b/c.rs".to_string(),
},
true,
)
.expect("still produces a breadcrumb, just with different wording");
assert!(
msg.contains("pointing at"),
"pinned breadcrumb should say 'pointing at', got {msg:?}"
);
assert!(msg.contains("c.rs"));
assert!(
msg.contains("Ctrl+G"),
"breadcrumb should hint at the follow key, got {msg:?}"
);
}
#[test]
fn breadcrumb_add_comment_includes_file_and_line() {
let msg = agent_action_breadcrumb(
&McpAction::AddComment {
file: "a/b/c.rs".to_string(),
line: 42,
side: "new".to_string(),
body: "nit".to_string(),
comment_type: None,
},
false,
)
.expect("add_comment should produce a breadcrumb");
assert!(msg.contains("commented on"));
assert!(msg.contains("c.rs"));
assert!(msg.contains("42"));
}
#[test]
fn breadcrumb_mark_reviewed_uses_basename() {
let msg = agent_action_breadcrumb(
&McpAction::MarkReviewed {
file: "nested/dir/file.rs".to_string(),
},
false,
)
.expect("mark_reviewed should produce a breadcrumb");
assert!(msg.contains("marked"));
assert!(msg.contains("file.rs"));
assert!(msg.contains("reviewed"));
}
#[cfg(windows)]
#[test]
fn breadcrumb_select_file_windows_backslash_uses_basename() {
let msg = agent_action_breadcrumb(
&McpAction::SelectFile {
file: "src\\foo\\bar.rs".to_string(),
},
false,
)
.expect("select_file should produce a breadcrumb on windows paths too");
assert!(msg.ends_with(" bar.rs"), "expected basename, got {msg:?}");
assert!(!msg.contains("src\\foo"));
}
#[test]
fn breadcrumb_tour_variants_all_produce_messages() {
assert!(agent_action_breadcrumb(&McpAction::TourNext, false).is_some());
assert!(agent_action_breadcrumb(&McpAction::TourPrev, false).is_some());
assert!(agent_action_breadcrumb(&McpAction::TourEnd, false).is_some());
assert!(agent_action_breadcrumb(&McpAction::TourGoto { index: 2 }, false).is_some());
assert!(
agent_action_breadcrumb(&McpAction::TourSetPlan { stops: vec![] }, false).is_some()
);
}
#[test]
fn breadcrumb_read_only_tools_skip_flash() {
assert!(agent_action_breadcrumb(&McpAction::ListFiles, false).is_none());
assert!(
agent_action_breadcrumb(
&McpAction::GetDiff {
file: "x".to_string()
},
false,
)
.is_none()
);
assert!(agent_action_breadcrumb(&McpAction::GetReviewStatus, false).is_none());
assert!(
agent_action_breadcrumb(
&McpAction::GetComments {
file: None,
since: None
},
false,
)
.is_none()
);
assert!(agent_action_breadcrumb(&McpAction::GetPrMetadata, false).is_none());
assert!(agent_action_breadcrumb(&McpAction::GetCommits, false).is_none());
assert!(agent_action_breadcrumb(&McpAction::TourStatus, false).is_none());
assert!(agent_action_breadcrumb(&McpAction::TourGetThreshold, false).is_none());
assert!(agent_action_breadcrumb(&McpAction::GetAiSummary, false).is_none());
}
#[test]
fn process_mcp_command_select_file_sets_flash_on_app() {
let mut app = build_test_app(vec![make_test_file("touched.txt")]);
let (reply_tx, reply_rx) = mpsc::channel();
let cmd = McpCommand {
action: McpAction::SelectFile {
file: "touched.txt".to_string(),
},
reply: reply_tx,
};
process_mcp_command(&mut app, cmd);
let reply = reply_rx.recv().expect("handler must reply");
assert!(!is_error_response(&reply), "unexpected error: {reply}");
let flash_text = app
.current_flash_text()
.expect("state-mutating action should set a flash");
assert!(
flash_text.starts_with("\u{1f916} agent: "),
"flash should have robot prefix: {flash_text:?}"
);
assert!(flash_text.contains("jumped to"));
}
#[test]
fn process_mcp_command_read_only_leaves_flash_empty() {
let mut app = build_test_app(vec![make_test_file("touched.txt")]);
assert!(app.current_flash_text().is_none(), "precondition");
let (reply_tx, _reply_rx) = mpsc::channel();
let cmd = McpCommand {
action: McpAction::GetReviewStatus,
reply: reply_tx,
};
process_mcp_command(&mut app, cmd);
assert!(
app.current_flash_text().is_none(),
"read-only tool must not set a flash"
);
}
#[test]
fn set_flash_message_expires_after_ttl() {
let mut app = build_test_app(vec![make_test_file("touched.txt")]);
app.set_flash_message("test flash", std::time::Duration::from_millis(50));
assert_eq!(app.current_flash_text(), Some("test flash"));
std::thread::sleep(std::time::Duration::from_millis(60));
assert!(
app.current_flash_text().is_none(),
"flash should have expired after its TTL"
);
}
#[test]
fn agent_quit_allowed_rejects_unrelated_values() {
let _lock = QUIT_ENV_LOCK.lock().unwrap();
for value in ["0", "false", "no", "", "on", "y", "maybe"] {
unsafe {
std::env::set_var("TRV_ALLOW_AGENT_QUIT", value);
}
assert!(
!agent_quit_allowed(),
"value {value:?} must NOT enable agent quit"
);
}
unsafe {
std::env::remove_var("TRV_ALLOW_AGENT_QUIT");
}
assert!(
!agent_quit_allowed(),
"unset env var must NOT enable agent quit"
);
}
fn register_session_file(app: &mut App, path: &str) {
app.engine
.session_mut()
.add_file(PathBuf::from(path), FileStatus::Modified);
}
fn add_line_comment_with_time(
app: &mut App,
path: &str,
line: u32,
body: &str,
created_at: chrono::DateTime<chrono::Utc>,
) {
use travelagent_core::model::{AnchorState, Comment, CommentType, LineSide};
let mut c = Comment::new(body.to_string(), CommentType::Note, Some(LineSide::New));
c.created_at = created_at;
c.anchor = Some(AnchorState::Anchored {
line,
side: LineSide::New,
reanchored_at: None,
});
app.engine
.seed_anchored_line_comment(PathBuf::from(path), FileStatus::Modified, line, c);
}
fn add_orphaned_comment_with_time(
app: &mut App,
path: &str,
was_line: u32,
body: &str,
created_at: chrono::DateTime<chrono::Utc>,
orphaned_at: chrono::DateTime<chrono::Utc>,
) {
use travelagent_core::model::{AnchorState, Comment, CommentType, LineSide};
let mut c = Comment::new(body.to_string(), CommentType::Note, Some(LineSide::New));
c.created_at = created_at;
c.anchor = Some(AnchorState::Orphaned {
was_line,
was_side: LineSide::New,
last_seen_content: "the old line".to_string(),
orphaned_at: Some(orphaned_at),
});
app.engine
.seed_orphaned_comment(PathBuf::from(path), FileStatus::Modified, c);
}
#[test]
fn get_comments_legacy_shape_when_since_absent() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let now = chrono::Utc::now();
add_line_comment_with_time(&mut app, "foo.txt", 3, "hi", now);
let out = handle_get_comments(&app, None, None);
let v = parse_json(&out);
assert!(v.is_array(), "legacy shape must be a plain array");
assert_eq!(v.as_array().unwrap().len(), 1);
}
#[test]
fn get_comments_with_since_returns_wrapped_envelope_with_server_time() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let now = chrono::Utc::now();
add_line_comment_with_time(&mut app, "foo.txt", 3, "recent", now);
let out = handle_get_comments(&app, None, Some(0));
let v = parse_json(&out);
assert!(
v.get("comments").is_some() && v.get("server_time").is_some(),
"with since, response must be wrapped envelope, got {out}"
);
assert!(v["comments"].is_array());
assert_eq!(v["comments"].as_array().unwrap().len(), 1);
assert!(
v["server_time"].is_i64(),
"server_time must be i64 unix-ms, got {}",
v["server_time"]
);
}
#[test]
fn get_comments_filters_out_older_than_since() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let cutoff = chrono::Utc::now();
let earlier = cutoff - chrono::Duration::seconds(10);
let later = cutoff + chrono::Duration::seconds(10);
add_line_comment_with_time(&mut app, "foo.txt", 1, "before", earlier);
add_line_comment_with_time(&mut app, "foo.txt", 2, "after", later);
let out = handle_get_comments(&app, None, Some(cutoff.timestamp_millis()));
let v = parse_json(&out);
let bodies: Vec<&str> = v["comments"]
.as_array()
.unwrap()
.iter()
.map(|c| c["body"].as_str().unwrap())
.collect();
assert_eq!(bodies, vec!["after"], "old comment must be excluded");
}
#[test]
fn get_comments_since_includes_newly_orphaned_even_if_created_earlier() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let cutoff = chrono::Utc::now();
let long_ago = cutoff - chrono::Duration::hours(1);
let just_now = cutoff + chrono::Duration::seconds(1);
add_orphaned_comment_with_time(&mut app, "foo.txt", 5, "stale", long_ago, just_now);
let out = handle_get_comments(&app, None, Some(cutoff.timestamp_millis()));
let v = parse_json(&out);
let comments = v["comments"].as_array().unwrap();
assert_eq!(comments.len(), 1, "newly-orphaned must be included: {out}");
assert_eq!(comments[0]["scope"], "orphaned");
assert_eq!(comments[0]["was_line"], 5);
assert_eq!(comments[0]["was_side"], "new");
assert_eq!(comments[0]["last_seen_content"], "the old line");
assert!(comments[0]["orphaned_at"].is_string());
}
#[test]
fn get_comments_rejects_invalid_since_with_error() {
let app = build_test_app(vec![make_test_file("foo.txt")]);
let out = handle_get_comments(&app, None, Some(i64::MAX));
let v = parse_json(&out);
assert!(
v.get("error").is_some(),
"bogus cursor must surface an error, got {out}"
);
}
fn add_reanchored_comment_with_time(
app: &mut App,
path: &str,
line: u32,
body: &str,
created_at: chrono::DateTime<chrono::Utc>,
reanchored_at: chrono::DateTime<chrono::Utc>,
) {
use travelagent_core::model::{AnchorState, Comment, CommentType, LineSide};
let mut c = Comment::new(body.to_string(), CommentType::Note, Some(LineSide::New));
c.created_at = created_at;
c.anchor = Some(AnchorState::Anchored {
line,
side: LineSide::New,
reanchored_at: Some(reanchored_at),
});
app.engine
.seed_anchored_line_comment(PathBuf::from(path), FileStatus::Modified, line, c);
}
#[test]
fn get_comments_since_includes_reanchored_even_if_created_earlier() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let cutoff = chrono::Utc::now();
let long_ago = cutoff - chrono::Duration::hours(1);
let just_now = cutoff + chrono::Duration::seconds(1);
add_reanchored_comment_with_time(&mut app, "foo.txt", 4, "recovered", long_ago, just_now);
let out = handle_get_comments(&app, None, Some(cutoff.timestamp_millis()));
let v = parse_json(&out);
let comments = v["comments"].as_array().unwrap();
assert_eq!(comments.len(), 1, "re-anchored must be included: {out}");
assert_eq!(comments[0]["scope"], "line");
assert_eq!(comments[0]["body"], "recovered");
}
#[test]
fn get_comments_emits_orphan_bucket_when_since_absent() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let now = chrono::Utc::now();
add_orphaned_comment_with_time(&mut app, "foo.txt", 7, "gone", now, now);
let out = handle_get_comments(&app, None, None);
let v = parse_json(&out);
let arr = v.as_array().expect("legacy array shape");
assert_eq!(arr.len(), 1, "orphan must be emitted in legacy shape too");
assert_eq!(arr[0]["scope"], "orphaned");
}
#[test]
fn get_comments_since_excludes_anchorless_comment_older_than_cutoff() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let cutoff = chrono::Utc::now();
let long_ago = cutoff - chrono::Duration::hours(1);
use travelagent_core::model::{Comment, CommentType, LineSide};
let mut c = Comment::new("legacy".to_string(), CommentType::Note, Some(LineSide::New));
c.created_at = long_ago;
c.anchor = None; register_session_file(&mut app, "foo.txt");
let review = app
.engine
.session_mut()
.get_file_mut(&PathBuf::from("foo.txt"))
.expect("file present");
review.add_line_comment(1, c);
let out = handle_get_comments(&app, None, Some(cutoff.timestamp_millis()));
let v = parse_json(&out);
let comments = v["comments"].as_array().unwrap();
assert!(
comments.is_empty(),
"legacy anchorless comment created before cutoff must be excluded: {out}"
);
}
#[test]
fn get_comments_server_time_is_snapshotted_before_iteration() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let before_call = chrono::Utc::now();
add_line_comment_with_time(&mut app, "foo.txt", 1, "c", before_call);
let out = handle_get_comments(&app, None, Some(0));
let after_call = chrono::Utc::now();
let v = parse_json(&out);
let server_time = v["server_time"].as_i64().expect("i64 server_time");
assert!(
server_time >= before_call.timestamp_millis()
&& server_time <= after_call.timestamp_millis(),
"server_time {server_time} must fall within [{}, {}]",
before_call.timestamp_millis(),
after_call.timestamp_millis(),
);
}
#[test]
fn list_tour_comments_surfaces_orphaned_comment_body_and_type() {
use travelagent_core::model::tour::TourCommentMeta;
use travelagent_core::model::{AnchorState, Comment, CommentType, LineSide};
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let mut comment = Comment::new(
"flagged during tour".to_string(),
CommentType::Issue,
Some(LineSide::New),
);
let comment_id = comment.id.clone();
comment.anchor = Some(AnchorState::Orphaned {
was_line: 5,
was_side: LineSide::New,
last_seen_content: "gone".to_string(),
orphaned_at: Some(chrono::Utc::now()),
});
app.engine
.seed_orphaned_comment(PathBuf::from("foo.txt"), FileStatus::Modified, comment);
app.tour.comment_meta.insert(
comment_id.clone(),
TourCommentMeta {
stop_index: 0,
stop_commit_shas: vec!["sha123".to_string()],
file: "foo.txt".to_string(),
line: 5,
},
);
let out = handle_tour_list_tour_comments(&app);
let v = parse_json(&out);
let arr = v.as_array().expect("top-level array");
assert_eq!(arr.len(), 1, "one tour comment must be emitted: {out}");
assert_eq!(arr[0]["comment_id"], comment_id);
assert_eq!(
arr[0]["body"], "flagged during tour",
"body must survive the orphan transition, got {out}"
);
assert_eq!(
arr[0]["type"], "issue",
"type must survive the orphan transition, got {out}"
);
}
#[test]
fn capture_view_reports_core_session_shape() {
let app = build_test_app(vec![make_test_file("foo.txt")]);
let v = parse_json(&handle_capture_view(&app));
assert_eq!(v["mode"], "local");
assert_eq!(v["diff_view"], "unified");
assert_eq!(v["input_mode"], "normal");
let current = v["current_file"].as_object().expect("current_file object");
assert_eq!(current["path"], "foo.txt");
assert_eq!(current["index"], 0);
assert_eq!(current["reviewed"], false);
let session = v["session"].as_object().expect("session object");
assert_eq!(session["files_total"], 1);
assert_eq!(session["files_reviewed"], 0);
assert_eq!(session["comments_count"], 0);
let ai = v["ai_summary"].as_object().expect("ai_summary object");
assert_eq!(ai["present"], false);
assert!(ai["updated_at"].is_null());
}
#[test]
fn capture_view_read_only_leaves_no_flash() {
let app = build_test_app(vec![make_test_file("foo.txt")]);
assert!(app.current_flash_text().is_none(), "precondition");
let _ = handle_capture_view(&app);
assert!(
app.current_flash_text().is_none(),
"capture_view is read-only — it must not set a flash"
);
let none = agent_action_breadcrumb(&McpAction::CaptureView, false);
assert!(
none.is_none(),
"CaptureView must be classified as read-only in agent_action_breadcrumb"
);
}
#[test]
fn capture_view_cursor_is_null_when_on_header_row() {
let app = build_test_app(vec![make_test_file("foo.txt")]);
let v = parse_json(&handle_capture_view(&app));
assert!(
v["cursor"].is_null(),
"cursor on header row must be null, got {v:?}"
);
}
#[test]
fn capture_view_cursor_resolves_file_line_on_diff_row() {
use crate::app::AnnotatedLine;
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let diff_row = app
.line_annotations
.iter()
.position(|a| matches!(a, AnnotatedLine::DiffLine { .. }))
.expect("test fixture must render at least one diff line");
app.diff_state.cursor_line = diff_row;
let v = parse_json(&handle_capture_view(&app));
let cursor = v["cursor"].as_object().expect("cursor must resolve");
assert_eq!(cursor["file"], "foo.txt");
assert_eq!(cursor["new_line"], 1, "addition on line 1 per fixture");
assert_eq!(cursor["side"], "new");
}
#[test]
fn capture_view_visible_range_respects_viewport() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
app.diff_state.viewport_height = 10;
app.diff_state.scroll_offset = 2;
let v = parse_json(&handle_capture_view(&app));
let vis = v["visible"].as_object().expect("visible object");
assert_eq!(vis["first_logical_line"], 2);
assert_eq!(vis["viewport_height"], 10);
let last = vis["last_logical_line"].as_u64().expect("u64");
let first = vis["first_logical_line"].as_u64().expect("u64");
assert!(
last >= first,
"last must be >= first, got {last} >= {first}"
);
}
#[test]
fn select_file_in_follow_mode_moves_cursor() {
let mut app = build_test_app(vec![
make_test_file("a.txt"),
make_test_file("b.txt"),
make_test_file("c.txt"),
]);
assert!(!app.viewport_pinned);
let out = handle_select_file(&mut app, "c.txt");
let v = parse_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["pinned"], false);
assert_eq!(
app.diff_state.current_file_idx, 2,
"follow-mode select should jump the cursor"
);
assert!(
app.agent_ghost.is_none(),
"follow-mode select should NOT set a ghost"
);
}
#[test]
fn select_file_when_pinned_records_ghost_not_cursor() {
let mut app = build_test_app(vec![
make_test_file("a.txt"),
make_test_file("b.txt"),
make_test_file("c.txt"),
]);
app.viewport_pinned = true;
let before_idx = app.diff_state.current_file_idx;
let out = handle_select_file(&mut app, "c.txt");
let v = parse_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["pinned"], true);
assert_eq!(
app.diff_state.current_file_idx, before_idx,
"pinned select must NOT move the cursor"
);
let ghost = app.agent_ghost.as_ref().expect("ghost must be recorded");
assert_eq!(ghost.file_idx, 2);
assert_eq!(ghost.path, "c.txt");
}
#[test]
fn select_file_when_pinned_produces_pointing_breadcrumb() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
app.viewport_pinned = true;
let (reply_tx, reply_rx) = mpsc::channel();
let cmd = McpCommand {
action: McpAction::SelectFile {
file: "foo.txt".to_string(),
},
reply: reply_tx,
};
process_mcp_command(&mut app, cmd);
let _ = reply_rx.recv().expect("reply");
let flash = app
.current_flash_text()
.expect("pinned SelectFile must still flash — just with different wording");
assert!(
flash.contains("pointing at"),
"pinned breadcrumb should say 'pointing at', got {flash:?}"
);
assert!(flash.contains("Ctrl+G"));
}
#[test]
fn capture_view_exposes_pin_and_ghost() {
let mut app = build_test_app(vec![make_test_file("a.txt"), make_test_file("b.txt")]);
app.viewport_pinned = true;
app.record_agent_ghost(1);
let v = parse_json(&handle_capture_view(&app));
let co = v["cursor_ownership"].as_object().expect("cursor_ownership");
assert_eq!(co["viewport_pinned"], true);
let ghost = co["agent_ghost"].as_object().expect("ghost object");
assert_eq!(ghost["file_idx"], 1);
assert_eq!(ghost["path"], "b.txt");
}
#[test]
fn capture_view_ghost_null_when_follow_mode() {
let app = build_test_app(vec![make_test_file("foo.txt")]);
let v = parse_json(&handle_capture_view(&app));
let co = v["cursor_ownership"].as_object().expect("cursor_ownership");
assert_eq!(co["viewport_pinned"], false);
assert!(co["agent_ghost"].is_null());
}
#[test]
fn notify_method_suffix_is_stable_wire_name() {
use crate::app::McpNotify;
assert_eq!(
McpNotify::FileChanged { files: vec![] }.method_suffix(),
"file_changed"
);
assert_eq!(
McpNotify::CommentAdded {
file: "x".into(),
line: None,
author: "human"
}
.method_suffix(),
"comment_added"
);
assert_eq!(
McpNotify::ReviewSubmitted {
verdict: None,
at: "2026-04-30T00:00:00Z".into()
}
.method_suffix(),
"review_submitted"
);
}
#[test]
fn notify_to_params_file_changed_uses_files_array() {
use crate::app::McpNotify;
let v = notify_to_params(&McpNotify::FileChanged {
files: vec!["a.rs".to_string(), "b.rs".to_string()],
});
assert_eq!(v["files"].as_array().expect("array").len(), 2);
assert_eq!(v["files"][0], "a.rs");
}
#[test]
fn notify_to_params_comment_added_carries_author_tag() {
use crate::app::McpNotify;
let v = notify_to_params(&McpNotify::CommentAdded {
file: "foo.txt".to_string(),
line: Some(42),
author: "agent",
});
assert_eq!(v["file"], "foo.txt");
assert_eq!(v["line"], 42);
assert_eq!(
v["author"], "agent",
"author must be pinned verbatim so agents can dedupe their own echoes"
);
}
#[test]
fn notify_to_params_review_submitted_null_verdict_for_local_export() {
use crate::app::McpNotify;
let v = notify_to_params(&McpNotify::ReviewSubmitted {
verdict: None,
at: "2026-04-30T12:00:00Z".to_string(),
});
assert!(
v["verdict"].is_null(),
"local export must carry null verdict so agents can distinguish it from a forge submit"
);
assert_eq!(v["at"], "2026-04-30T12:00:00Z");
}
#[test]
fn hangup_wire_shape_is_event_only_with_deadline_and_reason() {
use crate::app::McpNotify;
let n = McpNotify::Hangup {
deadline_ms: 5000,
reason: "user requested :mcp-off".into(),
};
assert_eq!(n.method_suffix(), "hangup");
let v = notify_to_params(&n);
assert_eq!(v["deadline_ms"], 5000);
assert_eq!(v["reason"], "user requested :mcp-off");
assert!(
resource_uris_for(&n).is_empty(),
"hang-up must not fan out a resources/updated — it's a pure event"
);
}
#[test]
fn tour_request_wire_shape_carries_commit_ids_and_is_event_only() {
use crate::app::McpNotify;
let n = McpNotify::TourRequest {
commit_ids: vec!["aaa1111".to_string(), "bbb2222".to_string()],
};
assert_eq!(n.method_suffix(), "tour_request");
let v = notify_to_params(&n);
let ids = v["commit_ids"].as_array().expect("commit_ids array");
assert_eq!(ids.len(), 2);
assert_eq!(ids[0], "aaa1111");
assert_eq!(ids[1], "bbb2222");
assert!(
resource_uris_for(&n).is_empty(),
"tour request must not fan out a resources/updated — it's a pure event"
);
}
#[test]
fn handle_add_comment_queues_agent_authored_notification() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
assert!(app.notify_queue.is_empty());
let out = handle_add_comment(&mut app, "foo.txt", 1, "new", "looks good", Some("note"));
assert!(
parse_json(&out).get("comment_id").is_some(),
"precondition: add succeeded"
);
let notify = app.notify_queue.pop_front().expect("must queue notify");
match notify {
crate::app::McpNotify::CommentAdded { file, line, author } => {
assert_eq!(file, "foo.txt");
assert_eq!(line, Some(1));
assert_eq!(author, "agent");
}
other => panic!("expected CommentAdded, got {other:?}"),
}
}
#[test]
fn push_notify_bounded_by_cap() {
use crate::app::{MCP_NOTIFY_QUEUE_CAP, McpNotify};
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
for i in 0..MCP_NOTIFY_QUEUE_CAP {
app.push_notify(McpNotify::FileChanged {
files: vec![format!("gen-{i}.txt")],
});
}
assert_eq!(app.notify_queue.len(), MCP_NOTIFY_QUEUE_CAP);
app.push_notify(McpNotify::FileChanged {
files: vec!["overflow.txt".to_string()],
});
assert_eq!(app.notify_queue.len(), MCP_NOTIFY_QUEUE_CAP);
let last = app.notify_queue.back().expect("non-empty");
match last {
McpNotify::FileChanged { files } => assert_eq!(files[0], "overflow.txt"),
other => panic!("expected FileChanged, got {other:?}"),
}
let first = app.notify_queue.front().expect("non-empty");
match first {
McpNotify::FileChanged { files } => {
assert_eq!(files[0], "gen-1.txt");
}
other => panic!("expected FileChanged, got {other:?}"),
}
}
#[test]
fn capture_view_visible_clamps_over_scroll() {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
app.diff_state.viewport_height = 5;
app.diff_state.scroll_offset = 9999;
let v = parse_json(&handle_capture_view(&app));
let vis = v["visible"].as_object().expect("visible object");
let first = vis["first_logical_line"].as_u64().expect("u64");
let last = vis["last_logical_line"].as_u64().expect("u64");
let total = vis["total_lines"].as_u64().expect("u64");
assert!(total > 0, "fixture must have some lines");
assert!(
first < total,
"first must be clamped to < total: {first} < {total}"
);
assert!(last >= first, "last must be >= first after clamp");
}
fn make_context_line_file(path: &str) -> DiffFile {
DiffFile {
old_path: Some(PathBuf::from(path)),
new_path: Some(PathBuf::from(path)),
status: FileStatus::Modified,
hunks: vec![DiffHunk {
header: "@@ -5,1 +5,1 @@".to_string(),
lines: vec![DiffLine {
origin: LineOrigin::Context,
content: "context".to_string(),
old_lineno: Some(5),
new_lineno: Some(5),
highlighted_spans: None,
}],
old_start: 5,
old_count: 1,
new_start: 5,
new_count: 1,
}],
is_binary: false,
is_too_large: false,
is_commit_message: false,
}
}
#[test]
fn sbs_context_line_annotation_includes_old_side_comment_row() {
use crate::app::{AnnotatedLine, DiffViewMode};
use travelagent_core::model::{Comment, CommentType, LineSide};
let mut app = build_test_app(vec![make_context_line_file("foo.txt")]);
app.nav.diff_view_mode = DiffViewMode::SideBySide;
let comment = Comment::new(
"old-side thought".to_string(),
CommentType::Note,
Some(LineSide::Old),
);
app.engine
.try_add_line_comment(&std::path::PathBuf::from("foo.txt"), 5, comment);
app.rebuild_annotations();
let old_side_count = app
.line_annotations
.iter()
.filter(|a| {
matches!(
a,
AnnotatedLine::LineComment {
line: 5,
side: LineSide::Old,
..
}
)
})
.count();
assert!(
old_side_count > 0,
"SBS context-line annotation must include Old-side comment rows; annotations: {:?}",
app.line_annotations
);
assert_eq!(
app.line_annotations.len(),
app.total_lines(),
"annotation count must match total_lines estimate"
);
}
#[test]
fn reconcile_agent_ghost_keeps_stable_pair() {
let mut app = build_test_app(vec![make_test_file("a.txt"), make_test_file("b.txt")]);
app.record_agent_ghost(1);
app.reconcile_agent_ghost();
let ghost = app.agent_ghost.as_ref().expect("ghost preserved");
assert_eq!(ghost.file_idx, 1);
assert_eq!(ghost.path, "b.txt");
}
#[test]
fn reconcile_agent_ghost_rebinds_when_path_moved_indices() {
let mut app = build_test_app(vec![make_test_file("a.txt"), make_test_file("b.txt")]);
app.record_agent_ghost(1);
app.diff_files.remove(0);
app.reconcile_agent_ghost();
let ghost = app
.agent_ghost
.as_ref()
.expect("ghost rebinds, not cleared");
assert_eq!(ghost.file_idx, 0, "index rebound to new slot");
assert_eq!(ghost.path, "b.txt", "path preserved");
}
#[test]
fn reconcile_agent_ghost_clears_when_path_gone() {
let mut app = build_test_app(vec![make_test_file("a.txt"), make_test_file("b.txt")]);
app.record_agent_ghost(1);
app.diff_files.remove(1);
app.reconcile_agent_ghost();
assert!(
app.agent_ghost.is_none(),
"ghost must clear when path is no longer in diff"
);
}
use rmcp::model::CallToolRequestParams;
use rmcp::model::{ClientCapabilities, ClientInfo, Implementation};
use rmcp::service::NotificationContext;
use rmcp::{ClientHandler, RoleClient};
use std::sync::Arc;
use tokio::sync::{Mutex as AsyncMutexClient, Notify};
type RecordedNotifications = Vec<(String, Option<serde_json::Value>)>;
#[derive(Clone, Default)]
struct RecordingClient {
inner: Arc<AsyncMutexClient<RecordedNotifications>>,
signal: Arc<Notify>,
}
impl ClientHandler for RecordingClient {
async fn on_custom_notification(
&self,
notification: rmcp::model::CustomNotification,
_context: NotificationContext<RoleClient>,
) {
let rmcp::model::CustomNotification { method, params, .. } = notification;
self.inner.lock().await.push((method, params));
self.signal.notify_one();
}
async fn on_resource_updated(
&self,
params: rmcp::model::ResourceUpdatedNotificationParam,
_context: NotificationContext<RoleClient>,
) {
let uri = params.uri;
self.inner.lock().await.push((
"notifications/resources/updated".to_string(),
Some(serde_json::json!({ "uri": uri })),
));
self.signal.notify_one();
}
fn get_info(&self) -> ClientInfo {
let mut info = ClientInfo::default();
info.capabilities = ClientCapabilities::default();
info.client_info = Implementation::from_build_env();
info
}
}
async fn spawn_bridge_under_test(
files: Vec<DiffFile>,
) -> (
rmcp::service::RunningService<RoleClient, RecordingClient>,
RecordingClient,
) {
let (server_side, client_side) = tokio::io::duplex(8192);
let runtime = crate::test_support::runtime_handle();
let hub = McpHub::start(&runtime);
let registry = hub.registry.clone();
let notify_tx = hub.notify_tx.clone();
let (cmd_tx, cmd_rx) = std::sync::mpsc::channel::<McpCommand>();
let connection_id = registry.allocate_id();
let server = McpBridgeServer::new(cmd_tx, registry.clone(), connection_id);
runtime.spawn(async move {
if let Ok(svc) = server.serve(server_side).await {
let _ = svc.waiting().await;
}
registry.remove(connection_id).await;
});
std::thread::spawn(move || {
let mut app = build_test_app(files);
while let Ok(cmd) = cmd_rx.recv() {
process_mcp_command(&mut app, cmd);
while let Some(notify) = app.notify_queue.pop_front() {
if notify_tx.blocking_send(notify).is_err() {
return;
}
}
}
});
let recording = RecordingClient::default();
let client = recording
.clone()
.serve(client_side)
.await
.expect("client connect + init handshake");
(client, recording)
}
async fn call_tool_json(
client: &rmcp::service::RunningService<RoleClient, RecordingClient>,
name: &str,
args: serde_json::Value,
) -> serde_json::Value {
let mut params = CallToolRequestParams::default();
params.name = name.to_string().into();
params.arguments = args.as_object().cloned().or(Some(serde_json::Map::new()));
let result = client.call_tool(params).await.expect("call_tool success");
let text = result
.content
.into_iter()
.find_map(|c| c.as_text().map(|t| t.text.clone()))
.expect("text content");
serde_json::from_str(&text).expect("tool response is valid JSON")
}
#[test]
fn integration_tool_round_trip_list_files() {
let runtime = crate::test_support::runtime_handle();
runtime.block_on(async {
let (client, _recording) =
spawn_bridge_under_test(vec![make_test_file("alpha.rs")]).await;
let v = call_tool_json(&client, "trv_list_files", serde_json::json!({})).await;
let arr = v.as_array().expect("trv_list_files returns an array");
assert_eq!(arr.len(), 1, "fixture has one file: {v:?}");
assert_eq!(arr[0]["path"], "alpha.rs");
assert_eq!(arr[0]["reviewed"], false);
let _ = client.cancel().await;
});
}
#[test]
fn integration_add_comment_delivers_server_push_notification() {
let runtime = crate::test_support::runtime_handle();
runtime.block_on(async {
let (client, recording) =
spawn_bridge_under_test(vec![make_test_file("alpha.rs")]).await;
let v = call_tool_json(
&client,
"trv_add_comment",
serde_json::json!({
"file": "alpha.rs",
"line": 1,
"side": "new",
"body": "looks good",
}),
)
.await;
assert!(v["comment_id"].as_str().is_some(), "precondition: add ok");
let wait = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
{
let guard = recording.inner.lock().await;
if !guard.is_empty() {
return Ok::<_, ()>(guard.clone());
}
}
recording.signal.notified().await;
}
})
.await
.expect("notification arrives within 2s");
let received = wait.expect("recorder captured at least one notify");
let (method, params) = &received[0];
assert_eq!(
method, "notifications/trv/comment_added",
"server must push CommentAdded under the documented method"
);
let p = params.as_ref().expect("params object");
assert_eq!(p["file"], "alpha.rs");
assert_eq!(p["line"], 1);
assert_eq!(
p["author"], "agent",
"author must be 'agent' for MCP-originated comments"
);
let _ = client.cancel().await;
});
}
#[test]
fn integration_select_file_then_capture_view_reflects_state() {
let runtime = crate::test_support::runtime_handle();
runtime.block_on(async {
let (client, _recording) = spawn_bridge_under_test(vec![
make_test_file("first.rs"),
make_test_file("second.rs"),
])
.await;
let sel = call_tool_json(
&client,
"trv_select_file",
serde_json::json!({ "file": "second.rs" }),
)
.await;
assert_eq!(sel["ok"], true);
let view = call_tool_json(&client, "trv_capture_view", serde_json::json!({})).await;
assert_eq!(
view["current_file"]["path"], "second.rs",
"capture_view must reflect the post-select state: {view:?}"
);
assert_eq!(view["current_file"]["index"], 1);
let _ = client.cancel().await;
});
}
#[test]
fn integration_two_clients_both_receive_notification_fan_out() {
let runtime = crate::test_support::runtime_handle();
runtime.block_on(async {
let hub = McpHub::start(&runtime);
let registry = hub.registry.clone();
let notify_tx = hub.notify_tx.clone();
let (cmd_tx, cmd_rx) = std::sync::mpsc::channel::<McpCommand>();
std::thread::spawn(move || {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
while let Ok(cmd) = cmd_rx.recv() {
process_mcp_command(&mut app, cmd);
while let Some(notify) = app.notify_queue.pop_front() {
if notify_tx.blocking_send(notify).is_err() {
return;
}
}
}
});
let mut clients = Vec::new();
let mut recorders = Vec::new();
for _ in 0..2 {
let (server_side, client_side) = tokio::io::duplex(8192);
let cmd_tx = cmd_tx.clone();
let connection_id = registry.allocate_id();
let server = McpBridgeServer::new(cmd_tx, registry.clone(), connection_id);
let registry_for_cleanup = registry.clone();
runtime.spawn(async move {
if let Ok(svc) = server.serve(server_side).await {
let _ = svc.waiting().await;
}
registry_for_cleanup.remove(connection_id).await;
});
let recording = RecordingClient::default();
let client = recording
.clone()
.serve(client_side)
.await
.expect("client connect + handshake");
clients.push(client);
recorders.push(recording);
}
tokio::time::timeout(
std::time::Duration::from_secs(2),
registry.wait_for_peer_count(2),
)
.await
.expect("both peers register within 2s");
let v = call_tool_json(
&clients[0],
"trv_add_comment",
serde_json::json!({
"file": "foo.txt",
"line": 1,
"side": "new",
"body": "fan-out check",
}),
)
.await;
assert!(v["comment_id"].as_str().is_some(), "precondition: add ok");
for (idx, recording) in recorders.iter().enumerate() {
let received = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
{
let guard = recording.inner.lock().await;
if !guard.is_empty() {
return guard.clone();
}
}
recording.signal.notified().await;
}
})
.await
.unwrap_or_else(|_| panic!("client {idx} did not receive notification within 2s"));
let (method, params) = &received[0];
assert_eq!(
method, "notifications/trv/comment_added",
"client {idx} must see CommentAdded"
);
let p = params.as_ref().expect("params");
assert_eq!(p["file"], "foo.txt");
assert_eq!(p["line"], 1);
assert_eq!(p["author"], "agent");
}
for client in clients {
let _ = client.cancel().await;
}
});
}
#[test]
#[allow(non_snake_case)]
fn peer_registry_recent_ring_caps_at_RECENT_NOTIFICATIONS_CAP() {
let runtime = crate::test_support::runtime_handle();
runtime.block_on(async {
let registry = PeerRegistry::new();
let total = RECENT_NOTIFICATIONS_CAP + 5;
for i in 0..total {
registry
.push_recent(crate::app::McpNotify::FileChanged {
files: vec![format!("event-{i}.rs")],
})
.await;
}
let snap = registry.recent_snapshot().await;
assert_eq!(
snap.len(),
RECENT_NOTIFICATIONS_CAP,
"ring must clamp to RECENT_NOTIFICATIONS_CAP"
);
let first_surviving = match &snap[0] {
crate::app::McpNotify::FileChanged { files } => files[0].clone(),
other => panic!("unexpected variant in ring: {other:?}"),
};
assert_eq!(
first_surviving,
format!("event-{}.rs", total - RECENT_NOTIFICATIONS_CAP),
"oldest events must be evicted first (drop-oldest)"
);
let last_surviving = match snap.last().unwrap() {
crate::app::McpNotify::FileChanged { files } => files[0].clone(),
other => panic!("unexpected variant in ring: {other:?}"),
};
assert_eq!(
last_surviving,
format!("event-{}.rs", total - 1),
"newest event must be at the tail"
);
});
}
#[test]
fn peer_registry_insert_replays_recent_to_new_peer_only() {
let runtime = crate::test_support::runtime_handle();
runtime.block_on(async {
let hub = McpHub::start(&runtime);
let registry = hub.registry.clone();
let notify_tx = hub.notify_tx.clone();
let (a_server_side, a_client_side) = tokio::io::duplex(8192);
let (cmd_tx_a, _cmd_rx_a) = std::sync::mpsc::channel::<McpCommand>();
let a_id = registry.allocate_id();
let server_a = McpBridgeServer::new(cmd_tx_a, registry.clone(), a_id);
let registry_a = registry.clone();
runtime.spawn(async move {
if let Ok(svc) = server_a.serve(a_server_side).await {
let _ = svc.waiting().await;
}
registry_a.remove(a_id).await;
});
let rec_a = RecordingClient::default();
let client_a = rec_a
.clone()
.serve(a_client_side)
.await
.expect("peer A handshake");
tokio::time::timeout(
std::time::Duration::from_secs(2),
registry.wait_for_peer_count(1),
)
.await
.expect("peer A registers within 2s");
notify_tx
.send(crate::app::McpNotify::FileChanged {
files: vec!["phase-e.rs".to_string()],
})
.await
.expect("notify channel open");
tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
{
let g = rec_a.inner.lock().await;
let custom_count = g
.iter()
.filter(|(m, _)| m == "notifications/trv/file_changed")
.count();
if custom_count > 0 {
return;
}
}
rec_a.signal.notified().await;
}
})
.await
.expect("peer A receives live notification");
let a_live_file_changed_count = rec_a
.inner
.lock()
.await
.iter()
.filter(|(m, _)| m == "notifications/trv/file_changed")
.count();
assert_eq!(
a_live_file_changed_count, 1,
"peer A received live custom file_changed exactly once"
);
let (b_server_side, b_client_side) = tokio::io::duplex(8192);
let (cmd_tx_b, _cmd_rx_b) = std::sync::mpsc::channel::<McpCommand>();
let b_id = registry.allocate_id();
let server_b = McpBridgeServer::new(cmd_tx_b, registry.clone(), b_id);
let registry_b = registry.clone();
runtime.spawn(async move {
if let Ok(svc) = server_b.serve(b_server_side).await {
let _ = svc.waiting().await;
}
registry_b.remove(b_id).await;
});
let rec_b = RecordingClient::default();
let client_b = rec_b
.clone()
.serve(b_client_side)
.await
.expect("peer B handshake");
let received_b = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
{
let g = rec_b.inner.lock().await;
if !g.is_empty() {
return g.clone();
}
}
rec_b.signal.notified().await;
}
})
.await
.expect("peer B receives replayed notification");
let saw_file_changed_on_b = received_b.iter().any(|(m, p)| {
m == "notifications/trv/file_changed"
&& p.as_ref().and_then(|v| v["files"][0].as_str()) == Some("phase-e.rs")
});
assert!(
saw_file_changed_on_b,
"peer B received the replayed custom file_changed, got {:?}",
received_b
.iter()
.map(|(m, _)| m.clone())
.collect::<Vec<_>>()
);
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let a_final_file_changed_count = rec_a
.inner
.lock()
.await
.iter()
.filter(|(m, _)| m == "notifications/trv/file_changed")
.count();
assert_eq!(
a_final_file_changed_count, 1,
"peer A must not receive the custom file_changed twice (still 1 live notify)"
);
let _ = client_a.cancel().await;
let _ = client_b.cancel().await;
});
}
#[test]
fn integration_reconnecting_client_receives_missed_notifications() {
let runtime = crate::test_support::runtime_handle();
runtime.block_on(async {
let hub = McpHub::start(&runtime);
let registry = hub.registry.clone();
let notify_tx = hub.notify_tx.clone();
let (a_server_side, a_client_side) = tokio::io::duplex(8192);
let (cmd_tx_a, _cmd_rx_a) = std::sync::mpsc::channel::<McpCommand>();
let a_id = registry.allocate_id();
let server_a = McpBridgeServer::new(cmd_tx_a, registry.clone(), a_id);
let registry_a = registry.clone();
runtime.spawn(async move {
if let Ok(svc) = server_a.serve(a_server_side).await {
let _ = svc.waiting().await;
}
registry_a.remove(a_id).await;
});
let rec_a = RecordingClient::default();
let client_a = rec_a
.clone()
.serve(a_client_side)
.await
.expect("peer A handshake");
tokio::time::timeout(
std::time::Duration::from_secs(2),
registry.wait_for_peer_count(1),
)
.await
.expect("A registers");
notify_tx
.send(crate::app::McpNotify::FileChanged {
files: vec!["pre-disconnect.rs".to_string()],
})
.await
.expect("send pre-disconnect");
tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
if !rec_a.inner.lock().await.is_empty() {
return;
}
rec_a.signal.notified().await;
}
})
.await
.expect("A receives pre-disconnect event");
let _ = client_a.cancel().await;
tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
if registry.snapshot().await.is_empty() {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
})
.await
.expect("A cleaned up from registry");
notify_tx
.send(crate::app::McpNotify::FileChanged {
files: vec!["during-gap.rs".to_string()],
})
.await
.expect("send during-gap");
let (ap_server_side, ap_client_side) = tokio::io::duplex(8192);
let (cmd_tx_ap, _cmd_rx_ap) = std::sync::mpsc::channel::<McpCommand>();
let ap_id = registry.allocate_id();
let server_ap = McpBridgeServer::new(cmd_tx_ap, registry.clone(), ap_id);
let registry_ap = registry.clone();
runtime.spawn(async move {
if let Ok(svc) = server_ap.serve(ap_server_side).await {
let _ = svc.waiting().await;
}
registry_ap.remove(ap_id).await;
});
let rec_ap = RecordingClient::default();
let client_ap = rec_ap
.clone()
.serve(ap_client_side)
.await
.expect("peer A' handshake");
let saw_during_gap = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
{
let g = rec_ap.inner.lock().await;
for (method, params) in g.iter() {
if method == "notifications/trv/file_changed"
&& let Some(p) = params
&& p["files"][0] == "during-gap.rs"
{
return true;
}
}
}
rec_ap.signal.notified().await;
}
})
.await
.unwrap_or(false);
assert!(
saw_during_gap,
"reconnecting peer must see the missed during-gap event"
);
let _ = client_ap.cancel().await;
});
}
#[test]
fn shutdown_awaits_replay_tasks() {
let runtime = crate::test_support::runtime_handle();
let (hub, replay_tasks_handle) = runtime.block_on(async {
let hub = McpHub::start(&runtime);
let registry = hub.registry.clone();
for i in 0..16 {
registry
.push_recent(crate::app::McpNotify::FileChanged {
files: vec![format!("seed-{i}.rs")],
})
.await;
}
let (server_side, client_side) = tokio::io::duplex(8192);
let (cmd_tx, _cmd_rx) = std::sync::mpsc::channel::<McpCommand>();
let peer_id = registry.allocate_id();
let server = McpBridgeServer::new(cmd_tx, registry.clone(), peer_id);
let registry_clone = registry.clone();
runtime.spawn(async move {
if let Ok(svc) = server.serve(server_side).await {
let _ = svc.waiting().await;
}
registry_clone.remove(peer_id).await;
});
let rec = RecordingClient::default();
let client = rec
.clone()
.serve(client_side)
.await
.expect("peer handshake");
tokio::time::timeout(
std::time::Duration::from_secs(2),
registry.wait_for_peer_count(1),
)
.await
.expect("peer registers");
let _ = client.cancel().await;
let replay_tasks_handle = hub.replay_tasks.clone();
(hub, replay_tasks_handle)
});
let start = std::time::Instant::now();
hub.shutdown(std::time::Duration::from_secs(5));
assert!(
start.elapsed() < std::time::Duration::from_secs(5),
"shutdown returned within its own budget: elapsed={:?}",
start.elapsed()
);
runtime.block_on(async {
let mut set = replay_tasks_handle.lock().await;
assert!(
set.try_join_next().is_none(),
"replay JoinSet must be empty after shutdown awaited it"
);
});
}
#[test]
fn bridge_list_resources_returns_three_entries() {
let resources = bridge_static_resources();
let template = bridge_diff_resource_template();
let mut uris: Vec<String> = resources.iter().map(|r| r.uri.clone()).collect();
uris.push(template.uri_template.clone());
uris.sort();
assert_eq!(
uris,
vec![
"review://comments".to_string(),
"review://diff/{file}".to_string(),
"review://status".to_string(),
]
);
}
#[test]
fn bridge_read_status_resource_matches_tool_output() {
let runtime = crate::test_support::runtime_handle();
runtime.block_on(async {
let (client, _recording) =
spawn_bridge_under_test(vec![make_test_file("alpha.rs")]).await;
let tool_json =
call_tool_json(&client, "trv_get_review_status", serde_json::json!({})).await;
let result = client
.read_resource(ReadResourceRequestParams::new("review://status"))
.await
.expect("read_resource success");
let text = result
.contents
.into_iter()
.find_map(|c| match c {
ResourceContents::TextResourceContents { text, .. } => Some(text),
ResourceContents::BlobResourceContents { .. } => None,
})
.expect("status resource returns text");
let resource_json: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(resource_json, tool_json);
let _ = client.cancel().await;
});
}
#[test]
fn bridge_read_diff_resource_parses_file_path_from_uri() {
let runtime = crate::test_support::runtime_handle();
runtime.block_on(async {
let (client, _recording) =
spawn_bridge_under_test(vec![make_test_file("alpha.rs")]).await;
let tool_json = call_tool_json(
&client,
"trv_get_diff",
serde_json::json!({ "file": "alpha.rs" }),
)
.await;
let result = client
.read_resource(ReadResourceRequestParams::new("review://diff/alpha.rs"))
.await
.expect("diff resource read");
let text = result
.contents
.into_iter()
.find_map(|c| match c {
ResourceContents::TextResourceContents { text, .. } => Some(text),
ResourceContents::BlobResourceContents { .. } => None,
})
.expect("diff resource is text");
let resource_json: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(resource_json, tool_json);
let _ = client.cancel().await;
});
}
#[test]
fn bridge_read_unknown_resource_returns_error() {
let runtime = crate::test_support::runtime_handle();
runtime.block_on(async {
let (client, _recording) =
spawn_bridge_under_test(vec![make_test_file("alpha.rs")]).await;
let err = client
.read_resource(ReadResourceRequestParams::new("review://nope"))
.await
.expect_err("unknown URI must be an error");
assert!(
format!("{err:?}").contains("unknown") || format!("{err:?}").contains("invalid"),
"error message must name the failure: {err:?}"
);
let _ = client.cancel().await;
});
}
#[test]
fn bridge_read_diff_unknown_file_surfaces_resource_not_found_code() {
use rmcp::model::ErrorCode;
let runtime = crate::test_support::runtime_handle();
runtime.block_on(async {
let (client, _recording) =
spawn_bridge_under_test(vec![make_test_file("alpha.rs")]).await;
let err = client
.read_resource(ReadResourceRequestParams::new(
"review://diff/does-not-exist.rs",
))
.await
.expect_err("missing-file URI must be an error");
let dbg = format!("{err:?}");
let expected_code = ErrorCode::RESOURCE_NOT_FOUND.0;
assert!(
dbg.contains(&expected_code.to_string()),
"missing diff must carry RESOURCE_NOT_FOUND ({expected_code}), got {dbg}"
);
let _ = client.cancel().await;
});
}
#[test]
fn integration_comment_add_emits_resources_updated_notification() {
let runtime = crate::test_support::runtime_handle();
runtime.block_on(async {
let (client, recording) =
spawn_bridge_under_test(vec![make_test_file("alpha.rs")]).await;
let v = call_tool_json(
&client,
"trv_add_comment",
serde_json::json!({
"file": "alpha.rs",
"line": 1,
"side": "new",
"body": "resources/updated plumbing test",
}),
)
.await;
assert!(v["comment_id"].as_str().is_some());
let received = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
{
let guard = recording.inner.lock().await;
if guard.len() >= 3 {
return guard.clone();
}
}
recording.signal.notified().await;
}
})
.await
.expect("3 notifications arrive within 2s");
let custom_count = received
.iter()
.filter(|(m, _)| m == "notifications/trv/comment_added")
.count();
assert_eq!(
custom_count, 1,
"existing custom event must still fire: {received:?}"
);
let updated_uris: Vec<String> = received
.iter()
.filter(|(m, _)| m == "notifications/resources/updated")
.filter_map(|(_, p)| {
p.as_ref()
.and_then(|v| v["uri"].as_str())
.map(str::to_string)
})
.collect();
let mut sorted = updated_uris.clone();
sorted.sort();
assert_eq!(
sorted,
vec!["review://comments".to_string(), "review://status".to_string()],
"comment_add must update both review://comments and review://status: got {updated_uris:?}"
);
let _ = client.cancel().await;
});
}
mod forge_confirm_tests {
use super::*;
use crate::app::{
AgentActionKind, AppMode, ConfirmationStatus, McpNotify, PendingAgentAction,
RejectReason, RemoteSessionState,
};
use async_trait::async_trait;
use std::sync::{Arc, Mutex};
use travelagent_core::error::Result as CoreResult;
use travelagent_core::forge::{
ForgeComments, ForgeMerge, ForgeReactions, ForgeRead, ForgeReview, ForgeType,
MergeMethod, NewComment, NewReview, Permissions, PrId, PrListFilter, PrListItem,
PrMetadata, ReactionContent, ReactionTarget, RemoteComment, ReviewThread,
ReviewVerdict, User,
};
use travelagent_core::model::DiffFile;
use travelagent_core::vcs::CommitInfo;
type SubmitCallLog = Arc<Mutex<Vec<(ReviewVerdict, String)>>>;
struct RecordingSubmitForge {
calls: SubmitCallLog,
fail_mode: bool,
}
impl RecordingSubmitForge {
fn new() -> (Self, SubmitCallLog) {
let calls: SubmitCallLog = Arc::new(Mutex::new(Vec::new()));
(
Self {
calls: calls.clone(),
fail_mode: false,
},
calls,
)
}
fn new_failing() -> (Self, SubmitCallLog) {
let calls: SubmitCallLog = Arc::new(Mutex::new(Vec::new()));
(
Self {
calls: calls.clone(),
fail_mode: true,
},
calls,
)
}
}
#[async_trait]
impl ForgeRead for RecordingSubmitForge {
fn forge_type(&self) -> ForgeType {
ForgeType::GitHub
}
async fn get_pr(&self, _id: &PrId) -> CoreResult<PrMetadata> {
unimplemented!()
}
async fn get_pr_commits(&self, _id: &PrId) -> CoreResult<Vec<CommitInfo>> {
unimplemented!()
}
async fn get_pr_files(&self, _id: &PrId) -> CoreResult<Vec<DiffFile>> {
unimplemented!()
}
async fn get_commit_diff(
&self,
_id: &PrId,
_commit_sha: &str,
) -> CoreResult<Vec<DiffFile>> {
unimplemented!()
}
async fn list_prs(
&self,
_owner: &str,
_repo: &str,
_filter: &PrListFilter,
) -> CoreResult<Vec<PrListItem>> {
unimplemented!()
}
async fn current_user(&self) -> CoreResult<User> {
unimplemented!()
}
async fn check_permissions(&self, _id: &PrId) -> CoreResult<Permissions> {
unimplemented!()
}
}
#[async_trait]
impl ForgeComments for RecordingSubmitForge {
async fn get_comments(&self, _id: &PrId) -> CoreResult<Vec<RemoteComment>> {
unimplemented!()
}
async fn get_review_threads(&self, _id: &PrId) -> CoreResult<Vec<ReviewThread>> {
unimplemented!()
}
async fn post_comment(
&self,
_id: &PrId,
_comment: NewComment,
) -> CoreResult<RemoteComment> {
unimplemented!()
}
async fn post_reply(
&self,
_id: &PrId,
_thread_id: &str,
_body: &str,
) -> CoreResult<RemoteComment> {
unimplemented!()
}
async fn edit_comment(
&self,
_id: &PrId,
_comment_id: u64,
_body: &str,
) -> CoreResult<RemoteComment> {
unimplemented!()
}
async fn delete_comment(&self, _id: &PrId, _comment_id: u64) -> CoreResult<()> {
unimplemented!()
}
async fn resolve_thread(&self, _thread_id: &str) -> CoreResult<()> {
unimplemented!()
}
async fn unresolve_thread(&self, _thread_id: &str) -> CoreResult<()> {
unimplemented!()
}
}
#[async_trait]
impl ForgeReview for RecordingSubmitForge {
async fn submit_review(&self, _id: &PrId, review: NewReview) -> CoreResult<()> {
self.calls
.lock()
.unwrap()
.push((review.verdict, review.body));
if self.fail_mode {
Err(travelagent_core::error::TrvError::UnsupportedOperation(
"stub forge deliberately failed".into(),
))
} else {
Ok(())
}
}
}
#[async_trait]
impl ForgeMerge for RecordingSubmitForge {
async fn merge(&self, _id: &PrId, _method: MergeMethod) -> CoreResult<()> {
unimplemented!()
}
async fn close(&self, _id: &PrId) -> CoreResult<()> {
unimplemented!()
}
async fn reopen(&self, _id: &PrId) -> CoreResult<()> {
unimplemented!()
}
}
#[async_trait]
impl ForgeReactions for RecordingSubmitForge {
async fn add_reaction(
&self,
_target: &ReactionTarget,
_content: ReactionContent,
) -> CoreResult<()> {
unimplemented!()
}
async fn remove_reaction(
&self,
_target: &ReactionTarget,
_content: ReactionContent,
) -> CoreResult<()> {
unimplemented!()
}
}
fn build_remote_app_with_forge(
forge: Option<std::sync::Arc<dyn travelagent_core::forge::ForgeBackend>>,
) -> App {
let mut app = build_test_app(vec![make_test_file("touched.txt")]);
let pr_id = PrId {
owner: "owner".to_string(),
repo: "repo".to_string(),
number: 1,
};
let remote = RemoteSessionState::new(forge, pr_id);
app.mode = AppMode::Remote(remote);
app
}
fn last_decided(app: &App) -> Option<(String, &'static str, Option<&'static str>)> {
app.notify_queue.iter().rev().find_map(|n| match n {
McpNotify::AgentActionDecided {
id,
decision,
reason,
} => Some((id.clone(), *decision, *reason)),
_ => None,
})
}
fn last_proposed(app: &App) -> Option<(String, &'static str)> {
app.notify_queue.iter().rev().find_map(|n| match n {
McpNotify::AgentActionProposed { id, kind } => Some((id.clone(), *kind)),
_ => None,
})
}
#[test]
fn propose_submit_review_with_no_remote_returns_invalid_params() {
let mut app = build_test_app(vec![make_test_file("touched.txt")]);
let out = handle_propose_forge_submit_review(&mut app, "comment", "body".into());
let v = parse_json(&out);
assert_eq!(v["kind"], "invalid_params");
assert!(v["error"].as_str().unwrap().contains("remote forge"));
assert!(app.agent_action.pending().is_none());
}
#[test]
fn propose_submit_review_stashes_pending_and_returns_id() {
let (forge, _calls) = RecordingSubmitForge::new();
let mut app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
let out = handle_propose_forge_submit_review(&mut app, "approve", "ship it".into());
let v = parse_json(&out);
assert_eq!(v["status"], "pending");
let id = v["confirmation_id"]
.as_str()
.expect("confirmation_id returned");
assert_eq!(
v["timeout_seconds"],
crate::app::CONFIRMATION_TIMEOUT.as_secs()
);
let pending = app.agent_action.pending().expect("pending action stashed");
assert_eq!(pending.id, id);
assert!(matches!(pending.status, ConfirmationStatus::Pending));
let (notify_id, kind) = last_proposed(&app).expect("AgentActionProposed queued");
assert_eq!(notify_id, id);
assert_eq!(kind, "submit_review");
}
#[test]
fn propose_submit_review_rejects_unknown_verdict() {
let (forge, _calls) = RecordingSubmitForge::new();
let mut app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
let out = handle_propose_forge_submit_review(&mut app, "aprove", "typo test".into());
let v = parse_json(&out);
assert_eq!(v["kind"], "invalid_params");
assert!(v["error"].as_str().unwrap().contains("Unknown verdict"));
assert!(app.agent_action.pending().is_none());
}
#[test]
fn propose_submit_review_when_another_pending_returns_busy() {
let (forge, _calls) = RecordingSubmitForge::new();
let mut app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
let first = handle_propose_forge_submit_review(&mut app, "comment", "first".into());
let v1 = parse_json(&first);
let first_id = v1["confirmation_id"].as_str().unwrap().to_string();
let second = handle_propose_forge_submit_review(&mut app, "comment", "second".into());
let v2 = parse_json(&second);
assert_eq!(v2["status"], "busy");
assert_eq!(v2["current_id"], first_id);
assert_eq!(app.agent_action.pending().unwrap().id, first_id);
}
#[test]
fn get_confirmation_status_pending_returns_pending() {
let (forge, _calls) = RecordingSubmitForge::new();
let mut app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
let out = handle_propose_forge_submit_review(&mut app, "comment", "hi".into());
let id = parse_json(&out)["confirmation_id"]
.as_str()
.unwrap()
.to_string();
let status = handle_get_confirmation_status(&app, &id);
let v = parse_json(&status);
assert_eq!(v["status"], "pending");
assert_eq!(v["id"], id);
assert_eq!(v["kind"], "submit_review");
assert!(v["proposed_at"].as_str().is_some());
}
fn approve_and_wait(app: &mut App) {
app.approve_pending_agent_action();
let Some(rx) = app.agent_action.take_completion() else {
return;
};
let result = app.runtime_handle.block_on(rx).ok();
if let Some(result) = result {
let (tx, rx2) = tokio::sync::oneshot::channel();
let _ = tx.send(result);
app.agent_action.set_completion(rx2);
}
app.poll_forge_completion();
}
#[test]
fn get_confirmation_status_after_user_approve_returns_succeeded() {
let (forge, calls) = RecordingSubmitForge::new();
let mut app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
let out = handle_propose_forge_submit_review(&mut app, "approve", "lgtm".into());
let id = parse_json(&out)["confirmation_id"]
.as_str()
.unwrap()
.to_string();
approve_and_wait(&mut app);
let recorded = calls.lock().unwrap();
assert_eq!(recorded.len(), 1);
assert_eq!(recorded[0].0, ReviewVerdict::Approve);
assert_eq!(recorded[0].1, "lgtm");
drop(recorded);
let status = handle_get_confirmation_status(&app, &id);
let v = parse_json(&status);
assert_eq!(v["status"], "succeeded");
assert!(v["result"].is_object());
assert_eq!(v["result"]["verdict"], "Approve");
let (decided_id, decision, reason) =
last_decided(&app).expect("AgentActionDecided queued");
assert_eq!(decided_id, id);
assert_eq!(decision, "approved");
assert!(reason.is_none());
assert!(app.agent_action.pending().is_none());
}
#[test]
fn get_confirmation_status_after_user_reject_returns_rejected_user() {
let (forge, calls) = RecordingSubmitForge::new();
let mut app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
let out = handle_propose_forge_submit_review(&mut app, "comment", "nope".into());
let id = parse_json(&out)["confirmation_id"]
.as_str()
.unwrap()
.to_string();
app.reject_pending_agent_action();
assert!(calls.lock().unwrap().is_empty());
let status = handle_get_confirmation_status(&app, &id);
let v = parse_json(&status);
assert_eq!(v["status"], "rejected");
assert_eq!(v["reason"], "user");
let (_, decision, reason) = last_decided(&app).unwrap();
assert_eq!(decision, "rejected");
assert_eq!(reason, Some("user"));
}
#[test]
fn get_confirmation_status_after_timeout_returns_rejected_timeout() {
let (forge, calls) = RecordingSubmitForge::new();
let mut app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
let out = handle_propose_forge_submit_review(&mut app, "comment", "waiter".into());
let id = parse_json(&out)["confirmation_id"]
.as_str()
.unwrap()
.to_string();
if let Some(a) = app.agent_action.pending_mut() {
a.proposed_at_monotonic = std::time::Instant::now()
.checked_sub(
crate::app::CONFIRMATION_TIMEOUT + std::time::Duration::from_secs(1),
)
.unwrap_or(a.proposed_at_monotonic);
}
app.tick_agent_action_timeout();
assert!(app.agent_action.pending().is_none());
assert!(calls.lock().unwrap().is_empty());
let status = handle_get_confirmation_status(&app, &id);
let v = parse_json(&status);
assert_eq!(v["status"], "rejected");
assert_eq!(v["reason"], "timeout");
}
#[test]
fn get_confirmation_status_unknown_id_returns_error() {
let app = build_test_app(vec![make_test_file("touched.txt")]);
let out = handle_get_confirmation_status(&app, "nonexistent-id");
let v = parse_json(&out);
assert_eq!(v["kind"], "resource_not_found");
}
#[test]
fn cancel_confirmation_current_pending_transitions_to_rejected_agent_cancelled() {
let (forge, calls) = RecordingSubmitForge::new();
let mut app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
let out = handle_propose_forge_submit_review(&mut app, "comment", "withdraw".into());
let id = parse_json(&out)["confirmation_id"]
.as_str()
.unwrap()
.to_string();
let cancel_out = handle_cancel_confirmation(&mut app, &id);
let cv = parse_json(&cancel_out);
assert_eq!(cv["ok"], true);
assert!(app.agent_action.pending().is_none());
assert!(calls.lock().unwrap().is_empty());
let status = handle_get_confirmation_status(&app, &id);
let v = parse_json(&status);
assert_eq!(v["status"], "rejected");
assert_eq!(v["reason"], "agent_cancelled");
let (_, decision, reason) = last_decided(&app).unwrap();
assert_eq!(decision, "rejected");
assert_eq!(reason, Some("agent_cancelled"));
}
#[test]
fn cancel_confirmation_already_decided_returns_error() {
let (forge, _calls) = RecordingSubmitForge::new();
let mut app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
let out = handle_propose_forge_submit_review(&mut app, "comment", "done".into());
let id = parse_json(&out)["confirmation_id"]
.as_str()
.unwrap()
.to_string();
app.reject_pending_agent_action();
let cancel_out = handle_cancel_confirmation(&mut app, &id);
let v = parse_json(&cancel_out);
assert_eq!(v["kind"], "invalid_params");
assert!(v["error"].as_str().unwrap().contains("already decided"));
}
#[test]
fn cancel_confirmation_unknown_id_returns_resource_not_found() {
let mut app = build_test_app(vec![make_test_file("touched.txt")]);
let out = handle_cancel_confirmation(&mut app, "ghost");
let v = parse_json(&out);
assert_eq!(v["kind"], "resource_not_found");
}
#[test]
fn approve_when_forge_fails_transitions_to_failed() {
let (forge, calls) = RecordingSubmitForge::new_failing();
let mut app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
let out = handle_propose_forge_submit_review(&mut app, "approve", "will fail".into());
let id = parse_json(&out)["confirmation_id"]
.as_str()
.unwrap()
.to_string();
approve_and_wait(&mut app);
assert_eq!(calls.lock().unwrap().len(), 1);
let status = handle_get_confirmation_status(&app, &id);
let v = parse_json(&status);
assert_eq!(v["status"], "failed");
assert!(v["error"].as_str().unwrap().contains("deliberately"));
}
#[test]
fn history_ring_caps_at_16() {
let (forge, _calls) = RecordingSubmitForge::new();
let mut app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
let mut ids = Vec::new();
for i in 0..20 {
let out = handle_propose_forge_submit_review(&mut app, "comment", format!("{i}"));
let id = parse_json(&out)["confirmation_id"]
.as_str()
.unwrap()
.to_string();
ids.push(id);
app.reject_pending_agent_action();
}
assert_eq!(
app.agent_action.history_len(),
crate::app::CONFIRMATION_HISTORY_CAP
);
let out = handle_get_confirmation_status(&app, &ids[0]);
let v = parse_json(&out);
assert_eq!(v["kind"], "resource_not_found");
let out = handle_get_confirmation_status(&app, ids.last().unwrap());
let v = parse_json(&out);
assert_eq!(v["status"], "rejected");
}
#[test]
fn pending_agent_action_gets_breadcrumb() {
let bc = agent_action_breadcrumb(
&McpAction::ProposeForgeSubmitReview {
verdict: "approve".to_string(),
body: "body".to_string(),
},
false,
)
.expect("propose must produce a breadcrumb");
assert!(bc.contains("proposed submit_review"));
assert!(bc.contains("approve"));
}
#[test]
fn propose_body_over_limit_is_rejected_by_ingress_cap() {
let tx = spawn_ok_responder();
let server = McpBridgeServer::new_for_test(tx);
let body = "x".repeat(travelagent_core::mcp_limits::MCP_REVIEW_BODY_MAX + 1);
let out = server.trv_propose_forge_submit_review(Parameters(
ProposeForgeSubmitReviewRequest {
verdict: "comment".to_string(),
body,
},
));
let v = parse_json(&out);
let err = v["error"].as_str().expect("error payload");
assert!(
err.contains("Input exceeds review body size limit"),
"unexpected error: {err}"
);
}
#[test]
fn agent_action_notify_method_suffixes_are_stable() {
let proposed = McpNotify::AgentActionProposed {
id: "x".to_string(),
kind: "submit_review",
};
assert_eq!(proposed.method_suffix(), "agent_action_proposed");
let decided = McpNotify::AgentActionDecided {
id: "x".to_string(),
decision: "approved",
reason: None,
};
assert_eq!(decided.method_suffix(), "agent_action_decided");
}
#[test]
fn forge_confirm_notify_params_carry_id_and_kind() {
let proposed = notify_to_params(&McpNotify::AgentActionProposed {
id: "abc".to_string(),
kind: "submit_review",
});
assert_eq!(proposed["id"], "abc");
assert_eq!(proposed["kind"], "submit_review");
let decided_rejected = notify_to_params(&McpNotify::AgentActionDecided {
id: "abc".to_string(),
decision: "rejected",
reason: Some("timeout"),
});
assert_eq!(decided_rejected["id"], "abc");
assert_eq!(decided_rejected["decision"], "rejected");
assert_eq!(decided_rejected["reason"], "timeout");
let decided_approved = notify_to_params(&McpNotify::AgentActionDecided {
id: "abc".to_string(),
decision: "approved",
reason: None,
});
assert_eq!(decided_approved["decision"], "approved");
assert!(decided_approved["reason"].is_null());
}
#[test]
fn forge_confirm_resource_uris_update_status_only() {
let uris = resource_uris_for(&McpNotify::AgentActionProposed {
id: "x".to_string(),
kind: "submit_review",
});
assert_eq!(uris, vec!["review://status".to_string()]);
let uris = resource_uris_for(&McpNotify::AgentActionDecided {
id: "x".to_string(),
decision: "approved",
reason: None,
});
assert_eq!(uris, vec!["review://status".to_string()]);
}
#[test]
fn reject_reason_wire_names_are_stable() {
assert_eq!(RejectReason::User.as_str(), "user");
assert_eq!(RejectReason::Timeout.as_str(), "timeout");
assert_eq!(RejectReason::AgentCancelled.as_str(), "agent_cancelled");
assert_eq!(RejectReason::AlreadyPending.as_str(), "already_pending");
}
#[test]
fn pending_action_kind_wire_name_is_stable() {
let action = PendingAgentAction {
id: "x".to_string(),
kind: AgentActionKind::SubmitReview {
verdict: ReviewVerdict::Approve,
body: String::new(),
},
status: ConfirmationStatus::Pending,
proposed_at: chrono::Utc::now(),
proposed_at_monotonic: std::time::Instant::now(),
decided_at: None,
};
assert_eq!(action.kind.wire_name(), "submit_review");
}
#[test]
fn forge_modal_does_not_capture_in_comment_mode() {
let (forge, _calls) = RecordingSubmitForge::new();
let mut app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
let _ = handle_propose_forge_submit_review(&mut app, "comment", "body".into());
for mode in [
InputMode::Comment,
InputMode::Command,
InputMode::CommandPalette,
InputMode::Search,
InputMode::ReviewSubmit,
] {
app.nav.input_mode = mode;
assert!(
!app.forge_modal_should_capture(),
"modal must NOT capture in {:?} — literal y/n would leak into text buffer",
mode
);
}
app.nav.input_mode = InputMode::Normal;
assert!(app.forge_modal_should_capture());
}
#[test]
fn forge_modal_does_not_capture_without_pending_action() {
let (forge, _calls) = RecordingSubmitForge::new();
let app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
assert!(!app.forge_modal_should_capture());
}
#[test]
fn approve_is_a_noop_while_in_comment_mode_even_if_invoked() {
let (forge, _calls) = RecordingSubmitForge::new();
let mut app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
let out = handle_propose_forge_submit_review(&mut app, "comment", "body".into());
let id = parse_json(&out)["confirmation_id"]
.as_str()
.unwrap()
.to_string();
app.nav.input_mode = InputMode::Comment;
if app.forge_modal_should_capture() {
app.approve_pending_agent_action();
}
let pending = app.agent_action.pending().expect("pending must be intact");
assert_eq!(pending.id, id);
assert!(matches!(pending.status, ConfirmationStatus::Pending));
}
#[test]
fn busy_branch_archives_rejected_and_emits_decided() {
let (forge, _calls) = RecordingSubmitForge::new();
let mut app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
let first = handle_propose_forge_submit_review(&mut app, "comment", "first".into());
let first_id = parse_json(&first)["confirmation_id"]
.as_str()
.unwrap()
.to_string();
let before_queue_len = app.notify_queue.len();
let second = handle_propose_forge_submit_review(&mut app, "comment", "second".into());
let v2 = parse_json(&second);
assert_eq!(v2["status"], "busy");
assert_eq!(v2["current_id"], first_id);
let (decided_id, decision, reason) =
last_decided(&app).expect("decided notification required");
assert_ne!(
decided_id, first_id,
"decided should target the rejected transient id, not the still-pending one"
);
assert_eq!(decision, "rejected");
assert_eq!(reason, Some("already_pending"));
assert!(
app.notify_queue.len() > before_queue_len,
"queue should have grown by at least one decided event"
);
let status = handle_get_confirmation_status(&app, &decided_id);
let sv = parse_json(&status);
assert_eq!(sv["status"], "rejected");
assert_eq!(sv["reason"], "already_pending");
assert_eq!(app.agent_action.pending().unwrap().id, first_id);
}
#[test]
fn approve_success_records_last_review_submitted_at_and_dirty() {
let (forge, _calls) = RecordingSubmitForge::new();
let mut app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
let _ = handle_propose_forge_submit_review(&mut app, "comment", "via agent".into());
app.engine.session_mut().last_review_submitted_at = None;
app.engine.session_mut().last_review_sha = None;
app.dirty = false;
approve_and_wait(&mut app);
assert!(
app.engine.session().last_review_submitted_at.is_some(),
"last_review_submitted_at must be stamped on agent-approved submit"
);
assert!(
app.dirty,
"session must be marked dirty so the next save persists the timestamp"
);
}
#[test]
fn review_status_exposes_pending_confirmation_state() {
let (forge, _calls) = RecordingSubmitForge::new();
let mut app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
let base = parse_json(&handle_get_review_status(&app));
assert!(base["agent_action"].is_object());
assert!(base["agent_action"]["pending"].is_null());
assert!(base["agent_action"]["last_decision"].is_null());
assert!(
base["forge_action"].is_null(),
"legacy forge_action must be gone in v1.7.0"
);
let out = handle_propose_forge_submit_review(&mut app, "comment", "x".into());
let id = parse_json(&out)["confirmation_id"]
.as_str()
.unwrap()
.to_string();
let mid = parse_json(&handle_get_review_status(&app));
assert_eq!(mid["agent_action"]["pending"]["id"], id);
assert_eq!(mid["agent_action"]["pending"]["kind"], "submit_review");
assert!(
mid["agent_action"]["pending"]["proposed_at"]
.as_str()
.is_some()
);
assert!(mid["forge_action"].is_null());
app.reject_pending_agent_action();
let after = parse_json(&handle_get_review_status(&app));
assert!(after["agent_action"]["pending"].is_null());
assert_eq!(after["agent_action"]["last_decision"]["id"], id);
assert_eq!(
after["agent_action"]["last_decision"]["decision"],
"rejected"
);
assert_eq!(after["agent_action"]["last_decision"]["reason"], "user");
assert!(after["forge_action"].is_null());
}
struct GitlabRecordingForge {
inner: RecordingSubmitForge,
}
#[async_trait]
impl ForgeRead for GitlabRecordingForge {
fn forge_type(&self) -> ForgeType {
ForgeType::GitLab
}
async fn get_pr(&self, id: &PrId) -> CoreResult<PrMetadata> {
self.inner.get_pr(id).await
}
async fn get_pr_commits(&self, id: &PrId) -> CoreResult<Vec<CommitInfo>> {
self.inner.get_pr_commits(id).await
}
async fn get_pr_files(&self, id: &PrId) -> CoreResult<Vec<DiffFile>> {
self.inner.get_pr_files(id).await
}
async fn get_commit_diff(&self, id: &PrId, sha: &str) -> CoreResult<Vec<DiffFile>> {
self.inner.get_commit_diff(id, sha).await
}
async fn list_prs(
&self,
o: &str,
r: &str,
f: &PrListFilter,
) -> CoreResult<Vec<PrListItem>> {
self.inner.list_prs(o, r, f).await
}
async fn current_user(&self) -> CoreResult<User> {
self.inner.current_user().await
}
async fn check_permissions(&self, id: &PrId) -> CoreResult<Permissions> {
self.inner.check_permissions(id).await
}
}
#[async_trait]
impl ForgeComments for GitlabRecordingForge {
async fn get_comments(&self, id: &PrId) -> CoreResult<Vec<RemoteComment>> {
self.inner.get_comments(id).await
}
async fn get_review_threads(&self, id: &PrId) -> CoreResult<Vec<ReviewThread>> {
self.inner.get_review_threads(id).await
}
async fn post_comment(&self, id: &PrId, c: NewComment) -> CoreResult<RemoteComment> {
self.inner.post_comment(id, c).await
}
async fn post_reply(&self, id: &PrId, t: &str, b: &str) -> CoreResult<RemoteComment> {
self.inner.post_reply(id, t, b).await
}
async fn edit_comment(&self, id: &PrId, c: u64, b: &str) -> CoreResult<RemoteComment> {
self.inner.edit_comment(id, c, b).await
}
async fn delete_comment(&self, id: &PrId, c: u64) -> CoreResult<()> {
self.inner.delete_comment(id, c).await
}
async fn resolve_thread(&self, t: &str) -> CoreResult<()> {
self.inner.resolve_thread(t).await
}
async fn unresolve_thread(&self, t: &str) -> CoreResult<()> {
self.inner.unresolve_thread(t).await
}
}
#[async_trait]
impl ForgeReview for GitlabRecordingForge {
async fn submit_review(&self, id: &PrId, r: NewReview) -> CoreResult<()> {
self.inner.submit_review(id, r).await
}
}
#[async_trait]
impl ForgeMerge for GitlabRecordingForge {
async fn merge(&self, id: &PrId, m: MergeMethod) -> CoreResult<()> {
self.inner.merge(id, m).await
}
async fn close(&self, id: &PrId) -> CoreResult<()> {
self.inner.close(id).await
}
async fn reopen(&self, id: &PrId) -> CoreResult<()> {
self.inner.reopen(id).await
}
}
#[async_trait]
impl ForgeReactions for GitlabRecordingForge {
async fn add_reaction(&self, t: &ReactionTarget, c: ReactionContent) -> CoreResult<()> {
self.inner.add_reaction(t, c).await
}
async fn remove_reaction(
&self,
t: &ReactionTarget,
c: ReactionContent,
) -> CoreResult<()> {
self.inner.remove_reaction(t, c).await
}
}
#[test]
fn propose_request_changes_against_gitlab_is_rejected() {
let (inner, _calls) = RecordingSubmitForge::new();
let forge = GitlabRecordingForge { inner };
let mut app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
let out =
handle_propose_forge_submit_review(&mut app, "request_changes", "nope".into());
let v = parse_json(&out);
assert_eq!(v["kind"], "invalid_params");
assert!(
v["error"].as_str().unwrap().contains("request_changes"),
"error should mention the rejected verdict: {}",
v["error"]
);
assert!(app.agent_action.pending().is_none());
}
#[test]
fn propose_comment_against_gitlab_still_works() {
let (inner, _calls) = RecordingSubmitForge::new();
let forge = GitlabRecordingForge { inner };
let mut app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
let out = handle_propose_forge_submit_review(&mut app, "approve", "lgtm".into());
let v = parse_json(&out);
assert_eq!(v["status"], "pending");
}
#[test]
fn timeout_after_approve_does_not_double_fire_decided() {
let (forge, _calls) = RecordingSubmitForge::new();
let mut app = build_remote_app_with_forge(Some(std::sync::Arc::new(forge)));
let _ = handle_propose_forge_submit_review(&mut app, "comment", "race".into());
if let Some(a) = app.agent_action.pending_mut() {
a.proposed_at_monotonic = std::time::Instant::now()
.checked_sub(
crate::app::CONFIRMATION_TIMEOUT + std::time::Duration::from_secs(1),
)
.unwrap_or(a.proposed_at_monotonic);
}
approve_and_wait(&mut app);
let decided_count_before = app
.notify_queue
.iter()
.filter(|n| matches!(n, McpNotify::AgentActionDecided { .. }))
.count();
app.tick_agent_action_timeout();
let decided_count_after = app
.notify_queue
.iter()
.filter(|n| matches!(n, McpNotify::AgentActionDecided { .. }))
.count();
assert_eq!(
decided_count_before, decided_count_after,
"tick_agent_action_timeout must not re-emit decided after a completed approve"
);
}
}
#[test]
fn integration_propose_approve_flow_delivers_agent_action_decided() {
use crate::app::{AppMode, RemoteSessionState};
use async_trait::async_trait;
use std::sync::{Arc, Mutex};
use travelagent_core::error::Result as CoreResult;
use travelagent_core::forge::{
ForgeComments, ForgeMerge, ForgeReactions, ForgeRead, ForgeReview, ForgeType,
MergeMethod, NewComment, NewReview, Permissions, PrId, PrListFilter, PrListItem,
PrMetadata, ReactionContent, ReactionTarget, RemoteComment, ReviewThread,
ReviewVerdict, User,
};
use travelagent_core::vcs::CommitInfo;
struct StubForge {
calls: Arc<Mutex<Vec<ReviewVerdict>>>,
}
#[async_trait]
impl ForgeRead for StubForge {
fn forge_type(&self) -> ForgeType {
ForgeType::GitHub
}
async fn get_pr(&self, _id: &PrId) -> CoreResult<PrMetadata> {
unimplemented!()
}
async fn get_pr_commits(&self, _id: &PrId) -> CoreResult<Vec<CommitInfo>> {
unimplemented!()
}
async fn get_pr_files(&self, _id: &PrId) -> CoreResult<Vec<DiffFile>> {
unimplemented!()
}
async fn get_commit_diff(
&self,
_id: &PrId,
_commit_sha: &str,
) -> CoreResult<Vec<DiffFile>> {
unimplemented!()
}
async fn list_prs(
&self,
_owner: &str,
_repo: &str,
_filter: &PrListFilter,
) -> CoreResult<Vec<PrListItem>> {
unimplemented!()
}
async fn current_user(&self) -> CoreResult<User> {
unimplemented!()
}
async fn check_permissions(&self, _id: &PrId) -> CoreResult<Permissions> {
unimplemented!()
}
}
#[async_trait]
impl ForgeComments for StubForge {
async fn get_comments(&self, _id: &PrId) -> CoreResult<Vec<RemoteComment>> {
unimplemented!()
}
async fn get_review_threads(&self, _id: &PrId) -> CoreResult<Vec<ReviewThread>> {
unimplemented!()
}
async fn post_comment(
&self,
_id: &PrId,
_comment: NewComment,
) -> CoreResult<RemoteComment> {
unimplemented!()
}
async fn post_reply(
&self,
_id: &PrId,
_thread_id: &str,
_body: &str,
) -> CoreResult<RemoteComment> {
unimplemented!()
}
async fn edit_comment(
&self,
_id: &PrId,
_comment_id: u64,
_body: &str,
) -> CoreResult<RemoteComment> {
unimplemented!()
}
async fn delete_comment(&self, _id: &PrId, _comment_id: u64) -> CoreResult<()> {
unimplemented!()
}
async fn resolve_thread(&self, _thread_id: &str) -> CoreResult<()> {
unimplemented!()
}
async fn unresolve_thread(&self, _thread_id: &str) -> CoreResult<()> {
unimplemented!()
}
}
#[async_trait]
impl ForgeReview for StubForge {
async fn submit_review(&self, _id: &PrId, review: NewReview) -> CoreResult<()> {
self.calls.lock().unwrap().push(review.verdict);
Ok(())
}
}
#[async_trait]
impl ForgeMerge for StubForge {
async fn merge(&self, _id: &PrId, _method: MergeMethod) -> CoreResult<()> {
unimplemented!()
}
async fn close(&self, _id: &PrId) -> CoreResult<()> {
unimplemented!()
}
async fn reopen(&self, _id: &PrId) -> CoreResult<()> {
unimplemented!()
}
}
#[async_trait]
impl ForgeReactions for StubForge {
async fn add_reaction(
&self,
_target: &ReactionTarget,
_content: ReactionContent,
) -> CoreResult<()> {
unimplemented!()
}
async fn remove_reaction(
&self,
_target: &ReactionTarget,
_content: ReactionContent,
) -> CoreResult<()> {
unimplemented!()
}
}
let runtime = crate::test_support::runtime_handle();
runtime.block_on(async {
let hub = McpHub::start(&runtime);
let registry = hub.registry.clone();
let notify_tx = hub.notify_tx.clone();
let (server_side, client_side) = tokio::io::duplex(8192);
let (cmd_tx, cmd_rx) = std::sync::mpsc::channel::<McpCommand>();
let connection_id = registry.allocate_id();
let server = McpBridgeServer::new(cmd_tx, registry.clone(), connection_id);
runtime.spawn(async move {
if let Ok(svc) = server.serve(server_side).await {
let _ = svc.waiting().await;
}
registry.remove(connection_id).await;
});
let calls = Arc::new(Mutex::new(Vec::new()));
let calls_for_thread = calls.clone();
let notify_tx_clone = notify_tx.clone();
let (human_tx, human_rx) = std::sync::mpsc::channel::<()>();
std::thread::spawn(move || {
let mut app = build_test_app(vec![make_test_file("foo.txt")]);
let pr_id = PrId {
owner: "o".to_string(),
repo: "r".to_string(),
number: 1,
};
app.mode = AppMode::Remote(RemoteSessionState::new(
Some(std::sync::Arc::new(StubForge {
calls: calls_for_thread,
})),
pr_id,
));
loop {
match cmd_rx.recv() {
Ok(cmd) => {
process_mcp_command(&mut app, cmd);
while let Some(notify) = app.notify_queue.pop_front() {
if notify_tx_clone.blocking_send(notify).is_err() {
return;
}
}
}
Err(_) => return,
}
if matches!(
app.agent_action.pending().map(|a| &a.status),
Some(crate::app::ConfirmationStatus::Pending)
) {
app.approve_pending_agent_action();
if let Some(rx) = app.agent_action.take_completion()
&& let Ok(result) = app.runtime_handle.block_on(rx)
{
let (tx2, rx2) = tokio::sync::oneshot::channel();
let _ = tx2.send(result);
app.agent_action.set_completion(rx2);
app.poll_forge_completion();
}
while let Some(notify) = app.notify_queue.pop_front() {
if notify_tx_clone.blocking_send(notify).is_err() {
return;
}
}
let _ = human_tx.send(());
}
}
});
let recording = RecordingClient::default();
let client = recording
.clone()
.serve(client_side)
.await
.expect("client handshake");
let v = call_tool_json(
&client,
"trv_propose_forge_submit_review",
serde_json::json!({
"verdict": "approve",
"body": "integration lgtm",
}),
)
.await;
assert_eq!(v["status"], "pending");
tokio::task::spawn_blocking(move || {
let _ = human_rx.recv_timeout(std::time::Duration::from_secs(2));
})
.await
.expect("blocking sync task");
let received = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
{
let g = recording.inner.lock().await;
let saw = g
.iter()
.any(|(m, _)| m == "notifications/trv/agent_action_decided");
if saw {
return g.clone();
}
}
recording.signal.notified().await;
}
})
.await
.expect("agent_action_decided arrives within 2s");
let decided = received
.iter()
.find(|(m, _)| m == "notifications/trv/agent_action_decided")
.expect("agent_action_decided present");
let params = decided.1.as_ref().expect("params");
assert_eq!(params["decision"], "approved");
assert!(
!received
.iter()
.any(|(m, _)| m == "notifications/trv/forge_action_decided"),
"legacy forge_action_decided alias must be gone in v1.5",
);
assert_eq!(calls.lock().unwrap().len(), 1);
assert_eq!(calls.lock().unwrap()[0], ReviewVerdict::Approve);
let _ = client.cancel().await;
});
}
#[test]
fn mental_model_returns_null_when_unset() {
let app = build_test_app(Vec::new());
let out = handle_get_mental_model(&app);
let v = parse_json(&out);
assert!(v["mental_model"].is_null());
}
#[test]
fn propose_set_mental_model_returns_pending_and_populates_pending_action() {
use crate::app::{AgentActionKind, ConfirmationStatus};
let mut app = build_test_app(Vec::new());
let out = handle_propose_set_mental_model(
&mut app,
"small diffs".into(),
"new deps".into(),
"callsite miss".into(),
"CI catches type errors".into(),
);
let v = parse_json(&out);
assert_eq!(v["status"], "pending");
assert!(v["confirmation_id"].as_str().is_some());
let pending = app.agent_action.pending().expect("must be pending");
assert!(matches!(pending.status, ConfirmationStatus::Pending));
assert!(matches!(
pending.kind,
AgentActionKind::SetMentalModel { .. }
));
}
#[test]
fn propose_set_mental_model_rejects_oversized_field() {
let mut app = build_test_app(Vec::new());
app.mental_model_byte_limit = 16;
let out = handle_propose_set_mental_model(
&mut app,
"a".repeat(32),
String::new(),
String::new(),
String::new(),
);
assert!(is_error_response(&out));
assert!(out.contains("should_do"));
}
#[test]
fn propose_set_mental_model_returns_busy_when_already_pending() {
let mut app = build_test_app(Vec::new());
let first = handle_propose_set_mental_model(
&mut app,
"x".into(),
String::new(),
String::new(),
String::new(),
);
let first_id = parse_json(&first)["confirmation_id"]
.as_str()
.unwrap()
.to_string();
let second = handle_propose_set_mental_model(
&mut app,
"y".into(),
String::new(),
String::new(),
String::new(),
);
let vv = parse_json(&second);
assert_eq!(vv["status"], "busy");
assert_eq!(vv["current_id"].as_str().unwrap(), first_id);
}
#[test]
fn approve_pending_set_mental_model_commits_synchronously() {
use crate::app::ConfirmationStatus;
let mut app = build_test_app(Vec::new());
let out = handle_propose_set_mental_model(
&mut app,
"small diffs".into(),
"new deps".into(),
"callsite miss".into(),
"CI catches type errors".into(),
);
let id = parse_json(&out)["confirmation_id"]
.as_str()
.unwrap()
.to_string();
app.approve_pending_agent_action();
let mm = app
.engine
.session()
.mental_model
.as_ref()
.expect("mental_model must be committed");
assert_eq!(mm.should_do, "small diffs");
assert_eq!(mm.shouldnt_do, "new deps");
assert!(app.agent_action.pending().is_none());
let archived = app.find_forge_action(&id).expect("archived");
assert!(matches!(
archived.status,
ConfirmationStatus::Succeeded { .. }
));
}
#[test]
fn approve_pending_set_mental_model_collapses_empty_to_none() {
let mut app = build_test_app(Vec::new());
handle_propose_set_mental_model(
&mut app,
String::new(),
String::new(),
String::new(),
String::new(),
);
app.approve_pending_agent_action();
assert!(app.engine.session().mental_model.is_none());
}
#[test]
fn mental_model_returns_all_fields_when_set() {
use travelagent_core::model::MentalModel;
let mut app = build_test_app(Vec::new());
let t = chrono::Utc::now();
app.engine.session_mut().mental_model = Some(MentalModel {
should_do: "small-diff rename cleanup".into(),
shouldnt_do: "add dependencies".into(),
could_go_wrong: "callsite miss".into(),
assumptions: "CI catches type errors".into(),
created_at: t,
updated_at: t,
});
let out = handle_get_mental_model(&app);
let v = parse_json(&out);
assert_eq!(v["mental_model"]["should_do"], "small-diff rename cleanup");
assert_eq!(v["mental_model"]["shouldnt_do"], "add dependencies");
assert_eq!(v["mental_model"]["could_go_wrong"], "callsite miss");
assert_eq!(v["mental_model"]["assumptions"], "CI catches type errors");
assert!(v["mental_model"]["created_at"].as_str().is_some());
assert!(v["mental_model"]["updated_at"].as_str().is_some());
}
#[test]
fn list_spec_comments_returns_empty_when_none_captured() {
let app = build_test_app(Vec::new());
let out = handle_list_spec_comments(&app);
let v = parse_json(&out);
assert_eq!(v["specs"].as_array().unwrap().len(), 0);
}
#[test]
fn list_spec_comments_includes_only_spec_type() {
use travelagent_core::model::{Comment, CommentType};
let mut app = build_test_app(Vec::new());
let session = app.engine.session_mut();
session
.review_comments
.push(Comment::new("note body".into(), CommentType::Note, None));
session
.review_comments
.push(Comment::new("spec body".into(), CommentType::Spec, None));
session.review_comments.push(Comment::new(
"question body".into(),
CommentType::Question,
None,
));
let out = handle_list_spec_comments(&app);
let v = parse_json(&out);
let specs = v["specs"].as_array().unwrap();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0]["scope"], "review");
assert_eq!(specs[0]["body"], "spec body");
}
#[test]
fn write_test_from_spec_unknown_id_errors() {
let app = build_test_app(Vec::new());
let out = handle_write_test_from_spec(&app, "does-not-exist", None);
assert!(is_error_response(&out));
assert!(out.contains("Unknown spec id"));
}
#[test]
fn write_test_from_spec_review_scoped_rejected() {
use travelagent_core::model::{Comment, CommentType};
let mut app = build_test_app(Vec::new());
let spec = Comment::new("spec body".into(), CommentType::Spec, None);
let spec_id = spec.id.clone();
app.engine.session_mut().review_comments.push(spec);
let out = handle_write_test_from_spec(&app, &spec_id, None);
assert!(is_error_response(&out));
assert!(out.contains("review-scoped"));
}
#[test]
fn write_test_from_spec_file_scoped_returns_diff() {
use std::path::PathBuf;
use travelagent_core::model::review::FileReview;
use travelagent_core::model::{Comment, CommentType, FileStatus};
let mut app = build_test_app(vec![make_test_file("foo.rs")]);
let session = app.engine.session_mut();
let path = PathBuf::from("foo.rs");
let mut fr = FileReview::new(path.clone(), FileStatus::Modified);
let spec = Comment::new("test this path".into(), CommentType::Spec, None);
let spec_id = spec.id.clone();
fr.add_file_comment(spec);
session.files.insert(path, fr);
let out = handle_write_test_from_spec(&app, &spec_id, Some("cargo-test"));
let v = parse_json(&out);
assert_eq!(v["spec"]["scope"], "file");
assert_eq!(v["spec"]["file"], "foo.rs");
assert_eq!(v["spec"]["body"], "test this path");
assert_eq!(v["framework"], "cargo-test");
assert_eq!(v["diff"]["file"], "foo.rs");
let hunks = v["diff"]["hunks"].as_array().unwrap();
assert_eq!(hunks.len(), 1);
let first_line = &hunks[0]["lines"][0];
assert_eq!(first_line["origin"], "addition");
assert_eq!(first_line["content"], "hello");
}
#[test]
fn write_test_from_spec_line_scoped_carries_line_and_side() {
use std::path::PathBuf;
use travelagent_core::model::review::FileReview;
use travelagent_core::model::{Comment, CommentType, FileStatus, LineSide};
let mut app = build_test_app(vec![make_test_file("foo.rs")]);
let session = app.engine.session_mut();
let path = PathBuf::from("foo.rs");
let mut fr = FileReview::new(path.clone(), FileStatus::Modified);
let spec = Comment::new("test line 1".into(), CommentType::Spec, Some(LineSide::New));
let spec_id = spec.id.clone();
fr.add_line_comment(1, spec);
session.files.insert(path, fr);
let out = handle_write_test_from_spec(&app, &spec_id, None);
let v = parse_json(&out);
assert_eq!(v["spec"]["scope"], "line");
assert_eq!(v["spec"]["line"], 1);
assert_eq!(v["spec"]["side"], "new");
assert!(v["framework"].is_null());
}
#[test]
fn write_test_from_spec_orphan_rejected() {
use std::path::PathBuf;
use travelagent_core::model::review::FileReview;
use travelagent_core::model::{Comment, CommentType, FileStatus};
let mut app = build_test_app(vec![make_test_file("foo.rs")]);
let session = app.engine.session_mut();
let path = PathBuf::from("foo.rs");
let mut fr = FileReview::new(path.clone(), FileStatus::Modified);
let spec = Comment::new("stale".into(), CommentType::Spec, None);
let spec_id = spec.id.clone();
fr.orphaned_comments.push(spec);
session.files.insert(path, fr);
let out = handle_write_test_from_spec(&app, &spec_id, None);
assert!(is_error_response(&out));
assert!(out.contains("orphaned"));
}
#[test]
fn write_test_from_spec_file_missing_from_diff_errors() {
use std::path::PathBuf;
use travelagent_core::model::review::FileReview;
use travelagent_core::model::{Comment, CommentType, FileStatus};
let mut app = build_test_app(Vec::new());
let session = app.engine.session_mut();
let path = PathBuf::from("gone.rs");
let mut fr = FileReview::new(path.clone(), FileStatus::Modified);
let spec = Comment::new("ghost spec".into(), CommentType::Spec, None);
let spec_id = spec.id.clone();
fr.add_file_comment(spec);
session.files.insert(path, fr);
let out = handle_write_test_from_spec(&app, &spec_id, None);
assert!(is_error_response(&out));
assert!(out.contains("gone.rs"));
assert!(out.contains("no longer in the diff"));
}
#[test]
fn propose_accept_test_rejects_outside_spar_mode() {
let mut app = build_test_app(Vec::new());
assert!(!app.spar_mode);
let out = handle_propose_accept_test(
&mut app,
"crates/x/tests/new.rs".to_string(),
"fn test_x() {}".to_string(),
"spec-id".to_string(),
);
assert!(is_error_response(&out));
assert!(out.contains("sparring mode"));
assert!(app.agent_action.pending().is_none());
}
#[test]
fn propose_accept_test_rejects_absolute_path() {
let mut app = build_test_app(Vec::new());
app.spar_mode = true;
let out = handle_propose_accept_test(
&mut app,
"/etc/passwd".to_string(),
"pwn".to_string(),
"spec-id".to_string(),
);
assert!(is_error_response(&out));
assert!(out.contains("repo-relative"));
}
#[test]
fn propose_accept_test_rejects_parent_traversal() {
let mut app = build_test_app(Vec::new());
app.spar_mode = true;
let out = handle_propose_accept_test(
&mut app,
"../outside.rs".to_string(),
"fn test() {}".to_string(),
"spec-id".to_string(),
);
assert!(is_error_response(&out));
assert!(out.contains("outside the repo"));
}
#[test]
fn propose_accept_test_rejects_empty_path() {
let mut app = build_test_app(Vec::new());
app.spar_mode = true;
let out = handle_propose_accept_test(
&mut app,
" ".to_string(),
"fn test() {}".to_string(),
"spec-id".to_string(),
);
assert!(is_error_response(&out));
assert!(out.contains("test_path must not be empty"));
}
#[test]
fn propose_accept_test_returns_pending_and_populates_action() {
use crate::app::{AgentActionKind, ConfirmationStatus};
let mut app = build_test_app(Vec::new());
app.spar_mode = true;
let body = "// trv-spec: spec-id\nfn test_x() {}\n";
let out = handle_propose_accept_test(
&mut app,
"tests/new.rs".to_string(),
body.to_string(),
"spec-id".to_string(),
);
let v = parse_json(&out);
assert_eq!(v["status"], "pending");
assert!(v["confirmation_id"].as_str().is_some());
let pending = app.agent_action.pending().expect("must be pending");
assert!(matches!(pending.status, ConfirmationStatus::Pending));
assert!(matches!(
pending.kind,
AgentActionKind::AcceptGeneratedTest { .. }
));
}
#[test]
fn propose_accept_test_returns_busy_when_already_pending() {
let mut app = build_test_app(Vec::new());
app.spar_mode = true;
let first = handle_propose_accept_test(
&mut app,
"tests/a.rs".to_string(),
"// trv-spec: spec-1\nfn a() {}\n".to_string(),
"spec-1".to_string(),
);
let first_id = parse_json(&first)["confirmation_id"]
.as_str()
.unwrap()
.to_string();
let second = handle_propose_accept_test(
&mut app,
"tests/b.rs".to_string(),
"// trv-spec: spec-2\nfn b() {}\n".to_string(),
"spec-2".to_string(),
);
let vv = parse_json(&second);
assert_eq!(vv["status"], "busy");
assert_eq!(vv["current_id"].as_str().unwrap(), first_id);
}
#[test]
fn approve_accept_test_writes_file_and_succeeds() {
use crate::app::ConfirmationStatus;
let tmp = tempfile::tempdir().expect("tmpdir");
let mut app = build_test_app(Vec::new());
app.spar_mode = true;
app.vcs_info.root_path = tmp.path().to_path_buf();
let body = "// trv-spec: spec-id\nfn test_x() { assert!(true); }\n";
let out = handle_propose_accept_test(
&mut app,
"tests/generated.rs".to_string(),
body.to_string(),
"spec-id".to_string(),
);
let id = parse_json(&out)["confirmation_id"]
.as_str()
.unwrap()
.to_string();
app.approve_pending_agent_action();
assert!(app.agent_action.pending().is_none());
let written = tmp.path().join("tests/generated.rs");
assert!(written.exists(), "test file should have been written");
let contents = std::fs::read_to_string(&written).expect("readable");
assert_eq!(contents, body);
let archived = app
.find_forge_action(&id)
.expect("archived action must be present");
assert!(matches!(
archived.status,
ConfirmationStatus::Succeeded { .. }
));
}
#[test]
fn approve_accept_test_creates_intermediate_dirs() {
let tmp = tempfile::tempdir().expect("tmpdir");
let mut app = build_test_app(Vec::new());
app.spar_mode = true;
app.vcs_info.root_path = tmp.path().to_path_buf();
let out = handle_propose_accept_test(
&mut app,
"a/b/c/deep.rs".to_string(),
"// trv-spec: spec-id\nfn deep() {}\n".to_string(),
"spec-id".to_string(),
);
assert_eq!(parse_json(&out)["status"], "pending");
app.approve_pending_agent_action();
assert!(tmp.path().join("a/b/c/deep.rs").exists());
}
#[test]
fn propose_accept_test_requires_spec_link_proof() {
let mut app = build_test_app(Vec::new());
app.spar_mode = true;
let out = handle_propose_accept_test(
&mut app,
"tests/generated.rs".to_string(),
"fn test_x() {}\n".to_string(),
"spec-id".to_string(),
);
assert!(is_error_response(&out));
assert!(out.contains("trv-spec"));
assert!(app.agent_action.pending().is_none());
}
#[test]
fn propose_accept_test_accepts_filename_fallback_when_body_lacks_marker() {
let mut app = build_test_app(Vec::new());
app.spar_mode = true;
let out = handle_propose_accept_test(
&mut app,
"tests/spec_deadbeef_adds.py".to_string(),
"def test_x(): pass\n".to_string(),
"deadbeef-cafe-babe-feed-0123456789ab".to_string(),
);
assert_eq!(parse_json(&out)["status"], "pending");
}
}