diff --git a/apps/staged/src-tauri/src/project_mcp.rs b/apps/staged/src-tauri/src/project_mcp.rs index bed97f6c..62e267df 100644 --- a/apps/staged/src-tauri/src/project_mcp.rs +++ b/apps/staged/src-tauri/src/project_mcp.rs @@ -34,6 +34,10 @@ enum RepoSessionOutcome { /// is created and the agent is instructed to commit with a signed-off conventional /// commit message. Commit, + /// The session should run an AI code review of the changes on the repo's branch. + /// A review record is created and populated with a confidence title and inline + /// comments when the session completes. + CodeReview, } #[derive(serde::Deserialize, schemars::JsonSchema)] @@ -47,15 +51,22 @@ struct StartRepoSessionParams { /// Instructions to give the agent. Notes previously created for this repo are available /// to the session, so you can refer to them by name (e.g. "refer to the architecture /// overview note"). + /// + /// For `"code_review"` leave this empty for a standard review of the branch's + /// changes. Provide instructions only when there is something specific you want + /// looked into or have concerns about (e.g. "focus on the migration ordering"). pub instructions: String, /// What the session should produce. Controls the prompt given to the agent and what /// artifact (if any) is created in the database. /// /// - `"note_in_repo"`: Use this for generating notes that can be referred to again /// later by other sessions or by the user. Useful for architecture overviews, plans, - /// research, reviews. + /// research. /// - `"commit"`: Use this to request code changes. Agent makes code changes and /// creates a signed-off commit with a conventional commit message. + /// - `"code_review"`: Use this to request an AI code review of the changes on the + /// repo's branch. Produces a review with a confidence title and inline comments + /// anchored to the diff. pub expected_outcome: RepoSessionOutcome, } @@ -108,6 +119,7 @@ struct RepoSessionActivityCounts { enum RepoArtifactKind { Note, Commit, + Review, } #[derive(Debug, serde::Serialize, serde::Deserialize)] @@ -122,6 +134,41 @@ struct ResolvedRepoTarget { branch: Branch, } +/// The `start_line` / `end_line` pair reported for a review comment. +/// +/// Comment spans are stored the way the review prompt asks for them: 0-indexed +/// lines from the "after" side of the diff with an exclusive end. The payload +/// reports 1-indexed inclusive lines instead — what the field names imply to +/// the reading agent, and the same conversion review comments already get when +/// they're posted to GitHub. An empty span (`start == end`, an anchor covering +/// no lines) collapses to the single line it sits on rather than reporting an +/// end before the start. +fn comment_line_range(span: crate::git::Span) -> (u32, u32) { + let start_line = span.start.saturating_add(1); + (start_line, span.end.max(start_line)) +} + +/// The ACP config selection a queued repo session should carry. +/// +/// The parent project session's selection names config and value IDs that only +/// exist on the parent's own provider, so it is inherited only when the repo +/// session runs on that provider. When review provider resolution falls back +/// to a different agent, the selection is dropped: the fallback agent would +/// fail config application at session start, and because a retried +/// `start_repo_session` stamps the handler's selection onto a fresh row, the +/// failure would repeat on every retry rather than self-heal. +fn inherited_acp_config_selection( + inherited_provider: Option<&str>, + session_provider: Option<&str>, + selection: Option<&AcpConfigSelection>, +) -> Option { + if session_provider == inherited_provider { + selection.cloned() + } else { + None + } +} + impl ProjectToolsHandler { fn resolve_repo_target( &self, @@ -327,6 +374,7 @@ impl ProjectToolsHandler { "type": match handle.artifact_kind { RepoArtifactKind::Note => "note", RepoArtifactKind::Commit => "commit", + RepoArtifactKind::Review => "review", }, "id": handle.artifact_id, }, @@ -375,6 +423,33 @@ impl ProjectToolsHandler { }); } } + RepoArtifactKind::Review => { + let review = self + .store + .get_review(&handle.artifact_id) + .map_err(|e| format!("Error loading review: {e}"))?; + if let Some(review) = review { + payload["review"] = serde_json::json!({ + "id": review.id, + "title": review.title, + "completed_at": review.completed_at, + "comments": review + .comments + .iter() + .map(|c| { + let (start_line, end_line) = comment_line_range(c.span); + serde_json::json!({ + "path": c.path, + "start_line": start_line, + "end_line": end_line, + "type": c.comment_type.as_ref().map(|t| t.as_str()), + "content": c.content, + }) + }) + .collect::>(), + }); + } + } } payload["output"] = @@ -470,7 +545,7 @@ impl ProjectToolsHandler { #[tool_router] impl ProjectToolsHandler { #[tool( - description = "Enqueue an agent session in one of the project's repositories and return immediately with an opaque `repo_session_id`. Use `expected_outcome=\"note_in_repo\"` for repo notes or `expected_outcome=\"commit\"` for code changes and a signed-off conventional commit. The `repo` + `subpath` combination must exactly match an entry already in the project." + description = "Enqueue an agent session in one of the project's repositories and return immediately with an opaque `repo_session_id`. Use `expected_outcome=\"note_in_repo\"` for repo notes, `expected_outcome=\"commit\"` for code changes and a signed-off conventional commit, or `expected_outcome=\"code_review\"` for an AI code review of the changes on the repo's branch. The `repo` + `subpath` combination must exactly match an entry already in the project." )] async fn start_repo_session( &self, @@ -489,11 +564,53 @@ impl ProjectToolsHandler { Err(e) => return e, }; + // Review sessions only run on review-capable providers, so resolve (and + // validate) the provider before creating any rows — a failure at drain + // time would strand a queued session the drain loop can never start. + // The provider is inherited from the parent project session rather than + // chosen for the review, so one that can't review (e.g. an agent that + // isn't available on remote workstations) falls back to the preferred + // review-capable provider instead of failing a call the agent has no + // provider parameter to work around. + let session_provider = if matches!(p.expected_outcome, RepoSessionOutcome::CodeReview) { + match crate::session_commands::resolve_inherited_review_provider( + self.provider.clone(), + target.branch.workspace_name.is_some(), + ) + .await + { + Ok(provider) => Some(provider), + Err(e) => return e, + } + } else { + self.provider.clone() + }; + + // An in-flight auto review of the same branch would duplicate a requested + // review, and is invalidated by a commit (which triggers a fresh auto review + // once it lands), so cancel it first — same as user-initiated sessions. + if matches!( + p.expected_outcome, + RepoSessionOutcome::CodeReview | RepoSessionOutcome::Commit + ) { + if let Err(e) = crate::session_commands::cancel_in_flight_auto_review_for_branch( + &self.store, + &self.registry, + &target.branch.id, + ) { + return format!("Error cancelling in-flight auto review: {e}"); + } + } + let mut session = crate::store::Session::new_queued(&p.instructions); - if let Some(ref provider) = self.provider { + if let Some(ref provider) = session_provider { session = session.with_provider(provider); } - if let Some(selection) = self.acp_config_selection.clone() { + if let Some(selection) = inherited_acp_config_selection( + self.provider.as_deref(), + session_provider.as_deref(), + self.acp_config_selection.as_ref(), + ) { session = session.with_acp_config_selection(selection); } if let Err(e) = self.store.create_session(&session) { @@ -519,6 +636,22 @@ impl ProjectToolsHandler { } (commit_id, RepoArtifactKind::Commit) } + RepoSessionOutcome::CodeReview => { + // The commit_sha is filled in at drain time, when the workspace + // exists and the branch tip can be read (same as queued user + // review sessions). + let review = crate::store::Review::new( + &target.branch.id, + "", + crate::store::ReviewScope::Branch, + ) + .with_session(&session.id); + let review_id = review.id.clone(); + if let Err(e) = self.store.create_review(&review) { + return format!("Error creating review stub: {e}"); + } + (review_id, RepoArtifactKind::Review) + } }; let repo_session_id = @@ -527,6 +660,11 @@ impl ProjectToolsHandler { Err(e) => return e, }; + // The inherited provider, not the review-resolved one: this argument is the + // branch-wide default the drain pass applies to every queued session without + // a stored provider, so a review's fallback agent must not pull unrelated + // queued sessions off the project session's provider. The session created + // above carries its own resolved provider on its row. if let Err(e) = crate::session_commands::drain_queued_sessions_for_branch( Arc::clone(&self.store), Arc::clone(&self.registry), @@ -546,6 +684,7 @@ impl ProjectToolsHandler { "type": match artifact_kind { RepoArtifactKind::Note => "note", RepoArtifactKind::Commit => "commit", + RepoArtifactKind::Review => "review", }, "id": artifact_id, }, @@ -1020,12 +1159,73 @@ pub async fn start_project_mcp_server( #[cfg(test)] mod tests { use super::{ - worktree_ready_reply, ProjectToolsHandler, RepoArtifactKind, - REPO_SESSION_ACTIVITY_PREVIEW_MAX_CHARS, + comment_line_range, inherited_acp_config_selection, worktree_ready_reply, + ProjectToolsHandler, RepoArtifactKind, REPO_SESSION_ACTIVITY_PREVIEW_MAX_CHARS, + }; + use crate::git::Span; + use crate::store::{ + AcpConfigSelection, AcpConfigValueSelection, MessageRole, Session, SessionMessage, }; - use crate::store::{MessageRole, Session, SessionMessage}; use std::path::Path; + #[test] + fn comment_line_range_reports_one_indexed_inclusive_lines() { + // Stored spans are 0-indexed with an exclusive end, so lines 11..=15 + // of the file are stored as 10..15. + assert_eq!(comment_line_range(Span::new(10, 15)), (11, 15)); + // Single-line comment. + assert_eq!(comment_line_range(Span::new(10, 11)), (11, 11)); + // Empty span: collapse onto the line it anchors to. + assert_eq!(comment_line_range(Span::new(10, 10)), (11, 11)); + // First line of the file. + assert_eq!(comment_line_range(Span::new(0, 1)), (1, 1)); + } + + #[test] + fn inherited_acp_config_selection_follows_the_inherited_provider_only() { + let selection = AcpConfigSelection { + model: Some(AcpConfigValueSelection { + config_id: "model".to_string(), + value_id: "claude-opus-5".to_string(), + label: None, + }), + effort: None, + }; + + // The session runs on the parent's own provider: selection inherited. + assert_eq!( + inherited_acp_config_selection(Some("claude"), Some("claude"), Some(&selection)), + Some(selection.clone()) + ); + + // Review provider resolution fell back to a different agent: the + // parent's config/value IDs don't exist there, so no selection. + assert_eq!( + inherited_acp_config_selection(Some("codex"), Some("claude"), Some(&selection)), + None + ); + + // No inherited provider to compare against (the review resolved the + // preferred provider instead): the selection's provider is unknown, + // so it is dropped rather than risked on the resolved agent. + assert_eq!( + inherited_acp_config_selection(None, Some("claude"), Some(&selection)), + None + ); + + // Non-review outcomes pass the inherited provider through unchanged, + // including when it is absent. + assert_eq!( + inherited_acp_config_selection(None, None, Some(&selection)), + Some(selection) + ); + + assert_eq!( + inherited_acp_config_selection(Some("claude"), Some("claude"), None), + None + ); + } + #[test] fn worktree_ready_reply_only_promises_setup_actions_when_they_can_run() { let with_executor = worktree_ready_reply("block/staged", true); @@ -1061,6 +1261,22 @@ mod tests { assert_eq!(decoded.artifact_id, "commit-456"); } + #[test] + fn review_repo_session_handles_round_trip() { + let encoded = ProjectToolsHandler::encode_repo_session_handle( + "session-123", + RepoArtifactKind::Review, + "review-789", + ) + .expect("handle should encode"); + let decoded = ProjectToolsHandler::decode_repo_session_handle(&encoded) + .expect("handle should decode"); + + assert_eq!(decoded.session_id, "session-123"); + assert!(matches!(decoded.artifact_kind, RepoArtifactKind::Review)); + assert_eq!(decoded.artifact_id, "review-789"); + } + #[test] fn repo_session_handles_reject_invalid_prefix() { let err = ProjectToolsHandler::decode_repo_session_handle("session-123") @@ -1163,6 +1379,45 @@ mod tests { assert!(preview.ends_with("...")); } + #[test] + fn start_repo_session_description_lists_all_outcomes() { + let router = ProjectToolsHandler::tool_router(); + let start_description = router + .get("start_repo_session") + .and_then(|tool| tool.description.as_deref()) + .expect("start tool description"); + + assert!(start_description.contains("note_in_repo")); + assert!(start_description.contains("\"commit\"")); + assert!(start_description.contains("code_review")); + assert!(start_description.contains("AI code review")); + } + + #[test] + fn start_repo_session_schema_lets_review_instructions_stay_empty() { + let router = ProjectToolsHandler::tool_router(); + let instructions = router + .get("start_repo_session") + .and_then(|tool| { + tool.input_schema + .get("properties")? + .get("instructions")? + .get("description")? + .as_str() + .map(ToOwned::to_owned) + }) + .expect("instructions description"); + + assert!( + instructions.contains("code_review"), + "instructions must explain the review outcome: {instructions}" + ); + assert!( + instructions.contains("leave this empty"), + "instructions must say a standard review needs none: {instructions}" + ); + } + #[test] fn repo_session_tool_descriptions_avoid_field_names_and_explain_cancellation() { let router = ProjectToolsHandler::tool_router(); diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index 502fd5be..b9b833d8 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -2178,9 +2178,10 @@ This is a remote-workspace project. Use the project MCP tools to orchestrate wor let start_repo_session_desc = if is_remote { "- start_repo_session: Use this to make changes or run tasks in one of the project's \ repositories. It enqueues work and returns a `repo_session_id` immediately. Use \ -`expected_outcome=\"note_in_repo\"` for repo notes and `expected_outcome=\"commit\"` for \ -code changes/commits; commit sessions create signed-off conventional commits. For remote branches \ -this subagent runs on the remote workspace, where file access, notes, and commits must happen.\n\ +`expected_outcome=\"note_in_repo\"` for repo notes, `expected_outcome=\"commit\"` for \ +code changes/commits, and `expected_outcome=\"code_review\"` for an AI code review of the \ +changes on a repo's branch; commit sessions create signed-off conventional commits. For remote branches \ +this subagent runs on the remote workspace, where file access, notes, commits, and reviews must happen.\n\ - wait_for_repo_session: Use this to wait on a previously started repo session by passing the \ `repo_session_id`. It returns the queue state (`queued`, `running`, `completed`, `cancelled`, \ or `failed`), any available artifacts, and activity details. Prefer another \ @@ -2191,9 +2192,11 @@ down a different path rather than when you are surprised at how long the session } else { "- start_repo_session: Use this to make changes or run tasks in one of the project's \ repositories. It enqueues work and returns a `repo_session_id` immediately. Use \ -`expected_outcome=\"note_in_repo\"` for repo notes and `expected_outcome=\"commit\"` for \ -code changes/commits; commit sessions create signed-off conventional commits. Do not ask for both \ -a note and a commit in a single start_repo_session request — choose one outcome per call. All \ +`expected_outcome=\"note_in_repo\"` for repo notes, `expected_outcome=\"commit\"` for \ +code changes/commits, and `expected_outcome=\"code_review\"` for an AI code review of the \ +changes on a repo's branch; commit sessions create signed-off conventional commits. Do not ask for \ +multiple outcomes (e.g. a note and a commit) in a single start_repo_session request — choose one \ +outcome per call. All \ reasoning specific to a repo must be done within a repo session rather than in this project-wide \ context. You MUST NOT write files directly — all file writes MUST go through start_repo_session \ with expected_outcome=\"commit\".\n\ @@ -3418,7 +3421,10 @@ fn resolve_provider_from_ids( /// When no provider is supplied, mirrors the frontend's `getPreferredAgent` /// logic: read `recent-agents`, filter against available providers, then fall /// back to the first available provider. -fn resolve_review_provider(provider: Option, is_remote: bool) -> Result { +pub(crate) fn resolve_review_provider( + provider: Option, + is_remote: bool, +) -> Result { resolve_provider_from_ids( provider, &available_provider_ids(is_remote), @@ -3427,6 +3433,58 @@ fn resolve_review_provider(provider: Option, is_remote: bool) -> Result< ) } +/// [`resolve_review_provider`] for a provider that was *inherited* rather than +/// chosen — the project session provider a `start_repo_session` review runs +/// with, which the calling agent has no parameter to override. +/// +/// The explicit-provider path of [`resolve_review_provider`] is a hard match: +/// a provider that can't run reviews (not installed locally, or not one of the +/// agents available on remote workstations) is an error. That's right for a +/// provider the user picked, but an inherited one is only a preference, and +/// erroring would leave the caller with no way to get a review at all — so fall +/// back to the preferred review-capable provider instead. +/// +/// `async` because local resolution probes every known agent through a login +/// shell: the MCP `start_repo_session` handler this serves promises to return +/// immediately, so the discovery runs on a blocking thread rather than holding +/// an async worker for the round-trip. +pub(crate) async fn resolve_inherited_review_provider( + provider: Option, + is_remote: bool, +) -> Result { + tokio::task::spawn_blocking(move || { + resolve_inherited_provider_from_ids( + provider, + &available_provider_ids(is_remote), + &read_recent_agent_ids(), + is_remote, + ) + }) + .await + .map_err(|e| format!("Failed to resolve the review provider: {e}"))? +} + +fn resolve_inherited_provider_from_ids( + provider: Option, + available_ids: &[String], + recent_ids: &[String], + is_remote: bool, +) -> Result { + match resolve_provider_from_ids(provider.clone(), available_ids, recent_ids, is_remote) { + Ok(resolved) => Ok(resolved), + // Only the "inherited provider can't review" case falls back; with no + // inherited provider the first call already took the preferred path, so + // retrying it would just repeat the same error. + Err(e) if provider.is_some() => { + log::info!( + "[review] inherited provider {provider:?} unusable for reviews ({e}); falling back to the preferred provider" + ); + resolve_provider_from_ids(None, available_ids, recent_ids, is_remote) + } + Err(e) => Err(e), + } +} + /// Core logic for starting an automatic review for a branch. /// /// Creates a review with `is_auto = true`, starts a session, and emits @@ -6693,6 +6751,42 @@ mod tests { assert!(err.contains("No ACP agent found")); } + #[test] + fn resolve_inherited_provider_falls_back_when_it_cannot_review() { + // A project session running on codex asking for a review on a remote + // branch, where only goose and claude are available. + let resolved = resolve_inherited_provider_from_ids( + Some("codex".to_string()), + &ids(&["goose", "claude"]), + &ids(&["claude"]), + true, + ) + .expect("unusable inherited provider should fall back"); + + assert_eq!(resolved, "claude"); + } + + #[test] + fn resolve_inherited_provider_keeps_a_review_capable_provider() { + let resolved = resolve_inherited_provider_from_ids( + Some("goose".to_string()), + &ids(&["goose", "claude"]), + &ids(&["claude"]), + true, + ) + .expect("review-capable inherited provider should be kept"); + + assert_eq!(resolved, "goose"); + } + + #[test] + fn resolve_inherited_provider_still_errors_without_any_provider() { + let err = resolve_inherited_provider_from_ids(Some("codex".to_string()), &[], &[], true) + .unwrap_err(); + + assert!(err.contains("No remote ACP provider is configured")); + } + #[test] fn infer_branch_resume_session_type_detects_pr_prompts() { assert_eq!( @@ -6772,6 +6866,13 @@ mod tests { assert!(!prompt.contains("repo session is taking a long time")); } + fn assert_project_session_outcome_guidance(prompt: &str) { + assert!(prompt.contains("expected_outcome=\"note_in_repo\"")); + assert!(prompt.contains("expected_outcome=\"commit\"")); + assert!(prompt.contains("expected_outcome=\"code_review\"")); + assert!(prompt.contains("AI code review")); + } + fn assert_pikchr_note_guidance(prompt: &str, reference: &str) { assert!(prompt.contains("Staged notes support rendered diagrams")); assert!(prompt.contains("fenced `pikchr` code blocks")); @@ -6843,6 +6944,7 @@ mod tests { assert_project_session_reference_guidance(&prompt); assert_project_session_repo_session_progress_guidance(&prompt); + assert_project_session_outcome_guidance(&prompt); assert_pikchr_note_guidance(&prompt, PIKCHR_GRAMMAR_URL); assert_note_standalone_output_guidance(&prompt); } @@ -6856,6 +6958,7 @@ mod tests { assert_project_session_reference_guidance(&prompt); assert_project_session_repo_session_progress_guidance(&prompt); + assert_project_session_outcome_guidance(&prompt); assert_pikchr_note_guidance(&prompt, PIKCHR_GRAMMAR_URL); assert_note_standalone_output_guidance(&prompt); }