From 4eeb63a4e99f127d2e01d40d87cd232e77d907e1 Mon Sep 17 00:00:00 2001 From: limityan Date: Mon, 11 May 2026 21:18:54 +0800 Subject: [PATCH 1/2] feat(deep-review): add scoped evidence runtime --- .../agents/prompts/deep_review_agent.md | 24 + .../prompts/review_architecture_agent.md | 13 + .../prompts/review_business_logic_agent.md | 13 + .../agents/prompts/review_frontend_agent.md | 13 + .../prompts/review_performance_agent.md | 13 + .../prompts/review_quality_gate_agent.md | 15 + .../agents/prompts/review_security_agent.md | 13 + .../core/src/agentic/deep_review/budget.rs | 6 + .../agentic/deep_review/concurrency_policy.rs | 5 + .../src/agentic/deep_review/diagnostics.rs | 7 + .../agentic/deep_review/execution_policy.rs | 5 + .../agentic/deep_review/incremental_cache.rs | 4 + .../core/src/agentic/deep_review/manifest.rs | 829 ++++++++++++++++++ .../core/src/agentic/deep_review/mod.rs | 5 + .../core/src/agentic/deep_review/queue.rs | 5 + .../core/src/agentic/deep_review/report.rs | 40 +- .../src/agentic/deep_review/shared_context.rs | 4 + .../src/agentic/deep_review/task_adapter.rs | 60 +- .../src/agentic/deep_review/tool_context.rs | 4 + .../agentic/deep_review/tool_measurement.rs | 4 + .../core/src/agentic/deep_review_policy.rs | 1 + src/crates/core/src/agentic/mod.rs | 1 + .../core/src/agentic/subagent_runtime/mod.rs | 8 + .../agentic/subagent_runtime/queue_timing.rs | 115 +++ .../tools/implementations/code_review_tool.rs | 151 ++++ 25 files changed, 1319 insertions(+), 39 deletions(-) create mode 100644 src/crates/core/src/agentic/subagent_runtime/mod.rs create mode 100644 src/crates/core/src/agentic/subagent_runtime/queue_timing.rs diff --git a/src/crates/core/src/agentic/agents/prompts/deep_review_agent.md b/src/crates/core/src/agentic/agents/prompts/deep_review_agent.md index 1b2058591..09b08eb09 100644 --- a/src/crates/core/src/agentic/agents/prompts/deep_review_agent.md +++ b/src/crates/core/src/agentic/agents/prompts/deep_review_agent.md @@ -27,6 +27,10 @@ The user request may also include a **configured team manifest** with additional The configured manifest may also include an **execution policy** with reviewer timeout, judge timeout, a team review strategy, per-reviewer strategy overrides, preferred reviewer `model_id` values, prompt directives, and file-split parameters. Treat that policy and roster as authoritative. +The configured manifest may also include a **scope profile** with `review_depth`, `risk_focus_tags`, `max_dependency_hops`, `allow_broad_tool_exploration`, and `coverage_expectation`. Treat this as the coverage contract for the run. `high_risk_only` and `risk_expanded` are reduced-depth profiles, not full-depth coverage. + +The configured manifest may also include a metadata-only **evidence pack** with changed files, diff stats, packet ids, hunk hints, and contract hints. Use it as an orientation map only. Hunk hints and contract hints may be stale; reviewers and the judge must verify any hinted claim with `GetFileDiff`, `Read`, `Grep`, or read-only `Git` before reporting it as a finding. + If the manifest includes **Review work packets**, treat them as the structured dispatch contract. Each packet defines the reviewer, assigned scope, allowed tools, timeout, required output fields, model, and prompt directive for one reviewer or judge task. Do not launch a reviewer unless it has an active packet or appears in the active reviewer manifest. ### File splitting for large review targets @@ -140,6 +144,8 @@ Each reviewer Task prompt must include: - the exact review target (for split instances: the assigned file group only) - any user-provided focus text - the reviewer-specific strategy from the configured manifest (`quick`, `normal`, or `deep`) and its exact `prompt_directive` +- the scope profile fields (`review_depth`, `risk_focus_tags`, `max_dependency_hops`, and `coverage_expectation`) +- the evidence pack when present, plus an instruction that it is metadata-only orientation and hinted claims require tool confirmation - a reminder to stay read-only - a request for concrete findings only - a strict output format that is easy to verify later @@ -154,6 +160,18 @@ Strategy guidance (fallback only; the configured `prompt_directive` is the sourc - `normal`: brief the reviewer to run the standard role-specific pass with balanced coverage and concrete evidence. - `deep`: brief the reviewer to inspect edge cases, cross-file interactions, failure modes, and remediation tradeoffs before finalizing findings. +Scope profile guidance: + +- `high_risk_only`: tell the reviewer this is reduced-depth. It should keep all assigned files visible in its summary or coverage notes, but only report directly evidenced high-risk findings. +- `risk_expanded`: tell the reviewer this is reduced-depth. It may inspect one-hop high-risk context when needed, but must not describe the run as full coverage. +- `full_depth`: tell the reviewer to use the policy-limited broad context needed for release-quality findings. + +Evidence pack guidance: + +- Treat `evidence_pack` as metadata orientation only. It is not source text, a full diff, model output, or provider raw data. +- Treat `hunk_hints` and `contract_hints` as stale until the reviewer confirms them with `GetFileDiff`, `Read`, `Grep`, or read-only `Git`. +- Do not let reviewers cite the evidence pack alone as proof for a finding. + Role-specific strategy amplification (append to the reviewer Task prompt when the strategy matches): - **ReviewBusinessLogic** + `quick`: "Only trace logic paths directly changed by the diff. Do not follow call chains beyond one hop." @@ -175,6 +193,8 @@ Role-specific strategy amplification (append to the reviewer Task prompt when th After the reviewer batch finishes, launch `ReviewJudge` with: - the matching judge work packet verbatim +- the scope profile fields and `coverage_expectation` +- the evidence pack when present, with the same metadata-only and tool-confirmation boundary - the same review target - the full reviewer outputs from every reviewer that ran, including timeout/cancel/failure notes - if file splitting was used, include outputs from **all** same-role instances and label each by group (e.g. "Security Reviewer [group 1/3]") @@ -195,6 +215,8 @@ The judge must explicitly call out: - findings where the reviewer's evidence does not support their conclusion - reviewer outputs that are missing `packet_id` or `status`; treat those as lower confidence rather than discarding the whole review - reviewer outputs whose packet id was inferred from scheduling metadata rather than reported by the reviewer +- whether `review_depth` was reduced-depth, and whether reviewer claims stay within the declared `coverage_expectation` +- whether any surviving finding relies on an evidence pack hint without independent tool confirmation - which findings should survive into the final report ### Phase 4: Report and wait for user approval @@ -208,6 +230,7 @@ After the quality gate finishes: - `context_pressure`: large target, constrained token budget, or reduced fan-out affected coverage. - `compression_preserved`: compression or compaction preserved key facts used in the final decision. - `partial_reviewer`: one or more reviewers timed out or were cancelled after producing useful partial evidence. + - `reduced_scope`: the scope profile was `high_risk_only` or `risk_expanded`; include the manifest `coverage_expectation` as detail when available. - `user_decision`: an item needs user/product judgment before remediation. Use `severity = "info" | "warning" | "action"`, include `count` when useful, and set `source = "runtime" | "manifest" | "report" | "inferred"`. 5. When enough information exists, also populate `report_sections` so the UI can present a compact, multi-dimensional report: @@ -223,6 +246,7 @@ After the quality gate finishes: - `remediation_groups.verification`: focused verification or follow-up review steps. - `strength_groups`: positive observations grouped under `architecture`, `maintainability`, `tests`, `security`, `performance`, `user_experience`, or `other`. - `coverage_notes`: confidence, timeout/cancel/failure, scope, or manual follow-up notes. + For reduced-depth scope profiles, explicitly state that the report is not full-depth coverage and preserve all skipped or reduced files in coverage notes when relevant. 6. Do **not** modify any files during the review phase. 7. Wait for explicit user approval before starting any remediation work. diff --git a/src/crates/core/src/agentic/agents/prompts/review_architecture_agent.md b/src/crates/core/src/agentic/agents/prompts/review_architecture_agent.md index 7ee303213..d94775f85 100644 --- a/src/crates/core/src/agentic/agents/prompts/review_architecture_agent.md +++ b/src/crates/core/src/agentic/agents/prompts/review_architecture_agent.md @@ -55,6 +55,19 @@ Never modify files or git state. - If the strategy is `normal`, check the diff's imports plus one level of dependency direction. Verify API contract consistency. - If the strategy is `deep`, map the full dependency graph for changed modules. Check for structural anti-patterns, circular dependencies, and cross-cutting concerns. +## Scope profile rules + +- If the task prompt includes `review_depth` and `coverage_expectation`, follow them as the coverage contract. +- If `review_depth` is `high_risk_only`, treat this as reduced-depth: report only directly evidenced high-risk architecture or boundary issues and do not claim full architecture coverage. +- If `review_depth` is `risk_expanded`, inspect changed files plus at most the provided high-risk dependency context; record any confidence limits in the reviewer summary. +- Keep all assigned files visible in the reviewer summary or coverage notes if you could not inspect them fully. + +## Evidence pack rules + +- If the task prompt includes an `evidence_pack`, use it only as metadata orientation for changed files, packets, hunk hints, and contract hints. +- Treat `hunk_hints` and `contract_hints` as stale until you confirm them with `GetFileDiff`, `Read`, `Grep`, or read-only `Git`. +- Do not cite the evidence pack alone as proof for an architecture finding. + ## Output format Return markdown only, using this exact structure: diff --git a/src/crates/core/src/agentic/agents/prompts/review_business_logic_agent.md b/src/crates/core/src/agentic/agents/prompts/review_business_logic_agent.md index 0669ed784..70cf90759 100644 --- a/src/crates/core/src/agentic/agents/prompts/review_business_logic_agent.md +++ b/src/crates/core/src/agentic/agents/prompts/review_business_logic_agent.md @@ -54,6 +54,19 @@ Never modify files or git state. - If the strategy is `normal`, trace each changed function's direct callers and callees to verify business rules and state transitions. Stop investigating a path once you have enough evidence. - If the strategy is `deep`, map the full call chain for each changed function to verify business rules and state transitions. Check rollback and error-recovery paths, and test edge cases in data shape and lifecycle assumptions. Prioritize findings by user-facing impact. Do not evaluate whether a call chain respects layer boundaries. +## Scope profile rules + +- If the task prompt includes `review_depth` and `coverage_expectation`, follow them as the coverage contract. +- If `review_depth` is `high_risk_only`, treat this as reduced-depth: report only directly evidenced high-risk issues and do not claim full business-logic coverage. +- If `review_depth` is `risk_expanded`, inspect changed files plus at most the provided high-risk dependency context; record any confidence limits in the reviewer summary. +- Keep all assigned files visible in the reviewer summary or coverage notes if you could not inspect them fully. + +## Evidence pack rules + +- If the task prompt includes an `evidence_pack`, use it only as metadata orientation for changed files, packets, hunk hints, and contract hints. +- Treat `hunk_hints` and `contract_hints` as stale until you confirm them with `GetFileDiff`, `Read`, `Grep`, or read-only `Git`. +- Do not cite the evidence pack alone as proof for a business-logic finding. + ## Output format Return markdown only, using this exact structure: diff --git a/src/crates/core/src/agentic/agents/prompts/review_frontend_agent.md b/src/crates/core/src/agentic/agents/prompts/review_frontend_agent.md index e3a180a1c..7cc976337 100644 --- a/src/crates/core/src/agentic/agents/prompts/review_frontend_agent.md +++ b/src/crates/core/src/agentic/agents/prompts/review_frontend_agent.md @@ -60,6 +60,19 @@ Never modify files or git state. - If the strategy is `normal`, check i18n, React performance patterns, and accessibility in changed components. Verify frontend-backend API contract alignment. - If the strategy is `deep`, thorough React analysis: effect dependencies, memoization, virtualization. Full accessibility audit. State management pattern review. Cross-layer contract verification. +## Scope profile rules + +- If the task prompt includes `review_depth` and `coverage_expectation`, follow them as the coverage contract. +- If `review_depth` is `high_risk_only`, treat this as reduced-depth: report only directly evidenced high-risk frontend issues and do not claim full frontend coverage. +- If `review_depth` is `risk_expanded`, inspect changed files plus at most the provided high-risk dependency context; record any confidence limits in the reviewer summary. +- Keep all assigned files visible in the reviewer summary or coverage notes if you could not inspect them fully. + +## Evidence pack rules + +- If the task prompt includes an `evidence_pack`, use it only as metadata orientation for changed files, packets, hunk hints, and contract hints. +- Treat `hunk_hints` and `contract_hints` as stale until you confirm them with `GetFileDiff`, `Read`, `Grep`, or read-only `Git`. +- Do not cite the evidence pack alone as proof for a frontend finding. + ## Output format Return markdown only, using this exact structure: diff --git a/src/crates/core/src/agentic/agents/prompts/review_performance_agent.md b/src/crates/core/src/agentic/agents/prompts/review_performance_agent.md index 719e29fd7..7bd300de3 100644 --- a/src/crates/core/src/agentic/agents/prompts/review_performance_agent.md +++ b/src/crates/core/src/agentic/agents/prompts/review_performance_agent.md @@ -55,6 +55,19 @@ Never modify files or git state. - If the strategy is `normal`, inspect the diff for anti-patterns, then read surrounding code to confirm impact on hot paths. Report only issues likely to matter at realistic scale. - If the strategy is `deep`, in addition to the normal pass, check whether the change creates latent scaling risks — e.g. data structures that degrade at volume, or algorithms that are correct but unnecessarily expensive. Only report if you can quantify or estimate the impact. Do not speculate about edge cases or failure modes unrelated to performance. +## Scope profile rules + +- If the task prompt includes `review_depth` and `coverage_expectation`, follow them as the coverage contract. +- If `review_depth` is `high_risk_only`, treat this as reduced-depth: report only directly evidenced high-risk performance regressions and do not claim full performance coverage. +- If `review_depth` is `risk_expanded`, inspect changed files plus at most the provided high-risk dependency context; record any confidence limits in the reviewer summary. +- Keep all assigned files visible in the reviewer summary or coverage notes if you could not inspect them fully. + +## Evidence pack rules + +- If the task prompt includes an `evidence_pack`, use it only as metadata orientation for changed files, packets, hunk hints, and contract hints. +- Treat `hunk_hints` and `contract_hints` as stale until you confirm them with `GetFileDiff`, `Read`, `Grep`, or read-only `Git`. +- Do not cite the evidence pack alone as proof for a performance finding. + ## Output format Return markdown only, using this exact structure: diff --git a/src/crates/core/src/agentic/agents/prompts/review_quality_gate_agent.md b/src/crates/core/src/agentic/agents/prompts/review_quality_gate_agent.md index d95cd6893..9c41bc206 100644 --- a/src/crates/core/src/agentic/agents/prompts/review_quality_gate_agent.md +++ b/src/crates/core/src/agentic/agents/prompts/review_quality_gate_agent.md @@ -10,6 +10,8 @@ You will receive: - the original review target - the user focus, if any +- the scope profile (`review_depth`, `coverage_expectation`, and related limits), if provided +- the metadata-only evidence pack, if provided - the outputs from the Business Logic Reviewer, Performance Reviewer, Security Reviewer, Architecture Reviewer, and Frontend Reviewer (if present) - if file splitting was used, outputs from **multiple same-role instances** (e.g. "Security Reviewer [group 1/3]", "Security Reviewer [group 2/3]") @@ -44,6 +46,19 @@ Be especially skeptical of: - If the team strategy was `normal`, validate each finding's logical consistency and evidence quality. Spot-check code only when a claim needs verification. - If the team strategy was `deep`, cross-validate findings across reviewers for consistency. For each finding, verify the evidence supports the conclusion and the suggested fix is safe. Pay extra attention to findings that overlap across reviewers or across same-role instances from file splitting. +## Scope profile rules + +- If `review_depth` is `high_risk_only` or `risk_expanded`, treat the review as reduced-depth and do not validate any summary that claims full-depth coverage. +- Preserve `coverage_expectation` in your decision summary or coverage notes when it limits confidence. +- Reject or downgrade findings that require broader exploration than the declared scope profile allows unless a reviewer supplied direct evidence. +- Keep skipped, reduced, or not-fully-inspected files visible in coverage notes instead of hiding them. + +## Evidence pack rules + +- Use `evidence_pack` only as metadata orientation for changed files, packets, hunk hints, and contract hints. +- Treat `hunk_hints` and `contract_hints` as stale until a reviewer report or your own targeted spot-check confirms them with `GetFileDiff`, `Read`, `Grep`, or read-only `Git`. +- Reject or downgrade findings that rely on the evidence pack alone. + ## Cross-reviewer overlap handling When multiple reviewers report findings about the same code location: diff --git a/src/crates/core/src/agentic/agents/prompts/review_security_agent.md b/src/crates/core/src/agentic/agents/prompts/review_security_agent.md index 3cf7b2e5d..caa382d85 100644 --- a/src/crates/core/src/agentic/agents/prompts/review_security_agent.md +++ b/src/crates/core/src/agentic/agents/prompts/review_security_agent.md @@ -55,6 +55,19 @@ Never modify files or git state. - If the strategy is `normal`, trace each changed input path from entry point to usage. Check trust boundaries, auth assumptions, and data sanitization. Report only issues with a realistic threat narrative. - If the strategy is `deep`, in addition to the normal pass, trace data flows across trust boundaries end-to-end. Check for privilege escalation chains, indirect injection vectors, and failure modes that expose sensitive data. Report only issues with a complete threat narrative. +## Scope profile rules + +- If the task prompt includes `review_depth` and `coverage_expectation`, follow them as the coverage contract. +- If `review_depth` is `high_risk_only`, treat this as reduced-depth: report only directly evidenced high-risk security issues and do not claim full security coverage. +- If `review_depth` is `risk_expanded`, inspect changed files plus at most the provided high-risk dependency context; record any confidence limits in the reviewer summary. +- Keep all assigned files visible in the reviewer summary or coverage notes if you could not inspect them fully. + +## Evidence pack rules + +- If the task prompt includes an `evidence_pack`, use it only as metadata orientation for changed files, packets, hunk hints, and contract hints. +- Treat `hunk_hints` and `contract_hints` as stale until you confirm them with `GetFileDiff`, `Read`, `Grep`, or read-only `Git`. +- Do not cite the evidence pack alone as proof for a security finding. + ## Output format Return markdown only, using this exact structure: diff --git a/src/crates/core/src/agentic/deep_review/budget.rs b/src/crates/core/src/agentic/deep_review/budget.rs index 2e34b1a5c..5c150366c 100644 --- a/src/crates/core/src/agentic/deep_review/budget.rs +++ b/src/crates/core/src/agentic/deep_review/budget.rs @@ -1,4 +1,10 @@ //! Deep Review reviewer budget, retry admission, and runtime accounting. +//! +//! This tracker is deliberately Deep Review-specific. It combines per-turn +//! reviewer/judge budgets, retry budgets, active reviewer counts, effective +//! concurrency learning, capacity diagnostics, and shared-context measurement. +//! Do not move it wholesale to `subagent_runtime`: only isolated mechanics with +//! no Deep Review policy, report, or diagnostic semantics should become generic. use super::concurrency_policy::{ DeepReviewEffectiveConcurrencySnapshot, DeepReviewEffectiveConcurrencyState, diff --git a/src/crates/core/src/agentic/deep_review/concurrency_policy.rs b/src/crates/core/src/agentic/deep_review/concurrency_policy.rs index de9a051fb..a650d3084 100644 --- a/src/crates/core/src/agentic/deep_review/concurrency_policy.rs +++ b/src/crates/core/src/agentic/deep_review/concurrency_policy.rs @@ -1,4 +1,9 @@ //! Deep Review concurrency limits and effective capacity learning. +//! +//! The policy here is product-specific: it learns an effective reviewer cap for +//! Deep Review sessions and stores the Review Team capacity preferences. Shared +//! queue timing or future generic admission primitives belong in +//! `agentic::subagent_runtime` once they are proven independent of Deep Review. use super::execution_policy::{ clamp_u64, clamp_usize, reviewer_agent_type_count, DeepReviewExecutionPolicy, diff --git a/src/crates/core/src/agentic/deep_review/diagnostics.rs b/src/crates/core/src/agentic/deep_review/diagnostics.rs index d9737784e..bdd626287 100644 --- a/src/crates/core/src/agentic/deep_review/diagnostics.rs +++ b/src/crates/core/src/agentic/deep_review/diagnostics.rs @@ -1,4 +1,8 @@ //! Content-free Deep Review runtime diagnostics counters. +//! +//! These counters are safe to surface in reports and logs because they record +//! aggregate counts, durations, and reason labels only. They must not store +//! source text, diffs, reviewer output, provider raw bodies, or full file paths. use serde::Serialize; use std::collections::BTreeMap; @@ -26,6 +30,7 @@ pub struct DeepReviewRuntimeDiagnostics { pub shared_context_total_calls: usize, pub shared_context_duplicate_calls: usize, pub shared_context_duplicate_context_count: usize, + pub shared_context_duplicate_savings_candidate_count: usize, } impl DeepReviewRuntimeDiagnostics { @@ -52,6 +57,7 @@ impl DeepReviewRuntimeDiagnostics { && self.shared_context_total_calls == 0 && self.shared_context_duplicate_calls == 0 && self.shared_context_duplicate_context_count == 0 + && self.shared_context_duplicate_savings_candidate_count == 0 } pub(crate) fn observe_effective_parallel(&mut self, effective_parallel_instances: usize) { @@ -73,5 +79,6 @@ impl DeepReviewRuntimeDiagnostics { self.shared_context_total_calls = total_calls; self.shared_context_duplicate_calls = duplicate_calls; self.shared_context_duplicate_context_count = duplicate_context_count; + self.shared_context_duplicate_savings_candidate_count = duplicate_calls; } } diff --git a/src/crates/core/src/agentic/deep_review/execution_policy.rs b/src/crates/core/src/agentic/deep_review/execution_policy.rs index 4689c243b..097b2fdc1 100644 --- a/src/crates/core/src/agentic/deep_review/execution_policy.rs +++ b/src/crates/core/src/agentic/deep_review/execution_policy.rs @@ -1,4 +1,9 @@ //! Deep Review execution policy parsing and strategy helpers. +//! +//! This module translates launch strategy metadata into runtime guardrails such +//! as reviewer timeouts, file-splitting thresholds, same-role caps, and retry +//! limits. Strategy scoring remains advisory unless a separate product decision +//! approves backend-owned strategy selection. use super::constants::{ CONDITIONAL_REVIEWER_AGENT_TYPES, CORE_REVIEWER_AGENT_TYPES, DEEP_REVIEW_AGENT_TYPE, diff --git a/src/crates/core/src/agentic/deep_review/incremental_cache.rs b/src/crates/core/src/agentic/deep_review/incremental_cache.rs index b5f98bb6d..48688847a 100644 --- a/src/crates/core/src/agentic/deep_review/incremental_cache.rs +++ b/src/crates/core/src/agentic/deep_review/incremental_cache.rs @@ -1,4 +1,8 @@ //! Per-session Deep Review packet cache model and serialization. +//! +//! This cache is scoped to a Deep Review session fingerprint. It is not a +//! project-level cache and does not define retention, invalidation, or deletion +//! policy across sessions. use serde_json::{json, Value}; use std::collections::HashMap; diff --git a/src/crates/core/src/agentic/deep_review/manifest.rs b/src/crates/core/src/agentic/deep_review/manifest.rs index cd26b641b..f1b185f15 100644 --- a/src/crates/core/src/agentic/deep_review/manifest.rs +++ b/src/crates/core/src/agentic/deep_review/manifest.rs @@ -1,8 +1,567 @@ //! Typed Deep Review launch manifest accessors. +//! +//! The frontend builds the launch manifest, but Rust owns defensive parsing and +//! the final trust boundary. Accessors in this module must remain backward +//! compatible with older manifest field spellings and should not silently hide +//! reduced coverage, omitted files, or stale evidence hints. use super::execution_policy::DeepReviewPolicyViolation; use serde_json::Value; use std::collections::{HashMap, HashSet}; +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DeepReviewScopeProfile { + review_depth: String, + risk_focus_tags: Vec, + max_dependency_hops: Option, + optional_reviewer_policy: Option, + allow_broad_tool_exploration: bool, + coverage_expectation: Option, +} + +impl DeepReviewScopeProfile { + pub(crate) fn from_manifest(raw: &Value) -> Option { + let manifest = raw.as_object()?; + let review_mode = string_for_any_key(raw, &["reviewMode", "review_mode"])?; + if review_mode != "deep" { + return None; + } + + let profile = manifest + .get("scopeProfile") + .or_else(|| manifest.get("scope_profile"))? + .as_object()?; + let review_depth = profile + .get("reviewDepth") + .or_else(|| profile.get("review_depth")) + .and_then(normalized_non_empty_string)?; + if !matches!( + review_depth.as_str(), + "high_risk_only" | "risk_expanded" | "full_depth" + ) { + return None; + } + + let risk_focus_tags = profile + .get("riskFocusTags") + .or_else(|| profile.get("risk_focus_tags")) + .and_then(Value::as_array) + .map(|tags| { + tags.iter() + .filter_map(|tag| tag.as_str().map(str::trim)) + .filter(|tag| !tag.is_empty()) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default(); + + Some(Self { + review_depth, + risk_focus_tags, + max_dependency_hops: profile + .get("maxDependencyHops") + .or_else(|| profile.get("max_dependency_hops")) + .and_then(scope_dependency_hops_to_string), + optional_reviewer_policy: profile + .get("optionalReviewerPolicy") + .or_else(|| profile.get("optional_reviewer_policy")) + .and_then(normalized_non_empty_string), + allow_broad_tool_exploration: profile + .get("allowBroadToolExploration") + .or_else(|| profile.get("allow_broad_tool_exploration")) + .and_then(Value::as_bool) + .unwrap_or(false), + coverage_expectation: profile + .get("coverageExpectation") + .or_else(|| profile.get("coverage_expectation")) + .and_then(normalized_non_empty_string), + }) + } + + pub(crate) fn review_depth(&self) -> &str { + &self.review_depth + } + + pub(crate) fn risk_focus_tags(&self) -> &[String] { + &self.risk_focus_tags + } + + pub(crate) fn max_dependency_hops(&self) -> Option<&str> { + self.max_dependency_hops.as_deref() + } + + pub(crate) fn optional_reviewer_policy(&self) -> Option<&str> { + self.optional_reviewer_policy.as_deref() + } + + pub(crate) fn allow_broad_tool_exploration(&self) -> bool { + self.allow_broad_tool_exploration + } + + pub(crate) fn coverage_expectation(&self) -> Option<&str> { + self.coverage_expectation.as_deref() + } + + pub(crate) fn is_reduced_depth(&self) -> bool { + self.review_depth != "full_depth" + } +} + +fn value_for_any_key<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a Value> { + keys.iter().find_map(|key| value.get(*key)) +} + +fn normalized_non_empty_string(value: &Value) -> Option { + value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn string_for_any_key(value: &Value, keys: &[&str]) -> Option { + value_for_any_key(value, keys).and_then(normalized_non_empty_string) +} + +fn scope_dependency_hops_to_string(value: &Value) -> Option { + if let Some(hops) = value.as_u64() { + return Some(hops.to_string()); + } + normalized_non_empty_string(value) +} + +const EVIDENCE_PACK_CHANGED_FILE_LIMIT: usize = 80; +const EVIDENCE_PACK_HUNK_HINT_LIMIT: usize = 80; +const EVIDENCE_PACK_CONTRACT_HINT_LIMIT: usize = 40; +const EVIDENCE_PACK_PACKET_ID_LIMIT: usize = 256; +const EVIDENCE_PACK_TAG_LIMIT: usize = 32; +const EVIDENCE_PACK_PRIVACY_EXCLUDES: &[&str] = &[ + "source_text", + "full_diff", + "model_output", + "provider_raw_body", + "full_file_contents", +]; +const EVIDENCE_PACK_FORBIDDEN_KEYS: &[&str] = &[ + "sourceText", + "source_text", + "fullDiff", + "full_diff", + "modelOutput", + "model_output", + "providerRawBody", + "provider_raw_body", + "fullFileContents", + "full_file_contents", +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DeepReviewEvidencePack { + version: u64, + source: String, + changed_files: Vec, + packet_ids: Vec, + hunk_hint_count: usize, + contract_hint_count: usize, + content_boundary: String, +} + +impl DeepReviewEvidencePack { + pub(crate) fn from_manifest( + raw: &Value, + ) -> Result, DeepReviewEvidencePackValidationError> { + if string_for_any_key(raw, &["reviewMode", "review_mode"]).as_deref() != Some("deep") { + return Ok(None); + } + + let Some(pack) = value_for_any_key(raw, &["evidencePack", "evidence_pack"]) else { + return Ok(None); + }; + ensure_object(pack, "evidencePack")?; + if let Some(key) = forbidden_evidence_pack_key(pack) { + return Err(DeepReviewEvidencePackValidationError::new(format!( + "forbidden evidence pack field '{}'", + key + ))); + } + + let version = required_u64_for_any_key(pack, &["version"], "version")?; + if version != 1 { + return Err(DeepReviewEvidencePackValidationError::invalid_field( + "version", + "expected 1", + )); + } + + let source = required_string_for_any_key(pack, &["source"], "source")?; + if source != "target_manifest" { + return Err(DeepReviewEvidencePackValidationError::invalid_field( + "source", + "expected target_manifest", + )); + } + + let changed_files = required_string_array_for_any_key( + pack, + &["changedFiles", "changed_files"], + "changedFiles", + EVIDENCE_PACK_CHANGED_FILE_LIMIT, + )?; + let domain_tags = required_string_array_for_any_key( + pack, + &["domainTags", "domain_tags"], + "domainTags", + EVIDENCE_PACK_TAG_LIMIT, + )?; + let risk_focus_tags = required_string_array_for_any_key( + pack, + &["riskFocusTags", "risk_focus_tags"], + "riskFocusTags", + EVIDENCE_PACK_TAG_LIMIT, + )?; + let packet_ids = required_string_array_for_any_key( + pack, + &["packetIds", "packet_ids"], + "packetIds", + EVIDENCE_PACK_PACKET_ID_LIMIT, + )?; + + let diff_stat = required_value_for_any_key(pack, &["diffStat", "diff_stat"], "diffStat")?; + ensure_object(diff_stat, "diffStat")?; + required_u64_for_any_key(diff_stat, &["fileCount", "file_count"], "diffStat.fileCount")?; + required_string_for_any_key( + diff_stat, + &["lineCountSource", "line_count_source"], + "diffStat.lineCountSource", + )?; + + let hunk_hints = required_array_for_any_key( + pack, + &["hunkHints", "hunk_hints"], + "hunkHints", + EVIDENCE_PACK_HUNK_HINT_LIMIT, + )?; + for hint in hunk_hints { + ensure_object(hint, "hunkHints[]")?; + required_string_for_any_key(hint, &["filePath", "file_path"], "hunkHints[].filePath")?; + required_u64_for_any_key( + hint, + &["changedLineCount", "changed_line_count"], + "hunkHints[].changedLineCount", + )?; + required_string_for_any_key( + hint, + &["lineCountSource", "line_count_source"], + "hunkHints[].lineCountSource", + )?; + } + + let contract_hints = required_array_for_any_key( + pack, + &["contractHints", "contract_hints"], + "contractHints", + EVIDENCE_PACK_CONTRACT_HINT_LIMIT, + )?; + for hint in contract_hints { + ensure_object(hint, "contractHints[]")?; + let kind = + required_string_for_any_key(hint, &["kind"], "contractHints[].kind")?; + if !matches!( + kind.as_str(), + "i18n_key" | "tauri_command" | "api_contract" | "config_key" + ) { + return Err(DeepReviewEvidencePackValidationError::invalid_field( + "contractHints[].kind", + "unknown contract hint kind", + )); + } + required_string_for_any_key( + hint, + &["filePath", "file_path"], + "contractHints[].filePath", + )?; + let hint_source = + required_string_for_any_key(hint, &["source"], "contractHints[].source")?; + if hint_source != "path_classifier" { + return Err(DeepReviewEvidencePackValidationError::invalid_field( + "contractHints[].source", + "expected path_classifier", + )); + } + } + + validate_evidence_pack_budget(pack)?; + let content_boundary = validate_evidence_pack_privacy(pack)?; + + let _ = (domain_tags, risk_focus_tags); + + Ok(Some(Self { + version, + source, + changed_files, + packet_ids, + hunk_hint_count: hunk_hints.len(), + contract_hint_count: contract_hints.len(), + content_boundary, + })) + } + + pub(crate) fn version(&self) -> u64 { + self.version + } + + pub(crate) fn source(&self) -> &str { + &self.source + } + + pub(crate) fn changed_files(&self) -> &[String] { + &self.changed_files + } + + pub(crate) fn packet_ids(&self) -> &[String] { + &self.packet_ids + } + + pub(crate) fn hunk_hint_count(&self) -> usize { + self.hunk_hint_count + } + + pub(crate) fn contract_hint_count(&self) -> usize { + self.contract_hint_count + } + + pub(crate) fn content_boundary(&self) -> &str { + &self.content_boundary + } + + pub(crate) fn requires_tool_confirmation(&self) -> bool { + true + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DeepReviewEvidencePackValidationError { + detail: String, +} + +impl DeepReviewEvidencePackValidationError { + fn new(detail: impl Into) -> Self { + Self { + detail: detail.into(), + } + } + + fn missing_field(field: &'static str) -> Self { + Self::new(format!("missing evidence pack field '{}'", field)) + } + + fn invalid_field(field: &'static str, reason: &'static str) -> Self { + Self::new(format!( + "invalid evidence pack field '{}': {}", + field, reason + )) + } + + fn too_many_items(field: &'static str, max: usize, actual: usize) -> Self { + Self::new(format!( + "too many evidence pack items in '{}': max {}, got {}", + field, max, actual + )) + } +} + +impl fmt::Display for DeepReviewEvidencePackValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.detail) + } +} + +fn ensure_object(value: &Value, field: &'static str) -> Result<(), DeepReviewEvidencePackValidationError> { + if value.is_object() { + Ok(()) + } else { + Err(DeepReviewEvidencePackValidationError::invalid_field( + field, + "expected object", + )) + } +} + +fn required_value_for_any_key<'a>( + value: &'a Value, + keys: &[&str], + field: &'static str, +) -> Result<&'a Value, DeepReviewEvidencePackValidationError> { + value_for_any_key(value, keys) + .ok_or_else(|| DeepReviewEvidencePackValidationError::missing_field(field)) +} + +fn required_string_for_any_key( + value: &Value, + keys: &[&str], + field: &'static str, +) -> Result { + string_for_any_key(value, keys) + .ok_or_else(|| DeepReviewEvidencePackValidationError::invalid_field(field, "expected non-empty string")) +} + +fn required_u64_for_any_key( + value: &Value, + keys: &[&str], + field: &'static str, +) -> Result { + required_value_for_any_key(value, keys, field)? + .as_u64() + .ok_or_else(|| DeepReviewEvidencePackValidationError::invalid_field(field, "expected unsigned integer")) +} + +fn required_array_for_any_key<'a>( + value: &'a Value, + keys: &[&str], + field: &'static str, + max: usize, +) -> Result<&'a Vec, DeepReviewEvidencePackValidationError> { + let array = required_value_for_any_key(value, keys, field)? + .as_array() + .ok_or_else(|| DeepReviewEvidencePackValidationError::invalid_field(field, "expected array"))?; + if array.len() > max { + return Err(DeepReviewEvidencePackValidationError::too_many_items( + field, + max, + array.len(), + )); + } + Ok(array) +} + +fn required_string_array_for_any_key( + value: &Value, + keys: &[&str], + field: &'static str, + max: usize, +) -> Result, DeepReviewEvidencePackValidationError> { + required_array_for_any_key(value, keys, field, max)? + .iter() + .map(|item| { + item.as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + DeepReviewEvidencePackValidationError::invalid_field( + field, + "expected non-empty string items", + ) + }) + }) + .collect() +} + +fn validate_evidence_pack_budget( + pack: &Value, +) -> Result<(), DeepReviewEvidencePackValidationError> { + let budget = required_value_for_any_key(pack, &["budget"], "budget")?; + ensure_object(budget, "budget")?; + validate_budget_cap( + budget, + &["maxChangedFiles", "max_changed_files"], + "budget.maxChangedFiles", + EVIDENCE_PACK_CHANGED_FILE_LIMIT, + )?; + validate_budget_cap( + budget, + &["maxHunkHints", "max_hunk_hints"], + "budget.maxHunkHints", + EVIDENCE_PACK_HUNK_HINT_LIMIT, + )?; + validate_budget_cap( + budget, + &["maxContractHints", "max_contract_hints"], + "budget.maxContractHints", + EVIDENCE_PACK_CONTRACT_HINT_LIMIT, + )?; + required_u64_for_any_key( + budget, + &["omittedChangedFileCount", "omitted_changed_file_count"], + "budget.omittedChangedFileCount", + )?; + required_u64_for_any_key( + budget, + &["omittedHunkHintCount", "omitted_hunk_hint_count"], + "budget.omittedHunkHintCount", + )?; + required_u64_for_any_key( + budget, + &["omittedContractHintCount", "omitted_contract_hint_count"], + "budget.omittedContractHintCount", + )?; + Ok(()) +} + +fn validate_budget_cap( + budget: &Value, + keys: &[&str], + field: &'static str, + max: usize, +) -> Result<(), DeepReviewEvidencePackValidationError> { + let cap = required_u64_for_any_key(budget, keys, field)?; + if cap as usize > max { + return Err(DeepReviewEvidencePackValidationError::invalid_field( + field, + "exceeds supported manifest cap", + )); + } + Ok(()) +} + +fn validate_evidence_pack_privacy( + pack: &Value, +) -> Result { + let privacy = required_value_for_any_key(pack, &["privacy"], "privacy")?; + ensure_object(privacy, "privacy")?; + let content = required_string_for_any_key(privacy, &["content"], "privacy.content")?; + if content != "metadata_only" { + return Err(DeepReviewEvidencePackValidationError::invalid_field( + "privacy.content", + "expected metadata_only", + )); + } + let excludes = required_string_array_for_any_key( + privacy, + &["excludes"], + "privacy.excludes", + EVIDENCE_PACK_PRIVACY_EXCLUDES.len(), + )?; + let excludes = excludes.into_iter().collect::>(); + for required in EVIDENCE_PACK_PRIVACY_EXCLUDES { + if !excludes.contains(*required) { + return Err(DeepReviewEvidencePackValidationError::invalid_field( + "privacy.excludes", + "missing required excluded content type", + )); + } + } + Ok(content) +} + +fn forbidden_evidence_pack_key(value: &Value) -> Option { + match value { + Value::Object(map) => { + for (key, child) in map { + if EVIDENCE_PACK_FORBIDDEN_KEYS.contains(&key.as_str()) { + return Some(key.clone()); + } + if let Some(nested) = forbidden_evidence_pack_key(child) { + return Some(nested); + } + } + None + } + Value::Array(items) => items.iter().find_map(forbidden_evidence_pack_key), + _ => None, + } +} #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeepReviewRunManifestGate { @@ -104,3 +663,273 @@ fn manifest_member_subagent_id(value: &Value) -> Option { .trim(); (!id.is_empty()).then(|| id.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Value}; + + #[test] + fn scope_profile_parses_camel_case_manifest() { + let manifest = json!({ + "reviewMode": "deep", + "scopeProfile": { + "reviewDepth": "high_risk_only", + "riskFocusTags": ["security", "cross_boundary_api_contracts"], + "maxDependencyHops": 0, + "optionalReviewerPolicy": "risk_matched_only", + "allowBroadToolExploration": false, + "coverageExpectation": "High-risk-only pass." + } + }); + + let profile = DeepReviewScopeProfile::from_manifest(&manifest) + .expect("scope profile should parse"); + + assert_eq!(profile.review_depth(), "high_risk_only"); + assert_eq!( + profile + .risk_focus_tags() + .iter() + .map(String::as_str) + .collect::>(), + vec!["security", "cross_boundary_api_contracts"] + ); + assert_eq!(profile.max_dependency_hops(), Some("0")); + assert_eq!(profile.optional_reviewer_policy(), Some("risk_matched_only")); + assert!(!profile.allow_broad_tool_exploration()); + assert_eq!(profile.coverage_expectation(), Some("High-risk-only pass.")); + assert!(profile.is_reduced_depth()); + } + + #[test] + fn scope_profile_parses_snake_case_manifest() { + let manifest = json!({ + "review_mode": "deep", + "scope_profile": { + "review_depth": "full_depth", + "risk_focus_tags": ["security"], + "max_dependency_hops": "policy_limited", + "optional_reviewer_policy": "full", + "allow_broad_tool_exploration": true, + "coverage_expectation": "Full-depth pass." + } + }); + + let profile = DeepReviewScopeProfile::from_manifest(&manifest) + .expect("scope profile should parse"); + + assert_eq!(profile.review_depth(), "full_depth"); + assert_eq!(profile.max_dependency_hops(), Some("policy_limited")); + assert!(profile.allow_broad_tool_exploration()); + assert!(!profile.is_reduced_depth()); + } + + #[test] + fn scope_profile_missing_stays_compatible_with_legacy_manifest() { + let manifest = json!({ + "reviewMode": "deep", + "workPackets": [] + }); + + assert!(DeepReviewScopeProfile::from_manifest(&manifest).is_none()); + } + + fn valid_evidence_pack_manifest() -> Value { + json!({ + "reviewMode": "deep", + "evidencePack": { + "version": 1, + "source": "target_manifest", + "changedFiles": ["src/crates/api-layer/src/review.rs"], + "diffStat": { + "fileCount": 1, + "totalChangedLines": 4, + "lineCountSource": "diff_stat" + }, + "domainTags": ["api_layer"], + "riskFocusTags": ["cross_boundary_api_contracts"], + "packetIds": ["reviewer:ReviewArchitecture", "judge:ReviewJudge"], + "hunkHints": [ + { + "filePath": "src/crates/api-layer/src/review.rs", + "changedLineCount": 4, + "lineCountSource": "diff_stat" + } + ], + "contractHints": [ + { + "kind": "api_contract", + "filePath": "src/crates/api-layer/src/review.rs", + "source": "path_classifier" + } + ], + "budget": { + "maxChangedFiles": 80, + "maxHunkHints": 80, + "maxContractHints": 40, + "omittedChangedFileCount": 0, + "omittedHunkHintCount": 0, + "omittedContractHintCount": 0 + }, + "privacy": { + "content": "metadata_only", + "excludes": [ + "source_text", + "full_diff", + "model_output", + "provider_raw_body", + "full_file_contents" + ] + } + } + }) + } + + #[test] + fn evidence_pack_parses_metadata_only_manifest() { + let manifest = valid_evidence_pack_manifest(); + + let pack = DeepReviewEvidencePack::from_manifest(&manifest) + .expect("evidence pack should validate") + .expect("evidence pack should be present"); + + assert_eq!(pack.version(), 1); + assert_eq!(pack.source(), "target_manifest"); + assert_eq!(pack.content_boundary(), "metadata_only"); + assert_eq!( + pack.changed_files() + .iter() + .map(String::as_str) + .collect::>(), + vec!["src/crates/api-layer/src/review.rs"] + ); + assert_eq!( + pack.packet_ids() + .iter() + .map(String::as_str) + .collect::>(), + vec!["reviewer:ReviewArchitecture", "judge:ReviewJudge"] + ); + assert_eq!(pack.hunk_hint_count(), 1); + assert_eq!(pack.contract_hint_count(), 1); + assert!(pack.requires_tool_confirmation()); + } + + #[test] + fn evidence_pack_parses_snake_case_manifest() { + let manifest = json!({ + "review_mode": "deep", + "evidence_pack": { + "version": 1, + "source": "target_manifest", + "changed_files": ["src/web-ui/src/locales/en-US/flow-chat.json"], + "diff_stat": { + "file_count": 1, + "total_changed_lines": 2, + "line_count_source": "diff_stat" + }, + "domain_tags": ["frontend_i18n"], + "risk_focus_tags": ["configuration_changes"], + "packet_ids": ["reviewer:ReviewFrontend"], + "hunk_hints": [ + { + "file_path": "src/web-ui/src/locales/en-US/flow-chat.json", + "changed_line_count": 2, + "line_count_source": "diff_stat" + } + ], + "contract_hints": [ + { + "kind": "i18n_key", + "file_path": "src/web-ui/src/locales/en-US/flow-chat.json", + "source": "path_classifier" + } + ], + "budget": { + "max_changed_files": 80, + "max_hunk_hints": 80, + "max_contract_hints": 40, + "omitted_changed_file_count": 0, + "omitted_hunk_hint_count": 0, + "omitted_contract_hint_count": 0 + }, + "privacy": { + "content": "metadata_only", + "excludes": [ + "source_text", + "full_diff", + "model_output", + "provider_raw_body", + "full_file_contents" + ] + } + } + }); + + let pack = DeepReviewEvidencePack::from_manifest(&manifest) + .expect("snake-case evidence pack should validate") + .expect("evidence pack should be present"); + + assert_eq!(pack.changed_files()[0], "src/web-ui/src/locales/en-US/flow-chat.json"); + assert_eq!(pack.contract_hint_count(), 1); + } + + #[test] + fn evidence_pack_missing_stays_compatible_with_legacy_manifest() { + let manifest = json!({ + "reviewMode": "deep", + "workPackets": [] + }); + + assert_eq!( + DeepReviewEvidencePack::from_manifest(&manifest).expect("legacy manifest should parse"), + None + ); + } + + #[test] + fn evidence_pack_rejects_forbidden_source_or_diff_payload_keys() { + let mut manifest = valid_evidence_pack_manifest(); + manifest["evidencePack"]["sourceText"] = json!("fn main() {}"); + + let error = DeepReviewEvidencePack::from_manifest(&manifest) + .expect_err("source text must not be accepted"); + + assert!(error.to_string().contains("forbidden evidence pack field")); + assert!(error.to_string().contains("sourceText")); + } + + #[test] + fn evidence_pack_rejects_non_metadata_privacy_boundary() { + let mut manifest = valid_evidence_pack_manifest(); + manifest["evidencePack"]["privacy"]["content"] = json!("full_diff"); + + let error = DeepReviewEvidencePack::from_manifest(&manifest) + .expect_err("full diff content must not be accepted"); + + assert!(error.to_string().contains("privacy.content")); + assert!(error.to_string().contains("metadata_only")); + } + + #[test] + fn evidence_pack_rejects_oversized_hunk_hint_arrays() { + let mut manifest = valid_evidence_pack_manifest(); + let hunk_hints = (0..=EVIDENCE_PACK_HUNK_HINT_LIMIT) + .map(|index| { + json!({ + "filePath": format!("src/lib_{index}.rs"), + "changedLineCount": 1, + "lineCountSource": "diff_stat" + }) + }) + .collect::>(); + manifest["evidencePack"]["hunkHints"] = json!(hunk_hints); + + let error = DeepReviewEvidencePack::from_manifest(&manifest) + .expect_err("oversized hunk hints must be rejected"); + + assert!(error.to_string().contains("hunkHints")); + assert!(error.to_string().contains("max 80")); + } +} diff --git a/src/crates/core/src/agentic/deep_review/mod.rs b/src/crates/core/src/agentic/deep_review/mod.rs index 71d90f8c7..52b5684a7 100644 --- a/src/crates/core/src/agentic/deep_review/mod.rs +++ b/src/crates/core/src/agentic/deep_review/mod.rs @@ -1,4 +1,9 @@ //! Deep Review runtime policy modules and tool adapters. +//! +//! Keep user-facing review semantics, manifest parsing, queue policy, retry +//! policy, and report shaping here. Reusable subagent runtime mechanics should +//! move to `agentic::subagent_runtime` only when they do not depend on Deep +//! Review roles, manifests, queue reasons, or reliability wording. pub mod budget; pub mod concurrency_policy; diff --git a/src/crates/core/src/agentic/deep_review/queue.rs b/src/crates/core/src/agentic/deep_review/queue.rs index 80c5cfa73..1bda8d710 100644 --- a/src/crates/core/src/agentic/deep_review/queue.rs +++ b/src/crates/core/src/agentic/deep_review/queue.rs @@ -1,4 +1,9 @@ //! Deep Review queue state, controls, and capacity error classification. +//! +//! This module owns reviewer-specific queue reasons, user controls, and +//! provider/local capacity classification. Generic queue wait mechanics remain +//! in `agentic::subagent_runtime`, so ordinary subagents do not inherit Deep +//! Review product behavior by importing this module. use dashmap::DashMap; use serde::Serialize; diff --git a/src/crates/core/src/agentic/deep_review/report.rs b/src/crates/core/src/agentic/deep_review/report.rs index 6f3c773bd..1126d4897 100644 --- a/src/crates/core/src/agentic/deep_review/report.rs +++ b/src/crates/core/src/agentic/deep_review/report.rs @@ -1,9 +1,15 @@ //! Deep Review report enrichment, diagnostics logging, and cache write-through. +//! +//! Report enrichment must be honest about queue skips, retries, reduced-depth +//! coverage, evidence hints, and cache reuse. Standard Code Review output +//! should only receive Deep Review-only metadata when the tool context proves a +//! Deep Review run is active. use crate::agentic::agents::get_agent_registry; use crate::agentic::context_profile::ContextProfilePolicy; use crate::agentic::coordination::get_global_coordinator; use crate::agentic::core::CompressionContract; +use crate::agentic::deep_review::manifest::{DeepReviewEvidencePack, DeepReviewScopeProfile}; use crate::agentic::deep_review_policy::{ deep_review_capacity_skip_count, deep_review_concurrency_cap_rejection_count, deep_review_runtime_diagnostics_snapshot, DeepReviewIncrementalCache, @@ -345,6 +351,34 @@ pub(crate) fn fill_deep_review_reliability_signals( run_manifest: Option<&Value>, compression_contract: Option<&CompressionContract>, ) { + if let Some(scope_profile) = run_manifest.and_then(DeepReviewScopeProfile::from_manifest) { + if scope_profile.is_reduced_depth() { + let mut signal = json!({ + "kind": "reduced_scope", + "severity": "info", + "source": "manifest" + }); + if let Some(detail) = scope_profile.coverage_expectation() { + signal["detail"] = json!(detail); + } + push_reliability_signal_if_missing(input, signal); + } + } + + if let Some(manifest) = run_manifest { + if let Err(error) = DeepReviewEvidencePack::from_manifest(manifest) { + push_reliability_signal_if_missing( + input, + json!({ + "kind": "context_pressure", + "severity": "warning", + "source": "manifest", + "detail": format!("Evidence pack ignored: {}", error) + }), + ); + } + } + if let Some(token_budget) = run_manifest .and_then(|manifest| value_for_any_key(manifest, &["tokenBudget", "token_budget"])) { @@ -504,6 +538,7 @@ pub(crate) fn log_deep_review_runtime_diagnostics(dialog_turn_id: Option<&str>) shared_context_total_calls, shared_context_duplicate_calls, shared_context_duplicate_context_count, + shared_context_duplicate_savings_candidate_count, }) = deep_review_runtime_diagnostics_snapshot(dialog_turn_id) else { return; @@ -524,7 +559,7 @@ pub(crate) fn log_deep_review_runtime_diagnostics(dialog_turn_id: Option<&str>) serde_json::to_string(&capacity_skip_reason_counts).unwrap_or_else(|_| "{}".to_string()); debug!( - "DeepReview runtime diagnostics: queue_wait_count={}, queue_wait_total_ms={}, queue_wait_max_ms={}, provider_capacity_queue_count={}, provider_capacity_retry_count={}, provider_capacity_retry_success_count={}, capacity_skip_count={}, provider_capacity_queue_reason_counts={}, provider_capacity_retry_reason_counts={}, provider_capacity_retry_success_reason_counts={}, capacity_skip_reason_counts={}, effective_parallel_min={}, effective_parallel_final={}, manual_queue_action_count={}, manual_retry_count={}, auto_retry_count={}, auto_retry_suppressed_reason_counts={}, shared_context_total_calls={}, shared_context_duplicate_calls={}, shared_context_duplicate_context_count={}", + "DeepReview runtime diagnostics: queue_wait_count={}, queue_wait_total_ms={}, queue_wait_max_ms={}, provider_capacity_queue_count={}, provider_capacity_retry_count={}, provider_capacity_retry_success_count={}, capacity_skip_count={}, provider_capacity_queue_reason_counts={}, provider_capacity_retry_reason_counts={}, provider_capacity_retry_success_reason_counts={}, capacity_skip_reason_counts={}, effective_parallel_min={}, effective_parallel_final={}, manual_queue_action_count={}, manual_retry_count={}, auto_retry_count={}, auto_retry_suppressed_reason_counts={}, shared_context_total_calls={}, shared_context_duplicate_calls={}, shared_context_duplicate_context_count={}, shared_context_duplicate_savings_candidate_count={}", queue_wait_count, queue_wait_total_ms, queue_wait_max_ms, @@ -548,7 +583,8 @@ pub(crate) fn log_deep_review_runtime_diagnostics(dialog_turn_id: Option<&str>) auto_retry_suppressed_reason_counts, shared_context_total_calls, shared_context_duplicate_calls, - shared_context_duplicate_context_count + shared_context_duplicate_context_count, + shared_context_duplicate_savings_candidate_count ); } diff --git a/src/crates/core/src/agentic/deep_review/shared_context.rs b/src/crates/core/src/agentic/deep_review/shared_context.rs index b302817d8..c2f5c29b0 100644 --- a/src/crates/core/src/agentic/deep_review/shared_context.rs +++ b/src/crates/core/src/agentic/deep_review/shared_context.rs @@ -1,4 +1,8 @@ //! Content-free duplicate tool-use tracking for shared reviewer context. +//! +//! This module measures duplicate `Read` and `GetFileDiff` usage without +//! storing tool results. It is an observability aid for future evidence/cache +//! decisions, not a programmatic full tool-result cache. use serde::Serialize; use std::collections::{HashMap, HashSet}; diff --git a/src/crates/core/src/agentic/deep_review/task_adapter.rs b/src/crates/core/src/agentic/deep_review/task_adapter.rs index 3f9370dd3..0560476ab 100644 --- a/src/crates/core/src/agentic/deep_review/task_adapter.rs +++ b/src/crates/core/src/agentic/deep_review/task_adapter.rs @@ -1,4 +1,10 @@ //! Deep Review-specific TaskTool adapter helpers. +//! +//! This module adapts generic TaskTool execution to Deep Review policy, +//! manifests, queue events, retry metadata, and report reliability signals. +//! Shared mechanics such as queue wait timing live under +//! `agentic::subagent_runtime`; Deep Review-specific admission and event +//! semantics stay here. use crate::agentic::coordination::get_global_coordinator; use crate::agentic::deep_review::queue::extract_retry_after_seconds; @@ -19,10 +25,12 @@ use crate::agentic::deep_review_policy::{ use crate::agentic::events::{ DeepReviewQueueReason, DeepReviewQueueState, DeepReviewQueueStatus, ErrorCategory, }; +use crate::agentic::subagent_runtime::queue_timing::QueueWaitTimer; use crate::util::errors::{BitFunError, BitFunResult}; use serde_json::{json, Value}; use std::collections::HashSet; -use tokio::time::{sleep, Duration, Instant}; +use std::time::{Duration, Instant}; +use tokio::time::sleep; #[cfg(test)] const DEEP_REVIEW_QUEUE_POLL_INTERVAL: Duration = Duration::from_millis(10); @@ -593,24 +601,17 @@ pub(crate) async fn wait_for_provider_capacity_retry( max_wait_seconds: u64, is_optional_reviewer: bool, ) -> DeepReviewProviderQueueWaitOutcome { - let started_at = Instant::now(); + let mut queue_timer = QueueWaitTimer::start(Instant::now()); let max_wait = Duration::from_secs(max_wait_seconds); - let mut paused_since: Option = None; - let mut paused_total = Duration::ZERO; let optional_reviewer_count = is_optional_reviewer.then_some(1); record_deep_review_runtime_provider_capacity_queue(dialog_turn_id, reason); loop { let now = Instant::now(); - let current_pause_elapsed = paused_since - .map(|paused_at| now.saturating_duration_since(paused_at)) - .unwrap_or_default(); - let queue_elapsed = now - .saturating_duration_since(started_at) - .saturating_sub(paused_total) - .saturating_sub(current_pause_elapsed); - let queue_elapsed_ms = u64::try_from(queue_elapsed.as_millis()).unwrap_or(u64::MAX); + let queue_snapshot = queue_timer.snapshot(now); + let queue_elapsed = queue_snapshot.queue_elapsed; + let queue_elapsed_ms = queue_snapshot.queue_elapsed_ms; let active_reviewers = deep_review_active_reviewer_count(dialog_turn_id); let effective_parallel_instances = deep_review_effective_parallel_instances( dialog_turn_id, @@ -647,9 +648,7 @@ pub(crate) async fn wait_for_provider_capacity_retry( } if control_snapshot.paused { - if paused_since.is_none() { - paused_since = Some(now); - } + queue_timer.pause(now); emit_queue_state( session_id, dialog_turn_id, @@ -669,11 +668,9 @@ pub(crate) async fn wait_for_provider_capacity_retry( continue; } - if let Some(paused_at) = paused_since.take() { - paused_total += now.saturating_duration_since(paused_at); - } + queue_timer.continue_now(now); - if queue_elapsed >= max_wait { + if queue_snapshot.is_expired(max_wait) { record_deep_review_runtime_queue_wait(dialog_turn_id, queue_elapsed_ms); clear_deep_review_queue_control_for_tool(dialog_turn_id, tool_id); emit_queue_state( @@ -745,22 +742,15 @@ pub(crate) async fn wait_for_reviewer_capacity( let reason = decision .reason .unwrap_or(DeepReviewCapacityQueueReason::LocalConcurrencyCap); - let started_at = Instant::now(); + let mut queue_timer = QueueWaitTimer::start(Instant::now()); let max_wait = Duration::from_secs(conc_policy.max_queue_wait_seconds); - let mut paused_since: Option = None; - let mut paused_total = Duration::ZERO; let optional_reviewer_count = is_optional_reviewer.then_some(1); loop { let now = Instant::now(); - let current_pause_elapsed = paused_since - .map(|paused_at| now.saturating_duration_since(paused_at)) - .unwrap_or_default(); - let queue_elapsed = now - .saturating_duration_since(started_at) - .saturating_sub(paused_total) - .saturating_sub(current_pause_elapsed); - let queue_elapsed_ms = u64::try_from(queue_elapsed.as_millis()).unwrap_or(u64::MAX); + let queue_snapshot = queue_timer.snapshot(now); + let queue_elapsed = queue_snapshot.queue_elapsed; + let queue_elapsed_ms = queue_snapshot.queue_elapsed_ms; let active_reviewers = deep_review_active_reviewer_count(dialog_turn_id); let effective_parallel_instances = deep_review_effective_parallel_instances( dialog_turn_id, @@ -798,9 +788,7 @@ pub(crate) async fn wait_for_reviewer_capacity( } if control_snapshot.paused { - if paused_since.is_none() { - paused_since = Some(now); - } + queue_timer.pause(now); emit_queue_state( session_id, dialog_turn_id, @@ -820,9 +808,7 @@ pub(crate) async fn wait_for_reviewer_capacity( continue; } - if let Some(paused_at) = paused_since.take() { - paused_total += now.saturating_duration_since(paused_at); - } + queue_timer.continue_now(now); if let Some(guard) = try_begin_deep_review_active_reviewer(dialog_turn_id, effective_parallel_instances) @@ -848,7 +834,7 @@ pub(crate) async fn wait_for_reviewer_capacity( return Ok(DeepReviewQueueWaitOutcome::Ready { guard }); } - if queue_elapsed >= max_wait { + if queue_snapshot.is_expired(max_wait) { let snapshot = record_deep_review_effective_concurrency_capacity_error( dialog_turn_id, conc_policy.max_parallel_instances, diff --git a/src/crates/core/src/agentic/deep_review/tool_context.rs b/src/crates/core/src/agentic/deep_review/tool_context.rs index 020cfeeac..a8556b7b1 100644 --- a/src/crates/core/src/agentic/deep_review/tool_context.rs +++ b/src/crates/core/src/agentic/deep_review/tool_context.rs @@ -1,4 +1,8 @@ //! Deep Review custom data propagation for generic tool execution contexts. +//! +//! Generic tool execution remains shared. This module only injects typed Deep +//! Review custom data when the parent launch context proves the tool call is +//! part of a Deep Review reviewer flow. use serde_json::Value; use std::collections::HashMap; diff --git a/src/crates/core/src/agentic/deep_review/tool_measurement.rs b/src/crates/core/src/agentic/deep_review/tool_measurement.rs index e5c3f0382..642ca5d6a 100644 --- a/src/crates/core/src/agentic/deep_review/tool_measurement.rs +++ b/src/crates/core/src/agentic/deep_review/tool_measurement.rs @@ -1,4 +1,8 @@ //! Deep Review shared-context measurement hook for successful tool calls. +//! +//! The hook is intentionally narrow: only successful reviewer `Read` and +//! `GetFileDiff` calls are measured, and BitFun runtime URIs are ignored. It +//! records normalized metadata for diagnostics, not file contents. use crate::agentic::deep_review_policy::record_deep_review_shared_context_tool_use; use crate::agentic::tools::framework::ToolUseContext; diff --git a/src/crates/core/src/agentic/deep_review_policy.rs b/src/crates/core/src/agentic/deep_review_policy.rs index 7bcdcb763..06fce135c 100644 --- a/src/crates/core/src/agentic/deep_review_policy.rs +++ b/src/crates/core/src/agentic/deep_review_policy.rs @@ -529,6 +529,7 @@ mod tests { shared_context_total_calls: 3, shared_context_duplicate_calls: 1, shared_context_duplicate_context_count: 1, + shared_context_duplicate_savings_candidate_count: 1, }; let facade_diagnostics: super::DeepReviewRuntimeDiagnostics = module_diagnostics.clone(); diff --git a/src/crates/core/src/agentic/mod.rs b/src/crates/core/src/agentic/mod.rs index 4a6041b1d..c6b40fe7a 100644 --- a/src/crates/core/src/agentic/mod.rs +++ b/src/crates/core/src/agentic/mod.rs @@ -21,6 +21,7 @@ pub mod context_profile; pub mod coordination; pub mod deep_review; pub mod deep_review_policy; +pub(crate) mod subagent_runtime; // Shared-context fork-agent execution module pub mod fork_agent; diff --git a/src/crates/core/src/agentic/subagent_runtime/mod.rs b/src/crates/core/src/agentic/subagent_runtime/mod.rs new file mode 100644 index 000000000..6355e6462 --- /dev/null +++ b/src/crates/core/src/agentic/subagent_runtime/mod.rs @@ -0,0 +1,8 @@ +//! Generic subagent runtime primitives. +//! +//! This module is intentionally smaller than a scheduler. It may contain +//! shared mechanics that are already proven generic across hidden subagent +//! execution paths, but it must not import Deep Review modules or define +//! Deep Review product policy. + +pub(crate) mod queue_timing; diff --git a/src/crates/core/src/agentic/subagent_runtime/queue_timing.rs b/src/crates/core/src/agentic/subagent_runtime/queue_timing.rs new file mode 100644 index 000000000..3a1b8cf14 --- /dev/null +++ b/src/crates/core/src/agentic/subagent_runtime/queue_timing.rs @@ -0,0 +1,115 @@ +//! Queue wait timing shared by subagent runtimes. +//! +//! Queue wait is tracked separately from execution time so callers can pause +//! admission without consuming the downstream task timeout. The type has no +//! knowledge of reviewer roles, queue reasons, events, or retry policy; those +//! concerns remain in the feature adapter that owns the product behavior. + +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone)] +pub(crate) struct QueueWaitTimer { + started_at: Instant, + paused_since: Option, + paused_total: Duration, +} + +impl QueueWaitTimer { + pub(crate) fn start(now: Instant) -> Self { + Self { + started_at: now, + paused_since: None, + paused_total: Duration::ZERO, + } + } + + pub(crate) fn snapshot(&self, now: Instant) -> QueueWaitSnapshot { + let active_pause = self + .paused_since + .map(|paused_at| now.saturating_duration_since(paused_at)) + .unwrap_or_default(); + let queue_elapsed = now + .saturating_duration_since(self.started_at) + .saturating_sub(self.paused_total) + .saturating_sub(active_pause); + + QueueWaitSnapshot { + queue_elapsed, + queue_elapsed_ms: u64::try_from(queue_elapsed.as_millis()).unwrap_or(u64::MAX), + } + } + + pub(crate) fn pause(&mut self, now: Instant) { + if self.paused_since.is_none() { + self.paused_since = Some(now); + } + } + + pub(crate) fn continue_now(&mut self, now: Instant) { + if let Some(paused_at) = self.paused_since.take() { + self.paused_total += now.saturating_duration_since(paused_at); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct QueueWaitSnapshot { + pub(crate) queue_elapsed: Duration, + pub(crate) queue_elapsed_ms: u64, +} + +impl QueueWaitSnapshot { + pub(crate) fn is_expired(self, max_wait: Duration) -> bool { + self.queue_elapsed >= max_wait + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn queue_elapsed_excludes_paused_duration() { + let start = Instant::now(); + let mut timer = QueueWaitTimer::start(start); + + let before_pause = start + Duration::from_millis(1_200); + assert_eq!( + timer.snapshot(before_pause).queue_elapsed, + Duration::from_millis(1_200) + ); + + timer.pause(before_pause); + let during_pause = start + Duration::from_millis(5_200); + assert_eq!( + timer.snapshot(during_pause).queue_elapsed, + Duration::from_millis(1_200) + ); + + timer.continue_now(during_pause); + let after_resume = start + Duration::from_millis(6_200); + let snapshot = timer.snapshot(after_resume); + assert_eq!(snapshot.queue_elapsed, Duration::from_millis(2_200)); + assert_eq!(snapshot.queue_elapsed_ms, 2_200); + } + + #[test] + fn pause_and_continue_are_idempotent() { + let start = Instant::now(); + let mut timer = QueueWaitTimer::start(start); + + let first_pause = start + Duration::from_millis(500); + let second_pause = start + Duration::from_millis(900); + timer.pause(first_pause); + timer.pause(second_pause); + + let resume = start + Duration::from_millis(1_500); + timer.continue_now(resume); + timer.continue_now(resume + Duration::from_millis(300)); + + let snapshot = timer.snapshot(start + Duration::from_millis(2_000)); + assert_eq!(snapshot.queue_elapsed, Duration::from_millis(1_000)); + assert!(!snapshot.is_expired(Duration::from_millis(1_001))); + assert!(snapshot.is_expired(Duration::from_millis(1_000))); + } +} diff --git a/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs b/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs index 6fd04a733..0f890b2f2 100644 --- a/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs @@ -364,6 +364,7 @@ impl CodeReviewTool { "cache_miss", "concurrency_limited", "partial_reviewer", + "reduced_scope", "retry_guidance", "skipped_reviewers", "token_budget_limited", @@ -812,6 +813,7 @@ mod tests { "cache_miss", "concurrency_limited", "partial_reviewer", + "reduced_scope", "retry_guidance", "skipped_reviewers", "token_budget_limited", @@ -1144,6 +1146,7 @@ mod tests { assert_eq!(diagnostics.shared_context_total_calls, 3); assert_eq!(diagnostics.shared_context_duplicate_calls, 1); assert_eq!(diagnostics.shared_context_duplicate_context_count, 1); + assert_eq!(diagnostics.shared_context_duplicate_savings_candidate_count, 1); let tool = CodeReviewTool::new(); let mut context = tool_context(Some("DeepReview")); @@ -1266,6 +1269,154 @@ mod tests { ); } + #[test] + fn deep_review_defaults_include_reduced_scope_reliability_signal() { + let manifest = json!({ + "reviewMode": "deep", + "scopeProfile": { + "reviewDepth": "high_risk_only", + "riskFocusTags": ["security"], + "maxDependencyHops": 0, + "optionalReviewerPolicy": "risk_matched_only", + "allowBroadToolExploration": false, + "coverageExpectation": "High-risk-only pass; changed files stay visible." + } + }); + let mut input = json!({ + "summary": { + "overall_assessment": "No blocking issues", + "risk_level": "low", + "recommended_action": "approve" + }, + "issues": [], + "positive_points": [] + }); + + CodeReviewTool::validate_and_fill_defaults(&mut input, true, Some(&manifest), None); + + assert_eq!( + input["reliability_signals"], + json!([ + { + "kind": "reduced_scope", + "severity": "info", + "source": "manifest", + "detail": "High-risk-only pass; changed files stay visible." + } + ]) + ); + } + + #[test] + fn deep_review_legacy_manifest_without_scope_profile_has_no_reduced_scope_signal() { + let manifest = json!({ + "reviewMode": "deep", + "workPackets": [] + }); + let mut input = json!({ + "summary": { + "overall_assessment": "No blocking issues", + "risk_level": "low", + "recommended_action": "approve" + }, + "issues": [], + "positive_points": [] + }); + + CodeReviewTool::validate_and_fill_defaults(&mut input, true, Some(&manifest), None); + + assert!(input.get("reliability_signals").is_none()); + } + + #[test] + fn deep_review_invalid_evidence_pack_becomes_manifest_reliability_signal() { + let manifest = json!({ + "reviewMode": "deep", + "evidencePack": { + "version": 1, + "source": "target_manifest", + "changedFiles": ["src/lib.rs"], + "diffStat": { + "fileCount": 1, + "lineCountSource": "diff_stat" + }, + "domainTags": ["core"], + "riskFocusTags": ["security"], + "packetIds": ["reviewer:ReviewSecurity"], + "hunkHints": [], + "contractHints": [], + "budget": { + "maxChangedFiles": 80, + "maxHunkHints": 80, + "maxContractHints": 40, + "omittedChangedFileCount": 0, + "omittedHunkHintCount": 0, + "omittedContractHintCount": 0 + }, + "privacy": { + "content": "full_diff", + "excludes": [ + "source_text", + "full_diff", + "model_output", + "provider_raw_body", + "full_file_contents" + ] + } + } + }); + let mut input = json!({ + "summary": { + "overall_assessment": "No blocking issues", + "risk_level": "low", + "recommended_action": "approve" + }, + "issues": [], + "positive_points": [] + }); + + CodeReviewTool::validate_and_fill_defaults(&mut input, true, Some(&manifest), None); + + let signals = input["reliability_signals"] + .as_array() + .expect("invalid evidence pack should emit a reliability signal"); + assert_eq!(signals[0]["kind"], "context_pressure"); + assert_eq!(signals[0]["severity"], "warning"); + assert_eq!(signals[0]["source"], "manifest"); + assert!(signals[0]["detail"] + .as_str() + .expect("signal should include detail") + .contains("privacy.content")); + } + + #[test] + fn deep_review_full_depth_manifest_has_no_reduced_scope_signal() { + let manifest = json!({ + "reviewMode": "deep", + "scopeProfile": { + "reviewDepth": "full_depth", + "riskFocusTags": ["security"], + "maxDependencyHops": "policy_limited", + "optionalReviewerPolicy": "full", + "allowBroadToolExploration": true, + "coverageExpectation": "Full-depth pass." + } + }); + let mut input = json!({ + "summary": { + "overall_assessment": "No blocking issues", + "risk_level": "low", + "recommended_action": "approve" + }, + "issues": [], + "positive_points": [] + }); + + CodeReviewTool::validate_and_fill_defaults(&mut input, true, Some(&manifest), None); + + assert!(input.get("reliability_signals").is_none()); + } + #[test] fn deep_review_compression_signal_requires_completed_compression() { let contract = CompressionContract { From 78c77d59cda709d3db805a5a61267b1967cdfa2d Mon Sep 17 00:00:00 2001 From: limityan Date: Mon, 11 May 2026 21:19:06 +0800 Subject: [PATCH 2/2] feat(web-ui): surface scoped deep review reports --- .../DeepReviewConsentDialog.test.tsx | 9 + .../components/DeepReviewConsentDialog.tsx | 18 + .../src/flow_chat/deep-review/README.md | 27 + .../action-bar/CapacityQueueNotice.test.tsx | 76 + .../action-bar/CapacityQueueNotice.tsx | 191 +++ .../action-bar/DeepReviewActionBar.tsx | 238 +-- .../action-bar/ReviewActionHeader.test.tsx | 30 + .../action-bar/ReviewActionHeader.tsx | 57 + .../action-bar/actionBarFormatting.test.ts | 11 + .../action-bar/actionBarFormatting.ts | 9 + .../deep-review/launch/DeepReviewService.ts | 459 +----- .../deep-review/launch/commandParser.test.ts | 75 + .../deep-review/launch/commandParser.ts | 120 ++ .../deep-review/launch/launchErrors.test.ts | 69 + .../deep-review/launch/launchErrors.ts | 123 ++ .../deep-review/launch/launchPrompt.test.ts | 39 + .../deep-review/launch/launchPrompt.ts | 54 + .../deep-review/launch/targetResolver.test.ts | 110 ++ .../deep-review/launch/targetResolver.ts | 162 ++ .../deep-review/report/codeReviewReport.ts | 763 +-------- .../report/manifestSections.test.ts | 172 ++ .../deep-review/report/manifestSections.ts | 172 ++ .../deep-review/report/markdown.test.ts | 21 + .../flow_chat/deep-review/report/markdown.ts | 213 +++ .../report/reliabilityNotices.test.ts | 37 + .../deep-review/report/reliabilityNotices.ts | 332 ++++ .../deep-review/report/reportSections.ts | 196 +++ .../CodeReviewReportExportActions.tsx | 3 + .../tool-cards/CodeReviewToolCard.test.tsx | 61 + .../tool-cards/CodeReviewToolCard.tsx | 22 + .../flow_chat/utils/codeReviewReport.test.ts | 117 ++ src/web-ui/src/locales/en-US/flow-chat.json | 18 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 18 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 18 +- .../shared/services/review-team/cachePlan.ts | 149 ++ .../services/review-team/evidencePack.ts | 122 ++ .../src/shared/services/review-team/index.ts | 1412 +---------------- .../services/review-team/manifestMembers.ts | 99 ++ .../services/review-team/pathMetadata.ts | 72 + .../services/review-team/preReviewSummary.ts | 61 + .../services/review-team/promptBlock.ts | 434 +++++ .../src/shared/services/review-team/risk.ts | 216 +++ .../services/review-team/scopeProfile.ts | 55 + .../services/review-team/tokenBudget.ts | 236 +++ .../src/shared/services/review-team/types.ts | 91 ++ .../services/review-team/workPackets.ts | 301 ++++ .../reviewTeamLocaleCompleteness.test.ts | 10 + .../shared/services/reviewTeamService.test.ts | 186 +++ 48 files changed, 4728 insertions(+), 2756 deletions(-) create mode 100644 src/web-ui/src/flow_chat/deep-review/README.md create mode 100644 src/web-ui/src/flow_chat/deep-review/action-bar/CapacityQueueNotice.test.tsx create mode 100644 src/web-ui/src/flow_chat/deep-review/action-bar/CapacityQueueNotice.tsx create mode 100644 src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.test.tsx create mode 100644 src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.tsx create mode 100644 src/web-ui/src/flow_chat/deep-review/action-bar/actionBarFormatting.test.ts create mode 100644 src/web-ui/src/flow_chat/deep-review/action-bar/actionBarFormatting.ts create mode 100644 src/web-ui/src/flow_chat/deep-review/launch/commandParser.test.ts create mode 100644 src/web-ui/src/flow_chat/deep-review/launch/commandParser.ts create mode 100644 src/web-ui/src/flow_chat/deep-review/launch/launchErrors.test.ts create mode 100644 src/web-ui/src/flow_chat/deep-review/launch/launchErrors.ts create mode 100644 src/web-ui/src/flow_chat/deep-review/launch/launchPrompt.test.ts create mode 100644 src/web-ui/src/flow_chat/deep-review/launch/launchPrompt.ts create mode 100644 src/web-ui/src/flow_chat/deep-review/launch/targetResolver.test.ts create mode 100644 src/web-ui/src/flow_chat/deep-review/launch/targetResolver.ts create mode 100644 src/web-ui/src/flow_chat/deep-review/report/manifestSections.test.ts create mode 100644 src/web-ui/src/flow_chat/deep-review/report/manifestSections.ts create mode 100644 src/web-ui/src/flow_chat/deep-review/report/markdown.test.ts create mode 100644 src/web-ui/src/flow_chat/deep-review/report/markdown.ts create mode 100644 src/web-ui/src/flow_chat/deep-review/report/reliabilityNotices.test.ts create mode 100644 src/web-ui/src/flow_chat/deep-review/report/reliabilityNotices.ts create mode 100644 src/web-ui/src/flow_chat/deep-review/report/reportSections.ts create mode 100644 src/web-ui/src/shared/services/review-team/cachePlan.ts create mode 100644 src/web-ui/src/shared/services/review-team/evidencePack.ts create mode 100644 src/web-ui/src/shared/services/review-team/manifestMembers.ts create mode 100644 src/web-ui/src/shared/services/review-team/pathMetadata.ts create mode 100644 src/web-ui/src/shared/services/review-team/preReviewSummary.ts create mode 100644 src/web-ui/src/shared/services/review-team/promptBlock.ts create mode 100644 src/web-ui/src/shared/services/review-team/risk.ts create mode 100644 src/web-ui/src/shared/services/review-team/scopeProfile.ts create mode 100644 src/web-ui/src/shared/services/review-team/tokenBudget.ts create mode 100644 src/web-ui/src/shared/services/review-team/workPackets.ts diff --git a/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.test.tsx b/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.test.tsx index a84ecbed5..e591c34a9 100644 --- a/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.test.tsx +++ b/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.test.tsx @@ -116,6 +116,14 @@ function buildPreview(): ReviewTeamRunManifest { warnings: [], }, strategyLevel: 'normal', + scopeProfile: { + reviewDepth: 'risk_expanded', + riskFocusTags: ['security', 'cross_boundary_api_contracts'], + maxDependencyHops: 1, + optionalReviewerPolicy: 'configured', + allowBroadToolExploration: false, + coverageExpectation: 'Risk-expanded pass; changed files remain visible.', + }, strategyRecommendation: { strategyLevel: 'deep', score: 24, @@ -284,6 +292,7 @@ describeWithJsdom('DeepReviewConsentDialog', () => { expect(container.textContent).toContain('1 optional reviewer'); expect(container.textContent).toContain('2 skipped'); expect(container.textContent).toContain('Run strategy: Normal'); + expect(container.textContent).toContain('Review depth: Risk-expanded'); expect(container.textContent).toContain('Frontend reviewer'); expect(container.textContent).toContain('Not applicable to this target'); expect(container.textContent).toContain('Custom invalid reviewer'); diff --git a/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.tsx b/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.tsx index e44f31d10..3ac358cab 100644 --- a/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.tsx +++ b/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.tsx @@ -88,6 +88,16 @@ function getFallbackTargetTagLabel(tag: string): string { .join(' '); } +function getReviewDepthLabel(reviewDepth: string, t: ReturnType['t']): string { + return t(`deepReviewConsent.reviewDepthLabels.${reviewDepth}`, { + defaultValue: { + high_risk_only: 'High-risk-only', + risk_expanded: 'Risk-expanded', + full_depth: 'Full-depth', + }[reviewDepth] ?? reviewDepth, + }); +} + export function useDeepReviewConsent(): DeepReviewConsentControls { const { t } = useTranslation('flow-chat'); const [pendingConsent, setPendingConsent] = useState(null); @@ -285,6 +295,14 @@ export function useDeepReviewConsent(): DeepReviewConsentControls { defaultValue: 'Run strategy: {{strategy}}', })} + {preview.scopeProfile && ( + + {t('deepReviewConsent.reviewDepth', { + depth: getReviewDepthLabel(preview.scopeProfile.reviewDepth, t), + defaultValue: 'Review depth: {{depth}}', + })} + + )} {preview.workspacePath && ( diff --git a/src/web-ui/src/flow_chat/deep-review/README.md b/src/web-ui/src/flow_chat/deep-review/README.md new file mode 100644 index 000000000..5d61b1b2a --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/README.md @@ -0,0 +1,27 @@ +# Flow Chat Deep Review Ownership + +This directory owns Flow Chat integration for Deep Review. Keep the historical import paths under `src/web-ui/src/flow_chat/services`, `components/btw`, and `utils` as compatibility facades. + +## Module Boundaries + +| Area | Owns | Should not own | +|---|---|---| +| `launch/` | Slash command parsing, review target resolution, launch prompt formatting, launch error shaping, child-session launch orchestration. | Review Team manifest policy internals, direct Tauri calls, action-bar state. | +| `action-bar/` | Shared review action bar rendering, Deep Review queue notice, recovery/remediation UI, compact status/header formatting. | Launch target parsing, report markdown semantics, backend queue classification. | +| `report/` | Code review report types, retryable slice extraction, reliability notices, run-manifest markdown sections, report section normalization, markdown export. | UI rendering, session launch, raw source/diff/model output storage. | + +## Guardrails + +- Preserve the facade exports from `services/DeepReviewService.ts`, `components/btw/DeepReviewActionBar.tsx`, and `utils/codeReviewReport.ts`. +- Keep Deep Review-only UI gated by `reviewMode === 'deep'` or an explicit Deep Review queue/report context. +- Standard Code Review remediation and markdown export must keep focused regression coverage when report or action-bar helpers move. +- Diagnostics, markdown export, and evidence summaries must stay metadata-first and must not include source text, full diff text, reviewer output, provider raw bodies, or full file contents. +- Add behavior to the narrow helper module first. Only grow the orchestration files when the behavior actually coordinates multiple helpers or adapters. + +## Focused Verification + +```powershell +pnpm --dir src/web-ui exec vitest run src/flow_chat/services/DeepReviewService.test.ts src/flow_chat/components/btw/DeepReviewActionBar.test.tsx src/flow_chat/utils/codeReviewReport.test.ts +pnpm run type-check:web +pnpm run lint:web +``` diff --git a/src/web-ui/src/flow_chat/deep-review/action-bar/CapacityQueueNotice.test.tsx b/src/web-ui/src/flow_chat/deep-review/action-bar/CapacityQueueNotice.test.tsx new file mode 100644 index 000000000..251f8ab3c --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/action-bar/CapacityQueueNotice.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import { CapacityQueueNotice } from './CapacityQueueNotice'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (_key: string, options?: Record & { defaultValue?: string }) => { + const template = options?.defaultValue ?? _key; + return template.replace(/{{(\w+)}}/g, (_match, token: string) => String(options?.[token] ?? _match)); + }, + }), +})); + +vi.mock('@/component-library', () => ({ + Button: ({ + children, + }: { + children: React.ReactNode; + }) => , +})); + +describe('CapacityQueueNotice', () => { + it('renders queue reason, elapsed time, and compact controls', () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('Reviewers waiting for capacity'); + expect(html).toContain('Queue wait does not count against reviewer runtime.'); + expect(html).toContain('Reason: provider concurrency limit'); + expect(html).toContain('Waited 12s of 1m 0s'); + expect(html).toContain('Pause queue'); + expect(html).toContain('Skip optional extras'); + expect(html).toContain('Run slower next time'); + }); + + it('renders the stop hint when inline queue controls are unavailable', () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('Use Stop to interrupt this review queue.'); + expect(html).not.toContain('Pause queue'); + }); +}); diff --git a/src/web-ui/src/flow_chat/deep-review/action-bar/CapacityQueueNotice.tsx b/src/web-ui/src/flow_chat/deep-review/action-bar/CapacityQueueNotice.tsx new file mode 100644 index 000000000..21f1c3e64 --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/action-bar/CapacityQueueNotice.tsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Clock, + Pause, + Play, + Settings, + SkipForward, +} from 'lucide-react'; +import { Button } from '@/component-library'; +import type { + DeepReviewCapacityQueueReason, + DeepReviewCapacityQueueState, +} from '../../store/deepReviewActionBarStore'; +import { formatElapsedTime } from './actionBarFormatting'; + +interface CapacityQueueNoticeProps { + capacityQueueState: DeepReviewCapacityQueueState; + supportsInlineQueueControls: boolean; + onPauseQueue: () => void | Promise; + onContinueQueue: () => void | Promise; + onSkipOptionalQueuedReviewers: () => void | Promise; + onCancelQueuedReviewers: () => void | Promise; + onRunSlowerNextTime: () => void | Promise; + onOpenReviewSettings: () => void | Promise; +} + +const CAPACITY_QUEUE_REASON_KEYS: Record = { + provider_rate_limit: 'deepReviewActionBar.capacityQueue.reasons.providerRateLimit', + provider_concurrency_limit: 'deepReviewActionBar.capacityQueue.reasons.providerConcurrencyLimit', + retry_after: 'deepReviewActionBar.capacityQueue.reasons.retryAfter', + local_concurrency_cap: 'deepReviewActionBar.capacityQueue.reasons.localConcurrencyCap', + temporary_overload: 'deepReviewActionBar.capacityQueue.reasons.temporaryOverload', +}; + +export const CapacityQueueNotice: React.FC = ({ + capacityQueueState, + supportsInlineQueueControls, + onPauseQueue, + onContinueQueue, + onSkipOptionalQueuedReviewers, + onCancelQueuedReviewers, + onRunSlowerNextTime, + onOpenReviewSettings, +}) => { + const { t } = useTranslation('flow-chat'); + const capacityQueueReasonLabel = capacityQueueState.reason + ? t(CAPACITY_QUEUE_REASON_KEYS[capacityQueueState.reason], { + defaultValue: capacityQueueState.reason.split('_').join(' '), + }) + : null; + const capacityQueueElapsedLabel = capacityQueueState.queueElapsedMs !== undefined + ? formatElapsedTime(capacityQueueState.queueElapsedMs) + : null; + const capacityQueueMaxWaitLabel = capacityQueueState.maxQueueWaitSeconds !== undefined + ? formatElapsedTime(capacityQueueState.maxQueueWaitSeconds * 1000) + : null; + + return ( +
+
+ +
+ + {capacityQueueState.status === 'paused_by_user' + ? t('deepReviewActionBar.capacityQueue.pausedTitle', { + defaultValue: 'Queue paused', + }) + : t('deepReviewActionBar.capacityQueue.title', { + defaultValue: 'Reviewers waiting for capacity', + })} + + + {t('deepReviewActionBar.capacityQueue.detail', { + defaultValue: 'Queue wait does not count against reviewer runtime.', + })} + + {(capacityQueueReasonLabel || capacityQueueElapsedLabel) && ( + + {capacityQueueReasonLabel && ( + + {t('deepReviewActionBar.capacityQueue.reason', { + reason: capacityQueueReasonLabel, + defaultValue: `Reason: ${capacityQueueReasonLabel}`, + })} + + )} + {capacityQueueElapsedLabel && ( + + {capacityQueueMaxWaitLabel + ? t('deepReviewActionBar.capacityQueue.elapsedWithMax', { + elapsed: capacityQueueElapsedLabel, + max: capacityQueueMaxWaitLabel, + defaultValue: `Waited ${capacityQueueElapsedLabel} of ${capacityQueueMaxWaitLabel}`, + }) + : t('deepReviewActionBar.capacityQueue.elapsed', { + elapsed: capacityQueueElapsedLabel, + defaultValue: `Waited ${capacityQueueElapsedLabel}`, + })} + + )} + + )} + {capacityQueueState.sessionConcurrencyHigh && ( + + {t('deepReviewActionBar.capacityQueue.sessionBusy', { + defaultValue: 'Your active session is busy. Pause Deep Review or continue later.', + })} + + )} + {!supportsInlineQueueControls && ( + + {t('deepReviewActionBar.capacityQueue.stopHint', { + defaultValue: 'Use Stop to interrupt this review queue.', + })} + + )} +
+
+
+ {supportsInlineQueueControls && ( + <> + {capacityQueueState.status === 'paused_by_user' ? ( + + ) : ( + + )} + {(capacityQueueState.optionalReviewerCount ?? 0) > 0 && ( + + )} + + + )} + + +
+
+ ); +}; diff --git a/src/web-ui/src/flow_chat/deep-review/action-bar/DeepReviewActionBar.tsx b/src/web-ui/src/flow_chat/deep-review/action-bar/DeepReviewActionBar.tsx index 94233b9e9..e9e456fd8 100644 --- a/src/web-ui/src/flow_chat/deep-review/action-bar/DeepReviewActionBar.tsx +++ b/src/web-ui/src/flow_chat/deep-review/action-bar/DeepReviewActionBar.tsx @@ -10,20 +10,16 @@ import { ChevronUp, MessageSquare, Play, - Pause, Copy, Info, SkipForward, RotateCcw, Eye, - Minus, - Settings, } from 'lucide-react'; import { Button, Checkbox, Tooltip } from '@/component-library'; import { useReviewActionBarStore, type DeepReviewCapacityQueueAction, - type DeepReviewCapacityQueueReason, type ReviewActionPhase, } from '../../store/deepReviewActionBarStore'; import type { ReviewRemediationItem } from '../../utils/codeReviewRemediation'; @@ -50,12 +46,14 @@ import { extractPartialReviewData, } from '../../utils/deepReviewExperience'; import { flowChatStore } from '../../store/FlowChatStore'; -import { CodeReviewReportExportActions } from '../../tool-cards/CodeReviewReportExportActions'; import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; import { lowerDefaultReviewTeamMaxParallelReviewers } from '@/shared/services/reviewTeamService'; import { useSettingsStore } from '@/app/scenes/settings/settingsStore'; import { useSceneStore } from '@/app/stores/sceneStore'; import type { ConfigTab } from '@/app/scenes/settings/settingsConfig'; +import { formatElapsedTime } from './actionBarFormatting'; +import { CapacityQueueNotice } from './CapacityQueueNotice'; +import { ReviewActionHeader } from './ReviewActionHeader'; import '../../components/btw/DeepReviewActionBar.scss'; const log = createLogger('DeepReviewActionBar'); @@ -92,14 +90,6 @@ const GROUP_PRIORITY_META: Record = { verification: { color: 'var(--color-success, #22c55e)' }, }; -const CAPACITY_QUEUE_REASON_KEYS: Record = { - provider_rate_limit: 'deepReviewActionBar.capacityQueue.reasons.providerRateLimit', - provider_concurrency_limit: 'deepReviewActionBar.capacityQueue.reasons.providerConcurrencyLimit', - retry_after: 'deepReviewActionBar.capacityQueue.reasons.retryAfter', - local_concurrency_cap: 'deepReviewActionBar.capacityQueue.reasons.localConcurrencyCap', - temporary_overload: 'deepReviewActionBar.capacityQueue.reasons.temporaryOverload', -}; - const stopNestedScrollPropagation = (event: React.WheelEvent | React.TouchEvent) => { event.stopPropagation(); if ('nativeEvent' in event && typeof event.nativeEvent.stopImmediatePropagation === 'function') { @@ -156,18 +146,6 @@ export const ReviewActionBar: React.FC = () => { capacityQueueState?.controlMode === 'backend' ? hasBackendQueueControlTarget : capacityQueueState?.controlMode !== 'session_stop_only'; - const capacityQueueReasonLabel = capacityQueueState?.reason - ? t(CAPACITY_QUEUE_REASON_KEYS[capacityQueueState.reason], { - defaultValue: capacityQueueState.reason.split('_').join(' '), - }) - : null; - const capacityQueueElapsedLabel = capacityQueueState?.queueElapsedMs !== undefined - ? formatElapsedTime(capacityQueueState.queueElapsedMs) - : null; - const capacityQueueMaxWaitLabel = capacityQueueState?.maxQueueWaitSeconds !== undefined - ? formatElapsedTime(capacityQueueState.maxQueueWaitSeconds * 1000) - : null; - const handleCapacityQueueAction = useCallback(async ( action: DeepReviewCapacityQueueAction, applyLocalAction: () => void, @@ -718,33 +696,15 @@ export const ReviewActionBar: React.FC = () => { onWheel={stopNestedScrollPropagation} onTouchMove={stopNestedScrollPropagation} > - {/* Top-right controls: export actions + minimize */} -
- {reviewData && ( - - )} - - -
- - {/* Phase status header */} -
- - {phaseTitle} - {errorMessage && ( - {errorMessage} - )} -
+ {/* Running progress */} {(phase === 'fix_running' || phase === 'resume_running') && progressSummary && ( @@ -765,148 +725,28 @@ export const ReviewActionBar: React.FC = () => { {/* Capacity queue notice */} {showCapacityQueueNotice && capacityQueueState && ( -
-
- -
- - {capacityQueueState.status === 'paused_by_user' - ? t('deepReviewActionBar.capacityQueue.pausedTitle', { - defaultValue: 'Queue paused', - }) - : t('deepReviewActionBar.capacityQueue.title', { - defaultValue: 'Reviewers waiting for capacity', - })} - - - {t('deepReviewActionBar.capacityQueue.detail', { - defaultValue: 'Queue wait does not count against reviewer runtime.', - })} - - {(capacityQueueReasonLabel || capacityQueueElapsedLabel) && ( - - {capacityQueueReasonLabel && ( - - {t('deepReviewActionBar.capacityQueue.reason', { - reason: capacityQueueReasonLabel, - defaultValue: `Reason: ${capacityQueueReasonLabel}`, - })} - - )} - {capacityQueueElapsedLabel && ( - - {capacityQueueMaxWaitLabel - ? t('deepReviewActionBar.capacityQueue.elapsedWithMax', { - elapsed: capacityQueueElapsedLabel, - max: capacityQueueMaxWaitLabel, - defaultValue: `Waited ${capacityQueueElapsedLabel} of ${capacityQueueMaxWaitLabel}`, - }) - : t('deepReviewActionBar.capacityQueue.elapsed', { - elapsed: capacityQueueElapsedLabel, - defaultValue: `Waited ${capacityQueueElapsedLabel}`, - })} - - )} - - )} - {capacityQueueState.sessionConcurrencyHigh && ( - - {t('deepReviewActionBar.capacityQueue.sessionBusy', { - defaultValue: 'Your active session is busy. Pause Deep Review or continue later.', - })} - - )} - {!supportsInlineQueueControls && ( - - {t('deepReviewActionBar.capacityQueue.stopHint', { - defaultValue: 'Use Stop to interrupt this review queue.', - })} - - )} -
-
-
- {supportsInlineQueueControls && ( - <> - {capacityQueueState.status === 'paused_by_user' ? ( - - ) : ( - - )} - {(capacityQueueState.optionalReviewerCount ?? 0) > 0 && ( - - )} - - - )} - - -
-
+ handleCapacityQueueAction( + 'continue', + store.continueCapacityQueue, + )} + onPauseQueue={() => handleCapacityQueueAction( + 'pause', + store.pauseCapacityQueue, + )} + onSkipOptionalQueuedReviewers={() => handleCapacityQueueAction( + 'skip_optional', + store.skipOptionalQueuedReviewers, + )} + onCancelQueuedReviewers={() => handleCapacityQueueAction( + 'cancel', + store.cancelQueuedReviewers, + )} + onRunSlowerNextTime={handleRunSlowerNextTime} + onOpenReviewSettings={handleOpenReviewSettings} + /> )} {/* Partial results summary on interruption */} @@ -1453,14 +1293,4 @@ export const ReviewActionBar: React.FC = () => { ); }; -function formatElapsedTime(ms: number): string { - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - if (minutes > 0) { - return `${minutes}m ${remainingSeconds}s`; - } - return `${seconds}s`; -} - export const DeepReviewActionBar = ReviewActionBar; diff --git a/src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.test.tsx b/src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.test.tsx new file mode 100644 index 000000000..41ef9f08c --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.test.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import { ReviewActionHeader } from './ReviewActionHeader'; + +vi.mock('../../tool-cards/CodeReviewReportExportActions', () => ({ + CodeReviewReportExportActions: () => export actions, +})); + +describe('ReviewActionHeader', () => { + it('renders export actions, status, error, and minimize control', () => { + const Icon = () => phase icon; + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('export actions'); + expect(html).toContain('Review completed'); + expect(html).toContain('Network warning'); + expect(html).toContain('aria-label="Minimize"'); + }); +}); diff --git a/src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.tsx b/src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.tsx new file mode 100644 index 000000000..43d10c8e8 --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/action-bar/ReviewActionHeader.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Minus } from 'lucide-react'; +import { CodeReviewReportExportActions } from '../../tool-cards/CodeReviewReportExportActions'; + +type ExportableReviewData = React.ComponentProps['reviewData']; + +interface ReviewActionHeaderProps { + reviewData?: ExportableReviewData | null; + PhaseIcon: React.ComponentType<{ + size?: number | string; + style?: React.CSSProperties; + className?: string; + }>; + phaseIconClass: string; + phaseTitle: string; + errorMessage?: string | null; + minimizeLabel: string; + onMinimize: () => void; +} + +export const ReviewActionHeader: React.FC = ({ + reviewData, + PhaseIcon, + phaseIconClass, + phaseTitle, + errorMessage, + minimizeLabel, + onMinimize, +}) => ( + <> +
+ {reviewData && ( + + )} + + +
+ +
+ + {phaseTitle} + {errorMessage && ( + {errorMessage} + )} +
+ +); diff --git a/src/web-ui/src/flow_chat/deep-review/action-bar/actionBarFormatting.test.ts b/src/web-ui/src/flow_chat/deep-review/action-bar/actionBarFormatting.test.ts new file mode 100644 index 000000000..1780866cf --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/action-bar/actionBarFormatting.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; +import { formatElapsedTime } from './actionBarFormatting'; + +describe('action bar formatting', () => { + it('formats elapsed milliseconds without changing existing labels', () => { + expect(formatElapsedTime(999)).toBe('0s'); + expect(formatElapsedTime(12_000)).toBe('12s'); + expect(formatElapsedTime(60_000)).toBe('1m 0s'); + expect(formatElapsedTime(125_000)).toBe('2m 5s'); + }); +}); diff --git a/src/web-ui/src/flow_chat/deep-review/action-bar/actionBarFormatting.ts b/src/web-ui/src/flow_chat/deep-review/action-bar/actionBarFormatting.ts new file mode 100644 index 000000000..7cc4cef63 --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/action-bar/actionBarFormatting.ts @@ -0,0 +1,9 @@ +export function formatElapsedTime(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes <= 0) { + return `${remainingSeconds}s`; + } + return `${minutes}m ${remainingSeconds}s`; +} diff --git a/src/web-ui/src/flow_chat/deep-review/launch/DeepReviewService.ts b/src/web-ui/src/flow_chat/deep-review/launch/DeepReviewService.ts index df1697873..b4b34e9aa 100644 --- a/src/web-ui/src/flow_chat/deep-review/launch/DeepReviewService.ts +++ b/src/web-ui/src/flow_chat/deep-review/launch/DeepReviewService.ts @@ -1,10 +1,4 @@ -import { agentAPI, gitAPI } from '@/infrastructure/api'; -import type { - GitChangedFile, - GitChangedFilesParams, - GitDiffParams, - GitStatus, -} from '@/infrastructure/api/service-api/GitAPI'; +import { agentAPI } from '@/infrastructure/api'; import { createLogger } from '@/shared/utils/logger'; import { createBtwChildSession } from '../../services/BtwThreadService'; import { closeBtwSessionInAuxPane, openBtwSessionInAuxPane } from '../../services/openBtwSession'; @@ -18,22 +12,37 @@ import { loadReviewTeamProjectStrategyOverride, loadReviewTeamRateLimitStatus, prepareDefaultReviewTeamForLaunch, - type ReviewTeamChangeStats, type ReviewTeamRunManifest, } from '@/shared/services/reviewTeamService'; +import { classifyReviewTargetFromFiles } from '@/shared/services/reviewTargetClassifier'; import { - classifyReviewTargetFromFiles, - createUnknownReviewTargetClassification, - normalizeReviewPath, - type ReviewTargetClassification, -} from '@/shared/services/reviewTargetClassifier'; -import { DEEP_REVIEW_COMMAND_RE } from '../../utils/deepReviewConstants'; -import { classifyLaunchError } from '../../utils/deepReviewExperience'; + getDeepReviewCommandFocus, +} from './commandParser'; +import { + buildUnknownChangeStats, + resolveSlashCommandReviewTarget, +} from './targetResolver'; +import { + formatSessionFilesLaunchPrompt, + formatSlashCommandLaunchPrompt, +} from './launchPrompt'; +import { + buildLaunchCleanupError, + createDeepReviewLaunchError, + isSessionMissingError, + normalizeErrorMessage, + type DeepReviewLaunchStep, + type FailedDeepReviewCleanupResult, +} from './launchErrors'; + +export { + DEEP_REVIEW_SLASH_COMMAND, + isDeepReviewSlashCommand, +} from './commandParser'; +export { getDeepReviewLaunchErrorMessage } from './launchErrors'; const log = createLogger('DeepReviewService'); -export const DEEP_REVIEW_SLASH_COMMAND = '/DeepReview'; - interface LaunchDeepReviewSessionParams { parentSessionId: string; workspacePath?: string; @@ -49,133 +58,6 @@ export interface DeepReviewLaunchPrompt { runManifest: ReviewTeamRunManifest; } -interface ResolvedDeepReviewTarget { - target: ReviewTargetClassification; - changeStats: ReviewTeamChangeStats; -} - -type DeepReviewLaunchStep = - | 'create_child_session' - | 'open_aux_pane' - | 'send_start_message'; - -interface FailedDeepReviewCleanupResult { - cleanupCompleted: boolean; - cleanupIssues: string[]; -} - -interface DeepReviewLaunchError extends Error { - launchErrorCategory?: string; - launchErrorActions?: string[]; - launchErrorMessageKey?: string; - launchErrorStep?: string; - originalMessage?: string; - childSessionId?: string; - cleanupCompleted?: boolean; - cleanupIssues?: string[]; -} - -const LAUNCH_ERROR_DEFAULT_MESSAGES: Record = { - 'deepReviewActionBar.launchError.modelConfig': 'Deep review could not create a review session. Check the model configuration.', - 'deepReviewActionBar.launchError.network': 'Network connection was interrupted before Deep Review could start.', - 'deepReviewActionBar.launchError.unknown': 'Deep review failed to start. Please try again.', -}; - -function normalizeErrorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim()) { - return error.message.trim(); - } - - if (typeof error === 'string' && error.trim()) { - return error.trim(); - } - - return 'Deep review failed to start'; -} - -function isSessionMissingError(error: unknown): boolean { - const message = normalizeErrorMessage(error).toLowerCase(); - return message.includes('session does not exist') || message.includes('not found'); -} - -function describeLaunchStep(step: DeepReviewLaunchStep): string { - switch (step) { - case 'create_child_session': - return 'creating the deep review session'; - case 'open_aux_pane': - return 'opening the deep review pane'; - case 'send_start_message': - return 'starting the deep review run'; - default: - return 'launching deep review'; - } -} - -function createDeepReviewLaunchError( - launchStep: DeepReviewLaunchStep, - originalError: unknown, - childSessionId?: string, - cleanupResult?: FailedDeepReviewCleanupResult, -): DeepReviewLaunchError { - const classified = classifyLaunchError(launchStep, originalError); - const friendlyError = new Error( - LAUNCH_ERROR_DEFAULT_MESSAGES[classified.messageKey] ?? - LAUNCH_ERROR_DEFAULT_MESSAGES['deepReviewActionBar.launchError.unknown'], - ) as DeepReviewLaunchError; - - friendlyError.launchErrorCategory = classified.category; - friendlyError.launchErrorActions = classified.actions; - friendlyError.launchErrorMessageKey = classified.messageKey; - friendlyError.launchErrorStep = classified.step; - friendlyError.originalMessage = normalizeErrorMessage(originalError); - if (childSessionId) { - friendlyError.childSessionId = childSessionId; - } - if (cleanupResult) { - friendlyError.cleanupCompleted = cleanupResult.cleanupCompleted; - friendlyError.cleanupIssues = cleanupResult.cleanupIssues; - } - - return friendlyError; -} - -export function getDeepReviewLaunchErrorMessage( - error: unknown, - translate: (key: string, options?: { defaultValue?: string }) => string, - fallback = LAUNCH_ERROR_DEFAULT_MESSAGES['deepReviewActionBar.launchError.unknown'], -): string { - const launchError = error as DeepReviewLaunchError | null | undefined; - if (launchError?.launchErrorMessageKey) { - return translate(launchError.launchErrorMessageKey, { - defaultValue: launchError.message || fallback, - }); - } - - if (error instanceof Error && error.message.trim()) { - return error.message.trim(); - } - - return fallback; -} - -function buildLaunchCleanupError( - launchStep: DeepReviewLaunchStep, - childSessionId: string, - originalError: unknown, - cleanupResult: FailedDeepReviewCleanupResult, -): Error { - const originalMessage = normalizeErrorMessage(originalError); - if (cleanupResult.cleanupCompleted) { - return originalError instanceof Error ? originalError : new Error(originalMessage); - } - - const cleanupSummary = cleanupResult.cleanupIssues.join(' '); - return new Error( - `${originalMessage} Cleanup was incomplete after failure while ${describeLaunchStep(launchStep)}. ` + - `The partially created deep review session (${childSessionId}) may need manual cleanup. ${cleanupSummary}`.trim(), - ); -} - async function cleanupFailedDeepReviewLaunch( childSessionId: string, launchStep: DeepReviewLaunchStep, @@ -236,259 +118,6 @@ async function cleanupFailedDeepReviewLaunch( }; } -function formatFileList(filePaths: string[]): string { - return filePaths.map(filePath => `- ${filePath}`).join('\n'); -} - -export function isDeepReviewSlashCommand(commandText: string): boolean { - return DEEP_REVIEW_COMMAND_RE.test(commandText.trim()); -} - -function getDeepReviewCommandFocus(commandText: string): string { - return commandText.trim().replace(/^\/DeepReview\b/, '').trim(); -} - -const EXPLICIT_REVIEW_FILE_EXTENSIONS = new Set([ - '.ts', - '.tsx', - '.js', - '.jsx', - '.rs', - '.json', - '.scss', - '.css', - '.md', - '.toml', - '.yaml', - '.yml', -]); - -function cleanPotentialFileToken(token: string): string { - return token - .trim() - .replace(/^[`"']+/, '') - .replace(/[`"',;:]+$/, ''); -} - -function getPathExtension(path: string): string { - const lastSlash = path.lastIndexOf('/'); - const lastDot = path.lastIndexOf('.'); - if (lastDot <= lastSlash) { - return ''; - } - return path.slice(lastDot); -} - -function looksLikeExplicitReviewPath(token: string): boolean { - const normalizedPath = normalizeReviewPath(token); - return ( - normalizedPath.includes('/') && - !normalizedPath.startsWith('-') && - EXPLICIT_REVIEW_FILE_EXTENSIONS.has(getPathExtension(normalizedPath)) - ); -} - -function extractExplicitReviewFilePaths(commandFocus: string): string[] { - const paths = commandFocus - .split(/\s+/) - .map(cleanPotentialFileToken) - .filter(Boolean) - .filter(looksLikeExplicitReviewPath); - - return Array.from(new Set(paths)); -} - -function parseSlashCommandGitTarget(commandFocus: string): GitChangedFilesParams | null { - const tokens = commandFocus - .split(/\s+/) - .map(cleanPotentialFileToken) - .filter(Boolean); - - const commitKeywordIndex = tokens.findIndex((token) => token.toLowerCase() === 'commit'); - const commitRef = commitKeywordIndex >= 0 ? tokens[commitKeywordIndex + 1] : undefined; - if (commitRef && !commitRef.startsWith('-')) { - return { - source: `${commitRef}^`, - target: commitRef, - }; - } - - const rangeToken = tokens.find((token) => { - if (token.startsWith('-') || !token.includes('..')) { - return false; - } - - const parts = token.split('..'); - return parts.length === 2 && Boolean(parts[0]) && Boolean(parts[1]); - }); - - if (!rangeToken) { - return null; - } - - const [source, target] = rangeToken.split('..'); - return { source, target }; -} - -function collectChangedFilePaths(changedFiles: GitChangedFile[]): string[] { - return Array.from( - new Set( - changedFiles - .flatMap((file) => [file.path, file.old_path]) - .filter((path): path is string => Boolean(path)), - ), - ); -} - -function collectWorkspaceDiffFilePaths(status: GitStatus): string[] { - return Array.from( - new Set([ - ...status.staged.map((file) => file.path), - ...status.unstaged.map((file) => file.path), - ...status.untracked, - ...status.conflicts, - ].filter(Boolean)), - ); -} - -function countReviewTargetFiles(target: ReviewTargetClassification): number { - return target.files.filter((file) => !file.excluded).length; -} - -function buildUnknownChangeStats(target: ReviewTargetClassification): ReviewTeamChangeStats { - return { - fileCount: countReviewTargetFiles(target), - lineCountSource: 'unknown', - }; -} - -function countChangedLinesFromUnifiedDiff(diff: string): number | undefined { - if (!diff.trim()) { - return undefined; - } - - let changedLines = 0; - for (const line of diff.split(/\r?\n/)) { - if ( - (line.startsWith('+') && !/^\+\+\+\s/.test(line)) || - (line.startsWith('-') && !/^---\s/.test(line)) - ) { - changedLines += 1; - } - } - - return changedLines; -} - -function buildDiffChangeStats( - target: ReviewTargetClassification, - totalLinesChanged: number | undefined, -): ReviewTeamChangeStats { - if (totalLinesChanged === undefined) { - return buildUnknownChangeStats(target); - } - - return { - fileCount: countReviewTargetFiles(target), - totalLinesChanged, - lineCountSource: 'diff_stat', - }; -} - -async function resolveGitDiffChangeStats( - workspacePath: string, - params: GitDiffParams, - target: ReviewTargetClassification, -): Promise { - try { - const diff = await gitAPI.getDiff(workspacePath, params); - return buildDiffChangeStats(target, countChangedLinesFromUnifiedDiff(diff)); - } catch (error) { - log.warn('Failed to resolve Git diff stats for Deep Review target', { - workspacePath, - params, - error, - }); - return buildUnknownChangeStats(target); - } -} - -async function resolveWorkspaceDiffChangeStats( - workspacePath: string, - target: ReviewTargetClassification, -): Promise { - return resolveGitDiffChangeStats(workspacePath, { source: 'HEAD' }, target); -} - -async function resolveSlashCommandReviewTarget( - commandFocus: string, - workspacePath?: string, -): Promise { - const explicitFilePaths = extractExplicitReviewFilePaths(commandFocus); - if (explicitFilePaths.length > 0) { - const target = classifyReviewTargetFromFiles( - explicitFilePaths, - 'slash_command_explicit_files', - ); - return { target, changeStats: buildUnknownChangeStats(target) }; - } - - const gitTarget = parseSlashCommandGitTarget(commandFocus); - if (gitTarget) { - if (!workspacePath) { - const target = createUnknownReviewTargetClassification('slash_command_git_ref'); - return { target, changeStats: buildUnknownChangeStats(target) }; - } - - try { - const changedFiles = await gitAPI.getChangedFiles(workspacePath, gitTarget); - const target = classifyReviewTargetFromFiles( - collectChangedFilePaths(changedFiles), - 'slash_command_git_ref', - ); - const changeStats = await resolveGitDiffChangeStats( - workspacePath, - gitTarget, - target, - ); - return { target, changeStats }; - } catch (error) { - log.warn('Failed to resolve Git target for Deep Review target', { - workspacePath, - gitTarget, - error, - }); - const target = createUnknownReviewTargetClassification('slash_command_git_ref'); - return { target, changeStats: buildUnknownChangeStats(target) }; - } - } - - if (!commandFocus && workspacePath) { - try { - const status = await gitAPI.getStatus(workspacePath); - const target = classifyReviewTargetFromFiles( - collectWorkspaceDiffFilePaths(status), - 'workspace_diff', - ); - const changeStats = await resolveWorkspaceDiffChangeStats( - workspacePath, - target, - ); - return { target, changeStats }; - } catch (error) { - log.warn('Failed to resolve workspace diff for Deep Review target', { - workspacePath, - error, - }); - } - } - - const target = createUnknownReviewTargetClassification( - commandFocus ? 'manual_prompt' : 'unknown', - ); - return { target, changeStats: buildUnknownChangeStats(target) }; -} - async function buildReviewTeamManifestWithRuntimeSignals( team: Parameters[0], options: Parameters[1], @@ -530,19 +159,11 @@ export async function buildDeepReviewLaunchFromSessionFiles( target, changeStats, }); - const fileList = formatFileList(filePaths); - const contextBlock = extraContext?.trim() - ? `User-provided focus:\n${extraContext.trim()}` - : 'User-provided focus:\nNone.'; - - const prompt = [ - 'Run a deep code review using the parallel Code Review Team.', - 'Review scope: ONLY inspect the following files modified in this session.', - fileList, - contextBlock, - buildReviewTeamPromptBlock(team, manifest), - 'Keep the scope tight to the listed files unless a directly-related dependency must be read to confirm a finding.', - ].join('\n\n'); + const prompt = formatSessionFilesLaunchPrompt({ + filePaths, + extraContext, + reviewTeamPromptBlock: buildReviewTeamPromptBlock(team, manifest), + }); return { prompt, runManifest: manifest }; } @@ -586,19 +207,11 @@ export async function buildDeepReviewLaunchFromSlashCommand( target, changeStats, }); - const contextBlock = extraContext - ? `User-provided focus or target:\n${extraContext}` - : 'User-provided focus or target:\nNone. If no explicit target is given, review the current workspace changes relative to HEAD.'; - - const prompt = [ - 'Run a deep code review using the parallel Code Review Team.', - 'Interpret the user command below to determine the review target.', - 'If the user mentions a commit, ref, branch, or explicit file set, review that target.', - 'Otherwise, review the current workspace changes relative to HEAD.', - `Original command:\n${trimmed}`, - contextBlock, - buildReviewTeamPromptBlock(team, manifest), - ].join('\n\n'); + const prompt = formatSlashCommandLaunchPrompt({ + commandText: trimmed, + extraContext, + reviewTeamPromptBlock: buildReviewTeamPromptBlock(team, manifest), + }); return { prompt, runManifest: manifest }; } diff --git a/src/web-ui/src/flow_chat/deep-review/launch/commandParser.test.ts b/src/web-ui/src/flow_chat/deep-review/launch/commandParser.test.ts new file mode 100644 index 000000000..eaf30c266 --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/launch/commandParser.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { + DEEP_REVIEW_SLASH_COMMAND, + collectChangedFilePaths, + collectWorkspaceDiffFilePaths, + extractExplicitReviewFilePaths, + getDeepReviewCommandFocus, + isDeepReviewSlashCommand, + parseSlashCommandGitTarget, +} from './commandParser'; + +describe('Deep Review launch command parser', () => { + it('recognizes only the canonical slash command', () => { + expect(DEEP_REVIEW_SLASH_COMMAND).toBe('/DeepReview'); + expect(isDeepReviewSlashCommand('/DeepReview')).toBe(true); + expect(isDeepReviewSlashCommand('/DeepReview review commit abc123')).toBe(true); + expect(isDeepReviewSlashCommand('/deepreview review commit abc123')).toBe(false); + expect(isDeepReviewSlashCommand('/DeepReviewer review commit abc123')).toBe(false); + }); + + it('strips the canonical command before target parsing', () => { + expect(getDeepReviewCommandFocus('/DeepReview review commit abc123')).toBe( + 'review commit abc123', + ); + expect(getDeepReviewCommandFocus('/DeepReview')).toBe(''); + }); + + it('extracts explicit review file paths once and ignores prose tokens', () => { + expect( + extractExplicitReviewFilePaths( + 'please inspect `src/web-ui/src/App.tsx`, src/web-ui/src/App.tsx and src/crates/core/src/lib.rs for risk', + ), + ).toEqual([ + 'src/web-ui/src/App.tsx', + 'src/crates/core/src/lib.rs', + ]); + }); + + it('parses commit and range targets', () => { + expect(parseSlashCommandGitTarget('review commit abc123 for regressions')).toEqual({ + source: 'abc123^', + target: 'abc123', + }); + expect(parseSlashCommandGitTarget('review main..feature/deep-review')).toEqual({ + source: 'main', + target: 'feature/deep-review', + }); + expect(parseSlashCommandGitTarget('review --flag docs only')).toBeNull(); + }); + + it('collects unique changed paths including renamed sources', () => { + expect( + collectChangedFilePaths([ + { path: 'src/new.ts', old_path: 'src/old.ts' }, + { path: 'src/new.ts' }, + ] as any), + ).toEqual(['src/new.ts', 'src/old.ts']); + }); + + it('collects workspace diff paths from all status buckets', () => { + expect( + collectWorkspaceDiffFilePaths({ + staged: [{ path: 'src/staged.ts', status: 'modified' }], + unstaged: [{ path: 'src/unstaged.ts', status: 'modified' }], + untracked: ['src/new.ts'], + conflicts: ['src/conflict.ts'], + } as any), + ).toEqual([ + 'src/staged.ts', + 'src/unstaged.ts', + 'src/new.ts', + 'src/conflict.ts', + ]); + }); +}); diff --git a/src/web-ui/src/flow_chat/deep-review/launch/commandParser.ts b/src/web-ui/src/flow_chat/deep-review/launch/commandParser.ts new file mode 100644 index 000000000..a7703faf6 --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/launch/commandParser.ts @@ -0,0 +1,120 @@ +import type { + GitChangedFile, + GitChangedFilesParams, + GitStatus, +} from '@/infrastructure/api/service-api/GitAPI'; +import { normalizeReviewPath } from '@/shared/services/reviewTargetClassifier'; +import { DEEP_REVIEW_COMMAND_RE } from '../../utils/deepReviewConstants'; + +export const DEEP_REVIEW_SLASH_COMMAND = '/DeepReview'; + +const EXPLICIT_REVIEW_FILE_EXTENSIONS = new Set([ + '.ts', + '.tsx', + '.js', + '.jsx', + '.rs', + '.json', + '.scss', + '.css', + '.md', + '.toml', + '.yaml', + '.yml', +]); + +export function isDeepReviewSlashCommand(commandText: string): boolean { + return DEEP_REVIEW_COMMAND_RE.test(commandText.trim()); +} + +export function getDeepReviewCommandFocus(commandText: string): string { + return commandText.trim().replace(/^\/DeepReview\b/, '').trim(); +} + +function cleanPotentialFileToken(token: string): string { + return token + .trim() + .replace(/^[`"']+/, '') + .replace(/[`"',;:]+$/, ''); +} + +function getPathExtension(path: string): string { + const lastSlash = path.lastIndexOf('/'); + const lastDot = path.lastIndexOf('.'); + if (lastDot <= lastSlash) { + return ''; + } + return path.slice(lastDot); +} + +function looksLikeExplicitReviewPath(token: string): boolean { + const normalizedPath = normalizeReviewPath(token); + return ( + normalizedPath.includes('/') && + !normalizedPath.startsWith('-') && + EXPLICIT_REVIEW_FILE_EXTENSIONS.has(getPathExtension(normalizedPath)) + ); +} + +export function extractExplicitReviewFilePaths(commandFocus: string): string[] { + const paths = commandFocus + .split(/\s+/) + .map(cleanPotentialFileToken) + .filter(Boolean) + .filter(looksLikeExplicitReviewPath); + + return Array.from(new Set(paths)); +} + +export function parseSlashCommandGitTarget(commandFocus: string): GitChangedFilesParams | null { + const tokens = commandFocus + .split(/\s+/) + .map(cleanPotentialFileToken) + .filter(Boolean); + + const commitKeywordIndex = tokens.findIndex((token) => token.toLowerCase() === 'commit'); + const commitRef = commitKeywordIndex >= 0 ? tokens[commitKeywordIndex + 1] : undefined; + if (commitRef && !commitRef.startsWith('-')) { + return { + source: `${commitRef}^`, + target: commitRef, + }; + } + + const rangeToken = tokens.find((token) => { + if (token.startsWith('-') || !token.includes('..')) { + return false; + } + + const parts = token.split('..'); + return parts.length === 2 && Boolean(parts[0]) && Boolean(parts[1]); + }); + + if (!rangeToken) { + return null; + } + + const [source, target] = rangeToken.split('..'); + return { source, target }; +} + +export function collectChangedFilePaths(changedFiles: GitChangedFile[]): string[] { + return Array.from( + new Set( + changedFiles + .flatMap((file) => [file.path, file.old_path]) + .filter((path): path is string => Boolean(path)), + ), + ); +} + +export function collectWorkspaceDiffFilePaths(status: GitStatus): string[] { + return Array.from( + new Set([ + ...status.staged.map((file) => file.path), + ...status.unstaged.map((file) => file.path), + ...status.untracked, + ...status.conflicts, + ].filter(Boolean)), + ); +} diff --git a/src/web-ui/src/flow_chat/deep-review/launch/launchErrors.test.ts b/src/web-ui/src/flow_chat/deep-review/launch/launchErrors.test.ts new file mode 100644 index 000000000..1aa8d29ff --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/launch/launchErrors.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { + buildLaunchCleanupError, + createDeepReviewLaunchError, + getDeepReviewLaunchErrorMessage, + isSessionMissingError, + normalizeErrorMessage, + type FailedDeepReviewCleanupResult, +} from './launchErrors'; + +describe('Deep Review launch errors', () => { + it('normalizes empty and string errors', () => { + expect(normalizeErrorMessage(' network down ')).toBe('network down'); + expect(normalizeErrorMessage(new Error(' model missing '))).toBe('model missing'); + expect(normalizeErrorMessage(null)).toBe('Deep review failed to start'); + }); + + it('recognizes missing-session cleanup failures as non-fatal', () => { + expect(isSessionMissingError(new Error('Session does not exist'))).toBe(true); + expect(isSessionMissingError(new Error('session not found'))).toBe(true); + expect(isSessionMissingError(new Error('permission denied'))).toBe(false); + }); + + it('creates localized launch errors for user-facing surfaces', () => { + const error = createDeepReviewLaunchError( + 'send_start_message', + new Error('SSE stream connection timeout'), + 'child-123', + { cleanupCompleted: true, cleanupIssues: [] }, + ); + + expect(error.message).toBe('Network connection was interrupted before Deep Review could start.'); + expect(error.launchErrorMessageKey).toBe('deepReviewActionBar.launchError.network'); + expect(error.launchErrorCategory).toBe('network'); + expect(error.childSessionId).toBe('child-123'); + expect( + getDeepReviewLaunchErrorMessage(error, (key) => `translated:${key}`), + ).toBe('translated:deepReviewActionBar.launchError.network'); + }); + + it('keeps original launch error when cleanup completed', () => { + const original = new Error('Pane open failed'); + const cleanupResult: FailedDeepReviewCleanupResult = { + cleanupCompleted: true, + cleanupIssues: [], + }; + + expect(buildLaunchCleanupError( + 'open_aux_pane', + 'child-123', + original, + cleanupResult, + )).toBe(original); + }); + + it('adds cleanup context when cleanup is incomplete', () => { + const cleanupResult: FailedDeepReviewCleanupResult = { + cleanupCompleted: false, + cleanupIssues: ['Failed to remove local state.'], + }; + + expect(buildLaunchCleanupError( + 'open_aux_pane', + 'child-123', + new Error('Pane open failed'), + cleanupResult, + ).message).toContain('The partially created deep review session (child-123) may need manual cleanup.'); + }); +}); diff --git a/src/web-ui/src/flow_chat/deep-review/launch/launchErrors.ts b/src/web-ui/src/flow_chat/deep-review/launch/launchErrors.ts new file mode 100644 index 000000000..6d9f6f746 --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/launch/launchErrors.ts @@ -0,0 +1,123 @@ +import { classifyLaunchError } from '../../utils/deepReviewExperience'; + +export type DeepReviewLaunchStep = + | 'create_child_session' + | 'open_aux_pane' + | 'send_start_message'; + +export interface FailedDeepReviewCleanupResult { + cleanupCompleted: boolean; + cleanupIssues: string[]; +} + +export interface DeepReviewLaunchError extends Error { + launchErrorCategory?: string; + launchErrorActions?: string[]; + launchErrorMessageKey?: string; + launchErrorStep?: string; + originalMessage?: string; + childSessionId?: string; + cleanupCompleted?: boolean; + cleanupIssues?: string[]; +} + +const LAUNCH_ERROR_DEFAULT_MESSAGES: Record = { + 'deepReviewActionBar.launchError.modelConfig': 'Deep review could not create a review session. Check the model configuration.', + 'deepReviewActionBar.launchError.network': 'Network connection was interrupted before Deep Review could start.', + 'deepReviewActionBar.launchError.unknown': 'Deep review failed to start. Please try again.', +}; + +export function normalizeErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim()) { + return error.message.trim(); + } + + if (typeof error === 'string' && error.trim()) { + return error.trim(); + } + + return 'Deep review failed to start'; +} + +export function isSessionMissingError(error: unknown): boolean { + const message = normalizeErrorMessage(error).toLowerCase(); + return message.includes('session does not exist') || message.includes('not found'); +} + +function describeLaunchStep(step: DeepReviewLaunchStep): string { + switch (step) { + case 'create_child_session': + return 'creating the deep review session'; + case 'open_aux_pane': + return 'opening the deep review pane'; + case 'send_start_message': + return 'starting the deep review run'; + default: + return 'launching deep review'; + } +} + +export function createDeepReviewLaunchError( + launchStep: DeepReviewLaunchStep, + originalError: unknown, + childSessionId?: string, + cleanupResult?: FailedDeepReviewCleanupResult, +): DeepReviewLaunchError { + const classified = classifyLaunchError(launchStep, originalError); + const friendlyError = new Error( + LAUNCH_ERROR_DEFAULT_MESSAGES[classified.messageKey] ?? + LAUNCH_ERROR_DEFAULT_MESSAGES['deepReviewActionBar.launchError.unknown'], + ) as DeepReviewLaunchError; + + friendlyError.launchErrorCategory = classified.category; + friendlyError.launchErrorActions = classified.actions; + friendlyError.launchErrorMessageKey = classified.messageKey; + friendlyError.launchErrorStep = classified.step; + friendlyError.originalMessage = normalizeErrorMessage(originalError); + if (childSessionId) { + friendlyError.childSessionId = childSessionId; + } + if (cleanupResult) { + friendlyError.cleanupCompleted = cleanupResult.cleanupCompleted; + friendlyError.cleanupIssues = cleanupResult.cleanupIssues; + } + + return friendlyError; +} + +export function getDeepReviewLaunchErrorMessage( + error: unknown, + translate: (key: string, options?: { defaultValue?: string }) => string, + fallback = LAUNCH_ERROR_DEFAULT_MESSAGES['deepReviewActionBar.launchError.unknown'], +): string { + const launchError = error as DeepReviewLaunchError | null | undefined; + if (launchError?.launchErrorMessageKey) { + return translate(launchError.launchErrorMessageKey, { + defaultValue: launchError.message || fallback, + }); + } + + if (error instanceof Error && error.message.trim()) { + return error.message.trim(); + } + + return fallback; +} + +export function buildLaunchCleanupError( + launchStep: DeepReviewLaunchStep, + childSessionId: string, + originalError: unknown, + cleanupResult: FailedDeepReviewCleanupResult, +): Error { + const originalMessage = normalizeErrorMessage(originalError); + if (cleanupResult.cleanupCompleted) { + return originalError instanceof Error ? originalError : new Error(originalMessage); + } + + const cleanupSummary = cleanupResult.cleanupIssues.join(' '); + return new Error( + `${originalMessage} Cleanup was incomplete after failure while ${describeLaunchStep(launchStep)}. ` + + `The partially created deep review session (${childSessionId}) may need manual cleanup. ${cleanupSummary}`.trim(), + ); +} diff --git a/src/web-ui/src/flow_chat/deep-review/launch/launchPrompt.test.ts b/src/web-ui/src/flow_chat/deep-review/launch/launchPrompt.test.ts new file mode 100644 index 000000000..e61967e3a --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/launch/launchPrompt.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { + formatFileList, + formatSessionFilesLaunchPrompt, + formatSlashCommandLaunchPrompt, +} from './launchPrompt'; + +describe('Deep Review launch prompt formatting', () => { + it('formats review file lists as markdown bullets', () => { + expect(formatFileList(['src/a.ts', 'src/b.ts'])).toBe('- src/a.ts\n- src/b.ts'); + }); + + it('builds a session-files prompt with explicit scope and optional focus', () => { + const prompt = formatSessionFilesLaunchPrompt({ + filePaths: ['src/a.ts'], + extraContext: 'check regressions', + reviewTeamPromptBlock: 'Review team manifest.', + }); + + expect(prompt).toContain('Review scope: ONLY inspect the following files modified in this session.'); + expect(prompt).toContain('- src/a.ts'); + expect(prompt).toContain('User-provided focus:\ncheck regressions'); + expect(prompt).toContain('Review team manifest.'); + }); + + it('builds a slash-command prompt with original command and fallback focus', () => { + const prompt = formatSlashCommandLaunchPrompt({ + commandText: '/DeepReview', + extraContext: '', + reviewTeamPromptBlock: 'Review team manifest.', + }); + + expect(prompt).toContain('Original command:\n/DeepReview'); + expect(prompt).toContain( + 'User-provided focus or target:\nNone. If no explicit target is given, review the current workspace changes relative to HEAD.', + ); + expect(prompt).toContain('Review team manifest.'); + }); +}); diff --git a/src/web-ui/src/flow_chat/deep-review/launch/launchPrompt.ts b/src/web-ui/src/flow_chat/deep-review/launch/launchPrompt.ts new file mode 100644 index 000000000..cdd8e8ec1 --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/launch/launchPrompt.ts @@ -0,0 +1,54 @@ +interface SessionFilesLaunchPromptParams { + filePaths: string[]; + extraContext?: string; + reviewTeamPromptBlock: string; +} + +interface SlashCommandLaunchPromptParams { + commandText: string; + extraContext: string; + reviewTeamPromptBlock: string; +} + +export function formatFileList(filePaths: string[]): string { + return filePaths.map(filePath => `- ${filePath}`).join('\n'); +} + +export function formatSessionFilesLaunchPrompt({ + filePaths, + extraContext, + reviewTeamPromptBlock, +}: SessionFilesLaunchPromptParams): string { + const contextBlock = extraContext?.trim() + ? `User-provided focus:\n${extraContext.trim()}` + : 'User-provided focus:\nNone.'; + + return [ + 'Run a deep code review using the parallel Code Review Team.', + 'Review scope: ONLY inspect the following files modified in this session.', + formatFileList(filePaths), + contextBlock, + reviewTeamPromptBlock, + 'Keep the scope tight to the listed files unless a directly-related dependency must be read to confirm a finding.', + ].join('\n\n'); +} + +export function formatSlashCommandLaunchPrompt({ + commandText, + extraContext, + reviewTeamPromptBlock, +}: SlashCommandLaunchPromptParams): string { + const contextBlock = extraContext + ? `User-provided focus or target:\n${extraContext}` + : 'User-provided focus or target:\nNone. If no explicit target is given, review the current workspace changes relative to HEAD.'; + + return [ + 'Run a deep code review using the parallel Code Review Team.', + 'Interpret the user command below to determine the review target.', + 'If the user mentions a commit, ref, branch, or explicit file set, review that target.', + 'Otherwise, review the current workspace changes relative to HEAD.', + `Original command:\n${commandText}`, + contextBlock, + reviewTeamPromptBlock, + ].join('\n\n'); +} diff --git a/src/web-ui/src/flow_chat/deep-review/launch/targetResolver.test.ts b/src/web-ui/src/flow_chat/deep-review/launch/targetResolver.test.ts new file mode 100644 index 000000000..dc7009995 --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/launch/targetResolver.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { + countChangedLinesFromUnifiedDiff, + resolveSlashCommandReviewTarget, +} from './targetResolver'; + +const mockGitGetStatus = vi.fn(); +const mockGitGetChangedFiles = vi.fn(); +const mockGitGetDiff = vi.fn(); + +vi.mock('@/infrastructure/api', () => ({ + gitAPI: { + getStatus: (...args: any[]) => mockGitGetStatus(...args), + getChangedFiles: (...args: any[]) => mockGitGetChangedFiles(...args), + getDiff: (...args: any[]) => mockGitGetDiff(...args), + }, +})); + +describe('Deep Review target resolver', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGitGetStatus.mockResolvedValue({ + staged: [], + unstaged: [], + untracked: [], + conflicts: [], + current_branch: 'main', + ahead: 0, + behind: 0, + }); + mockGitGetChangedFiles.mockResolvedValue([]); + mockGitGetDiff.mockResolvedValue(''); + }); + + it('counts changed lines from unified diff without headers', () => { + expect(countChangedLinesFromUnifiedDiff([ + 'diff --git a/src/lib.rs b/src/lib.rs', + '--- a/src/lib.rs', + '+++ b/src/lib.rs', + '@@ -1,2 +1,3 @@', + '-old line', + '+new line', + '+another line', + ].join('\n'))).toBe(3); + }); + + it('resolves explicit file targets before reading git state', async () => { + const result = await resolveSlashCommandReviewTarget( + 'src/web-ui/src/App.tsx src/crates/core/src/lib.rs for regressions', + 'D:\\workspace\\repo', + ); + + expect(mockGitGetStatus).not.toHaveBeenCalled(); + expect(mockGitGetChangedFiles).not.toHaveBeenCalled(); + expect(result.target.source).toBe('slash_command_explicit_files'); + expect(result.changeStats).toEqual({ + fileCount: 2, + lineCountSource: 'unknown', + }); + }); + + it('resolves commit targets using changed files and diff stats', async () => { + mockGitGetChangedFiles.mockResolvedValueOnce([ + { path: 'src/new.ts', old_path: 'src/old.ts' }, + ]); + mockGitGetDiff.mockResolvedValueOnce([ + 'diff --git a/src/new.ts b/src/new.ts', + '@@ -1 +1 @@', + '-old', + '+new', + ].join('\n')); + + const result = await resolveSlashCommandReviewTarget( + 'review commit abc123', + 'D:\\workspace\\repo', + ); + + expect(mockGitGetChangedFiles).toHaveBeenCalledWith('D:\\workspace\\repo', { + source: 'abc123^', + target: 'abc123', + }); + expect(result.target.source).toBe('slash_command_git_ref'); + expect(result.changeStats).toEqual({ + fileCount: 2, + totalLinesChanged: 2, + lineCountSource: 'diff_stat', + }); + }); + + it('resolves empty slash-command focus from workspace diff', async () => { + mockGitGetStatus.mockResolvedValueOnce({ + staged: [{ path: 'src/staged.ts', status: 'modified' }], + unstaged: [], + untracked: ['src/new.ts'], + conflicts: [], + current_branch: 'main', + ahead: 0, + behind: 0, + }); + + const result = await resolveSlashCommandReviewTarget('', 'D:\\workspace\\repo'); + + expect(mockGitGetStatus).toHaveBeenCalledWith('D:\\workspace\\repo'); + expect(result.target.source).toBe('workspace_diff'); + expect(result.changeStats).toEqual({ + fileCount: 2, + lineCountSource: 'unknown', + }); + }); +}); diff --git a/src/web-ui/src/flow_chat/deep-review/launch/targetResolver.ts b/src/web-ui/src/flow_chat/deep-review/launch/targetResolver.ts new file mode 100644 index 000000000..5b9a1dda2 --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/launch/targetResolver.ts @@ -0,0 +1,162 @@ +import { gitAPI } from '@/infrastructure/api'; +import type { GitDiffParams } from '@/infrastructure/api/service-api/GitAPI'; +import type { ReviewTeamChangeStats } from '@/shared/services/reviewTeamService'; +import { + classifyReviewTargetFromFiles, + createUnknownReviewTargetClassification, + type ReviewTargetClassification, +} from '@/shared/services/reviewTargetClassifier'; +import { createLogger } from '@/shared/utils/logger'; +import { + collectChangedFilePaths, + collectWorkspaceDiffFilePaths, + extractExplicitReviewFilePaths, + parseSlashCommandGitTarget, +} from './commandParser'; + +const log = createLogger('DeepReviewService'); + +export interface ResolvedDeepReviewTarget { + target: ReviewTargetClassification; + changeStats: ReviewTeamChangeStats; +} + +function countReviewTargetFiles(target: ReviewTargetClassification): number { + return target.files.filter((file) => !file.excluded).length; +} + +export function buildUnknownChangeStats( + target: ReviewTargetClassification, +): ReviewTeamChangeStats { + return { + fileCount: countReviewTargetFiles(target), + lineCountSource: 'unknown', + }; +} + +export function countChangedLinesFromUnifiedDiff(diff: string): number | undefined { + if (!diff.trim()) { + return undefined; + } + + let changedLines = 0; + for (const line of diff.split(/\r?\n/)) { + if ( + (line.startsWith('+') && !/^\+\+\+\s/.test(line)) || + (line.startsWith('-') && !/^---\s/.test(line)) + ) { + changedLines += 1; + } + } + + return changedLines; +} + +function buildDiffChangeStats( + target: ReviewTargetClassification, + totalLinesChanged: number | undefined, +): ReviewTeamChangeStats { + if (totalLinesChanged === undefined) { + return buildUnknownChangeStats(target); + } + + return { + fileCount: countReviewTargetFiles(target), + totalLinesChanged, + lineCountSource: 'diff_stat', + }; +} + +async function resolveGitDiffChangeStats( + workspacePath: string, + params: GitDiffParams, + target: ReviewTargetClassification, +): Promise { + try { + const diff = await gitAPI.getDiff(workspacePath, params); + return buildDiffChangeStats(target, countChangedLinesFromUnifiedDiff(diff)); + } catch (error) { + log.warn('Failed to resolve Git diff stats for Deep Review target', { + workspacePath, + params, + error, + }); + return buildUnknownChangeStats(target); + } +} + +async function resolveWorkspaceDiffChangeStats( + workspacePath: string, + target: ReviewTargetClassification, +): Promise { + return resolveGitDiffChangeStats(workspacePath, { source: 'HEAD' }, target); +} + +export async function resolveSlashCommandReviewTarget( + commandFocus: string, + workspacePath?: string, +): Promise { + const explicitFilePaths = extractExplicitReviewFilePaths(commandFocus); + if (explicitFilePaths.length > 0) { + const target = classifyReviewTargetFromFiles( + explicitFilePaths, + 'slash_command_explicit_files', + ); + return { target, changeStats: buildUnknownChangeStats(target) }; + } + + const gitTarget = parseSlashCommandGitTarget(commandFocus); + if (gitTarget) { + if (!workspacePath) { + const target = createUnknownReviewTargetClassification('slash_command_git_ref'); + return { target, changeStats: buildUnknownChangeStats(target) }; + } + + try { + const changedFiles = await gitAPI.getChangedFiles(workspacePath, gitTarget); + const target = classifyReviewTargetFromFiles( + collectChangedFilePaths(changedFiles), + 'slash_command_git_ref', + ); + const changeStats = await resolveGitDiffChangeStats( + workspacePath, + gitTarget, + target, + ); + return { target, changeStats }; + } catch (error) { + log.warn('Failed to resolve Git target for Deep Review target', { + workspacePath, + gitTarget, + error, + }); + const target = createUnknownReviewTargetClassification('slash_command_git_ref'); + return { target, changeStats: buildUnknownChangeStats(target) }; + } + } + + if (!commandFocus && workspacePath) { + try { + const status = await gitAPI.getStatus(workspacePath); + const target = classifyReviewTargetFromFiles( + collectWorkspaceDiffFilePaths(status), + 'workspace_diff', + ); + const changeStats = await resolveWorkspaceDiffChangeStats( + workspacePath, + target, + ); + return { target, changeStats }; + } catch (error) { + log.warn('Failed to resolve workspace diff for Deep Review target', { + workspacePath, + error, + }); + } + } + + const target = createUnknownReviewTargetClassification( + commandFocus ? 'manual_prompt' : 'unknown', + ); + return { target, changeStats: buildUnknownChangeStats(target) }; +} diff --git a/src/web-ui/src/flow_chat/deep-review/report/codeReviewReport.ts b/src/web-ui/src/flow_chat/deep-review/report/codeReviewReport.ts index 0e29224f7..5f2de673a 100644 --- a/src/web-ui/src/flow_chat/deep-review/report/codeReviewReport.ts +++ b/src/web-ui/src/flow_chat/deep-review/report/codeReviewReport.ts @@ -1,8 +1,14 @@ -import { - getActiveReviewTeamManifestMembers, - type ReviewTeamManifestMember, - type ReviewTeamRunManifest, -} from '@/shared/services/reviewTeamService'; +import type { ReviewTeamRunManifest } from '@/shared/services/reviewTeamService'; + +export { buildCodeReviewReliabilityNotices } from './reliabilityNotices'; +export { + buildCodeReviewReportSections, + getDefaultExpandedCodeReviewSectionIds, +} from './reportSections'; +export { + DEFAULT_CODE_REVIEW_MARKDOWN_LABELS, + formatCodeReviewReportMarkdown, +} from './markdown'; export type ReviewRiskLevel = 'low' | 'medium' | 'high' | 'critical'; export type ReviewAction = 'approve' | 'approve_with_suggestions' | 'request_changes' | 'block'; @@ -142,6 +148,7 @@ export type ReviewReliabilityNoticeKind = | 'cache_miss' | 'concurrency_limited' | 'partial_reviewer' + | 'reduced_scope' | 'retry_guidance' | 'skipped_reviewers' | 'token_budget_limited' @@ -203,112 +210,12 @@ export interface CodeReviewReportMarkdownOptions { runManifest?: ReviewTeamRunManifest; } -const REMEDIATION_GROUP_ORDER: RemediationGroupId[] = [ - 'must_fix', - 'should_improve', - 'needs_decision', - 'verification', -]; - -const STRENGTH_GROUP_ORDER: StrengthGroupId[] = [ - 'architecture', - 'maintainability', - 'tests', - 'security', - 'performance', - 'user_experience', - 'other', -]; - -const DEGRADED_REVIEWER_STATUSES = new Set(['timed_out', 'cancelled_by_user', 'failed', 'skipped']); -const PARTIAL_TIMEOUT_REVIEWER_STATUSES = new Set(['partial_timeout', 'timed_out', 'cancelled_by_user']); const RETRYABLE_CAPACITY_REASONS = new Set([ 'provider_rate_limit', 'provider_concurrency_limit', 'retry_after', 'temporary_overload', ]); -const RELIABILITY_NOTICE_ORDER: ReviewReliabilityNoticeKind[] = [ - 'context_pressure', - 'skipped_reviewers', - 'token_budget_limited', - 'compression_preserved', - 'cache_hit', - 'cache_miss', - 'concurrency_limited', - 'partial_reviewer', - 'retry_guidance', - 'user_decision', -]; -const RELIABILITY_NOTICE_FALLBACK_LABELS: Record = { - context_pressure: 'Context pressure rising', - compression_preserved: 'Compression preserved key facts', - cache_hit: 'Incremental cache reused reviewer output', - cache_miss: 'Incremental cache missed or refreshed', - concurrency_limited: 'Reviewer launch was concurrency-limited', - partial_reviewer: 'Reviewer timed out with partial result', - retry_guidance: 'Retry guidance emitted', - skipped_reviewers: 'Skipped reviewers', - token_budget_limited: 'Token budget limited reviewer coverage', - user_decision: 'User decision needed', -}; -const RELIABILITY_NOTICE_SEVERITY_BY_KIND: Record = { - context_pressure: 'info', - compression_preserved: 'info', - cache_hit: 'info', - cache_miss: 'info', - concurrency_limited: 'warning', - partial_reviewer: 'warning', - retry_guidance: 'warning', - skipped_reviewers: 'info', - token_budget_limited: 'warning', - user_decision: 'action', -}; - -export const DEFAULT_CODE_REVIEW_MARKDOWN_LABELS: CodeReviewReportMarkdownLabels = { - titleStandard: 'Code Review Report', - titleDeep: 'Deep Review Report', - executiveSummary: 'Executive Summary', - reviewDecision: 'Review Decision', - runManifest: 'Run manifest', - riskLevel: 'Risk Level', - recommendedAction: 'Recommended Action', - scope: 'Scope', - target: 'Target', - budget: 'Budget', - estimatedCalls: 'Estimated calls', - activeReviewers: 'Active reviewers', - skippedReviewers: 'Skipped reviewers', - issues: 'Issues', - noIssues: 'No validated issues.', - remediationPlan: 'Remediation Plan', - strengths: 'Strengths', - reviewTeam: 'Code Review Team', - reliabilitySignals: 'Review Reliability', - coverageNotes: 'Coverage Notes', - status: 'Status', - packet: 'Packet', - partialOutput: 'Partial output', - findings: 'Findings', - validation: 'Validation', - suggestion: 'Suggestion', - source: 'Source', - noItems: 'None.', - reliabilityNoticeLabels: RELIABILITY_NOTICE_FALLBACK_LABELS, - groupTitles: { - must_fix: 'Must Fix', - should_improve: 'Should Improve', - needs_decision: 'Needs Decision', - verification: 'Verification', - architecture: 'Architecture', - maintainability: 'Maintainability', - tests: 'Tests', - security: 'Security', - performance: 'Performance', - user_experience: 'User Experience', - other: 'Other', - }, -}; export type DeepReviewRetryableSliceSourceStatus = 'partial_timeout' | 'capacity_skipped'; @@ -514,649 +421,3 @@ export function buildDeepReviewRetryPrompt(slices: DeepReviewRetryableSlice[]): '', ].join('\n'); } - -function buildGroups( - order: TId[], - data?: Partial>, -): Array> { - return order - .map((id) => ({ id, items: nonEmpty(data?.[id]) })) - .filter((group) => group.items.length > 0); -} - -function buildLegacyRemediationGroups(report: CodeReviewReportData): Array> { - const items = nonEmpty(report.remediation_plan); - if (items.length === 0) { - return []; - } - - const recommendedAction = report.summary?.recommended_action; - const id: RemediationGroupId = - recommendedAction === 'request_changes' || recommendedAction === 'block' - ? 'must_fix' - : 'should_improve'; - - return [{ id, items }]; -} - -function buildLegacyStrengthGroups(report: CodeReviewReportData): Array> { - const items = nonEmpty(report.positive_points).filter((item) => item.toLowerCase() !== 'none'); - return items.length > 0 ? [{ id: 'other', items }] : []; -} - -function buildIssueStats(issues: CodeReviewIssue[] = []): ReviewIssueStats { - const stats: ReviewIssueStats = { - total: 0, - critical: 0, - high: 0, - medium: 0, - low: 0, - info: 0, - }; - - for (const issue of issues) { - const severity = issue.severity ?? 'info'; - stats[severity] += 1; - stats.total += 1; - } - - return stats; -} - -function buildReviewerStats(reviewers: CodeReviewReviewer[] = []): ReviewReviewerStats { - let completed = 0; - let degraded = 0; - - for (const reviewer of reviewers) { - if (reviewer.status === 'completed') { - completed += 1; - } else if ( - DEGRADED_REVIEWER_STATUSES.has(reviewer.status) || - reviewer.status === 'partial_timeout' - ) { - degraded += 1; - } - } - - return { - total: reviewers.length, - completed, - degraded, - }; -} - -function buildPartialReviewerCoverageNotes(reviewers: CodeReviewReviewer[] = []): string[] { - return reviewers - .map((reviewer) => { - const partialOutput = reviewer.partial_output?.trim(); - if (!partialOutput || !PARTIAL_TIMEOUT_REVIEWER_STATUSES.has(reviewer.status)) { - return null; - } - return `${reviewer.name} timed out after producing partial output: ${partialOutput}`; - }) - .filter((note): note is string => Boolean(note)); -} - -function hasCompressionPreservationNote(report: CodeReviewReportData): boolean { - const notes = [ - ...(report.report_sections?.coverage_notes ?? []), - report.summary?.confidence_note, - ]; - - return notes.some((note) => { - const normalized = note?.toLowerCase() ?? ''; - return normalized.includes('compress') && normalized.includes('preserv'); - }); -} - -function countPartialReviewers(reviewers: CodeReviewReviewer[] = []): number { - return reviewers.filter((reviewer) => - reviewer.status === 'partial_timeout' || - ( - PARTIAL_TIMEOUT_REVIEWER_STATUSES.has(reviewer.status) && - Boolean(reviewer.partial_output?.trim()) - ) - ).length; -} - -function countSkippedReviewers(runManifest?: ReviewTeamRunManifest): number { - return runManifest?.skippedReviewers.length ?? 0; -} - -function countTokenBudgetLimitedReviewers(runManifest?: ReviewTeamRunManifest): number { - if (!runManifest) { - return 0; - } - const skippedByBudget = new Set(runManifest.tokenBudget.skippedReviewerIds); - for (const reviewer of runManifest.skippedReviewers) { - if (reviewer.reason === 'budget_limited') { - skippedByBudget.add(reviewer.subagentId); - } - } - return skippedByBudget.size; -} - -function countDecisionItems(report: CodeReviewReportData): number { - const structuredDecisionItems = report.report_sections?.remediation_groups?.needs_decision ?? []; - if (structuredDecisionItems.length > 0) { - const stringItems = structuredDecisionItems.filter((item): item is string => typeof item === 'string'); - return nonEmpty(stringItems).length; - } - - return report.summary?.recommended_action === 'block' ? 1 : 0; -} - -function isReliabilityNoticeKind(value: string): value is ReviewReliabilityNoticeKind { - return RELIABILITY_NOTICE_ORDER.includes(value as ReviewReliabilityNoticeKind); -} - -function isReliabilitySeverity(value: string): value is ReviewReliabilityNoticeSeverity { - return value === 'info' || value === 'warning' || value === 'action'; -} - -function isReliabilitySignalSource(value: string): value is ReviewReliabilitySignalSource { - return value === 'runtime' || value === 'manifest' || value === 'report' || value === 'inferred'; -} - -function normalizeStructuredReliabilityNotice( - signal: CodeReviewReliabilitySignal, -): ReviewReliabilityNotice | null { - if (!isReliabilityNoticeKind(signal.kind)) { - return null; - } - - const detail = signal.detail?.trim(); - return { - kind: signal.kind, - severity: signal.severity && isReliabilitySeverity(signal.severity) - ? signal.severity - : RELIABILITY_NOTICE_SEVERITY_BY_KIND[signal.kind], - ...(typeof signal.count === 'number' ? { count: signal.count } : {}), - ...(signal.source && isReliabilitySignalSource(signal.source) - ? { source: signal.source } - : {}), - ...(detail ? { detail } : {}), - }; -} - -function structuredReliabilityNoticeMap( - report: CodeReviewReportData, -): Map { - const notices = new Map(); - for (const signal of report.reliability_signals ?? []) { - const notice = normalizeStructuredReliabilityNotice(signal); - if (notice && !notices.has(notice.kind)) { - notices.set(notice.kind, notice); - } - } - return notices; -} - -function reliabilityNoticeLabel( - kind: ReviewReliabilityNoticeKind, - labels: CodeReviewReportMarkdownLabels, -): string { - return labels.reliabilityNoticeLabels[kind] ?? RELIABILITY_NOTICE_FALLBACK_LABELS[kind]; -} - -function reliabilityNoticeMarkdownDetail(notice: ReviewReliabilityNotice): string { - if (notice.detail?.trim()) { - return notice.detail.trim(); - } - if (typeof notice.count === 'number') { - return `Count: ${notice.count}`; - } - return ''; -} - -function reliabilityNoticeMarkdownLine( - notice: ReviewReliabilityNotice, - labels: CodeReviewReportMarkdownLabels, -): string { - const tags = [notice.severity, notice.source].filter(Boolean).join('/'); - const detail = reliabilityNoticeMarkdownDetail(notice); - const tagText = tags ? ` [${tags}]` : ''; - return detail - ? `- ${reliabilityNoticeLabel(notice.kind, labels)}${tagText}: ${detail}` - : `- ${reliabilityNoticeLabel(notice.kind, labels)}${tagText}`; -} - -export function buildCodeReviewReliabilityNotices( - report: CodeReviewReportData, - runManifest?: ReviewTeamRunManifest, -): ReviewReliabilityNotice[] { - const notices: ReviewReliabilityNotice[] = []; - const structuredNotices = structuredReliabilityNoticeMap(report); - const hasContextPressure = runManifest - ? runManifest.tokenBudget.largeDiffSummaryFirst || runManifest.tokenBudget.warnings.length > 0 - : false; - - const structuredContextPressure = structuredNotices.get('context_pressure'); - if (structuredContextPressure) { - notices.push(structuredContextPressure); - } else if (hasContextPressure && runManifest) { - notices.push({ - kind: 'context_pressure', - severity: 'info', - count: runManifest.tokenBudget.estimatedReviewerCalls, - source: 'manifest', - }); - } - - const structuredCompressionPreserved = structuredNotices.get('compression_preserved'); - if (structuredCompressionPreserved) { - notices.push(structuredCompressionPreserved); - } else if (hasCompressionPreservationNote(report)) { - notices.push({ - kind: 'compression_preserved', - severity: 'info', - source: 'inferred', - }); - } - - for (const kind of ['cache_hit', 'cache_miss', 'concurrency_limited'] as const) { - const structuredNotice = structuredNotices.get(kind); - if (structuredNotice) { - notices.push(structuredNotice); - } - } - - const partialReviewerCount = countPartialReviewers(report.reviewers); - const structuredPartialReviewer = structuredNotices.get('partial_reviewer'); - if (structuredPartialReviewer) { - notices.push(structuredPartialReviewer); - } else if (partialReviewerCount > 0) { - notices.push({ - kind: 'partial_reviewer', - severity: 'warning', - count: partialReviewerCount, - source: 'runtime', - }); - } - - const structuredRetryGuidance = structuredNotices.get('retry_guidance'); - if (structuredRetryGuidance) { - notices.push(structuredRetryGuidance); - } else if (partialReviewerCount > 0) { - notices.push({ - kind: 'retry_guidance', - severity: 'warning', - count: partialReviewerCount, - source: 'runtime', - }); - } - - const skippedReviewerCount = countSkippedReviewers(runManifest); - const structuredSkippedReviewers = structuredNotices.get('skipped_reviewers'); - if (structuredSkippedReviewers) { - notices.push(structuredSkippedReviewers); - } else if (skippedReviewerCount > 0) { - notices.push({ - kind: 'skipped_reviewers', - severity: 'info', - count: skippedReviewerCount, - source: 'manifest', - }); - } - - const tokenBudgetLimitedReviewerCount = countTokenBudgetLimitedReviewers(runManifest); - const structuredTokenBudgetLimited = structuredNotices.get('token_budget_limited'); - if (structuredTokenBudgetLimited) { - notices.push(structuredTokenBudgetLimited); - } else if (tokenBudgetLimitedReviewerCount > 0) { - notices.push({ - kind: 'token_budget_limited', - severity: 'warning', - count: tokenBudgetLimitedReviewerCount, - source: 'manifest', - }); - } - - const decisionItemCount = countDecisionItems(report); - const structuredUserDecision = structuredNotices.get('user_decision'); - if (structuredUserDecision) { - notices.push(structuredUserDecision); - } else if (decisionItemCount > 0) { - notices.push({ - kind: 'user_decision', - severity: 'action', - count: decisionItemCount, - source: 'report', - }); - } - - return RELIABILITY_NOTICE_ORDER - .map((kind) => notices.find((notice) => notice.kind === kind)) - .filter((notice): notice is ReviewReliabilityNotice => Boolean(notice)); -} - -export function buildCodeReviewReportSections(report: CodeReviewReportData): ReviewReportSections { - const structuredSections = report.report_sections; - - // Normalize remediation groups: DecisionContext entries become their plan text for display - const rawRemediationGroups = structuredSections?.remediation_groups; - const normalizedRemediationGroups: Partial> = {}; - if (rawRemediationGroups) { - for (const [key, entries] of Object.entries(rawRemediationGroups) as [RemediationGroupId, (string | DecisionContext)[] | undefined][]) { - if (!entries) continue; - normalizedRemediationGroups[key] = entries.map((entry) => { - if (typeof entry === 'string') return entry; - return entry.plan; - }); - } - } - - const remediationGroups = buildGroups(REMEDIATION_GROUP_ORDER, normalizedRemediationGroups); - const strengthGroups = buildGroups(STRENGTH_GROUP_ORDER, structuredSections?.strength_groups); - const executiveSummary = nonEmpty(structuredSections?.executive_summary); - const coverageNotes = nonEmpty(structuredSections?.coverage_notes); - const partialReviewerCoverageNotes = buildPartialReviewerCoverageNotes(report.reviewers); - const confidenceNote = report.summary?.confidence_note?.trim(); - - return { - executiveSummary: executiveSummary.length > 0 - ? executiveSummary - : nonEmpty([report.summary?.overall_assessment]), - remediationGroups: remediationGroups.length > 0 - ? remediationGroups - : buildLegacyRemediationGroups(report), - strengthGroups: strengthGroups.length > 0 - ? strengthGroups - : buildLegacyStrengthGroups(report), - coverageNotes: coverageNotes.length > 0 - ? nonEmpty([...coverageNotes, ...partialReviewerCoverageNotes]) - : nonEmpty([confidenceNote, ...partialReviewerCoverageNotes]), - issueStats: buildIssueStats(report.issues), - reviewerStats: buildReviewerStats(report.reviewers), - }; -} - -export function getDefaultExpandedCodeReviewSectionIds(report: CodeReviewReportData): ReviewSectionId[] { - const sections = buildCodeReviewReportSections(report); - const expanded: ReviewSectionId[] = ['summary']; - - if (sections.remediationGroups.length > 0) { - expanded.push('remediation'); - } - - return expanded; -} - -function mergeLabels(labels?: Partial): CodeReviewReportMarkdownLabels { - return { - ...DEFAULT_CODE_REVIEW_MARKDOWN_LABELS, - ...labels, - groupTitles: { - ...DEFAULT_CODE_REVIEW_MARKDOWN_LABELS.groupTitles, - ...labels?.groupTitles, - }, - reliabilityNoticeLabels: { - ...DEFAULT_CODE_REVIEW_MARKDOWN_LABELS.reliabilityNoticeLabels, - ...labels?.reliabilityNoticeLabels, - }, - }; -} - -function pushList(lines: string[], items: string[], emptyLabel: string): void { - if (items.length === 0) { - lines.push(`- ${emptyLabel}`); - return; - } - - for (const item of items) { - lines.push(`- ${item}`); - } -} - -function issueLocation(issue: CodeReviewIssue): string { - if (!issue.file) { - return ''; - } - - return issue.line ? `${issue.file}:${issue.line}` : issue.file; -} - -function manifestTarget(manifest: ReviewTeamRunManifest): string { - return manifest.target.tags.length > 0 - ? manifest.target.tags.join(', ') - : manifest.target.source; -} - -function manifestMemberLabel(member: ReviewTeamManifestMember): string { - return member.displayName || member.subagentId; -} - -function manifestMemberLine(member: ReviewTeamManifestMember): string { - return `${manifestMemberLabel(member)} (${member.subagentId})`; -} - -function pluralize(count: number, singular: string): string { - return `${count} ${singular}${count === 1 ? '' : 's'}`; -} - -function pushPreReviewSummarySection( - lines: string[], - manifest: ReviewTeamRunManifest, -): void { - const summary = manifest.preReviewSummary; - if (!summary) { - return; - } - - lines.push(`### Pre-review summary`); - lines.push(`- ${summary.summary}`); - lines.push(`- Files: ${summary.fileCount}`); - if (summary.lineCount !== undefined) { - lines.push(`- Lines changed: ${summary.lineCount} (${summary.lineCountSource})`); - } else { - lines.push(`- Lines changed: unknown (${summary.lineCountSource})`); - } - if (summary.workspaceAreas.length > 0) { - for (const area of summary.workspaceAreas) { - const sampleFiles = area.sampleFiles.length > 0 - ? ` (${area.sampleFiles.join(', ')})` - : ''; - lines.push(`- ${area.key}: ${pluralize(area.fileCount, 'file')}${sampleFiles}`); - } - } - lines.push(''); -} - -function pushSharedContextCacheSection( - lines: string[], - manifest: ReviewTeamRunManifest, -): void { - const cachePlan = manifest.sharedContextCache; - if (!cachePlan) { - return; - } - - lines.push(`### Shared context cache`); - if (cachePlan.entries.length === 0) { - lines.push('- None.'); - } else { - for (const entry of cachePlan.entries) { - lines.push( - `- ${entry.cacheKey}: ${entry.path} -> ${entry.consumerPacketIds.join(', ')}`, - ); - } - } - if (cachePlan.omittedEntryCount > 0) { - lines.push(`- Omitted entries: ${cachePlan.omittedEntryCount}`); - } - lines.push(''); -} - -function pushIncrementalReviewCacheSection( - lines: string[], - manifest: ReviewTeamRunManifest, -): void { - const cachePlan = manifest.incrementalReviewCache; - if (!cachePlan) { - return; - } - - lines.push(`### Incremental review cache`); - lines.push(`- Cache key: ${cachePlan.cacheKey}`); - lines.push(`- Fingerprint: ${cachePlan.fingerprint}`); - lines.push(`- Strategy: ${cachePlan.strategy}`); - lines.push(`- Reviewer packets: ${cachePlan.reviewerPacketIds.join(', ') || 'none'}`); - lines.push(`- Invalidates on: ${cachePlan.invalidatesOn.join(', ') || 'none'}`); - lines.push(''); -} - -function pushRunManifestSection( - lines: string[], - manifest: ReviewTeamRunManifest, - labels: CodeReviewReportMarkdownLabels, -): void { - const activeReviewers = getActiveReviewTeamManifestMembers(manifest); - - lines.push(`## ${labels.runManifest}`); - lines.push(`- ${labels.target}: ${manifestTarget(manifest)}`); - lines.push(`- ${labels.budget}: ${manifest.tokenBudget.mode}`); - lines.push(`- ${labels.estimatedCalls}: ${manifest.tokenBudget.estimatedReviewerCalls}`); - if (manifest.strategyRecommendation) { - lines.push(`- Recommended strategy: ${manifest.strategyRecommendation.strategyLevel}`); - lines.push(`- Recommendation score: ${manifest.strategyRecommendation.score}`); - lines.push(`- Recommendation rationale: ${manifest.strategyRecommendation.rationale}`); - } - lines.push(''); - lines.push(`### ${labels.activeReviewers}`); - pushList( - lines, - activeReviewers.map((member) => manifestMemberLine(member)), - labels.noItems, - ); - lines.push(''); - lines.push(`### ${labels.skippedReviewers}`); - pushList( - lines, - manifest.skippedReviewers.map((member) => - `${manifestMemberLine(member)}: ${member.reason ?? 'skipped'}`, - ), - labels.noItems, - ); - lines.push(''); - pushPreReviewSummarySection(lines, manifest); - pushSharedContextCacheSection(lines, manifest); - pushIncrementalReviewCacheSection(lines, manifest); -} - -export function formatCodeReviewReportMarkdown( - report: CodeReviewReportData, - labels?: Partial, - options?: CodeReviewReportMarkdownOptions, -): string { - const mergedLabels = mergeLabels(labels); - const sections = buildCodeReviewReportSections(report); - const issues = report.issues ?? []; - const reviewers = report.reviewers ?? []; - const lines: string[] = []; - - lines.push(`# ${report.review_mode === 'deep' ? mergedLabels.titleDeep : mergedLabels.titleStandard}`); - lines.push(''); - lines.push(`## ${mergedLabels.executiveSummary}`); - pushList(lines, sections.executiveSummary, mergedLabels.noItems); - lines.push(''); - lines.push(`## ${mergedLabels.reviewDecision}`); - lines.push(`- ${mergedLabels.riskLevel}: ${report.summary?.risk_level ?? 'unknown'}`); - lines.push(`- ${mergedLabels.recommendedAction}: ${report.summary?.recommended_action ?? 'unknown'}`); - if (report.review_scope?.trim()) { - lines.push(`- ${mergedLabels.scope}: ${report.review_scope.trim()}`); - } - lines.push(''); - if (report.review_mode === 'deep' && options?.runManifest) { - pushRunManifestSection(lines, options.runManifest, mergedLabels); - } - const reliabilityNotices = buildCodeReviewReliabilityNotices(report, options?.runManifest); - if (reliabilityNotices.length > 0) { - lines.push(`## ${mergedLabels.reliabilitySignals}`); - reliabilityNotices.forEach((notice) => { - lines.push(reliabilityNoticeMarkdownLine(notice, mergedLabels)); - }); - lines.push(''); - } - lines.push(`## ${mergedLabels.issues}`); - if (issues.length === 0) { - lines.push(`- ${mergedLabels.noIssues}`); - } else { - issues.forEach((issue, index) => { - const location = issueLocation(issue); - const heading = [ - `${index + 1}.`, - `[${issue.severity ?? 'info'}/${issue.certainty ?? 'possible'}]`, - issue.title ?? 'Untitled issue', - location ? `(${location})` : '', - ].filter(Boolean).join(' '); - - lines.push(heading); - if (issue.category) { - lines.push(` - ${issue.category}`); - } - if (issue.source_reviewer) { - lines.push(` - ${mergedLabels.source}: ${issue.source_reviewer}`); - } - if (issue.description) { - lines.push(` - ${issue.description}`); - } - if (issue.suggestion) { - lines.push(` - ${mergedLabels.suggestion}: ${issue.suggestion}`); - } - if (issue.validation_note) { - lines.push(` - ${mergedLabels.validation}: ${issue.validation_note}`); - } - }); - } - lines.push(''); - lines.push(`## ${mergedLabels.remediationPlan}`); - for (const group of sections.remediationGroups) { - lines.push(`### ${mergedLabels.groupTitles[group.id]}`); - pushList(lines, group.items, mergedLabels.noItems); - lines.push(''); - } - if (sections.remediationGroups.length === 0) { - lines.push(`- ${mergedLabels.noItems}`); - lines.push(''); - } - lines.push(`## ${mergedLabels.strengths}`); - for (const group of sections.strengthGroups) { - lines.push(`### ${mergedLabels.groupTitles[group.id]}`); - pushList(lines, group.items, mergedLabels.noItems); - lines.push(''); - } - if (sections.strengthGroups.length === 0) { - lines.push(`- ${mergedLabels.noItems}`); - lines.push(''); - } - lines.push(`## ${mergedLabels.reviewTeam}`); - if (reviewers.length === 0) { - lines.push(`- ${mergedLabels.noItems}`); - } else { - for (const reviewer of reviewers) { - const issueCount = typeof reviewer.issue_count === 'number' - ? `; ${mergedLabels.findings}: ${reviewer.issue_count}` - : ''; - lines.push(`- ${reviewer.name} (${reviewer.specialty}; ${mergedLabels.status}: ${reviewer.status}${issueCount})`); - if (reviewer.summary) { - lines.push(` - ${reviewer.summary}`); - } - const packetId = reviewer.packet_id?.trim(); - if (packetId || reviewer.packet_status_source) { - const packetLabel = packetId || 'missing'; - const sourceLabel = reviewer.packet_status_source - ? ` (${reviewer.packet_status_source})` - : ''; - lines.push(` - ${mergedLabels.packet}: ${packetLabel}${sourceLabel}`); - } - if (reviewer.partial_output?.trim()) { - lines.push(` - ${mergedLabels.partialOutput}: ${reviewer.partial_output.trim()}`); - } - } - } - lines.push(''); - lines.push(`## ${mergedLabels.coverageNotes}`); - pushList(lines, sections.coverageNotes, mergedLabels.noItems); - - return lines.join('\n').trimEnd(); -} diff --git a/src/web-ui/src/flow_chat/deep-review/report/manifestSections.test.ts b/src/web-ui/src/flow_chat/deep-review/report/manifestSections.test.ts new file mode 100644 index 000000000..67af0d80f --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/report/manifestSections.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest'; +import type { + ReviewTeamManifestMember, + ReviewTeamRunManifest, +} from '@/shared/services/reviewTeamService'; +import { DEFAULT_CODE_REVIEW_MARKDOWN_LABELS } from './codeReviewReport'; +import { formatRunManifestMarkdownSection } from './manifestSections'; + +function manifestMember( + subagentId: string, + displayName: string, + reason?: ReviewTeamManifestMember['reason'], +): ReviewTeamManifestMember { + return { + subagentId, + displayName, + roleName: displayName, + model: 'fast', + configuredModel: 'fast', + defaultModelSlot: 'fast', + strategyLevel: 'normal', + strategySource: 'team', + strategyDirective: 'Review the target.', + locked: !subagentId.startsWith('Custom'), + source: subagentId.startsWith('Custom') ? 'extra' : 'core', + subagentSource: subagentId.startsWith('Custom') ? 'user' : 'builtin', + ...(reason ? { reason } : {}), + }; +} + +function buildRunManifest(): ReviewTeamRunManifest { + return { + reviewMode: 'deep', + workspacePath: '/test-fixtures/project-a', + policySource: 'default-review-team-config', + target: { + source: 'session_files', + resolution: 'resolved', + tags: ['frontend'], + files: ['src/App.tsx'], + warnings: [], + }, + strategyLevel: 'normal', + strategyRecommendation: { + strategyLevel: 'deep', + score: 24, + rationale: 'Large/high-risk change.', + factors: { + fileCount: 8, + totalLinesChanged: 900, + lineCountSource: 'diff_stat', + securityFileCount: 2, + workspaceAreaCount: 3, + contractSurfaceChanged: true, + }, + }, + executionPolicy: { + reviewerTimeoutSeconds: 300, + judgeTimeoutSeconds: 240, + reviewerFileSplitThreshold: 20, + maxSameRoleInstances: 3, + maxRetriesPerRole: 1, + }, + concurrencyPolicy: { + maxParallelInstances: 4, + staggerSeconds: 0, + maxQueueWaitSeconds: 60, + batchExtrasSeparately: true, + allowProviderCapacityQueue: true, + allowBoundedAutoRetry: false, + autoRetryElapsedGuardSeconds: 180, + }, + preReviewSummary: { + source: 'target_manifest', + summary: '1 file, 12 changed lines across 1 workspace area: web-ui (1)', + fileCount: 1, + excludedFileCount: 0, + lineCount: 12, + lineCountSource: 'diff_stat', + targetTags: ['frontend'], + workspaceAreas: [ + { + key: 'web-ui', + fileCount: 1, + sampleFiles: ['src/App.tsx'], + }, + ], + warnings: [], + }, + sharedContextCache: { + source: 'work_packets', + strategy: 'reuse_readonly_file_context_by_cache_key', + entries: [ + { + cacheKey: 'shared-context:1', + path: 'src/App.tsx', + workspaceArea: 'web-ui', + recommendedTools: ['GetFileDiff', 'Read'], + consumerPacketIds: [ + 'reviewer:ReviewBusinessLogic', + 'reviewer:CustomSecurity', + ], + }, + ], + omittedEntryCount: 0, + }, + incrementalReviewCache: { + source: 'target_manifest', + strategy: 'reuse_completed_packets_when_fingerprint_matches', + cacheKey: 'incremental-review:abc12345', + fingerprint: 'abc12345', + filePaths: ['src/App.tsx'], + workspaceAreas: ['web-ui'], + targetTags: ['frontend'], + reviewerPacketIds: [ + 'reviewer:ReviewBusinessLogic', + 'reviewer:CustomSecurity', + ], + lineCount: 12, + lineCountSource: 'diff_stat', + invalidatesOn: [ + 'target_file_set_changed', + 'target_line_count_changed', + 'reviewer_roster_changed', + ], + }, + tokenBudget: { + mode: 'balanced', + estimatedReviewerCalls: 3, + maxReviewerCalls: 4, + maxExtraReviewers: 1, + largeDiffSummaryFirst: false, + skippedReviewerIds: ['CustomInvalid'], + warnings: [], + }, + coreReviewers: [ + manifestMember('ReviewBusinessLogic', 'Logic reviewer'), + ], + qualityGateReviewer: manifestMember('ReviewJudge', 'Quality inspector'), + enabledExtraReviewers: [ + manifestMember('CustomSecurity', 'Custom security reviewer'), + ], + skippedReviewers: [ + manifestMember('ReviewFrontend', 'Frontend reviewer', 'not_applicable'), + manifestMember('CustomInvalid', 'Custom invalid reviewer', 'invalid_tooling'), + ], + }; +} + +describe('manifestSections', () => { + it('formats Deep Review manifest markdown without content payload fields', () => { + const markdown = formatRunManifestMarkdownSection( + buildRunManifest(), + DEFAULT_CODE_REVIEW_MARKDOWN_LABELS, + ); + + expect(markdown).toContain('## Run manifest'); + expect(markdown).toContain('- Target: frontend'); + expect(markdown).toContain('- Logic reviewer (ReviewBusinessLogic)'); + expect(markdown).toContain('- Quality inspector (ReviewJudge)'); + expect(markdown).toContain('- Custom invalid reviewer (CustomInvalid): invalid_tooling'); + expect(markdown).toContain('### Shared context cache'); + expect(markdown).toContain( + '- shared-context:1: src/App.tsx -> reviewer:ReviewBusinessLogic, reviewer:CustomSecurity', + ); + expect(markdown).not.toContain('source_text'); + expect(markdown).not.toContain('full_diff'); + expect(markdown).not.toContain('model_output'); + expect(markdown).not.toContain('provider_raw_body'); + expect(markdown).not.toContain('full_file_contents'); + }); +}); diff --git a/src/web-ui/src/flow_chat/deep-review/report/manifestSections.ts b/src/web-ui/src/flow_chat/deep-review/report/manifestSections.ts new file mode 100644 index 000000000..fc95ff538 --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/report/manifestSections.ts @@ -0,0 +1,172 @@ +import { + getActiveReviewTeamManifestMembers, + type ReviewTeamManifestMember, + type ReviewTeamRunManifest, +} from '@/shared/services/reviewTeamService'; +import type { CodeReviewReportMarkdownLabels } from './codeReviewReport'; + +function pushList(lines: string[], items: string[], emptyLabel: string): void { + if (items.length === 0) { + lines.push(`- ${emptyLabel}`); + return; + } + + for (const item of items) { + lines.push(`- ${item}`); + } +} + +function manifestTarget(manifest: ReviewTeamRunManifest): string { + return manifest.target.tags.length > 0 + ? manifest.target.tags.join(', ') + : manifest.target.source; +} + +function manifestMemberLabel(member: ReviewTeamManifestMember): string { + return member.displayName || member.subagentId; +} + +function manifestMemberLine(member: ReviewTeamManifestMember): string { + return `${manifestMemberLabel(member)} (${member.subagentId})`; +} + +function pluralize(count: number, singular: string): string { + return `${count} ${singular}${count === 1 ? '' : 's'}`; +} + +function pushPreReviewSummarySection( + lines: string[], + manifest: ReviewTeamRunManifest, +): void { + const summary = manifest.preReviewSummary; + if (!summary) { + return; + } + + lines.push(`### Pre-review summary`); + lines.push(`- ${summary.summary}`); + lines.push(`- Files: ${summary.fileCount}`); + if (summary.lineCount !== undefined) { + lines.push(`- Lines changed: ${summary.lineCount} (${summary.lineCountSource})`); + } else { + lines.push(`- Lines changed: unknown (${summary.lineCountSource})`); + } + if (summary.workspaceAreas.length > 0) { + for (const area of summary.workspaceAreas) { + const sampleFiles = area.sampleFiles.length > 0 + ? ` (${area.sampleFiles.join(', ')})` + : ''; + lines.push(`- ${area.key}: ${pluralize(area.fileCount, 'file')}${sampleFiles}`); + } + } + lines.push(''); +} + +function pushEvidencePackSection( + lines: string[], + manifest: ReviewTeamRunManifest, +): void { + const pack = manifest.evidencePack; + if (!pack) { + return; + } + + lines.push(`### Evidence pack`); + lines.push(`- Source: ${pack.source}; privacy: ${pack.privacy.content}`); + lines.push( + `- Changed files: ${pack.changedFiles.length}; hunk hints: ${pack.hunkHints.length}; contract hints: ${pack.contractHints.length}; packet ids: ${pack.packetIds.length}`, + ); + lines.push( + `- Omitted metadata: changed files ${pack.budget.omittedChangedFileCount}, hunk hints ${pack.budget.omittedHunkHintCount}, contract hints ${pack.budget.omittedContractHintCount}`, + ); + lines.push('- Hints are orientation only and require tool confirmation before findings.'); + lines.push(''); +} + +function pushSharedContextCacheSection( + lines: string[], + manifest: ReviewTeamRunManifest, +): void { + const cachePlan = manifest.sharedContextCache; + if (!cachePlan) { + return; + } + + lines.push(`### Shared context cache`); + if (cachePlan.entries.length === 0) { + lines.push('- None.'); + } else { + for (const entry of cachePlan.entries) { + lines.push( + `- ${entry.cacheKey}: ${entry.path} -> ${entry.consumerPacketIds.join(', ')}`, + ); + } + } + if (cachePlan.omittedEntryCount > 0) { + lines.push(`- Omitted entries: ${cachePlan.omittedEntryCount}`); + } + lines.push(''); +} + +function pushIncrementalReviewCacheSection( + lines: string[], + manifest: ReviewTeamRunManifest, +): void { + const cachePlan = manifest.incrementalReviewCache; + if (!cachePlan) { + return; + } + + lines.push(`### Incremental review cache`); + lines.push(`- Cache key: ${cachePlan.cacheKey}`); + lines.push(`- Fingerprint: ${cachePlan.fingerprint}`); + lines.push(`- Strategy: ${cachePlan.strategy}`); + lines.push(`- Reviewer packets: ${cachePlan.reviewerPacketIds.join(', ') || 'none'}`); + lines.push(`- Invalidates on: ${cachePlan.invalidatesOn.join(', ') || 'none'}`); + lines.push(''); +} + +export function formatRunManifestMarkdownSection( + manifest: ReviewTeamRunManifest, + labels: CodeReviewReportMarkdownLabels, +): string { + const lines: string[] = []; + const activeReviewers = getActiveReviewTeamManifestMembers(manifest); + + lines.push(`## ${labels.runManifest}`); + lines.push(`- ${labels.target}: ${manifestTarget(manifest)}`); + lines.push(`- ${labels.budget}: ${manifest.tokenBudget.mode}`); + lines.push(`- ${labels.estimatedCalls}: ${manifest.tokenBudget.estimatedReviewerCalls}`); + if (manifest.scopeProfile) { + lines.push(`- Review depth: ${manifest.scopeProfile.reviewDepth}`); + lines.push(`- Coverage expectation: ${manifest.scopeProfile.coverageExpectation}`); + } + if (manifest.strategyRecommendation) { + lines.push(`- Recommended strategy: ${manifest.strategyRecommendation.strategyLevel}`); + lines.push(`- Recommendation score: ${manifest.strategyRecommendation.score}`); + lines.push(`- Recommendation rationale: ${manifest.strategyRecommendation.rationale}`); + } + lines.push(''); + lines.push(`### ${labels.activeReviewers}`); + pushList( + lines, + activeReviewers.map((member) => manifestMemberLine(member)), + labels.noItems, + ); + lines.push(''); + lines.push(`### ${labels.skippedReviewers}`); + pushList( + lines, + manifest.skippedReviewers.map((member) => + `${manifestMemberLine(member)}: ${member.reason ?? 'skipped'}`, + ), + labels.noItems, + ); + lines.push(''); + pushPreReviewSummarySection(lines, manifest); + pushEvidencePackSection(lines, manifest); + pushSharedContextCacheSection(lines, manifest); + pushIncrementalReviewCacheSection(lines, manifest); + + return lines.join('\n').trimEnd(); +} diff --git a/src/web-ui/src/flow_chat/deep-review/report/markdown.test.ts b/src/web-ui/src/flow_chat/deep-review/report/markdown.test.ts new file mode 100644 index 000000000..820e4493c --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/report/markdown.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { formatCodeReviewReportMarkdown } from './markdown'; + +describe('markdown', () => { + it('formats standard reports without Deep Review manifest sections', () => { + const markdown = formatCodeReviewReportMarkdown({ + review_mode: 'standard', + summary: { + overall_assessment: 'Looks good.', + risk_level: 'low', + recommended_action: 'approve', + }, + issues: [], + }); + + expect(markdown).toContain('# Code Review Report'); + expect(markdown).toContain('## Executive Summary'); + expect(markdown).toContain('- Looks good.'); + expect(markdown).not.toContain('## Run manifest'); + }); +}); diff --git a/src/web-ui/src/flow_chat/deep-review/report/markdown.ts b/src/web-ui/src/flow_chat/deep-review/report/markdown.ts new file mode 100644 index 000000000..42031e121 --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/report/markdown.ts @@ -0,0 +1,213 @@ +import type { + CodeReviewIssue, + CodeReviewReportData, + CodeReviewReportMarkdownLabels, + CodeReviewReportMarkdownOptions, + RemediationGroupId, + StrengthGroupId, +} from './codeReviewReport'; +import { formatRunManifestMarkdownSection } from './manifestSections'; +import { + buildCodeReviewReliabilityNotices, + RELIABILITY_NOTICE_FALLBACK_LABELS, + reliabilityNoticeMarkdownLine, +} from './reliabilityNotices'; +import { buildCodeReviewReportSections } from './reportSections'; + +export const DEFAULT_CODE_REVIEW_MARKDOWN_LABELS: CodeReviewReportMarkdownLabels = { + titleStandard: 'Code Review Report', + titleDeep: 'Deep Review Report', + executiveSummary: 'Executive Summary', + reviewDecision: 'Review Decision', + runManifest: 'Run manifest', + riskLevel: 'Risk Level', + recommendedAction: 'Recommended Action', + scope: 'Scope', + target: 'Target', + budget: 'Budget', + estimatedCalls: 'Estimated calls', + activeReviewers: 'Active reviewers', + skippedReviewers: 'Skipped reviewers', + issues: 'Issues', + noIssues: 'No validated issues.', + remediationPlan: 'Remediation Plan', + strengths: 'Strengths', + reviewTeam: 'Code Review Team', + reliabilitySignals: 'Review Reliability', + coverageNotes: 'Coverage Notes', + status: 'Status', + packet: 'Packet', + partialOutput: 'Partial output', + findings: 'Findings', + validation: 'Validation', + suggestion: 'Suggestion', + source: 'Source', + noItems: 'None.', + reliabilityNoticeLabels: RELIABILITY_NOTICE_FALLBACK_LABELS, + groupTitles: { + must_fix: 'Must Fix', + should_improve: 'Should Improve', + needs_decision: 'Needs Decision', + verification: 'Verification', + architecture: 'Architecture', + maintainability: 'Maintainability', + tests: 'Tests', + security: 'Security', + performance: 'Performance', + user_experience: 'User Experience', + other: 'Other', + }, +}; + +function mergeLabels(labels?: Partial): CodeReviewReportMarkdownLabels { + return { + ...DEFAULT_CODE_REVIEW_MARKDOWN_LABELS, + ...labels, + groupTitles: { + ...DEFAULT_CODE_REVIEW_MARKDOWN_LABELS.groupTitles, + ...labels?.groupTitles, + }, + reliabilityNoticeLabels: { + ...DEFAULT_CODE_REVIEW_MARKDOWN_LABELS.reliabilityNoticeLabels, + ...labels?.reliabilityNoticeLabels, + }, + }; +} + +function pushList(lines: string[], items: string[], emptyLabel: string): void { + if (items.length === 0) { + lines.push(`- ${emptyLabel}`); + return; + } + + for (const item of items) { + lines.push(`- ${item}`); + } +} + +function issueLocation(issue: CodeReviewIssue): string { + if (!issue.file) { + return ''; + } + + return issue.line ? `${issue.file}:${issue.line}` : issue.file; +} + +export function formatCodeReviewReportMarkdown( + report: CodeReviewReportData, + labels?: Partial, + options?: CodeReviewReportMarkdownOptions, +): string { + const mergedLabels = mergeLabels(labels); + const sections = buildCodeReviewReportSections(report); + const issues = report.issues ?? []; + const reviewers = report.reviewers ?? []; + const lines: string[] = []; + + lines.push(`# ${report.review_mode === 'deep' ? mergedLabels.titleDeep : mergedLabels.titleStandard}`); + lines.push(''); + lines.push(`## ${mergedLabels.executiveSummary}`); + pushList(lines, sections.executiveSummary, mergedLabels.noItems); + lines.push(''); + lines.push(`## ${mergedLabels.reviewDecision}`); + lines.push(`- ${mergedLabels.riskLevel}: ${report.summary?.risk_level ?? 'unknown'}`); + lines.push(`- ${mergedLabels.recommendedAction}: ${report.summary?.recommended_action ?? 'unknown'}`); + if (report.review_scope?.trim()) { + lines.push(`- ${mergedLabels.scope}: ${report.review_scope.trim()}`); + } + lines.push(''); + if (report.review_mode === 'deep' && options?.runManifest) { + lines.push(formatRunManifestMarkdownSection(options.runManifest, mergedLabels)); + lines.push(''); + } + const reliabilityNotices = buildCodeReviewReliabilityNotices(report, options?.runManifest); + if (reliabilityNotices.length > 0) { + lines.push(`## ${mergedLabels.reliabilitySignals}`); + reliabilityNotices.forEach((notice) => { + lines.push(reliabilityNoticeMarkdownLine(notice, mergedLabels)); + }); + lines.push(''); + } + lines.push(`## ${mergedLabels.issues}`); + if (issues.length === 0) { + lines.push(`- ${mergedLabels.noIssues}`); + } else { + issues.forEach((issue, index) => { + const location = issueLocation(issue); + const heading = [ + `${index + 1}.`, + `[${issue.severity ?? 'info'}/${issue.certainty ?? 'possible'}]`, + issue.title ?? 'Untitled issue', + location ? `(${location})` : '', + ].filter(Boolean).join(' '); + + lines.push(heading); + if (issue.category) { + lines.push(` - ${issue.category}`); + } + if (issue.source_reviewer) { + lines.push(` - ${mergedLabels.source}: ${issue.source_reviewer}`); + } + if (issue.description) { + lines.push(` - ${issue.description}`); + } + if (issue.suggestion) { + lines.push(` - ${mergedLabels.suggestion}: ${issue.suggestion}`); + } + if (issue.validation_note) { + lines.push(` - ${mergedLabels.validation}: ${issue.validation_note}`); + } + }); + } + lines.push(''); + lines.push(`## ${mergedLabels.remediationPlan}`); + for (const group of sections.remediationGroups) { + lines.push(`### ${mergedLabels.groupTitles[group.id as RemediationGroupId]}`); + pushList(lines, group.items, mergedLabels.noItems); + lines.push(''); + } + if (sections.remediationGroups.length === 0) { + lines.push(`- ${mergedLabels.noItems}`); + lines.push(''); + } + lines.push(`## ${mergedLabels.strengths}`); + for (const group of sections.strengthGroups) { + lines.push(`### ${mergedLabels.groupTitles[group.id as StrengthGroupId]}`); + pushList(lines, group.items, mergedLabels.noItems); + lines.push(''); + } + if (sections.strengthGroups.length === 0) { + lines.push(`- ${mergedLabels.noItems}`); + lines.push(''); + } + lines.push(`## ${mergedLabels.reviewTeam}`); + if (reviewers.length === 0) { + lines.push(`- ${mergedLabels.noItems}`); + } else { + for (const reviewer of reviewers) { + const issueCount = typeof reviewer.issue_count === 'number' + ? `; ${mergedLabels.findings}: ${reviewer.issue_count}` + : ''; + lines.push(`- ${reviewer.name} (${reviewer.specialty}; ${mergedLabels.status}: ${reviewer.status}${issueCount})`); + if (reviewer.summary) { + lines.push(` - ${reviewer.summary}`); + } + const packetId = reviewer.packet_id?.trim(); + if (packetId || reviewer.packet_status_source) { + const packetLabel = packetId || 'missing'; + const sourceLabel = reviewer.packet_status_source + ? ` (${reviewer.packet_status_source})` + : ''; + lines.push(` - ${mergedLabels.packet}: ${packetLabel}${sourceLabel}`); + } + if (reviewer.partial_output?.trim()) { + lines.push(` - ${mergedLabels.partialOutput}: ${reviewer.partial_output.trim()}`); + } + } + } + lines.push(''); + lines.push(`## ${mergedLabels.coverageNotes}`); + pushList(lines, sections.coverageNotes, mergedLabels.noItems); + + return lines.join('\n').trimEnd(); +} diff --git a/src/web-ui/src/flow_chat/deep-review/report/reliabilityNotices.test.ts b/src/web-ui/src/flow_chat/deep-review/report/reliabilityNotices.test.ts new file mode 100644 index 000000000..22a081857 --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/report/reliabilityNotices.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { + buildCodeReviewReliabilityNotices, + reliabilityNoticeMarkdownLine, +} from './reliabilityNotices'; +import { DEFAULT_CODE_REVIEW_MARKDOWN_LABELS } from './codeReviewReport'; + +describe('reliabilityNotices', () => { + it('normalizes structured notices and keeps markdown label fallback stable', () => { + const notices = buildCodeReviewReliabilityNotices({ + review_mode: 'deep', + reliability_signals: [ + { + kind: 'retry_guidance', + severity: 'warning', + source: 'runtime', + detail: 'Retry one reduced reviewer packet.', + }, + ], + }); + + expect(notices).toEqual([ + { + kind: 'retry_guidance', + severity: 'warning', + source: 'runtime', + detail: 'Retry one reduced reviewer packet.', + }, + ]); + expect(reliabilityNoticeMarkdownLine( + notices[0], + DEFAULT_CODE_REVIEW_MARKDOWN_LABELS, + )).toBe( + '- Retry guidance emitted [warning/runtime]: Retry one reduced reviewer packet.', + ); + }); +}); diff --git a/src/web-ui/src/flow_chat/deep-review/report/reliabilityNotices.ts b/src/web-ui/src/flow_chat/deep-review/report/reliabilityNotices.ts new file mode 100644 index 000000000..b611dfdba --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/report/reliabilityNotices.ts @@ -0,0 +1,332 @@ +import type { ReviewTeamRunManifest } from '@/shared/services/reviewTeamService'; +import type { + CodeReviewReliabilitySignal, + CodeReviewReportData, + CodeReviewReportMarkdownLabels, + CodeReviewReviewer, + ReviewReliabilityNotice, + ReviewReliabilityNoticeKind, + ReviewReliabilityNoticeSeverity, + ReviewReliabilitySignalSource, +} from './codeReviewReport'; + +export const PARTIAL_TIMEOUT_REVIEWER_STATUSES = new Set([ + 'partial_timeout', + 'timed_out', + 'cancelled_by_user', +]); + +const RELIABILITY_NOTICE_ORDER: ReviewReliabilityNoticeKind[] = [ + 'context_pressure', + 'reduced_scope', + 'skipped_reviewers', + 'token_budget_limited', + 'compression_preserved', + 'cache_hit', + 'cache_miss', + 'concurrency_limited', + 'partial_reviewer', + 'retry_guidance', + 'user_decision', +]; + +export const RELIABILITY_NOTICE_FALLBACK_LABELS: Record = { + context_pressure: 'Context pressure rising', + compression_preserved: 'Compression preserved key facts', + cache_hit: 'Incremental cache reused reviewer output', + cache_miss: 'Incremental cache missed or refreshed', + concurrency_limited: 'Reviewer launch was concurrency-limited', + partial_reviewer: 'Reviewer timed out with partial result', + reduced_scope: 'Reduced-depth coverage', + retry_guidance: 'Retry guidance emitted', + skipped_reviewers: 'Skipped reviewers', + token_budget_limited: 'Token budget limited reviewer coverage', + user_decision: 'User decision needed', +}; + +const RELIABILITY_NOTICE_SEVERITY_BY_KIND: Record< + ReviewReliabilityNoticeKind, + ReviewReliabilityNoticeSeverity +> = { + context_pressure: 'info', + compression_preserved: 'info', + cache_hit: 'info', + cache_miss: 'info', + concurrency_limited: 'warning', + partial_reviewer: 'warning', + reduced_scope: 'info', + retry_guidance: 'warning', + skipped_reviewers: 'info', + token_budget_limited: 'warning', + user_decision: 'action', +}; + +function nonEmptyStrings(values?: Array): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const value of values ?? []) { + const trimmed = value?.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + result.push(trimmed); + } + + return result; +} + +function hasCompressionPreservationNote(report: CodeReviewReportData): boolean { + const notes = [ + ...(report.report_sections?.coverage_notes ?? []), + report.summary?.confidence_note, + ]; + + return notes.some((note) => { + const normalized = note?.toLowerCase() ?? ''; + return normalized.includes('compress') && normalized.includes('preserv'); + }); +} + +function countPartialReviewers(reviewers: CodeReviewReviewer[] = []): number { + return reviewers.filter((reviewer) => + reviewer.status === 'partial_timeout' || + ( + PARTIAL_TIMEOUT_REVIEWER_STATUSES.has(reviewer.status) && + Boolean(reviewer.partial_output?.trim()) + ) + ).length; +} + +function countSkippedReviewers(runManifest?: ReviewTeamRunManifest): number { + return runManifest?.skippedReviewers.length ?? 0; +} + +function countTokenBudgetLimitedReviewers(runManifest?: ReviewTeamRunManifest): number { + if (!runManifest) { + return 0; + } + const skippedByBudget = new Set(runManifest.tokenBudget.skippedReviewerIds); + for (const reviewer of runManifest.skippedReviewers) { + if (reviewer.reason === 'budget_limited') { + skippedByBudget.add(reviewer.subagentId); + } + } + return skippedByBudget.size; +} + +function isReducedScopeProfile(runManifest?: ReviewTeamRunManifest): boolean { + const reviewDepth = runManifest?.scopeProfile?.reviewDepth; + return reviewDepth === 'high_risk_only' || reviewDepth === 'risk_expanded'; +} + +function countDecisionItems(report: CodeReviewReportData): number { + const structuredDecisionItems = report.report_sections?.remediation_groups?.needs_decision ?? []; + if (structuredDecisionItems.length > 0) { + const stringItems = structuredDecisionItems.filter( + (item): item is string => typeof item === 'string', + ); + return nonEmptyStrings(stringItems).length; + } + + return report.summary?.recommended_action === 'block' ? 1 : 0; +} + +function isReliabilityNoticeKind(value: string): value is ReviewReliabilityNoticeKind { + return RELIABILITY_NOTICE_ORDER.includes(value as ReviewReliabilityNoticeKind); +} + +function isReliabilitySeverity(value: string): value is ReviewReliabilityNoticeSeverity { + return value === 'info' || value === 'warning' || value === 'action'; +} + +function isReliabilitySignalSource(value: string): value is ReviewReliabilitySignalSource { + return value === 'runtime' || value === 'manifest' || value === 'report' || value === 'inferred'; +} + +function normalizeStructuredReliabilityNotice( + signal: CodeReviewReliabilitySignal, +): ReviewReliabilityNotice | null { + if (!isReliabilityNoticeKind(signal.kind)) { + return null; + } + + const detail = signal.detail?.trim(); + return { + kind: signal.kind, + severity: signal.severity && isReliabilitySeverity(signal.severity) + ? signal.severity + : RELIABILITY_NOTICE_SEVERITY_BY_KIND[signal.kind], + ...(typeof signal.count === 'number' ? { count: signal.count } : {}), + ...(signal.source && isReliabilitySignalSource(signal.source) + ? { source: signal.source } + : {}), + ...(detail ? { detail } : {}), + }; +} + +function structuredReliabilityNoticeMap( + report: CodeReviewReportData, +): Map { + const notices = new Map(); + for (const signal of report.reliability_signals ?? []) { + const notice = normalizeStructuredReliabilityNotice(signal); + if (notice && !notices.has(notice.kind)) { + notices.set(notice.kind, notice); + } + } + return notices; +} + +function reliabilityNoticeLabel( + kind: ReviewReliabilityNoticeKind, + labels: CodeReviewReportMarkdownLabels, +): string { + return labels.reliabilityNoticeLabels[kind] ?? RELIABILITY_NOTICE_FALLBACK_LABELS[kind]; +} + +function reliabilityNoticeMarkdownDetail(notice: ReviewReliabilityNotice): string { + if (notice.detail?.trim()) { + return notice.detail.trim(); + } + if (typeof notice.count === 'number') { + return `Count: ${notice.count}`; + } + return ''; +} + +export function reliabilityNoticeMarkdownLine( + notice: ReviewReliabilityNotice, + labels: CodeReviewReportMarkdownLabels, +): string { + const tags = [notice.severity, notice.source].filter(Boolean).join('/'); + const detail = reliabilityNoticeMarkdownDetail(notice); + const tagText = tags ? ` [${tags}]` : ''; + return detail + ? `- ${reliabilityNoticeLabel(notice.kind, labels)}${tagText}: ${detail}` + : `- ${reliabilityNoticeLabel(notice.kind, labels)}${tagText}`; +} + +export function buildCodeReviewReliabilityNotices( + report: CodeReviewReportData, + runManifest?: ReviewTeamRunManifest, +): ReviewReliabilityNotice[] { + const notices: ReviewReliabilityNotice[] = []; + const structuredNotices = structuredReliabilityNoticeMap(report); + const hasContextPressure = runManifest + ? runManifest.tokenBudget.largeDiffSummaryFirst || runManifest.tokenBudget.warnings.length > 0 + : false; + + const structuredContextPressure = structuredNotices.get('context_pressure'); + if (structuredContextPressure) { + notices.push(structuredContextPressure); + } else if (hasContextPressure && runManifest) { + notices.push({ + kind: 'context_pressure', + severity: 'info', + count: runManifest.tokenBudget.estimatedReviewerCalls, + source: 'manifest', + }); + } + + const structuredReducedScope = structuredNotices.get('reduced_scope'); + if (structuredReducedScope) { + notices.push(structuredReducedScope); + } else if (isReducedScopeProfile(runManifest)) { + notices.push({ + kind: 'reduced_scope', + severity: 'info', + source: 'manifest', + ...(runManifest?.scopeProfile?.coverageExpectation + ? { detail: runManifest.scopeProfile.coverageExpectation } + : {}), + }); + } + + const structuredCompressionPreserved = structuredNotices.get('compression_preserved'); + if (structuredCompressionPreserved) { + notices.push(structuredCompressionPreserved); + } else if (hasCompressionPreservationNote(report)) { + notices.push({ + kind: 'compression_preserved', + severity: 'info', + source: 'inferred', + }); + } + + for (const kind of ['cache_hit', 'cache_miss', 'concurrency_limited'] as const) { + const structuredNotice = structuredNotices.get(kind); + if (structuredNotice) { + notices.push(structuredNotice); + } + } + + const partialReviewerCount = countPartialReviewers(report.reviewers); + const structuredPartialReviewer = structuredNotices.get('partial_reviewer'); + if (structuredPartialReviewer) { + notices.push(structuredPartialReviewer); + } else if (partialReviewerCount > 0) { + notices.push({ + kind: 'partial_reviewer', + severity: 'warning', + count: partialReviewerCount, + source: 'runtime', + }); + } + + const structuredRetryGuidance = structuredNotices.get('retry_guidance'); + if (structuredRetryGuidance) { + notices.push(structuredRetryGuidance); + } else if (partialReviewerCount > 0) { + notices.push({ + kind: 'retry_guidance', + severity: 'warning', + count: partialReviewerCount, + source: 'runtime', + }); + } + + const skippedReviewerCount = countSkippedReviewers(runManifest); + const structuredSkippedReviewers = structuredNotices.get('skipped_reviewers'); + if (structuredSkippedReviewers) { + notices.push(structuredSkippedReviewers); + } else if (skippedReviewerCount > 0) { + notices.push({ + kind: 'skipped_reviewers', + severity: 'info', + count: skippedReviewerCount, + source: 'manifest', + }); + } + + const tokenBudgetLimitedReviewerCount = countTokenBudgetLimitedReviewers(runManifest); + const structuredTokenBudgetLimited = structuredNotices.get('token_budget_limited'); + if (structuredTokenBudgetLimited) { + notices.push(structuredTokenBudgetLimited); + } else if (tokenBudgetLimitedReviewerCount > 0) { + notices.push({ + kind: 'token_budget_limited', + severity: 'warning', + count: tokenBudgetLimitedReviewerCount, + source: 'manifest', + }); + } + + const decisionItemCount = countDecisionItems(report); + const structuredUserDecision = structuredNotices.get('user_decision'); + if (structuredUserDecision) { + notices.push(structuredUserDecision); + } else if (decisionItemCount > 0) { + notices.push({ + kind: 'user_decision', + severity: 'action', + count: decisionItemCount, + source: 'report', + }); + } + + return RELIABILITY_NOTICE_ORDER + .map((kind) => notices.find((notice) => notice.kind === kind)) + .filter((notice): notice is ReviewReliabilityNotice => Boolean(notice)); +} diff --git a/src/web-ui/src/flow_chat/deep-review/report/reportSections.ts b/src/web-ui/src/flow_chat/deep-review/report/reportSections.ts new file mode 100644 index 000000000..2daf5f9cf --- /dev/null +++ b/src/web-ui/src/flow_chat/deep-review/report/reportSections.ts @@ -0,0 +1,196 @@ +import { PARTIAL_TIMEOUT_REVIEWER_STATUSES } from './reliabilityNotices'; +import type { + CodeReviewIssue, + CodeReviewReportData, + CodeReviewReviewer, + DecisionContext, + RemediationGroupId, + ReviewIssueStats, + ReviewReportGroup, + ReviewReportSections, + ReviewReviewerStats, + ReviewSectionId, + StrengthGroupId, +} from './codeReviewReport'; + +const REMEDIATION_GROUP_ORDER: RemediationGroupId[] = [ + 'must_fix', + 'should_improve', + 'needs_decision', + 'verification', +]; + +const STRENGTH_GROUP_ORDER: StrengthGroupId[] = [ + 'architecture', + 'maintainability', + 'tests', + 'security', + 'performance', + 'user_experience', + 'other', +]; + +const DEGRADED_REVIEWER_STATUSES = new Set([ + 'timed_out', + 'cancelled_by_user', + 'failed', + 'skipped', +]); + +function nonEmpty(values?: Array): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const value of values ?? []) { + const trimmed = value?.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + result.push(trimmed); + } + + return result; +} + +function buildGroups( + order: TId[], + data?: Partial>, +): Array> { + return order + .map((id) => ({ id, items: nonEmpty(data?.[id]) })) + .filter((group) => group.items.length > 0); +} + +function buildLegacyRemediationGroups( + report: CodeReviewReportData, +): Array> { + const items = nonEmpty(report.remediation_plan); + if (items.length === 0) { + return []; + } + + const recommendedAction = report.summary?.recommended_action; + const id: RemediationGroupId = + recommendedAction === 'request_changes' || recommendedAction === 'block' + ? 'must_fix' + : 'should_improve'; + + return [{ id, items }]; +} + +function buildLegacyStrengthGroups( + report: CodeReviewReportData, +): Array> { + const items = nonEmpty(report.positive_points).filter((item) => item.toLowerCase() !== 'none'); + return items.length > 0 ? [{ id: 'other', items }] : []; +} + +function buildIssueStats(issues: CodeReviewIssue[] = []): ReviewIssueStats { + const stats: ReviewIssueStats = { + total: 0, + critical: 0, + high: 0, + medium: 0, + low: 0, + info: 0, + }; + + for (const issue of issues) { + const severity = issue.severity ?? 'info'; + stats[severity] += 1; + stats.total += 1; + } + + return stats; +} + +function buildReviewerStats(reviewers: CodeReviewReviewer[] = []): ReviewReviewerStats { + let completed = 0; + let degraded = 0; + + for (const reviewer of reviewers) { + if (reviewer.status === 'completed') { + completed += 1; + } else if ( + DEGRADED_REVIEWER_STATUSES.has(reviewer.status) || + reviewer.status === 'partial_timeout' + ) { + degraded += 1; + } + } + + return { + total: reviewers.length, + completed, + degraded, + }; +} + +function buildPartialReviewerCoverageNotes(reviewers: CodeReviewReviewer[] = []): string[] { + return reviewers + .map((reviewer) => { + const partialOutput = reviewer.partial_output?.trim(); + if (!partialOutput || !PARTIAL_TIMEOUT_REVIEWER_STATUSES.has(reviewer.status)) { + return null; + } + return `${reviewer.name} timed out after producing partial output: ${partialOutput}`; + }) + .filter((note): note is string => Boolean(note)); +} + +export function buildCodeReviewReportSections(report: CodeReviewReportData): ReviewReportSections { + const structuredSections = report.report_sections; + + const rawRemediationGroups = structuredSections?.remediation_groups; + const normalizedRemediationGroups: Partial> = {}; + if (rawRemediationGroups) { + for (const [key, entries] of Object.entries(rawRemediationGroups) as [ + RemediationGroupId, + (string | DecisionContext)[] | undefined, + ][]) { + if (!entries) continue; + normalizedRemediationGroups[key] = entries.map((entry) => { + if (typeof entry === 'string') return entry; + return entry.plan; + }); + } + } + + const remediationGroups = buildGroups(REMEDIATION_GROUP_ORDER, normalizedRemediationGroups); + const strengthGroups = buildGroups(STRENGTH_GROUP_ORDER, structuredSections?.strength_groups); + const executiveSummary = nonEmpty(structuredSections?.executive_summary); + const coverageNotes = nonEmpty(structuredSections?.coverage_notes); + const partialReviewerCoverageNotes = buildPartialReviewerCoverageNotes(report.reviewers); + const confidenceNote = report.summary?.confidence_note?.trim(); + + return { + executiveSummary: executiveSummary.length > 0 + ? executiveSummary + : nonEmpty([report.summary?.overall_assessment]), + remediationGroups: remediationGroups.length > 0 + ? remediationGroups + : buildLegacyRemediationGroups(report), + strengthGroups: strengthGroups.length > 0 + ? strengthGroups + : buildLegacyStrengthGroups(report), + coverageNotes: coverageNotes.length > 0 + ? nonEmpty([...coverageNotes, ...partialReviewerCoverageNotes]) + : nonEmpty([confidenceNote, ...partialReviewerCoverageNotes]), + issueStats: buildIssueStats(report.issues), + reviewerStats: buildReviewerStats(report.reviewers), + }; +} + +export function getDefaultExpandedCodeReviewSectionIds( + report: CodeReviewReportData, +): ReviewSectionId[] { + const sections = buildCodeReviewReportSections(report); + const expanded: ReviewSectionId[] = ['summary']; + + if (sections.remediationGroups.length > 0) { + expanded.push('remediation'); + } + + return expanded; +} diff --git a/src/web-ui/src/flow_chat/tool-cards/CodeReviewReportExportActions.tsx b/src/web-ui/src/flow_chat/tool-cards/CodeReviewReportExportActions.tsx index 0e0173c76..b0a03c69b 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CodeReviewReportExportActions.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CodeReviewReportExportActions.tsx @@ -107,6 +107,9 @@ export const CodeReviewReportExportActions: React.FC { expect(container.textContent).toContain('Reviewer timed out with partial result'); expect(container.textContent).toContain('1 reviewer result is partial; confidence is reduced.'); }); + + it('renders reduced-depth reliability status from structured report signals', () => { + const toolItem: FlowToolItem = { + id: 'tool-1', + type: 'tool', + timestamp: Date.now(), + toolName: 'submit_code_review', + status: 'completed', + toolCall: { + id: 'call-1', + input: {}, + }, + toolResult: { + success: true, + result: { + review_mode: 'deep', + summary: { + overall_assessment: 'High-risk pass completed.', + risk_level: 'low', + recommended_action: 'approve', + }, + issues: [], + reviewers: [], + reliability_signals: [ + { + kind: 'reduced_scope', + severity: 'info', + source: 'manifest', + detail: 'High-risk-only pass; changed files remain visible.', + }, + ], + }, + }, + }; + const config: ToolCardConfig = { + toolName: 'submit_code_review', + displayName: 'Code Review', + icon: 'REVIEW', + requiresConfirmation: false, + resultDisplayType: 'detailed', + }; + + act(() => { + root.render( + , + ); + }); + + act(() => { + container.querySelector('.preview-toggle-btn')?.dispatchEvent( + new window.Event('click', { bubbles: true }), + ); + }); + + expect(container.textContent).toContain('Reduced-depth coverage'); + expect(container.textContent).toContain('High-risk-only pass; changed files remain visible.'); + }); }); diff --git a/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx index ebf88866d..85cfe1fc8 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx @@ -145,6 +145,7 @@ function getReliabilityNoticeLabel(notice: ReviewReliabilityNotice, t: Translate cache_miss: 'Incremental cache missed or refreshed', concurrency_limited: 'Reviewer launch was concurrency-limited', partial_reviewer: 'Reviewer timed out with partial result', + reduced_scope: 'Reduced-depth coverage', retry_guidance: 'Retry guidance emitted', skipped_reviewers: 'Skipped reviewers', token_budget_limited: 'Token budget limited reviewer coverage', @@ -167,6 +168,7 @@ function getReliabilityNoticeDetail(notice: ReviewReliabilityNotice, t: Translat cache_miss: '{{count}} reviewer packet ran fresh or refreshed stale cache.', concurrency_limited: '{{count}} reviewer launch hit a concurrency cap.', partial_reviewer: '{{count}} reviewer result is partial; confidence is reduced.', + reduced_scope: 'This review used a reduced-depth scope profile.', retry_guidance: '{{count}} retry guidance item was emitted for partial review coverage.', skipped_reviewers: '{{count}} reviewer was skipped by applicability, configuration, or budget.', token_budget_limited: '{{count}} reviewer was skipped by token budget mode.', @@ -246,6 +248,16 @@ function formatRunManifestSummary( }); } +function formatReviewDepthLabel(reviewDepth: string, t: Translate): string { + return t(`toolCards.codeReview.runManifest.reviewDepthLabels.${reviewDepth}`, { + defaultValue: { + high_risk_only: 'High-risk-only', + risk_expanded: 'Risk-expanded', + full_depth: 'Full-depth', + }[reviewDepth] ?? reviewDepth, + }); +} + function formatRunManifestTarget(manifest: ReviewTeamRunManifest): string { return manifest.target.tags.length > 0 ? manifest.target.tags.join(', ') @@ -756,6 +768,16 @@ export const CodeReviewToolCard: React.FC = React.memo(({ {runManifest.strategyRecommendation.strategyLevel} )} + {runManifest.scopeProfile && ( +
+ + {t('toolCards.codeReview.runManifest.reviewDepth', { + defaultValue: 'Review depth', + })} + + {formatReviewDepthLabel(runManifest.scopeProfile.reviewDepth, t)} +
+ )} {runManifest.strategyRecommendation && ( diff --git a/src/web-ui/src/flow_chat/utils/codeReviewReport.test.ts b/src/web-ui/src/flow_chat/utils/codeReviewReport.test.ts index 38ecb1f6c..05b590f29 100644 --- a/src/web-ui/src/flow_chat/utils/codeReviewReport.test.ts +++ b/src/web-ui/src/flow_chat/utils/codeReviewReport.test.ts @@ -188,6 +188,20 @@ function buildRetryRunManifest( }; } +function buildReducedScopeRunManifest(): ReviewTeamRunManifest { + return { + ...buildRunManifest(), + scopeProfile: { + reviewDepth: 'high_risk_only', + riskFocusTags: ['security', 'cross_boundary_api_contracts'], + maxDependencyHops: 0, + optionalReviewerPolicy: 'risk_matched_only', + allowBroadToolExploration: false, + coverageExpectation: 'High-risk-only pass; changed files remain visible.', + }, + }; +} + describe('codeReviewReport', () => { it('uses structured report sections when present', () => { const report = { @@ -532,6 +546,109 @@ describe('codeReviewReport', () => { expect(markdown).toContain('- Token budget limited reviewer coverage [warning/manifest]: Count: 1'); }); + it('surfaces reduced-depth scope profile in reliability notices and markdown export', () => { + const report = { + summary: { + overall_assessment: 'No blocking issues found in the high-risk pass.', + risk_level: 'low' as const, + recommended_action: 'approve' as const, + }, + review_mode: 'deep' as const, + reviewers: [], + }; + const runManifest = buildReducedScopeRunManifest(); + + const notices = buildCodeReviewReliabilityNotices(report, runManifest); + + expect(notices).toContainEqual({ + kind: 'reduced_scope', + severity: 'info', + source: 'manifest', + detail: 'High-risk-only pass; changed files remain visible.', + }); + + const markdown = formatCodeReviewReportMarkdown(report, undefined, { runManifest }); + + expect(markdown).toContain('- Review depth: high_risk_only'); + expect(markdown).toContain('- Coverage expectation: High-risk-only pass; changed files remain visible.'); + expect(markdown).toContain( + '- Reduced-depth coverage [info/manifest]: High-risk-only pass; changed files remain visible.', + ); + }); + + it('exports a compact metadata-only evidence pack summary without content', () => { + const report = { + summary: { + overall_assessment: 'Review completed.', + risk_level: 'low' as const, + recommended_action: 'approve' as const, + }, + review_mode: 'deep' as const, + reviewers: [], + }; + const runManifest: ReviewTeamRunManifest = { + ...buildRunManifest(), + evidencePack: { + version: 1, + source: 'target_manifest', + changedFiles: ['src/App.tsx'], + diffStat: { + fileCount: 1, + totalChangedLines: 12, + lineCountSource: 'diff_stat', + }, + domainTags: ['frontend'], + riskFocusTags: ['cross_boundary_api_contracts'], + packetIds: ['reviewer:ReviewBusinessLogic', 'judge:ReviewJudge'], + hunkHints: [ + { + filePath: 'src/App.tsx', + changedLineCount: 12, + lineCountSource: 'diff_stat', + }, + ], + contractHints: [ + { + kind: 'api_contract', + filePath: 'src/App.tsx', + source: 'path_classifier', + }, + ], + budget: { + maxChangedFiles: 80, + maxHunkHints: 80, + maxContractHints: 40, + omittedChangedFileCount: 0, + omittedHunkHintCount: 0, + omittedContractHintCount: 0, + }, + privacy: { + content: 'metadata_only', + excludes: [ + 'source_text', + 'full_diff', + 'model_output', + 'provider_raw_body', + 'full_file_contents', + ], + }, + }, + }; + + const markdown = formatCodeReviewReportMarkdown(report, undefined, { runManifest }); + + expect(markdown).toContain('### Evidence pack'); + expect(markdown).toContain('- Source: target_manifest; privacy: metadata_only'); + expect(markdown).toContain('- Changed files: 1; hunk hints: 1; contract hints: 1; packet ids: 2'); + expect(markdown).toContain('- Omitted metadata: changed files 0, hunk hints 0, contract hints 0'); + expect(markdown).toContain('- Hints are orientation only and require tool confirmation before findings.'); + expect(markdown).not.toContain('source_text'); + expect(markdown).not.toContain('full_diff'); + expect(markdown).not.toContain('model_output'); + expect(markdown).not.toContain('provider_raw_body'); + expect(markdown).not.toContain('full_file_contents'); + }); + it('keeps team and issue details collapsed by default while leaving remediation visible', () => { const report = { summary: { diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index b7e238012..ce274ebe2 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -749,6 +749,12 @@ "estimatedTokens": "Estimated: {{min}} - {{max}} tokens", "runStrategy": "Run strategy: {{strategy}}", "recommendedStrategy": "Recommended strategy: {{strategy}}", + "reviewDepth": "Review depth: {{depth}}", + "reviewDepthLabels": { + "high_risk_only": "High-risk-only", + "risk_expanded": "Risk-expanded", + "full_depth": "Full-depth" + }, "recommendationTitle": "Risk recommendation", "strategyOverrideTitle": "Run strategy", "strategyOverrideBody": "Choose a project-specific strategy for this launch.", @@ -1514,7 +1520,13 @@ }, "runManifest": { "recommendedStrategy": "Recommended strategy", - "riskRecommendationTitle": "Risk recommendation" + "riskRecommendationTitle": "Risk recommendation", + "reviewDepth": "Review depth", + "reviewDepthLabels": { + "high_risk_only": "High-risk-only", + "risk_expanded": "Risk-expanded", + "full_depth": "Full-depth" + } }, "sectionItemCount": "{{count}} items", "remediationPlan": "Remediation Plan", @@ -1544,6 +1556,10 @@ "label": "Reviewer timed out with partial result", "detail": "{{count}} reviewer result is partial; confidence is reduced." }, + "reduced_scope": { + "label": "Reduced-depth coverage", + "detail": "This review used a reduced-depth scope profile." + }, "retry_guidance": { "label": "Retry guidance emitted", "detail": "{{count}} retry guidance item was emitted for partial review coverage." diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 3a8e9a9a3..9ced6266c 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -749,6 +749,12 @@ "estimatedTokens": "预计消耗:{{min}} - {{max}} tokens", "runStrategy": "运行策略:{{strategy}}", "recommendedStrategy": "推荐策略:{{strategy}}", + "reviewDepth": "审查深度:{{depth}}", + "reviewDepthLabels": { + "high_risk_only": "仅高风险", + "risk_expanded": "风险扩展", + "full_depth": "完整深度" + }, "recommendationTitle": "风险推荐", "strategyOverrideTitle": "运行策略", "strategyOverrideBody": "为本项目的本次启动选择审核策略。", @@ -1514,7 +1520,13 @@ }, "runManifest": { "recommendedStrategy": "推荐策略", - "riskRecommendationTitle": "风险推荐" + "riskRecommendationTitle": "风险推荐", + "reviewDepth": "审查深度", + "reviewDepthLabels": { + "high_risk_only": "仅高风险", + "risk_expanded": "风险扩展", + "full_depth": "完整深度" + } }, "sectionItemCount": "{{count}} 项", "remediationPlan": "修复计划", @@ -1544,6 +1556,10 @@ "label": "审核者超时但有部分结果", "detail": "{{count}} 个审核者结果是部分结果,置信度已降低。" }, + "reduced_scope": { + "label": "降深度覆盖", + "detail": "本次审核使用了降深度范围配置。" + }, "retry_guidance": { "label": "已给出重试指引", "detail": "{{count}} 条重试指引用于补足部分审核覆盖。" diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 3f3fc44b0..5c680d161 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -749,6 +749,12 @@ "estimatedTokens": "預計消耗:{{min}} - {{max}} tokens", "runStrategy": "運行策略:{{strategy}}", "recommendedStrategy": "推薦策略:{{strategy}}", + "reviewDepth": "審查深度:{{depth}}", + "reviewDepthLabels": { + "high_risk_only": "僅高風險", + "risk_expanded": "風險擴展", + "full_depth": "完整深度" + }, "recommendationTitle": "風險推薦", "strategyOverrideTitle": "運行策略", "strategyOverrideBody": "為本專案的本次啟動選擇審核策略。", @@ -1514,7 +1520,13 @@ }, "runManifest": { "recommendedStrategy": "推薦策略", - "riskRecommendationTitle": "風險推薦" + "riskRecommendationTitle": "風險推薦", + "reviewDepth": "審查深度", + "reviewDepthLabels": { + "high_risk_only": "僅高風險", + "risk_expanded": "風險擴展", + "full_depth": "完整深度" + } }, "sectionItemCount": "{{count}} 項", "remediationPlan": "修復計劃", @@ -1544,6 +1556,10 @@ "label": "審核者逾時但有部分結果", "detail": "{{count}} 個審核者結果是部分結果,信心已降低。" }, + "reduced_scope": { + "label": "降深度覆蓋", + "detail": "本次審核使用了降深度範圍設定。" + }, "retry_guidance": { "label": "已給出重試指引", "detail": "{{count}} 條重試指引用於補足部分審核覆蓋。" diff --git a/src/web-ui/src/shared/services/review-team/cachePlan.ts b/src/web-ui/src/shared/services/review-team/cachePlan.ts new file mode 100644 index 000000000..561251e61 --- /dev/null +++ b/src/web-ui/src/shared/services/review-team/cachePlan.ts @@ -0,0 +1,149 @@ +import type { ReviewTargetClassification } from '../reviewTargetClassifier'; +import type { + ReviewTeamChangeStats, + ReviewTeamIncrementalReviewCacheInvalidation, + ReviewTeamIncrementalReviewCachePlan, + ReviewTeamSharedContextCachePlan, + ReviewTeamSharedContextTool, + ReviewTeamWorkPacket, + ReviewStrategyLevel, +} from './types'; +import { + includedReviewTargetFiles, + workspaceAreaForReviewPath, +} from './pathMetadata'; + +const SHARED_CONTEXT_CACHE_ENTRY_LIMIT = 80; +const SHARED_CONTEXT_CACHE_RECOMMENDED_TOOLS: ReviewTeamSharedContextTool[] = [ + 'GetFileDiff', + 'Read', +]; + +export function buildSharedContextCachePlan( + workPackets: ReviewTeamWorkPacket[] = [], +): ReviewTeamSharedContextCachePlan { + const fileContextByPath = new Map< + string, + { + path: string; + workspaceArea: string; + consumerPacketIds: string[]; + firstSeenIndex: number; + } + >(); + let nextSeenIndex = 0; + + for (const packet of workPackets) { + if (packet.phase !== 'reviewer') { + continue; + } + + for (const path of packet.assignedScope.files) { + let entry = fileContextByPath.get(path); + if (!entry) { + entry = { + path, + workspaceArea: workspaceAreaForReviewPath(path), + consumerPacketIds: [], + firstSeenIndex: nextSeenIndex, + }; + nextSeenIndex += 1; + fileContextByPath.set(path, entry); + } + if (!entry.consumerPacketIds.includes(packet.packetId)) { + entry.consumerPacketIds.push(packet.packetId); + } + } + } + + const repeatedFileContexts = Array.from(fileContextByPath.values()) + .filter((entry) => entry.consumerPacketIds.length > 1) + .sort((a, b) => a.firstSeenIndex - b.firstSeenIndex); + const entries = repeatedFileContexts + .slice(0, SHARED_CONTEXT_CACHE_ENTRY_LIMIT) + .map((entry, index) => ({ + cacheKey: `shared-context:${index + 1}`, + path: entry.path, + workspaceArea: entry.workspaceArea, + recommendedTools: [...SHARED_CONTEXT_CACHE_RECOMMENDED_TOOLS], + consumerPacketIds: entry.consumerPacketIds, + })); + + return { + source: 'work_packets', + strategy: 'reuse_readonly_file_context_by_cache_key', + entries, + omittedEntryCount: Math.max( + 0, + repeatedFileContexts.length - SHARED_CONTEXT_CACHE_ENTRY_LIMIT, + ), + }; +} + +const INCREMENTAL_REVIEW_CACHE_INVALIDATIONS: ReviewTeamIncrementalReviewCacheInvalidation[] = [ + 'target_file_set_changed', + 'target_line_count_changed', + 'target_tag_changed', + 'target_warning_changed', + 'reviewer_roster_changed', + 'strategy_changed', +]; + +function stableFingerprint(input: unknown): string { + const serialized = JSON.stringify(input); + let hash = 0x811c9dc5; + for (let index = 0; index < serialized.length; index += 1) { + hash ^= serialized.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, '0'); +} + +export function buildIncrementalReviewCachePlan(params: { + target: ReviewTargetClassification; + changeStats: ReviewTeamChangeStats; + strategyLevel: ReviewStrategyLevel; + workPackets: ReviewTeamWorkPacket[]; +}): ReviewTeamIncrementalReviewCachePlan { + const filePaths = includedReviewTargetFiles(params.target) + .sort((a, b) => a.localeCompare(b)); + const workspaceAreas = Array.from( + new Set(filePaths.map((file) => workspaceAreaForReviewPath(file))), + ).sort((a, b) => a.localeCompare(b)); + const targetTags = [...params.target.tags].sort((a, b) => a.localeCompare(b)); + const targetWarnings = params.target.warnings + .map((warning) => warning.code) + .sort((a, b) => a.localeCompare(b)); + const reviewerPacketIds = params.workPackets + .filter((packet) => packet.phase === 'reviewer') + .map((packet) => packet.packetId) + .sort((a, b) => a.localeCompare(b)); + const fingerprint = stableFingerprint({ + source: params.target.source, + resolution: params.target.resolution, + filePaths, + workspaceAreas, + targetTags, + targetWarnings, + lineCount: params.changeStats.totalLinesChanged ?? null, + lineCountSource: params.changeStats.lineCountSource, + reviewerPacketIds, + strategyLevel: params.strategyLevel, + }); + + return { + source: 'target_manifest', + strategy: 'reuse_completed_packets_when_fingerprint_matches', + cacheKey: `incremental-review:${fingerprint}`, + fingerprint, + filePaths, + workspaceAreas, + targetTags, + reviewerPacketIds, + ...(params.changeStats.totalLinesChanged !== undefined + ? { lineCount: params.changeStats.totalLinesChanged } + : {}), + lineCountSource: params.changeStats.lineCountSource, + invalidatesOn: [...INCREMENTAL_REVIEW_CACHE_INVALIDATIONS], + }; +} diff --git a/src/web-ui/src/shared/services/review-team/evidencePack.ts b/src/web-ui/src/shared/services/review-team/evidencePack.ts new file mode 100644 index 000000000..d14cadd9f --- /dev/null +++ b/src/web-ui/src/shared/services/review-team/evidencePack.ts @@ -0,0 +1,122 @@ +import type { ReviewTargetClassification } from '../reviewTargetClassifier'; +import type { + DeepReviewEvidencePack, + DeepReviewEvidencePackContractHint, + DeepReviewEvidencePackContractHintKind, + DeepReviewScopeProfile, + ReviewTeamChangeStats, + ReviewTeamWorkPacket, +} from './types'; +import { includedReviewTargetFiles } from './pathMetadata'; + +const EVIDENCE_PACK_CHANGED_FILE_LIMIT = 80; +const EVIDENCE_PACK_HUNK_HINT_LIMIT = 80; +const EVIDENCE_PACK_CONTRACT_HINT_LIMIT = 40; + +function evidencePackContractHintKindForFile( + filePath: string, + target: ReviewTargetClassification, +): DeepReviewEvidencePackContractHintKind | undefined { + const file = target.files.find((candidate) => candidate.normalizedPath === filePath); + const tags = file?.tags ?? []; + if (tags.includes('frontend_i18n')) { + return 'i18n_key'; + } + if (tags.includes('desktop_contract')) { + return 'tauri_command'; + } + if ( + tags.includes('api_layer') || + tags.includes('frontend_contract') || + tags.includes('web_server_contract') + ) { + return 'api_contract'; + } + if (tags.includes('config')) { + return 'config_key'; + } + return undefined; +} + +function buildEvidencePackContractHints( + changedFiles: string[], + target: ReviewTargetClassification, +): DeepReviewEvidencePackContractHint[] { + return changedFiles + .map((filePath) => { + const kind = evidencePackContractHintKindForFile(filePath, target); + return kind + ? { + kind, + filePath, + source: 'path_classifier' as const, + } + : undefined; + }) + .filter((hint): hint is DeepReviewEvidencePackContractHint => Boolean(hint)); +} + +function buildEvidencePackHunkHints( + changedFiles: string[], + changeStats: ReviewTeamChangeStats, +): DeepReviewEvidencePack['hunkHints'] { + const changedLineCount = changeStats.totalLinesChanged === undefined || + changedFiles.length === 0 + ? 0 + : Math.ceil(changeStats.totalLinesChanged / changedFiles.length); + return changedFiles.map((filePath) => ({ + filePath, + changedLineCount, + lineCountSource: changeStats.lineCountSource, + })); +} + +export function buildDeepReviewEvidencePack(params: { + target: ReviewTargetClassification; + changeStats: ReviewTeamChangeStats; + scopeProfile?: DeepReviewScopeProfile; + workPackets: ReviewTeamWorkPacket[]; +}): DeepReviewEvidencePack { + const includedFiles = includedReviewTargetFiles(params.target); + const allHunkHints = buildEvidencePackHunkHints(includedFiles, params.changeStats); + const allContractHints = buildEvidencePackContractHints(includedFiles, params.target); + const changedFiles = includedFiles.slice(0, EVIDENCE_PACK_CHANGED_FILE_LIMIT); + const hunkHints = allHunkHints.slice(0, EVIDENCE_PACK_HUNK_HINT_LIMIT); + const contractHints = allContractHints.slice(0, EVIDENCE_PACK_CONTRACT_HINT_LIMIT); + + return { + version: 1, + source: 'target_manifest', + changedFiles, + diffStat: { + fileCount: params.changeStats.fileCount, + ...(params.changeStats.totalLinesChanged !== undefined + ? { totalChangedLines: params.changeStats.totalLinesChanged } + : {}), + lineCountSource: params.changeStats.lineCountSource, + }, + domainTags: [...params.target.tags], + riskFocusTags: [...(params.scopeProfile?.riskFocusTags ?? [])], + packetIds: params.workPackets.map((packet) => packet.packetId), + hunkHints, + contractHints, + budget: { + maxChangedFiles: EVIDENCE_PACK_CHANGED_FILE_LIMIT, + maxHunkHints: EVIDENCE_PACK_HUNK_HINT_LIMIT, + maxContractHints: EVIDENCE_PACK_CONTRACT_HINT_LIMIT, + omittedChangedFileCount: Math.max(0, includedFiles.length - changedFiles.length), + omittedHunkHintCount: Math.max(0, allHunkHints.length - hunkHints.length), + omittedContractHintCount: Math.max(0, allContractHints.length - contractHints.length), + }, + privacy: { + content: 'metadata_only', + excludes: [ + 'source_text', + 'full_diff', + 'model_output', + 'provider_raw_body', + 'full_file_contents', + ], + }, + }; +} diff --git a/src/web-ui/src/shared/services/review-team/index.ts b/src/web-ui/src/shared/services/review-team/index.ts index f83082b23..092401713 100644 --- a/src/web-ui/src/shared/services/review-team/index.ts +++ b/src/web-ui/src/shared/services/review-team/index.ts @@ -27,68 +27,65 @@ import { DISALLOWED_REVIEW_TEAM_MEMBER_IDS, EXTRA_MEMBER_DEFAULTS, FALLBACK_REVIEW_TEAM_DEFINITION, - JUDGE_WORK_PACKET_REQUIRED_OUTPUT_FIELDS, MAX_AUTO_RETRY_ELAPSED_GUARD_SECONDS, MAX_PARALLEL_REVIEWER_INSTANCES, - MAX_PREDICTIVE_TIMEOUT_SECONDS, MAX_QUEUE_WAIT_SECONDS, - PREDICTIVE_TIMEOUT_BASE_SECONDS, - PREDICTIVE_TIMEOUT_PER_100_LINES_SECONDS, - PREDICTIVE_TIMEOUT_PER_FILE_SECONDS, - PROMPT_BYTE_ESTIMATE_BASE_BYTES, - PROMPT_BYTE_ESTIMATE_PER_CHANGED_LINE_BYTES, - PROMPT_BYTE_ESTIMATE_PER_FILE_BYTES, - PROMPT_BYTE_ESTIMATE_UNKNOWN_LINES_PER_FILE, - REVIEWER_WORK_PACKET_REQUIRED_OUTPUT_FIELDS, REVIEW_WORK_PACKET_ALLOWED_TOOLS, - TOKEN_BUDGET_PROMPT_BYTE_LIMIT_BY_MODE, } from './defaults'; import { - REVIEW_STRATEGY_COMMON_RULES, REVIEW_STRATEGY_LEVELS, REVIEW_STRATEGY_PROFILES, - getReviewStrategyProfile, } from './strategy'; +import { buildPreReviewSummary } from './preReviewSummary'; +import { + buildIncrementalReviewCachePlan, + buildSharedContextCachePlan, +} from './cachePlan'; +import { buildDeepReviewEvidencePack } from './evidencePack'; +import { + applyTeamStrategyOverrideToMember, + toManifestMember, +} from './manifestMembers'; +import { + buildReviewStrategyDecision, + recommendBackendCompatibleStrategyForTarget, + recommendReviewStrategyForTarget, +} from './risk'; +import { buildDeepReviewScopeProfile } from './scopeProfile'; +import { + buildEffectiveExecutionPolicy, + buildTokenBudgetPlan, +} from './tokenBudget'; +import { + buildWorkPackets, + resolveChangeStats, + resolveMaxExtraReviewers, +} from './workPackets'; +import { buildReviewTeamPromptBlockContent } from './promptBlock'; import type { ReviewMemberStrategyLevel, ReviewModelFallbackReason, - ReviewRoleDirectiveKey, ReviewStrategyLevel, ReviewStrategyProfile, ReviewStrategySource, ReviewTeam, - ReviewTeamBackendRiskFactors, - ReviewTeamBackendStrategyRecommendation, ReviewTeamChangeStats, ReviewTeamConcurrencyPolicy, ReviewTeamCoreRoleDefinition, ReviewTeamCoreRoleKey, ReviewTeamDefinition, ReviewTeamExecutionPolicy, - ReviewTeamIncrementalReviewCacheInvalidation, - ReviewTeamIncrementalReviewCachePlan, - ReviewTeamManifestMember, ReviewTeamManifestMemberReason, ReviewTeamMember, - ReviewTeamPreReviewSummary, ReviewTeamRateLimitStatus, - ReviewTeamRiskFactors, ReviewTeamRunManifest, - ReviewTeamSharedContextCachePlan, - ReviewTeamSharedContextTool, ReviewTeamStoredConfig, - ReviewTeamStrategyDecision, - ReviewTeamStrategyMismatchSeverity, - ReviewTeamStrategyRecommendation, - ReviewTeamTokenBudgetDecision, - ReviewTeamTokenBudgetPlan, - ReviewTeamWorkPacket, - ReviewTeamWorkPacketScope, ReviewTokenBudgetMode, } from './types'; export * from './types'; export * from './strategy'; +export { recommendReviewStrategyForTarget } from './risk'; export { DEFAULT_REVIEW_TEAM_ID, DEFAULT_REVIEW_TEAM_CONFIG_PATH, @@ -1235,79 +1232,6 @@ export async function prepareDefaultReviewTeamForLaunch( return team; } -function toManifestMember( - member: ReviewTeamMember, - reason?: ReviewTeamManifestMember['reason'], -): ReviewTeamManifestMember { - const strategyProfile = getReviewStrategyProfile(member.strategyLevel); - const roleDirective = - strategyProfile.roleDirectives[member.subagentId as ReviewRoleDirectiveKey]; - return { - subagentId: member.subagentId, - displayName: member.displayName, - roleName: member.roleName, - model: member.model || DEFAULT_REVIEW_TEAM_MODEL, - configuredModel: member.configuredModel || member.model || DEFAULT_REVIEW_TEAM_MODEL, - modelFallbackReason: member.modelFallbackReason, - defaultModelSlot: member.defaultModelSlot ?? strategyProfile.defaultModelSlot, - strategyLevel: member.strategyLevel, - strategySource: member.strategySource, - strategyDirective: - member.strategyDirective || roleDirective || strategyProfile.promptDirective, - locked: member.locked, - source: member.source, - subagentSource: member.subagentSource, - ...(reason ? { reason } : {}), - }; -} - -function resolveManifestMemberModelForStrategy( - member: ReviewTeamMember, - strategyLevel: ReviewStrategyLevel, -): { - model: string; - configuredModel: string; - modelFallbackReason?: ReviewModelFallbackReason; -} { - if (member.modelFallbackReason === 'model_removed') { - return { - model: getReviewStrategyProfile(strategyLevel).defaultModelSlot, - configuredModel: member.configuredModel, - modelFallbackReason: member.modelFallbackReason, - }; - } - - return resolveMemberModel( - member.configuredModel || member.model || DEFAULT_REVIEW_TEAM_MODEL, - strategyLevel, - ); -} - -function applyTeamStrategyOverrideToMember( - member: ReviewTeamMember, - strategyLevel: ReviewStrategyLevel, -): ReviewTeamMember { - if (member.strategySource === 'member' || member.strategyLevel === strategyLevel) { - return member; - } - - const strategyProfile = getReviewStrategyProfile(strategyLevel); - const model = resolveManifestMemberModelForStrategy(member, strategyLevel); - return { - ...member, - model: model.model, - configuredModel: model.configuredModel, - modelFallbackReason: model.modelFallbackReason, - strategyOverride: DEFAULT_REVIEW_MEMBER_STRATEGY_LEVEL, - strategyLevel, - strategySource: 'team', - defaultModelSlot: strategyProfile.defaultModelSlot, - strategyDirective: - strategyProfile.roleDirectives[member.subagentId as ReviewRoleDirectiveKey] || - strategyProfile.promptDirective, - }; -} - function shouldRunCoreReviewerForTarget( member: ReviewTeamMember, target: ReviewTargetClassification, @@ -1315,954 +1239,6 @@ function shouldRunCoreReviewerForTarget( return shouldRunReviewerForTarget(member.subagentId, target); } -function resolveMaxExtraReviewers( - mode: ReviewTokenBudgetMode, - eligibleExtraReviewerCount: number, -): number { - if (mode === 'economy') { - return 0; - } - return eligibleExtraReviewerCount; -} - -function resolveChangeStats( - target: ReviewTargetClassification, - stats?: Partial, -): ReviewTeamChangeStats { - const fileCount = Math.max( - 0, - Math.floor( - stats?.fileCount ?? - target.files.filter((file) => !file.excluded).length, - ), - ); - const totalLinesChanged = - typeof stats?.totalLinesChanged === 'number' && - Number.isFinite(stats.totalLinesChanged) - ? Math.max(0, Math.floor(stats.totalLinesChanged)) - : undefined; - - return { - fileCount, - ...(totalLinesChanged !== undefined ? { totalLinesChanged } : {}), - lineCountSource: - totalLinesChanged !== undefined - ? stats?.lineCountSource ?? 'diff_stat' - : 'unknown', - }; -} - -const SECURITY_SENSITIVE_PATH_PATTERN = - /(^|[/._-])(auth|oauth|crypto|security|permission|permissions|secret|secrets|token|tokens|credential|credentials)([/._-]|$)/; - -function isSecuritySensitiveReviewPath(normalizedPath: string): boolean { - return SECURITY_SENSITIVE_PATH_PATTERN.test(normalizedPath.toLowerCase()); -} - -function workspaceAreaForReviewPath(normalizedPath: string): string { - const crateMatch = normalizedPath.match(/^src\/crates\/([^/]+)/); - if (crateMatch) { - return `crate:${crateMatch[1]}`; - } - - const appMatch = normalizedPath.match(/^src\/apps\/([^/]+)/); - if (appMatch) { - return `app:${appMatch[1]}`; - } - - if (normalizedPath.startsWith('src/web-ui/')) { - return 'web-ui'; - } - - if (normalizedPath.startsWith('BitFun-Installer/')) { - return 'installer'; - } - - const [root] = normalizedPath.split('/'); - return root || 'unknown'; -} - -function pluralize(count: number, singular: string): string { - return `${count} ${singular}${count === 1 ? '' : 's'}`; -} - -const PRE_REVIEW_SUMMARY_SAMPLE_FILE_LIMIT = 3; -const PRE_REVIEW_SUMMARY_AREA_LIMIT = 8; - -function buildPreReviewSummary( - target: ReviewTargetClassification, - changeStats: ReviewTeamChangeStats, -): ReviewTeamPreReviewSummary { - const includedFiles = target.files - .filter((file) => !file.excluded) - .map((file) => file.normalizedPath); - const excludedFileCount = target.files.length - includedFiles.length; - const allWorkspaceAreas = groupFilesByWorkspaceArea(includedFiles) - .sort((a, b) => b.files.length - a.files.length || a.index - b.index); - const workspaceAreas = allWorkspaceAreas - .slice(0, PRE_REVIEW_SUMMARY_AREA_LIMIT) - .map((area) => ({ - key: area.key, - fileCount: area.files.length, - sampleFiles: area.files.slice(0, PRE_REVIEW_SUMMARY_SAMPLE_FILE_LIMIT), - })); - const lineCount = changeStats.totalLinesChanged; - const lineCountLabel = - lineCount === undefined - ? 'unknown changed lines' - : `${lineCount} changed lines`; - const areaLabel = workspaceAreas.length > 0 - ? workspaceAreas.map((area) => `${area.key} (${area.fileCount})`).join(', ') - : 'no resolved workspace area'; - const targetTags = [...target.tags]; - const tagLabel = targetTags.filter((tag) => tag !== 'unknown').join(', ') || 'unknown'; - const omittedAreaCount = Math.max( - 0, - allWorkspaceAreas.length - workspaceAreas.length, - ); - const summaryParts = [ - `${pluralize(changeStats.fileCount, 'file')}, ${lineCountLabel} across ${pluralize(allWorkspaceAreas.length, 'workspace area')}: ${areaLabel}`, - `tags: ${tagLabel}`, - omittedAreaCount > 0 ? `${pluralize(omittedAreaCount, 'workspace area')} omitted from summary` : undefined, - ].filter(Boolean); - - return { - source: 'target_manifest', - summary: summaryParts.join('; '), - fileCount: changeStats.fileCount, - excludedFileCount, - ...(lineCount !== undefined ? { lineCount } : {}), - lineCountSource: changeStats.lineCountSource, - targetTags, - workspaceAreas, - warnings: target.warnings.map((warning) => warning.code), - }; -} - -export function recommendReviewStrategyForTarget( - target: ReviewTargetClassification, - changeStats: ReviewTeamChangeStats, -): ReviewTeamStrategyRecommendation { - const includedFiles = target.files.filter((file) => !file.excluded); - const securityFileCount = includedFiles.filter((file) => - isSecuritySensitiveReviewPath(file.normalizedPath), - ).length; - const workspaceAreaCount = new Set( - includedFiles.map((file) => workspaceAreaForReviewPath(file.normalizedPath)), - ).size; - const contractSurfaceChanged = target.tags.includes('frontend_contract') || - target.tags.includes('desktop_contract') || - target.tags.includes('web_server_contract') || - target.tags.includes('api_layer') || - target.tags.includes('transport'); - const totalLinesChanged = changeStats.totalLinesChanged; - const factors: ReviewTeamRiskFactors = { - fileCount: changeStats.fileCount, - ...(totalLinesChanged !== undefined ? { totalLinesChanged } : {}), - lineCountSource: changeStats.lineCountSource, - securityFileCount, - workspaceAreaCount, - contractSurfaceChanged, - }; - - if (target.resolution === 'unknown' || changeStats.fileCount === 0) { - return { - strategyLevel: 'normal', - score: 0, - rationale: 'unresolved target; keep a conservative normal review recommendation.', - factors, - }; - } - - const lineScore = - totalLinesChanged === undefined - ? 0 - : Math.floor(totalLinesChanged / 100); - const crossAreaScore = Math.max(0, workspaceAreaCount - 1) * 2; - const score = - changeStats.fileCount + - lineScore + - securityFileCount * 3 + - crossAreaScore + - (contractSurfaceChanged ? 2 : 0); - const strategyLevel: ReviewStrategyLevel = - score <= 5 - ? 'quick' - : score <= 20 - ? 'normal' - : 'deep'; - const sizeLabel = totalLinesChanged === undefined - ? `${changeStats.fileCount} files, unknown lines` - : `${changeStats.fileCount} files, ${totalLinesChanged} lines`; - const riskDetails = [ - pluralize(securityFileCount, 'security-sensitive file'), - pluralize(workspaceAreaCount, 'workspace area'), - contractSurfaceChanged ? 'contract surface changed' : undefined, - ].filter(Boolean).join(', '); - const rationale = - strategyLevel === 'quick' - ? `Small change (${sizeLabel}). Quick scan sufficient.` - : strategyLevel === 'normal' - ? `Medium change (${sizeLabel}; ${riskDetails}). Standard review recommended.` - : `Large/high-risk change (${sizeLabel}; ${riskDetails}). Deep review recommended.`; - - return { - strategyLevel, - score, - rationale, - factors, - }; -} - -const REVIEW_STRATEGY_RANK: Record = { - quick: 0, - normal: 1, - deep: 2, -}; - -function crossCrateChangeCountForReviewTarget( - target: ReviewTargetClassification, -): number { - const crateNames = new Set( - target.files - .filter((file) => !file.excluded) - .map((file) => /^src\/crates\/([^/]+)/.exec(file.normalizedPath)?.[1]) - .filter((crateName): crateName is string => Boolean(crateName)), - ); - - return Math.max(0, crateNames.size - 1); -} - -function buildBackendCompatibleRiskFactors( - target: ReviewTargetClassification, - changeStats: ReviewTeamChangeStats, -): ReviewTeamBackendRiskFactors { - const includedFiles = target.files.filter((file) => !file.excluded); - - return { - fileCount: changeStats.fileCount, - totalLinesChanged: changeStats.totalLinesChanged ?? 0, - lineCountSource: changeStats.lineCountSource, - filesInSecurityPaths: includedFiles.filter((file) => - isSecuritySensitiveReviewPath(file.normalizedPath), - ).length, - crossCrateChanges: crossCrateChangeCountForReviewTarget(target), - maxCyclomaticComplexityDelta: 0, - maxCyclomaticComplexityDeltaSource: 'not_measured', - }; -} - -function recommendBackendCompatibleStrategyForTarget( - target: ReviewTargetClassification, - changeStats: ReviewTeamChangeStats, -): ReviewTeamBackendStrategyRecommendation { - const factors = buildBackendCompatibleRiskFactors(target, changeStats); - const score = - factors.fileCount + - Math.floor(factors.totalLinesChanged / 100) + - factors.filesInSecurityPaths * 3 + - factors.crossCrateChanges * 2; - const strategyLevel: ReviewStrategyLevel = - score <= 5 - ? 'quick' - : score <= 20 - ? 'normal' - : 'deep'; - const rationale = - strategyLevel === 'quick' - ? `Backend-compatible policy sees a small change (${factors.fileCount} files, ${factors.totalLinesChanged} lines).` - : strategyLevel === 'normal' - ? `Backend-compatible policy sees a medium change (${factors.fileCount} files, ${factors.totalLinesChanged} lines).` - : `Backend-compatible policy sees a large/high-risk change (${factors.fileCount} files, ${factors.totalLinesChanged} lines, ${factors.filesInSecurityPaths} security files).`; - - return { - strategyLevel, - score, - rationale, - factors, - }; -} - -function resolveStrategyMismatchSeverity(params: { - finalStrategy: ReviewStrategyLevel; - frontendRecommendation: ReviewStrategyLevel; - backendRecommendation: ReviewStrategyLevel; -}): ReviewTeamStrategyMismatchSeverity { - const finalRank = REVIEW_STRATEGY_RANK[params.finalStrategy]; - const recommendedRank = Math.max( - REVIEW_STRATEGY_RANK[params.frontendRecommendation], - REVIEW_STRATEGY_RANK[params.backendRecommendation], - ); - const distance = Math.abs(finalRank - recommendedRank); - - if (distance === 0) { - return 'none'; - } - if (distance >= 2) { - return 'high'; - } - return finalRank < recommendedRank ? 'medium' : 'low'; -} - -function buildReviewStrategyDecision(params: { - teamDefaultStrategy: ReviewStrategyLevel; - finalStrategy: ReviewStrategyLevel; - userOverride?: ReviewStrategyLevel; - frontendRecommendation: ReviewTeamStrategyRecommendation; - backendRecommendation: ReviewTeamBackendStrategyRecommendation; -}): ReviewTeamStrategyDecision { - const mismatch = - params.finalStrategy !== params.frontendRecommendation.strategyLevel || - params.finalStrategy !== params.backendRecommendation.strategyLevel; - const mismatchSeverity = resolveStrategyMismatchSeverity({ - finalStrategy: params.finalStrategy, - frontendRecommendation: params.frontendRecommendation.strategyLevel, - backendRecommendation: params.backendRecommendation.strategyLevel, - }); - const recommendationSummary = [ - `frontend=${params.frontendRecommendation.strategyLevel}`, - `backend=${params.backendRecommendation.strategyLevel}`, - ].join(', '); - - return { - authority: 'mismatch_warning', - teamDefaultStrategy: params.teamDefaultStrategy, - ...(params.userOverride ? { userOverride: params.userOverride } : {}), - finalStrategy: params.finalStrategy, - frontendRecommendation: params.frontendRecommendation, - backendRecommendation: params.backendRecommendation, - mismatch, - mismatchSeverity, - rationale: mismatch - ? `Final strategy ${params.finalStrategy} differs from advisory recommendations (${recommendationSummary}); keep this as non-blocking launch/report metadata.` - : `Final strategy ${params.finalStrategy} matches advisory recommendations (${recommendationSummary}).`, - }; -} - -function buildWorkPacketScopeFromFiles( - target: ReviewTargetClassification, - files: string[], - group?: { index: number; count: number }, -): ReviewTeamWorkPacketScope { - return { - kind: 'review_target', - targetSource: target.source, - targetResolution: target.resolution, - targetTags: [...target.tags], - fileCount: files.length, - files, - excludedFileCount: - target.files.length - target.files.filter((file) => !file.excluded).length, - ...(group ? { groupIndex: group.index, groupCount: group.count } : {}), - }; -} - -function buildWorkPacket(params: { - member: ReviewTeamMember; - phase: ReviewTeamWorkPacket['phase']; - launchBatch: number; - scope: ReviewTeamWorkPacketScope; - timeoutSeconds: number; -}): ReviewTeamWorkPacket { - const manifestMember = toManifestMember(params.member); - const packetGroupSuffix = - params.phase === 'reviewer' && - params.scope.groupIndex !== undefined && - params.scope.groupCount !== undefined - ? `:group-${params.scope.groupIndex}-of-${params.scope.groupCount}` - : ''; - - return { - packetId: `${params.phase}:${manifestMember.subagentId}${packetGroupSuffix}`, - phase: params.phase, - launchBatch: params.launchBatch, - subagentId: manifestMember.subagentId, - displayName: manifestMember.displayName, - roleName: manifestMember.roleName, - assignedScope: params.scope, - allowedTools: [...params.member.allowedTools], - timeoutSeconds: params.timeoutSeconds, - requiredOutputFields: - params.phase === 'judge' - ? [...JUDGE_WORK_PACKET_REQUIRED_OUTPUT_FIELDS] - : [...REVIEWER_WORK_PACKET_REQUIRED_OUTPUT_FIELDS], - strategyLevel: manifestMember.strategyLevel, - strategyDirective: manifestMember.strategyDirective, - model: manifestMember.model || DEFAULT_REVIEW_TEAM_MODEL, - }; -} - -function splitFilesIntoGroups(files: string[], groupCount: number): string[][] { - if (groupCount <= 1) { - return [files]; - } - - const groups: string[][] = []; - let cursor = 0; - for (let index = 0; index < groupCount; index += 1) { - const remainingFiles = files.length - cursor; - const remainingGroups = groupCount - index; - const groupSize = Math.ceil(remainingFiles / remainingGroups); - groups.push(files.slice(cursor, cursor + groupSize)); - cursor += groupSize; - } - return groups; -} - -interface WorkspaceAreaFileBucket { - key: string; - index: number; - files: string[]; -} - -function groupFilesByWorkspaceArea(files: string[]): WorkspaceAreaFileBucket[] { - const buckets: WorkspaceAreaFileBucket[] = []; - const bucketByKey = new Map(); - - for (const file of files) { - const key = workspaceAreaForReviewPath(file); - let bucket = bucketByKey.get(key); - if (!bucket) { - bucket = { - key, - index: buckets.length, - files: [], - }; - buckets.push(bucket); - bucketByKey.set(key, bucket); - } - bucket.files.push(file); - } - - return buckets; -} - -function splitFilesIntoModuleAwareGroups( - files: string[], - groupCount: number, -): string[][] { - if (groupCount <= 1) { - return [files]; - } - - const buckets = groupFilesByWorkspaceArea(files); - if (buckets.length <= 1) { - return splitFilesIntoGroups(files, groupCount); - } - - if (buckets.length >= groupCount) { - const groups = Array.from({ length: groupCount }, () => [] as string[]); - const sortedBuckets = [...buckets].sort( - (a, b) => b.files.length - a.files.length || a.index - b.index, - ); - - for (const bucket of sortedBuckets) { - let targetIndex = 0; - for (let index = 1; index < groups.length; index += 1) { - if (groups[index].length < groups[targetIndex].length) { - targetIndex = index; - } - } - groups[targetIndex].push(...bucket.files); - } - - return groups.filter((group) => group.length > 0); - } - - const chunkCounts = buckets.map(() => 1); - let remainingChunks = groupCount - buckets.length; - while (remainingChunks > 0) { - let targetBucketIndex = -1; - let largestAverageChunkSize = 0; - - for (let index = 0; index < buckets.length; index += 1) { - if (chunkCounts[index] >= buckets[index].files.length) { - continue; - } - const averageChunkSize = buckets[index].files.length / chunkCounts[index]; - if (averageChunkSize > largestAverageChunkSize) { - largestAverageChunkSize = averageChunkSize; - targetBucketIndex = index; - } - } - - if (targetBucketIndex === -1) { - break; - } - - chunkCounts[targetBucketIndex] += 1; - remainingChunks -= 1; - } - - return buckets.flatMap((bucket, index) => - splitFilesIntoGroups(bucket.files, chunkCounts[index]), - ); -} - -function effectiveMaxSameRoleInstances(params: { - executionPolicy: ReviewTeamExecutionPolicy; - concurrencyPolicy: ReviewTeamConcurrencyPolicy; - reviewerMemberCount: number; -}): number { - const reviewerMemberCount = Math.max(1, params.reviewerMemberCount); - const maxPerRole = Math.floor( - params.concurrencyPolicy.maxParallelInstances / reviewerMemberCount, - ); - - return Math.max( - 1, - Math.min(params.executionPolicy.maxSameRoleInstances, Math.max(1, maxPerRole)), - ); -} - -function resolveReviewerPacketScopes( - target: ReviewTargetClassification, - executionPolicy: ReviewTeamExecutionPolicy, - concurrencyPolicy: ReviewTeamConcurrencyPolicy, - reviewerMemberCount: number, -): ReviewTeamWorkPacketScope[] { - const includedFiles = target.files - .filter((file) => !file.excluded) - .map((file) => file.normalizedPath); - const shouldSplit = - executionPolicy.reviewerFileSplitThreshold > 0 && - executionPolicy.maxSameRoleInstances > 1 && - includedFiles.length > executionPolicy.reviewerFileSplitThreshold; - - if (!shouldSplit) { - return [buildWorkPacketScopeFromFiles(target, includedFiles)]; - } - - const maxSameRoleInstances = effectiveMaxSameRoleInstances({ - executionPolicy, - concurrencyPolicy, - reviewerMemberCount, - }); - const groupCount = Math.min( - maxSameRoleInstances, - Math.ceil(includedFiles.length / executionPolicy.reviewerFileSplitThreshold), - ); - if (groupCount <= 1) { - return [buildWorkPacketScopeFromFiles(target, includedFiles)]; - } - - const fileGroups = splitFilesIntoModuleAwareGroups(includedFiles, groupCount); - return fileGroups.map((files, index) => - buildWorkPacketScopeFromFiles(target, files, { - index: index + 1, - count: fileGroups.length, - }), - ); -} - -function buildWorkPackets(params: { - reviewerMembers: ReviewTeamMember[]; - judgeMember?: ReviewTeamMember; - target: ReviewTargetClassification; - executionPolicy: ReviewTeamExecutionPolicy; - concurrencyPolicy: ReviewTeamConcurrencyPolicy; -}): ReviewTeamWorkPacket[] { - const reviewerScopes = resolveReviewerPacketScopes( - params.target, - params.executionPolicy, - params.concurrencyPolicy, - params.reviewerMembers.length, - ); - const fullScope = buildWorkPacketScopeFromFiles( - params.target, - params.target.files - .filter((file) => !file.excluded) - .map((file) => file.normalizedPath), - ); - const reviewerSeeds = params.reviewerMembers.flatMap((member) => - reviewerScopes.map((scope) => ({ member, scope })), - ); - const orderedReviewerSeeds = params.concurrencyPolicy.batchExtrasSeparately - ? [ - ...reviewerSeeds.filter((seed) => seed.member.source === 'core'), - ...reviewerSeeds.filter((seed) => seed.member.source === 'extra'), - ] - : reviewerSeeds; - const reviewerPackets = orderedReviewerSeeds.map((seed, index) => - buildWorkPacket({ - member: seed.member, - phase: 'reviewer', - launchBatch: - Math.floor(index / params.concurrencyPolicy.maxParallelInstances) + 1, - scope: seed.scope, - timeoutSeconds: params.executionPolicy.reviewerTimeoutSeconds, - }), - ); - const finalReviewerBatch = reviewerPackets.reduce( - (maxBatch, packet) => Math.max(maxBatch, packet.launchBatch), - 0, - ); - const judgePacket = params.judgeMember - ? [ - buildWorkPacket({ - member: params.judgeMember, - phase: 'judge', - launchBatch: finalReviewerBatch + 1, - scope: fullScope, - timeoutSeconds: params.executionPolicy.judgeTimeoutSeconds, - }), - ] - : []; - - return [...reviewerPackets, ...judgePacket]; -} - -const SHARED_CONTEXT_CACHE_ENTRY_LIMIT = 80; -const SHARED_CONTEXT_CACHE_RECOMMENDED_TOOLS: ReviewTeamSharedContextTool[] = [ - 'GetFileDiff', - 'Read', -]; - -function buildSharedContextCachePlan( - workPackets: ReviewTeamWorkPacket[] = [], -): ReviewTeamSharedContextCachePlan { - const fileContextByPath = new Map< - string, - { - path: string; - workspaceArea: string; - consumerPacketIds: string[]; - firstSeenIndex: number; - } - >(); - let nextSeenIndex = 0; - - for (const packet of workPackets) { - if (packet.phase !== 'reviewer') { - continue; - } - - for (const path of packet.assignedScope.files) { - let entry = fileContextByPath.get(path); - if (!entry) { - entry = { - path, - workspaceArea: workspaceAreaForReviewPath(path), - consumerPacketIds: [], - firstSeenIndex: nextSeenIndex, - }; - nextSeenIndex += 1; - fileContextByPath.set(path, entry); - } - if (!entry.consumerPacketIds.includes(packet.packetId)) { - entry.consumerPacketIds.push(packet.packetId); - } - } - } - - const repeatedFileContexts = Array.from(fileContextByPath.values()) - .filter((entry) => entry.consumerPacketIds.length > 1) - .sort((a, b) => a.firstSeenIndex - b.firstSeenIndex); - const entries = repeatedFileContexts - .slice(0, SHARED_CONTEXT_CACHE_ENTRY_LIMIT) - .map((entry, index) => ({ - cacheKey: `shared-context:${index + 1}`, - path: entry.path, - workspaceArea: entry.workspaceArea, - recommendedTools: [...SHARED_CONTEXT_CACHE_RECOMMENDED_TOOLS], - consumerPacketIds: entry.consumerPacketIds, - })); - - return { - source: 'work_packets', - strategy: 'reuse_readonly_file_context_by_cache_key', - entries, - omittedEntryCount: Math.max( - 0, - repeatedFileContexts.length - SHARED_CONTEXT_CACHE_ENTRY_LIMIT, - ), - }; -} - -const INCREMENTAL_REVIEW_CACHE_INVALIDATIONS: ReviewTeamIncrementalReviewCacheInvalidation[] = [ - 'target_file_set_changed', - 'target_line_count_changed', - 'target_tag_changed', - 'target_warning_changed', - 'reviewer_roster_changed', - 'strategy_changed', -]; - -function stableFingerprint(input: unknown): string { - const serialized = JSON.stringify(input); - let hash = 0x811c9dc5; - for (let index = 0; index < serialized.length; index += 1) { - hash ^= serialized.charCodeAt(index); - hash = Math.imul(hash, 0x01000193); - } - return (hash >>> 0).toString(16).padStart(8, '0'); -} - -function buildIncrementalReviewCachePlan(params: { - target: ReviewTargetClassification; - changeStats: ReviewTeamChangeStats; - strategyLevel: ReviewStrategyLevel; - workPackets: ReviewTeamWorkPacket[]; -}): ReviewTeamIncrementalReviewCachePlan { - const filePaths = params.target.files - .filter((file) => !file.excluded) - .map((file) => file.normalizedPath) - .sort((a, b) => a.localeCompare(b)); - const workspaceAreas = Array.from( - new Set(filePaths.map((file) => workspaceAreaForReviewPath(file))), - ).sort((a, b) => a.localeCompare(b)); - const targetTags = [...params.target.tags].sort((a, b) => a.localeCompare(b)); - const targetWarnings = params.target.warnings - .map((warning) => warning.code) - .sort((a, b) => a.localeCompare(b)); - const reviewerPacketIds = params.workPackets - .filter((packet) => packet.phase === 'reviewer') - .map((packet) => packet.packetId) - .sort((a, b) => a.localeCompare(b)); - const fingerprint = stableFingerprint({ - source: params.target.source, - resolution: params.target.resolution, - filePaths, - workspaceAreas, - targetTags, - targetWarnings, - lineCount: params.changeStats.totalLinesChanged ?? null, - lineCountSource: params.changeStats.lineCountSource, - reviewerPacketIds, - strategyLevel: params.strategyLevel, - }); - - return { - source: 'target_manifest', - strategy: 'reuse_completed_packets_when_fingerprint_matches', - cacheKey: `incremental-review:${fingerprint}`, - fingerprint, - filePaths, - workspaceAreas, - targetTags, - reviewerPacketIds, - ...(params.changeStats.totalLinesChanged !== undefined - ? { lineCount: params.changeStats.totalLinesChanged } - : {}), - lineCountSource: params.changeStats.lineCountSource, - invalidatesOn: [...INCREMENTAL_REVIEW_CACHE_INVALIDATIONS], - }; -} - -function predictTimeoutSeconds(params: { - role: 'reviewer' | 'judge'; - strategyLevel: ReviewStrategyLevel; - changeStats: ReviewTeamChangeStats; - reviewerCount: number; -}): number { - const totalLinesChanged = params.changeStats.totalLinesChanged ?? 0; - const base = PREDICTIVE_TIMEOUT_BASE_SECONDS[params.strategyLevel]; - const raw = - base + - params.changeStats.fileCount * PREDICTIVE_TIMEOUT_PER_FILE_SECONDS + - Math.floor(totalLinesChanged / 100) * - PREDICTIVE_TIMEOUT_PER_100_LINES_SECONDS; - const reviewerCount = Math.max(1, params.reviewerCount); - const multiplier = - params.role === 'judge' - ? 1 + Math.floor((reviewerCount - 1) / 3) - : 1; - - return Math.min(raw * multiplier, MAX_PREDICTIVE_TIMEOUT_SECONDS); -} - -function buildEffectiveExecutionPolicy(params: { - basePolicy: ReviewTeamExecutionPolicy; - strategyLevel: ReviewStrategyLevel; - target: ReviewTargetClassification; - changeStats: ReviewTeamChangeStats; - reviewerCount: number; -}): ReviewTeamExecutionPolicy { - if ( - params.target.resolution === 'unknown' && - params.changeStats.fileCount === 0 && - params.changeStats.totalLinesChanged === undefined - ) { - return params.basePolicy; - } - - const reviewerTimeoutSeconds = predictTimeoutSeconds({ - role: 'reviewer', - strategyLevel: params.strategyLevel, - changeStats: params.changeStats, - reviewerCount: params.reviewerCount, - }); - const judgeTimeoutSeconds = predictTimeoutSeconds({ - role: 'judge', - strategyLevel: params.strategyLevel, - changeStats: params.changeStats, - reviewerCount: params.reviewerCount, - }); - - return { - ...params.basePolicy, - reviewerTimeoutSeconds: - params.basePolicy.reviewerTimeoutSeconds === 0 - ? 0 - : Math.max( - params.basePolicy.reviewerTimeoutSeconds, - reviewerTimeoutSeconds, - ), - judgeTimeoutSeconds: - params.basePolicy.judgeTimeoutSeconds === 0 - ? 0 - : Math.max( - params.basePolicy.judgeTimeoutSeconds, - judgeTimeoutSeconds, - ), - }; -} - -function estimateChangedLinesForScope(params: { - scope: ReviewTeamWorkPacketScope; - changeStats: ReviewTeamChangeStats; - totalIncludedFileCount: number; -}): number { - if (params.changeStats.totalLinesChanged === undefined) { - return params.scope.fileCount * PROMPT_BYTE_ESTIMATE_UNKNOWN_LINES_PER_FILE; - } - - if (params.totalIncludedFileCount <= 0) { - return params.changeStats.totalLinesChanged; - } - - return Math.ceil( - params.changeStats.totalLinesChanged * - (params.scope.fileCount / params.totalIncludedFileCount), - ); -} - -function estimateReviewerPromptBytes(params: { - packet: ReviewTeamWorkPacket; - changeStats: ReviewTeamChangeStats; - totalIncludedFileCount: number; -}): number { - const pathBytes = params.packet.assignedScope.files.reduce( - (total, filePath) => total + filePath.length + 1, - 0, - ); - const estimatedChangedLines = estimateChangedLinesForScope({ - scope: params.packet.assignedScope, - changeStats: params.changeStats, - totalIncludedFileCount: params.totalIncludedFileCount, - }); - - return Math.ceil( - PROMPT_BYTE_ESTIMATE_BASE_BYTES + - pathBytes + - params.packet.assignedScope.fileCount * PROMPT_BYTE_ESTIMATE_PER_FILE_BYTES + - estimatedChangedLines * PROMPT_BYTE_ESTIMATE_PER_CHANGED_LINE_BYTES, - ); -} - -function estimateMaxReviewerPromptBytes(params: { - workPackets: ReviewTeamWorkPacket[]; - target: ReviewTargetClassification; - changeStats: ReviewTeamChangeStats; -}): number { - const reviewerPackets = params.workPackets.filter( - (packet) => packet.phase === 'reviewer', - ); - const totalIncludedFileCount = params.target.files.filter( - (file) => !file.excluded, - ).length; - - if (reviewerPackets.length === 0) { - return PROMPT_BYTE_ESTIMATE_BASE_BYTES; - } - - return Math.max( - ...reviewerPackets.map((packet) => - estimateReviewerPromptBytes({ - packet, - changeStats: params.changeStats, - totalIncludedFileCount, - }), - ), - ); -} - -function buildTokenBudgetPlan(params: { - mode: ReviewTokenBudgetMode; - activeReviewerCalls: number; - eligibleExtraReviewerCount: number; - maxExtraReviewers: number; - skippedReviewerIds: string[]; - target: ReviewTargetClassification; - changeStats: ReviewTeamChangeStats; - executionPolicy: ReviewTeamExecutionPolicy; - workPackets: ReviewTeamWorkPacket[]; -}): ReviewTeamTokenBudgetPlan { - const includedFileCount = params.target.files.filter( - (file) => !file.excluded, - ).length; - const fileSplitGuardrailActive = - params.executionPolicy.reviewerFileSplitThreshold > 0 && - includedFileCount > params.executionPolicy.reviewerFileSplitThreshold; - const maxPromptBytesPerReviewer = - TOKEN_BUDGET_PROMPT_BYTE_LIMIT_BY_MODE[params.mode]; - const estimatedPromptBytesPerReviewer = estimateMaxReviewerPromptBytes({ - workPackets: params.workPackets, - target: params.target, - changeStats: params.changeStats, - }); - const promptByteLimitExceeded = - estimatedPromptBytesPerReviewer > maxPromptBytesPerReviewer; - const largeDiffSummaryFirst = promptByteLimitExceeded; - const decisions: ReviewTeamTokenBudgetDecision[] = []; - const warnings: string[] = []; - - if (promptByteLimitExceeded) { - decisions.push({ - kind: 'summary_first_full_scope', - reason: 'prompt_bytes_exceeded', - detail: - `Estimated reviewer prompt ${estimatedPromptBytesPerReviewer} bytes exceeds ${maxPromptBytesPerReviewer} bytes for ${params.mode} budget; use summary-first while keeping every assigned_scope file visible.`, - }); - warnings.push( - 'Estimated reviewer prompt exceeds the selected token budget; use summary-first without hiding assigned files.', - ); - } - - if (params.skippedReviewerIds.length > 0) { - decisions.push({ - kind: 'skip_extra_reviewers', - reason: 'extra_reviewers_skipped', - detail: - 'Some extra reviewers were skipped by the selected token budget mode.', - affectedReviewerIds: [...params.skippedReviewerIds], - }); - warnings.push( - 'Some extra reviewers were skipped by the selected token budget mode.', - ); - } - - return { - mode: params.mode, - estimatedReviewerCalls: params.activeReviewerCalls, - maxReviewerCalls: - params.activeReviewerCalls + - Math.max(0, params.eligibleExtraReviewerCount - params.maxExtraReviewers), - maxExtraReviewers: params.maxExtraReviewers, - ...(fileSplitGuardrailActive - ? { maxFilesPerReviewer: params.executionPolicy.reviewerFileSplitThreshold } - : {}), - maxPromptBytesPerReviewer, - estimatedPromptBytesPerReviewer, - promptByteEstimateSource: 'manifest_heuristic', - promptByteLimitExceeded, - largeDiffSummaryFirst, - decisions, - skippedReviewerIds: params.skippedReviewerIds, - warnings, - }; -} - export function buildEffectiveReviewTeamManifest( team: ReviewTeam, options: ReviewTeamManifestOptions = {}, @@ -2283,6 +1259,7 @@ export function buildEffectiveReviewTeamManifest( options.rateLimitStatus, ); const strategyLevel = options.strategyOverride ?? team.strategyLevel; + const scopeProfile = buildDeepReviewScopeProfile(strategyLevel); const strategyRecommendation = recommendReviewStrategyForTarget(target, changeStats); const backendStrategyRecommendation = recommendBackendCompatibleStrategyForTarget( target, @@ -2344,6 +1321,12 @@ export function buildEffectiveReviewTeamManifest( executionPolicy, concurrencyPolicy, }); + const evidencePack = buildDeepReviewEvidencePack({ + target, + changeStats, + scopeProfile, + workPackets, + }); const sharedContextCache = buildSharedContextCachePlan(workPackets); const incrementalReviewCache = buildIncrementalReviewCachePlan({ target, @@ -2388,12 +1371,14 @@ export function buildEffectiveReviewTeamManifest( policySource: options.policySource ?? 'default-review-team-config', target, strategyLevel, + scopeProfile, strategyRecommendation, strategyDecision, executionPolicy, concurrencyPolicy, changeStats, preReviewSummary, + evidencePack, sharedContextCache, incrementalReviewCache, tokenBudget, @@ -2405,332 +1390,9 @@ export function buildEffectiveReviewTeamManifest( }; } -function formatResponsibilities(items: string[]): string { - return items.map((item) => ` - ${item}`).join('\n'); -} - -function formatStrategyImpact( - strategyLevel: ReviewStrategyLevel, - strategyProfiles: Record = REVIEW_STRATEGY_PROFILES, -): string { - const definition = strategyProfiles[strategyLevel]; - return `Token/time impact: approximately ${definition.tokenImpact} token usage and ${definition.runtimeImpact} runtime.`; -} - -function formatManifestList( - members: ReviewTeamManifestMember[], - emptyValue: string, -): string { - if (members.length === 0) { - return emptyValue; - } - - return members - .map((member) => - member.reason - ? `${member.subagentId}: ${member.reason}` - : member.subagentId, - ) - .join(', '); -} - -function workPacketToPromptPayload(packet: ReviewTeamWorkPacket) { - return { - packet_id: packet.packetId, - phase: packet.phase, - launch_batch: packet.launchBatch, - subagent_type: packet.subagentId, - display_name: packet.displayName, - role: packet.roleName, - assigned_scope: { - kind: packet.assignedScope.kind, - target_source: packet.assignedScope.targetSource, - target_resolution: packet.assignedScope.targetResolution, - target_tags: packet.assignedScope.targetTags, - file_count: packet.assignedScope.fileCount, - files: packet.assignedScope.files, - excluded_file_count: packet.assignedScope.excludedFileCount, - ...(packet.assignedScope.groupIndex !== undefined - ? { group_index: packet.assignedScope.groupIndex } - : {}), - ...(packet.assignedScope.groupCount !== undefined - ? { group_count: packet.assignedScope.groupCount } - : {}), - }, - allowed_tools: packet.allowedTools, - timeout_seconds: packet.timeoutSeconds, - required_output_fields: packet.requiredOutputFields, - strategy: packet.strategyLevel, - model_id: packet.model, - prompt_directive: packet.strategyDirective, - }; -} - -function formatWorkPacketBlock(workPackets: ReviewTeamWorkPacket[] = []): string { - if (workPackets.length === 0) { - return '- none'; - } - - return [ - '```json', - JSON.stringify(workPackets.map(workPacketToPromptPayload), null, 2), - '```', - ].join('\n'); -} - -function formatPreReviewSummaryBlock(summary: ReviewTeamPreReviewSummary): string { - return [ - 'Pre-generated diff summary:', - '```json', - JSON.stringify(summary, null, 2), - '```', - ].join('\n'); -} - -function sharedContextCacheToPromptPayload(plan: ReviewTeamSharedContextCachePlan) { - return { - source: plan.source, - strategy: plan.strategy, - omitted_entry_count: plan.omittedEntryCount, - entries: plan.entries.map((entry) => ({ - cache_key: entry.cacheKey, - path: entry.path, - workspace_area: entry.workspaceArea, - recommended_tools: entry.recommendedTools, - consumer_packet_ids: entry.consumerPacketIds, - })), - }; -} - -function formatSharedContextCacheBlock(plan: ReviewTeamSharedContextCachePlan): string { - return [ - 'Shared context cache plan:', - '```json', - JSON.stringify(sharedContextCacheToPromptPayload(plan), null, 2), - '```', - ].join('\n'); -} - -function incrementalReviewCacheToPromptPayload(plan: ReviewTeamIncrementalReviewCachePlan) { - return { - source: plan.source, - strategy: plan.strategy, - cache_key: plan.cacheKey, - fingerprint: plan.fingerprint, - file_paths: plan.filePaths, - workspace_areas: plan.workspaceAreas, - target_tags: plan.targetTags, - reviewer_packet_ids: plan.reviewerPacketIds, - ...(plan.lineCount !== undefined ? { line_count: plan.lineCount } : {}), - line_count_source: plan.lineCountSource, - invalidates_on: plan.invalidatesOn, - }; -} - -function formatIncrementalReviewCacheBlock(plan: ReviewTeamIncrementalReviewCachePlan): string { - return [ - 'Incremental review cache plan:', - '```json', - JSON.stringify(incrementalReviewCacheToPromptPayload(plan), null, 2), - '```', - ].join('\n'); -} - -function formatTokenBudgetDecisionKinds( - decisions: ReviewTeamTokenBudgetDecision[] = [], -): string { - return decisions.length > 0 - ? decisions.map((decision) => decision.kind).join(', ') - : 'none'; -} - export function buildReviewTeamPromptBlock( team: ReviewTeam, manifest = buildEffectiveReviewTeamManifest(team), ): string { - const activeSubagentIds = new Set([ - ...manifest.coreReviewers.map((member) => member.subagentId), - ...manifest.enabledExtraReviewers.map((member) => member.subagentId), - ...(manifest.qualityGateReviewer - ? [manifest.qualityGateReviewer.subagentId] - : []), - ]); - const activeManifestMembers = [ - ...manifest.coreReviewers, - ...(manifest.qualityGateReviewer ? [manifest.qualityGateReviewer] : []), - ...manifest.enabledExtraReviewers, - ]; - const manifestMemberBySubagentId = new Map( - activeManifestMembers.map((member) => [member.subagentId, member]), - ); - const members = team.members - .filter((member) => member.available && activeSubagentIds.has(member.subagentId)) - .map((member) => { - const manifestMember = - manifestMemberBySubagentId.get(member.subagentId) ?? toManifestMember(member); - return [ - `- ${manifestMember.displayName}`, - ` - subagent_type: ${manifestMember.subagentId}`, - ` - preferred_task_label: ${manifestMember.displayName}`, - ` - role: ${manifestMember.roleName}`, - ` - locked_core_role: ${manifestMember.locked ? 'yes' : 'no'}`, - ` - strategy: ${manifestMember.strategyLevel}`, - ` - strategy_source: ${manifestMember.strategySource}`, - ` - default_model_slot: ${manifestMember.defaultModelSlot}`, - ` - model: ${manifestMember.model || DEFAULT_REVIEW_TEAM_MODEL}`, - ` - model_id: ${manifestMember.model || DEFAULT_REVIEW_TEAM_MODEL}`, - ` - configured_model: ${manifestMember.configuredModel || manifestMember.model || DEFAULT_REVIEW_TEAM_MODEL}`, - ...(manifestMember.modelFallbackReason - ? [` - model_fallback: ${manifestMember.modelFallbackReason}`] - : []), - ` - prompt_directive: ${manifestMember.strategyDirective}`, - ' - responsibilities:', - formatResponsibilities(member.responsibilities), - ].join('\n'); - }) - .join('\n'); - const executionPolicy = [ - `- reviewer_timeout_seconds: ${manifest.executionPolicy.reviewerTimeoutSeconds}`, - `- judge_timeout_seconds: ${manifest.executionPolicy.judgeTimeoutSeconds}`, - `- reviewer_file_split_threshold: ${manifest.executionPolicy.reviewerFileSplitThreshold}`, - `- max_same_role_instances: ${manifest.executionPolicy.maxSameRoleInstances}`, - `- max_retries_per_role: ${manifest.executionPolicy.maxRetriesPerRole}`, - ].join('\n'); - const concurrencyPolicy = [ - `- max_parallel_instances: ${manifest.concurrencyPolicy.maxParallelInstances}`, - `- stagger_seconds: ${manifest.concurrencyPolicy.staggerSeconds}`, - `- max_queue_wait_seconds: ${manifest.concurrencyPolicy.maxQueueWaitSeconds}`, - `- batch_extras_separately: ${manifest.concurrencyPolicy.batchExtrasSeparately ? 'yes' : 'no'}`, - `- allow_provider_capacity_queue: ${manifest.concurrencyPolicy.allowProviderCapacityQueue ? 'yes' : 'no'}`, - `- allow_bounded_auto_retry: ${manifest.concurrencyPolicy.allowBoundedAutoRetry ? 'yes' : 'no'}`, - `- auto_retry_elapsed_guard_seconds: ${manifest.concurrencyPolicy.autoRetryElapsedGuardSeconds}`, - ].join('\n'); - const targetLineCount = - manifest.changeStats?.totalLinesChanged !== undefined - ? `${manifest.changeStats.totalLinesChanged}` - : 'unknown'; - const manifestBlock = [ - 'Run manifest:', - `- review_mode: ${manifest.reviewMode}`, - `- team_strategy: ${manifest.strategyLevel}`, - `- strategy_authority: ${manifest.strategyDecision.authority}`, - `- final_strategy: ${manifest.strategyDecision.finalStrategy}`, - `- frontend_recommended_strategy: ${manifest.strategyDecision.frontendRecommendation.strategyLevel}`, - `- backend_recommended_strategy: ${manifest.strategyDecision.backendRecommendation.strategyLevel}`, - `- strategy_user_override: ${manifest.strategyDecision.userOverride ?? 'none'}`, - `- strategy_mismatch: ${manifest.strategyDecision.mismatch ? 'yes' : 'no'}`, - `- strategy_mismatch_severity: ${manifest.strategyDecision.mismatchSeverity}`, - `- max_cyclomatic_complexity_delta: ${manifest.strategyDecision.backendRecommendation.factors.maxCyclomaticComplexityDelta}`, - `- max_cyclomatic_complexity_delta_source: ${manifest.strategyDecision.backendRecommendation.factors.maxCyclomaticComplexityDeltaSource}`, - ...(manifest.strategyRecommendation - ? [ - `- recommended_strategy: ${manifest.strategyRecommendation.strategyLevel}`, - `- strategy_recommendation_score: ${manifest.strategyRecommendation.score}`, - `- strategy_recommendation_rationale: ${manifest.strategyRecommendation.rationale}`, - ] - : []), - `- workspace_path: ${manifest.workspacePath || 'inherited from current session'}`, - `- policy_source: ${manifest.policySource}`, - `- target_source: ${manifest.target.source}`, - `- target_resolution: ${manifest.target.resolution}`, - `- target_tags: ${manifest.target.tags.join(', ') || 'none'}`, - `- target_warnings: ${manifest.target.warnings.map((warning) => warning.code).join(', ') || 'none'}`, - `- target_file_count: ${manifest.changeStats?.fileCount ?? manifest.target.files.length}`, - `- target_line_count: ${targetLineCount}`, - `- target_line_count_source: ${manifest.changeStats?.lineCountSource ?? 'unknown'}`, - `- token_budget_mode: ${manifest.tokenBudget.mode}`, - `- estimated_reviewer_calls: ${manifest.tokenBudget.estimatedReviewerCalls}`, - `- max_prompt_bytes_per_reviewer: ${manifest.tokenBudget.maxPromptBytesPerReviewer ?? 'none'}`, - `- estimated_prompt_bytes_per_reviewer: ${manifest.tokenBudget.estimatedPromptBytesPerReviewer ?? 'unknown'}`, - `- prompt_byte_estimate_source: ${manifest.tokenBudget.promptByteEstimateSource ?? 'none'}`, - `- prompt_byte_limit_exceeded: ${manifest.tokenBudget.promptByteLimitExceeded ? 'yes' : 'no'}`, - `- token_budget_decisions: ${formatTokenBudgetDecisionKinds(manifest.tokenBudget.decisions)}`, - `- budget_limited_reviewers: ${manifest.tokenBudget.skippedReviewerIds.join(', ') || 'none'}`, - `- core_reviewers: ${formatManifestList(manifest.coreReviewers, 'none')}`, - `- quality_gate_reviewer: ${manifest.qualityGateReviewer?.subagentId || 'none'}`, - `- enabled_extra_reviewers: ${formatManifestList(manifest.enabledExtraReviewers, 'none')}`, - '- skipped_reviewers:', - ...(manifest.skippedReviewers.length > 0 - ? manifest.skippedReviewers.map( - (member) => ` - ${member.subagentId}: ${member.reason || 'skipped'}`, - ) - : [' - none']), - ].join('\n'); - const strategyProfiles = team.definition?.strategyProfiles ?? REVIEW_STRATEGY_PROFILES; - const strategyRules = REVIEW_STRATEGY_LEVELS.map((level) => { - const definition = strategyProfiles[level]; - const roleEntries = Object.entries(definition.roleDirectives) as [ReviewRoleDirectiveKey, string][]; - const roleLines = roleEntries.map( - ([role, directive]) => ` - ${role}: ${directive}`, - ); - return [ - `- ${level}: ${definition.summary}`, - ` - ${formatStrategyImpact(level, strategyProfiles)}`, - ` - Default model slot: ${definition.defaultModelSlot}`, - ` - Prompt directive (fallback): ${definition.promptDirective}`, - ` - Role-specific directives:`, - ...roleLines, - ].join('\n'); - }).join('\n'); - const commonStrategyRules = REVIEW_STRATEGY_COMMON_RULES.reviewerPromptRules - .map((rule) => `- ${rule}`) - .join('\n'); - - return [ - manifestBlock, - formatPreReviewSummaryBlock(manifest.preReviewSummary), - formatSharedContextCacheBlock(manifest.sharedContextCache), - formatIncrementalReviewCacheBlock(manifest.incrementalReviewCache), - 'Review work packets:', - formatWorkPacketBlock(manifest.workPackets), - 'Work packet rules:', - '- Each reviewer Task prompt must include the matching work packet verbatim.', - '- Include the packet_id in each Task description, for example "Security review [packet reviewer:ReviewSecurity:group-1-of-3]".', - '- Each reviewer and judge response must echo packet_id and set status to completed, partial_timeout, timed_out, cancelled_by_user, failed, or skipped.', - '- If the reviewer reports packet_id itself, mark reviewers[].packet_status_source as reported in the final submit_code_review payload.', - '- If the reviewer omits packet_id but the Task was launched from a packet, infer the packet_id from the Task description or work packet and mark packet_status_source as inferred.', - '- If packet_id cannot be reported or inferred, mark packet_status_source as missing and explain the confidence impact in coverage_notes.', - '- If a reviewer response is missing packet_id or status, the judge must treat that reviewer output as lower confidence instead of discarding the whole review.', - '- Use the pre-generated diff summary for initial orientation and token discipline, but verify claims against assigned files or diffs before reporting findings.', - '- When prompt_byte_limit_exceeded is yes, use the pre-generated diff summary before detailed reads. Do not remove files from assigned_scope or hide unreviewed files; if a file cannot be covered, report it in coverage_notes and reliability_signals.', - '- Use shared_context_cache entries to reuse read-only GetFileDiff/Read context by cache_key across reviewer packets. Do not duplicate full-file reads when a reusable cached diff or file summary already covers the same path.', - '- Use incremental_review_cache only when the target fingerprint matches a prior run; preserve completed reviewer outputs by packet_id and rerun only missing, failed, timed-out, or stale packets. If any invalidates_on condition changed, ignore the cache and explain the fresh review boundary.', - '- The assigned_scope is the default scope for that packet; only widen it when a critical cross-file dependency requires it and note the reason in coverage_notes.', - 'Configured code review team:', - members || '- No team members available.', - 'Execution policy:', - executionPolicy, - 'Concurrency policy:', - concurrencyPolicy, - 'Team execution rules:', - '- Run only reviewers listed in core_reviewers and enabled_extra_reviewers.', - '- Do not launch skipped_reviewers.', - '- If a skipped reviewer has reason not_applicable, mention it in coverage notes without treating it as reduced confidence.', - '- If a skipped reviewer has reason budget_limited, mention the budget mode and the coverage tradeoff.', - '- If a skipped reviewer has reason invalid_tooling, report it as a configuration issue and do not reduce confidence in the reviewers that did run.', - '- If target_resolution is unknown, conditional reviewers may be activated conservatively; report that as coverage context.', - `- Run the active core reviewer roles first: ${formatManifestList(manifest.coreReviewers, 'none')}.`, - '- Launch reviewer Tasks by launch_batch. Do not launch a later reviewer batch until every reviewer Task in the earlier batch has completed, failed, timed out, or returned partial_timeout.', - '- Never launch more reviewer Tasks in one batch than max_parallel_instances. If stagger_seconds is greater than 0, wait that many seconds before starting the next launch_batch.', - '- Run ReviewJudge only after the reviewer batch finishes, as the quality-gate pass.', - '- If other extra reviewers are configured and enabled, run them in parallel with the locked reviewers whenever possible.', - '- When a configured member entry provides model_id, pass model_id with that value to the matching Task call.', - '- If reviewer_timeout_seconds is greater than 0, pass timeout_seconds with that value to every reviewer Task call.', - '- If judge_timeout_seconds is greater than 0, pass timeout_seconds with that value to the ReviewJudge Task call.', - '- If a reviewer Task returns status partial_timeout, treat its output as partial evidence: preserve it in reviewers[].partial_output, mark the reviewer status partial_timeout, and mention the confidence impact in coverage_notes.', - '- If a reviewer fails or times out without useful partial output, retry that same reviewer at most max_retries_per_role times: reduce its scope, downgrade strategy by one level when possible, use a shorter timeout, and set retry to true on the retry Task call.', - '- In the final submit_code_review payload, populate reliability_signals for context_pressure, compression_preserved, partial_reviewer, and user_decision when those conditions apply. Use severity info/warning/action, count when useful, and source runtime/manifest/report/inferred.', - '- If reviewer_file_split_threshold is greater than 0 and the target file count exceeds it, split files across multiple same-role reviewer instances only up to the concurrency-capped max_same_role_instances for this run.', - '- Prefer module/workspace-area coherent file groups when splitting reviewer work; avoid mixing unrelated workspace areas in the same packet when the group budget allows it.', - '- When file splitting is active, each same-role instance must only review its assigned file group. Label instances in the Task description with both group and packet_id (e.g. "Security review [group 1/3] [packet reviewer:ReviewSecurity:group-1-of-3]").', - '- Do not run ReviewFixer during the review pass.', - '- Wait for explicit user approval before starting any remediation.', - '- The Review Quality Inspector acts as a third-party arbiter: it primarily examines reviewer reports for logical consistency and evidence quality, and only uses code inspection tools for targeted spot-checks when a specific claim needs verification.', - 'Review strategy rules:', - `- Team strategy: ${manifest.strategyLevel}. ${formatStrategyImpact(manifest.strategyLevel, strategyProfiles)}`, - '- Risk recommendation is advisory; follow team_strategy, member strategy fields, and work-packet strategy for this run unless the user explicitly changes strategy.', - commonStrategyRules, - 'Review strategy profiles:', - strategyRules, - ].join('\n'); + return buildReviewTeamPromptBlockContent(team, manifest); } diff --git a/src/web-ui/src/shared/services/review-team/manifestMembers.ts b/src/web-ui/src/shared/services/review-team/manifestMembers.ts new file mode 100644 index 000000000..5412baecf --- /dev/null +++ b/src/web-ui/src/shared/services/review-team/manifestMembers.ts @@ -0,0 +1,99 @@ +import { DEFAULT_REVIEW_MEMBER_STRATEGY_LEVEL, DEFAULT_REVIEW_TEAM_MODEL } from './defaults'; +import { getReviewStrategyProfile } from './strategy'; +import type { + ReviewModelFallbackReason, + ReviewRoleDirectiveKey, + ReviewStrategyLevel, + ReviewTeamManifestMember, + ReviewTeamMember, +} from './types'; + +// Centralizes member-to-manifest projection so strategy overrides and model +// fallback semantics stay identical across prompt blocks and work packets. +export function toManifestMember( + member: ReviewTeamMember, + reason?: ReviewTeamManifestMember['reason'], +): ReviewTeamManifestMember { + const strategyProfile = getReviewStrategyProfile(member.strategyLevel); + const roleDirective = + strategyProfile.roleDirectives[member.subagentId as ReviewRoleDirectiveKey]; + return { + subagentId: member.subagentId, + displayName: member.displayName, + roleName: member.roleName, + model: member.model || DEFAULT_REVIEW_TEAM_MODEL, + configuredModel: member.configuredModel || member.model || DEFAULT_REVIEW_TEAM_MODEL, + modelFallbackReason: member.modelFallbackReason, + defaultModelSlot: member.defaultModelSlot ?? strategyProfile.defaultModelSlot, + strategyLevel: member.strategyLevel, + strategySource: member.strategySource, + strategyDirective: + member.strategyDirective || roleDirective || strategyProfile.promptDirective, + locked: member.locked, + source: member.source, + subagentSource: member.subagentSource, + ...(reason ? { reason } : {}), + }; +} + +function resolveManifestMemberModelForStrategy( + member: ReviewTeamMember, + strategyLevel: ReviewStrategyLevel, +): { + model: string; + configuredModel: string; + modelFallbackReason?: ReviewModelFallbackReason; +} { + const strategyProfile = getReviewStrategyProfile(strategyLevel); + + if (member.modelFallbackReason === 'model_removed') { + return { + model: strategyProfile.defaultModelSlot, + configuredModel: member.configuredModel, + modelFallbackReason: member.modelFallbackReason, + }; + } + + const configuredModel = + member.configuredModel?.trim() || member.model?.trim() || DEFAULT_REVIEW_TEAM_MODEL; + if ( + !configuredModel || + configuredModel === 'fast' || + configuredModel === 'primary' + ) { + return { + model: strategyProfile.defaultModelSlot, + configuredModel: configuredModel || strategyProfile.defaultModelSlot, + }; + } + + return { + model: configuredModel, + configuredModel, + }; +} + +export function applyTeamStrategyOverrideToMember( + member: ReviewTeamMember, + strategyLevel: ReviewStrategyLevel, +): ReviewTeamMember { + if (member.strategySource === 'member' || member.strategyLevel === strategyLevel) { + return member; + } + + const strategyProfile = getReviewStrategyProfile(strategyLevel); + const model = resolveManifestMemberModelForStrategy(member, strategyLevel); + return { + ...member, + model: model.model, + configuredModel: model.configuredModel, + modelFallbackReason: model.modelFallbackReason, + strategyOverride: DEFAULT_REVIEW_MEMBER_STRATEGY_LEVEL, + strategyLevel, + strategySource: 'team', + defaultModelSlot: strategyProfile.defaultModelSlot, + strategyDirective: + strategyProfile.roleDirectives[member.subagentId as ReviewRoleDirectiveKey] || + strategyProfile.promptDirective, + }; +} diff --git a/src/web-ui/src/shared/services/review-team/pathMetadata.ts b/src/web-ui/src/shared/services/review-team/pathMetadata.ts new file mode 100644 index 000000000..3ba2d41ce --- /dev/null +++ b/src/web-ui/src/shared/services/review-team/pathMetadata.ts @@ -0,0 +1,72 @@ +import type { ReviewTargetClassification } from '../reviewTargetClassifier'; + +// Content-free path metadata shared by risk, cache, summary, and evidence builders. +// Keep this module independent from manifest construction to avoid circular policy flow. + +const SECURITY_SENSITIVE_PATH_PATTERN = + /(^|[/._-])(auth|oauth|crypto|security|permission|permissions|secret|secrets|token|tokens|credential|credentials)([/._-]|$)/; + +export interface WorkspaceAreaFileBucket { + key: string; + index: number; + files: string[]; +} + +export function isSecuritySensitiveReviewPath(normalizedPath: string): boolean { + return SECURITY_SENSITIVE_PATH_PATTERN.test(normalizedPath.toLowerCase()); +} + +export function workspaceAreaForReviewPath(normalizedPath: string): string { + const crateMatch = normalizedPath.match(/^src\/crates\/([^/]+)/); + if (crateMatch) { + return `crate:${crateMatch[1]}`; + } + + const appMatch = normalizedPath.match(/^src\/apps\/([^/]+)/); + if (appMatch) { + return `app:${appMatch[1]}`; + } + + if (normalizedPath.startsWith('src/web-ui/')) { + return 'web-ui'; + } + + if (normalizedPath.startsWith('BitFun-Installer/')) { + return 'installer'; + } + + const [root] = normalizedPath.split('/'); + return root || 'unknown'; +} + +export function groupFilesByWorkspaceArea(files: string[]): WorkspaceAreaFileBucket[] { + const buckets: WorkspaceAreaFileBucket[] = []; + const bucketByKey = new Map(); + + for (const file of files) { + const key = workspaceAreaForReviewPath(file); + let bucket = bucketByKey.get(key); + if (!bucket) { + bucket = { + key, + index: buckets.length, + files: [], + }; + buckets.push(bucket); + bucketByKey.set(key, bucket); + } + bucket.files.push(file); + } + + return buckets; +} + +export function includedReviewTargetFiles(target: ReviewTargetClassification): string[] { + return target.files + .filter((file) => !file.excluded) + .map((file) => file.normalizedPath); +} + +export function pluralize(count: number, singular: string): string { + return `${count} ${singular}${count === 1 ? '' : 's'}`; +} diff --git a/src/web-ui/src/shared/services/review-team/preReviewSummary.ts b/src/web-ui/src/shared/services/review-team/preReviewSummary.ts new file mode 100644 index 000000000..9481ef1c5 --- /dev/null +++ b/src/web-ui/src/shared/services/review-team/preReviewSummary.ts @@ -0,0 +1,61 @@ +import type { ReviewTargetClassification } from '../reviewTargetClassifier'; +import type { + ReviewTeamChangeStats, + ReviewTeamPreReviewSummary, +} from './types'; +import { + groupFilesByWorkspaceArea, + includedReviewTargetFiles, + pluralize, +} from './pathMetadata'; + +const PRE_REVIEW_SUMMARY_SAMPLE_FILE_LIMIT = 3; +const PRE_REVIEW_SUMMARY_AREA_LIMIT = 8; + +export function buildPreReviewSummary( + target: ReviewTargetClassification, + changeStats: ReviewTeamChangeStats, +): ReviewTeamPreReviewSummary { + const includedFiles = includedReviewTargetFiles(target); + const excludedFileCount = target.files.length - includedFiles.length; + const allWorkspaceAreas = groupFilesByWorkspaceArea(includedFiles) + .sort((a, b) => b.files.length - a.files.length || a.index - b.index); + const workspaceAreas = allWorkspaceAreas + .slice(0, PRE_REVIEW_SUMMARY_AREA_LIMIT) + .map((area) => ({ + key: area.key, + fileCount: area.files.length, + sampleFiles: area.files.slice(0, PRE_REVIEW_SUMMARY_SAMPLE_FILE_LIMIT), + })); + const lineCount = changeStats.totalLinesChanged; + const lineCountLabel = + lineCount === undefined + ? 'unknown changed lines' + : `${lineCount} changed lines`; + const areaLabel = workspaceAreas.length > 0 + ? workspaceAreas.map((area) => `${area.key} (${area.fileCount})`).join(', ') + : 'no resolved workspace area'; + const targetTags = [...target.tags]; + const tagLabel = targetTags.filter((tag) => tag !== 'unknown').join(', ') || 'unknown'; + const omittedAreaCount = Math.max( + 0, + allWorkspaceAreas.length - workspaceAreas.length, + ); + const summaryParts = [ + `${pluralize(changeStats.fileCount, 'file')}, ${lineCountLabel} across ${pluralize(allWorkspaceAreas.length, 'workspace area')}: ${areaLabel}`, + `tags: ${tagLabel}`, + omittedAreaCount > 0 ? `${pluralize(omittedAreaCount, 'workspace area')} omitted from summary` : undefined, + ].filter(Boolean); + + return { + source: 'target_manifest', + summary: summaryParts.join('; '), + fileCount: changeStats.fileCount, + excludedFileCount, + ...(lineCount !== undefined ? { lineCount } : {}), + lineCountSource: changeStats.lineCountSource, + targetTags, + workspaceAreas, + warnings: target.warnings.map((warning) => warning.code), + }; +} diff --git a/src/web-ui/src/shared/services/review-team/promptBlock.ts b/src/web-ui/src/shared/services/review-team/promptBlock.ts new file mode 100644 index 000000000..bff451827 --- /dev/null +++ b/src/web-ui/src/shared/services/review-team/promptBlock.ts @@ -0,0 +1,434 @@ +import { DEFAULT_REVIEW_TEAM_MODEL } from './defaults'; +import { + REVIEW_STRATEGY_COMMON_RULES, + REVIEW_STRATEGY_LEVELS, + REVIEW_STRATEGY_PROFILES, +} from './strategy'; +import { toManifestMember } from './manifestMembers'; +import type { + DeepReviewEvidencePack, + DeepReviewScopeProfile, + ReviewRoleDirectiveKey, + ReviewStrategyLevel, + ReviewStrategyProfile, + ReviewTeam, + ReviewTeamIncrementalReviewCachePlan, + ReviewTeamManifestMember, + ReviewTeamPreReviewSummary, + ReviewTeamRunManifest, + ReviewTeamSharedContextCachePlan, + ReviewTeamTokenBudgetDecision, + ReviewTeamWorkPacket, +} from './types'; + +// Prompt formatting consumes an already-built manifest. Keep launch policy and +// side effects in the manifest/service layers so this stays deterministic. +function formatResponsibilities(items: string[]): string { + return items.map((item) => ` - ${item}`).join('\n'); +} + +function formatStrategyImpact( + strategyLevel: ReviewStrategyLevel, + strategyProfiles: Record = REVIEW_STRATEGY_PROFILES, +): string { + const definition = strategyProfiles[strategyLevel]; + return `Token/time impact: approximately ${definition.tokenImpact} token usage and ${definition.runtimeImpact} runtime.`; +} + +function formatManifestList( + members: ReviewTeamManifestMember[], + emptyValue: string, +): string { + if (members.length === 0) { + return emptyValue; + } + + return members + .map((member) => + member.reason + ? `${member.subagentId}: ${member.reason}` + : member.subagentId, + ) + .join(', '); +} + +function workPacketToPromptPayload(packet: ReviewTeamWorkPacket) { + return { + packet_id: packet.packetId, + phase: packet.phase, + launch_batch: packet.launchBatch, + subagent_type: packet.subagentId, + display_name: packet.displayName, + role: packet.roleName, + assigned_scope: { + kind: packet.assignedScope.kind, + target_source: packet.assignedScope.targetSource, + target_resolution: packet.assignedScope.targetResolution, + target_tags: packet.assignedScope.targetTags, + file_count: packet.assignedScope.fileCount, + files: packet.assignedScope.files, + excluded_file_count: packet.assignedScope.excludedFileCount, + ...(packet.assignedScope.groupIndex !== undefined + ? { group_index: packet.assignedScope.groupIndex } + : {}), + ...(packet.assignedScope.groupCount !== undefined + ? { group_count: packet.assignedScope.groupCount } + : {}), + }, + allowed_tools: packet.allowedTools, + timeout_seconds: packet.timeoutSeconds, + required_output_fields: packet.requiredOutputFields, + strategy: packet.strategyLevel, + model_id: packet.model, + prompt_directive: packet.strategyDirective, + }; +} + +function formatWorkPacketBlock(workPackets: ReviewTeamWorkPacket[] = []): string { + if (workPackets.length === 0) { + return '- none'; + } + + return [ + '```json', + JSON.stringify(workPackets.map(workPacketToPromptPayload), null, 2), + '```', + ].join('\n'); +} + +function formatPreReviewSummaryBlock(summary: ReviewTeamPreReviewSummary): string { + return [ + 'Pre-generated diff summary:', + '```json', + JSON.stringify(summary, null, 2), + '```', + ].join('\n'); +} + +function evidencePackToPromptPayload(pack: DeepReviewEvidencePack) { + return { + version: pack.version, + source: pack.source, + changed_files: pack.changedFiles, + diff_stat: { + file_count: pack.diffStat.fileCount, + ...(pack.diffStat.totalChangedLines !== undefined + ? { total_changed_lines: pack.diffStat.totalChangedLines } + : {}), + line_count_source: pack.diffStat.lineCountSource, + }, + domain_tags: pack.domainTags, + risk_focus_tags: pack.riskFocusTags, + packet_ids: pack.packetIds, + hunk_hints: pack.hunkHints.map((hint) => ({ + file_path: hint.filePath, + changed_line_count: hint.changedLineCount, + line_count_source: hint.lineCountSource, + })), + contract_hints: pack.contractHints.map((hint) => ({ + kind: hint.kind, + file_path: hint.filePath, + source: hint.source, + })), + budget: { + max_changed_files: pack.budget.maxChangedFiles, + max_hunk_hints: pack.budget.maxHunkHints, + max_contract_hints: pack.budget.maxContractHints, + omitted_changed_file_count: pack.budget.omittedChangedFileCount, + omitted_hunk_hint_count: pack.budget.omittedHunkHintCount, + omitted_contract_hint_count: pack.budget.omittedContractHintCount, + }, + privacy: pack.privacy, + }; +} + +function formatEvidencePackBlock(pack?: DeepReviewEvidencePack): string { + if (!pack) { + return [ + 'Evidence pack:', + '- none', + ].join('\n'); + } + + return [ + 'Evidence pack:', + '```json', + JSON.stringify(evidencePackToPromptPayload(pack), null, 2), + '```', + '- Evidence pack hunk_hints and contract_hints are orientation only; verify each hinted claim with GetFileDiff, Read, Grep, or Git before reporting it.', + '- The evidence pack privacy boundary is metadata_only. Do not treat it as source text, a full diff, model output, or provider raw data.', + ].join('\n'); +} + +function sharedContextCacheToPromptPayload(plan: ReviewTeamSharedContextCachePlan) { + return { + source: plan.source, + strategy: plan.strategy, + omitted_entry_count: plan.omittedEntryCount, + entries: plan.entries.map((entry) => ({ + cache_key: entry.cacheKey, + path: entry.path, + workspace_area: entry.workspaceArea, + recommended_tools: entry.recommendedTools, + consumer_packet_ids: entry.consumerPacketIds, + })), + }; +} + +function formatSharedContextCacheBlock(plan: ReviewTeamSharedContextCachePlan): string { + return [ + 'Shared context cache plan:', + '```json', + JSON.stringify(sharedContextCacheToPromptPayload(plan), null, 2), + '```', + ].join('\n'); +} + +function formatScopeProfileBlock(profile?: DeepReviewScopeProfile): string { + if (!profile) { + return [ + 'Scope profile:', + '- none', + ].join('\n'); + } + + return [ + 'Scope profile:', + `- review_depth: ${profile.reviewDepth}`, + `- risk_focus_tags: ${profile.riskFocusTags.join(', ') || 'none'}`, + `- max_dependency_hops: ${profile.maxDependencyHops}`, + `- optional_reviewer_policy: ${profile.optionalReviewerPolicy}`, + `- allow_broad_tool_exploration: ${profile.allowBroadToolExploration ? 'yes' : 'no'}`, + `- coverage_expectation: ${profile.coverageExpectation}`, + '- Reduced-depth profiles are not full-depth coverage. Keep changed files visible in coverage notes and do not describe quick or normal runs as full-depth reviews.', + '- Reviewers and the judge must carry review_depth and coverage_expectation into their summaries. If review_depth is high_risk_only or risk_expanded, populate reliability_signals with reduced_scope in the final submit_code_review payload.', + ].join('\n'); +} + +function incrementalReviewCacheToPromptPayload(plan: ReviewTeamIncrementalReviewCachePlan) { + return { + source: plan.source, + strategy: plan.strategy, + cache_key: plan.cacheKey, + fingerprint: plan.fingerprint, + file_paths: plan.filePaths, + workspace_areas: plan.workspaceAreas, + target_tags: plan.targetTags, + reviewer_packet_ids: plan.reviewerPacketIds, + ...(plan.lineCount !== undefined ? { line_count: plan.lineCount } : {}), + line_count_source: plan.lineCountSource, + invalidates_on: plan.invalidatesOn, + }; +} + +function formatIncrementalReviewCacheBlock(plan: ReviewTeamIncrementalReviewCachePlan): string { + return [ + 'Incremental review cache plan:', + '```json', + JSON.stringify(incrementalReviewCacheToPromptPayload(plan), null, 2), + '```', + ].join('\n'); +} + +function formatTokenBudgetDecisionKinds( + decisions: ReviewTeamTokenBudgetDecision[] = [], +): string { + return decisions.length > 0 + ? decisions.map((decision) => decision.kind).join(', ') + : 'none'; +} + +export function buildReviewTeamPromptBlockContent( + team: ReviewTeam, + manifest: ReviewTeamRunManifest, +): string { + const activeSubagentIds = new Set([ + ...manifest.coreReviewers.map((member) => member.subagentId), + ...manifest.enabledExtraReviewers.map((member) => member.subagentId), + ...(manifest.qualityGateReviewer + ? [manifest.qualityGateReviewer.subagentId] + : []), + ]); + const activeManifestMembers = [ + ...manifest.coreReviewers, + ...(manifest.qualityGateReviewer ? [manifest.qualityGateReviewer] : []), + ...manifest.enabledExtraReviewers, + ]; + const manifestMemberBySubagentId = new Map( + activeManifestMembers.map((member) => [member.subagentId, member]), + ); + const members = team.members + .filter((member) => member.available && activeSubagentIds.has(member.subagentId)) + .map((member) => { + const manifestMember = + manifestMemberBySubagentId.get(member.subagentId) ?? toManifestMember(member); + return [ + `- ${manifestMember.displayName}`, + ` - subagent_type: ${manifestMember.subagentId}`, + ` - preferred_task_label: ${manifestMember.displayName}`, + ` - role: ${manifestMember.roleName}`, + ` - locked_core_role: ${manifestMember.locked ? 'yes' : 'no'}`, + ` - strategy: ${manifestMember.strategyLevel}`, + ` - strategy_source: ${manifestMember.strategySource}`, + ` - default_model_slot: ${manifestMember.defaultModelSlot}`, + ` - model: ${manifestMember.model || DEFAULT_REVIEW_TEAM_MODEL}`, + ` - model_id: ${manifestMember.model || DEFAULT_REVIEW_TEAM_MODEL}`, + ` - configured_model: ${manifestMember.configuredModel || manifestMember.model || DEFAULT_REVIEW_TEAM_MODEL}`, + ...(manifestMember.modelFallbackReason + ? [` - model_fallback: ${manifestMember.modelFallbackReason}`] + : []), + ` - prompt_directive: ${manifestMember.strategyDirective}`, + ' - responsibilities:', + formatResponsibilities(member.responsibilities), + ].join('\n'); + }) + .join('\n'); + const executionPolicy = [ + `- reviewer_timeout_seconds: ${manifest.executionPolicy.reviewerTimeoutSeconds}`, + `- judge_timeout_seconds: ${manifest.executionPolicy.judgeTimeoutSeconds}`, + `- reviewer_file_split_threshold: ${manifest.executionPolicy.reviewerFileSplitThreshold}`, + `- max_same_role_instances: ${manifest.executionPolicy.maxSameRoleInstances}`, + `- max_retries_per_role: ${manifest.executionPolicy.maxRetriesPerRole}`, + ].join('\n'); + const concurrencyPolicy = [ + `- max_parallel_instances: ${manifest.concurrencyPolicy.maxParallelInstances}`, + `- stagger_seconds: ${manifest.concurrencyPolicy.staggerSeconds}`, + `- max_queue_wait_seconds: ${manifest.concurrencyPolicy.maxQueueWaitSeconds}`, + `- batch_extras_separately: ${manifest.concurrencyPolicy.batchExtrasSeparately ? 'yes' : 'no'}`, + `- allow_provider_capacity_queue: ${manifest.concurrencyPolicy.allowProviderCapacityQueue ? 'yes' : 'no'}`, + `- allow_bounded_auto_retry: ${manifest.concurrencyPolicy.allowBoundedAutoRetry ? 'yes' : 'no'}`, + `- auto_retry_elapsed_guard_seconds: ${manifest.concurrencyPolicy.autoRetryElapsedGuardSeconds}`, + ].join('\n'); + const targetLineCount = + manifest.changeStats?.totalLinesChanged !== undefined + ? `${manifest.changeStats.totalLinesChanged}` + : 'unknown'; + const manifestBlock = [ + 'Run manifest:', + `- review_mode: ${manifest.reviewMode}`, + `- team_strategy: ${manifest.strategyLevel}`, + `- strategy_authority: ${manifest.strategyDecision.authority}`, + `- final_strategy: ${manifest.strategyDecision.finalStrategy}`, + `- frontend_recommended_strategy: ${manifest.strategyDecision.frontendRecommendation.strategyLevel}`, + `- backend_recommended_strategy: ${manifest.strategyDecision.backendRecommendation.strategyLevel}`, + `- strategy_user_override: ${manifest.strategyDecision.userOverride ?? 'none'}`, + `- strategy_mismatch: ${manifest.strategyDecision.mismatch ? 'yes' : 'no'}`, + `- strategy_mismatch_severity: ${manifest.strategyDecision.mismatchSeverity}`, + `- max_cyclomatic_complexity_delta: ${manifest.strategyDecision.backendRecommendation.factors.maxCyclomaticComplexityDelta}`, + `- max_cyclomatic_complexity_delta_source: ${manifest.strategyDecision.backendRecommendation.factors.maxCyclomaticComplexityDeltaSource}`, + ...(manifest.strategyRecommendation + ? [ + `- recommended_strategy: ${manifest.strategyRecommendation.strategyLevel}`, + `- strategy_recommendation_score: ${manifest.strategyRecommendation.score}`, + `- strategy_recommendation_rationale: ${manifest.strategyRecommendation.rationale}`, + ] + : []), + `- workspace_path: ${manifest.workspacePath || 'inherited from current session'}`, + `- policy_source: ${manifest.policySource}`, + `- target_source: ${manifest.target.source}`, + `- target_resolution: ${manifest.target.resolution}`, + `- target_tags: ${manifest.target.tags.join(', ') || 'none'}`, + `- target_warnings: ${manifest.target.warnings.map((warning) => warning.code).join(', ') || 'none'}`, + `- target_file_count: ${manifest.changeStats?.fileCount ?? manifest.target.files.length}`, + `- target_line_count: ${targetLineCount}`, + `- target_line_count_source: ${manifest.changeStats?.lineCountSource ?? 'unknown'}`, + `- token_budget_mode: ${manifest.tokenBudget.mode}`, + `- estimated_reviewer_calls: ${manifest.tokenBudget.estimatedReviewerCalls}`, + `- max_prompt_bytes_per_reviewer: ${manifest.tokenBudget.maxPromptBytesPerReviewer ?? 'none'}`, + `- estimated_prompt_bytes_per_reviewer: ${manifest.tokenBudget.estimatedPromptBytesPerReviewer ?? 'unknown'}`, + `- prompt_byte_estimate_source: ${manifest.tokenBudget.promptByteEstimateSource ?? 'none'}`, + `- prompt_byte_limit_exceeded: ${manifest.tokenBudget.promptByteLimitExceeded ? 'yes' : 'no'}`, + `- token_budget_decisions: ${formatTokenBudgetDecisionKinds(manifest.tokenBudget.decisions)}`, + `- budget_limited_reviewers: ${manifest.tokenBudget.skippedReviewerIds.join(', ') || 'none'}`, + `- core_reviewers: ${formatManifestList(manifest.coreReviewers, 'none')}`, + `- quality_gate_reviewer: ${manifest.qualityGateReviewer?.subagentId || 'none'}`, + `- enabled_extra_reviewers: ${formatManifestList(manifest.enabledExtraReviewers, 'none')}`, + '- skipped_reviewers:', + ...(manifest.skippedReviewers.length > 0 + ? manifest.skippedReviewers.map( + (member) => ` - ${member.subagentId}: ${member.reason || 'skipped'}`, + ) + : [' - none']), + ].join('\n'); + const strategyProfiles = team.definition?.strategyProfiles ?? REVIEW_STRATEGY_PROFILES; + const strategyRules = REVIEW_STRATEGY_LEVELS.map((level) => { + const definition = strategyProfiles[level]; + const roleEntries = Object.entries(definition.roleDirectives) as [ReviewRoleDirectiveKey, string][]; + const roleLines = roleEntries.map( + ([role, directive]) => ` - ${role}: ${directive}`, + ); + return [ + `- ${level}: ${definition.summary}`, + ` - ${formatStrategyImpact(level, strategyProfiles)}`, + ` - Default model slot: ${definition.defaultModelSlot}`, + ` - Prompt directive (fallback): ${definition.promptDirective}`, + ` - Role-specific directives:`, + ...roleLines, + ].join('\n'); + }).join('\n'); + const commonStrategyRules = REVIEW_STRATEGY_COMMON_RULES.reviewerPromptRules + .map((rule) => `- ${rule}`) + .join('\n'); + + return [ + manifestBlock, + formatScopeProfileBlock(manifest.scopeProfile), + formatEvidencePackBlock(manifest.evidencePack), + formatPreReviewSummaryBlock(manifest.preReviewSummary), + formatSharedContextCacheBlock(manifest.sharedContextCache), + formatIncrementalReviewCacheBlock(manifest.incrementalReviewCache), + 'Review work packets:', + formatWorkPacketBlock(manifest.workPackets), + 'Work packet rules:', + '- Each reviewer Task prompt must include the matching work packet verbatim.', + '- Each reviewer and judge Task prompt must include the Scope profile review_depth, risk_focus_tags, max_dependency_hops, and coverage_expectation.', + '- Include the packet_id in each Task description, for example "Security review [packet reviewer:ReviewSecurity:group-1-of-3]".', + '- Each reviewer and judge response must echo packet_id and set status to completed, partial_timeout, timed_out, cancelled_by_user, failed, or skipped.', + '- If the reviewer reports packet_id itself, mark reviewers[].packet_status_source as reported in the final submit_code_review payload.', + '- If the reviewer omits packet_id but the Task was launched from a packet, infer the packet_id from the Task description or work packet and mark packet_status_source as inferred.', + '- If packet_id cannot be reported or inferred, mark packet_status_source as missing and explain the confidence impact in coverage_notes.', + '- If a reviewer response is missing packet_id or status, the judge must treat that reviewer output as lower confidence instead of discarding the whole review.', + '- Use the pre-generated diff summary for initial orientation and token discipline, but verify claims against assigned files or diffs before reporting findings.', + '- Evidence pack hunk_hints and contract_hints are orientation only; verify each hinted claim with GetFileDiff, Read, Grep, or Git before reporting it.', + '- When prompt_byte_limit_exceeded is yes, use the pre-generated diff summary before detailed reads. Do not remove files from assigned_scope or hide unreviewed files; if a file cannot be covered, report it in coverage_notes and reliability_signals.', + '- Use shared_context_cache entries to reuse read-only GetFileDiff/Read context by cache_key across reviewer packets. Do not duplicate full-file reads when a reusable cached diff or file summary already covers the same path.', + '- Use incremental_review_cache only when the target fingerprint matches a prior run; preserve completed reviewer outputs by packet_id and rerun only missing, failed, timed-out, or stale packets. If any invalidates_on condition changed, ignore the cache and explain the fresh review boundary.', + '- The assigned_scope is the default scope for that packet; only widen it when a critical cross-file dependency requires it and note the reason in coverage_notes.', + 'Configured code review team:', + members || '- No team members available.', + 'Execution policy:', + executionPolicy, + 'Concurrency policy:', + concurrencyPolicy, + 'Team execution rules:', + '- Run only reviewers listed in core_reviewers and enabled_extra_reviewers.', + '- Do not launch skipped_reviewers.', + '- If a skipped reviewer has reason not_applicable, mention it in coverage notes without treating it as reduced confidence.', + '- If a skipped reviewer has reason budget_limited, mention the budget mode and the coverage tradeoff.', + '- If a skipped reviewer has reason invalid_tooling, report it as a configuration issue and do not reduce confidence in the reviewers that did run.', + '- If target_resolution is unknown, conditional reviewers may be activated conservatively; report that as coverage context.', + `- Run the active core reviewer roles first: ${formatManifestList(manifest.coreReviewers, 'none')}.`, + '- Launch reviewer Tasks by launch_batch. Do not launch a later reviewer batch until every reviewer Task in the earlier batch has completed, failed, timed out, or returned partial_timeout.', + '- Never launch more reviewer Tasks in one batch than max_parallel_instances. If stagger_seconds is greater than 0, wait that many seconds before starting the next launch_batch.', + '- Run ReviewJudge only after the reviewer batch finishes, as the quality-gate pass.', + '- If other extra reviewers are configured and enabled, run them in parallel with the locked reviewers whenever possible.', + '- When a configured member entry provides model_id, pass model_id with that value to the matching Task call.', + '- If reviewer_timeout_seconds is greater than 0, pass timeout_seconds with that value to every reviewer Task call.', + '- If judge_timeout_seconds is greater than 0, pass timeout_seconds with that value to the ReviewJudge Task call.', + '- If a reviewer Task returns status partial_timeout, treat its output as partial evidence: preserve it in reviewers[].partial_output, mark the reviewer status partial_timeout, and mention the confidence impact in coverage_notes.', + '- If a reviewer fails or times out without useful partial output, retry that same reviewer at most max_retries_per_role times: reduce its scope, downgrade strategy by one level when possible, use a shorter timeout, and set retry to true on the retry Task call.', + '- In the final submit_code_review payload, populate reliability_signals for context_pressure, compression_preserved, partial_reviewer, reduced_scope, and user_decision when those conditions apply. Use severity info/warning/action, count when useful, and source runtime/manifest/report/inferred.', + '- If reviewer_file_split_threshold is greater than 0 and the target file count exceeds it, split files across multiple same-role reviewer instances only up to the concurrency-capped max_same_role_instances for this run.', + '- Prefer module/workspace-area coherent file groups when splitting reviewer work; avoid mixing unrelated workspace areas in the same packet when the group budget allows it.', + '- When file splitting is active, each same-role instance must only review its assigned file group. Label instances in the Task description with both group and packet_id (e.g. "Security review [group 1/3] [packet reviewer:ReviewSecurity:group-1-of-3]").', + '- Do not run ReviewFixer during the review pass.', + '- Wait for explicit user approval before starting any remediation.', + '- The Review Quality Inspector acts as a third-party arbiter: it primarily examines reviewer reports for logical consistency and evidence quality, and only uses code inspection tools for targeted spot-checks when a specific claim needs verification.', + 'Review strategy rules:', + `- Team strategy: ${manifest.strategyLevel}. ${formatStrategyImpact(manifest.strategyLevel, strategyProfiles)}`, + '- Risk recommendation is advisory; follow team_strategy, member strategy fields, and work-packet strategy for this run unless the user explicitly changes strategy.', + commonStrategyRules, + 'Review strategy profiles:', + strategyRules, + ].join('\n'); +} diff --git a/src/web-ui/src/shared/services/review-team/risk.ts b/src/web-ui/src/shared/services/review-team/risk.ts new file mode 100644 index 000000000..a9e610486 --- /dev/null +++ b/src/web-ui/src/shared/services/review-team/risk.ts @@ -0,0 +1,216 @@ +import type { ReviewTargetClassification } from '../reviewTargetClassifier'; +import type { + ReviewStrategyLevel, + ReviewTeamBackendRiskFactors, + ReviewTeamBackendStrategyRecommendation, + ReviewTeamChangeStats, + ReviewTeamRiskFactors, + ReviewTeamStrategyDecision, + ReviewTeamStrategyMismatchSeverity, + ReviewTeamStrategyRecommendation, +} from './types'; +import { + isSecuritySensitiveReviewPath, + pluralize, + workspaceAreaForReviewPath, +} from './pathMetadata'; + +export function recommendReviewStrategyForTarget( + target: ReviewTargetClassification, + changeStats: ReviewTeamChangeStats, +): ReviewTeamStrategyRecommendation { + const includedFiles = target.files.filter((file) => !file.excluded); + const securityFileCount = includedFiles.filter((file) => + isSecuritySensitiveReviewPath(file.normalizedPath), + ).length; + const workspaceAreaCount = new Set( + includedFiles.map((file) => workspaceAreaForReviewPath(file.normalizedPath)), + ).size; + const contractSurfaceChanged = target.tags.includes('frontend_contract') || + target.tags.includes('desktop_contract') || + target.tags.includes('web_server_contract') || + target.tags.includes('api_layer') || + target.tags.includes('transport'); + const totalLinesChanged = changeStats.totalLinesChanged; + const factors: ReviewTeamRiskFactors = { + fileCount: changeStats.fileCount, + ...(totalLinesChanged !== undefined ? { totalLinesChanged } : {}), + lineCountSource: changeStats.lineCountSource, + securityFileCount, + workspaceAreaCount, + contractSurfaceChanged, + }; + + if (target.resolution === 'unknown' || changeStats.fileCount === 0) { + return { + strategyLevel: 'normal', + score: 0, + rationale: 'unresolved target; keep a conservative normal review recommendation.', + factors, + }; + } + + const lineScore = + totalLinesChanged === undefined + ? 0 + : Math.floor(totalLinesChanged / 100); + const crossAreaScore = Math.max(0, workspaceAreaCount - 1) * 2; + const score = + changeStats.fileCount + + lineScore + + securityFileCount * 3 + + crossAreaScore + + (contractSurfaceChanged ? 2 : 0); + const strategyLevel: ReviewStrategyLevel = + score <= 5 + ? 'quick' + : score <= 20 + ? 'normal' + : 'deep'; + const sizeLabel = totalLinesChanged === undefined + ? `${changeStats.fileCount} files, unknown lines` + : `${changeStats.fileCount} files, ${totalLinesChanged} lines`; + const riskDetails = [ + pluralize(securityFileCount, 'security-sensitive file'), + pluralize(workspaceAreaCount, 'workspace area'), + contractSurfaceChanged ? 'contract surface changed' : undefined, + ].filter(Boolean).join(', '); + const rationale = + strategyLevel === 'quick' + ? `Small change (${sizeLabel}). Quick scan sufficient.` + : strategyLevel === 'normal' + ? `Medium change (${sizeLabel}; ${riskDetails}). Standard review recommended.` + : `Large/high-risk change (${sizeLabel}; ${riskDetails}). Deep review recommended.`; + + return { + strategyLevel, + score, + rationale, + factors, + }; +} + +const REVIEW_STRATEGY_RANK: Record = { + quick: 0, + normal: 1, + deep: 2, +}; + +function crossCrateChangeCountForReviewTarget( + target: ReviewTargetClassification, +): number { + const crateNames = new Set( + target.files + .filter((file) => !file.excluded) + .map((file) => /^src\/crates\/([^/]+)/.exec(file.normalizedPath)?.[1]) + .filter((crateName): crateName is string => Boolean(crateName)), + ); + + return Math.max(0, crateNames.size - 1); +} + +function buildBackendCompatibleRiskFactors( + target: ReviewTargetClassification, + changeStats: ReviewTeamChangeStats, +): ReviewTeamBackendRiskFactors { + const includedFiles = target.files.filter((file) => !file.excluded); + + return { + fileCount: changeStats.fileCount, + totalLinesChanged: changeStats.totalLinesChanged ?? 0, + lineCountSource: changeStats.lineCountSource, + filesInSecurityPaths: includedFiles.filter((file) => + isSecuritySensitiveReviewPath(file.normalizedPath), + ).length, + crossCrateChanges: crossCrateChangeCountForReviewTarget(target), + maxCyclomaticComplexityDelta: 0, + maxCyclomaticComplexityDeltaSource: 'not_measured', + }; +} + +export function recommendBackendCompatibleStrategyForTarget( + target: ReviewTargetClassification, + changeStats: ReviewTeamChangeStats, +): ReviewTeamBackendStrategyRecommendation { + const factors = buildBackendCompatibleRiskFactors(target, changeStats); + const score = + factors.fileCount + + Math.floor(factors.totalLinesChanged / 100) + + factors.filesInSecurityPaths * 3 + + factors.crossCrateChanges * 2; + const strategyLevel: ReviewStrategyLevel = + score <= 5 + ? 'quick' + : score <= 20 + ? 'normal' + : 'deep'; + const rationale = + strategyLevel === 'quick' + ? `Backend-compatible policy sees a small change (${factors.fileCount} files, ${factors.totalLinesChanged} lines).` + : strategyLevel === 'normal' + ? `Backend-compatible policy sees a medium change (${factors.fileCount} files, ${factors.totalLinesChanged} lines).` + : `Backend-compatible policy sees a large/high-risk change (${factors.fileCount} files, ${factors.totalLinesChanged} lines, ${factors.filesInSecurityPaths} security files).`; + + return { + strategyLevel, + score, + rationale, + factors, + }; +} + +function resolveStrategyMismatchSeverity(params: { + finalStrategy: ReviewStrategyLevel; + frontendRecommendation: ReviewStrategyLevel; + backendRecommendation: ReviewStrategyLevel; +}): ReviewTeamStrategyMismatchSeverity { + const finalRank = REVIEW_STRATEGY_RANK[params.finalStrategy]; + const recommendedRank = Math.max( + REVIEW_STRATEGY_RANK[params.frontendRecommendation], + REVIEW_STRATEGY_RANK[params.backendRecommendation], + ); + const distance = Math.abs(finalRank - recommendedRank); + + if (distance === 0) { + return 'none'; + } + if (distance >= 2) { + return 'high'; + } + return finalRank < recommendedRank ? 'medium' : 'low'; +} + +export function buildReviewStrategyDecision(params: { + teamDefaultStrategy: ReviewStrategyLevel; + finalStrategy: ReviewStrategyLevel; + userOverride?: ReviewStrategyLevel; + frontendRecommendation: ReviewTeamStrategyRecommendation; + backendRecommendation: ReviewTeamBackendStrategyRecommendation; +}): ReviewTeamStrategyDecision { + const mismatch = + params.finalStrategy !== params.frontendRecommendation.strategyLevel || + params.finalStrategy !== params.backendRecommendation.strategyLevel; + const mismatchSeverity = resolveStrategyMismatchSeverity({ + finalStrategy: params.finalStrategy, + frontendRecommendation: params.frontendRecommendation.strategyLevel, + backendRecommendation: params.backendRecommendation.strategyLevel, + }); + const recommendationSummary = [ + `frontend=${params.frontendRecommendation.strategyLevel}`, + `backend=${params.backendRecommendation.strategyLevel}`, + ].join(', '); + + return { + authority: 'mismatch_warning', + teamDefaultStrategy: params.teamDefaultStrategy, + ...(params.userOverride ? { userOverride: params.userOverride } : {}), + finalStrategy: params.finalStrategy, + frontendRecommendation: params.frontendRecommendation, + backendRecommendation: params.backendRecommendation, + mismatch, + mismatchSeverity, + rationale: mismatch + ? `Final strategy ${params.finalStrategy} differs from advisory recommendations (${recommendationSummary}); keep this as non-blocking launch/report metadata.` + : `Final strategy ${params.finalStrategy} matches advisory recommendations (${recommendationSummary}).`, + }; +} diff --git a/src/web-ui/src/shared/services/review-team/scopeProfile.ts b/src/web-ui/src/shared/services/review-team/scopeProfile.ts new file mode 100644 index 000000000..a23b9b4ab --- /dev/null +++ b/src/web-ui/src/shared/services/review-team/scopeProfile.ts @@ -0,0 +1,55 @@ +import type { + DeepReviewRiskFocusTag, + DeepReviewScopeProfile, + ReviewStrategyLevel, +} from './types'; + +const DEEP_REVIEW_RISK_FOCUS_TAGS: DeepReviewRiskFocusTag[] = [ + 'security', + 'data_loss', + 'migrations', + 'authentication_authorization', + 'cross_boundary_api_contracts', + 'concurrency', + 'persistence', + 'configuration_changes', + 'platform_boundary_violations', +]; + +export function buildDeepReviewScopeProfile( + strategyLevel: ReviewStrategyLevel, +): DeepReviewScopeProfile { + if (strategyLevel === 'quick') { + return { + reviewDepth: 'high_risk_only', + riskFocusTags: [...DEEP_REVIEW_RISK_FOCUS_TAGS], + maxDependencyHops: 0, + optionalReviewerPolicy: 'risk_matched_only', + allowBroadToolExploration: false, + coverageExpectation: + 'High-risk-only pass. Keep all changed files visible in coverage metadata, but only report directly evidenced high-risk findings and do not claim full-depth coverage.', + }; + } + + if (strategyLevel === 'normal') { + return { + reviewDepth: 'risk_expanded', + riskFocusTags: [...DEEP_REVIEW_RISK_FOCUS_TAGS], + maxDependencyHops: 1, + optionalReviewerPolicy: 'configured', + allowBroadToolExploration: false, + coverageExpectation: + 'Risk-expanded pass. Cover changed files plus one-hop high-risk context when evidence requires it, and describe any reduced-depth confidence limits.', + }; + } + + return { + reviewDepth: 'full_depth', + riskFocusTags: [...DEEP_REVIEW_RISK_FOCUS_TAGS], + maxDependencyHops: 'policy_limited', + optionalReviewerPolicy: 'full', + allowBroadToolExploration: true, + coverageExpectation: + 'Full-depth pass. Review changed files and policy-limited dependency context deeply enough to support release-quality findings.', + }; +} diff --git a/src/web-ui/src/shared/services/review-team/tokenBudget.ts b/src/web-ui/src/shared/services/review-team/tokenBudget.ts new file mode 100644 index 000000000..40a54b79f --- /dev/null +++ b/src/web-ui/src/shared/services/review-team/tokenBudget.ts @@ -0,0 +1,236 @@ +import type { ReviewTargetClassification } from '../reviewTargetClassifier'; +import { + MAX_PREDICTIVE_TIMEOUT_SECONDS, + PREDICTIVE_TIMEOUT_BASE_SECONDS, + PREDICTIVE_TIMEOUT_PER_100_LINES_SECONDS, + PREDICTIVE_TIMEOUT_PER_FILE_SECONDS, + PROMPT_BYTE_ESTIMATE_BASE_BYTES, + PROMPT_BYTE_ESTIMATE_PER_CHANGED_LINE_BYTES, + PROMPT_BYTE_ESTIMATE_PER_FILE_BYTES, + PROMPT_BYTE_ESTIMATE_UNKNOWN_LINES_PER_FILE, + TOKEN_BUDGET_PROMPT_BYTE_LIMIT_BY_MODE, +} from './defaults'; +import type { + ReviewStrategyLevel, + ReviewTeamChangeStats, + ReviewTeamExecutionPolicy, + ReviewTeamTokenBudgetDecision, + ReviewTeamTokenBudgetPlan, + ReviewTeamWorkPacket, + ReviewTeamWorkPacketScope, + ReviewTokenBudgetMode, +} from './types'; + +function predictTimeoutSeconds(params: { + role: 'reviewer' | 'judge'; + strategyLevel: ReviewStrategyLevel; + changeStats: ReviewTeamChangeStats; + reviewerCount: number; +}): number { + const totalLinesChanged = params.changeStats.totalLinesChanged ?? 0; + const base = PREDICTIVE_TIMEOUT_BASE_SECONDS[params.strategyLevel]; + const raw = + base + + params.changeStats.fileCount * PREDICTIVE_TIMEOUT_PER_FILE_SECONDS + + Math.floor(totalLinesChanged / 100) * + PREDICTIVE_TIMEOUT_PER_100_LINES_SECONDS; + const reviewerCount = Math.max(1, params.reviewerCount); + const multiplier = + params.role === 'judge' + ? 1 + Math.floor((reviewerCount - 1) / 3) + : 1; + + return Math.min(raw * multiplier, MAX_PREDICTIVE_TIMEOUT_SECONDS); +} + +export function buildEffectiveExecutionPolicy(params: { + basePolicy: ReviewTeamExecutionPolicy; + strategyLevel: ReviewStrategyLevel; + target: ReviewTargetClassification; + changeStats: ReviewTeamChangeStats; + reviewerCount: number; +}): ReviewTeamExecutionPolicy { + if ( + params.target.resolution === 'unknown' && + params.changeStats.fileCount === 0 && + params.changeStats.totalLinesChanged === undefined + ) { + return params.basePolicy; + } + + const reviewerTimeoutSeconds = predictTimeoutSeconds({ + role: 'reviewer', + strategyLevel: params.strategyLevel, + changeStats: params.changeStats, + reviewerCount: params.reviewerCount, + }); + const judgeTimeoutSeconds = predictTimeoutSeconds({ + role: 'judge', + strategyLevel: params.strategyLevel, + changeStats: params.changeStats, + reviewerCount: params.reviewerCount, + }); + + return { + ...params.basePolicy, + reviewerTimeoutSeconds: + params.basePolicy.reviewerTimeoutSeconds === 0 + ? 0 + : Math.max( + params.basePolicy.reviewerTimeoutSeconds, + reviewerTimeoutSeconds, + ), + judgeTimeoutSeconds: + params.basePolicy.judgeTimeoutSeconds === 0 + ? 0 + : Math.max( + params.basePolicy.judgeTimeoutSeconds, + judgeTimeoutSeconds, + ), + }; +} + +function estimateChangedLinesForScope(params: { + scope: ReviewTeamWorkPacketScope; + changeStats: ReviewTeamChangeStats; + totalIncludedFileCount: number; +}): number { + if (params.changeStats.totalLinesChanged === undefined) { + return params.scope.fileCount * PROMPT_BYTE_ESTIMATE_UNKNOWN_LINES_PER_FILE; + } + + if (params.totalIncludedFileCount <= 0) { + return params.changeStats.totalLinesChanged; + } + + return Math.ceil( + params.changeStats.totalLinesChanged * + (params.scope.fileCount / params.totalIncludedFileCount), + ); +} + +function estimateReviewerPromptBytes(params: { + packet: ReviewTeamWorkPacket; + changeStats: ReviewTeamChangeStats; + totalIncludedFileCount: number; +}): number { + const pathBytes = params.packet.assignedScope.files.reduce( + (total, filePath) => total + filePath.length + 1, + 0, + ); + const estimatedChangedLines = estimateChangedLinesForScope({ + scope: params.packet.assignedScope, + changeStats: params.changeStats, + totalIncludedFileCount: params.totalIncludedFileCount, + }); + + return Math.ceil( + PROMPT_BYTE_ESTIMATE_BASE_BYTES + + pathBytes + + params.packet.assignedScope.fileCount * PROMPT_BYTE_ESTIMATE_PER_FILE_BYTES + + estimatedChangedLines * PROMPT_BYTE_ESTIMATE_PER_CHANGED_LINE_BYTES, + ); +} + +function estimateMaxReviewerPromptBytes(params: { + workPackets: ReviewTeamWorkPacket[]; + target: ReviewTargetClassification; + changeStats: ReviewTeamChangeStats; +}): number { + const reviewerPackets = params.workPackets.filter( + (packet) => packet.phase === 'reviewer', + ); + const totalIncludedFileCount = params.target.files.filter( + (file) => !file.excluded, + ).length; + + if (reviewerPackets.length === 0) { + return PROMPT_BYTE_ESTIMATE_BASE_BYTES; + } + + return Math.max( + ...reviewerPackets.map((packet) => + estimateReviewerPromptBytes({ + packet, + changeStats: params.changeStats, + totalIncludedFileCount, + }), + ), + ); +} + +export function buildTokenBudgetPlan(params: { + mode: ReviewTokenBudgetMode; + activeReviewerCalls: number; + eligibleExtraReviewerCount: number; + maxExtraReviewers: number; + skippedReviewerIds: string[]; + target: ReviewTargetClassification; + changeStats: ReviewTeamChangeStats; + executionPolicy: ReviewTeamExecutionPolicy; + workPackets: ReviewTeamWorkPacket[]; +}): ReviewTeamTokenBudgetPlan { + const includedFileCount = params.target.files.filter( + (file) => !file.excluded, + ).length; + const fileSplitGuardrailActive = + params.executionPolicy.reviewerFileSplitThreshold > 0 && + includedFileCount > params.executionPolicy.reviewerFileSplitThreshold; + const maxPromptBytesPerReviewer = + TOKEN_BUDGET_PROMPT_BYTE_LIMIT_BY_MODE[params.mode]; + const estimatedPromptBytesPerReviewer = estimateMaxReviewerPromptBytes({ + workPackets: params.workPackets, + target: params.target, + changeStats: params.changeStats, + }); + const promptByteLimitExceeded = + estimatedPromptBytesPerReviewer > maxPromptBytesPerReviewer; + const largeDiffSummaryFirst = promptByteLimitExceeded; + const decisions: ReviewTeamTokenBudgetDecision[] = []; + const warnings: string[] = []; + + if (promptByteLimitExceeded) { + decisions.push({ + kind: 'summary_first_full_scope', + reason: 'prompt_bytes_exceeded', + detail: + `Estimated reviewer prompt ${estimatedPromptBytesPerReviewer} bytes exceeds ${maxPromptBytesPerReviewer} bytes for ${params.mode} budget; use summary-first while keeping every assigned_scope file visible.`, + }); + warnings.push( + 'Estimated reviewer prompt exceeds the selected token budget; use summary-first without hiding assigned files.', + ); + } + + if (params.skippedReviewerIds.length > 0) { + decisions.push({ + kind: 'skip_extra_reviewers', + reason: 'extra_reviewers_skipped', + detail: + 'Some extra reviewers were skipped by the selected token budget mode.', + affectedReviewerIds: [...params.skippedReviewerIds], + }); + warnings.push( + 'Some extra reviewers were skipped by the selected token budget mode.', + ); + } + + return { + mode: params.mode, + estimatedReviewerCalls: params.activeReviewerCalls, + maxReviewerCalls: + params.activeReviewerCalls + + Math.max(0, params.eligibleExtraReviewerCount - params.maxExtraReviewers), + maxExtraReviewers: params.maxExtraReviewers, + ...(fileSplitGuardrailActive + ? { maxFilesPerReviewer: params.executionPolicy.reviewerFileSplitThreshold } + : {}), + maxPromptBytesPerReviewer, + estimatedPromptBytesPerReviewer, + promptByteEstimateSource: 'manifest_heuristic', + promptByteLimitExceeded, + largeDiffSummaryFirst, + decisions, + skippedReviewerIds: params.skippedReviewerIds, + warnings, + }; +} diff --git a/src/web-ui/src/shared/services/review-team/types.ts b/src/web-ui/src/shared/services/review-team/types.ts index d12e3f6dc..680162da4 100644 --- a/src/web-ui/src/shared/services/review-team/types.ts +++ b/src/web-ui/src/shared/services/review-team/types.ts @@ -9,6 +9,95 @@ export type ReviewMemberStrategyLevel = ReviewStrategyLevel | 'inherit'; export type ReviewStrategySource = 'team' | 'member'; export type ReviewModelFallbackReason = 'model_removed'; +export type DeepReviewScopeReviewDepth = + | 'high_risk_only' + | 'risk_expanded' + | 'full_depth'; +export type DeepReviewScopeDependencyHops = number | 'policy_limited'; +export type DeepReviewOptionalReviewerPolicy = + | 'risk_matched_only' + | 'configured' + | 'full'; +export type DeepReviewRiskFocusTag = + | 'security' + | 'data_loss' + | 'migrations' + | 'authentication_authorization' + | 'cross_boundary_api_contracts' + | 'concurrency' + | 'persistence' + | 'configuration_changes' + | 'platform_boundary_violations'; + +export interface DeepReviewScopeProfile { + reviewDepth: DeepReviewScopeReviewDepth; + riskFocusTags: DeepReviewRiskFocusTag[]; + maxDependencyHops: DeepReviewScopeDependencyHops; + optionalReviewerPolicy: DeepReviewOptionalReviewerPolicy; + allowBroadToolExploration: boolean; + coverageExpectation: string; +} + +export type DeepReviewEvidencePackSource = 'target_manifest'; +export type DeepReviewEvidencePackContentBoundary = 'metadata_only'; +export type DeepReviewEvidencePackContractHintKind = + | 'i18n_key' + | 'tauri_command' + | 'api_contract' + | 'config_key'; + +export interface DeepReviewEvidencePackDiffStat { + fileCount: number; + totalChangedLines?: number; + lineCountSource: ReviewTeamChangeStats['lineCountSource']; +} + +export interface DeepReviewEvidencePackHunkHint { + filePath: string; + changedLineCount: number; + lineCountSource: ReviewTeamChangeStats['lineCountSource']; +} + +export interface DeepReviewEvidencePackContractHint { + kind: DeepReviewEvidencePackContractHintKind; + filePath: string; + source: 'path_classifier'; +} + +export interface DeepReviewEvidencePackBudget { + maxChangedFiles: number; + maxHunkHints: number; + maxContractHints: number; + omittedChangedFileCount: number; + omittedHunkHintCount: number; + omittedContractHintCount: number; +} + +export interface DeepReviewEvidencePackPrivacyBoundary { + content: DeepReviewEvidencePackContentBoundary; + excludes: [ + 'source_text', + 'full_diff', + 'model_output', + 'provider_raw_body', + 'full_file_contents', + ]; +} + +export interface DeepReviewEvidencePack { + version: 1; + source: DeepReviewEvidencePackSource; + changedFiles: string[]; + diffStat: DeepReviewEvidencePackDiffStat; + domainTags: ReviewDomainTag[]; + riskFocusTags: DeepReviewRiskFocusTag[]; + packetIds: string[]; + hunkHints: DeepReviewEvidencePackHunkHint[]; + contractHints: DeepReviewEvidencePackContractHint[]; + budget: DeepReviewEvidencePackBudget; + privacy: DeepReviewEvidencePackPrivacyBoundary; +} + export interface ReviewStrategyCommonRules { reviewerPromptRules: string[]; } @@ -337,12 +426,14 @@ export interface ReviewTeamRunManifest { policySource: 'default-review-team-config'; target: ReviewTargetClassification; strategyLevel: ReviewStrategyLevel; + scopeProfile?: DeepReviewScopeProfile; strategyRecommendation?: ReviewTeamStrategyRecommendation; strategyDecision: ReviewTeamStrategyDecision; executionPolicy: ReviewTeamExecutionPolicy; concurrencyPolicy: ReviewTeamConcurrencyPolicy; changeStats?: ReviewTeamChangeStats; preReviewSummary: ReviewTeamPreReviewSummary; + evidencePack?: DeepReviewEvidencePack; sharedContextCache: ReviewTeamSharedContextCachePlan; incrementalReviewCache: ReviewTeamIncrementalReviewCachePlan; tokenBudget: ReviewTeamTokenBudgetPlan; diff --git a/src/web-ui/src/shared/services/review-team/workPackets.ts b/src/web-ui/src/shared/services/review-team/workPackets.ts new file mode 100644 index 000000000..7bd2a7d3e --- /dev/null +++ b/src/web-ui/src/shared/services/review-team/workPackets.ts @@ -0,0 +1,301 @@ +import type { ReviewTargetClassification } from '../reviewTargetClassifier'; +import { + DEFAULT_REVIEW_TEAM_MODEL, + JUDGE_WORK_PACKET_REQUIRED_OUTPUT_FIELDS, + REVIEWER_WORK_PACKET_REQUIRED_OUTPUT_FIELDS, +} from './defaults'; +import { toManifestMember } from './manifestMembers'; +import { groupFilesByWorkspaceArea } from './pathMetadata'; +import type { + ReviewTeamChangeStats, + ReviewTeamConcurrencyPolicy, + ReviewTeamExecutionPolicy, + ReviewTeamMember, + ReviewTeamWorkPacket, + ReviewTeamWorkPacketScope, + ReviewTokenBudgetMode, +} from './types'; + +// Work packets are pure launch-plan metadata. They must not inspect file +// contents or make runtime retry/queue decisions. +export function resolveMaxExtraReviewers( + mode: ReviewTokenBudgetMode, + eligibleExtraReviewerCount: number, +): number { + if (mode === 'economy') { + return 0; + } + return eligibleExtraReviewerCount; +} + +export function resolveChangeStats( + target: ReviewTargetClassification, + stats?: Partial, +): ReviewTeamChangeStats { + const fileCount = Math.max( + 0, + Math.floor( + stats?.fileCount ?? + target.files.filter((file) => !file.excluded).length, + ), + ); + const totalLinesChanged = + typeof stats?.totalLinesChanged === 'number' && + Number.isFinite(stats.totalLinesChanged) + ? Math.max(0, Math.floor(stats.totalLinesChanged)) + : undefined; + + return { + fileCount, + ...(totalLinesChanged !== undefined ? { totalLinesChanged } : {}), + lineCountSource: + totalLinesChanged !== undefined + ? stats?.lineCountSource ?? 'diff_stat' + : 'unknown', + }; +} + +function buildWorkPacketScopeFromFiles( + target: ReviewTargetClassification, + files: string[], + group?: { index: number; count: number }, +): ReviewTeamWorkPacketScope { + return { + kind: 'review_target', + targetSource: target.source, + targetResolution: target.resolution, + targetTags: [...target.tags], + fileCount: files.length, + files, + excludedFileCount: + target.files.length - target.files.filter((file) => !file.excluded).length, + ...(group ? { groupIndex: group.index, groupCount: group.count } : {}), + }; +} + +function buildWorkPacket(params: { + member: ReviewTeamMember; + phase: ReviewTeamWorkPacket['phase']; + launchBatch: number; + scope: ReviewTeamWorkPacketScope; + timeoutSeconds: number; +}): ReviewTeamWorkPacket { + const manifestMember = toManifestMember(params.member); + const packetGroupSuffix = + params.phase === 'reviewer' && + params.scope.groupIndex !== undefined && + params.scope.groupCount !== undefined + ? `:group-${params.scope.groupIndex}-of-${params.scope.groupCount}` + : ''; + + return { + packetId: `${params.phase}:${manifestMember.subagentId}${packetGroupSuffix}`, + phase: params.phase, + launchBatch: params.launchBatch, + subagentId: manifestMember.subagentId, + displayName: manifestMember.displayName, + roleName: manifestMember.roleName, + assignedScope: params.scope, + allowedTools: [...params.member.allowedTools], + timeoutSeconds: params.timeoutSeconds, + requiredOutputFields: + params.phase === 'judge' + ? [...JUDGE_WORK_PACKET_REQUIRED_OUTPUT_FIELDS] + : [...REVIEWER_WORK_PACKET_REQUIRED_OUTPUT_FIELDS], + strategyLevel: manifestMember.strategyLevel, + strategyDirective: manifestMember.strategyDirective, + model: manifestMember.model || DEFAULT_REVIEW_TEAM_MODEL, + }; +} + +function splitFilesIntoGroups(files: string[], groupCount: number): string[][] { + if (groupCount <= 1) { + return [files]; + } + + const groups: string[][] = []; + let cursor = 0; + for (let index = 0; index < groupCount; index += 1) { + const remainingFiles = files.length - cursor; + const remainingGroups = groupCount - index; + const groupSize = Math.ceil(remainingFiles / remainingGroups); + groups.push(files.slice(cursor, cursor + groupSize)); + cursor += groupSize; + } + return groups; +} + +function splitFilesIntoModuleAwareGroups( + files: string[], + groupCount: number, +): string[][] { + if (groupCount <= 1) { + return [files]; + } + + const buckets = groupFilesByWorkspaceArea(files); + if (buckets.length <= 1) { + return splitFilesIntoGroups(files, groupCount); + } + + if (buckets.length >= groupCount) { + const groups = Array.from({ length: groupCount }, () => [] as string[]); + const sortedBuckets = [...buckets].sort( + (a, b) => b.files.length - a.files.length || a.index - b.index, + ); + + for (const bucket of sortedBuckets) { + let targetIndex = 0; + for (let index = 1; index < groups.length; index += 1) { + if (groups[index].length < groups[targetIndex].length) { + targetIndex = index; + } + } + groups[targetIndex].push(...bucket.files); + } + + return groups.filter((group) => group.length > 0); + } + + const chunkCounts = buckets.map(() => 1); + let remainingChunks = groupCount - buckets.length; + while (remainingChunks > 0) { + let targetBucketIndex = -1; + let largestAverageChunkSize = 0; + + for (let index = 0; index < buckets.length; index += 1) { + if (chunkCounts[index] >= buckets[index].files.length) { + continue; + } + const averageChunkSize = buckets[index].files.length / chunkCounts[index]; + if (averageChunkSize > largestAverageChunkSize) { + largestAverageChunkSize = averageChunkSize; + targetBucketIndex = index; + } + } + + if (targetBucketIndex === -1) { + break; + } + + chunkCounts[targetBucketIndex] += 1; + remainingChunks -= 1; + } + + return buckets.flatMap((bucket, index) => + splitFilesIntoGroups(bucket.files, chunkCounts[index]), + ); +} + +function effectiveMaxSameRoleInstances(params: { + executionPolicy: ReviewTeamExecutionPolicy; + concurrencyPolicy: ReviewTeamConcurrencyPolicy; + reviewerMemberCount: number; +}): number { + const reviewerMemberCount = Math.max(1, params.reviewerMemberCount); + const maxPerRole = Math.floor( + params.concurrencyPolicy.maxParallelInstances / reviewerMemberCount, + ); + + return Math.max( + 1, + Math.min(params.executionPolicy.maxSameRoleInstances, Math.max(1, maxPerRole)), + ); +} + +function resolveReviewerPacketScopes( + target: ReviewTargetClassification, + executionPolicy: ReviewTeamExecutionPolicy, + concurrencyPolicy: ReviewTeamConcurrencyPolicy, + reviewerMemberCount: number, +): ReviewTeamWorkPacketScope[] { + const includedFiles = target.files + .filter((file) => !file.excluded) + .map((file) => file.normalizedPath); + const shouldSplit = + executionPolicy.reviewerFileSplitThreshold > 0 && + executionPolicy.maxSameRoleInstances > 1 && + includedFiles.length > executionPolicy.reviewerFileSplitThreshold; + + if (!shouldSplit) { + return [buildWorkPacketScopeFromFiles(target, includedFiles)]; + } + + const maxSameRoleInstances = effectiveMaxSameRoleInstances({ + executionPolicy, + concurrencyPolicy, + reviewerMemberCount, + }); + const groupCount = Math.min( + maxSameRoleInstances, + Math.ceil(includedFiles.length / executionPolicy.reviewerFileSplitThreshold), + ); + if (groupCount <= 1) { + return [buildWorkPacketScopeFromFiles(target, includedFiles)]; + } + + const fileGroups = splitFilesIntoModuleAwareGroups(includedFiles, groupCount); + return fileGroups.map((files, index) => + buildWorkPacketScopeFromFiles(target, files, { + index: index + 1, + count: fileGroups.length, + }), + ); +} + +export function buildWorkPackets(params: { + reviewerMembers: ReviewTeamMember[]; + judgeMember?: ReviewTeamMember; + target: ReviewTargetClassification; + executionPolicy: ReviewTeamExecutionPolicy; + concurrencyPolicy: ReviewTeamConcurrencyPolicy; +}): ReviewTeamWorkPacket[] { + const reviewerScopes = resolveReviewerPacketScopes( + params.target, + params.executionPolicy, + params.concurrencyPolicy, + params.reviewerMembers.length, + ); + const fullScope = buildWorkPacketScopeFromFiles( + params.target, + params.target.files + .filter((file) => !file.excluded) + .map((file) => file.normalizedPath), + ); + const reviewerSeeds = params.reviewerMembers.flatMap((member) => + reviewerScopes.map((scope) => ({ member, scope })), + ); + const orderedReviewerSeeds = params.concurrencyPolicy.batchExtrasSeparately + ? [ + ...reviewerSeeds.filter((seed) => seed.member.source === 'core'), + ...reviewerSeeds.filter((seed) => seed.member.source === 'extra'), + ] + : reviewerSeeds; + const reviewerPackets = orderedReviewerSeeds.map((seed, index) => + buildWorkPacket({ + member: seed.member, + phase: 'reviewer', + launchBatch: + Math.floor(index / params.concurrencyPolicy.maxParallelInstances) + 1, + scope: seed.scope, + timeoutSeconds: params.executionPolicy.reviewerTimeoutSeconds, + }), + ); + const finalReviewerBatch = reviewerPackets.reduce( + (maxBatch, packet) => Math.max(maxBatch, packet.launchBatch), + 0, + ); + const judgePacket = params.judgeMember + ? [ + buildWorkPacket({ + member: params.judgeMember, + phase: 'judge', + launchBatch: finalReviewerBatch + 1, + scope: fullScope, + timeoutSeconds: params.executionPolicy.judgeTimeoutSeconds, + }), + ] + : []; + + return [...reviewerPackets, ...judgePacket]; +} diff --git a/src/web-ui/src/shared/services/reviewTeamLocaleCompleteness.test.ts b/src/web-ui/src/shared/services/reviewTeamLocaleCompleteness.test.ts index 4f370aed4..d1df51dd4 100644 --- a/src/web-ui/src/shared/services/reviewTeamLocaleCompleteness.test.ts +++ b/src/web-ui/src/shared/services/reviewTeamLocaleCompleteness.test.ts @@ -18,8 +18,18 @@ const REVIEW_TEAM_FLOW_CHAT_KEYS = [ 'deepReviewConsent.strategyLabels.quick', 'deepReviewConsent.strategyLabels.normal', 'deepReviewConsent.strategyLabels.deep', + 'deepReviewConsent.reviewDepth', + 'deepReviewConsent.reviewDepthLabels.high_risk_only', + 'deepReviewConsent.reviewDepthLabels.risk_expanded', + 'deepReviewConsent.reviewDepthLabels.full_depth', 'toolCards.codeReview.runManifest.recommendedStrategy', 'toolCards.codeReview.runManifest.riskRecommendationTitle', + 'toolCards.codeReview.runManifest.reviewDepth', + 'toolCards.codeReview.runManifest.reviewDepthLabels.high_risk_only', + 'toolCards.codeReview.runManifest.reviewDepthLabels.risk_expanded', + 'toolCards.codeReview.runManifest.reviewDepthLabels.full_depth', + 'toolCards.codeReview.reliabilityStatus.reduced_scope.label', + 'toolCards.codeReview.reliabilityStatus.reduced_scope.detail', ] as const; function readLocaleJson( diff --git a/src/web-ui/src/shared/services/reviewTeamService.test.ts b/src/web-ui/src/shared/services/reviewTeamService.test.ts index 8769fd50f..85550fb0c 100644 --- a/src/web-ui/src/shared/services/reviewTeamService.test.ts +++ b/src/web-ui/src/shared/services/reviewTeamService.test.ts @@ -690,6 +690,82 @@ describe('reviewTeamService', () => { ]); }); + it('maps review strategies to explicit scope profiles in the run manifest', () => { + const team = resolveDefaultReviewTeam( + coreSubagents(), + storedConfigWithExtra(), + ); + + expect(buildEffectiveReviewTeamManifest(team, { strategyOverride: 'quick' }).scopeProfile) + .toMatchObject({ + reviewDepth: 'high_risk_only', + maxDependencyHops: 0, + optionalReviewerPolicy: 'risk_matched_only', + allowBroadToolExploration: false, + }); + expect(buildEffectiveReviewTeamManifest(team, { strategyOverride: 'normal' }).scopeProfile) + .toMatchObject({ + reviewDepth: 'risk_expanded', + maxDependencyHops: 1, + optionalReviewerPolicy: 'configured', + allowBroadToolExploration: false, + }); + expect(buildEffectiveReviewTeamManifest(team, { strategyOverride: 'deep' }).scopeProfile) + .toMatchObject({ + reviewDepth: 'full_depth', + maxDependencyHops: 'policy_limited', + optionalReviewerPolicy: 'full', + allowBroadToolExploration: true, + }); + }); + + it('keeps changed-file coverage metadata visible for reduced-depth scope profiles', () => { + const team = resolveDefaultReviewTeam( + coreSubagents(), + storedConfigWithExtra([], { strategy_level: 'quick' }), + ); + const files = [ + 'src/crates/core/src/agentic/deep_review/report.rs', + 'src/apps/desktop/src/api/agentic_api.rs', + 'src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx', + ]; + + const manifest = buildEffectiveReviewTeamManifest(team, { + target: classifyReviewTargetFromFiles(files, 'workspace_diff'), + }); + + expect(manifest.scopeProfile.reviewDepth).toBe('high_risk_only'); + expect(manifest.target.files.map((file) => file.normalizedPath)).toEqual(files); + expect( + manifest.workPackets + ?.filter((packet) => packet.phase === 'reviewer') + .every((packet) => packet.assignedScope.files.every((file) => files.includes(file))), + ).toBe(true); + expect( + manifest.workPackets + ?.filter((packet) => packet.phase === 'reviewer') + .some((packet) => files.every((file) => packet.assignedScope.files.includes(file))), + ).toBe(true); + }); + + it('includes reduced-depth scope profile guidance in the prompt block', () => { + const team = resolveDefaultReviewTeam( + coreSubagents(), + storedConfigWithExtra([], { strategy_level: 'quick' }), + ); + + const promptBlock = buildReviewTeamPromptBlock(team); + + expect(promptBlock).toContain('Scope profile:'); + expect(promptBlock).toContain('- review_depth: high_risk_only'); + expect(promptBlock).toContain('- max_dependency_hops: 0'); + expect(promptBlock).toContain('- optional_reviewer_policy: risk_matched_only'); + expect(promptBlock).toContain('- allow_broad_tool_exploration: no'); + expect(promptBlock).toContain('- coverage_expectation: High-risk-only pass.'); + expect(promptBlock).toContain('Reduced-depth profiles are not full-depth coverage.'); + expect(promptBlock).toContain('populate reliability_signals with reduced_scope'); + }); + it('generates structured work packets for active reviewers and the judge', () => { const team = resolveDefaultReviewTeam( [ @@ -861,6 +937,116 @@ describe('reviewTeamService', () => { expect(promptBlock).toContain('Use shared_context_cache entries'); }); + it('builds a metadata-only evidence pack without source, diff, or model output', () => { + const team = resolveDefaultReviewTeam( + [ + ...coreSubagents(), + subagent('ExtraEnabled', true, 'user', 'fast', true, true), + ], + storedConfigWithExtra(['ExtraEnabled']), + ); + const files = [ + 'src/web-ui/src/locales/en-US/flow-chat.json', + 'src/apps/desktop/src/api/agentic_api.rs', + 'src/crates/api-layer/src/review.rs', + 'package.json', + ]; + + const manifest = buildEffectiveReviewTeamManifest(team, { + target: classifyReviewTargetFromFiles(files, 'workspace_diff'), + changeStats: { + fileCount: files.length, + totalLinesChanged: 120, + lineCountSource: 'diff_stat', + }, + strategyOverride: 'quick', + }); + + expect(manifest.evidencePack).toMatchObject({ + version: 1, + source: 'target_manifest', + changedFiles: files, + diffStat: { + fileCount: files.length, + totalChangedLines: 120, + lineCountSource: 'diff_stat', + }, + riskFocusTags: manifest.scopeProfile?.riskFocusTags, + packetIds: expect.arrayContaining([ + 'reviewer:ReviewBusinessLogic', + 'judge:ReviewJudge', + ]), + privacy: { + content: 'metadata_only', + }, + }); + expect(manifest.evidencePack?.contractHints).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'i18n_key', + filePath: 'src/web-ui/src/locales/en-US/flow-chat.json', + }), + expect.objectContaining({ + kind: 'tauri_command', + filePath: 'src/apps/desktop/src/api/agentic_api.rs', + }), + expect.objectContaining({ + kind: 'api_contract', + filePath: 'src/crates/api-layer/src/review.rs', + }), + expect.objectContaining({ + kind: 'config_key', + filePath: 'package.json', + }), + ]), + ); + expect(manifest.evidencePack?.hunkHints).toEqual( + files.map((filePath) => ({ + filePath, + changedLineCount: 30, + lineCountSource: 'diff_stat', + })), + ); + + const serializedEvidencePack = JSON.stringify(manifest.evidencePack); + expect(serializedEvidencePack).not.toContain('promptDirective'); + expect(serializedEvidencePack).not.toContain('allowedTools'); + expect(serializedEvidencePack).not.toContain('fullDiff'); + expect(serializedEvidencePack).not.toContain('sourceText'); + expect(serializedEvidencePack).not.toContain('modelOutput'); + }); + + it('injects the metadata-only evidence pack into the prompt as verifiable orientation', () => { + const team = resolveDefaultReviewTeam( + coreSubagents(), + storedConfigWithExtra(), + ); + const files = [ + 'src/web-ui/src/locales/en-US/flow-chat.json', + 'src/crates/api-layer/src/review.rs', + ]; + const manifest = buildEffectiveReviewTeamManifest(team, { + target: classifyReviewTargetFromFiles(files, 'workspace_diff'), + changeStats: { + fileCount: files.length, + totalLinesChanged: 20, + lineCountSource: 'diff_stat', + }, + }); + + const promptBlock = buildReviewTeamPromptBlock(team, manifest); + + expect(promptBlock).toContain('Evidence pack:'); + expect(promptBlock).toContain('"content": "metadata_only"'); + expect(promptBlock).toContain('"changed_files"'); + expect(promptBlock).toContain('"contract_hints"'); + expect(promptBlock).toContain('Evidence pack hunk_hints and contract_hints are orientation only'); + expect(promptBlock).toContain('verify each hinted claim with GetFileDiff, Read, Grep, or Git before reporting it'); + expect(promptBlock).not.toContain('sourceText'); + expect(promptBlock).not.toContain('fullDiff'); + expect(promptBlock).not.toContain('modelOutput'); + }); + it('builds an incremental review cache plan for follow-up reviews', () => { const team = resolveDefaultReviewTeam( coreSubagents(),