From 7a28300aab48564bd155a5091f465d5052c1d40a Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Wed, 13 May 2026 10:33:33 +0800 Subject: [PATCH 01/38] feat(review-platform): add review platform panel with core service and Tauri API feat(review-platform): link pull requests with review chat sessions feat(review-platform): support inner git platform fix(review-platform): fix: improve PR review panel reliability and launch context feat(review-platform): open PR links in dedicated detail panel feat: add review platform agent tool feat(review-platform): refine pull request detail context actions fix(review-platform): propagate remoteId through PR context and auto-resolve write actions feat(review-platform): unify PR review/comment timeline perf(review-platform): load PR detail sections on demand feat(review-platform): change User-Agent to ReviewPlatform feat(review-platform): add lazy CI log details for pull requests feat(review-platform): add auth challenge flow for token setup feat(review-platform): replace inner CI data source with MR-level APIs feat(review-platform): integrate PR panel APIs into ReviewPlatform tool --- Cargo.toml | 2 +- src/apps/cli/src/ui/theme.rs | 2 +- src/apps/desktop/src/api/mod.rs | 1 + .../desktop/src/api/review_platform_api.rs | 201 + src/apps/desktop/src/lib.rs | 7 + src/crates/ai-adapters/src/client/http.rs | 1 + .../src/agentic/tools/implementations/mod.rs | 2 + .../implementations/review_platform_tool.rs | 1256 +++++ src/crates/core/src/agentic/tools/registry.rs | 3 + .../src/agentic/tools/static_providers.rs | 1 + src/crates/core/src/service/mod.rs | 10 + .../core/src/service/review_platform/mod.rs | 4863 +++++++++++++++++ src/crates/tool-packs/src/lib.rs | 2 + .../components/panels/base/FlexiblePanel.tsx | 24 + .../src/app/components/panels/base/types.ts | 2 + .../src/app/components/panels/base/utils.ts | 17 + .../review-platform/ReviewPlatformPanel.scss | 1679 ++++++ .../review-platform/ReviewPlatformPanel.tsx | 2306 ++++++++ .../components/Markdown/Markdown.tsx | 6 + .../flow_chat/components/FlowTextBlock.tsx | 3 +- .../flow_chat/components/RichTextInput.tsx | 10 + .../src/flow_chat/components/UserMessage.tsx | 7 +- .../components/modern/FlowChatContext.tsx | 2 + .../components/modern/FlowChatHeader.scss | 1 + .../components/modern/FlowChatHeader.tsx | 20 +- .../modern/ModernFlowChatContainer.tsx | 18 + .../deep-review/launch/DeepReviewService.ts | 42 + .../deep-review/launch/launchPrompt.test.ts | 15 + .../deep-review/launch/launchPrompt.ts | 31 + .../src/flow_chat/hooks/useMessageSender.ts | 42 +- src/web-ui/src/infrastructure/api/index.ts | 5 +- .../api/service-api/ReviewPlatformAPI.ts | 341 ++ src/web-ui/src/shared/services/DragManager.ts | 2 + .../shared/services/reviewTargetClassifier.ts | 1 + src/web-ui/src/shared/stores/contextStore.ts | 2 +- src/web-ui/src/shared/types/context.ts | 17 + .../src/shared/utils/contextPrompt.test.ts | 49 + src/web-ui/src/shared/utils/contextPrompt.ts | 52 + .../src/shared/utils/pullRequestLinks.ts | 93 + src/web-ui/src/shared/utils/tabUtils.ts | 76 + 40 files changed, 11165 insertions(+), 49 deletions(-) create mode 100644 src/apps/desktop/src/api/review_platform_api.rs create mode 100644 src/crates/core/src/agentic/tools/implementations/review_platform_tool.rs create mode 100644 src/crates/core/src/service/review_platform/mod.rs create mode 100644 src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.scss create mode 100644 src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx create mode 100644 src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.ts create mode 100644 src/web-ui/src/shared/utils/contextPrompt.test.ts create mode 100644 src/web-ui/src/shared/utils/contextPrompt.ts create mode 100644 src/web-ui/src/shared/utils/pullRequestLinks.ts diff --git a/Cargo.toml b/Cargo.toml index 4cef76448b..01cbc04ea9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,7 +73,7 @@ indexmap = "2" include_dir = "0.7" # HTTP client -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots", "json", "stream", "multipart"] } +reqwest = { version = "0.12", default-features = false, features = ["native-tls", "rustls-tls-native-roots", "json", "stream", "multipart"] } # Debug Log HTTP Server axum = { version = "0.7", features = ["json", "ws"] } diff --git a/src/apps/cli/src/ui/theme.rs b/src/apps/cli/src/ui/theme.rs index 044e2c6130..2973f25356 100644 --- a/src/apps/cli/src/ui/theme.rs +++ b/src/apps/cli/src/ui/theme.rs @@ -1,10 +1,10 @@ +use once_cell::sync::Lazy; /// Theme and style definitions use std::collections::{HashMap, HashSet}; use std::io::IsTerminal; use std::path::Path; use std::time::{Duration, Instant}; -use once_cell::sync::Lazy; use ratatui::style::{Color, Modifier, Style}; #[cfg(unix)] diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 707305041b..982122f424 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -28,6 +28,7 @@ pub mod miniapp_api; pub mod path_target; pub mod project_context_api; pub mod remote_connect_api; +pub mod review_platform_api; pub mod runtime_api; pub mod search_api; pub mod session_api; diff --git a/src/apps/desktop/src/api/review_platform_api.rs b/src/apps/desktop/src/api/review_platform_api.rs new file mode 100644 index 0000000000..4ac268432a --- /dev/null +++ b/src/apps/desktop/src/api/review_platform_api.rs @@ -0,0 +1,201 @@ +//! Review platform Tauri commands. + +use crate::api::app_state::AppState; +use bitfun_core::service::review_platform::{ + ReviewPlatformCiLog, ReviewPlatformDetailSection, ReviewPlatformKind, + ReviewPlatformPullRequestDetail, ReviewPlatformPullRequestDetailPage, ReviewPlatformService, + ReviewPlatformWorkspaceSnapshot, +}; +use log::error; +use serde::Deserialize; +use tauri::State; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformWorkspaceSnapshotRequest { + pub repository_path: String, + pub remote_id: Option, + pub page: Option, + pub per_page: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPullRequestDetailRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPullRequestDetailPageRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub section: ReviewPlatformDetailSection, + pub page: Option, + pub per_page: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPullRequestCiLogRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub ci_item_id: String, + pub ci_item_name: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformUpdateAuthTokenRequest { + pub platform: ReviewPlatformKind, + pub host: String, + pub token: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformClearAuthTokenRequest { + pub platform: ReviewPlatformKind, + pub host: String, +} + +#[tauri::command] +pub async fn review_platform_get_workspace_snapshot( + _state: State<'_, AppState>, + request: ReviewPlatformWorkspaceSnapshotRequest, +) -> Result { + ReviewPlatformService::workspace_snapshot( + &request.repository_path, + request.remote_id.as_deref(), + request.page, + request.per_page, + ) + .await + .map_err(|error| { + error!( + "Failed to get review platform workspace snapshot: path={}, remote_id={:?}, error={}", + request.repository_path, request.remote_id, error + ); + format!( + "Failed to get review platform workspace snapshot: {}", + error + ) + }) +} + +#[tauri::command] +pub async fn review_platform_get_pull_request_detail( + _state: State<'_, AppState>, + request: ReviewPlatformPullRequestDetailRequest, +) -> Result { + ReviewPlatformService::pull_request_detail( + &request.repository_path, + &request.remote_id, + &request.pull_request_id, + ) + .await + .map_err(|error| { + error!( + "Failed to get review platform pull request detail: path={}, remote_id={}, pull_request_id={}, error={}", + request.repository_path, + request.remote_id, + request.pull_request_id, + error + ); + format!("Failed to get review platform pull request detail: {}", error) + }) +} + +#[tauri::command] +pub async fn review_platform_get_pull_request_detail_page( + _state: State<'_, AppState>, + request: ReviewPlatformPullRequestDetailPageRequest, +) -> Result { + ReviewPlatformService::pull_request_detail_page( + &request.repository_path, + &request.remote_id, + &request.pull_request_id, + request.section, + request.page, + request.per_page, + ) + .await + .map_err(|error| { + error!( + "Failed to get review platform pull request detail page: path={}, remote_id={}, pull_request_id={}, section={:?}, page={:?}, per_page={:?}, error={}", + request.repository_path, + request.remote_id, + request.pull_request_id, + request.section, + request.page, + request.per_page, + error + ); + format!( + "Failed to get review platform pull request detail page: {}", + error + ) + }) +} + +#[tauri::command] +pub async fn review_platform_get_pull_request_ci_log( + _state: State<'_, AppState>, + request: ReviewPlatformPullRequestCiLogRequest, +) -> Result { + ReviewPlatformService::pull_request_ci_log( + &request.repository_path, + &request.remote_id, + &request.pull_request_id, + &request.ci_item_id, + &request.ci_item_name, + ) + .await + .map_err(|error| { + error!( + "Failed to get review platform CI log: path={}, remote_id={}, pull_request_id={}, ci_item_id={}, error={}", + request.repository_path, + request.remote_id, + request.pull_request_id, + request.ci_item_id, + error + ); + format!("Failed to get review platform CI log: {}", error) + }) +} + +#[tauri::command] +pub async fn review_platform_update_auth_token( + _state: State<'_, AppState>, + request: ReviewPlatformUpdateAuthTokenRequest, +) -> Result<(), String> { + ReviewPlatformService::update_auth_token(request.platform, &request.host, &request.token) + .await + .map_err(|error| { + error!( + "Failed to update review platform auth token: platform={:?}, host={}, error={}", + request.platform, request.host, error + ); + format!("Failed to update review platform auth token: {}", error) + }) +} + +#[tauri::command] +pub async fn review_platform_clear_auth_token( + _state: State<'_, AppState>, + request: ReviewPlatformClearAuthTokenRequest, +) -> Result<(), String> { + ReviewPlatformService::clear_auth_token(request.platform, &request.host) + .await + .map_err(|error| { + error!( + "Failed to clear review platform auth token: platform={:?}, host={}, error={}", + request.platform, request.host, error + ); + format!("Failed to clear review platform auth token: {}", error) + }) +} diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 23ec40cb42..f9632bf9f7 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -41,6 +41,7 @@ use api::i18n_api::*; use api::lsp_api::*; use api::lsp_workspace_api::*; use api::mcp_api::*; +use api::review_platform_api::*; use api::runtime_api::*; use api::search_api::*; use api::session_api::*; @@ -713,6 +714,12 @@ pub async fn run() { git_is_repository, git_get_repository_basic, git_get_repository, + review_platform_get_workspace_snapshot, + review_platform_get_pull_request_detail, + review_platform_get_pull_request_detail_page, + review_platform_get_pull_request_ci_log, + review_platform_update_auth_token, + review_platform_clear_auth_token, git_get_status, git_get_branches, git_get_enhanced_branches, diff --git a/src/crates/ai-adapters/src/client/http.rs b/src/crates/ai-adapters/src/client/http.rs index 545e3f0661..826ac82b57 100644 --- a/src/crates/ai-adapters/src/client/http.rs +++ b/src/crates/ai-adapters/src/client/http.rs @@ -9,6 +9,7 @@ pub(crate) fn create_http_client( skip_ssl_verify: bool, ) -> Client { let mut builder = Client::builder() + .use_rustls_tls() .connect_timeout(std::time::Duration::from_secs( AIClient::STREAM_CONNECT_TIMEOUT_SECS, )) diff --git a/src/crates/core/src/agentic/tools/implementations/mod.rs b/src/crates/core/src/agentic/tools/implementations/mod.rs index 498dd7f61c..a0fa25e80c 100644 --- a/src/crates/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/core/src/agentic/tools/implementations/mod.rs @@ -30,6 +30,7 @@ pub mod ls_tool; pub mod mcp_tools; pub mod miniapp_init_tool; pub mod playbook_tool; +pub mod review_platform_tool; pub mod session_control_tool; pub mod session_history_tool; pub mod session_message_tool; @@ -68,6 +69,7 @@ pub use mcp_tools::{ }; pub use miniapp_init_tool::InitMiniAppTool; pub use playbook_tool::PlaybookTool; +pub use review_platform_tool::ReviewPlatformTool; pub use session_control_tool::SessionControlTool; pub use session_history_tool::SessionHistoryTool; pub use session_message_tool::SessionMessageTool; diff --git a/src/crates/core/src/agentic/tools/implementations/review_platform_tool.rs b/src/crates/core/src/agentic/tools/implementations/review_platform_tool.rs new file mode 100644 index 0000000000..60211d1070 --- /dev/null +++ b/src/crates/core/src/agentic/tools/implementations/review_platform_tool.rs @@ -0,0 +1,1256 @@ +//! Pull request / review platform tool. +//! +//! This tool exposes hosted review-platform operations to the agent while +//! keeping provider-specific HTTP behavior inside `ReviewPlatformService`. + +use crate::agentic::tools::framework::{ + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::service::review_platform::{ + ReviewPlatformApprovalRequest, ReviewPlatformCreatePullRequestRequest, + ReviewPlatformDetailSection, ReviewPlatformError, ReviewPlatformKind, ReviewPlatformRemote, + ReviewPlatformReplyToThreadRequest, ReviewPlatformRequestChangesRequest, + ReviewPlatformResolveThreadRequest, ReviewPlatformService, ReviewPlatformSubmitReviewRequest, + ReviewSubmitEvent, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; + +const ACTION_WORKSPACE_SNAPSHOT: &str = "get_workspace_snapshot"; +const ACTION_LIST_REMOTES: &str = "list_remotes"; +const ACTION_LIST: &str = "list_pull_requests"; +const ACTION_COUNT: &str = "count_pull_requests"; +const ACTION_GET: &str = "get_pull_request"; +const ACTION_GET_DETAIL_PAGE: &str = "get_pull_request_detail_page"; +const ACTION_GET_CI_LOG: &str = "get_pull_request_ci_log"; +const ACTION_CREATE: &str = "create_pull_request"; +const ACTION_REPLY: &str = "reply_to_thread"; +const ACTION_SUBMIT_REVIEW: &str = "submit_review"; +const ACTION_APPROVE: &str = "approve_pull_request"; +const ACTION_REVOKE_APPROVAL: &str = "revoke_approval"; +const ACTION_REQUEST_CHANGES: &str = "request_changes"; +const ACTION_RESOLVE: &str = "resolve_thread"; +const ACTION_UPDATE_AUTH_TOKEN: &str = "update_auth_token"; +const ACTION_CLEAR_AUTH_TOKEN: &str = "clear_auth_token"; + +const WRITE_ACTIONS: &[&str] = &[ + ACTION_CREATE, + ACTION_REPLY, + ACTION_SUBMIT_REVIEW, + ACTION_APPROVE, + ACTION_REVOKE_APPROVAL, + ACTION_REQUEST_CHANGES, + ACTION_RESOLVE, + ACTION_UPDATE_AUTH_TOKEN, + ACTION_CLEAR_AUTH_TOKEN, +]; + +pub struct ReviewPlatformTool; + +impl ReviewPlatformTool { + pub fn new() -> Self { + Self + } + + fn repository_path(input: &Value, context: &ToolUseContext) -> BitFunResult { + let requested = input + .get("repository_path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + + if let Some(path) = requested { + return context.resolve_workspace_tool_path(path); + } + + context + .workspace + .as_ref() + .map(|workspace| workspace.root_path_string()) + .ok_or_else(|| BitFunError::tool("repository_path is required".to_string())) + } + + fn string_field(input: &Value, key: &str) -> BitFunResult { + input + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| BitFunError::tool(format!("{} is required", key))) + } + + fn optional_string_field(input: &Value, key: &str) -> Option { + input + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + } + + fn submit_event(input: &Value) -> BitFunResult { + match input + .get("event") + .and_then(Value::as_str) + .unwrap_or("comment") + { + "comment" => Ok(ReviewSubmitEvent::Comment), + "approve" => Ok(ReviewSubmitEvent::Approve), + "request_changes" => Ok(ReviewSubmitEvent::RequestChanges), + other => Err(BitFunError::tool(format!( + "Unsupported review event: {}", + other + ))), + } + } + + fn detail_section(input: &Value) -> BitFunResult { + match input + .get("section") + .and_then(Value::as_str) + .unwrap_or("overview") + { + "overview" => Ok(ReviewPlatformDetailSection::Overview), + "ci" => Ok(ReviewPlatformDetailSection::Ci), + "files" => Ok(ReviewPlatformDetailSection::Files), + "commits" => Ok(ReviewPlatformDetailSection::Commits), + "reviews" => Ok(ReviewPlatformDetailSection::Reviews), + other => Err(BitFunError::tool(format!( + "Unsupported pull request detail section: {}", + other + ))), + } + } + + fn platform_kind(input: &Value) -> BitFunResult { + match Self::string_field(input, "platform")?.as_str() { + "github" => Ok(ReviewPlatformKind::Github), + "gitlab" => Ok(ReviewPlatformKind::Gitlab), + "gitcode" => Ok(ReviewPlatformKind::Gitcode), + "unknown" => Ok(ReviewPlatformKind::Unknown), + other => Err(BitFunError::tool(format!( + "Unsupported review platform kind: {}", + other + ))), + } + } + + async fn resolve_remote_id(repository_path: &str, input: &Value) -> BitFunResult { + if let Some(remote_id) = Self::optional_string_field(input, "remote_id") { + return Ok(remote_id); + } + + let remotes = ReviewPlatformService::discover_remotes(repository_path) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + let supported = supported_remotes(&remotes); + match supported.as_slice() { + [] => Err(BitFunError::tool( + "No supported review platform remote found".to_string(), + )), + [remote] => Ok(remote.id.clone()), + _ => Err(BitFunError::tool(remote_ambiguity_message(&supported))), + } + } + + async fn resolve_remote_id_for_list( + repository_path: &str, + input: &Value, + ) -> BitFunResult> { + if let Some(remote_id) = Self::optional_string_field(input, "remote_id") { + return Ok(Ok(remote_id)); + } + + let remotes = ReviewPlatformService::discover_remotes(repository_path) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + let supported = supported_remotes(&remotes); + match supported.as_slice() { + [] => Err(BitFunError::tool( + "No supported review platform remote found".to_string(), + )), + [remote] => Ok(Ok(remote.id.clone())), + _ => Ok(Err(json!({ + "action": ACTION_LIST, + "repositoryPath": repository_path, + "status": "needs_remote_selection", + "message": "Multiple supported review platform remotes were found. Provide remote_id explicitly.", + "candidateRemotes": supported, + }))), + } + } + + fn action(input: &Value) -> Option<&str> { + input.get("action").and_then(Value::as_str) + } + + fn auth_required_result( + action: &str, + repository_path: &str, + remote_id: &str, + error: &ReviewPlatformError, + ) -> Option { + let status = match error { + ReviewPlatformError::Http { status, .. } if *status == 401 || *status == 403 => *status, + _ => return None, + }; + let state = if status == 403 { + "insufficient_scope" + } else { + "invalid" + }; + Some(json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "status": "needs_auth", + "authChallenge": { + "state": state, + "message": if status == 403 { + "Review platform token is missing required permissions. Update the token in the pull request panel, then retry." + } else { + "Review platform authentication is required or the configured token was rejected. Add or update the token in the pull request panel, then retry." + }, + }, + "openPanel": { + "type": "review-platform-auth", + "workspacePath": repository_path, + "remoteId": remote_id, + }, + })) + } + + fn render_action_result(output: &Value) -> Option { + let result = output.get("result")?; + let message = result + .get("message") + .and_then(Value::as_str) + .unwrap_or("Review platform action completed"); + let web_url = result.get("webUrl").and_then(Value::as_str); + let pr = result.get("pullRequest"); + + let mut lines = vec![message.to_string()]; + if let Some(pr) = pr { + let title = pr + .get("title") + .and_then(Value::as_str) + .unwrap_or("Pull request"); + let number = pr.get("number").and_then(Value::as_i64).unwrap_or_default(); + let url = pr.get("webUrl").and_then(Value::as_str).or(web_url); + if let Some(url) = url { + lines.push(format!("[#{} {}]({})", number, title, url)); + } + } else if let Some(url) = web_url { + lines.push(url.to_string()); + } + Some(lines.join("\n")) + } +} + +#[async_trait] +impl Tool for ReviewPlatformTool { + fn name(&self) -> &str { + "ReviewPlatform" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Read and operate on hosted pull requests / merge requests. + +Use this for remote review-platform operations such as discovering remotes, loading the workspace PR snapshot, counting pull requests, listing pull requests, opening full or paginated pull request detail, loading CI logs, creating a pull request, replying to review threads, submitting a comment review, approving, revoking approval, requesting changes, or resolving a review thread. Use the Git tool for local repository state and branch/commit/push operations. + +Authentication-token actions are available only when the user explicitly provides a token or asks to clear a stored token. Never guess or expose token values. + +When returning pull request results to the user, include the provider web URL so the chat UI can open the pull request detail panel naturally."#.to_string()) + } + + fn short_description(&self) -> String { + "Inspect and operate on hosted pull requests / merge requests.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + ACTION_WORKSPACE_SNAPSHOT, + ACTION_LIST_REMOTES, + ACTION_LIST, + ACTION_COUNT, + ACTION_GET, + ACTION_GET_DETAIL_PAGE, + ACTION_GET_CI_LOG, + ACTION_CREATE, + ACTION_REPLY, + ACTION_SUBMIT_REVIEW, + ACTION_APPROVE, + ACTION_REVOKE_APPROVAL, + ACTION_REQUEST_CHANGES, + ACTION_RESOLVE, + ACTION_UPDATE_AUTH_TOKEN, + ACTION_CLEAR_AUTH_TOKEN + ], + "description": "Review platform action to perform." + }, + "repository_path": { + "type": "string", + "description": "Repository path. Omit to use the current workspace." + }, + "remote_id": { + "type": "string", + "description": "Review platform remote id. Omit to use the only supported remote; provide it explicitly when the repository has multiple supported review-platform remotes." + }, + "pull_request_id": { + "type": "string", + "description": "Pull request or merge request number/id." + }, + "page": { + "type": "integer", + "description": "Page number for list_pull_requests, get_workspace_snapshot, or get_pull_request_detail_page." + }, + "per_page": { + "type": "integer", + "description": "Page size for list_pull_requests, get_workspace_snapshot, or get_pull_request_detail_page." + }, + "section": { + "type": "string", + "enum": ["overview", "ci", "files", "commits", "reviews"], + "description": "Detail section for get_pull_request_detail_page." + }, + "ci_item_id": { + "type": "string", + "description": "CI item id for get_pull_request_ci_log." + }, + "ci_item_name": { + "type": "string", + "description": "CI item display name for get_pull_request_ci_log; used by providers that need a job name fallback." + }, + "platform": { + "type": "string", + "enum": ["github", "gitlab", "gitcode", "unknown"], + "description": "Review platform kind for update_auth_token or clear_auth_token." + }, + "host": { + "type": "string", + "description": "Review platform host for update_auth_token or clear_auth_token." + }, + "token": { + "type": "string", + "description": "Personal access token for update_auth_token. Only provide this when the user explicitly asks to store that token." + }, + "title": { + "type": "string", + "description": "Pull request title for create_pull_request." + }, + "source_branch": { + "type": "string", + "description": "Source/head branch for create_pull_request." + }, + "target_branch": { + "type": "string", + "description": "Target/base branch for create_pull_request." + }, + "body": { + "type": "string", + "description": "Pull request body, review body, or comment body depending on action." + }, + "draft": { + "type": "boolean", + "description": "Create a draft pull request when the provider supports it." + }, + "thread_id": { + "type": "string", + "description": "Thread id returned by get_pull_request for reply_to_thread or resolve_thread." + }, + "event": { + "type": "string", + "enum": ["comment", "approve", "request_changes"], + "description": "Review event for submit_review." + }, + "resolved": { + "type": "boolean", + "description": "Whether resolve_thread should mark the thread resolved or reopened." + } + }, + "required": ["action"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn is_concurrency_safe(&self, input: Option<&Value>) -> bool { + input + .and_then(Self::action) + .is_some_and(|action| !WRITE_ACTIONS.contains(&action)) + } + + fn needs_permissions(&self, input: Option<&Value>) -> bool { + input + .and_then(Self::action) + .map(|action| WRITE_ACTIONS.contains(&action)) + .unwrap_or(true) + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + let Some(action) = Self::action(input) else { + return ValidationResult { + result: false, + message: Some("action is required".to_string()), + error_code: Some(400), + meta: None, + }; + }; + let valid = [ + ACTION_WORKSPACE_SNAPSHOT, + ACTION_LIST_REMOTES, + ACTION_LIST, + ACTION_COUNT, + ACTION_GET, + ACTION_GET_DETAIL_PAGE, + ACTION_GET_CI_LOG, + ACTION_CREATE, + ACTION_REPLY, + ACTION_SUBMIT_REVIEW, + ACTION_APPROVE, + ACTION_REVOKE_APPROVAL, + ACTION_REQUEST_CHANGES, + ACTION_RESOLVE, + ACTION_UPDATE_AUTH_TOKEN, + ACTION_CLEAR_AUTH_TOKEN, + ]; + if !valid.contains(&action) { + return ValidationResult { + result: false, + message: Some(format!("Unsupported ReviewPlatform action: {}", action)), + error_code: Some(400), + meta: None, + }; + } + ValidationResult { + result: true, + message: None, + error_code: None, + meta: None, + } + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let action = Self::action(input).unwrap_or("unknown"); + match action { + ACTION_WORKSPACE_SNAPSHOT => "Load review platform workspace snapshot".to_string(), + ACTION_LIST_REMOTES => "List review platform remotes".to_string(), + ACTION_LIST => "List pull requests".to_string(), + ACTION_COUNT => "Count pull requests".to_string(), + ACTION_GET => format!( + "Open pull request {}", + input + .get("pull_request_id") + .and_then(Value::as_str) + .unwrap_or("detail") + ), + ACTION_GET_DETAIL_PAGE => "Load pull request detail page".to_string(), + ACTION_GET_CI_LOG => "Load pull request CI log".to_string(), + ACTION_CREATE => "Create pull request".to_string(), + ACTION_REPLY => "Reply to pull request thread".to_string(), + ACTION_SUBMIT_REVIEW => "Submit pull request review".to_string(), + ACTION_APPROVE => "Approve pull request".to_string(), + ACTION_REVOKE_APPROVAL => "Revoke pull request approval".to_string(), + ACTION_REQUEST_CHANGES => "Request pull request changes".to_string(), + ACTION_RESOLVE => "Resolve pull request thread".to_string(), + ACTION_UPDATE_AUTH_TOKEN => "Update review platform auth token".to_string(), + ACTION_CLEAR_AUTH_TOKEN => "Clear review platform auth token".to_string(), + _ => format!("Review platform action: {}", action), + } + } + + fn render_result_for_assistant(&self, output: &Value) -> String { + let action = output.get("action").and_then(Value::as_str).unwrap_or(""); + if output + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| status == "needs_auth") + { + let message = output + .pointer("/authChallenge/message") + .and_then(Value::as_str) + .unwrap_or("Review platform authentication is required."); + return format!("{} Ask the user to configure the token in the pull request panel, then retry this action.", message); + } + if let Some(action_result) = Self::render_action_result(output) { + return action_result; + } + + match action { + ACTION_LIST_REMOTES => { + let remotes = output + .get("remotes") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]); + let mut lines = vec![format!("Found {} review platform remotes.", remotes.len())]; + lines.extend(remotes.iter().map(|remote| { + let id = remote.get("id").and_then(Value::as_str).unwrap_or(""); + let name = remote.get("name").and_then(Value::as_str).unwrap_or(""); + let platform = remote.get("platform").and_then(Value::as_str).unwrap_or(""); + let project = remote + .get("projectPath") + .and_then(Value::as_str) + .unwrap_or(""); + let url = remote.get("webUrl").and_then(Value::as_str).unwrap_or(""); + format!( + "- remote_id: {} | name: {} | platform: {} | project: {} | url: {}", + id, name, platform, project, url + ) + })); + lines.join("\n") + } + ACTION_WORKSPACE_SNAPSHOT => { + let snapshot = output.get("snapshot"); + let Some(snapshot) = snapshot else { + return "Review platform workspace snapshot loaded.".to_string(); + }; + let remotes = snapshot + .get("remotes") + .and_then(Value::as_array) + .map(|items| items.len()) + .unwrap_or(0); + let prs = snapshot + .get("pullRequests") + .and_then(Value::as_array) + .map(|items| items.len()) + .unwrap_or(0); + let selected = snapshot + .get("selectedRemoteId") + .and_then(Value::as_str) + .unwrap_or("none"); + let message = snapshot.get("message").and_then(Value::as_str); + match message { + Some(message) if !message.is_empty() => format!( + "Loaded review platform snapshot: selected remote {}, {} remotes, {} pull requests. {}", + selected, remotes, prs, message + ), + _ => format!( + "Loaded review platform snapshot: selected remote {}, {} remotes, {} pull requests.", + selected, remotes, prs + ), + } + } + ACTION_COUNT => { + if output + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| status == "needs_remote_selection") + { + let remotes = output + .get("candidateRemotes") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]); + let mut lines = vec![ + "Multiple review platform remotes were found. Ask the user which remote to use, then retry with remote_id.".to_string(), + "Candidate remotes:".to_string(), + ]; + lines.extend(remotes.iter().map(|remote| { + let id = remote.get("id").and_then(Value::as_str).unwrap_or(""); + let name = remote.get("name").and_then(Value::as_str).unwrap_or(""); + let platform = remote.get("platform").and_then(Value::as_str).unwrap_or(""); + let project = remote + .get("projectPath") + .and_then(Value::as_str) + .unwrap_or(""); + let url = remote.get("webUrl").and_then(Value::as_str).unwrap_or(""); + format!( + "- remote_id: {} | name: {} | platform: {} | project: {} | url: {}", + id, name, platform, project, url + ) + })); + return lines.join("\n"); + } + + let remote_id = output.get("remoteId").and_then(Value::as_str).unwrap_or(""); + let total = output.get("total").and_then(Value::as_u64); + match total { + Some(total) => format!("Remote {} has {} pull requests.", remote_id, total), + None => format!( + "Remote {} did not return an exact pull request count.", + remote_id + ), + } + } + ACTION_LIST => { + if output + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| status == "needs_remote_selection") + { + let remotes = output + .get("candidateRemotes") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]); + let mut lines = vec![ + "Multiple review platform remotes were found. Ask the user which remote to use, then retry with remote_id.".to_string(), + "Candidate remotes:".to_string(), + ]; + lines.extend(remotes.iter().map(|remote| { + let id = remote.get("id").and_then(Value::as_str).unwrap_or(""); + let name = remote.get("name").and_then(Value::as_str).unwrap_or(""); + let platform = remote.get("platform").and_then(Value::as_str).unwrap_or(""); + let project = remote + .get("projectPath") + .and_then(Value::as_str) + .unwrap_or(""); + let url = remote.get("webUrl").and_then(Value::as_str).unwrap_or(""); + format!( + "- remote_id: {} | name: {} | platform: {} | project: {} | url: {}", + id, name, platform, project, url + ) + })); + return lines.join("\n"); + } + + let prs = output + .pointer("/snapshot/pullRequests") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]); + let pagination = output + .get("snapshot") + .and_then(|snapshot| snapshot.get("pagination")); + let page = pagination + .and_then(|value| value.get("page")) + .and_then(Value::as_u64) + .unwrap_or(1); + let per_page = pagination + .and_then(|value| value.get("perPage")) + .and_then(Value::as_u64) + .unwrap_or(prs.len() as u64); + let total = pagination + .and_then(|value| value.get("total")) + .and_then(Value::as_u64); + let has_next = pagination + .and_then(|value| value.get("hasNext")) + .and_then(Value::as_bool) + .unwrap_or(false); + let remote_id = output.get("remoteId").and_then(Value::as_str).unwrap_or(""); + + let mut lines = vec![match total { + Some(total) => format!( + "Remote {} has {} pull requests. Showing {} from page {} (page size {}).", + remote_id, + total, + prs.len(), + page, + per_page + ), + None => format!( + "Remote {} returned {} pull requests on page {} (page size {}).{}", + remote_id, + prs.len(), + page, + per_page, + if has_next { + " More pages are available; this is not the total count." + } else { + "" + } + ), + }]; + if prs.is_empty() { + return lines.join("\n"); + } + lines.extend(prs.iter().take(10).map(|pr| { + let number = pr.get("number").and_then(Value::as_i64).unwrap_or_default(); + let title = pr + .get("title") + .and_then(Value::as_str) + .unwrap_or("Untitled"); + let state = pr.get("state").and_then(Value::as_str).unwrap_or("unknown"); + let url = pr.get("webUrl").and_then(Value::as_str).unwrap_or(""); + if url.is_empty() { + format!("#{} {} ({})", number, title, state) + } else { + format!("[#{} {}]({}) ({})", number, title, url, state) + } + })); + lines.join("\n") + } + ACTION_GET => { + let pr = output.get("pullRequest"); + let Some(pr) = pr else { + return "Pull request detail loaded.".to_string(); + }; + let number = pr.get("number").and_then(Value::as_i64).unwrap_or_default(); + let title = pr + .get("title") + .and_then(Value::as_str) + .unwrap_or("Untitled"); + let url = pr.get("webUrl").and_then(Value::as_str).unwrap_or(""); + let files = output + .get("files") + .and_then(Value::as_array) + .map(|items| items.len()) + .unwrap_or(0); + let threads = output + .get("threads") + .and_then(Value::as_array) + .map(|items| items.len()) + .unwrap_or(0); + if url.is_empty() { + format!( + "Loaded PR #{} {} ({} files, {} threads)", + number, title, files, threads + ) + } else { + format!( + "Loaded [#{} {}]({}) ({} files, {} threads)", + number, title, url, files, threads + ) + } + } + ACTION_GET_DETAIL_PAGE => { + let section = output + .get("section") + .and_then(Value::as_str) + .unwrap_or("detail"); + let items = output + .get("items") + .and_then(Value::as_array) + .map(|items| items.len()) + .unwrap_or(0); + let pagination = output.get("pagination"); + let page = pagination + .and_then(|value| value.get("page")) + .and_then(Value::as_u64) + .unwrap_or(1); + let has_next = pagination + .and_then(|value| value.get("hasNext")) + .and_then(Value::as_bool) + .unwrap_or(false); + format!( + "Loaded pull request {} page {} with {} items.{}", + section, + page, + items, + if has_next { + " More pages are available." + } else { + "" + } + ) + } + ACTION_GET_CI_LOG => { + let ci_item_id = output.get("ciItemId").and_then(Value::as_str).unwrap_or(""); + let truncated = output + .get("truncated") + .and_then(Value::as_bool) + .unwrap_or(false); + let log_chars = output + .get("log") + .and_then(Value::as_str) + .map(str::len) + .unwrap_or(0); + format!( + "Loaded CI log for {} ({} characters).{}", + ci_item_id, + log_chars, + if truncated { + " The log was truncated." + } else { + "" + } + ) + } + ACTION_UPDATE_AUTH_TOKEN => "Review platform auth token updated.".to_string(), + ACTION_CLEAR_AUTH_TOKEN => "Review platform auth token cleared.".to_string(), + _ => "Review platform action completed.".to_string(), + } + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let action = Self::string_field(input, "action")?; + let repository_path = match action.as_str() { + ACTION_UPDATE_AUTH_TOKEN | ACTION_CLEAR_AUTH_TOKEN => { + Self::optional_string_field(input, "repository_path") + .map(|path| context.resolve_workspace_tool_path(&path)) + .transpose()? + .or_else(|| { + context + .workspace + .as_ref() + .map(|workspace| workspace.root_path_string()) + }) + .unwrap_or_default() + } + _ => Self::repository_path(input, context)?, + }; + + let data = match action.as_str() { + ACTION_LIST_REMOTES => { + let remotes = ReviewPlatformService::discover_remotes(&repository_path) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ + "action": action, + "repositoryPath": repository_path, + "remotes": remotes, + }) + } + ACTION_WORKSPACE_SNAPSHOT => { + let page = input + .get("page") + .and_then(Value::as_u64) + .map(|value| value as u32); + let per_page = input + .get("per_page") + .and_then(Value::as_u64) + .map(|value| value as u32); + let remote_id = Self::optional_string_field(input, "remote_id"); + let snapshot = ReviewPlatformService::workspace_snapshot( + &repository_path, + remote_id.as_deref(), + page, + per_page, + ) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + let status = if snapshot.auth_challenge.is_some() { + "needs_auth" + } else { + "ok" + }; + let auth_challenge = snapshot.auth_challenge.clone(); + let selected_remote_id = snapshot.selected_remote_id.clone(); + let panel_remote_id = selected_remote_id.clone(); + json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": selected_remote_id, + "status": status, + "authChallenge": auth_challenge, + "snapshot": snapshot, + "openPanel": if status == "needs_auth" { + json!({ + "type": "review-platform-auth", + "workspacePath": repository_path, + "remoteId": panel_remote_id, + }) + } else { + Value::Null + }, + }) + } + ACTION_COUNT => { + let remote_id = + match Self::resolve_remote_id_for_list(&repository_path, input).await? { + Ok(remote_id) => remote_id, + Err(mut selection_result) => { + if let Some(obj) = selection_result.as_object_mut() { + obj.insert("action".to_string(), json!(ACTION_COUNT)); + } + let result_for_assistant = + self.render_result_for_assistant(&selection_result); + return Ok(vec![ToolResult::Result { + data: selection_result, + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]); + } + }; + let snapshot = ReviewPlatformService::workspace_snapshot( + &repository_path, + Some(remote_id.as_str()), + Some(1), + Some(1), + ) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + if snapshot.auth_challenge.is_some() { + json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "status": "needs_auth", + "authChallenge": snapshot.auth_challenge, + "snapshot": snapshot, + "openPanel": { + "type": "review-platform-auth", + "workspacePath": repository_path, + "remoteId": remote_id, + }, + }) + } else { + json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "total": snapshot.pagination.total, + "hasNext": snapshot.pagination.has_next, + }) + } + } + ACTION_LIST => { + let page = input + .get("page") + .and_then(Value::as_u64) + .map(|value| value as u32); + let per_page = input + .get("per_page") + .and_then(Value::as_u64) + .map(|value| value as u32); + let remote_id = + match Self::resolve_remote_id_for_list(&repository_path, input).await? { + Ok(remote_id) => remote_id, + Err(selection_result) => { + let result_for_assistant = + self.render_result_for_assistant(&selection_result); + return Ok(vec![ToolResult::Result { + data: selection_result, + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]); + } + }; + let snapshot = ReviewPlatformService::workspace_snapshot( + &repository_path, + Some(remote_id.as_str()), + page, + per_page, + ) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + if snapshot.auth_challenge.is_some() { + json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "status": "needs_auth", + "authChallenge": snapshot.auth_challenge, + "snapshot": snapshot, + "openPanel": { + "type": "review-platform-auth", + "workspacePath": repository_path, + "remoteId": remote_id, + }, + }) + } else { + json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "snapshot": snapshot, + }) + } + } + ACTION_GET => { + let pull_request_id = Self::string_field(input, "pull_request_id")?; + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + match ReviewPlatformService::pull_request_detail( + &repository_path, + &remote_id, + &pull_request_id, + ) + .await + { + Ok(detail) => json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "pullRequest": detail.pull_request, + "body": detail.body, + "ci": detail.ci, + "files": detail.files, + "commits": detail.commits, + "threads": detail.threads, + }), + Err(error) => { + if let Some(result) = Self::auth_required_result( + &action, + &repository_path, + &remote_id, + &error, + ) { + result + } else { + return Err(BitFunError::tool(error.to_string())); + } + } + } + } + ACTION_GET_DETAIL_PAGE => { + let pull_request_id = Self::string_field(input, "pull_request_id")?; + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let section = Self::detail_section(input)?; + let page = input + .get("page") + .and_then(Value::as_u64) + .map(|value| value as u32); + let per_page = input + .get("per_page") + .and_then(Value::as_u64) + .map(|value| value as u32); + match ReviewPlatformService::pull_request_detail_page( + &repository_path, + &remote_id, + &pull_request_id, + section, + page, + per_page, + ) + .await + { + Ok(detail) => { + let items = match section { + ReviewPlatformDetailSection::Overview => json!([]), + ReviewPlatformDetailSection::Ci => json!(detail.ci), + ReviewPlatformDetailSection::Files => json!(detail.files), + ReviewPlatformDetailSection::Commits => json!(detail.commits), + ReviewPlatformDetailSection::Reviews => json!(detail.threads), + }; + json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "pullRequest": detail.pull_request, + "body": detail.body, + "section": detail.section, + "pagination": detail.pagination, + "items": items, + "detailPage": detail, + }) + } + Err(error) => { + if let Some(result) = Self::auth_required_result( + &action, + &repository_path, + &remote_id, + &error, + ) { + result + } else { + return Err(BitFunError::tool(error.to_string())); + } + } + } + } + ACTION_GET_CI_LOG => { + let pull_request_id = Self::string_field(input, "pull_request_id")?; + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let ci_item_id = Self::string_field(input, "ci_item_id")?; + let ci_item_name = Self::string_field(input, "ci_item_name")?; + match ReviewPlatformService::pull_request_ci_log( + &repository_path, + &remote_id, + &pull_request_id, + &ci_item_id, + &ci_item_name, + ) + .await + { + Ok(ci_log) => json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "pullRequestId": pull_request_id, + "ciItemId": ci_log.ci_item_id, + "log": ci_log.log, + "truncated": ci_log.truncated, + "message": ci_log.message, + }), + Err(error) => { + if let Some(result) = Self::auth_required_result( + &action, + &repository_path, + &remote_id, + &error, + ) { + result + } else { + return Err(BitFunError::tool(error.to_string())); + } + } + } + } + ACTION_CREATE => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformCreatePullRequestRequest { + repository_path: repository_path.clone(), + remote_id: Some(remote_id), + title: Self::string_field(input, "title")?, + source_branch: Self::string_field(input, "source_branch")?, + target_branch: Self::string_field(input, "target_branch")?, + body: Self::optional_string_field(input, "body"), + draft: input.get("draft").and_then(Value::as_bool), + }; + let result = ReviewPlatformService::create_pull_request(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_REPLY => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformReplyToThreadRequest { + repository_path: repository_path.clone(), + remote_id, + pull_request_id: Self::string_field(input, "pull_request_id")?, + thread_id: Self::string_field(input, "thread_id")?, + body: Self::string_field(input, "body")?, + }; + let result = ReviewPlatformService::reply_to_thread(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_SUBMIT_REVIEW => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformSubmitReviewRequest { + repository_path: repository_path.clone(), + remote_id, + pull_request_id: Self::string_field(input, "pull_request_id")?, + event: Self::submit_event(input)?, + body: Self::string_field(input, "body")?, + }; + let result = ReviewPlatformService::submit_review(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_APPROVE => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformApprovalRequest { + repository_path: repository_path.clone(), + remote_id, + pull_request_id: Self::string_field(input, "pull_request_id")?, + body: Self::optional_string_field(input, "body"), + }; + let result = ReviewPlatformService::approve_pull_request(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_REVOKE_APPROVAL => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformApprovalRequest { + repository_path: repository_path.clone(), + remote_id, + pull_request_id: Self::string_field(input, "pull_request_id")?, + body: None, + }; + let result = ReviewPlatformService::revoke_approval(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_REQUEST_CHANGES => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformRequestChangesRequest { + repository_path: repository_path.clone(), + remote_id, + pull_request_id: Self::string_field(input, "pull_request_id")?, + body: Self::string_field(input, "body")?, + }; + let result = ReviewPlatformService::request_changes(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_RESOLVE => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformResolveThreadRequest { + repository_path: repository_path.clone(), + remote_id, + pull_request_id: Self::string_field(input, "pull_request_id")?, + thread_id: Self::string_field(input, "thread_id")?, + resolved: input + .get("resolved") + .and_then(Value::as_bool) + .unwrap_or(true), + }; + let result = ReviewPlatformService::resolve_thread(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_UPDATE_AUTH_TOKEN => { + let platform = Self::platform_kind(input)?; + let host = Self::string_field(input, "host")?; + let token = Self::string_field(input, "token")?; + ReviewPlatformService::update_auth_token(platform, &host, &token) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ + "action": action, + "repositoryPath": repository_path, + "platform": platform, + "host": host, + "status": "ok", + }) + } + ACTION_CLEAR_AUTH_TOKEN => { + let platform = Self::platform_kind(input)?; + let host = Self::string_field(input, "host")?; + ReviewPlatformService::clear_auth_token(platform, &host) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ + "action": action, + "repositoryPath": repository_path, + "platform": platform, + "host": host, + "status": "ok", + }) + } + _ => return Err(BitFunError::tool(format!("Unsupported action: {}", action))), + }; + + let result_for_assistant = self.render_result_for_assistant(&data); + Ok(vec![ToolResult::Result { + data, + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]) + } +} + +impl Default for ReviewPlatformTool { + fn default() -> Self { + Self::new() + } +} + +fn supported_remotes(remotes: &[ReviewPlatformRemote]) -> Vec<&ReviewPlatformRemote> { + remotes.iter().filter(|remote| remote.supported).collect() +} + +fn remote_ambiguity_message(remotes: &[&ReviewPlatformRemote]) -> String { + let mut lines = vec![ + "Multiple supported review platform remotes were found. Provide remote_id explicitly." + .to_string(), + "Candidate remotes:".to_string(), + ]; + lines.extend(remotes.iter().map(|remote| { + format!( + "- remote_id: {} | name: {} | platform: {:?} | project: {} | url: {}", + remote.id, remote.name, remote.platform, remote.project_path, remote.web_url + ) + })); + lines.join("\n") +} diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index 7aa456eab1..85d9a22ddc 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -369,6 +369,7 @@ mod tests { "GetMCPPrompt", "GenerativeUI", "Git", + "ReviewPlatform", "InitMiniApp", "ControlHub", "ComputerUse", @@ -526,6 +527,7 @@ mod tests { assert!(registry.is_tool_collapsed("GetFileDiff")); assert!(!registry.is_tool_collapsed("GetToolSpec")); assert!(registry.is_tool_collapsed("Git")); + assert!(registry.is_tool_collapsed("ReviewPlatform")); } #[test] @@ -550,6 +552,7 @@ mod tests { "GetMCPPrompt", "GenerativeUI", "Git", + "ReviewPlatform", "InitMiniApp", "ControlHub", "ComputerUse", diff --git a/src/crates/core/src/agentic/tools/static_providers.rs b/src/crates/core/src/agentic/tools/static_providers.rs index ae22d2a653..180534cd2c 100644 --- a/src/crates/core/src/agentic/tools/static_providers.rs +++ b/src/crates/core/src/agentic/tools/static_providers.rs @@ -54,6 +54,7 @@ fn materialize_tool(tool_name: &str) -> Arc { "GetMCPPrompt" => Arc::new(GetMCPPromptTool::new()), "GenerativeUI" => Arc::new(GenerativeUITool::new()), "Git" => Arc::new(GitTool::new()), + "ReviewPlatform" => Arc::new(ReviewPlatformTool::new()), "InitMiniApp" => Arc::new(InitMiniAppTool::new()), "ControlHub" => Arc::new(ControlHubTool::new()), "ComputerUse" => Arc::new(ComputerUseTool::new()), diff --git a/src/crates/core/src/service/mod.rs b/src/crates/core/src/service/mod.rs index 98657b44ff..6eae26fe3d 100644 --- a/src/crates/core/src/service/mod.rs +++ b/src/crates/core/src/service/mod.rs @@ -17,6 +17,7 @@ pub mod mcp; // MCP (Model Context Protocol) system pub mod project_context; // Project context management pub mod remote_connect; // Remote Connect (phone → desktop) pub mod remote_ssh; // Remote SSH (desktop → server) +pub mod review_platform; // Pull request review platform adapters pub mod runtime; // Managed runtime and capability management pub mod search; // Workspace search via managed flashgrep daemon pub mod session; // Session persistence @@ -53,6 +54,15 @@ pub use i18n::{get_global_i18n_service, I18nConfig, I18nService, LocaleId, Local pub use lsp::LspManager; pub use mcp::MCPService; pub use project_context::{ContextDocumentStatus, ProjectContextConfig, ProjectContextService}; +pub use review_platform::{ + ReviewAuthSource, ReviewAuthState, ReviewChecks, ReviewDecision, ReviewFileStatus, + ReviewItemState, ReviewPlatformAccount, ReviewPlatformAuthChallenge, + ReviewPlatformAuthChallengeState, ReviewPlatformCapabilities, ReviewPlatformCiLog, + ReviewPlatformCommit, ReviewPlatformError, ReviewPlatformFile, ReviewPlatformKind, + ReviewPlatformPullRequest, ReviewPlatformPullRequestDetail, ReviewPlatformRemote, + ReviewPlatformRepositoryRef, ReviewPlatformService, ReviewPlatformThread, + ReviewPlatformWorkspaceSnapshot, +}; pub use runtime::{ResolvedCommand, RuntimeCommandCapability, RuntimeManager, RuntimeSource}; pub use search::{ get_global_workspace_search_service, set_global_workspace_search_service, ContentSearchRequest, diff --git a/src/crates/core/src/service/review_platform/mod.rs b/src/crates/core/src/service/review_platform/mod.rs new file mode 100644 index 0000000000..19907b7127 --- /dev/null +++ b/src/crates/core/src/service/review_platform/mod.rs @@ -0,0 +1,4863 @@ +//! Platform-neutral pull request review data service. +//! +//! This module owns provider detection, token handling, and provider-specific +//! HTTP calls. UI and desktop adapters consume only the common DTOs below. + +use crate::infrastructure::try_get_path_manager_arc; +use crate::service::git::{execute_git_command, get_repository_root}; +use futures::{stream, StreamExt}; +use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION, USER_AGENT}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::time::Duration; +use tokio::fs; + +const USER_AGENT_VALUE: &str = "ReviewPlatform"; +const DEFAULT_PR_PAGE: u32 = 1; +const DEFAULT_PR_PAGE_SIZE: u32 = 10; +const MAX_PR_PAGE_SIZE: u32 = 50; +const PROVIDER_ENRICH_CONCURRENCY: usize = 4; +const MAX_CI_LOG_CHARS: usize = 80_000; + +#[derive(Debug, thiserror::Error)] +pub enum ReviewPlatformError { + #[error("Invalid repository path: {0}")] + InvalidRepository(String), + #[error("Remote not found: {0}")] + RemoteNotFound(String), + #[error("Unsupported review platform: {0}")] + UnsupportedPlatform(String), + #[error("Provider API failed: {0}")] + Api(String), + #[error("Provider API failed: HTTP {status}{message}")] + Http { status: u16, message: String }, + #[error("Network error: {0}")] + Network(String), + #[error("Parse error: {0}")] + Parse(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewPlatformKind { + Github, + Gitlab, + Gitcode, + Unknown, +} + +impl ReviewPlatformKind { + fn as_str(self) -> &'static str { + match self { + Self::Github => "github", + Self::Gitlab => "gitlab", + Self::Gitcode => "gitcode", + Self::Unknown => "unknown", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewAuthState { + NotConnected, + NotRequired, + Connected, + Expired, + Error, + Unsupported, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewAuthSource { + Env, + Stored, + None, + Unsupported, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewItemState { + Open, + Merged, + Closed, + Draft, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewDecision { + Approved, + ChangesRequested, + Commented, + Pending, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewFileStatus { + Added, + Modified, + Deleted, + Renamed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformAccount { + pub id: String, + pub platform: ReviewPlatformKind, + pub label: String, + pub username: Option, + pub host: String, + pub auth_state: ReviewAuthState, + pub auth_source: ReviewAuthSource, + pub scopes: Vec, + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformRepositoryRef { + pub provider_id: String, + pub platform: ReviewPlatformKind, + pub host: String, + pub owner: String, + pub name: String, + pub project_path: String, + pub default_branch: String, + pub workspace_path: Option, + pub web_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformRemote { + pub id: String, + pub name: String, + pub url: String, + pub platform: ReviewPlatformKind, + pub host: String, + pub owner: String, + pub repository_name: String, + pub project_path: String, + pub web_url: String, + pub supported: bool, + pub auth_state: ReviewAuthState, + pub auth_source: ReviewAuthSource, + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewChecks { + pub total: i32, + pub passed: i32, + pub failed: i32, + pub pending: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformCiItem { + pub id: String, + pub name: String, + pub status: String, + pub conclusion: Option, + pub detail: Option, + pub stage: Option, + pub web_url: Option, + pub log: Option, + pub log_truncated: bool, + pub started_at: Option, + pub finished_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPullRequest { + pub id: String, + pub number: i64, + pub title: String, + pub state: ReviewItemState, + pub author: String, + pub source_branch: String, + pub target_branch: String, + pub updated_at: String, + pub web_url: String, + pub additions: i32, + pub deletions: i32, + pub changed_files: i32, + pub comments: i32, + pub review_decision: ReviewDecision, + pub checks: ReviewChecks, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformFile { + pub path: String, + pub old_path: Option, + pub status: ReviewFileStatus, + pub additions: i32, + pub deletions: i32, + pub patch: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformCommit { + pub hash: String, + pub short_hash: String, + pub title: String, + pub author: String, + pub committed_at: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewPlatformThreadKind { + Review, + Comment, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformThread { + pub id: String, + pub provider_thread_id: Option, + pub provider_comment_id: Option, + pub kind: ReviewPlatformThreadKind, + pub reply_to_provider_comment_id: Option, + pub file_path: Option, + pub line: Option, + pub resolved: bool, + pub author: String, + pub body: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPullRequestDetail { + #[serde(flatten)] + pub pull_request: ReviewPlatformPullRequest, + pub body: String, + pub ci: Vec, + pub files: Vec, + pub commits: Vec, + pub threads: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewPlatformDetailSection { + Overview, + Ci, + Files, + Commits, + Reviews, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPullRequestDetailPage { + #[serde(flatten)] + pub pull_request: ReviewPlatformPullRequest, + pub body: String, + pub ci: Vec, + pub files: Vec, + pub commits: Vec, + pub threads: Vec, + pub section: ReviewPlatformDetailSection, + pub pagination: ReviewPlatformPagination, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformCiLog { + pub ci_item_id: String, + pub log: Option, + pub truncated: bool, + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformCapabilities { + pub can_create_review: bool, + pub can_create_pull_request: bool, + pub can_reply_to_thread: bool, + pub can_resolve_thread: bool, + pub can_approve: bool, + pub can_revoke_approval: bool, + pub can_request_changes: bool, + pub can_merge: bool, + pub supports_draft_review: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewSubmitEvent { + Comment, + Approve, + RequestChanges, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformCreatePullRequestRequest { + pub repository_path: String, + pub remote_id: Option, + pub title: String, + pub source_branch: String, + pub target_branch: String, + pub body: Option, + pub draft: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformReplyToThreadRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub thread_id: String, + pub body: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformSubmitReviewRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub event: ReviewSubmitEvent, + pub body: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformResolveThreadRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub thread_id: String, + pub resolved: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformApprovalRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub body: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformRequestChangesRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub body: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformActionResult { + pub success: bool, + pub message: String, + pub web_url: Option, + pub pull_request: Option, + pub thread: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewPlatformAuthChallengeState { + Missing, + Invalid, + InsufficientScope, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformAuthChallenge { + pub platform: ReviewPlatformKind, + pub host: String, + pub remote_id: String, + pub project_path: String, + pub state: ReviewPlatformAuthChallengeState, + pub message: String, + pub required_scopes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformWorkspaceSnapshot { + pub remotes: Vec, + pub selected_remote_id: Option, + pub accounts: Vec, + pub repository: Option, + pub pull_requests: Vec, + pub pagination: ReviewPlatformPagination, + pub capabilities: ReviewPlatformCapabilities, + pub message: Option, + pub auth_challenge: Option, +} + +pub struct ReviewPlatformService; + +#[derive(Debug, Clone, Copy)] +struct PullRequestPagination { + page: u32, + per_page: u32, +} + +impl PullRequestPagination { + fn new(page: Option, per_page: Option) -> Self { + Self { + page: page.unwrap_or(DEFAULT_PR_PAGE).max(1), + per_page: per_page + .unwrap_or(DEFAULT_PR_PAGE_SIZE) + .clamp(1, MAX_PR_PAGE_SIZE), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPagination { + pub page: u32, + pub per_page: u32, + pub total: Option, + pub has_next: bool, +} + +#[derive(Debug, Clone)] +struct ReviewPlatformPullRequestPage { + items: Vec, + pagination: ReviewPlatformPagination, +} + +#[derive(Debug, Clone)] +struct ProviderContext { + remote: ReviewPlatformRemote, + api_base_url: String, + token: Option, +} + +#[derive(Debug, Clone, Default)] +struct ReviewPlatformAuthTokens { + tokens: HashMap, +} + +impl ReviewPlatformAuthTokens { + fn get(&self, platform: ReviewPlatformKind, host: &str) -> Option<&str> { + token_key(platform, host).and_then(|key| self.tokens.get(&key).map(String::as_str)) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StoredReviewPlatformTokens { + #[serde(default)] + tokens: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StoredReviewPlatformToken { + token: String, + updated_at: String, +} + +impl ReviewPlatformService { + pub async fn discover_remotes( + repository_path: &str, + ) -> Result, ReviewPlatformError> { + let auth_tokens = load_stored_tokens().await?; + Self::discover_remotes_with_tokens(repository_path, &auth_tokens).await + } + + async fn discover_remotes_with_tokens( + repository_path: &str, + auth_tokens: &ReviewPlatformAuthTokens, + ) -> Result, ReviewPlatformError> { + let root = get_repository_root(repository_path) + .map_err(|error| ReviewPlatformError::InvalidRepository(error.to_string()))?; + let output = execute_git_command(&root, &["remote", "-v"]) + .await + .map_err(|error| ReviewPlatformError::InvalidRepository(error.to_string()))?; + + let mut seen = HashSet::new(); + let mut remotes = Vec::new(); + + for line in output.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 2 { + continue; + } + if parts.get(2).is_some_and(|kind| *kind != "(fetch)") { + continue; + } + let remote_name = parts[0]; + let remote_url = parts[1]; + let key = format!("{}|{}", remote_name, remote_url); + if !seen.insert(key) { + continue; + } + if let Some(remote) = parse_remote(remote_name, remote_url, auth_tokens) { + remotes.push(remote); + } + } + + Ok(remotes) + } + + pub async fn workspace_snapshot( + repository_path: &str, + remote_id: Option<&str>, + page: Option, + per_page: Option, + ) -> Result { + if crate::service::remote_ssh::workspace_state::is_remote_path(repository_path).await { + return Ok(empty_snapshot( + Vec::new(), + None, + None, + "Pull request browsing is not available for remote SSH workspaces yet.", + )); + } + + let pagination_request = PullRequestPagination::new(page, per_page); + let auth_tokens = load_stored_tokens().await?; + let root = get_repository_root(repository_path) + .map_err(|error| ReviewPlatformError::InvalidRepository(error.to_string()))?; + let remotes = Self::discover_remotes_with_tokens(&root, &auth_tokens).await?; + let selected_remote = select_remote(&remotes, remote_id).cloned(); + + let Some(remote) = selected_remote else { + return Ok(empty_snapshot( + remotes, + None, + None, + "No Git remotes were found", + )); + }; + + if !remote.supported { + return Ok(empty_snapshot( + remotes, + Some(remote.id.clone()), + Some(account_for_remote(&remote)), + remote + .message + .as_deref() + .unwrap_or("Unsupported remote provider"), + )); + } + + if remote.platform == ReviewPlatformKind::Gitcode + && token_for_remote(&remote, &auth_tokens).is_none() + { + return Ok(empty_snapshot( + remotes, + Some(remote.id.clone()), + Some(account_for_remote(&remote)), + "GitCode pull request APIs require a Personal Access Token. Add a token for this remote and refresh.", + )); + } + + let ctx = provider_context(remote.clone(), &auth_tokens)?; + let provider = provider_for(ctx.remote.platform); + let repository = Some(repository_ref(&ctx.remote, Some(root))); + let account = account_for_remote(&ctx.remote); + let capabilities = capabilities_for_remote(&remote); + match provider.list_pull_requests(&ctx, pagination_request).await { + Ok(page) => Ok(ReviewPlatformWorkspaceSnapshot { + remotes, + selected_remote_id: Some(remote.id.clone()), + accounts: vec![account], + repository, + pull_requests: page.items, + pagination: page.pagination, + capabilities, + message: None, + auth_challenge: None, + }), + Err(error) if is_auth_http_error(&error) => { + let challenge = auth_challenge_for_remote( + &remote, + &error, + token_for_remote(&remote, &auth_tokens).is_some(), + ); + let mut account = account; + account.auth_state = auth_state_for_challenge(challenge.state); + account.auth_source = + if matches!(challenge.state, ReviewPlatformAuthChallengeState::Missing) { + ReviewAuthSource::None + } else { + account.auth_source + }; + account.message = Some(challenge.message.clone()); + Ok(auth_required_snapshot( + remotes, + remote, + repository, + account, + capabilities, + challenge, + )) + } + Err(error) => Err(error), + } + } + + pub async fn pull_request_detail( + repository_path: &str, + remote_id: &str, + pull_request_id: &str, + ) -> Result { + if crate::service::remote_ssh::workspace_state::is_remote_path(repository_path).await { + return Err(ReviewPlatformError::UnsupportedPlatform( + "remote SSH workspace".to_string(), + )); + } + + let auth_tokens = load_stored_tokens().await?; + let root = get_repository_root(repository_path) + .map_err(|error| ReviewPlatformError::InvalidRepository(error.to_string()))?; + let remotes = Self::discover_remotes_with_tokens(&root, &auth_tokens).await?; + let remote = remotes + .into_iter() + .find(|remote| remote.id == remote_id) + .ok_or_else(|| ReviewPlatformError::RemoteNotFound(remote_id.to_string()))?; + if !remote.supported { + return Err(ReviewPlatformError::UnsupportedPlatform(remote.host)); + } + let ctx = provider_context(remote, &auth_tokens)?; + provider_for(ctx.remote.platform) + .pull_request_detail(&ctx, pull_request_id) + .await + } + + pub async fn pull_request_detail_page( + repository_path: &str, + remote_id: &str, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + page: Option, + per_page: Option, + ) -> Result { + let ctx = Self::provider_context_for_repository(repository_path, Some(remote_id)).await?; + provider_for(ctx.remote.platform) + .pull_request_detail_page( + &ctx, + pull_request_id, + section, + PullRequestPagination::new(page, per_page), + ) + .await + } + + pub async fn pull_request_ci_log( + repository_path: &str, + remote_id: &str, + pull_request_id: &str, + ci_item_id: &str, + ci_item_name: &str, + ) -> Result { + let ctx = Self::provider_context_for_repository(repository_path, Some(remote_id)).await?; + provider_for(ctx.remote.platform) + .pull_request_ci_log(&ctx, pull_request_id, ci_item_id, ci_item_name) + .await + } + + pub async fn create_pull_request( + request: ReviewPlatformCreatePullRequestRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + request.remote_id.as_deref(), + ) + .await?; + provider_for(ctx.remote.platform) + .create_pull_request(&ctx, &request) + .await + } + + pub async fn reply_to_thread( + request: ReviewPlatformReplyToThreadRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + Some(request.remote_id.as_str()), + ) + .await?; + provider_for(ctx.remote.platform) + .reply_to_thread(&ctx, &request) + .await + } + + pub async fn submit_review( + request: ReviewPlatformSubmitReviewRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + Some(request.remote_id.as_str()), + ) + .await?; + provider_for(ctx.remote.platform) + .submit_review(&ctx, &request) + .await + } + + pub async fn resolve_thread( + request: ReviewPlatformResolveThreadRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + Some(request.remote_id.as_str()), + ) + .await?; + provider_for(ctx.remote.platform) + .resolve_thread(&ctx, &request) + .await + } + + pub async fn approve_pull_request( + request: ReviewPlatformApprovalRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + Some(request.remote_id.as_str()), + ) + .await?; + provider_for(ctx.remote.platform) + .approve_pull_request(&ctx, &request) + .await + } + + pub async fn revoke_approval( + request: ReviewPlatformApprovalRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + Some(request.remote_id.as_str()), + ) + .await?; + provider_for(ctx.remote.platform) + .revoke_approval(&ctx, &request) + .await + } + + pub async fn request_changes( + request: ReviewPlatformRequestChangesRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + Some(request.remote_id.as_str()), + ) + .await?; + provider_for(ctx.remote.platform) + .request_changes(&ctx, &request) + .await + } + + async fn provider_context_for_repository( + repository_path: &str, + remote_id: Option<&str>, + ) -> Result { + if crate::service::remote_ssh::workspace_state::is_remote_path(repository_path).await { + return Err(ReviewPlatformError::UnsupportedPlatform( + "remote SSH workspace".to_string(), + )); + } + + let auth_tokens = load_stored_tokens().await?; + let root = get_repository_root(repository_path) + .map_err(|error| ReviewPlatformError::InvalidRepository(error.to_string()))?; + let remotes = Self::discover_remotes_with_tokens(&root, &auth_tokens).await?; + let remote = select_remote_for_action(&remotes, remote_id)?.clone(); + if !remote.supported { + return Err(ReviewPlatformError::UnsupportedPlatform(remote.host)); + } + provider_context(remote, &auth_tokens) + } + + pub async fn update_auth_token( + platform: ReviewPlatformKind, + host: &str, + token: &str, + ) -> Result<(), ReviewPlatformError> { + let token = token.trim(); + if token.is_empty() { + return Err(ReviewPlatformError::Api( + "Token cannot be empty".to_string(), + )); + } + let key = token_key(platform, host) + .ok_or_else(|| ReviewPlatformError::UnsupportedPlatform(host.to_string()))?; + let mut stored = load_stored_token_file().await?; + stored.tokens.insert( + key, + StoredReviewPlatformToken { + token: token.to_string(), + updated_at: chrono::Utc::now().to_rfc3339(), + }, + ); + save_stored_token_file(&stored).await + } + + pub async fn clear_auth_token( + platform: ReviewPlatformKind, + host: &str, + ) -> Result<(), ReviewPlatformError> { + let key = token_key(platform, host) + .ok_or_else(|| ReviewPlatformError::UnsupportedPlatform(host.to_string()))?; + let mut stored = load_stored_token_file().await?; + stored.tokens.remove(&key); + save_stored_token_file(&stored).await + } +} + +#[async_trait::async_trait] +trait ReviewProvider: Sync { + async fn list_pull_requests( + &self, + ctx: &ProviderContext, + pagination: PullRequestPagination, + ) -> Result; + + async fn pull_request_detail( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + ) -> Result; + + async fn pull_request_detail_page( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, + ) -> Result { + let detail = self.pull_request_detail(ctx, pull_request_id).await?; + let ci_total = detail.ci.len(); + let file_total = detail.files.len(); + let commit_total = detail.commits.len(); + let thread_total = detail.threads.len(); + let (ci, files, commits, threads) = match section { + ReviewPlatformDetailSection::Overview => { + (Vec::new(), Vec::new(), Vec::new(), Vec::new()) + } + ReviewPlatformDetailSection::Ci => ( + slice_page(detail.ci, pagination), + Vec::new(), + Vec::new(), + Vec::new(), + ), + ReviewPlatformDetailSection::Files => ( + Vec::new(), + slice_page(detail.files, pagination), + Vec::new(), + Vec::new(), + ), + ReviewPlatformDetailSection::Commits => ( + Vec::new(), + Vec::new(), + slice_page(detail.commits, pagination), + Vec::new(), + ), + ReviewPlatformDetailSection::Reviews => ( + Vec::new(), + Vec::new(), + Vec::new(), + slice_page(detail.threads, pagination), + ), + }; + let total = match section { + ReviewPlatformDetailSection::Overview => 0, + ReviewPlatformDetailSection::Ci => ci_total, + ReviewPlatformDetailSection::Files => file_total, + ReviewPlatformDetailSection::Commits => commit_total, + ReviewPlatformDetailSection::Reviews => thread_total, + }; + Ok(ReviewPlatformPullRequestDetailPage { + pull_request: detail.pull_request, + body: detail.body, + ci, + files, + commits, + threads, + section, + pagination: pagination_from_total(pagination, total), + }) + } + + async fn pull_request_ci_log( + &self, + ctx: &ProviderContext, + _pull_request_id: &str, + _ci_item_id: &str, + _ci_item_name: &str, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} CI logs", + platform_label(ctx.remote.platform) + ))) + } + + async fn create_pull_request( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformCreatePullRequestRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} pull request creation", + platform_label(ctx.remote.platform) + ))) + } + + async fn reply_to_thread( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformReplyToThreadRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} thread replies", + platform_label(ctx.remote.platform) + ))) + } + + async fn submit_review( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformSubmitReviewRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} review submission", + platform_label(ctx.remote.platform) + ))) + } + + async fn resolve_thread( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformResolveThreadRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} thread resolution", + platform_label(ctx.remote.platform) + ))) + } + + async fn approve_pull_request( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformApprovalRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} pull request approval", + platform_label(ctx.remote.platform) + ))) + } + + async fn revoke_approval( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformApprovalRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} approval revocation", + platform_label(ctx.remote.platform) + ))) + } + + async fn request_changes( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformRequestChangesRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} native change requests", + platform_label(ctx.remote.platform) + ))) + } +} + +struct GithubProvider; +struct GitlabProvider; +struct GitcodeProvider; +struct UnsupportedProvider; + +fn provider_for(platform: ReviewPlatformKind) -> &'static dyn ReviewProvider { + match platform { + ReviewPlatformKind::Github => &GithubProvider, + ReviewPlatformKind::Gitlab => &GitlabProvider, + ReviewPlatformKind::Gitcode => &GitcodeProvider, + ReviewPlatformKind::Unknown => &UnsupportedProvider, + } +} + +#[async_trait::async_trait] +impl ReviewProvider for GithubProvider { + async fn list_pull_requests( + &self, + ctx: &ProviderContext, + pagination: PullRequestPagination, + ) -> Result { + let url = format!( + "{}/repos/{}/{}/pulls", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name + ); + let per_page = pagination.per_page.to_string(); + let page = pagination.page.to_string(); + let response = send_json_response( + github_request(http_client()?, &url, ctx.token.as_deref()).query(&[ + ("state", "all"), + ("per_page", &per_page), + ("page", &page), + ]), + ) + .await?; + let items = response.value.as_array().ok_or_else(|| { + ReviewPlatformError::Parse("GitHub pull response was not an array".to_string()) + })?; + let total = pagination_total_from_links(&response.headers, pagination, items.len()); + let has_next = link_header_has_rel(&response.headers, "next"); + + let pull_requests = items + .iter() + .map(github_pull_request_from_value) + .collect::>(); + let pull_requests = enrich_github_pull_request_counts(ctx, pull_requests).await; + + Ok(ReviewPlatformPullRequestPage { + items: pull_requests, + pagination: ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total, + has_next, + }, + }) + } + + async fn pull_request_detail( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + ) -> Result { + let base = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let client = http_client()?; + let detail = send_json(github_request(client.clone(), &base, ctx.token.as_deref())).await?; + let token = ctx.token.clone(); + let files_url = format!("{}/files", base); + let files = fetch_paginated_array( + |page| { + let page = page.to_string(); + github_request(client.clone(), &files_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await?; + let token = ctx.token.clone(); + let commits_url = format!("{}/commits", base); + let commits = fetch_paginated_array( + |page| { + let page = page.to_string(); + github_request(client.clone(), &commits_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await?; + let token = ctx.token.clone(); + let reviews_url = format!("{}/reviews", base); + let reviews = fetch_paginated_array( + |page| { + let page = page.to_string(); + github_request(client.clone(), &reviews_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await?; + let token = ctx.token.clone(); + let review_comments_url = format!("{}/comments", base); + let review_comments = fetch_paginated_array( + |page| { + let page = page.to_string(); + github_request(client.clone(), &review_comments_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await?; + let token = ctx.token.clone(); + let issue_comments_url = format!( + "{}/repos/{}/{}/issues/{}/comments", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let issue_comments = fetch_paginated_array( + |page| { + let page = page.to_string(); + github_request(client.clone(), &issue_comments_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await?; + + let mut pull_request = github_pull_request_from_value(&detail); + pull_request.review_decision = github_review_decision(&reviews); + let (checks, ci) = github_checks_and_ci(ctx, &client, &detail).await; + pull_request.checks = checks; + + Ok(ReviewPlatformPullRequestDetail { + body: value_string(&detail, "body"), + pull_request, + ci, + files: array_items(&files) + .iter() + .map(github_file_from_value) + .collect(), + commits: array_items(&commits) + .iter() + .map(github_commit_from_value) + .collect(), + threads: github_threads(&reviews, &review_comments, &issue_comments), + }) + } + + async fn pull_request_detail_page( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, + ) -> Result { + github_pull_request_detail_page(ctx, pull_request_id, section, pagination).await + } + + async fn pull_request_ci_log( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + ci_item_id: &str, + ci_item_name: &str, + ) -> Result { + if ci_item_id.starts_with("status-") { + return Ok(ReviewPlatformCiLog { + ci_item_id: ci_item_id.to_string(), + log: None, + truncated: false, + message: Some( + "GitHub commit statuses do not expose logs; use the linked target URL instead." + .to_string(), + ), + }); + } + + let client = http_client()?; + let base = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let detail = send_json(github_request(client.clone(), &base, ctx.token.as_deref())).await?; + let sha = nested_string(&detail, &["head", "sha"]); + if sha.trim().is_empty() { + return Ok(ReviewPlatformCiLog { + ci_item_id: ci_item_id.to_string(), + log: None, + truncated: false, + message: Some("GitHub pull request head SHA was not available.".to_string()), + }); + } + + let check_run_id = ci_item_id.strip_prefix("check-run-").unwrap_or(ci_item_id); + github_actions_log_for_check_run_item(ctx, &client, check_run_id, ci_item_name, &sha).await + } + + async fn create_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformCreatePullRequestRequest, + ) -> Result { + let token = require_write_token(ctx, "Creating a pull request")?; + let url = format!( + "{}/repos/{}/{}/pulls", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name + ); + let payload = json!({ + "title": request.title, + "head": request.source_branch, + "base": request.target_branch, + "body": request.body.clone().unwrap_or_default(), + "draft": request.draft.unwrap_or(false), + }); + let value = + send_json(github_post_request(http_client()?, &url, Some(token)).json(&payload)) + .await?; + let pull_request = github_pull_request_from_value(&value); + let web_url = Some(pull_request.web_url.clone()); + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Created pull request #{}", pull_request.number), + web_url, + pull_request: Some(pull_request), + thread: None, + }) + } + + async fn reply_to_thread( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformReplyToThreadRequest, + ) -> Result { + let token = require_write_token(ctx, "Replying to a pull request thread")?; + let comment_id = parse_provider_comment_id(&request.thread_id).ok_or_else(|| { + ReviewPlatformError::Api( + "GitHub replies require a review comment thread id such as comment-123".to_string(), + ) + })?; + let url = format!( + "{}/repos/{}/{}/pulls/{}/comments/{}/replies", + ctx.api_base_url, + ctx.remote.owner, + ctx.remote.repository_name, + request.pull_request_id, + comment_id + ); + let value = send_json( + github_post_request(http_client()?, &url, Some(token)) + .json(&json!({ "body": request.body })), + ) + .await?; + let thread = github_thread_from_review_comment(&value); + Ok(ReviewPlatformActionResult { + success: true, + message: "Replied to pull request thread".to_string(), + web_url: value + .get("html_url") + .and_then(Value::as_str) + .map(str::to_string), + pull_request: None, + thread: Some(thread), + }) + } + + async fn submit_review( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformSubmitReviewRequest, + ) -> Result { + let event = match request.event { + ReviewSubmitEvent::Comment => "COMMENT", + ReviewSubmitEvent::Approve => "APPROVE", + ReviewSubmitEvent::RequestChanges => "REQUEST_CHANGES", + }; + github_submit_review(ctx, &request.pull_request_id, event, &request.body).await + } + + async fn approve_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + ) -> Result { + github_submit_review( + ctx, + &request.pull_request_id, + "APPROVE", + request.body.as_deref().unwrap_or(""), + ) + .await + } + + async fn request_changes( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformRequestChangesRequest, + ) -> Result { + github_submit_review( + ctx, + &request.pull_request_id, + "REQUEST_CHANGES", + &request.body, + ) + .await + } +} + +async fn github_submit_review( + ctx: &ProviderContext, + pull_request_id: &str, + event: &str, + body: &str, +) -> Result { + let token = require_write_token(ctx, "Submitting a pull request review")?; + let url = format!( + "{}/repos/{}/{}/pulls/{}/reviews", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let value = send_json( + github_post_request(http_client()?, &url, Some(token)).json(&json!({ + "body": body, + "event": event, + })), + ) + .await?; + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Submitted GitHub review with event {}", event), + web_url: value + .get("html_url") + .and_then(Value::as_str) + .map(str::to_string), + pull_request: None, + thread: None, + }) +} + +async fn github_pull_request_detail_page( + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, +) -> Result { + let client = http_client()?; + let base = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let detail = send_json(github_request(client.clone(), &base, ctx.token.as_deref())).await?; + let mut pull_request = github_pull_request_from_value(&detail); + let (checks, ci_all) = github_checks_and_ci(ctx, &client, &detail).await; + pull_request.checks = checks; + + let mut files = Vec::new(); + let mut commits = Vec::new(); + let mut threads = Vec::new(); + let mut ci = Vec::new(); + let mut section_pagination = empty_detail_pagination(section, pagination); + + match section { + ReviewPlatformDetailSection::Overview => {} + ReviewPlatformDetailSection::Ci => { + section_pagination = pagination_from_total(pagination, ci_all.len()); + ci = slice_page(ci_all, pagination); + } + ReviewPlatformDetailSection::Files => { + let response = fetch_array_page( + github_request( + client.clone(), + &format!("{}/files", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + section_pagination = pagination_from_response(&response, pagination); + files = array_items(&response.value) + .iter() + .map(github_file_from_value) + .collect(); + } + ReviewPlatformDetailSection::Commits => { + let response = fetch_array_page( + github_request( + client.clone(), + &format!("{}/commits", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + section_pagination = pagination_from_response(&response, pagination); + commits = array_items(&response.value) + .iter() + .map(github_commit_from_value) + .collect(); + } + ReviewPlatformDetailSection::Reviews => { + let reviews_url = format!("{}/reviews", base); + let reviews = fetch_array_page( + github_request(client.clone(), &reviews_url, ctx.token.as_deref()), + pagination, + ) + .await?; + let review_comments = fetch_array_page( + github_request( + client.clone(), + &format!("{}/comments", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + let issue_comments = fetch_array_page( + github_request( + client.clone(), + &format!( + "{}/repos/{}/{}/issues/{}/comments", + ctx.api_base_url, + ctx.remote.owner, + ctx.remote.repository_name, + pull_request_id + ), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + pull_request.review_decision = github_review_decision(&reviews.value); + section_pagination = combine_page_pagination( + pagination, + &[ + pagination_from_response(&reviews, pagination), + pagination_from_response(&review_comments, pagination), + pagination_from_response(&issue_comments, pagination), + ], + ); + threads = github_threads( + &reviews.value, + &review_comments.value, + &issue_comments.value, + ); + } + } + + Ok(ReviewPlatformPullRequestDetailPage { + pull_request, + body: value_string(&detail, "body"), + ci, + files, + commits, + threads, + section, + pagination: section_pagination, + }) +} + +#[async_trait::async_trait] +impl ReviewProvider for GitlabProvider { + async fn list_pull_requests( + &self, + ctx: &ProviderContext, + pagination: PullRequestPagination, + ) -> Result { + gitlab_list_pull_requests(ctx, pagination).await + } + + async fn pull_request_detail( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + ) -> Result { + gitlab_pull_request_detail(ctx, pull_request_id).await + } + + async fn pull_request_detail_page( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, + ) -> Result { + gitlab_pull_request_detail_page(ctx, pull_request_id, section, pagination).await + } + + async fn pull_request_ci_log( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + ci_item_id: &str, + ci_item_name: &str, + ) -> Result { + gitlab_pull_request_ci_log(ctx, pull_request_id, ci_item_id, ci_item_name).await + } + + async fn create_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformCreatePullRequestRequest, + ) -> Result { + gitlab_create_pull_request(ctx, request, "merge request").await + } + + async fn reply_to_thread( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformReplyToThreadRequest, + ) -> Result { + gitlab_reply_to_thread(ctx, request, "merge request").await + } + + async fn submit_review( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformSubmitReviewRequest, + ) -> Result { + if request.event != ReviewSubmitEvent::Comment { + return Err(ReviewPlatformError::UnsupportedPlatform( + "GitLab submit_review supports comments only; use approve_pull_request for approvals" + .to_string(), + )); + } + gitlab_add_merge_request_note( + ctx, + &request.pull_request_id, + &request.body, + "Added merge request comment", + ) + .await + } + + async fn resolve_thread( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformResolveThreadRequest, + ) -> Result { + gitlab_resolve_thread(ctx, request, "merge request").await + } + + async fn approve_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + ) -> Result { + gitlab_approve_pull_request(ctx, request, "merge request").await + } + + async fn revoke_approval( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + ) -> Result { + gitlab_revoke_approval(ctx, request, "merge request").await + } +} + +async fn gitlab_list_pull_requests( + ctx: &ProviderContext, + pagination: PullRequestPagination, +) -> Result { + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!("{}/projects/{}/merge_requests", ctx.api_base_url, project); + let per_page = pagination.per_page.to_string(); + let page = pagination.page.to_string(); + let response = send_json_response( + gitlab_request(http_client()?, &url, ctx.token.as_deref()).query(&[ + ("state", "all"), + ("per_page", &per_page), + ("page", &page), + ]), + ) + .await?; + let items = response.value.as_array().ok_or_else(|| { + ReviewPlatformError::Parse("GitLab merge request response was not an array".to_string()) + })?; + let total = header_u64(&response.headers, "x-total"); + let has_next = header_string(&response.headers, "x-next-page") + .is_some_and(|value| !value.trim().is_empty()) + || total + .map(|total| u64::from(pagination.page) * u64::from(pagination.per_page) < total) + .unwrap_or(false); + + let pull_requests = items + .iter() + .map(gitlab_pull_request_from_value) + .collect::>(); + let pull_requests = enrich_gitlab_pull_request_counts(ctx, pull_requests).await; + + Ok(ReviewPlatformPullRequestPage { + items: pull_requests, + pagination: ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total, + has_next, + }, + }) +} + +async fn gitlab_pull_request_detail( + ctx: &ProviderContext, + pull_request_id: &str, +) -> Result { + let client = http_client()?; + let project = urlencoding::encode(&ctx.remote.project_path); + let base = format!( + "{}/projects/{}/merge_requests/{}", + ctx.api_base_url, project, pull_request_id + ); + let detail = send_json(gitlab_request(client.clone(), &base, ctx.token.as_deref())).await?; + let changes = send_json(gitlab_request( + client.clone(), + &format!("{}/changes", base), + ctx.token.as_deref(), + )) + .await?; + let token = ctx.token.clone(); + let commits_url = format!("{}/commits", base); + let commits = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitlab_request(client.clone(), &commits_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + gitlab_next_page, + ) + .await?; + let token = ctx.token.clone(); + let discussions_url = format!("{}/discussions", base); + let discussions = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitlab_request(client.clone(), &discussions_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + gitlab_next_page, + ) + .await?; + let token = ctx.token.clone(); + let notes_url = format!("{}/notes", base); + let notes = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitlab_request(client.clone(), ¬es_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + gitlab_next_page, + ) + .await?; + + let mut pull_request = gitlab_pull_request_from_value(&detail); + let files = gitlab_files(&changes); + apply_files_stats(&mut pull_request, &files); + let ci = gitlab_pipeline_summary_item(&detail) + .into_iter() + .collect::>(); + pull_request.checks = summarize_ci_items(&ci); + + Ok(ReviewPlatformPullRequestDetail { + body: value_string(&detail, "description"), + pull_request, + ci, + files, + commits: array_items(&commits) + .iter() + .map(gitlab_commit_from_value) + .collect(), + threads: gitlab_threads(&discussions, ¬es), + }) +} + +async fn gitlab_pull_request_detail_page( + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, +) -> Result { + let client = http_client()?; + let project = urlencoding::encode(&ctx.remote.project_path); + let base = format!( + "{}/projects/{}/merge_requests/{}", + ctx.api_base_url, project, pull_request_id + ); + let detail = send_json(gitlab_request(client.clone(), &base, ctx.token.as_deref())).await?; + let mut pull_request = gitlab_pull_request_from_value(&detail); + let changes = send_json(gitlab_request( + client.clone(), + &format!("{}/changes", base), + ctx.token.as_deref(), + )) + .await?; + let all_files = gitlab_files(&changes); + apply_files_stats(&mut pull_request, &all_files); + let mut ci = gitlab_pipeline_summary_item(&detail) + .into_iter() + .collect::>(); + pull_request.checks = summarize_ci_items(&ci); + let mut files = Vec::new(); + let mut commits = Vec::new(); + let mut threads = Vec::new(); + let mut section_pagination = empty_detail_pagination(section, pagination); + + match section { + ReviewPlatformDetailSection::Overview => {} + ReviewPlatformDetailSection::Ci => { + if let Some(pipeline_id) = detail + .get("head_pipeline") + .and_then(|value| value.get("id")) + .and_then(Value::as_i64) + .map(|id| id.to_string()) + .or_else(|| { + detail + .get("head_pipeline") + .and_then(|value| value.get("id")) + .and_then(Value::as_str) + .map(str::to_string) + }) + { + let jobs = gitlab_pipeline_jobs( + ctx, + client.clone(), + &urlencoding::encode(&ctx.remote.project_path), + &pipeline_id, + ) + .await; + if !jobs.is_empty() { + ci = jobs; + pull_request.checks = summarize_ci_items(&ci); + } + } + section_pagination = pagination_from_total(pagination, ci.len()); + ci = slice_page(ci, pagination); + } + ReviewPlatformDetailSection::Files => { + section_pagination = pagination_from_total(pagination, all_files.len()); + files = slice_page(all_files, pagination); + } + ReviewPlatformDetailSection::Commits => { + let response = fetch_array_page( + gitlab_request( + client.clone(), + &format!("{}/commits", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + section_pagination = pagination_from_response(&response, pagination); + commits = array_items(&response.value) + .iter() + .map(gitlab_commit_from_value) + .collect(); + } + ReviewPlatformDetailSection::Reviews => { + let discussions = fetch_array_page( + gitlab_request( + client.clone(), + &format!("{}/discussions", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + let notes = fetch_array_page( + gitlab_request( + client.clone(), + &format!("{}/notes", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + section_pagination = combine_page_pagination( + pagination, + &[ + pagination_from_response(&discussions, pagination), + pagination_from_response(¬es, pagination), + ], + ); + threads = gitlab_threads(&discussions.value, ¬es.value); + } + } + + Ok(ReviewPlatformPullRequestDetailPage { + pull_request, + body: value_string(&detail, "description"), + ci, + files, + commits, + threads, + section, + pagination: section_pagination, + }) +} + +async fn gitlab_create_pull_request( + ctx: &ProviderContext, + request: &ReviewPlatformCreatePullRequestRequest, + label: &str, +) -> Result { + let token = require_write_token(ctx, &format!("Creating a {}", label))?; + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!("{}/projects/{}/merge_requests", ctx.api_base_url, project); + let value = send_json( + gitlab_post_request(http_client()?, &url, Some(token)).json(&json!({ + "title": request.title, + "source_branch": request.source_branch, + "target_branch": request.target_branch, + "description": request.body.clone().unwrap_or_default(), + })), + ) + .await?; + let pull_request = gitlab_pull_request_from_value(&value); + let web_url = Some(pull_request.web_url.clone()); + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Created {} !{}", label, pull_request.number), + web_url, + pull_request: Some(pull_request), + thread: None, + }) +} + +async fn gitlab_reply_to_thread( + ctx: &ProviderContext, + request: &ReviewPlatformReplyToThreadRequest, + label: &str, +) -> Result { + let token = require_write_token(ctx, &format!("Replying to a {} thread", label))?; + let discussion_id = parse_provider_thread_id(&request.thread_id).ok_or_else(|| { + ReviewPlatformError::Api( + "Replies require a discussion thread id from pull request detail".to_string(), + ) + })?; + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!( + "{}/projects/{}/merge_requests/{}/discussions/{}/notes", + ctx.api_base_url, project, request.pull_request_id, discussion_id + ); + let value = send_json( + gitlab_post_request(http_client()?, &url, Some(token)) + .json(&json!({ "body": request.body })), + ) + .await?; + let thread = gitlab_thread_from_note( + &value, + Some(discussion_id.to_string()), + false, + ReviewPlatformThreadKind::Comment, + None, + ); + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Replied to {} discussion", label), + web_url: None, + pull_request: None, + thread: Some(thread), + }) +} + +async fn gitlab_add_merge_request_note( + ctx: &ProviderContext, + pull_request_id: &str, + body: &str, + message: &str, +) -> Result { + let token = require_write_token(ctx, "Adding a merge request comment")?; + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!( + "{}/projects/{}/merge_requests/{}/notes", + ctx.api_base_url, project, pull_request_id + ); + let value = send_json( + gitlab_post_request(http_client()?, &url, Some(token)).json(&json!({ "body": body })), + ) + .await?; + let thread = + gitlab_thread_from_note(&value, None, false, ReviewPlatformThreadKind::Comment, None); + Ok(ReviewPlatformActionResult { + success: true, + message: message.to_string(), + web_url: None, + pull_request: None, + thread: Some(thread), + }) +} + +async fn gitlab_resolve_thread( + ctx: &ProviderContext, + request: &ReviewPlatformResolveThreadRequest, + label: &str, +) -> Result { + let token = require_write_token(ctx, &format!("Resolving a {} thread", label))?; + let discussion_id = parse_provider_thread_id(&request.thread_id).ok_or_else(|| { + ReviewPlatformError::Api( + "Thread resolution requires a discussion thread id from pull request detail" + .to_string(), + ) + })?; + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!( + "{}/projects/{}/merge_requests/{}/discussions/{}", + ctx.api_base_url, project, request.pull_request_id, discussion_id + ); + send_json( + gitlab_put_request(http_client()?, &url, Some(token)) + .json(&json!({ "resolved": request.resolved })), + ) + .await?; + Ok(ReviewPlatformActionResult { + success: true, + message: if request.resolved { + format!("Resolved {} discussion", label) + } else { + format!("Reopened {} discussion", label) + }, + web_url: None, + pull_request: None, + thread: None, + }) +} + +async fn gitlab_approve_pull_request( + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + label: &str, +) -> Result { + let token = require_write_token(ctx, &format!("Approving a {}", label))?; + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!( + "{}/projects/{}/merge_requests/{}/approve", + ctx.api_base_url, project, request.pull_request_id + ); + send_json(gitlab_post_request(http_client()?, &url, Some(token))).await?; + if let Some(body) = request + .body + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + let _ = gitlab_add_merge_request_note( + ctx, + &request.pull_request_id, + body, + "Added approval note", + ) + .await; + } + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Approved {}", label), + web_url: None, + pull_request: None, + thread: None, + }) +} + +async fn gitlab_revoke_approval( + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + label: &str, +) -> Result { + let token = require_write_token(ctx, &format!("Revoking approval for a {}", label))?; + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!( + "{}/projects/{}/merge_requests/{}/unapprove", + ctx.api_base_url, project, request.pull_request_id + ); + send_json(gitlab_post_request(http_client()?, &url, Some(token))).await?; + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Revoked approval for {}", label), + web_url: None, + pull_request: None, + thread: None, + }) +} + +async fn gitcode_add_pull_request_comment( + ctx: &ProviderContext, + pull_request_id: &str, + body: &str, +) -> Result { + let token = require_write_token(ctx, "Adding a GitCode pull request comment")?; + let url = format!( + "{}/repos/{}/{}/pulls/{}/comments", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let value = send_json( + gitcode_post_request(http_client()?, &url, Some(token)).json(&json!({ "body": body })), + ) + .await?; + let thread = gitcode_threads(&Value::Array(vec![value])) + .into_iter() + .next(); + Ok(ReviewPlatformActionResult { + success: true, + message: "Added GitCode pull request comment".to_string(), + web_url: None, + pull_request: None, + thread, + }) +} + +async fn gitcode_pull_request_detail_page( + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, +) -> Result { + let client = http_client()?; + let base = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let detail = send_json(gitcode_request(client.clone(), &base, ctx.token.as_deref())).await?; + let mut ci = gitcode_ci_items(&detail); + let mut pull_request = gitcode_pull_request_from_value(&detail); + pull_request.checks = summarize_ci_items(&ci); + let mut files = Vec::new(); + let mut commits = Vec::new(); + let mut threads = Vec::new(); + let mut section_pagination = empty_detail_pagination(section, pagination); + + match section { + ReviewPlatformDetailSection::Overview => {} + ReviewPlatformDetailSection::Ci => { + section_pagination = pagination_from_total(pagination, ci.len()); + ci = slice_page(ci, pagination); + } + ReviewPlatformDetailSection::Files => { + if let Ok(response) = fetch_array_page( + gitcode_request( + client.clone(), + &format!("{}/files", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await + { + section_pagination = pagination_from_response(&response, pagination); + files = array_items(&response.value) + .iter() + .map(gitcode_file_from_value) + .collect(); + } + } + ReviewPlatformDetailSection::Commits => { + if let Ok(response) = fetch_array_page( + gitcode_request( + client.clone(), + &format!("{}/commits", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await + { + section_pagination = pagination_from_response(&response, pagination); + commits = array_items(&response.value) + .iter() + .map(gitcode_commit_from_value) + .collect(); + } + } + ReviewPlatformDetailSection::Reviews => { + if let Ok(response) = fetch_array_page( + gitcode_request( + client.clone(), + &format!("{}/comments", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await + { + section_pagination = pagination_from_response(&response, pagination); + threads = gitcode_threads(&response.value); + } + } + } + + Ok(ReviewPlatformPullRequestDetailPage { + body: first_non_empty(&[ + value_string(&detail, "body"), + value_string(&detail, "description"), + ]), + pull_request, + ci, + files, + commits, + threads, + section, + pagination: section_pagination, + }) +} + +#[async_trait::async_trait] +impl ReviewProvider for GitcodeProvider { + async fn list_pull_requests( + &self, + ctx: &ProviderContext, + pagination: PullRequestPagination, + ) -> Result { + let url = format!( + "{}/repos/{}/{}/pulls", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name + ); + let per_page = pagination.per_page.to_string(); + let page = pagination.page.to_string(); + let response = send_json_response( + gitcode_request(http_client()?, &url, ctx.token.as_deref()).query(&[ + ("state", "all"), + ("per_page", &per_page), + ("page", &page), + ]), + ) + .await?; + let items = response.value.as_array().ok_or_else(|| { + ReviewPlatformError::Parse("GitCode pull response was not an array".to_string()) + })?; + let total = header_u64(&response.headers, "x-total").or_else(|| { + link_header_last_page(&response.headers).map(|last_page| { + if last_page == pagination.page { + (u64::from(last_page.saturating_sub(1)) * u64::from(pagination.per_page)) + + items.len() as u64 + } else { + u64::from(last_page) * u64::from(pagination.per_page) + } + }) + }); + let has_next = link_header_has_rel(&response.headers, "next") + || total + .map(|total| u64::from(pagination.page) * u64::from(pagination.per_page) < total) + .unwrap_or(items.len() == pagination.per_page as usize); + + let pull_requests = items + .iter() + .map(gitcode_pull_request_from_value) + .collect::>(); + let pull_requests = enrich_gitcode_pull_request_counts(ctx, pull_requests).await; + + Ok(ReviewPlatformPullRequestPage { + items: pull_requests, + pagination: ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total, + has_next, + }, + }) + } + + async fn pull_request_detail( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + ) -> Result { + let client = http_client()?; + let base = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let detail = + send_json(gitcode_request(client.clone(), &base, ctx.token.as_deref())).await?; + let token = ctx.token.clone(); + let files_url = format!("{}/files", base); + let files = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitcode_request(client.clone(), &files_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await + .unwrap_or(Value::Array(Vec::new())); + let token = ctx.token.clone(); + let commits_url = format!("{}/commits", base); + let commits = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitcode_request(client.clone(), &commits_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await + .unwrap_or(Value::Array(Vec::new())); + let token = ctx.token.clone(); + let comments_url = format!("{}/comments", base); + let comments = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitcode_request(client.clone(), &comments_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await + .unwrap_or(Value::Array(Vec::new())); + let ci = gitcode_ci_items(&detail); + let mut pull_request = gitcode_pull_request_from_value(&detail); + pull_request.checks = summarize_ci_items(&ci); + + Ok(ReviewPlatformPullRequestDetail { + body: first_non_empty(&[ + value_string(&detail, "body"), + value_string(&detail, "description"), + ]), + pull_request, + ci, + files: array_items(&files) + .iter() + .map(gitcode_file_from_value) + .collect(), + commits: array_items(&commits) + .iter() + .map(gitcode_commit_from_value) + .collect(), + threads: gitcode_threads(&comments), + }) + } + + async fn pull_request_detail_page( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, + ) -> Result { + gitcode_pull_request_detail_page(ctx, pull_request_id, section, pagination).await + } + + async fn pull_request_ci_log( + &self, + _ctx: &ProviderContext, + _pull_request_id: &str, + ci_item_id: &str, + _ci_item_name: &str, + ) -> Result { + Ok(ReviewPlatformCiLog { + ci_item_id: ci_item_id.to_string(), + log: None, + truncated: false, + message: Some( + "GitCode CI log retrieval is not available through a documented API.".to_string(), + ), + }) + } + + async fn create_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformCreatePullRequestRequest, + ) -> Result { + let token = require_write_token(ctx, "Creating a GitCode pull request")?; + let url = format!( + "{}/repos/{}/{}/pulls", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name + ); + let value = send_json( + gitcode_post_request(http_client()?, &url, Some(token)).json(&json!({ + "title": request.title, + "head": request.source_branch, + "base": request.target_branch, + "body": request.body.clone().unwrap_or_default(), + "draft": request.draft.unwrap_or(false), + })), + ) + .await?; + let pull_request = gitcode_pull_request_from_value(&value); + let web_url = Some(pull_request.web_url.clone()); + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Created GitCode pull request #{}", pull_request.number), + web_url, + pull_request: Some(pull_request), + thread: None, + }) + } + + async fn submit_review( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformSubmitReviewRequest, + ) -> Result { + if request.event != ReviewSubmitEvent::Comment { + return Err(ReviewPlatformError::UnsupportedPlatform( + "GitCode submit_review supports comments only; use approve_pull_request for review processing" + .to_string(), + )); + } + gitcode_add_pull_request_comment(ctx, &request.pull_request_id, &request.body).await + } + + async fn approve_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + ) -> Result { + let token = require_write_token(ctx, "Approving a GitCode pull request")?; + let url = format!( + "{}/repos/{}/{}/pulls/{}/review", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, request.pull_request_id + ); + send_json( + gitcode_post_request(http_client()?, &url, Some(token)) + .json(&json!({ "force": false })), + ) + .await?; + if let Some(body) = request + .body + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + let _ = gitcode_add_pull_request_comment(ctx, &request.pull_request_id, body).await; + } + Ok(ReviewPlatformActionResult { + success: true, + message: "Approved GitCode pull request".to_string(), + web_url: None, + pull_request: None, + thread: None, + }) + } +} + +#[async_trait::async_trait] +impl ReviewProvider for UnsupportedProvider { + async fn list_pull_requests( + &self, + ctx: &ProviderContext, + _pagination: PullRequestPagination, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform( + ctx.remote.host.clone(), + )) + } + + async fn pull_request_detail( + &self, + ctx: &ProviderContext, + _pull_request_id: &str, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform( + ctx.remote.host.clone(), + )) + } +} + +fn http_client() -> Result { + reqwest::Client::builder() + .use_native_tls() + .timeout(Duration::from_secs(25)) + .build() + .map_err(|error| ReviewPlatformError::Network(error.to_string())) +} + +struct JsonResponse { + value: Value, + headers: HeaderMap, +} + +async fn send_json(request: reqwest::RequestBuilder) -> Result { + send_json_response(request) + .await + .map(|response| response.value) +} + +async fn send_json_response( + request: reqwest::RequestBuilder, +) -> Result { + let response = request + .send() + .await + .map_err(|error| ReviewPlatformError::Network(error.to_string()))?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let preview = body.chars().take(280).collect::(); + return Err(ReviewPlatformError::Http { + status: status.as_u16(), + message: preview, + }); + } + let headers = response.headers().clone(); + let value = response + .json::() + .await + .map_err(|error| ReviewPlatformError::Parse(error.to_string()))?; + Ok(JsonResponse { value, headers }) +} + +async fn send_text(request: reqwest::RequestBuilder) -> Result { + let response = request + .send() + .await + .map_err(|error| ReviewPlatformError::Network(error.to_string()))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| ReviewPlatformError::Network(error.to_string()))?; + if !status.is_success() { + let preview = text.chars().take(280).collect::(); + return Err(ReviewPlatformError::Http { + status: status.as_u16(), + message: preview, + }); + } + Ok(text) +} + +async fn fetch_paginated_array( + mut build_request: F, + next_page: fn(&HeaderMap, u32) -> Option, +) -> Result +where + F: FnMut(u32) -> reqwest::RequestBuilder, +{ + let mut page = 1; + let mut values = Vec::new(); + + loop { + let response = send_json_response(build_request(page)).await?; + let items = response.value.as_array().ok_or_else(|| { + ReviewPlatformError::Parse("Provider paginated response was not an array".to_string()) + })?; + values.extend(items.iter().cloned()); + + let Some(next) = next_page(&response.headers, page).filter(|next| *next > page) else { + break; + }; + page = next; + } + + Ok(Value::Array(values)) +} + +async fn fetch_array_page( + request: reqwest::RequestBuilder, + pagination: PullRequestPagination, +) -> Result { + let page = pagination.page.to_string(); + let per_page = pagination.per_page.to_string(); + let response = + send_json_response(request.query(&[("per_page", &per_page), ("page", &page)])).await?; + response.value.as_array().ok_or_else(|| { + ReviewPlatformError::Parse("Provider paginated response was not an array".to_string()) + })?; + Ok(response) +} + +fn pagination_from_response( + response: &JsonResponse, + pagination: PullRequestPagination, +) -> ReviewPlatformPagination { + let item_count = response.value.as_array().map(Vec::len).unwrap_or(0); + let total = header_u64(&response.headers, "x-total") + .or_else(|| pagination_total_from_links(&response.headers, pagination, item_count)); + ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total, + has_next: link_header_has_rel(&response.headers, "next") + || header_string(&response.headers, "x-next-page") + .is_some_and(|value| !value.trim().is_empty()) + || total + .map(|total| u64::from(pagination.page) * u64::from(pagination.per_page) < total) + .unwrap_or(false), + } +} + +fn combine_page_pagination( + pagination: PullRequestPagination, + pages: &[ReviewPlatformPagination], +) -> ReviewPlatformPagination { + let totals = if pages.iter().any(|page| page.has_next) { + None + } else { + pages + .iter() + .map(|page| page.total) + .collect::>>() + .map(|values| values.into_iter().sum()) + }; + ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total: totals, + has_next: pages.iter().any(|page| page.has_next), + } +} + +fn header_string(headers: &HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) +} + +fn header_u64(headers: &HeaderMap, name: &str) -> Option { + header_string(headers, name).and_then(|value| value.parse::().ok()) +} + +fn link_header_has_rel(headers: &HeaderMap, rel: &str) -> bool { + header_string(headers, "link") + .as_deref() + .is_some_and(|value| { + value + .split(',') + .any(|part| part.contains(&format!("rel=\"{}\"", rel))) + }) +} + +fn link_header_last_page(headers: &HeaderMap) -> Option { + let link = header_string(headers, "link")?; + for part in link.split(',') { + if !part.contains("rel=\"last\"") { + continue; + } + let url = part + .split(';') + .next()? + .trim() + .trim_start_matches('<') + .trim_end_matches('>'); + return query_param_u32(url, "page"); + } + None +} + +fn pagination_total_from_links( + headers: &HeaderMap, + pagination: PullRequestPagination, + item_count: usize, +) -> Option { + if let Some(last_page) = link_header_last_page(headers) { + if pagination.per_page == 1 { + return Some(u64::from(last_page)); + } + if last_page == pagination.page { + return Some( + u64::from(pagination.page.saturating_sub(1)) * u64::from(pagination.per_page) + + item_count as u64, + ); + } + return None; + } + + Some( + u64::from(pagination.page.saturating_sub(1)) * u64::from(pagination.per_page) + + item_count as u64, + ) +} + +fn pagination_from_total( + pagination: PullRequestPagination, + total: usize, +) -> ReviewPlatformPagination { + ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total: Some(total as u64), + has_next: usize::try_from(pagination.page) + .ok() + .is_some_and(|page| page * (pagination.per_page as usize) < total), + } +} + +fn slice_page(items: Vec, pagination: PullRequestPagination) -> Vec { + let start = pagination + .page + .saturating_sub(1) + .saturating_mul(pagination.per_page) as usize; + items + .into_iter() + .skip(start) + .take(pagination.per_page as usize) + .collect() +} + +fn empty_detail_pagination( + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, +) -> ReviewPlatformPagination { + ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total: if section == ReviewPlatformDetailSection::Overview { + Some(0) + } else { + None + }, + has_next: false, + } +} + +fn github_next_page(headers: &HeaderMap, current_page: u32) -> Option { + if link_header_has_rel(headers, "next") { + Some(current_page.saturating_add(1)) + } else { + None + } +} + +fn gitlab_next_page(headers: &HeaderMap, _current_page: u32) -> Option { + header_string(headers, "x-next-page").and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + trimmed.parse::().ok() + } + }) +} + +fn query_param_u32(url: &str, name: &str) -> Option { + let query = url.split_once('?')?.1; + for pair in query.split('&') { + if let Some((key, value)) = pair.split_once('=') { + if key == name { + return value.parse::().ok(); + } + } + } + None +} + +async fn enrich_github_pull_request_counts( + ctx: &ProviderContext, + pull_requests: Vec, +) -> Vec { + let Ok(client) = http_client() else { + return pull_requests; + }; + let futures = pull_requests.into_iter().map(|mut pull_request| { + let client = client.clone(); + let url = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request.id + ); + let token = ctx.token.clone(); + async move { + if let Ok(value) = send_json(github_request(client, &url, token.as_deref())).await { + pull_request.additions = value_i64(&value, "additions") as i32; + pull_request.deletions = value_i64(&value, "deletions") as i32; + pull_request.changed_files = value_i64(&value, "changed_files") as i32; + pull_request.comments = + (value_i64(&value, "comments") + value_i64(&value, "review_comments")) as i32; + } + pull_request + } + }); + stream::iter(futures) + .buffered(PROVIDER_ENRICH_CONCURRENCY) + .collect() + .await +} + +async fn enrich_gitlab_pull_request_counts( + ctx: &ProviderContext, + pull_requests: Vec, +) -> Vec { + let Ok(client) = http_client() else { + return pull_requests; + }; + let project = urlencoding::encode(&ctx.remote.project_path).to_string(); + let futures = pull_requests.into_iter().map(|mut pull_request| { + let client = client.clone(); + let url = format!( + "{}/projects/{}/merge_requests/{}/changes", + ctx.api_base_url, project, pull_request.id + ); + let token = ctx.token.clone(); + async move { + if let Ok(value) = send_json(gitlab_request(client, &url, token.as_deref())).await { + let files = gitlab_files(&value); + apply_files_stats(&mut pull_request, &files); + } + pull_request + } + }); + stream::iter(futures) + .buffered(PROVIDER_ENRICH_CONCURRENCY) + .collect() + .await +} + +async fn enrich_gitcode_pull_request_counts( + ctx: &ProviderContext, + pull_requests: Vec, +) -> Vec { + let Ok(client) = http_client() else { + return pull_requests; + }; + let futures = pull_requests.into_iter().map(|mut pull_request| { + let client = client.clone(); + let url = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request.id + ); + let token = ctx.token.clone(); + async move { + if let Ok(value) = send_json(gitcode_request(client, &url, token.as_deref())).await { + let detail = gitcode_pull_request_from_value(&value); + pull_request.additions = detail.additions; + pull_request.deletions = detail.deletions; + pull_request.changed_files = detail.changed_files; + pull_request.comments = detail.comments; + } + pull_request + } + }); + stream::iter(futures) + .buffered(PROVIDER_ENRICH_CONCURRENCY) + .collect() + .await +} + +fn github_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .get(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28"); + if let Some(token) = token { + request = request.header(AUTHORIZATION, format!("Bearer {}", token)); + } + request +} + +fn github_post_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .post(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28"); + if let Some(token) = token { + request = request.header(AUTHORIZATION, format!("Bearer {}", token)); + } + request +} + +fn gitlab_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .get(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/json"); + if let Some(token) = token { + request = request.header("PRIVATE-TOKEN", token); + } + request +} + +fn gitlab_post_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .post(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/json"); + if let Some(token) = token { + request = request.header("PRIVATE-TOKEN", token); + } + request +} + +fn gitlab_put_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .put(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/json"); + if let Some(token) = token { + request = request.header("PRIVATE-TOKEN", token); + } + request +} + +fn gitcode_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .get(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/json"); + if let Some(token) = token { + request = request + .header("PRIVATE-TOKEN", token) + .header(AUTHORIZATION, format!("Bearer {}", token)) + .query(&[("access_token", token)]); + } + request +} + +fn gitcode_post_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .post(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/json"); + if let Some(token) = token { + request = request + .header("PRIVATE-TOKEN", token) + .header(AUTHORIZATION, format!("Bearer {}", token)) + .query(&[("access_token", token)]); + } + request +} + +fn require_write_token<'a>( + ctx: &'a ProviderContext, + action: &str, +) -> Result<&'a str, ReviewPlatformError> { + ctx.token.as_deref().ok_or_else(|| { + ReviewPlatformError::Api(format!( + "{} requires a {} token for {}", + action, + platform_label(ctx.remote.platform), + ctx.remote.host + )) + }) +} + +fn provider_context( + remote: ReviewPlatformRemote, + auth_tokens: &ReviewPlatformAuthTokens, +) -> Result { + let api_base_url = match remote.platform { + ReviewPlatformKind::Github => "https://api.github.com".to_string(), + ReviewPlatformKind::Gitlab => format!("https://{}/api/v4", remote.host), + ReviewPlatformKind::Gitcode => "https://api.gitcode.com/api/v5".to_string(), + ReviewPlatformKind::Unknown => { + return Err(ReviewPlatformError::UnsupportedPlatform(remote.host)); + } + }; + let token = token_for_remote(&remote, auth_tokens); + Ok(ProviderContext { + remote, + api_base_url, + token, + }) +} + +fn token_for_remote( + remote: &ReviewPlatformRemote, + auth_tokens: &ReviewPlatformAuthTokens, +) -> Option { + auth_tokens + .get(remote.platform, &remote.host) + .map(str::to_string) + .or_else(|| env_token_for_platform(remote.platform)) +} + +fn env_token_for_platform(platform: ReviewPlatformKind) -> Option { + let names: &[&str] = match platform { + ReviewPlatformKind::Github => &["GITHUB_TOKEN", "GH_TOKEN"], + ReviewPlatformKind::Gitlab => &["GITLAB_TOKEN", "GITLAB_PRIVATE_TOKEN"], + ReviewPlatformKind::Gitcode => &["GITCODE_TOKEN"], + ReviewPlatformKind::Unknown => &[], + }; + names.iter().find_map(|name| { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }) +} + +fn auth_for_platform_host( + platform: ReviewPlatformKind, + host: &str, + auth_tokens: &ReviewPlatformAuthTokens, +) -> (ReviewAuthState, ReviewAuthSource) { + if platform == ReviewPlatformKind::Unknown { + return (ReviewAuthState::Unsupported, ReviewAuthSource::Unsupported); + } + if auth_tokens.get(platform, host).is_some() { + return (ReviewAuthState::Connected, ReviewAuthSource::Stored); + } + if env_token_for_platform(platform).is_some() { + return (ReviewAuthState::Connected, ReviewAuthSource::Env); + } + if platform == ReviewPlatformKind::Gitcode { + (ReviewAuthState::NotConnected, ReviewAuthSource::None) + } else { + (ReviewAuthState::NotRequired, ReviewAuthSource::None) + } +} + +fn token_key(platform: ReviewPlatformKind, host: &str) -> Option { + if platform == ReviewPlatformKind::Unknown { + return None; + } + let host = host.trim().to_ascii_lowercase(); + if host.is_empty() { + return None; + } + Some(format!("{}:{}", platform.as_str(), host)) +} + +fn stored_token_file_path() -> Result { + let path_manager = + try_get_path_manager_arc().map_err(|error| ReviewPlatformError::Api(error.to_string()))?; + Ok(path_manager + .user_data_dir() + .join("review-platform-tokens.json")) +} + +async fn load_stored_tokens() -> Result { + let stored = load_stored_token_file().await?; + Ok(ReviewPlatformAuthTokens { + tokens: stored + .tokens + .into_iter() + .filter_map(|(key, entry)| { + let token = entry.token.trim().to_string(); + if token.is_empty() { + None + } else { + Some((key, token)) + } + }) + .collect(), + }) +} + +async fn load_stored_token_file() -> Result { + let path = stored_token_file_path()?; + match fs::read_to_string(&path).await { + Ok(content) => serde_json::from_str::(&content) + .map_err(|error| ReviewPlatformError::Parse(error.to_string())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(StoredReviewPlatformTokens::default()) + } + Err(error) => Err(ReviewPlatformError::Api(format!( + "Failed to read review platform token store: {}", + error + ))), + } +} + +async fn save_stored_token_file( + stored: &StoredReviewPlatformTokens, +) -> Result<(), ReviewPlatformError> { + let path = stored_token_file_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await.map_err(|error| { + ReviewPlatformError::Api(format!( + "Failed to create review platform token store directory: {}", + error + )) + })?; + } + let content = serde_json::to_string_pretty(stored) + .map_err(|error| ReviewPlatformError::Parse(error.to_string()))?; + fs::write(&path, content).await.map_err(|error| { + ReviewPlatformError::Api(format!( + "Failed to write review platform token store: {}", + error + )) + }) +} + +fn select_remote<'a>( + remotes: &'a [ReviewPlatformRemote], + remote_id: Option<&str>, +) -> Option<&'a ReviewPlatformRemote> { + if let Some(remote_id) = remote_id { + if let Some(remote) = remotes.iter().find(|remote| remote.id == remote_id) { + return Some(remote); + } + } + remotes + .iter() + .find(|remote| remote.supported) + .or_else(|| remotes.first()) +} + +fn select_remote_for_action<'a>( + remotes: &'a [ReviewPlatformRemote], + remote_id: Option<&str>, +) -> Result<&'a ReviewPlatformRemote, ReviewPlatformError> { + if let Some(remote_id) = remote_id { + return remotes + .iter() + .find(|remote| remote.id == remote_id) + .ok_or_else(|| ReviewPlatformError::RemoteNotFound(remote_id.to_string())); + } + + let supported = remotes + .iter() + .filter(|remote| remote.supported) + .collect::>(); + match supported.as_slice() { + [] => remotes + .first() + .ok_or_else(|| ReviewPlatformError::RemoteNotFound("default".to_string())), + [remote] => Ok(remote), + _ => Err(ReviewPlatformError::Api(format!( + "Multiple supported review platform remotes were found. Provide remote_id explicitly. Candidate remotes:\n{}", + supported + .iter() + .map(|remote| format!( + "- remote_id: {} | name: {} | platform: {:?} | project: {} | url: {}", + remote.id, remote.name, remote.platform, remote.project_path, remote.web_url + )) + .collect::>() + .join("\n") + ))), + } +} + +fn empty_snapshot( + remotes: Vec, + selected_remote_id: Option, + account: Option, + message: &str, +) -> ReviewPlatformWorkspaceSnapshot { + let mut accounts = account.into_iter().collect::>(); + if let Some(account) = accounts.first_mut() { + if account.message.is_none() && !message.trim().is_empty() { + account.message = Some(message.to_string()); + } + } + + ReviewPlatformWorkspaceSnapshot { + remotes, + selected_remote_id, + accounts, + repository: None, + pull_requests: Vec::new(), + pagination: ReviewPlatformPagination { + page: DEFAULT_PR_PAGE, + per_page: DEFAULT_PR_PAGE_SIZE, + total: Some(0), + has_next: false, + }, + capabilities: ReviewPlatformCapabilities { + can_create_review: false, + can_create_pull_request: false, + can_reply_to_thread: false, + can_resolve_thread: false, + can_approve: false, + can_revoke_approval: false, + can_request_changes: false, + can_merge: false, + supports_draft_review: false, + }, + message: if message.trim().is_empty() { + None + } else { + Some(message.to_string()) + }, + auth_challenge: None, + } +} + +fn auth_required_snapshot( + remotes: Vec, + remote: ReviewPlatformRemote, + repository: Option, + account: ReviewPlatformAccount, + capabilities: ReviewPlatformCapabilities, + challenge: ReviewPlatformAuthChallenge, +) -> ReviewPlatformWorkspaceSnapshot { + ReviewPlatformWorkspaceSnapshot { + remotes, + selected_remote_id: Some(remote.id), + accounts: vec![account], + repository, + pull_requests: Vec::new(), + pagination: ReviewPlatformPagination { + page: DEFAULT_PR_PAGE, + per_page: DEFAULT_PR_PAGE_SIZE, + total: Some(0), + has_next: false, + }, + capabilities, + message: Some(challenge.message.clone()), + auth_challenge: Some(challenge), + } +} + +fn repository_ref( + remote: &ReviewPlatformRemote, + workspace_path: Option, +) -> ReviewPlatformRepositoryRef { + ReviewPlatformRepositoryRef { + provider_id: remote.id.clone(), + platform: remote.platform, + host: remote.host.clone(), + owner: remote.owner.clone(), + name: remote.repository_name.clone(), + project_path: remote.project_path.clone(), + default_branch: "main".to_string(), + workspace_path, + web_url: remote.web_url.clone(), + } +} + +fn account_for_remote(remote: &ReviewPlatformRemote) -> ReviewPlatformAccount { + ReviewPlatformAccount { + id: remote.id.clone(), + platform: remote.platform, + label: format!("{} ({})", platform_label(remote.platform), remote.host), + username: None, + host: remote.host.clone(), + auth_state: remote.auth_state, + auth_source: remote.auth_source, + scopes: if matches!( + remote.auth_source, + ReviewAuthSource::Env | ReviewAuthSource::Stored + ) { + vec!["pull_request:read".to_string()] + } else { + Vec::new() + }, + message: remote.message.clone(), + } +} + +fn capabilities_for_remote(_remote: &ReviewPlatformRemote) -> ReviewPlatformCapabilities { + let platform = _remote.platform; + ReviewPlatformCapabilities { + can_create_review: matches!( + platform, + ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab | ReviewPlatformKind::Gitcode + ), + can_create_pull_request: matches!( + platform, + ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab | ReviewPlatformKind::Gitcode + ), + can_reply_to_thread: matches!(platform, ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab), + can_resolve_thread: matches!(platform, ReviewPlatformKind::Gitlab), + can_approve: matches!( + platform, + ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab | ReviewPlatformKind::Gitcode + ), + can_revoke_approval: matches!(platform, ReviewPlatformKind::Gitlab), + can_request_changes: matches!(platform, ReviewPlatformKind::Github), + can_merge: false, + supports_draft_review: matches!(platform, ReviewPlatformKind::Github), + } +} + +fn platform_label(platform: ReviewPlatformKind) -> &'static str { + match platform { + ReviewPlatformKind::Github => "GitHub", + ReviewPlatformKind::Gitlab => "GitLab", + ReviewPlatformKind::Gitcode => "GitCode", + ReviewPlatformKind::Unknown => "Git", + } +} + +fn required_scopes_for_platform(platform: ReviewPlatformKind) -> Vec { + match platform { + ReviewPlatformKind::Github => vec!["repo".to_string(), "pull_requests:read".to_string()], + ReviewPlatformKind::Gitlab => { + vec!["read_api".to_string(), "api for write actions".to_string()] + } + ReviewPlatformKind::Gitcode => vec!["pull_request".to_string()], + ReviewPlatformKind::Unknown => Vec::new(), + } +} + +fn auth_state_for_challenge(state: ReviewPlatformAuthChallengeState) -> ReviewAuthState { + match state { + ReviewPlatformAuthChallengeState::Missing => ReviewAuthState::NotConnected, + ReviewPlatformAuthChallengeState::Invalid => ReviewAuthState::Expired, + ReviewPlatformAuthChallengeState::InsufficientScope => ReviewAuthState::Error, + } +} + +fn is_auth_http_error(error: &ReviewPlatformError) -> bool { + matches!( + error, + ReviewPlatformError::Http { + status: 401 | 403, + .. + } + ) +} + +fn auth_challenge_for_remote( + remote: &ReviewPlatformRemote, + error: &ReviewPlatformError, + has_token: bool, +) -> ReviewPlatformAuthChallenge { + let status = match error { + ReviewPlatformError::Http { status, .. } => *status, + _ => 0, + }; + let state = if !has_token { + ReviewPlatformAuthChallengeState::Missing + } else if status == 403 { + ReviewPlatformAuthChallengeState::InsufficientScope + } else { + ReviewPlatformAuthChallengeState::Invalid + }; + let action = match state { + ReviewPlatformAuthChallengeState::Missing => "Add", + ReviewPlatformAuthChallengeState::Invalid => "Update", + ReviewPlatformAuthChallengeState::InsufficientScope => "Update", + }; + let reason = match state { + ReviewPlatformAuthChallengeState::Missing => { + "a token is required to access this repository" + } + ReviewPlatformAuthChallengeState::Invalid => "the saved or environment token was rejected", + ReviewPlatformAuthChallengeState::InsufficientScope => { + "the token does not have enough permissions" + } + }; + ReviewPlatformAuthChallenge { + platform: remote.platform, + host: remote.host.clone(), + remote_id: remote.id.clone(), + project_path: remote.project_path.clone(), + state, + message: format!( + "{} {} token for {}: {}.", + action, + platform_label(remote.platform), + remote.host, + reason + ), + required_scopes: required_scopes_for_platform(remote.platform), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CiOutcome { + Passed, + Failed, + Pending, +} + +fn summarize_ci_items(items: &[ReviewPlatformCiItem]) -> ReviewChecks { + let mut checks = empty_checks(); + for item in items { + match ci_item_outcome(item) { + CiOutcome::Passed => checks.passed += 1, + CiOutcome::Failed => checks.failed += 1, + CiOutcome::Pending => checks.pending += 1, + } + } + checks.total = checks.passed + checks.failed + checks.pending; + checks +} + +fn ci_item_outcome(item: &ReviewPlatformCiItem) -> CiOutcome { + let status = item.status.trim().to_ascii_lowercase(); + let conclusion = item + .conclusion + .as_deref() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + + if conclusion.is_empty() { + return ci_status_outcome(&status); + } + + match conclusion.as_str() { + "success" | "neutral" | "skipped" | "passed" => CiOutcome::Passed, + "failure" | "timed_out" | "timed-out" | "cancelled" | "canceled" | "action_required" + | "error" => CiOutcome::Failed, + "queued" + | "pending" + | "running" + | "in_progress" + | "in progress" + | "created" + | "manual" + | "scheduled" + | "waiting_for_resource" + | "preparing" + | "requested" => CiOutcome::Pending, + _ => ci_status_outcome(&status), + } +} + +fn ci_status_outcome(status: &str) -> CiOutcome { + match status.trim().to_ascii_lowercase().as_str() { + "success" | "passed" | "pass" | "skipped" | "ok" | "available" | "can_be_merged" + | "mergeable" | "true" | "enabled" | "active" => CiOutcome::Passed, + "failure" | "failed" | "fail" | "error" | "cancelled" | "canceled" | "cannot_be_merged" + | "conflict" | "blocked" | "false" | "disabled" | "inactive" => CiOutcome::Failed, + "pending" + | "queued" + | "running" + | "in_progress" + | "in progress" + | "created" + | "manual" + | "scheduled" + | "waiting_for_resource" + | "preparing" + | "requested" + | "checking" + | "unchecked" + | "completed" => CiOutcome::Pending, + _ => CiOutcome::Pending, + } +} + +fn ci_log_value(text: String) -> (Option, bool) { + let extracted = ci_error_excerpt(&text); + let Some(excerpt) = extracted else { + return (None, false); + }; + let char_count = excerpt.chars().count(); + if char_count <= MAX_CI_LOG_CHARS { + return (Some(excerpt), false); + } + + ( + Some(format!( + "[Error excerpt truncated: showing first {} of {} chars]\n{}", + MAX_CI_LOG_CHARS, + char_count, + excerpt.chars().take(MAX_CI_LOG_CHARS).collect::() + )), + true, + ) +} + +fn empty_ci_log() -> (Option, bool) { + (None, false) +} + +fn ci_error_excerpt(text: &str) -> Option { + let lines: Vec<&str> = text.lines().collect(); + if lines.is_empty() { + return None; + } + + let mut ranges: Vec<(usize, usize)> = Vec::new(); + for (index, line) in lines.iter().enumerate() { + if !is_ci_error_line(line) { + continue; + } + + let start = index.saturating_sub(2); + let mut end = (index + 6).min(lines.len()); + while end < lines.len() && lines[end].trim().is_empty() { + end += 1; + } + ranges.push((start, end)); + } + + if ranges.is_empty() { + return None; + } + + ranges.sort_unstable_by_key(|range| range.0); + let mut merged: Vec<(usize, usize)> = Vec::new(); + for (start, end) in ranges { + if let Some(last) = merged.last_mut() { + if start <= last.1.saturating_add(1) { + last.1 = last.1.max(end); + continue; + } + } + merged.push((start, end)); + } + + let mut output = String::new(); + for (index, (start, end)) in merged.iter().enumerate() { + if index > 0 { + output.push_str("\n...\n"); + } + for line in &lines[*start..*end] { + output.push_str(line); + output.push('\n'); + } + } + + let output = output.trim_end_matches('\n').trim().to_string(); + if output.is_empty() { + None + } else { + Some(output) + } +} + +fn is_ci_error_line(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.contains("##[error]") + || lower.contains("error:") + || lower.contains(" failed") + || lower.contains("failure") + || lower.contains("fatal") + || lower.contains("exception") + || lower.contains("traceback") + || lower.contains("panic") + || lower.contains("assertion failed") + || lower.contains("command failed") + || lower.contains("exited with code") + || lower.contains("return code") + || lower.contains("build failed") + || lower.contains("test failed") +} + +async fn github_checks_and_ci( + ctx: &ProviderContext, + client: &reqwest::Client, + pull_detail: &Value, +) -> (ReviewChecks, Vec) { + let sha = nested_string(pull_detail, &["head", "sha"]); + if sha.trim().is_empty() { + return (empty_checks(), Vec::new()); + } + + let mut ci_items = Vec::new(); + let status_url = format!( + "{}/repos/{}/{}/commits/{}/status", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, sha + ); + if let Ok(status) = send_json(github_request( + client.clone(), + &status_url, + ctx.token.as_deref(), + )) + .await + { + let statuses = status + .get("statuses") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]); + for (index, item) in statuses.iter().enumerate() { + ci_items.push(ReviewPlatformCiItem { + id: format!( + "status-{}", + first_non_empty(&[value_string(item, "id"), index.to_string()]) + ), + name: first_non_empty(&[ + value_string(item, "context"), + value_string(item, "description"), + "Status".to_string(), + ]), + status: value_string(item, "state"), + conclusion: None, + detail: optional_string(item, "description"), + stage: None, + web_url: optional_string(item, "target_url"), + log: None, + log_truncated: false, + started_at: None, + finished_at: None, + }); + } + } + + let check_runs_url = format!( + "{}/repos/{}/{}/commits/{}/check-runs", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, sha + ); + if let Ok(check_runs) = send_json( + github_request(client.clone(), &check_runs_url, ctx.token.as_deref()) + .query(&[("per_page", "100")]), + ) + .await + { + for (index, item) in check_runs + .get("check_runs") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]) + .iter() + .enumerate() + { + ci_items.push(ReviewPlatformCiItem { + id: format!( + "check-run-{}", + first_non_empty(&[value_string(item, "id"), index.to_string()]) + ), + name: first_non_empty(&[value_string(item, "name"), "Check run".to_string()]), + status: value_string(item, "status"), + conclusion: optional_string(item, "conclusion"), + detail: nested_optional_string(item, &["output", "summary"]) + .or_else(|| nested_optional_string(item, &["output", "text"])) + .or_else(|| optional_string(item, "details_url")), + stage: None, + web_url: optional_string(item, "html_url") + .or_else(|| optional_string(item, "details_url")), + log: None, + log_truncated: false, + started_at: optional_string(item, "started_at"), + finished_at: optional_string(item, "completed_at"), + }); + } + } + + let checks = summarize_ci_items(&ci_items); + (checks, ci_items) +} + +async fn github_actions_jobs_for_head_sha( + ctx: &ProviderContext, + client: &reqwest::Client, + sha: &str, +) -> Vec { + let runs_url = format!( + "{}/repos/{}/{}/actions/runs", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name + ); + let runs = match send_json( + github_request(client.clone(), &runs_url, ctx.token.as_deref()) + .query(&[("head_sha", sha), ("per_page", "100")]), + ) + .await + { + Ok(value) => value, + Err(_) => return Vec::new(), + }; + + let mut jobs = Vec::new(); + for run in runs + .get("workflow_runs") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]) + { + let run_id = value_string(run, "id"); + if run_id.trim().is_empty() { + continue; + } + let jobs_url = format!( + "{}/repos/{}/{}/actions/runs/{}/jobs", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, run_id + ); + if let Ok(value) = send_json( + github_request(client.clone(), &jobs_url, ctx.token.as_deref()) + .query(&[("per_page", "100")]), + ) + .await + { + jobs.extend( + value + .get("jobs") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]) + .iter() + .cloned(), + ); + } + } + + jobs +} + +async fn github_actions_log_for_check_run_item( + ctx: &ProviderContext, + client: &reqwest::Client, + check_run_id: &str, + check_run_name: &str, + head_sha: &str, +) -> Result { + let action_jobs = github_actions_jobs_for_head_sha(ctx, client, head_sha).await; + let check_run = action_jobs + .iter() + .find(|job| { + let check_run_url = value_string(job, "check_run_url"); + check_run_url.ends_with(&format!("/check-runs/{}", check_run_id)) + || value_string(job, "name") == check_run_name + }) + .cloned(); + + let Some(job) = check_run else { + return Ok(ReviewPlatformCiLog { + ci_item_id: format!("check-run-{}", check_run_id), + log: None, + truncated: false, + message: Some( + "No matching GitHub Actions job was found for this check run.".to_string(), + ), + }); + }; + + let job_id = value_string(&job, "id"); + if job_id.trim().is_empty() { + return Ok(ReviewPlatformCiLog { + ci_item_id: format!("check-run-{}", check_run_id), + log: None, + truncated: false, + message: Some("The matching GitHub Actions job does not expose a job id.".to_string()), + }); + } + + let logs_url = format!( + "{}/repos/{}/{}/actions/jobs/{}/logs", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, job_id + ); + let text = send_text(github_request( + client.clone(), + &logs_url, + ctx.token.as_deref(), + )) + .await?; + let (log, truncated) = ci_log_value(text); + let message = log + .as_ref() + .is_none() + .then_some("No error lines were detected in the GitHub Actions job log.".to_string()); + Ok(ReviewPlatformCiLog { + ci_item_id: format!("check-run-{}", check_run_id), + log, + truncated, + message, + }) +} + +fn gitlab_pipeline_summary_item(detail: &Value) -> Option { + let pipeline = detail.get("head_pipeline")?; + let status = value_string(pipeline, "status"); + if status.trim().is_empty() { + return None; + } + Some(ReviewPlatformCiItem { + id: first_non_empty(&[ + value_string(pipeline, "id"), + value_string(pipeline, "iid"), + "head-pipeline".to_string(), + ]), + name: "Pipeline".to_string(), + status, + conclusion: None, + detail: nested_optional_string(pipeline, &["detailed_status", "text"]) + .or_else(|| nested_optional_string(pipeline, &["detailed_status", "label"])), + stage: None, + web_url: optional_string(pipeline, "web_url"), + log: None, + log_truncated: false, + started_at: optional_string(pipeline, "started_at"), + finished_at: optional_string(pipeline, "finished_at"), + }) +} + +async fn gitlab_pipeline_jobs( + ctx: &ProviderContext, + client: reqwest::Client, + project: &str, + pipeline_id: &str, +) -> Vec { + let jobs_url = format!( + "{}/projects/{}/pipelines/{}/jobs", + ctx.api_base_url, project, pipeline_id + ); + if let Ok(response) = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitlab_request(client.clone(), &jobs_url, ctx.token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + gitlab_next_page, + ) + .await + { + let mut jobs = Vec::new(); + for (index, job) in array_items(&response).iter().enumerate() { + let provider_id = value_string(job, "id"); + let id = first_non_empty(&[provider_id.clone(), index.to_string()]); + jobs.push(ReviewPlatformCiItem { + id, + name: first_non_empty(&[value_string(job, "name"), "Job".to_string()]), + status: value_string(job, "status"), + conclusion: None, + detail: optional_string(job, "failure_reason"), + stage: optional_string(job, "stage"), + web_url: optional_string(job, "web_url"), + log: None, + log_truncated: false, + started_at: optional_string(job, "started_at"), + finished_at: optional_string(job, "finished_at"), + }); + } + return jobs; + } + Vec::new() +} + +async fn gitlab_job_trace( + ctx: &ProviderContext, + client: reqwest::Client, + project: &str, + job_id: &str, +) -> (Option, bool) { + if job_id.trim().is_empty() { + return empty_ci_log(); + } + let trace_url = format!( + "{}/projects/{}/jobs/{}/trace", + ctx.api_base_url, project, job_id + ); + match send_text(gitlab_request(client, &trace_url, ctx.token.as_deref())).await { + Ok(text) => ci_log_value(text), + Err(_) => empty_ci_log(), + } +} + +async fn gitlab_pull_request_ci_log( + ctx: &ProviderContext, + _pull_request_id: &str, + ci_item_id: &str, + _ci_item_name: &str, +) -> Result { + if ci_item_id == "head-pipeline" || ci_item_id == "pipeline" { + return Ok(ReviewPlatformCiLog { + ci_item_id: ci_item_id.to_string(), + log: None, + truncated: false, + message: Some("Pipeline summaries do not expose a separate job trace.".to_string()), + }); + } + + let client = http_client()?; + let project = urlencoding::encode(&ctx.remote.project_path).to_string(); + let (log, truncated) = gitlab_job_trace(ctx, client, &project, ci_item_id).await; + let message = log + .as_ref() + .is_none() + .then_some("No error lines were detected in the job trace.".to_string()); + Ok(ReviewPlatformCiLog { + ci_item_id: ci_item_id.to_string(), + log, + truncated, + message, + }) +} + +fn gitcode_ci_items(detail: &Value) -> Vec { + let mut items = Vec::new(); + let pipeline_status = first_non_empty(&[ + value_string(detail, "pipeline_status"), + value_string(detail, "pipeline_status_with_code_quality"), + ]); + if !pipeline_status.trim().is_empty() { + items.push(ReviewPlatformCiItem { + id: first_non_empty(&[ + value_string(detail, "head_pipeline_id"), + "pipeline".to_string(), + ]), + name: "Pipeline".to_string(), + status: pipeline_status, + conclusion: None, + detail: optional_string(detail, "pipeline_status_with_code_quality"), + stage: None, + web_url: optional_string(detail, "web_url") + .or_else(|| optional_string(detail, "html_url")), + log: None, + log_truncated: false, + started_at: None, + finished_at: None, + }); + } + + let codequality_status = value_string(detail, "codequality_status"); + if !codequality_status.trim().is_empty() { + items.push(ReviewPlatformCiItem { + id: first_non_empty(&[ + format!("{}-codequality", value_string(detail, "head_pipeline_id")), + "codequality".to_string(), + ]), + name: "Code quality".to_string(), + status: codequality_status, + conclusion: None, + detail: None, + stage: None, + web_url: optional_string(detail, "web_url") + .or_else(|| optional_string(detail, "html_url")), + log: None, + log_truncated: false, + started_at: None, + finished_at: None, + }); + } + + items +} + +fn parse_remote( + remote_name: &str, + remote_url: &str, + auth_tokens: &ReviewPlatformAuthTokens, +) -> Option { + let parsed = parse_remote_url(remote_url)?; + let host_lower = parsed.host.to_ascii_lowercase(); + let platform = if host_lower.contains("github.com") { + ReviewPlatformKind::Github + } else if host_lower.contains("gitlab") { + ReviewPlatformKind::Gitlab + } else if host_lower.contains("gitcode") { + ReviewPlatformKind::Gitcode + } else { + ReviewPlatformKind::Unknown + }; + + let segments: Vec<&str> = parsed + .path + .trim_matches('/') + .split('/') + .filter(|segment| !segment.is_empty()) + .collect(); + if segments.len() < 2 { + return None; + } + let owner = segments.first()?.to_string(); + let repository_name = segments.last()?.trim_end_matches(".git").to_string(); + let project_path = segments + .iter() + .map(|segment| segment.trim_end_matches(".git")) + .collect::>() + .join("/"); + + let supported = platform != ReviewPlatformKind::Unknown; + let (auth_state, auth_source) = auth_for_platform_host(platform, &parsed.host, auth_tokens); + let web_url = format!("{}://{}/{}", parsed.scheme, parsed.host, project_path); + + Some(ReviewPlatformRemote { + id: format!( + "{}:{}:{}", + remote_name, + platform.as_str(), + project_path.replace('/', "__") + ), + name: remote_name.to_string(), + url: sanitize_remote_url(remote_url), + platform, + host: parsed.host, + owner, + repository_name, + project_path, + web_url, + supported, + auth_state, + auth_source, + message: if !supported { + Some("This remote is detected, but no provider adapter is available yet.".to_string()) + } else if platform == ReviewPlatformKind::Gitcode + && auth_state == ReviewAuthState::NotConnected + { + Some("Add a GitCode token to load pull requests.".to_string()) + } else { + None + }, + }) +} + +#[derive(Debug)] +struct ParsedRemoteUrl { + scheme: String, + host: String, + path: String, +} + +fn parse_remote_url(remote_url: &str) -> Option { + if let Some(scheme_end) = remote_url.find("://") { + let scheme = &remote_url[..scheme_end]; + let rest = &remote_url[scheme_end + 3..]; + let slash = rest.find('/')?; + let authority = &rest[..slash]; + let host_part = authority.rsplit('@').next().unwrap_or(authority); + let host = host_part.split(':').next().unwrap_or(host_part); + let path = rest[slash + 1..].trim_end_matches(".git").to_string(); + return Some(ParsedRemoteUrl { + scheme: if scheme == "ssh" { "https" } else { scheme }.to_string(), + host: host.to_string(), + path, + }); + } + + if let Some((user_host, path)) = remote_url.split_once(':') { + if user_host.contains('@') && !path.contains('\\') { + let host = user_host.rsplit('@').next()?.to_string(); + return Some(ParsedRemoteUrl { + scheme: "https".to_string(), + host, + path: path.trim_end_matches(".git").to_string(), + }); + } + } + + None +} + +fn sanitize_remote_url(remote_url: &str) -> String { + if let Some(scheme_end) = remote_url.find("://") { + let scheme = &remote_url[..scheme_end]; + let rest = &remote_url[scheme_end + 3..]; + if let Some(slash) = rest.find('/') { + let authority = &rest[..slash]; + if authority.contains('@') { + let host = authority.rsplit('@').next().unwrap_or(authority); + return format!("{}://{}/{}", scheme, host, &rest[slash + 1..]); + } + } + } + remote_url.to_string() +} + +fn github_pull_request_from_value(value: &Value) -> ReviewPlatformPullRequest { + let number = value_i64(value, "number"); + let state = if value_bool(value, "draft") { + ReviewItemState::Draft + } else if !value_string(value, "merged_at").is_empty() { + ReviewItemState::Merged + } else { + match value_string(value, "state").as_str() { + "closed" => ReviewItemState::Closed, + _ => ReviewItemState::Open, + } + }; + + ReviewPlatformPullRequest { + id: number.to_string(), + number, + title: value_string(value, "title"), + state, + author: nested_string(value, &["user", "login"]), + source_branch: nested_string(value, &["head", "ref"]), + target_branch: nested_string(value, &["base", "ref"]), + updated_at: value_string(value, "updated_at"), + web_url: value_string(value, "html_url"), + additions: value_i64(value, "additions") as i32, + deletions: value_i64(value, "deletions") as i32, + changed_files: value_i64(value, "changed_files") as i32, + comments: (value_i64(value, "comments") + value_i64(value, "review_comments")) as i32, + review_decision: ReviewDecision::Pending, + checks: empty_checks(), + } +} + +fn gitlab_pull_request_from_value(value: &Value) -> ReviewPlatformPullRequest { + let number = value_i64(value, "iid"); + let state = if value_bool(value, "draft") || value_bool(value, "work_in_progress") { + ReviewItemState::Draft + } else { + match value_string(value, "state").as_str() { + "merged" => ReviewItemState::Merged, + "closed" => ReviewItemState::Closed, + _ => ReviewItemState::Open, + } + }; + let changed_files = value_string(value, "changes_count") + .parse::() + .unwrap_or(0); + + ReviewPlatformPullRequest { + id: number.to_string(), + number, + title: value_string(value, "title"), + state, + author: first_non_empty(&[ + nested_string(value, &["author", "username"]), + nested_string(value, &["author", "name"]), + ]), + source_branch: value_string(value, "source_branch"), + target_branch: value_string(value, "target_branch"), + updated_at: value_string(value, "updated_at"), + web_url: value_string(value, "web_url"), + additions: 0, + deletions: 0, + changed_files, + comments: value_i64(value, "user_notes_count") as i32, + review_decision: ReviewDecision::Pending, + checks: empty_checks(), + } +} + +fn gitcode_pull_request_from_value(value: &Value) -> ReviewPlatformPullRequest { + let number = first_non_zero(&[value_i64(value, "number"), value_i64(value, "id")]); + let state = match value_string(value, "state").as_str() { + "merged" => ReviewItemState::Merged, + "closed" => ReviewItemState::Closed, + _ => ReviewItemState::Open, + }; + ReviewPlatformPullRequest { + id: number.to_string(), + number, + title: value_string(value, "title"), + state, + author: first_non_empty(&[ + nested_string(value, &["user", "login"]), + nested_string(value, &["user", "name"]), + nested_string(value, &["author", "login"]), + ]), + source_branch: first_non_empty(&[ + nested_string(value, &["head", "ref"]), + value_string(value, "head_branch"), + ]), + target_branch: first_non_empty(&[ + nested_string(value, &["base", "ref"]), + value_string(value, "base_branch"), + ]), + updated_at: value_string(value, "updated_at"), + web_url: first_non_empty(&[ + value_string(value, "html_url"), + value_string(value, "web_url"), + ]), + additions: value_i64(value, "additions") as i32, + deletions: value_i64(value, "deletions") as i32, + changed_files: value_i64(value, "changed_files") as i32, + comments: value_i64(value, "comments") as i32, + review_decision: ReviewDecision::Pending, + checks: empty_checks(), + } +} + +fn github_file_from_value(value: &Value) -> ReviewPlatformFile { + ReviewPlatformFile { + path: value_string(value, "filename"), + old_path: value + .get("previous_filename") + .and_then(Value::as_str) + .map(str::to_string), + status: file_status(&value_string(value, "status")), + additions: value_i64(value, "additions") as i32, + deletions: value_i64(value, "deletions") as i32, + patch: optional_string(value, "patch"), + } +} + +fn gitcode_file_from_value(value: &Value) -> ReviewPlatformFile { + ReviewPlatformFile { + path: first_non_empty(&[ + value_string(value, "filename"), + value_string(value, "new_path"), + ]), + old_path: value + .get("previous_filename") + .and_then(Value::as_str) + .map(str::to_string), + status: file_status(&value_string(value, "status")), + additions: value_i64(value, "additions") as i32, + deletions: value_i64(value, "deletions") as i32, + patch: optional_string(value, "patch").or_else(|| optional_string(value, "diff")), + } +} + +fn gitlab_files(value: &Value) -> Vec { + value + .get("changes") + .and_then(Value::as_array) + .unwrap_or(&Vec::new()) + .iter() + .map(|change| { + let diff = value_string(change, "diff"); + let (additions, deletions) = count_diff_lines(&diff); + let status = if value_bool(change, "new_file") { + ReviewFileStatus::Added + } else if value_bool(change, "deleted_file") { + ReviewFileStatus::Deleted + } else if value_bool(change, "renamed_file") { + ReviewFileStatus::Renamed + } else { + ReviewFileStatus::Modified + }; + ReviewPlatformFile { + path: value_string(change, "new_path"), + old_path: change + .get("old_path") + .and_then(Value::as_str) + .map(str::to_string), + status, + additions, + deletions, + patch: Some(diff), + } + }) + .collect() +} + +fn github_commit_from_value(value: &Value) -> ReviewPlatformCommit { + let hash = value_string(value, "sha"); + ReviewPlatformCommit { + short_hash: short_hash(&hash), + hash, + title: first_line(&nested_string(value, &["commit", "message"])), + author: first_non_empty(&[ + nested_string(value, &["author", "login"]), + nested_string(value, &["commit", "author", "name"]), + ]), + committed_at: nested_string(value, &["commit", "author", "date"]), + } +} + +fn gitlab_commit_from_value(value: &Value) -> ReviewPlatformCommit { + let hash = value_string(value, "id"); + ReviewPlatformCommit { + short_hash: first_non_empty(&[value_string(value, "short_id"), short_hash(&hash)]), + hash, + title: first_non_empty(&[ + value_string(value, "title"), + first_line(&value_string(value, "message")), + ]), + author: value_string(value, "author_name"), + committed_at: first_non_empty(&[ + value_string(value, "committed_date"), + value_string(value, "created_at"), + ]), + } +} + +fn gitcode_commit_from_value(value: &Value) -> ReviewPlatformCommit { + let hash = first_non_empty(&[value_string(value, "sha"), value_string(value, "id")]); + ReviewPlatformCommit { + short_hash: short_hash(&hash), + hash, + title: first_non_empty(&[ + nested_string(value, &["commit", "message"]) + .lines() + .next() + .unwrap_or_default() + .to_string(), + value_string(value, "message"), + ]), + author: first_non_empty(&[ + nested_string(value, &["author", "login"]), + nested_string(value, &["commit", "author", "name"]), + ]), + committed_at: first_non_empty(&[ + nested_string(value, &["commit", "author", "date"]), + value_string(value, "created_at"), + ]), + } +} + +fn github_review_decision(reviews: &Value) -> ReviewDecision { + let mut latest_by_author: HashMap = HashMap::new(); + let mut anonymous_states = Vec::new(); + for review in array_items(reviews) { + let state = value_string(review, "state"); + if state == "DISMISSED" || state.trim().is_empty() { + continue; + } + let author = nested_string(review, &["user", "login"]); + if author.trim().is_empty() { + anonymous_states.push(state); + } else { + latest_by_author.insert(author, state); + } + } + + let states = latest_by_author + .values() + .chain(anonymous_states.iter()) + .map(String::as_str) + .collect::>(); + + if states.iter().any(|state| *state == "CHANGES_REQUESTED") { + return ReviewDecision::ChangesRequested; + } + if states.iter().any(|state| *state == "APPROVED") { + return ReviewDecision::Approved; + } + if states.iter().any(|state| *state == "COMMENTED") { + return ReviewDecision::Commented; + } + ReviewDecision::Pending +} + +fn github_threads( + reviews: &Value, + review_comments: &Value, + issue_comments: &Value, +) -> Vec { + let mut threads = Vec::new(); + for review in array_items(reviews) { + let body = github_review_body(review); + threads.push(ReviewPlatformThread { + id: format!("review-{}", value_i64(review, "id")), + provider_thread_id: None, + provider_comment_id: value_i64(review, "id") + .checked_abs() + .map(|id| id.to_string()), + kind: ReviewPlatformThreadKind::Review, + reply_to_provider_comment_id: None, + file_path: None, + line: None, + resolved: false, + author: nested_string(review, &["user", "login"]), + body, + updated_at: first_non_empty(&[ + value_string(review, "submitted_at"), + value_string(review, "updated_at"), + ]), + }); + } + for comment in array_items(review_comments) { + threads.push(github_thread_from_review_comment(comment)); + } + for comment in array_items(issue_comments) { + threads.push(github_thread_from_issue_comment(comment)); + } + threads +} + +fn github_review_body(review: &Value) -> String { + let body = value_string(review, "body"); + if !body.trim().is_empty() { + return body; + } + match value_string(review, "state").as_str() { + "APPROVED" => "Approved this pull request.".to_string(), + "CHANGES_REQUESTED" => "Requested changes.".to_string(), + "COMMENTED" => "Submitted a pull request review.".to_string(), + state if !state.trim().is_empty() => format!("Submitted a {} review.", state), + _ => "Submitted a pull request review.".to_string(), + } +} + +fn github_thread_from_review_comment(comment: &Value) -> ReviewPlatformThread { + let comment_id = first_non_empty(&[ + value_string(comment, "id"), + value_i64(comment, "id").to_string(), + ]); + ReviewPlatformThread { + id: format!("comment-{}", comment_id), + provider_thread_id: None, + provider_comment_id: Some(comment_id), + kind: ReviewPlatformThreadKind::Comment, + reply_to_provider_comment_id: value_i64(comment, "in_reply_to_id") + .checked_abs() + .map(|id| id.to_string()) + .or_else(|| { + comment + .get("in_reply_to_id") + .and_then(Value::as_str) + .map(str::to_string) + }), + file_path: comment + .get("path") + .and_then(Value::as_str) + .map(str::to_string), + line: comment + .get("line") + .and_then(Value::as_i64) + .or_else(|| comment.get("original_line").and_then(Value::as_i64)), + resolved: false, + author: nested_string(comment, &["user", "login"]), + body: value_string(comment, "body"), + updated_at: value_string(comment, "updated_at"), + } +} + +fn github_thread_from_issue_comment(comment: &Value) -> ReviewPlatformThread { + let comment_id = first_non_empty(&[ + value_string(comment, "id"), + value_i64(comment, "id").to_string(), + ]); + ReviewPlatformThread { + id: format!("issue-comment-{}", comment_id), + provider_thread_id: None, + provider_comment_id: Some(comment_id), + kind: ReviewPlatformThreadKind::Comment, + reply_to_provider_comment_id: None, + file_path: None, + line: None, + resolved: false, + author: nested_string(comment, &["user", "login"]), + body: value_string(comment, "body"), + updated_at: value_string(comment, "updated_at"), + } +} + +fn gitlab_threads(discussions: &Value, notes: &Value) -> Vec { + let mut threads = Vec::new(); + let mut seen_comment_ids = HashSet::new(); + for discussion in array_items(discussions) { + let discussion_id = value_string(discussion, "id"); + let resolved = value_bool(discussion, "resolved"); + let discussion_notes = discussion + .get("notes") + .and_then(Value::as_array) + .map(|notes| notes.as_slice()) + .unwrap_or(&[]); + let mut root_comment_id: Option = None; + for (index, note) in discussion_notes.iter().enumerate() { + let kind = if index == 0 { + ReviewPlatformThreadKind::Review + } else { + ReviewPlatformThreadKind::Comment + }; + let reply_to = if index == 0 { + None + } else { + root_comment_id.clone() + }; + let thread = gitlab_thread_from_note( + note, + Some(discussion_id.clone()), + resolved, + kind, + reply_to, + ); + if root_comment_id.is_none() { + root_comment_id = thread.provider_comment_id.clone(); + } + if let Some(comment_id) = thread.provider_comment_id.clone() { + seen_comment_ids.insert(comment_id); + } + threads.push(thread); + } + } + for note in array_items(notes) { + let thread = + gitlab_thread_from_note(note, None, false, ReviewPlatformThreadKind::Comment, None); + if let Some(comment_id) = thread.provider_comment_id.as_ref() { + if seen_comment_ids.contains(comment_id) { + continue; + } + seen_comment_ids.insert(comment_id.clone()); + } + threads.push(thread); + } + threads +} + +fn gitlab_thread_from_note( + note: &Value, + discussion_id: Option, + discussion_resolved: bool, + kind: ReviewPlatformThreadKind, + reply_to_provider_comment_id: Option, +) -> ReviewPlatformThread { + let note_id = value_string(note, "id"); + let id = match discussion_id.as_deref() { + Some(discussion_id) if !discussion_id.trim().is_empty() => { + format!("discussion-{}:note-{}", discussion_id, note_id) + } + _ => format!("note-{}", note_id), + }; + + ReviewPlatformThread { + id, + provider_thread_id: discussion_id, + provider_comment_id: Some(note_id), + kind, + reply_to_provider_comment_id, + file_path: nested_optional_string(note, &["position", "new_path"]) + .or_else(|| nested_optional_string(note, &["position", "old_path"])), + line: note + .pointer("/position/new_line") + .and_then(Value::as_i64) + .or_else(|| note.pointer("/position/old_line").and_then(Value::as_i64)), + resolved: discussion_resolved || value_bool(note, "resolved"), + author: first_non_empty(&[ + nested_string(note, &["author", "username"]), + nested_string(note, &["author", "name"]), + ]), + body: value_string(note, "body"), + updated_at: first_non_empty(&[ + value_string(note, "updated_at"), + value_string(note, "created_at"), + ]), + } +} + +fn parse_provider_comment_id(thread_id: &str) -> Option<&str> { + let trimmed = thread_id.trim(); + trimmed + .strip_prefix("comment-") + .or_else(|| trimmed.strip_prefix("note-")) + .or_else(|| trimmed.split_once(":note-").map(|(_, note_id)| note_id)) + .or_else(|| { + if trimmed.chars().all(|ch| ch.is_ascii_digit()) { + Some(trimmed) + } else { + None + } + }) + .filter(|value| !value.trim().is_empty()) +} + +fn parse_provider_thread_id(thread_id: &str) -> Option<&str> { + let trimmed = thread_id.trim(); + trimmed + .strip_prefix("discussion-") + .map(|value| { + value + .split_once(":note-") + .map(|(id, _)| id) + .unwrap_or(value) + }) + .or_else(|| { + if trimmed + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_') + { + Some(trimmed) + } else { + None + } + }) + .filter(|value| !value.trim().is_empty()) +} + +fn gitcode_threads(value: &Value) -> Vec { + array_items(value) + .iter() + .map(|comment| ReviewPlatformThread { + id: value_string(comment, "id"), + provider_thread_id: None, + provider_comment_id: Some(value_string(comment, "id")), + kind: ReviewPlatformThreadKind::Comment, + reply_to_provider_comment_id: comment + .get("in_reply_to_id") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + comment + .get("in_reply_to_id") + .and_then(Value::as_i64) + .map(|id| id.to_string()) + }), + file_path: comment + .get("path") + .and_then(Value::as_str) + .map(str::to_string), + line: comment.get("line").and_then(Value::as_i64), + resolved: false, + author: first_non_empty(&[ + nested_string(comment, &["user", "login"]), + nested_string(comment, &["user", "name"]), + ]), + body: value_string(comment, "body"), + updated_at: first_non_empty(&[ + value_string(comment, "updated_at"), + value_string(comment, "created_at"), + ]), + }) + .collect() +} + +fn empty_checks() -> ReviewChecks { + ReviewChecks { + total: 0, + passed: 0, + failed: 0, + pending: 0, + } +} + +fn file_status(status: &str) -> ReviewFileStatus { + match status { + "added" | "new" => ReviewFileStatus::Added, + "removed" | "deleted" => ReviewFileStatus::Deleted, + "renamed" => ReviewFileStatus::Renamed, + _ => ReviewFileStatus::Modified, + } +} + +fn count_diff_lines(diff: &str) -> (i32, i32) { + let mut additions = 0; + let mut deletions = 0; + for line in diff.lines() { + if line.starts_with("+++") || line.starts_with("---") { + continue; + } + if line.starts_with('+') { + additions += 1; + } else if line.starts_with('-') { + deletions += 1; + } + } + (additions, deletions) +} + +fn apply_files_stats(pull_request: &mut ReviewPlatformPullRequest, files: &[ReviewPlatformFile]) { + pull_request.changed_files = files.len() as i32; + let (additions, deletions) = files.iter().fold((0, 0), |acc, file| { + (acc.0 + file.additions, acc.1 + file.deletions) + }); + pull_request.additions = additions; + pull_request.deletions = deletions; +} + +fn array_items<'a>(value: &'a Value) -> &'a [Value] { + value + .as_array() + .map(|items| items.as_slice()) + .unwrap_or(&[]) +} + +fn value_string(value: &Value, key: &str) -> String { + match value.get(key) { + Some(Value::String(text)) => text.clone(), + Some(Value::Number(number)) => number.to_string(), + Some(Value::Bool(flag)) => flag.to_string(), + _ => String::new(), + } +} + +fn optional_string(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .map(str::to_string) + .filter(|value| !value.trim().is_empty()) +} + +fn nested_string(value: &Value, path: &[&str]) -> String { + nested_optional_string(value, path).unwrap_or_default() +} + +fn nested_optional_string(value: &Value, path: &[&str]) -> Option { + let mut current = value; + for key in path { + current = current.get(*key)?; + } + match current { + Value::String(text) => Some(text.clone()), + Value::Number(number) => Some(number.to_string()), + Value::Bool(flag) => Some(flag.to_string()), + _ => None, + } +} + +fn value_i64(value: &Value, key: &str) -> i64 { + value + .get(key) + .and_then(|value| { + value + .as_i64() + .or_else(|| value.as_str()?.parse::().ok()) + }) + .unwrap_or(0) +} + +fn value_bool(value: &Value, key: &str) -> bool { + value + .get(key) + .and_then(|value| { + value + .as_bool() + .or_else(|| value.as_str().map(|text| text.eq_ignore_ascii_case("true"))) + }) + .unwrap_or(false) +} + +fn first_non_empty(values: &[String]) -> String { + values + .iter() + .find(|value| !value.trim().is_empty()) + .cloned() + .unwrap_or_default() +} + +fn first_non_zero(values: &[i64]) -> i64 { + values + .iter() + .copied() + .find(|value| *value != 0) + .unwrap_or(0) +} + +fn first_line(value: &str) -> String { + value.lines().next().unwrap_or_default().to_string() +} + +fn short_hash(hash: &str) -> String { + hash.chars().take(7).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn github_review_decision_uses_latest_review_per_author() { + let reviews = json!([ + { + "id": 1, + "state": "CHANGES_REQUESTED", + "user": { "login": "alice" } + }, + { + "id": 2, + "state": "APPROVED", + "user": { "login": "alice" } + } + ]); + + assert_eq!(github_review_decision(&reviews), ReviewDecision::Approved); + } + + #[test] + fn github_review_decision_keeps_active_change_request_from_any_reviewer() { + let reviews = json!([ + { + "id": 1, + "state": "APPROVED", + "user": { "login": "alice" } + }, + { + "id": 2, + "state": "CHANGES_REQUESTED", + "user": { "login": "bob" } + } + ]); + + assert_eq!( + github_review_decision(&reviews), + ReviewDecision::ChangesRequested + ); + } + + #[test] + fn github_threads_include_issue_comments_and_review_comments() { + let reviews = json!([]); + let review_comments = json!([ + { + "id": 10, + "path": "src/lib.rs", + "line": 8, + "user": { "login": "alice" }, + "body": "Inline comment", + "updated_at": "2026-05-18T01:00:00Z" + } + ]); + let issue_comments = json!([ + { + "id": 20, + "user": { "login": "bob" }, + "body": "Conversation comment", + "updated_at": "2026-05-18T02:00:00Z" + } + ]); + + let threads = github_threads(&reviews, &review_comments, &issue_comments); + + assert_eq!(threads.len(), 2); + assert_eq!(threads[0].id, "comment-10"); + assert_eq!(threads[0].file_path.as_deref(), Some("src/lib.rs")); + assert_eq!(threads[1].id, "issue-comment-20"); + assert_eq!(threads[1].file_path, None); + assert_eq!(threads[1].body, "Conversation comment"); + } + + #[test] + fn github_threads_keep_empty_body_reviews_visible() { + let reviews = json!([ + { + "id": 30, + "state": "APPROVED", + "user": { "login": "alice" }, + "body": "", + "submitted_at": "2026-05-18T03:00:00Z" + } + ]); + + let threads = github_threads(&reviews, &json!([]), &json!([])); + + assert_eq!(threads.len(), 1); + assert_eq!(threads[0].id, "review-30"); + assert_eq!(threads[0].body, "Approved this pull request."); + } + + #[test] + fn github_review_comment_replies_track_parent_comment() { + let threads = github_threads( + &json!([]), + &json!([ + { + "id": 40, + "in_reply_to_id": 10, + "user": { "login": "alice" }, + "body": "Reply", + "updated_at": "2026-05-18T04:30:00Z" + } + ]), + &json!([]), + ); + + assert_eq!(threads.len(), 1); + assert_eq!(threads[0].kind, ReviewPlatformThreadKind::Comment); + assert_eq!( + threads[0].reply_to_provider_comment_id.as_deref(), + Some("10") + ); + } + + #[test] + fn gitlab_threads_include_top_level_notes_without_duplication() { + let discussions = json!([ + { + "id": "discussion-1", + "resolved": false, + "notes": [ + { + "id": "100", + "author": { "username": "alice" }, + "body": "Inline note", + "updated_at": "2026-05-18T04:00:00Z", + "position": { "new_path": "src/lib.rs", "new_line": 12 } + } + ] + } + ]); + let notes = json!([ + { + "id": "100", + "author": { "username": "alice" }, + "body": "Inline note", + "updated_at": "2026-05-18T04:00:00Z", + "position": { "new_path": "src/lib.rs", "new_line": 12 } + }, + { + "id": "200", + "author": { "username": "bob" }, + "body": "Top-level note", + "updated_at": "2026-05-18T05:00:00Z" + } + ]); + + let threads = gitlab_threads(&discussions, ¬es); + + assert_eq!(threads.len(), 2); + assert_eq!(threads[0].id, "discussion-discussion-1:note-100"); + assert_eq!(threads[1].id, "note-200"); + assert_eq!(threads[1].file_path, None); + assert_eq!(threads[1].body, "Top-level note"); + } + + #[test] + fn gitlab_discussion_threads_mark_root_as_review_and_replies_as_comments() { + let discussions = json!([ + { + "id": "discussion-2", + "resolved": false, + "notes": [ + { + "id": "300", + "author": { "username": "alice" }, + "body": "Root note", + "updated_at": "2026-05-18T06:00:00Z" + }, + { + "id": "301", + "author": { "username": "bob" }, + "body": "Reply note", + "updated_at": "2026-05-18T06:05:00Z" + } + ] + } + ]); + + let threads = gitlab_threads(&discussions, &json!([])); + + assert_eq!(threads.len(), 2); + assert_eq!(threads[0].kind, ReviewPlatformThreadKind::Review); + assert_eq!(threads[0].reply_to_provider_comment_id, None); + assert_eq!(threads[1].kind, ReviewPlatformThreadKind::Comment); + assert_eq!( + threads[1].reply_to_provider_comment_id.as_deref(), + Some("300") + ); + } + + #[test] + fn summarize_ci_items_counts_provider_outcomes() { + let items = vec![ + ReviewPlatformCiItem { + id: "build".to_string(), + name: "Build".to_string(), + status: "completed".to_string(), + conclusion: Some("success".to_string()), + detail: None, + stage: Some("build".to_string()), + web_url: None, + log: None, + log_truncated: false, + started_at: None, + finished_at: None, + }, + ReviewPlatformCiItem { + id: "test".to_string(), + name: "Test".to_string(), + status: "failed".to_string(), + conclusion: None, + detail: None, + stage: Some("test".to_string()), + web_url: None, + log: None, + log_truncated: false, + started_at: None, + finished_at: None, + }, + ReviewPlatformCiItem { + id: "deploy".to_string(), + name: "Deploy".to_string(), + status: "running".to_string(), + conclusion: None, + detail: None, + stage: Some("deploy".to_string()), + web_url: None, + log: None, + log_truncated: false, + started_at: None, + finished_at: None, + }, + ]; + + let checks = summarize_ci_items(&items); + + assert_eq!(checks.total, 3); + assert_eq!(checks.passed, 1); + assert_eq!(checks.failed, 1); + assert_eq!(checks.pending, 1); + } + + #[test] + fn ci_log_value_extracts_error_excerpt_only() { + let text = [ + "running setup", + "downloading dependencies", + "cargo test failed with exit code 101", + "thread 'main' panicked at src/lib.rs:4", + "uploading artifacts", + ] + .join("\n"); + + let (log, truncated) = ci_log_value(text); + + let log = log.expect("log should be present"); + assert!(!truncated); + assert!(log.contains("cargo test failed")); + assert!(log.contains("panicked at src/lib.rs")); + } + + #[test] + fn ci_log_value_reports_when_no_error_lines_match() { + let (log, truncated) = ci_log_value("all checks passed".to_string()); + + assert!(!truncated); + assert!(log.is_none()); + } +} diff --git a/src/crates/tool-packs/src/lib.rs b/src/crates/tool-packs/src/lib.rs index aff05d94d2..1ec31ec489 100644 --- a/src/crates/tool-packs/src/lib.rs +++ b/src/crates/tool-packs/src/lib.rs @@ -153,6 +153,7 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ "GetMCPPrompt", "GenerativeUI", "Git", + "ReviewPlatform", "InitMiniApp", "ControlHub", "ComputerUse", @@ -302,6 +303,7 @@ mod tests { "GetMCPPrompt", "GenerativeUI", "Git", + "ReviewPlatform", "InitMiniApp", "ControlHub", "ComputerUse", diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index a046bced26..9aa569784e 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -101,6 +101,10 @@ const SessionUsagePanel = React.lazy(() => })) ); +const ReviewPlatformPanel = React.lazy(() => + import('@/app/components/panels/review-platform/ReviewPlatformPanel') +); + // CodePreview, ChartRenderer and CodeNode removed - visualization features disabled import { FlexiblePanelProps @@ -743,6 +747,26 @@ const FlexiblePanel: React.FC = memo(({ ); + case 'review-platform': + return ( + Loading pull requests...}> + + + ); + + case 'review-platform-pr-detail': + return ( + Loading pull request...}> + + + ); + case 'browser': return ( {t('flexiblePanel.loading.terminal')}}> diff --git a/src/web-ui/src/app/components/panels/base/types.ts b/src/web-ui/src/app/components/panels/base/types.ts index 3e1745b26c..0888041ee2 100644 --- a/src/web-ui/src/app/components/panels/base/types.ts +++ b/src/web-ui/src/app/components/panels/base/types.ts @@ -27,6 +27,8 @@ export type PanelContentType = | 'plan-viewer' | 'btw-session' | 'session-usage' + | 'review-platform' + | 'review-platform-pr-detail' | 'terminal' | 'generative-widget' | 'browser'; diff --git a/src/web-ui/src/app/components/panels/base/utils.ts b/src/web-ui/src/app/components/panels/base/utils.ts index a3693f75e6..cb846342b0 100644 --- a/src/web-ui/src/app/components/panels/base/utils.ts +++ b/src/web-ui/src/app/components/panels/base/utils.ts @@ -18,6 +18,7 @@ import { MessageSquareQuote, Globe, Activity, + GitPullRequest, } from 'lucide-react'; import { PanelContentType, PanelContentConfig } from './types'; @@ -207,6 +208,22 @@ export const PANEL_CONTENT_CONFIGS: Record supportsDownload: false, showHeader: false }, + 'review-platform': { + type: 'review-platform', + displayName: 'Pull Requests', + icon: GitPullRequest, + supportsCopy: false, + supportsDownload: false, + showHeader: false + }, + 'review-platform-pr-detail': { + type: 'review-platform-pr-detail', + displayName: 'Pull Request', + icon: GitPullRequest, + supportsCopy: false, + supportsDownload: false, + showHeader: false + }, 'terminal': { type: 'terminal', displayName: 'Terminal', diff --git a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.scss b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.scss new file mode 100644 index 0000000000..859e26e7bb --- /dev/null +++ b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.scss @@ -0,0 +1,1679 @@ +@use '../../../../component-library/styles/tokens' as *; + +.review-platform { + container: review-platform / inline-size; + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + color: var(--color-text-primary); + background: + linear-gradient(180deg, color-mix(in srgb, var(--color-bg-elevated) 52%, transparent), transparent 120px), + var(--color-bg-primary); + + &__topbar, + &__subbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 44px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-base); + } + + &__topbar { + flex-wrap: wrap; + } + + &__topbar { + background: color-mix(in srgb, var(--color-bg-elevated) 86%, transparent); + } + + &__subbar { + min-height: 34px; + padding-block: 6px; + background: color-mix(in srgb, var(--color-bg-elevated) 42%, transparent); + } + + &__brand, + &__topbar-actions, + &__status-line, + &__detail-meta, + &__detail-actions, + &__agent-link-actions, + &__summary-strip, + &__summary-grid span, + &__timeline-item, + &__thread-head, + &__account, + &__capability-row, + &__pagination { + display: flex; + align-items: center; + } + + &__brand { + min-width: 0; + gap: 10px; + } + + &__brand-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: 1px solid color-mix(in srgb, var(--accent-primary) 25%, var(--border-base)); + border-radius: 6px; + color: var(--accent-primary); + background: color-mix(in srgb, var(--accent-primary) 10%, transparent); + } + + &__brand-copy, + &__pr-main, + &__detail-title-block, + &__timeline-main { + min-width: 0; + display: flex; + flex-direction: column; + } + + &__title { + font-size: 13px; + font-weight: 650; + letter-spacing: 0; + } + + &__subtitle, + &__pr-meta, + &__detail-meta, + &__timeline-main span, + &__thread-anchor, + &__status-line, + &__summary-label { + color: var(--color-text-secondary); + font-size: 11px; + } + + &__topbar-actions { + gap: 8px; + min-width: 0; + flex-shrink: 0; + flex-wrap: wrap; + justify-content: flex-end; + } + + &__remote-select { + width: min(320px, 100%); + min-width: 180px; + } + + &__account { + max-width: 176px; + gap: 5px; + padding: 4px 8px; + border: 1px solid var(--border-base); + border-radius: 6px; + background: color-mix(in srgb, var(--element-bg-soft) 64%, transparent); + color: var(--color-text-secondary); + font-size: 11px; + + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + &__account--connected { + color: color-mix(in srgb, #3fb950 92%, var(--color-text-secondary)); + } + + &__account--not_required { + color: color-mix(in srgb, #58a6ff 90%, var(--color-text-secondary)); + } + + &__account--expired, + &__account--error { + color: #f85149; + } + + &__account--unsupported { + color: #d29922; + } + + &__icon-button.icon-btn { + border-radius: 6px; + color: var(--color-text-secondary); + background: transparent; + + &:hover:not(:disabled) { + color: var(--color-text-primary); + background: color-mix(in srgb, var(--element-bg-soft) 72%, transparent); + } + } + + &__auth-form { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; + } + + &__auth-target { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--border-base); + border-radius: 6px; + background: color-mix(in srgb, var(--element-bg-soft) 58%, transparent); + + span, + strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + span { + color: var(--color-text-secondary); + font-size: 11px; + font-weight: 500; + } + + strong { + color: var(--color-text-primary); + font-size: 12px; + font-weight: 650; + } + } + + &__auth-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + } + + &__auth-gate { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 10px 12px; + padding: 12px; + border: 1px solid color-mix(in srgb, #d29922 42%, var(--border-base)); + border-radius: 10px; + background: color-mix(in srgb, #d29922 10%, var(--color-bg-elevated)); + } + + &__auth-gate--detail { + max-width: 720px; + margin: 0; + } + + &__auth-gate-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + flex: 0 0 auto; + border: 1px solid color-mix(in srgb, #d29922 42%, var(--border-base)); + border-radius: 8px; + color: #d29922; + background: color-mix(in srgb, #d29922 14%, transparent); + } + + &__auth-gate-copy { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + + strong { + color: var(--color-text-primary); + font-size: 13px; + font-weight: 650; + } + + span { + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.4; + overflow-wrap: anywhere; + } + } + + &__auth-gate-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; + } + + &__status-line { + gap: 12px; + flex-wrap: wrap; + + span { + display: inline-flex; + align-items: center; + gap: 5px; + } + } + + &__loading-inline { + color: var(--accent-primary); + display: inline-block; + transform-origin: center; + } + + &__loading-inline--icon { + animation: review-platform-spin 0.9s linear infinite; + } + + &__loading-status { + color: var(--color-text-secondary); + } + + &__cache-label { + color: var(--color-text-secondary); + } + + &__body { + min-height: 0; + flex: 1; + display: grid; + grid-template-columns: minmax(260px, clamp(260px, 32cqi, 360px)) minmax(0, 1fr); + } + + &--detail-only &__body { + grid-template-columns: minmax(0, 1fr); + } + + &__list { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border-base); + background: color-mix(in srgb, var(--color-bg-elevated) 48%, transparent); + } + + &__list-toolbar { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 10px 8px; + border-bottom: 1px solid var(--border-base); + } + + &__state-filters { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + + &__state-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 24px; + padding: 0 9px; + border: 1px solid var(--border-base); + border-radius: 999px; + background: transparent; + color: var(--color-text-secondary); + font-size: 11px; + cursor: pointer; + transition: border-color $motion-base $easing-standard, background $motion-base $easing-standard, color $motion-base $easing-standard; + + &:hover { + border-color: color-mix(in srgb, var(--accent-primary) 32%, var(--border-base)); + color: var(--color-text-primary); + } + + &.is-active { + border-color: color-mix(in srgb, var(--accent-primary) 40%, var(--border-base)); + color: var(--accent-primary); + background: color-mix(in srgb, var(--accent-primary) 11%, transparent); + } + } + + &__list-scroll { + min-height: 0; + flex: 1; + overflow-y: auto; + padding: 8px; + } + + &__pagination { + justify-content: space-between; + gap: 8px; + min-height: 34px; + padding: 6px 8px; + border-top: 1px solid var(--border-base); + color: var(--color-text-secondary); + background: color-mix(in srgb, var(--color-bg-elevated) 62%, transparent); + font-size: 11px; + font-variant-numeric: tabular-nums; + } + + &__detail-pagination { + flex: 0 0 auto; + margin-top: 4px; + border: 1px solid var(--border-base); + border-radius: 7px; + } + + &__empty-state, + &__error-state, + &__detail-empty { + min-height: 160px; + padding: 20px 16px; + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.5; + } + + &__empty-state, + &__detail-empty { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-direction: column; + text-align: center; + } + + &__detail-empty-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; + } + + &__error-state { + display: grid; + justify-items: center; + gap: 10px; + color: #f85149; + } + + &__pr-row { + width: 100%; + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + gap: 8px; + padding: 10px 9px; + border: 1px solid transparent; + border-radius: 7px; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; + transition: border-color $motion-base $easing-standard, background $motion-base $easing-standard; + + &:hover { + background: color-mix(in srgb, var(--element-bg-soft) 78%, transparent); + } + + &.is-selected { + border-color: color-mix(in srgb, var(--accent-primary) 28%, var(--border-base)); + background: linear-gradient(90deg, color-mix(in srgb, var(--accent-primary) 11%, transparent), color-mix(in srgb, var(--element-bg-soft) 86%, transparent)); + } + } + + &__pr-icon { + padding-top: 1px; + } + + &__pr-title, + &__file-path, + &__timeline-main strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__pr-title { + font-size: 12px; + font-weight: 600; + } + + &__pr-meta--secondary { + margin-top: 2px; + } + + &__pr-stats { + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: space-between; + gap: 8px; + padding-top: 1px; + } + + &__counts { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--color-text-secondary); + } + + &__decision, + &__file-status, + &__capability-chip { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 2px 6px; + border-radius: 5px; + font-size: 10px; + font-weight: 650; + line-height: 1.2; + } + + &__decision--approved { + color: #3fb950; + background: rgba(63, 185, 80, 0.12); + } + + &__decision--changes_requested { + color: #f85149; + background: rgba(248, 81, 73, 0.12); + } + + &__decision--commented, + &__decision--pending { + color: #d29922; + background: rgba(210, 153, 34, 0.13); + } + + &__state-icon--open { + color: #3fb950; + } + + &__state-icon--merged { + color: #a371f7; + } + + &__state-icon--closed { + color: #f85149; + } + + &__detail { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + } + + &__detail-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + min-width: 0; + gap: 12px; + padding: 12px 14px; + border-bottom: 1px solid var(--border-base); + } + + &__detail-title-row { + display: flex; + align-items: center; + gap: 7px; + + h3 { + min-width: 0; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 15px; + font-weight: 650; + letter-spacing: 0; + } + } + + &__detail-meta { + flex-wrap: wrap; + gap: 8px; + margin-top: 5px; + + span { + display: inline-flex; + align-items: center; + gap: 4px; + } + } + + &__detail-actions { + gap: 6px; + flex-shrink: 0; + flex-wrap: wrap; + justify-content: flex-end; + + .btn { + gap: 5px; + } + } + + &__panel-button.btn { + min-height: 28px; + border-radius: 6px; + border-color: color-mix(in srgb, var(--border-base) 82%, transparent); + background: color-mix(in srgb, var(--element-bg-soft) 54%, transparent); + color: var(--color-text-secondary); + font-size: 11px; + font-weight: 600; + box-shadow: none; + + &:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--accent-primary) 34%, var(--border-base)); + background: color-mix(in srgb, var(--accent-primary) 9%, var(--element-bg-soft)); + color: var(--color-text-primary); + } + + &:disabled { + opacity: 0.48; + cursor: default; + } + } + + &__summary-strip { + gap: 8px; + padding: 12px 14px 0; + flex-wrap: wrap; + } + + &__agent-link-panel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 10px 14px 0; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--accent-primary) 20%, var(--border-base)); + border-radius: 7px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--accent-primary) 8%, transparent), transparent), + color-mix(in srgb, var(--color-bg-elevated) 70%, transparent); + } + + &__agent-link-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; + + strong, + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + strong { + color: var(--color-text-primary); + font-size: 12px; + font-weight: 650; + } + + span { + color: var(--color-text-secondary); + font-size: 11px; + } + } + + &__agent-link-label { + color: var(--accent-primary) !important; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0; + } + + &__agent-link-actions { + gap: 6px; + flex-shrink: 0; + } + + &__summary-card { + min-width: 106px; + display: flex; + flex-direction: column; + gap: 3px; + padding: 10px 12px; + border: 1px solid var(--border-base); + border-radius: 6px; + background: color-mix(in srgb, var(--color-bg-elevated) 74%, transparent); + + strong { + font-size: 14px; + font-weight: 650; + } + } + + &__capability-row { + gap: 8px; + flex-wrap: wrap; + margin-bottom: 14px; + } + + &__section-heading { + width: 100%; + min-height: 30px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; + + > span { + min-width: 0; + color: var(--color-text-primary); + font-size: 12px; + font-weight: 650; + } + + .btn { + flex-shrink: 0; + gap: 5px; + } + } + + &__section-count { + flex: 0 0 auto; + color: var(--color-text-secondary); + font-size: 11px; + } + + &__card-heading { + min-height: 30px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: -2px 0 8px; + + > span { + min-width: 0; + color: var(--color-text-primary); + font-size: 12px; + font-weight: 650; + } + + .btn { + flex-shrink: 0; + gap: 5px; + } + } + + &__capability-chip { + color: var(--color-text-secondary); + background: color-mix(in srgb, var(--element-bg-soft) 76%, transparent); + border: 1px solid var(--border-base); + } + + &__tabs { + min-height: 0; + flex: 1; + + &.bitfun-tabs, + .bitfun-tabs__content, + .bitfun-tab-pane { + min-height: 0; + height: 100%; + } + + .bitfun-tabs__nav { + padding: 8px 12px 0; + border-bottom: 1px solid var(--border-base); + background: transparent; + } + + .bitfun-tabs__nav-list { + gap: 2px; + padding: 0; + border-radius: 0; + background: transparent; + } + + .bitfun-tabs__tab { + position: relative; + min-height: 30px; + padding: 0 10px 8px; + border: 0; + border-radius: 0; + background: transparent; + color: var(--color-text-secondary); + font-size: 11px; + font-weight: 600; + letter-spacing: 0; + box-shadow: none; + + &::after { + content: ''; + position: absolute; + right: 10px; + bottom: -1px; + left: 10px; + height: 2px; + border-radius: 2px; + background: transparent; + } + + &:hover { + color: var(--color-text-primary); + background: color-mix(in srgb, var(--element-bg-soft) 56%, transparent); + } + + &.active, + &.bitfun-tabs__tab--active, + &[aria-selected='true'] { + color: var(--color-text-primary); + background: transparent; + + &::after { + background: color-mix(in srgb, var(--accent-primary) 72%, var(--color-text-primary)); + } + } + } + } + + &__tab-content { + box-sizing: border-box; + height: 100%; + overflow-y: auto; + padding: 12px 14px; + } + + &__body-copy, + &__body-markdown { + width: min(100%, 1120px); + max-width: 100%; + margin: 0 0 14px; + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.55; + } + + &__review-sessions { + max-width: 860px; + margin-bottom: 12px; + border: 1px solid var(--border-base); + border-radius: 7px; + overflow: hidden; + background: color-mix(in srgb, var(--color-bg-elevated) 62%, transparent); + } + + &__review-sessions-head { + min-height: 34px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 0 10px; + border-bottom: 1px solid var(--border-base); + background: color-mix(in srgb, var(--element-bg-soft) 42%, transparent); + + span:first-child { + color: var(--color-text-primary); + font-size: 12px; + font-weight: 650; + } + + span:last-child { + color: var(--color-text-secondary); + font-size: 11px; + } + } + + &__review-session-list { + display: flex; + flex-direction: column; + } + + &__review-session { + width: 100%; + min-height: 42px; + display: grid; + grid-template-columns: 22px minmax(0, 1fr) 18px; + align-items: center; + gap: 8px; + padding: 7px 10px; + border: 0; + border-bottom: 1px solid var(--border-base); + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; + + &:last-child { + border-bottom: 0; + } + + &:hover { + background: color-mix(in srgb, var(--element-bg-soft) 58%, transparent); + } + + > svg { + color: var(--color-text-secondary); + } + } + + &__review-session--running &__review-session-icon { + color: var(--accent-primary); + } + + &__review-session--completed &__review-session-icon { + color: #3fb950; + } + + &__review-session--error &__review-session-icon { + color: #f85149; + } + + &__review-session-icon { + display: inline-flex; + align-items: center; + justify-content: center; + } + + &__review-session-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + + strong, + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + strong { + color: var(--color-text-primary); + font-size: 12px; + font-weight: 650; + } + + span { + color: var(--color-text-secondary); + font-size: 11px; + } + } + + &__review-session-empty { + padding: 10px; + color: var(--color-text-secondary); + font-size: 11px; + line-height: 1.5; + } + + &__body-markdown { + padding: 10px 12px; + border: 1px solid var(--border-base); + border-radius: 7px; + background: color-mix(in srgb, var(--color-bg-elevated) 64%, transparent); + + .markdown-renderer { + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.6; + + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + + h1, + h2, + h3, + h4 { + margin: 12px 0 8px; + color: var(--color-text-primary); + font-size: 13px; + letter-spacing: 0; + } + + p, + ul, + ol, + blockquote, + pre { + margin: 8px 0; + } + } + } + + &__summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 8px; + + span { + min-height: 34px; + gap: 6px; + padding: 0 10px; + border: 1px solid var(--border-base); + border-radius: 7px; + background: color-mix(in srgb, var(--element-bg-soft) 66%, transparent); + color: var(--color-text-secondary); + font-size: 11px; + } + } + + &__additions { + color: #3fb950 !important; + } + + &__deletions { + color: #f85149 !important; + } + + &__file-list, + &__timeline, + &__threads { + display: flex; + flex-direction: column; + gap: 6px; + align-items: stretch; + } + + &__file-card { + flex: 0 0 auto; + min-width: 0; + overflow: hidden; + border: 1px solid var(--border-base); + border-radius: 7px; + background: color-mix(in srgb, var(--color-bg-elevated) 72%, transparent); + } + + &__file-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + width: 100%; + min-height: 34px; + padding: 0 8px; + border-bottom: 1px solid var(--border-base); + background: color-mix(in srgb, var(--element-bg-soft) 42%, transparent); + } + + &__file-main { + min-width: 0; + display: grid; + grid-template-columns: 18px 72px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + width: 100%; + min-height: 34px; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; + + &:hover { + background: color-mix(in srgb, var(--element-bg-soft) 62%, transparent); + } + } + + &__file-add-button.btn { + min-height: 24px; + padding-inline: 8px; + flex-shrink: 0; + } + + &__file-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-text-secondary); + } + + &__file-status--added { + color: #3fb950; + background: rgba(63, 185, 80, 0.12); + } + + &__file-status--modified, + &__file-status--renamed { + color: #58a6ff; + background: rgba(88, 166, 255, 0.12); + } + + &__file-status--deleted { + color: #f85149; + background: rgba(248, 81, 73, 0.12); + } + + &__file-delta { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 11px; + font-variant-numeric: tabular-nums; + } + + &__diff-block { + display: block; + box-sizing: border-box; + width: 100%; + margin: 0; + max-height: 360px; + overflow: auto; + padding: 8px 0; + background: color-mix(in srgb, var(--color-bg-primary) 72%, transparent); + color: var(--color-text-secondary); + font-family: var(--font-family-mono, 'SFMono-Regular', Consolas, 'Liberation Mono', monospace); + font-size: 11px; + line-height: 1.55; + tab-size: 2; + } + + &__diff-line { + display: block; + min-height: 17px; + padding: 0 12px; + white-space: pre; + } + + &__diff-line--hunk { + color: #a371f7; + background: rgba(163, 113, 247, 0.1); + } + + &__diff-line--add { + color: #7ee787; + background: rgba(63, 185, 80, 0.1); + } + + &__diff-line--delete { + color: #ffa198; + background: rgba(248, 81, 73, 0.1); + } + + &__diff-line--meta { + color: #79c0ff; + } + + &__diff-empty { + padding: 10px 12px; + color: var(--color-text-secondary); + font-size: 11px; + } + + &__detail-error { + display: flex; + align-items: center; + gap: 8px; + min-height: 36px; + padding: 8px 10px; + border: 1px solid color-mix(in srgb, #f85149 34%, var(--border-base)); + border-radius: 7px; + background: rgba(248, 81, 73, 0.08); + color: #f85149; + font-size: 11px; + + span { + min-width: 0; + flex: 1; + } + } + + &__timeline-item { + gap: 9px; + min-height: 42px; + padding: 7px 8px; + border: 1px solid var(--border-base); + border-radius: 6px; + background: color-mix(in srgb, var(--color-bg-elevated) 72%, transparent); + + svg { + color: var(--color-text-secondary); + flex-shrink: 0; + } + + code { + margin-left: auto; + color: var(--color-text-secondary); + font-size: 11px; + } + } + + &__ci-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__ci-item { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 11px; + border: 1px solid var(--border-base); + border-radius: 6px; + background: color-mix(in srgb, var(--color-bg-elevated) 72%, transparent); + } + + &__ci-item--passed { + border-color: color-mix(in srgb, #3fb950 28%, var(--border-base)); + background: color-mix(in srgb, #3fb950 8%, var(--color-bg-elevated)); + } + + &__ci-item--failed { + border-color: color-mix(in srgb, #f85149 30%, var(--border-base)); + background: color-mix(in srgb, #f85149 8%, var(--color-bg-elevated)); + } + + &__ci-item--pending { + border-color: color-mix(in srgb, #d29922 24%, var(--border-base)); + background: color-mix(in srgb, #d29922 7%, var(--color-bg-elevated)); + } + + &__ci-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + } + + &__ci-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + + strong, + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + strong { + color: var(--color-text-primary); + font-size: 12px; + font-weight: 650; + white-space: nowrap; + } + + span { + color: var(--color-text-secondary); + font-size: 11px; + line-height: 1.45; + white-space: normal; + } + } + + &__ci-status { + flex-shrink: 0; + display: inline-flex; + align-items: center; + width: fit-content; + padding: 2px 6px; + border-radius: 5px; + font-size: 10px; + font-weight: 650; + line-height: 1.2; + } + + &__ci-log-chip { + flex-shrink: 0; + display: inline-flex; + align-items: center; + width: fit-content; + padding: 2px 6px; + border: 1px solid color-mix(in srgb, var(--accent-primary) 26%, var(--border-base)); + border-radius: 5px; + color: var(--accent-primary); + background: color-mix(in srgb, var(--accent-primary) 9%, transparent); + font-size: 10px; + font-weight: 650; + line-height: 1.2; + } + + &__ci-actions { + flex-shrink: 0; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + } + + &__ci-action.icon-btn { + border-radius: 6px; + } + + &__ci-status--passed { + color: #3fb950; + background: rgba(63, 185, 80, 0.12); + } + + &__ci-status--failed { + color: #f85149; + background: rgba(248, 81, 73, 0.12); + } + + &__ci-status--pending { + color: #d29922; + background: rgba(210, 153, 34, 0.13); + } + + &__ci-meta { + display: flex; + flex-direction: column; + gap: 3px; + color: var(--color-text-secondary); + font-size: 11px; + line-height: 1.4; + + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + } + + &__ci-log-panel { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 8px; + border-top: 1px solid color-mix(in srgb, var(--border-base) 72%, transparent); + } + + &__ci-detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 8px; + + div { + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--border-base); + border-radius: 6px; + background: color-mix(in srgb, var(--element-bg-soft) 48%, transparent); + } + + span { + display: block; + margin-bottom: 3px; + color: var(--color-text-tertiary); + font-size: 10px; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + strong { + display: block; + overflow-wrap: anywhere; + color: var(--color-text-primary); + font-size: 11px; + font-weight: 500; + line-height: 1.45; + } + } + + &__ci-log-block { + max-height: 320px; + min-height: 90px; + overflow: auto; + margin: 0; + padding: 10px; + border: 1px solid var(--border-base); + border-radius: 6px; + background: color-mix(in srgb, var(--color-bg-primary) 86%, transparent); + color: var(--color-text-primary); + font-family: var(--font-family-mono, ui-monospace, SFMono-Regular, Consolas, monospace); + font-size: 11px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; + } + + &__ci-log-empty { + padding: 8px 10px; + border: 1px dashed var(--border-base); + border-radius: 6px; + color: var(--color-text-secondary); + background: color-mix(in srgb, var(--element-bg-soft) 48%, transparent); + font-size: 11px; + line-height: 1.45; + } + + &__thread { + padding: 10px; + border: 1px solid var(--border-base); + border-radius: 7px; + background: color-mix(in srgb, var(--color-bg-elevated) 76%, transparent); + + &.is-resolved { + opacity: 0.72; + } + + p { + margin: 8px 0; + color: var(--color-text-primary); + font-size: 12px; + line-height: 1.5; + } + } + + &__thread-loading { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--border-base); + border-radius: 7px; + color: var(--color-text-secondary); + font-size: 12px; + background: color-mix(in srgb, var(--color-bg-elevated) 66%, transparent); + + svg { + animation: review-platform-spin 0.9s linear infinite; + } + } + + &__thread-loading--refreshing { + margin-bottom: 2px; + } + + &__thread--reply { + margin-left: 18px; + border-left-width: 3px; + border-left-color: color-mix(in srgb, var(--accent-primary) 42%, var(--border-base)); + } + + &__thread--review { + background: color-mix(in srgb, var(--accent-primary) 7%, var(--color-bg-elevated)); + } + + &__thread--comment { + background: color-mix(in srgb, var(--color-bg-elevated) 78%, transparent); + } + + &__thread-head { + justify-content: space-between; + gap: 12px; + color: var(--color-text-secondary); + font-size: 11px; + } + + &__thread-tags { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + flex-wrap: wrap; + } + + &__thread-tag { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 0 7px; + border-radius: 999px; + border: 1px solid transparent; + font-size: 10px; + font-weight: 600; + letter-spacing: 0; + text-transform: uppercase; + } + + &__thread-tag--review { + color: color-mix(in srgb, var(--accent-primary) 92%, var(--color-text-primary)); + border-color: color-mix(in srgb, var(--accent-primary) 20%, var(--border-base)); + background: color-mix(in srgb, var(--accent-primary) 10%, transparent); + } + + &__thread-tag--comment { + color: var(--color-text-secondary); + border-color: var(--border-base); + background: color-mix(in srgb, var(--element-bg-soft) 62%, transparent); + } + + &__thread-tag--resolved { + color: color-mix(in srgb, #3fb950 90%, var(--color-text-secondary)); + border-color: color-mix(in srgb, #3fb950 24%, var(--border-base)); + background: color-mix(in srgb, #3fb950 10%, transparent); + } + + &__thread-tag--open { + color: color-mix(in srgb, #f1c40f 82%, var(--color-text-secondary)); + border-color: color-mix(in srgb, #f1c40f 24%, var(--border-base)); + background: color-mix(in srgb, #f1c40f 10%, transparent); + } + + &__thread-meta { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + margin-top: 8px; + + strong { + min-width: 0; + color: var(--color-text-primary); + font-size: 12px; + font-weight: 650; + } + } + + &__thread-reply-block { + margin-top: 10px; + border: 1px solid var(--border-base); + border-radius: 6px; + overflow: hidden; + background: color-mix(in srgb, var(--element-bg-soft) 64%, transparent); + } + + &__thread-reply-header { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-base); + background: color-mix(in srgb, var(--color-bg-elevated) 76%, transparent); + } + + &__thread-reply-label { + color: var(--color-text-secondary); + font-size: 10px; + font-weight: 600; + letter-spacing: 0; + text-transform: uppercase; + } + + &__thread-reply-author { + color: var(--color-text-primary); + font-size: 11px; + font-weight: 650; + } + + &__thread-reply-body { + min-width: 0; + padding: 10px 12px; + color: var(--color-text-secondary); + font-size: 11px; + line-height: 1.45; + } + + &__thread-body { + min-width: 0; + margin-top: 8px; + color: var(--color-text-primary); + font-size: 12px; + line-height: 1.5; + } + + &__thread-reply-body, + &__thread-body { + :where(p, ul, ol, pre, blockquote) { + margin-top: 0; + } + + :where(p, ul, ol, pre, blockquote):last-child { + margin-bottom: 0; + } + + :where(pre) { + max-width: 100%; + overflow: auto; + border-radius: 6px; + } + } + + &__thread-anchor { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__loading { + padding: 18px; + color: var(--color-text-secondary); + font-size: 12px; + } + + @keyframes review-platform-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } + } + + @media (max-width: 980px) { + &__body { + grid-template-columns: 1fr; + } + + &__list { + max-height: 260px; + border-right: 0; + border-bottom: 1px solid var(--border-base); + } + + &__summary-grid { + grid-template-columns: repeat(2, minmax(120px, 1fr)); + } + + &__summary-strip { + gap: 6px; + } + + &__agent-link-panel { + align-items: flex-start; + flex-direction: column; + } + + &__agent-link-actions { + width: 100%; + flex-wrap: wrap; + } + } + + @container review-platform (max-width: 980px) { + &__body { + grid-template-columns: 1fr; + } + + &__list { + max-height: 260px; + border-right: 0; + border-bottom: 1px solid var(--border-base); + } + + &__summary-grid { + grid-template-columns: repeat(2, minmax(120px, 1fr)); + } + + &__summary-strip { + gap: 6px; + } + + &__agent-link-panel, + &__detail-header { + align-items: flex-start; + flex-direction: column; + } + + &__agent-link-actions, + &__detail-actions { + width: 100%; + flex-wrap: wrap; + justify-content: flex-start; + } + } + + @container review-platform (max-width: 560px) { + &__topbar-actions, + &__remote-select { + width: 100%; + } + + &__auth-gate { + flex-direction: column; + } + + &__auth-gate-actions { + width: 100%; + justify-content: flex-start; + } + + &__summary-grid { + grid-template-columns: 1fr; + } + + &__file-row { + align-items: stretch; + grid-template-columns: 1fr; + gap: 0; + padding-block: 4px; + } + + &__file-main { + grid-template-columns: 18px 68px minmax(0, 1fr); + min-height: 30px; + } + + &__file-delta { + grid-column: 3; + justify-self: start; + } + + &__file-add-button.btn { + justify-self: flex-start; + margin-left: 98px; + } + } +} diff --git a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx new file mode 100644 index 0000000000..25080ac1bd --- /dev/null +++ b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx @@ -0,0 +1,2306 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ChevronDown, + ChevronLeft, + ChevronRight, + CheckCircle2, + CircleDot, + Clock3, + Code2, + GitCommitHorizontal, + GitPullRequest, + GitPullRequestClosed, + KeyRound, + Link2, + Loader2, + MessageSquareText, + RefreshCw, + Search, + ShieldCheck, + Sparkles, + Trash2, + UserRound, + XCircle, +} from 'lucide-react'; +import { Button, IconButton, Input, MarkdownRenderer, Modal, Select, Tabs, TabPane, Tooltip, type SelectOption } from '@/component-library'; +import { reviewPlatformAPI, systemAPI, type ReviewPlatformAccount, type ReviewPlatformAuthChallenge, type ReviewPlatformCiItem, type ReviewPlatformCiLog, type ReviewPlatformCommit, type ReviewPlatformDetailSection, type ReviewPlatformFile, type ReviewPlatformPagination, type ReviewPlatformPullRequest, type ReviewPlatformPullRequestDetail, type ReviewPlatformPullRequestDetailPage, type ReviewPlatformRemote, type ReviewPlatformRepositoryRef, type ReviewPlatformThread, type ReviewPlatformWorkspaceSnapshot } from '@/infrastructure/api'; +import { createLogger } from '@/shared/utils/logger'; +import { notificationService } from '@/shared/notification-system'; +import { openMainSession } from '@/flow_chat/services/openBtwSession'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import type { FlowToolItem, Session } from '@/flow_chat/types/flow-chat'; +import { findLatestCodeReviewResult, summarizeCodeReviewResult } from '@/flow_chat/utils/reviewSessionSummary'; +import { parsePullRequestUrl, remoteMatchesPullRequestLink } from '@/shared/utils/pullRequestLinks'; +import { useContextStore } from '@/shared/stores/contextStore'; +import type { PullRequestContext } from '@/shared/types/context'; +import './ReviewPlatformPanel.scss'; + +const log = createLogger('ReviewPlatformPanel'); + +interface ReviewPlatformPanelProps { + workspacePath?: string; + initialRemoteId?: string; + initialPullRequestId?: string; + initialPullRequestUrl?: string; + detailOnly?: boolean; +} + +type DetailTab = 'overview' | 'ci' | 'changes' | 'commits' | 'reviews'; +type ListStateFilter = 'all' | 'open' | 'draft' | 'merged' | 'closed'; +type SnapshotCacheState = 'none' | 'cached' | 'refreshing'; + +const PR_PAGE_SIZE = 10; +const CI_PAGE_SIZE = 20; +const CHANGE_PAGE_SIZE = 15; +const COMMIT_PAGE_SIZE = 30; +const REVIEW_PAGE_SIZE = 20; +const REMOTE_STORAGE_PREFIX = 'bitfun:review-platform:last-remote:'; +const MAX_LINKED_REVIEW_SESSIONS = 6; + +interface SnapshotCacheEntry { + snapshot: ReviewPlatformWorkspaceSnapshot; + fetchedAt: number; +} + +interface DetailCacheEntry { + detail: ReviewPlatformPullRequestDetail; + fetchedAt: number; +} + +interface DetailPageCacheEntry { + detail: ReviewPlatformPullRequestDetailPage; + fetchedAt: number; +} + +interface PageInfo { + pageIndex: number; + totalPages: number; + start: number; + end: number; + totalLabel: string; + hasNext: boolean; +} + +interface ReviewSessionMarkerInput { + childSessionId?: string; + parentSessionId?: string; + kind?: 'review' | 'deep_review'; + title?: string; + requestedFiles?: string[]; +} + +interface ReviewSessionMarker { + childSessionId: string; + parentSessionId?: string; + kind: 'review' | 'deep_review'; + title?: string; + requestedFiles: string[]; +} + +interface LinkedReviewSession { + childSession: Session; + parentSession?: Session; + marker?: ReviewSessionMarker; + kind: 'review' | 'deep_review'; + title: string; + requestedFiles: string[]; + issueCount: number; + riskLevel?: string; + lifecycle: 'running' | 'completed' | 'error' | 'idle'; + updatedAt: number; +} + +const snapshotCache = new Map(); +const detailCache = new Map(); +const detailPageCache = new Map(); +const EMPTY_REVIEW_THREADS: ReviewPlatformThread[] = []; + +function detailPageInfo(pagination: ReviewPlatformPagination, itemCount: number): PageInfo { + const pageIndex = Math.max(0, (pagination.page || 1) - 1); + const perPage = Math.max(1, pagination.perPage || itemCount || 1); + const total = pagination.total ?? null; + const totalPages = total !== null + ? Math.max(1, Math.ceil(total / perPage)) + : pageIndex + (pagination.hasNext ? 2 : 1); + const start = itemCount === 0 ? 0 : pageIndex * perPage + 1; + const end = total !== null + ? Math.min(total, pageIndex * perPage + itemCount) + : pageIndex * perPage + itemCount; + return { + pageIndex, + totalPages, + start, + end, + totalLabel: total !== null ? String(total) : `${end}+`, + hasNext: pagination.hasNext, + }; +} + +function snapshotCacheKey(workspacePath: string, remoteId: string | null, page: number, perPage: number): string { + return `${workspacePath}::${remoteId ?? 'default'}::${page}::${perPage}`; +} + +function detailCacheKey(workspacePath: string, remoteId: string, pullRequestId: string): string { + return `${workspacePath}::${remoteId}::${pullRequestId}`; +} + +function detailPageCacheKey(workspacePath: string, remoteId: string, pullRequestId: string, section: ReviewPlatformDetailSection, page: number, perPage: number): string { + return `${workspacePath}::${remoteId}::${pullRequestId}::${section}::${page}::${perPage}`; +} + +function clearDetailPageCacheForPullRequest(workspacePath: string, remoteId: string, pullRequestId: string): void { + const prefix = `${workspacePath}::${remoteId}::${pullRequestId}::`; + for (const key of detailPageCache.keys()) { + if (key.startsWith(prefix)) { + detailPageCache.delete(key); + } + } +} + +function emptyPagination(page: number, perPage: number): ReviewPlatformPagination { + return { page, perPage, total: null, hasNext: false }; +} + +function mergeDetailPage( + current: ReviewPlatformPullRequestDetail | null, + page: ReviewPlatformPullRequestDetailPage, +): ReviewPlatformPullRequestDetail { + const base = current ?? page; + return { + ...base, + ...page, + additions: page.additions || base.additions, + deletions: page.deletions || base.deletions, + changedFiles: page.changedFiles || base.changedFiles, + ci: page.section === 'ci' ? page.ci : base.ci, + files: page.section === 'files' ? page.files : base.files, + commits: page.section === 'commits' ? page.commits : base.commits, + threads: page.section === 'reviews' ? page.threads : base.threads, + }; +} + +function remotePreferenceKey(workspacePath: string): string { + return `${REMOTE_STORAGE_PREFIX}${workspacePath}`; +} + +function readRememberedRemote(workspacePath?: string): string | null { + if (!workspacePath || typeof window === 'undefined') return null; + try { + return window.localStorage.getItem(remotePreferenceKey(workspacePath)); + } catch { + return null; + } +} + +function rememberRemote(workspacePath: string | undefined, remoteId: string | null): void { + if (!workspacePath || typeof window === 'undefined') return; + try { + const key = remotePreferenceKey(workspacePath); + if (remoteId) { + window.localStorage.setItem(key, remoteId); + } else { + window.localStorage.removeItem(key); + } + } catch { + // Ignore storage failures; the selector still works for the current session. + } +} + +function formatRelativeTime(value: string): string { + const time = new Date(value).getTime(); + if (!Number.isFinite(time)) return ''; + const diffMs = Date.now() - time; + const minutes = Math.max(1, Math.floor(diffMs / 60000)); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +function formatAbsoluteTime(value: string): string { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date); +} + +function getPrIcon(pr: ReviewPlatformPullRequest) { + if (pr.state === 'merged') return ; + if (pr.state === 'closed') return ; + return ; +} + +function decisionLabel(decision: ReviewPlatformPullRequest['reviewDecision']): string { + switch (decision) { + case 'approved': + return 'Approved'; + case 'changes_requested': + return 'Changes requested'; + case 'commented': + return 'Commented'; + default: + return 'Pending review'; + } +} + +function stateLabel(state: ReviewPlatformPullRequest['state']): string { + switch (state) { + case 'open': + return 'Open'; + case 'draft': + return 'Draft'; + case 'merged': + return 'Merged'; + case 'closed': + return 'Closed'; + default: + return state; + } +} + +function providerLabel(remote: ReviewPlatformRemote | ReviewPlatformAccount | null): string { + if (!remote) return 'No provider'; + switch (remote.platform) { + case 'github': + return 'GitHub'; + case 'gitlab': + return 'GitLab'; + case 'gitcode': + return 'GitCode'; + default: + return 'Git'; + } +} + +function remoteLabel(remote: ReviewPlatformRemote): string { + return `${providerLabel(remote)} · ${remote.name} · ${remote.projectPath}`; +} + +function authLabel(account: ReviewPlatformAccount | null): string { + if (!account) return 'Disconnected'; + switch (account.authState) { + case 'connected': + return 'Connected'; + case 'not_required': + return 'Public'; + case 'unsupported': + return 'Unsupported'; + case 'expired': + return 'Expired'; + case 'error': + return 'Auth error'; + default: + return 'Not connected'; + } +} + +function authSourceLabel(source: ReviewPlatformAccount['authSource'] | undefined): string { + switch (source) { + case 'stored': + return 'Saved token'; + case 'env': + return 'Environment token'; + case 'unsupported': + return 'Unsupported'; + default: + return 'No token'; + } +} + +function authChallengeTitle(challenge: ReviewPlatformAuthChallenge): string { + switch (challenge.state) { + case 'missing': + return 'Token required'; + case 'insufficient_scope': + return 'Token permissions required'; + default: + return 'Token update required'; + } +} + +function authChallengeScopes(challenge: ReviewPlatformAuthChallenge): string { + return challenge.requiredScopes.length ? challenge.requiredScopes.join(', ') : 'Provider API access'; +} + +function emptySnapshot(): ReviewPlatformWorkspaceSnapshot { + return { + remotes: [], + selectedRemoteId: null, + accounts: [], + repository: null, + pullRequests: [], + pagination: { + page: 1, + perPage: PR_PAGE_SIZE, + total: 0, + hasNext: false, + }, + capabilities: { + canCreateReview: false, + canCreatePullRequest: false, + canReplyToThread: false, + canResolveThread: false, + canApprove: false, + canRevokeApproval: false, + canRequestChanges: false, + canMerge: false, + supportsDraftReview: false, + }, + message: null, + authChallenge: null, + }; +} + +function diffLineClass(line: string): string { + if (line.startsWith('+++') || line.startsWith('---')) return 'review-platform__diff-line review-platform__diff-line--meta'; + if (line.startsWith('@@')) return 'review-platform__diff-line review-platform__diff-line--hunk'; + if (line.startsWith('+')) return 'review-platform__diff-line review-platform__diff-line--add'; + if (line.startsWith('-')) return 'review-platform__diff-line review-platform__diff-line--delete'; + return 'review-platform__diff-line'; +} + +function fileKey(file: { path: string; oldPath?: string | null }): string { + return `${file.oldPath ?? ''}->${file.path}`; +} + +function normalizePath(value: string): string { + return value.replace(/\\/g, '/').trim(); +} + +function uniquePaths(paths: string[]): string[] { + const seen = new Set(); + const next: string[] = []; + for (const path of paths) { + const normalized = normalizePath(path); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + next.push(normalized); + } + return next; +} + +function pathsOverlap(left: string[], right: string[]): boolean { + if (!left.length || !right.length) return false; + const rightSet = new Set(right.map(normalizePath)); + return left.some(path => rightSet.has(normalizePath(path))); +} + +function isReviewSessionRunning(session: Session): boolean { + const turn = session.dialogTurns[session.dialogTurns.length - 1]; + return turn?.status === 'pending' || + turn?.status === 'image_analyzing' || + turn?.status === 'processing' || + turn?.status === 'finishing'; +} + +function reviewSessionLifecycle(session: Session): LinkedReviewSession['lifecycle'] { + const turn = session.dialogTurns[session.dialogTurns.length - 1]; + if (session.error || turn?.status === 'error') return 'error'; + if (isReviewSessionRunning(session)) return 'running'; + if (turn?.status === 'completed') return 'completed'; + return 'idle'; +} + +function getSessionTitle(session?: Session, fallback = 'Review session'): string { + return session?.title?.trim() || fallback; +} + +function extractReviewSessionMarkers(session: Session): ReviewSessionMarker[] { + const markers: ReviewSessionMarker[] = []; + for (const turn of session.dialogTurns) { + for (const round of turn.modelRounds) { + for (const item of round.items) { + if (item.type !== 'tool') continue; + const toolItem = item as FlowToolItem; + if (toolItem.toolName !== 'ReviewSessionSummary') continue; + const input = (toolItem.toolCall?.input ?? {}) as ReviewSessionMarkerInput; + if (!input.childSessionId) continue; + markers.push({ + childSessionId: input.childSessionId, + parentSessionId: input.parentSessionId ?? session.sessionId, + kind: input.kind === 'deep_review' ? 'deep_review' : 'review', + title: input.title, + requestedFiles: uniquePaths(input.requestedFiles ?? []), + }); + } + } + } + return markers; +} + +function buildPrChatPrompt(params: { + pr: ReviewPlatformPullRequest; + remote: ReviewPlatformRemote | null; + repository: ReviewPlatformRepositoryRef | null; + filePaths: string[]; + webUrl?: string; +}): string { + const fileList = params.filePaths.length + ? params.filePaths.map(path => `- ${path}`).join('\n') + : '- No file list is loaded yet'; + const provider = params.remote ? providerLabel(params.remote) : 'review platform'; + const repository = params.repository?.projectPath ?? params.remote?.projectPath ?? 'current repository'; + + return [ + `Review PR #${params.pr.number}: ${params.pr.title}`, + '', + `Provider: ${provider}`, + `Repository: ${repository}`, + `Branch: ${params.pr.sourceBranch} -> ${params.pr.targetBranch}`, + params.webUrl ? `URL: ${params.webUrl}` : null, + '', + 'Changed files:', + fileList, + '', + 'Please use this PR context with the current conversation. Focus on risks, review findings, and concrete fixes.', + ].filter(Boolean).join('\n'); +} + +function createContextId(prefix: string): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +function formatChecksText(pr: ReviewPlatformPullRequest): string { + return pr.checks.total > 0 + ? `${pr.checks.passed}/${pr.checks.total} passed, ${pr.checks.failed} failed, ${pr.checks.pending} pending` + : 'No checks reported'; +} + +function buildPrOverviewContext(params: { + pr: ReviewPlatformPullRequest; + detail: ReviewPlatformPullRequestDetail | null; + remote: ReviewPlatformRemote | null; + repository: ReviewPlatformRepositoryRef | null; + filePaths: string[]; + reviewItemCount: number; + webUrl?: string; +}): string { + const body = params.detail?.body?.trim() || 'No pull request description was returned by the provider.'; + return [ + buildPrChatPrompt(params), + '', + 'Overview:', + body, + '', + `State: ${stateLabel(params.pr.state)}`, + `Review decision: ${decisionLabel(params.pr.reviewDecision)}`, + `Checks: ${formatChecksText(params.pr)}`, + `Comments: ${params.reviewItemCount}`, + ].join('\n'); +} + +function buildPrFileDiffContext(pr: ReviewPlatformPullRequest, file: ReviewPlatformFile): string { + return [ + `Pull request file diff: PR #${pr.number} ${pr.title}`, + `File: ${file.path}`, + file.oldPath && file.oldPath !== file.path ? `Old path: ${file.oldPath}` : null, + `Status: ${file.status}`, + `Delta: +${file.additions} -${file.deletions}`, + '', + 'Diff:', + file.patch?.trim() || 'No inline diff is available for this file.', + ].filter(Boolean).join('\n'); +} + +function buildPrCommitsContext(pr: ReviewPlatformPullRequest, commits: ReviewPlatformCommit[]): string { + if (!commits.length) { + return `Pull request commits: PR #${pr.number} ${pr.title}\n\nNo commits were returned by the provider.`; + } + return [ + `Pull request commits: PR #${pr.number} ${pr.title}`, + '', + ...commits.map(commit => [ + `- ${commit.shortHash} ${commit.title}`, + ` Author: ${commit.author}`, + ` Committed: ${formatAbsoluteTime(commit.committedAt) || commit.committedAt}`, + ` Hash: ${commit.hash}`, + ].join('\n')), + ].join('\n'); +} + +function buildPrReviewsContext(pr: ReviewPlatformPullRequest, threads: ReviewPlatformThread[]): string { + if (!threads.length) { + return `Pull request reviews: PR #${pr.number} ${pr.title}\n\nNo review threads were returned by the provider.`; + } + const threadByCommentId = new Map( + threads + .filter(thread => thread.providerCommentId) + .map(thread => [thread.providerCommentId as string, thread]), + ); + return [ + `Pull request reviews: PR #${pr.number} ${pr.title}`, + '', + ...threads.map(thread => [ + `- [${thread.kind === 'review' ? 'Review' : 'Comment'}] ${thread.resolved ? 'Resolved' : 'Open'} thread by ${thread.author}`, + thread.replyToProviderCommentId + ? ` Reply to: ${threadByCommentId.get(thread.replyToProviderCommentId)?.author ?? thread.replyToProviderCommentId}` + : null, + thread.filePath ? ` Location: ${thread.filePath}${thread.line ? `:${thread.line}` : ''}` : null, + ` Updated: ${formatAbsoluteTime(thread.updatedAt) || thread.updatedAt}`, + ` Body: ${thread.body}`, + ].filter(Boolean).join('\n')), + ].join('\n'); +} + +function ciItemTone(item: ReviewPlatformCiItem): 'passed' | 'failed' | 'pending' { + const raw = `${item.conclusion ?? item.status}`.trim().toLowerCase(); + if (['success', 'neutral', 'skipped', 'passed', 'pass'].includes(raw)) return 'passed'; + if (['failure', 'failed', 'error', 'timed_out', 'timed-out', 'cancelled', 'canceled', 'action_required'].includes(raw)) return 'failed'; + return 'pending'; +} + +function ciItemStatusText(item: ReviewPlatformCiItem): string { + const status = item.status.trim(); + const conclusion = item.conclusion?.trim(); + if (!conclusion || conclusion.toLowerCase() === status.toLowerCase()) { + return status || 'unknown'; + } + return `${status || 'unknown'} · ${conclusion}`; +} + +function buildPrCiContext(pr: ReviewPlatformPullRequest, ciItems: ReviewPlatformCiItem[]): string { + if (!ciItems.length) { + return `Pull request CI: PR #${pr.number} ${pr.title}\n\nNo CI entries were returned by the provider.`; + } + return [ + `Pull request CI page: PR #${pr.number} ${pr.title}`, + '', + `Checks: ${formatChecksText(pr)}`, + '', + ...ciItems.map(item => [ + `- ${item.name}`, + ` Status: ${ciItemStatusText(item)}`, + item.stage ? ` Stage: ${item.stage}` : null, + item.detail ? ` Detail: ${item.detail}` : null, + item.webUrl ? ` URL: ${item.webUrl}` : null, + item.startedAt ? ` Started: ${formatAbsoluteTime(item.startedAt) || item.startedAt}` : null, + item.finishedAt ? ` Finished: ${formatAbsoluteTime(item.finishedAt) || item.finishedAt}` : null, + ].filter(Boolean).join('\n')), + ].join('\n'); +} + +function buildPrCiItemContext(pr: ReviewPlatformPullRequest, item: ReviewPlatformCiItem, ciLog?: ReviewPlatformCiLog | null): string { + const hasLog = Boolean(ciLog?.log); + return [ + `Pull request CI result: PR #${pr.number} ${pr.title}`, + '', + `Checks: ${formatChecksText(pr)}`, + '', + `Name: ${item.name}`, + `Status: ${ciItemStatusText(item)}`, + item.conclusion ? `Conclusion: ${item.conclusion}` : null, + item.stage ? `Stage: ${item.stage}` : null, + item.detail ? `Detail: ${item.detail}` : null, + item.webUrl ? `URL: ${item.webUrl}` : null, + item.startedAt ? `Started: ${formatAbsoluteTime(item.startedAt) || item.startedAt}` : null, + item.finishedAt ? `Finished: ${formatAbsoluteTime(item.finishedAt) || item.finishedAt}` : null, + '', + hasLog ? 'Error log excerpt:' : 'Provider detail:', + hasLog + ? `${ciLog?.truncated ? '[Truncated error excerpt]\n' : ''}${ciLog?.log ?? ''}` + : ciLog?.message || item.detail || 'No additional provider detail has been loaded for this CI result.', + ].filter(Boolean).join('\n'); +} + +function canLoadCiLog(remote: ReviewPlatformRemote | null, _item: ReviewPlatformCiItem): boolean { + return Boolean(remote); +} + +function canExpandCiItem(remote: ReviewPlatformRemote | null, item: ReviewPlatformCiItem): boolean { + return canLoadCiLog(remote, item) || Boolean(item.log || item.detail || item.stage || item.webUrl || item.startedAt || item.finishedAt); +} + +export const ReviewPlatformPanel: React.FC = ({ + workspacePath, + initialRemoteId, + initialPullRequestId, + initialPullRequestUrl, + detailOnly = false, +}) => { + const snapshotRequestSeq = useRef(0); + const detailRequestSeq = useRef(0); + const detailSectionRequestSeq = useRef(0); + const [snapshot, setSnapshot] = useState(emptySnapshot); + const [selectedRemoteId, setSelectedRemoteId] = useState(null); + const [selectedPrId, setSelectedPrId] = useState(null); + const [detail, setDetail] = useState(null); + const [flowState, setFlowState] = useState(() => flowChatStore.getState()); + const [activeTab, setActiveTab] = useState('overview'); + const [loading, setLoading] = useState(true); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(null); + const [error, setError] = useState(null); + const [query, setQuery] = useState(''); + const [stateFilter, setStateFilter] = useState('all'); + const [pageIndex, setPageIndex] = useState(0); + const [ciPageIndex, setCiPageIndex] = useState(0); + const [changePageIndex, setChangePageIndex] = useState(0); + const [commitPageIndex, setCommitPageIndex] = useState(0); + const [reviewPageIndex, setReviewPageIndex] = useState(0); + const [ciPagination, setCiPagination] = useState(() => emptyPagination(1, CI_PAGE_SIZE)); + const [changePagination, setChangePagination] = useState(() => emptyPagination(1, CHANGE_PAGE_SIZE)); + const [commitPagination, setCommitPagination] = useState(() => emptyPagination(1, COMMIT_PAGE_SIZE)); + const [reviewPagination, setReviewPagination] = useState(() => emptyPagination(1, REVIEW_PAGE_SIZE)); + const [expandedFileKeys, setExpandedFileKeys] = useState>(() => new Set()); + const [expandedCiItemIds, setExpandedCiItemIds] = useState>(() => new Set()); + const [ciLogById, setCiLogById] = useState>({}); + const [ciLogErrorById, setCiLogErrorById] = useState>({}); + const [ciLogLoadingIds, setCiLogLoadingIds] = useState>(() => new Set()); + const [snapshotCacheState, setSnapshotCacheState] = useState('none'); + const [authModalOpen, setAuthModalOpen] = useState(false); + const [authToken, setAuthToken] = useState(''); + const [authSaving, setAuthSaving] = useState(false); + const [authError, setAuthError] = useState(null); + + const repository = snapshot.repository; + const account = snapshot.accounts[0] ?? null; + const selectedRemote = useMemo( + () => snapshot.remotes.find(remote => remote.id === selectedRemoteId) ?? snapshot.remotes[0] ?? null, + [selectedRemoteId, snapshot.remotes], + ); + const authChallenge = snapshot.authChallenge ?? null; + const selectedPrFromList = useMemo( + () => snapshot.pullRequests.find(pr => pr.id === selectedPrId) ?? null, + [selectedPrId, snapshot.pullRequests], + ); + const selectedPr = detail ?? selectedPrFromList; + const hasDetail = detail !== null; + const initialPullRequestTarget = useMemo( + () => initialPullRequestUrl ? parsePullRequestUrl(initialPullRequestUrl) : null, + [initialPullRequestUrl], + ); + const prFilePaths = useMemo( + () => uniquePaths((detail?.files ?? []).map(file => file.path)), + [detail?.files], + ); + const ciItems = useMemo(() => detail?.ci ?? [], [detail?.ci]); + const changedFiles = useMemo(() => detail?.files ?? [], [detail?.files]); + const commits = useMemo(() => detail?.commits ?? [], [detail?.commits]); + const reviewThreads = useMemo(() => detail?.threads ?? EMPTY_REVIEW_THREADS, [detail?.threads]); + const reviewThreadByCommentId = useMemo( + () => new Map( + reviewThreads + .filter(thread => thread.providerCommentId) + .map(thread => [thread.providerCommentId as string, thread]), + ), + [reviewThreads], + ); + const reviewItemCount = reviewPagination.total + ?? (reviewThreads.length > 0 ? reviewThreads.length : (selectedPr?.comments ?? 0)); + const ciTotal = ciPagination.total ?? ciItems.length; + const ciPage = detailPageInfo(ciPagination, ciTotal); + const changePage = detailPageInfo(changePagination, changedFiles.length); + const commitPage = detailPageInfo(commitPagination, commits.length); + const reviewPage = detailPageInfo(reviewPagination, reviewThreads.length); + const pagedCiItems = ciItems; + const pagedChangedFiles = changedFiles; + const pagedCommits = commits; + const pagedReviewThreads = reviewThreads; + const remoteOptions = useMemo( + () => snapshot.remotes.map(remote => ({ + value: remote.id, + label: remoteLabel(remote), + description: `${remote.host} · ${authLabel(account && account.id === remote.id ? account : null)}`, + })), + [account, snapshot.remotes], + ); + + const loadSnapshot = useCallback(async (nextRemoteId?: string | null, options?: { force?: boolean; page?: number }) => { + const requestSeq = ++snapshotRequestSeq.current; + if (!workspacePath) { + setSnapshot(emptySnapshot()); + setSelectedRemoteId(null); + setSelectedPrId(null); + setDetail(null); + setDetailError(null); + setError('No active workspace is available.'); + setLoading(false); + return; + } + + const requestedRemoteId = nextRemoteId !== undefined ? nextRemoteId : readRememberedRemote(workspacePath); + const requestedPage = Math.max(1, options?.page ?? 1); + const requestedCacheKey = snapshotCacheKey(workspacePath, requestedRemoteId ?? null, requestedPage, PR_PAGE_SIZE); + const cached = snapshotCache.get(requestedCacheKey); + const force = options?.force === true; + + if (cached && !force) { + const remoteId = cached.snapshot.selectedRemoteId ?? cached.snapshot.remotes[0]?.id ?? null; + setSnapshot(cached.snapshot); + setSelectedRemoteId(remoteId); + setPageIndex(Math.max(0, (cached.snapshot.pagination.page || requestedPage) - 1)); + setSelectedPrId(null); + setDetail(null); + setDetailError(null); + setError(null); + setSnapshotCacheState('cached'); + setLoading(false); + return; + } else { + setSnapshot(emptySnapshot()); + setSelectedPrId(null); + setDetail(null); + setDetailError(null); + setSnapshotCacheState('none'); + } + + setLoading(true); + setError(null); + try { + const next = await reviewPlatformAPI.getWorkspaceSnapshot(workspacePath, requestedRemoteId ?? null, requestedPage, PR_PAGE_SIZE); + if (snapshotRequestSeq.current !== requestSeq) return; + setSnapshot(next); + const remoteId = next.selectedRemoteId ?? next.remotes[0]?.id ?? null; + setSelectedRemoteId(remoteId); + setPageIndex(Math.max(0, (next.pagination.page || requestedPage) - 1)); + rememberRemote(workspacePath, remoteId); + setSelectedPrId(null); + setDetail(null); + setDetailError(null); + const entry = { snapshot: next, fetchedAt: Date.now() }; + snapshotCache.set(requestedCacheKey, entry); + if (remoteId) { + snapshotCache.set(snapshotCacheKey(workspacePath, remoteId, requestedPage, PR_PAGE_SIZE), entry); + } + setSnapshotCacheState('cached'); + } catch (err) { + if (snapshotRequestSeq.current !== requestSeq) return; + const message = err instanceof Error ? err.message : 'Failed to load pull requests'; + setError(message); + if (!cached) { + setSnapshot(emptySnapshot()); + } + log.error('Failed to load review platform snapshot', { workspacePath, error: err }); + } finally { + if (snapshotRequestSeq.current === requestSeq) { + setLoading(false); + } + } + }, [workspacePath]); + + const loadDetail = useCallback(async (repo: ReviewPlatformRepositoryRef | null, remoteId: string, pullRequestId: string, options?: { force?: boolean }) => { + const requestSeq = ++detailRequestSeq.current; + detailSectionRequestSeq.current += 1; + const repositoryPath = workspacePath || repo?.workspacePath || ''; + const cacheKey = detailCacheKey(repositoryPath, remoteId, pullRequestId); + const cached = detailCache.get(cacheKey); + const force = options?.force === true; + + setDetailError(null); + if (force) { + detailCache.delete(cacheKey); + clearDetailPageCacheForPullRequest(repositoryPath, remoteId, pullRequestId); + } + + if (cached && !force) { + setDetail(cached.detail); + setDetailLoading(false); + return; + } else { + setDetail(null); + } + + setDetailLoading(true); + try { + const nextDetail = await reviewPlatformAPI.getPullRequestDetailPage({ + repositoryPath, + remoteId, + pullRequestId, + section: 'overview', + page: 1, + perPage: 1, + }); + if (detailRequestSeq.current !== requestSeq) return; + setDetail(nextDetail); + detailCache.set(cacheKey, { detail: nextDetail, fetchedAt: Date.now() }); + } catch (err) { + if (detailRequestSeq.current !== requestSeq) return; + log.error('Failed to load pull request detail', { pullRequestId, error: err }); + setDetailError(err instanceof Error ? err.message : 'Failed to load pull request details.'); + if (!cached) { + setDetail(null); + } + } finally { + if (detailRequestSeq.current === requestSeq) { + setDetailLoading(false); + } + } + }, [workspacePath]); + + const applySectionPagination = useCallback((section: Exclude, pagination: ReviewPlatformPagination) => { + if (section === 'ci') { + setCiPagination(pagination); + } else if (section === 'files') { + setChangePagination(pagination); + } else if (section === 'commits') { + setCommitPagination(pagination); + } else { + setReviewPagination(pagination); + } + }, []); + + const loadDetailSection = useCallback(async ( + repo: ReviewPlatformRepositoryRef | null, + remoteId: string, + pullRequestId: string, + section: Exclude, + pageIndex: number, + perPage: number, + options?: { force?: boolean }, + ) => { + const repositoryPath = workspacePath || repo?.workspacePath || ''; + const page = Math.max(1, pageIndex + 1); + const cacheKey = detailPageCacheKey(repositoryPath, remoteId, pullRequestId, section, page, perPage); + const cached = detailPageCache.get(cacheKey); + const force = options?.force === true; + + if (cached && !force) { + setDetail(prev => mergeDetailPage(prev, cached.detail)); + applySectionPagination(section, cached.detail.pagination); + return; + } + + const requestSeq = ++detailSectionRequestSeq.current; + setDetailLoading(true); + setDetailError(null); + try { + const nextPage = await reviewPlatformAPI.getPullRequestDetailPage({ + repositoryPath, + remoteId, + pullRequestId, + section, + page, + perPage, + }); + if (detailSectionRequestSeq.current !== requestSeq) return; + detailPageCache.set(cacheKey, { detail: nextPage, fetchedAt: Date.now() }); + setDetail(prev => mergeDetailPage(prev, nextPage)); + applySectionPagination(section, nextPage.pagination); + } catch (err) { + if (detailSectionRequestSeq.current !== requestSeq) return; + log.error('Failed to load pull request detail section', { pullRequestId, section, page, perPage, error: err }); + setDetailError(err instanceof Error ? err.message : 'Failed to load pull request details.'); + } finally { + if (detailSectionRequestSeq.current === requestSeq) { + setDetailLoading(false); + } + } + }, [applySectionPagination, workspacePath]); + + useEffect(() => { + void loadSnapshot(detailOnly && initialRemoteId ? initialRemoteId : undefined); + }, [detailOnly, initialRemoteId, loadSnapshot]); + + useEffect(() => flowChatStore.subscribe(setFlowState), []); + + useEffect(() => { + if (!selectedRemoteId) { + setDetail(null); + setDetailError(null); + return; + } + if (!selectedPrId || (!repository && !workspacePath)) { + setDetail(null); + setDetailError(null); + return; + } + void loadDetail(repository, selectedRemoteId, selectedPrId); + }, [loadDetail, repository, selectedPrId, selectedRemoteId, workspacePath]); + + useEffect(() => { + if (!snapshot.remotes.length) return; + if (!selectedRemoteId && snapshot.selectedRemoteId) { + setSelectedRemoteId(snapshot.selectedRemoteId); + } + }, [selectedRemoteId, snapshot.remotes.length, snapshot.selectedRemoteId]); + + useEffect(() => { + if (!detailOnly) return; + const targetPullRequestId = initialPullRequestId ?? initialPullRequestTarget?.pullRequestId ?? null; + if (!targetPullRequestId) { + if (initialPullRequestUrl) { + setDetailError('This link is not a supported pull request URL.'); + } + return; + } + + const matchedRemote = initialRemoteId + ? snapshot.remotes.find(remote => remote.id === initialRemoteId) ?? null + : initialPullRequestTarget + ? snapshot.remotes.find(remote => remoteMatchesPullRequestLink(remote, initialPullRequestTarget)) ?? null + : null; + const nextRemoteId = initialRemoteId + ?? matchedRemote?.id + ?? (snapshot.remotes.length === 1 ? snapshot.remotes[0].id : null) + ?? snapshot.selectedRemoteId + ?? selectedRemoteId; + + if (nextRemoteId && selectedRemoteId !== nextRemoteId) { + setSelectedRemoteId(nextRemoteId); + rememberRemote(workspacePath, nextRemoteId); + } + + if (selectedPrId !== targetPullRequestId) { + setSelectedPrId(targetPullRequestId); + } + }, [ + detailOnly, + initialPullRequestId, + initialPullRequestTarget, + initialPullRequestUrl, + initialRemoteId, + selectedPrId, + selectedRemoteId, + snapshot.remotes, + snapshot.selectedRemoteId, + workspacePath, + ]); + + useEffect(() => { + setExpandedFileKeys(new Set()); + setExpandedCiItemIds(new Set()); + setCiLogById({}); + setCiLogErrorById({}); + setCiLogLoadingIds(new Set()); + setCiPageIndex(0); + setChangePageIndex(0); + setCommitPageIndex(0); + setReviewPageIndex(0); + setCiPagination(emptyPagination(1, CI_PAGE_SIZE)); + setChangePagination(emptyPagination(1, CHANGE_PAGE_SIZE)); + setCommitPagination(emptyPagination(1, COMMIT_PAGE_SIZE)); + setReviewPagination(emptyPagination(1, REVIEW_PAGE_SIZE)); + }, [selectedPrId]); + + useEffect(() => { + if (activeTab !== 'changes' || changedFiles.length === 0 || expandedFileKeys.size > 0) return; + setExpandedFileKeys(new Set(changedFiles.slice(0, 1).map(fileKey))); + }, [activeTab, changedFiles, expandedFileKeys.size]); + + useEffect(() => { + if (!hasDetail || !selectedRemoteId || !selectedPrId || (!repository && !workspacePath)) return; + if (activeTab === 'ci') { + void loadDetailSection(repository, selectedRemoteId, selectedPrId, 'ci', ciPageIndex, CI_PAGE_SIZE); + } else if (activeTab === 'changes') { + void loadDetailSection(repository, selectedRemoteId, selectedPrId, 'files', changePageIndex, CHANGE_PAGE_SIZE); + } else if (activeTab === 'commits') { + void loadDetailSection(repository, selectedRemoteId, selectedPrId, 'commits', commitPageIndex, COMMIT_PAGE_SIZE); + } else if (activeTab === 'reviews') { + void loadDetailSection(repository, selectedRemoteId, selectedPrId, 'reviews', reviewPageIndex, REVIEW_PAGE_SIZE); + } + }, [ + activeTab, + ciPageIndex, + changePageIndex, + commitPageIndex, + hasDetail, + loadDetailSection, + repository, + reviewPageIndex, + selectedPrId, + selectedRemoteId, + workspacePath, + ]); + + const visiblePullRequests = useMemo(() => { + const needle = query.trim().toLowerCase(); + return snapshot.pullRequests.filter(pr => { + if (stateFilter !== 'all' && pr.state !== stateFilter) return false; + if (!needle) return true; + return [ + pr.title, + pr.author, + pr.sourceBranch, + pr.targetBranch, + `#${pr.number}`, + ].some(value => value.toLowerCase().includes(needle)); + }); + }, [query, snapshot.pullRequests, stateFilter]); + + const parentSession = useMemo(() => { + const sessions = Array.from(flowState.sessions.values()); + const activeSession = flowState.activeSessionId + ? flowState.sessions.get(flowState.activeSessionId) + : undefined; + const sameWorkspace = (session?: Session) => + Boolean(session && (!workspacePath || normalizePath(session.workspacePath ?? '') === normalizePath(workspacePath))); + + if (activeSession?.sessionKind === 'normal' && sameWorkspace(activeSession)) { + return activeSession; + } + + if ( + activeSession && + (activeSession.sessionKind === 'review' || activeSession.sessionKind === 'deep_review') && + activeSession.parentSessionId + ) { + const parent = flowState.sessions.get(activeSession.parentSessionId); + if (parent?.sessionKind === 'normal' && sameWorkspace(parent)) { + return parent; + } + } + + return sessions + .filter(session => session.sessionKind === 'normal' && sameWorkspace(session)) + .sort((left, right) => (right.lastActiveAt || right.updatedAt || right.createdAt) - (left.lastActiveAt || left.updatedAt || left.createdAt))[0]; + }, [flowState.activeSessionId, flowState.sessions, workspacePath]); + + const linkedReviewSessions = useMemo(() => { + const sessions = Array.from(flowState.sessions.values()); + const markersByChildId = new Map(); + for (const session of sessions) { + for (const marker of extractReviewSessionMarkers(session)) { + markersByChildId.set(marker.childSessionId, marker); + } + } + + const selectedPaths = prFilePaths; + const sameWorkspace = (session: Session) => + !workspacePath || normalizePath(session.workspacePath ?? '') === normalizePath(workspacePath); + + return sessions + .filter(session => + (session.sessionKind === 'review' || session.sessionKind === 'deep_review') && + sameWorkspace(session), + ) + .map((session): LinkedReviewSession | null => { + const marker = markersByChildId.get(session.sessionId); + const requestedFiles = marker?.requestedFiles ?? []; + if (selectedPaths.length > 0 && requestedFiles.length > 0 && !pathsOverlap(selectedPaths, requestedFiles)) { + return null; + } + + const reviewResult = findLatestCodeReviewResult(session); + const summary = summarizeCodeReviewResult(reviewResult); + const kind = session.sessionKind === 'deep_review' ? 'deep_review' : 'review'; + return { + childSession: session, + parentSession: marker?.parentSessionId ? flowState.sessions.get(marker.parentSessionId) : undefined, + marker, + kind, + title: marker?.title || getSessionTitle(session, kind === 'deep_review' ? 'Deep review' : 'Code review'), + requestedFiles, + issueCount: summary.issueCount, + riskLevel: summary.riskLevel, + lifecycle: reviewSessionLifecycle(session), + updatedAt: session.lastActiveAt || session.updatedAt || session.createdAt, + }; + }) + .filter((session): session is LinkedReviewSession => Boolean(session)) + .sort((left, right) => right.updatedAt - left.updatedAt) + .slice(0, MAX_LINKED_REVIEW_SESSIONS); + }, [flowState.sessions, prFilePaths, workspacePath]); + + const pagination = snapshot.pagination; + const totalCount = pagination.total ?? null; + const currentPageIndex = Math.max(0, (pagination.page || pageIndex + 1) - 1); + const totalPages = totalCount !== null + ? Math.max(1, Math.ceil(totalCount / pagination.perPage)) + : currentPageIndex + (pagination.hasNext ? 2 : 1); + const pageStart = snapshot.pullRequests.length ? currentPageIndex * pagination.perPage + 1 : 0; + const pageEnd = totalCount !== null + ? Math.min(totalCount, currentPageIndex * pagination.perPage + snapshot.pullRequests.length) + : currentPageIndex * pagination.perPage + snapshot.pullRequests.length; + + const summary = useMemo(() => { + const prs = snapshot.pullRequests; + return { + open: prs.filter(pr => pr.state === 'open').length, + draft: prs.filter(pr => pr.state === 'draft').length, + merged: prs.filter(pr => pr.state === 'merged').length, + reviewRequired: prs.filter(pr => pr.reviewDecision === 'changes_requested' || pr.reviewDecision === 'pending').length, + }; + }, [snapshot.pullRequests]); + + const headerLabel = selectedRemote ? remoteLabel(selectedRemote) : repository ? repository.projectPath : 'No repository'; + const panelTitle = detailOnly ? 'Pull Request' : 'Pull Requests'; + + const handleRemoteChange = useCallback((value: string | number | (string | number)[]) => { + const remoteId = Array.isArray(value) ? String(value[0] ?? '') : String(value); + setSelectedRemoteId(remoteId || null); + setSelectedPrId(null); + setDetail(null); + setDetailError(null); + setPageIndex(0); + rememberRemote(workspacePath, remoteId || null); + void loadSnapshot(remoteId || null, { page: 1 }); + }, [loadSnapshot, workspacePath]); + + const handlePageChange = useCallback((nextPageIndex: number) => { + const nextPage = Math.max(1, nextPageIndex + 1); + setSelectedPrId(null); + setDetail(null); + setDetailError(null); + setPageIndex(nextPage - 1); + void loadSnapshot(selectedRemoteId, { page: nextPage }); + }, [loadSnapshot, selectedRemoteId]); + + const toggleFileExpanded = useCallback((key: string) => { + setExpandedFileKeys(prev => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, []); + + const renderDetailPagination = useCallback(( + label: string, + page: PageInfo, + itemCount: number, + onPageChange: (nextPageIndex: number) => void, + ) => { + if (itemCount <= 0 || (page.totalPages <= 1 && !page.hasNext && page.pageIndex === 0)) return null; + return ( +
+ onPageChange(page.pageIndex - 1)} + > + + + + {label}: {page.start}-{page.end} of {page.totalLabel} + + = page.totalPages - 1} + onClick={() => onPageChange(page.pageIndex + 1)} + > + + +
+ ); + }, []); + + const renderDetailLoading = useCallback((message: string, refreshing = false) => ( +
+ + {message} +
+ ), []); + + const handleOpenExternal = useCallback(async () => { + const webUrl = selectedPr?.webUrl || initialPullRequestUrl; + if (!webUrl) return; + try { + await systemAPI.openExternal(webUrl); + } catch (error) { + log.error('Failed to open pull request URL', { error, webUrl }); + } + }, [initialPullRequestUrl, selectedPr?.webUrl]); + + const handleOpenCiUrl = useCallback(async (webUrl?: string | null) => { + if (!webUrl) return; + try { + await systemAPI.openExternal(webUrl); + } catch (error) { + log.error('Failed to open CI URL', { error, webUrl }); + } + }, []); + + const loadCiLog = useCallback(async (item: ReviewPlatformCiItem): Promise => { + const cached = ciLogById[item.id]; + if (cached) return cached; + if (!canLoadCiLog(selectedRemote, item)) { + return { + ciItemId: item.id, + log: item.log ?? null, + truncated: item.logTruncated, + message: item.detail || null, + }; + } + if ((!repository && !workspacePath) || !selectedRemoteId || !selectedPrId) return null; + + const repositoryPath = workspacePath || repository?.workspacePath || ''; + setCiLogLoadingIds(prev => { + const next = new Set(prev); + next.add(item.id); + return next; + }); + setCiLogErrorById(prev => { + const next = { ...prev }; + delete next[item.id]; + return next; + }); + + try { + const nextLog = await reviewPlatformAPI.getPullRequestCiLog({ + repositoryPath, + remoteId: selectedRemoteId, + pullRequestId: selectedPrId, + ciItemId: item.id, + ciItemName: item.name, + }); + setCiLogById(prev => ({ ...prev, [item.id]: nextLog })); + return nextLog; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load CI error log.'; + setCiLogErrorById(prev => ({ ...prev, [item.id]: message })); + log.error('Failed to load CI log', { itemId: item.id, error: err }); + return null; + } finally { + setCiLogLoadingIds(prev => { + const next = new Set(prev); + next.delete(item.id); + return next; + }); + } + }, [ciLogById, repository, selectedPrId, selectedRemote, selectedRemoteId, workspacePath]); + + const toggleCiExpanded = useCallback((item: ReviewPlatformCiItem) => { + if (expandedCiItemIds.has(item.id)) { + setExpandedCiItemIds(prev => { + const next = new Set(prev); + next.delete(item.id); + return next; + }); + return; + } + + setExpandedCiItemIds(prev => { + const next = new Set(prev); + next.add(item.id); + return next; + }); + if (canLoadCiLog(selectedRemote, item) || item.log) { + void loadCiLog(item); + } + }, [expandedCiItemIds, loadCiLog, selectedRemote]); + + const handleOpenParentChat = useCallback(async () => { + if (!parentSession) { + notificationService.warning('Open or create a chat session before linking PR context.', { duration: 3500 }); + return; + } + await openMainSession(parentSession.sessionId); + }, [parentSession]); + + const addPullRequestContextToChat = useCallback(async (input: { + label: string; + section: PullRequestContext['section']; + content: string; + metadata?: Record; + }) => { + if (!parentSession) { + notificationService.warning('Open or create a chat session before sending PR context.', { duration: 3500 }); + return; + } + + await openMainSession(parentSession.sessionId); + const context: PullRequestContext = { + id: createContextId('pr'), + type: 'pull-request', + label: input.label, + section: input.section, + content: input.content, + metadata: input.metadata, + timestamp: Date.now(), + sourceUrl: selectedPr?.webUrl || initialPullRequestUrl, + remoteId: selectedRemote?.id, + repository: repository?.projectPath ?? selectedRemote?.projectPath, + pullRequestNumber: selectedPr?.number, + pullRequestTitle: selectedPr?.title, + }; + + useContextStore.getState().addContext(context); + window.dispatchEvent(new CustomEvent('insert-context-tag', { detail: { context } })); + }, [initialPullRequestUrl, parentSession, repository?.projectPath, selectedPr, selectedRemote?.id, selectedRemote?.projectPath]); + + const handleFillPrContext = useCallback(async () => { + if (!selectedPr) return; + await addPullRequestContextToChat({ + label: `PR #${selectedPr.number} overview`, + section: 'overview', + content: buildPrOverviewContext({ + pr: selectedPr, + detail, + remote: selectedRemote, + repository, + filePaths: prFilePaths, + reviewItemCount, + webUrl: selectedPr.webUrl, + }), + }); + }, [addPullRequestContextToChat, detail, prFilePaths, repository, reviewItemCount, selectedPr, selectedRemote]); + + const handleAddFileDiffContext = useCallback(async (file: ReviewPlatformFile) => { + if (!selectedPr) return; + await addPullRequestContextToChat({ + label: `PR #${selectedPr.number} ${file.path}`, + section: 'file-diff', + content: buildPrFileDiffContext(selectedPr, file), + }); + }, [addPullRequestContextToChat, selectedPr]); + + const handleAddCommitsContext = useCallback(async () => { + if (!selectedPr) return; + await addPullRequestContextToChat({ + label: `PR #${selectedPr.number} commits`, + section: 'commits', + content: buildPrCommitsContext(selectedPr, detail?.commits ?? []), + }); + }, [addPullRequestContextToChat, detail?.commits, selectedPr]); + + const handleAddReviewsContext = useCallback(async () => { + if (!selectedPr) return; + await addPullRequestContextToChat({ + label: `PR #${selectedPr.number} reviews`, + section: 'reviews', + content: buildPrReviewsContext(selectedPr, detail?.threads ?? []), + }); + }, [addPullRequestContextToChat, detail?.threads, selectedPr]); + + const handleAddCiPageContext = useCallback(async () => { + if (!selectedPr) return; + await addPullRequestContextToChat({ + label: `PR #${selectedPr.number} CI page`, + section: 'ci', + content: buildPrCiContext(selectedPr, detail?.ci ?? []), + }); + }, [addPullRequestContextToChat, detail?.ci, selectedPr]); + + const handleAddCiItemContext = useCallback(async (item: ReviewPlatformCiItem) => { + if (!selectedPr) return; + const ciLog = ciLogById[item.id] ?? await loadCiLog(item); + await addPullRequestContextToChat({ + label: `PR #${selectedPr.number} CI · ${item.name}`, + section: 'ci', + content: buildPrCiItemContext(selectedPr, item, ciLog), + metadata: { + ciItemId: item.id, + ciItemName: item.name, + ciItemStatus: item.status, + ciItemConclusion: item.conclusion, + ciItemStage: item.stage, + ciLogTruncated: ciLog?.truncated ?? false, + }, + }); + }, [addPullRequestContextToChat, ciLogById, loadCiLog, selectedPr]); + + const refreshAuthSnapshot = useCallback((remoteId: string | null) => { + snapshotCache.clear(); + detailCache.clear(); + detailPageCache.clear(); + void loadSnapshot(remoteId, { force: true, page: currentPageIndex + 1 }); + }, [currentPageIndex, loadSnapshot]); + + const handleOpenAuthModal = useCallback(() => { + setAuthToken(''); + setAuthError(null); + setAuthModalOpen(true); + }, []); + + const handleSaveAuthToken = useCallback(async () => { + if (!selectedRemote || selectedRemote.platform === 'unknown') return; + const token = authToken.trim(); + if (!token) { + setAuthError('Token is required.'); + return; + } + + setAuthSaving(true); + setAuthError(null); + try { + await reviewPlatformAPI.updateAuthToken({ + platform: selectedRemote.platform, + host: selectedRemote.host, + token, + }); + setAuthModalOpen(false); + setAuthToken(''); + refreshAuthSnapshot(selectedRemote.id); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to save token.'; + setAuthError(message); + log.error('Failed to save review platform token', { error: err, host: selectedRemote.host }); + } finally { + setAuthSaving(false); + } + }, [authToken, refreshAuthSnapshot, selectedRemote]); + + const handleClearAuthToken = useCallback(async () => { + if (!selectedRemote || selectedRemote.platform === 'unknown') return; + setAuthSaving(true); + setAuthError(null); + try { + await reviewPlatformAPI.clearAuthToken({ + platform: selectedRemote.platform, + host: selectedRemote.host, + }); + refreshAuthSnapshot(selectedRemote.id); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to clear token.'; + setAuthError(message); + setAuthModalOpen(true); + log.error('Failed to clear review platform token', { error: err, host: selectedRemote.host }); + } finally { + setAuthSaving(false); + } + }, [refreshAuthSnapshot, selectedRemote]); + + const renderAuthGate = useCallback((mode: 'inline' | 'detail' = 'inline') => { + if (!authChallenge || !selectedRemote || selectedRemote.platform === 'unknown') return null; + return ( +
+
+ +
+
+ {authChallengeTitle(authChallenge)} + {authChallenge.message} + {authChallenge.host} · {authChallenge.projectPath} + Required scopes: {authChallengeScopes(authChallenge)} +
+
+ + +
+
+ ); + }, [authChallenge, authSaving, handleOpenAuthModal, loading, refreshAuthSnapshot, selectedRemote]); + + const handleRetryDetail = useCallback(() => { + if ((!repository && !workspacePath) || !selectedRemoteId || !selectedPrId) return; + if (activeTab === 'ci') { + void loadDetailSection(repository, selectedRemoteId, selectedPrId, 'ci', ciPageIndex, CI_PAGE_SIZE, { force: true }); + return; + } + if (activeTab === 'changes') { + void loadDetailSection(repository, selectedRemoteId, selectedPrId, 'files', changePageIndex, CHANGE_PAGE_SIZE, { force: true }); + return; + } + if (activeTab === 'commits') { + void loadDetailSection(repository, selectedRemoteId, selectedPrId, 'commits', commitPageIndex, COMMIT_PAGE_SIZE, { force: true }); + return; + } + if (activeTab === 'reviews') { + void loadDetailSection(repository, selectedRemoteId, selectedPrId, 'reviews', reviewPageIndex, REVIEW_PAGE_SIZE, { force: true }); + return; + } + void loadDetail(repository, selectedRemoteId, selectedPrId, { force: true }); + }, [ + activeTab, + changePageIndex, + commitPageIndex, + loadDetail, + loadDetailSection, + ciPageIndex, + repository, + reviewPageIndex, + selectedPrId, + selectedRemoteId, + workspacePath, + ]); + + const handleRefreshDetail = useCallback(() => { + if ((!repository && !workspacePath) || !selectedRemoteId || !selectedPrId) return; + if (activeTab === 'ci') { + void loadDetailSection(repository, selectedRemoteId, selectedPrId, 'ci', ciPageIndex, CI_PAGE_SIZE, { force: true }); + return; + } + void loadDetail(repository, selectedRemoteId, selectedPrId, { force: true }); + }, [activeTab, ciPageIndex, loadDetail, loadDetailSection, repository, selectedPrId, selectedRemoteId, workspacePath]); + + const remoteStatus = selectedRemote + ? `${providerLabel(selectedRemote)} · ${authLabel(account)}` + : 'No remote detected'; + const displayPr = detail ?? selectedPr; + const checksText = displayPr && displayPr.checks.total > 0 + ? `${displayPr.checks.passed}/${displayPr.checks.total}` + : 'N/A'; + const emptyStateMessage = snapshot.message + || account?.message + || selectedRemote?.message + || (snapshot.remotes.length ? 'No pull requests match the current filter.' : 'No supported remotes were detected.'); + const loadingLabel = loading + ? snapshotCacheState === 'refreshing' + ? 'Refreshing cached pull requests...' + : 'Loading pull requests...' + : snapshotCacheState === 'cached' + ? 'Cached pull requests' + : null; + const parentSessionLabel = parentSession ? getSessionTitle(parentSession, 'Current chat') : 'No chat session linked'; + + return ( +
+ {!detailOnly && ( +
+
+ +
+ {panelTitle} + {headerLabel} +
+
+ +
+
+ setQuery(event.target.value)} + placeholder="Search pull requests" + prefix={} + suffix={query ? setQuery('')}> : undefined} + /> +
+ {(['all', 'open', 'draft', 'merged', 'closed'] as ListStateFilter[]).map(state => ( + + ))} +
+
+ +
+ {loading && ( +
Loading pull requests...
+ )} + {error && ( +
+ + {error} + +
+ )} + {!loading && !error && !authChallenge && !visiblePullRequests.length && ( +
+ + {emptyStateMessage} +
+ )} + {!loading && !error && visiblePullRequests.map(pr => ( + (() => { + return ( + + ); + })() + ))} +
+ {!loading && !error && (totalPages > 1 || pagination.hasNext) && ( +
+ handlePageChange(currentPageIndex - 1)} + > + + + + {pageStart}-{pageEnd} of {totalCount ?? `${pageEnd}+`} + + = totalPages - 1} + onClick={() => handlePageChange(currentPageIndex + 1)} + > + + +
+ )} + + )} + +
+ {!selectedPr && detailOnly && (loading || detailLoading) && ( +
+ + Loading pull request details... +
+ )} + + {!selectedPr && detailOnly && !loading && !detailLoading && authChallenge && ( +
+ {renderAuthGate('detail')} +
+ )} + + {!selectedPr && detailOnly && !loading && !detailLoading && !authChallenge && (detailError || error) && ( +
+ + {detailError || error} +
+ + {selectedRemote && selectedRemote.platform !== 'unknown' && ( + + )} +
+
+ )} + + {!selectedPr && !detailOnly && !loading && ( +
+ + Select a pull request to inspect it. +
+ )} + + {selectedPr && ( + <> +
+
+
+ {getPrIcon(selectedPr)} +

{selectedPr.title}

+
+
+ #{selectedPr.number} + {selectedPr.author} + {formatAbsoluteTime(selectedPr.updatedAt) || formatRelativeTime(selectedPr.updatedAt)} + {selectedPr.sourceBranch} → {selectedPr.targetBranch} +
+
+
+ + + + {detailOnly && selectedRemote && selectedRemote.platform !== 'unknown' && ( + + )} + + +
+
+ +
+
+ Files + {displayPr?.changedFiles ?? selectedPr.changedFiles} +
+
+ Additions + +{displayPr?.additions ?? selectedPr.additions} +
+
+ Deletions + -{displayPr?.deletions ?? selectedPr.deletions} +
+
+ Checks + {checksText} +
+
+ +
+
+ Conversation link + {parentSessionLabel} + + {prFilePaths.length} changed files + {linkedReviewSessions.length > 0 ? ` · ${linkedReviewSessions.length} related review sessions` : ''} + +
+
+ + +
+
+ + setActiveTab(key as DetailTab)} + type="pill" + size="small" + className="review-platform__tabs" + > + +
+
+
+ Overview + +
+ {detailError ? ( +
+ + {detailError} + +
+ ) : detail?.body ? ( + + ) : ( + renderDetailLoading('Loading pull request summary...') + )} +
+
+ State: {stateLabel(displayPr?.state ?? selectedPr.state)} + + {decisionLabel(displayPr?.reviewDecision ?? selectedPr.reviewDecision)} + + + {selectedRemote ? providerLabel(selectedRemote) : 'Unknown provider'} + +
+
+ {checksText} checks + {reviewItemCount} review items + {displayPr?.author ?? selectedPr.author} + {formatAbsoluteTime(displayPr?.updatedAt ?? selectedPr.updatedAt)} +
+
+
+ + +
+
+ CI + + {ciTotal ? `${ciTotal} items · ${checksText}` : checksText} + + +
+ {detailError && ( +
+ + {detailError} + +
+ )} + {detailLoading && renderDetailLoading(pagedCiItems.length ? 'Refreshing CI...' : 'Loading CI...', pagedCiItems.length > 0)} + {pagedCiItems.map(item => { + const tone = ciItemTone(item); + const isCiExpanded = expandedCiItemIds.has(item.id); + const ciLog = ciLogById[item.id]; + const ciLogLoading = ciLogLoadingIds.has(item.id); + const ciLogError = ciLogErrorById[item.id]; + const logAvailable = canLoadCiLog(selectedRemote, item); + const expandable = canExpandCiItem(selectedRemote, item); + return ( +
+
+
+ {item.name} + + {[item.detail, item.stage].filter(Boolean).join(' · ') || (item.webUrl ? 'Provider details available' : 'No extra details provided')} + +
+
+ {ciLog && logAvailable && ( + + {ciLog.log ? (ciLog.truncated ? 'Errors truncated' : 'Errors loaded') : 'No errors'} + + )} + + {ciItemStatusText(item)} + + {expandable && ( + toggleCiExpanded(item)} + disabled={ciLogLoading} + aria-busy={ciLogLoading} + > + {isCiExpanded ? : } + + )} + void handleAddCiItemContext(item)} + disabled={!selectedPr} + > + + + {item.webUrl && ( + void handleOpenCiUrl(item.webUrl)} + > + + + )} +
+
+ {(item.startedAt || item.finishedAt) && ( +
+ {item.startedAt && Started: {formatAbsoluteTime(item.startedAt) || item.startedAt}} + {item.finishedAt && Finished: {formatAbsoluteTime(item.finishedAt) || item.finishedAt}} +
+ )} + {isCiExpanded && ( +
+
+ {item.stage && ( +
+ Stage + {item.stage} +
+ )} + {item.detail && ( +
+ Detail + {item.detail} +
+ )} + {item.webUrl && ( +
+ URL + {item.webUrl} +
+ )} +
+ {ciLogLoading && renderDetailLoading('Loading CI details...')} + {!ciLogLoading && ciLogError && logAvailable && ( +
+ + {ciLogError} + +
+ )} + {!ciLogLoading && !ciLogError && (ciLog?.log || item.log) && ( +
{ciLog?.log || item.log}
+ )} + {!ciLogLoading && !ciLogError && ciLog && !ciLog.log && !item.log && ciLog.message && ( +
+ {ciLog.message} +
+ )} +
+ )} +
+ ); + })} + {!detailLoading && detail && ciItems.length === 0 && ( +
No CI entries were returned by this provider.
+ )} + {renderDetailPagination('CI', ciPage, ciTotal, setCiPageIndex)} +
+
+ + +
+ {detailError && ( +
+ + {detailError} + +
+ )} + {detailLoading && renderDetailLoading(pagedChangedFiles.length ? 'Refreshing files...' : 'Loading files...', pagedChangedFiles.length > 0)} + {pagedChangedFiles.map(file => { + const key = fileKey(file); + const isExpanded = expandedFileKeys.has(key); + return ( +
+
+ + +
+ {isExpanded && ( + file.patch ? ( +
+                                {file.patch.split('\n').map((line, index) => (
+                                  
+                                    {line || ' '}
+                                  
+                                ))}
+                              
+ ) : ( +
No inline diff is available for this file.
+ ) + )} +
+ ); + })} + {!detailLoading && detail && detail.files.length === 0 && ( +
No changed files were returned by this provider.
+ )} + {renderDetailPagination('Files', changePage, changedFiles.length, setChangePageIndex)} +
+
+ + +
+
+ Commits + +
+ {detailError && ( +
+ + {detailError} + +
+ )} + {detailLoading && renderDetailLoading(pagedCommits.length ? 'Refreshing commits...' : 'Loading commits...', pagedCommits.length > 0)} + {pagedCommits.map(commit => ( +
+ + + {commit.title} + {commit.author} · {formatRelativeTime(commit.committedAt)} + + {commit.shortHash} +
+ ))} + {!detailLoading && detail && commits.length === 0 && ( +
No commits were returned by this provider.
+ )} + {renderDetailPagination('Commits', commitPage, commits.length, setCommitPageIndex)} +
+
+ + +
+
+ Reviews + {reviewItemCount} items + +
+ {detailError && ( +
+ + {detailError} + +
+ )} + {detailLoading && renderDetailLoading(reviewThreads.length ? 'Refreshing reviews...' : 'Loading reviews...', reviewThreads.length > 0)} + {pagedReviewThreads.map(thread => { + const parent = thread.replyToProviderCommentId + ? reviewThreadByCommentId.get(thread.replyToProviderCommentId) + : null; + return ( +
+
+
+ + {thread.kind === 'review' ? 'Review' : 'Comment'} + + + {thread.resolved ? 'Resolved' : 'Open'} + +
+ {formatRelativeTime(thread.updatedAt) || formatAbsoluteTime(thread.updatedAt)} +
+
+ {thread.author} +
+ {parent && ( +
+
+ Reply to + @{parent.author} +
+
+ +
+
+ )} +
+ +
+ {thread.filePath && ( + + {thread.filePath}{thread.line ? `:${thread.line}` : ''} + + )} +
+ ); + })} + {!detailLoading && detail && reviewThreads.length === 0 && ( +
No reviews or comments were returned by this provider.
+ )} + {renderDetailPagination('Reviews', reviewPage, reviewThreads.length, setReviewPageIndex)} +
+
+
+ + )} +
+
+ { + if (!authSaving) { + setAuthModalOpen(false); + setAuthError(null); + } + }} + title={`${selectedRemote ? providerLabel(selectedRemote) : 'Provider'} token`} + size="small" + contentInset + > +
{ + event.preventDefault(); + void handleSaveAuthToken(); + }} + > +
+ {selectedRemote?.host ?? 'No remote'} + {selectedRemote?.projectPath ?? ''} +
+ { + setAuthToken(event.target.value); + if (authError) setAuthError(null); + }} + /> +
+ + +
+
+
+
+ ); +}; + +export default ReviewPlatformPanel; diff --git a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx index d8e1e23fc0..8e508dbfae 100644 --- a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx +++ b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx @@ -637,6 +637,7 @@ export interface MarkdownProps { onOpenVisualization?: (visualization: any) => void; onFileViewRequest?: (filePath: string, fileName: string, lineRange?: LineRange) => void; onTabOpen?: (tabInfo: any) => void; + onHttpLinkClick?: (url: string, event: React.MouseEvent) => boolean | void; onReproductionProceed?: () => void; } @@ -649,6 +650,7 @@ export const Markdown = React.memo(({ onOpenVisualization, onFileViewRequest, onTabOpen, + onHttpLinkClick, onReproductionProceed }) => { const { isLight } = useTheme(); @@ -1138,6 +1140,9 @@ export const Markdown = React.memo(({ onClick={async (e) => { e.preventDefault(); e.stopPropagation(); + if (onHttpLinkClick?.(hrefValue, e)) { + return; + } try { await systemAPI.openExternal(hrefValue); } catch (error) { @@ -1231,6 +1236,7 @@ export const Markdown = React.memo(({ handleWebLinkContextMenu, handleOpenVisualization, handleTabOpen, + onHttpLinkClick, parseLineRange, syntaxTheme, isLight, diff --git a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx index 723b9c9c80..941083abad 100644 --- a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx +++ b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx @@ -33,7 +33,7 @@ export const FlowTextBlock = React.memo(({ className = '', replayStreamingOnMount = true }) => { - const { onFileViewRequest, onTabOpen, onOpenVisualization } = useFlowChatContext(); + const { onFileViewRequest, onTabOpen, onHttpLinkClick, onOpenVisualization } = useFlowChatContext(); const { i18n } = useTranslation(); // Normalize content to a string. @@ -111,6 +111,7 @@ export const FlowTextBlock = React.memo(({ isStreaming={isStreaming} onFileViewRequest={onFileViewRequest} onTabOpen={onTabOpen} + onHttpLinkClick={onHttpLinkClick} onOpenVisualization={(visualization) => { onOpenVisualization?.(visualization?.type, visualization?.data); }} diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.tsx b/src/web-ui/src/flow_chat/components/RichTextInput.tsx index 4e30c0d0ea..90dbe12ed0 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.tsx +++ b/src/web-ui/src/flow_chat/components/RichTextInput.tsx @@ -42,6 +42,7 @@ function getContextDisplayName(context: ContextItem): string { case 'file': return context.fileName; case 'directory': return context.directoryName; case 'code-snippet': return `${context.fileName}:${context.startLine}-${context.endLine}`; + case 'pull-request': return context.label; case 'image': return context.imageName; case 'terminal-command': return context.command; case 'git-ref': return context.refValue; @@ -61,6 +62,7 @@ function getContextTagFormat(context: ContextItem): string { case 'file': return `#file:${context.fileName}`; case 'directory': return `#dir:${context.directoryName}`; case 'code-snippet': return `#code:${context.fileName}:${context.startLine}-${context.endLine}`; + case 'pull-request': return `#pr:${context.label.replace(/\s+/g, '_')}`; case 'image': return `#img:${context.imageName}`; case 'terminal-command': return `#cmd:${context.command}`; case 'git-ref': return `#git:${context.refValue}`; @@ -83,6 +85,14 @@ function getContextFullPath(context: ContextItem): string { return context.directoryPath + (context.recursive ? ' (recursive)' : ''); case 'code-snippet': return `${context.filePath} (lines ${context.startLine}-${context.endLine})`; + case 'pull-request': + return [ + context.repository, + context.remoteId ? `remote:${context.remoteId}` : null, + context.pullRequestNumber ? `PR #${context.pullRequestNumber}` : null, + context.section, + context.sourceUrl, + ].filter(Boolean).join(' · ') || context.label; case 'image': return context.imagePath; case 'terminal-command': diff --git a/src/web-ui/src/flow_chat/components/UserMessage.tsx b/src/web-ui/src/flow_chat/components/UserMessage.tsx index 42781fb385..fa67847cc8 100644 --- a/src/web-ui/src/flow_chat/components/UserMessage.tsx +++ b/src/web-ui/src/flow_chat/components/UserMessage.tsx @@ -4,7 +4,7 @@ */ import React, { useMemo, useState, useRef, useEffect } from 'react'; -import { File, Folder, Code, Image, Terminal, GitBranch, Link, FileText } from 'lucide-react'; +import { File, Folder, Code, Image, Terminal, GitBranch, Link, FileText, GitPullRequest } from 'lucide-react'; import { Tag } from '@/component-library'; import { shouldIgnoreCardToggleClick } from '@/shared/utils/textSelection'; import { SnapshotRollbackButton } from './SnapshotRollbackButton'; @@ -38,7 +38,8 @@ const TAG_CONFIG = { cmd: { icon: Terminal, color: '#94a3b8', label: 'Command' }, chart: { icon: FileText, color: '#22d3ee', label: 'Chart' }, git: { icon: GitBranch, color: '#f87171', label: 'Git' }, - link: { icon: Link, color: '#60a5fa', label: 'Link' } + link: { icon: Link, color: '#60a5fa', label: 'Link' }, + pr: { icon: GitPullRequest, color: '#a78bfa', label: 'Pull Request' } }; /** @@ -59,7 +60,7 @@ function parseMessageContent(content: string): ContentPart[] { const parts: ContentPart[] = []; // Match #type:value until whitespace or line break. - const tagPattern = /#(file|dir|code|img|cmd|chart|git|link):([^\s\n]+)/g; + const tagPattern = /#(file|dir|code|img|cmd|chart|git|link|pr):([^\s\n]+)/g; let lastIndex = 0; let match; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx index c52a8af41f..f714baa2b8 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx @@ -4,6 +4,7 @@ */ import { createContext, useContext } from 'react'; +import type React from 'react'; import type { FlowChatConfig, Session } from '../../types/flow-chat'; import type { LineRange } from '@/component-library'; @@ -11,6 +12,7 @@ export interface FlowChatContextValue { // File and panel actions onFileViewRequest?: (filePath: string, fileName: string, lineRange?: LineRange) => void; onTabOpen?: (tabInfo: any, sessionId?: string, panelType?: string) => void; + onHttpLinkClick?: (url: string, event: React.MouseEvent) => boolean | void; onOpenVisualization?: (type: string, data: any) => void; onSwitchToChatPanel?: () => void; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss index fc5728c7c0..2b397b041c 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss @@ -133,6 +133,7 @@ } &__search-btn, + &__review-platform-btn, &__search-close { flex: 0 0 auto; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx index 503c26959e..fb49015f51 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx @@ -5,10 +5,12 @@ */ import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; -import { Bot, ChevronDown, ChevronUp, List, Search, X } from 'lucide-react'; +import { Bot, ChevronDown, ChevronUp, GitPullRequest, List, Search, X } from 'lucide-react'; import { Tooltip, IconButton, Input } from '@/component-library'; import { useTranslation } from 'react-i18next'; import { SessionFilesBadge } from './SessionFilesBadge'; +import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; +import { createReviewPlatformTab } from '@/shared/utils/tabUtils'; import './FlowChatHeader.scss'; export interface FlowChatHeaderTurnSummary { @@ -89,6 +91,7 @@ export const FlowChatHeader: React.FC = ({ onOpenBackgroundSubagent, }) => { const { t } = useTranslation('flow-chat'); + const { currentWorkspace } = useWorkspaceContext(); const [isTurnListOpen, setIsTurnListOpen] = useState(false); const [isSubagentListOpen, setIsSubagentListOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false); @@ -248,6 +251,10 @@ export const FlowChatHeader: React.FC = ({ setIsSubagentListOpen(prev => !prev); }; + const handleOpenPullRequests = useCallback(() => { + createReviewPlatformTab(currentWorkspace?.rootPath); + }, [currentWorkspace?.rootPath]); + const handleTurnSelect = (turnId: string) => { if (!onJumpToTurn) return; onJumpToTurn(turnId); @@ -374,6 +381,17 @@ export const FlowChatHeader: React.FC = ({ )}
+ + + {isSearchOpen ? (
= ( workspacePath, onFileViewRequest, }); + const handleHttpLinkClick = useCallback((url: string, _event: React.MouseEvent) => { + const pullRequestTarget = parsePullRequestUrl(url); + if (!pullRequestTarget) { + return false; + } + + createReviewPlatformPullRequestDetailTab({ + workspacePath: activeSession?.workspacePath || workspacePath, + pullRequestId: pullRequestTarget.pullRequestId, + pullRequestUrl: pullRequestTarget.webUrl, + title: `PR #${pullRequestTarget.pullRequestId}`, + }); + return true; + }, [activeSession?.workspacePath, workspacePath]); const { searchQuery, onSearchChange, @@ -214,6 +230,7 @@ export const ModernFlowChatContainer: React.FC = ( const contextValue: FlowChatContextValue = useMemo(() => ({ onFileViewRequest: handleFileViewRequest, onTabOpen, + onHttpLinkClick: handleHttpLinkClick, onOpenVisualization, onSwitchToChatPanel, onToolConfirm: handleToolConfirm, @@ -241,6 +258,7 @@ export const ModernFlowChatContainer: React.FC = ( }), [ handleFileViewRequest, onTabOpen, + handleHttpLinkClick, onOpenVisualization, onSwitchToChatPanel, handleToolConfirm, 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 b4b34e9aa0..8e28846119 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 @@ -23,6 +23,7 @@ import { resolveSlashCommandReviewTarget, } from './targetResolver'; import { + formatPullRequestLaunchPrompt, formatSessionFilesLaunchPrompt, formatSlashCommandLaunchPrompt, } from './launchPrompt'; @@ -182,6 +183,47 @@ export async function buildDeepReviewPreviewFromSessionFiles( }); } +export async function buildDeepReviewLaunchFromPullRequestFiles( + filePaths: string[], + extraContext?: string, + diffContext?: string, + workspacePath?: string, +): Promise { + const target = classifyReviewTargetFromFiles(filePaths, 'pull_request'); + const changeStats = buildUnknownChangeStats(target); + const team = await prepareDefaultReviewTeamForLaunch(workspacePath, { + reviewTargetFilePaths: filePaths, + target, + }); + const manifest = await buildReviewTeamManifestWithRuntimeSignals(team, { + workspacePath, + target, + changeStats, + }); + const prompt = formatPullRequestLaunchPrompt({ + filePaths, + extraContext, + diffContext, + reviewTeamPromptBlock: buildReviewTeamPromptBlock(team, manifest), + }); + + return { prompt, runManifest: manifest }; +} + +export async function buildDeepReviewPreviewFromPullRequestFiles( + filePaths: string[], + workspacePath?: string, +): Promise { + const team = await loadDefaultReviewTeam(workspacePath); + const target = classifyReviewTargetFromFiles(filePaths, 'pull_request'); + const changeStats = buildUnknownChangeStats(target); + return buildReviewTeamManifestWithRuntimeSignals(team, { + workspacePath, + target, + changeStats, + }); +} + export async function buildDeepReviewPromptFromSessionFiles( filePaths: string[], extraContext?: string, 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 index e61967e3a3..9b643c768b 100644 --- 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 @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { formatFileList, + formatPullRequestLaunchPrompt, formatSessionFilesLaunchPrompt, formatSlashCommandLaunchPrompt, } from './launchPrompt'; @@ -23,6 +24,20 @@ describe('Deep Review launch prompt formatting', () => { expect(prompt).toContain('Review team manifest.'); }); + it('builds a pull-request prompt that uses provider diff as source of truth', () => { + const prompt = formatPullRequestLaunchPrompt({ + filePaths: ['src/a.ts'], + extraContext: 'PR #42', + diffContext: 'File: src/a.ts\nPatch:\n+changed', + reviewTeamPromptBlock: 'Review team manifest.', + }); + + expect(prompt).toContain('Review scope: ONLY inspect the following files changed by this pull request.'); + expect(prompt).toContain('Pull request context:\nPR #42'); + expect(prompt).toContain('Pull request provider diff:\nFile: src/a.ts'); + expect(prompt).toContain('Treat the provider diff as the source of truth'); + }); + it('builds a slash-command prompt with original command and fallback focus', () => { const prompt = formatSlashCommandLaunchPrompt({ commandText: '/DeepReview', 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 index cdd8e8ec18..258fd77e7b 100644 --- a/src/web-ui/src/flow_chat/deep-review/launch/launchPrompt.ts +++ b/src/web-ui/src/flow_chat/deep-review/launch/launchPrompt.ts @@ -4,6 +4,13 @@ interface SessionFilesLaunchPromptParams { reviewTeamPromptBlock: string; } +interface PullRequestLaunchPromptParams { + filePaths: string[]; + extraContext?: string; + diffContext?: string; + reviewTeamPromptBlock: string; +} + interface SlashCommandLaunchPromptParams { commandText: string; extraContext: string; @@ -33,6 +40,30 @@ export function formatSessionFilesLaunchPrompt({ ].join('\n\n'); } +export function formatPullRequestLaunchPrompt({ + filePaths, + extraContext, + diffContext, + reviewTeamPromptBlock, +}: PullRequestLaunchPromptParams): string { + const contextBlock = extraContext?.trim() + ? `Pull request context:\n${extraContext.trim()}` + : 'Pull request context:\nNone.'; + const diffBlock = diffContext?.trim() + ? `Pull request provider diff:\n${diffContext.trim()}` + : 'Pull request provider diff:\nNo provider diff was included. Confirm findings against the listed files and PR metadata.'; + + return [ + 'Run a deep code review using the parallel Code Review Team.', + 'Review scope: ONLY inspect the following files changed by this pull request.', + formatFileList(filePaths), + contextBlock, + diffBlock, + reviewTeamPromptBlock, + 'Treat the provider diff as the source of truth for what changed in the PR. Read repository files only to understand surrounding context or verify findings.', + ].join('\n\n'); +} + export function formatSlashCommandLaunchPrompt({ commandText, extraContext, diff --git a/src/web-ui/src/flow_chat/hooks/useMessageSender.ts b/src/web-ui/src/flow_chat/hooks/useMessageSender.ts index a8c0d96719..e6ad8f377c 100644 --- a/src/web-ui/src/flow_chat/hooks/useMessageSender.ts +++ b/src/web-ui/src/flow_chat/hooks/useMessageSender.ts @@ -14,6 +14,7 @@ import { notificationService } from '@/shared/notification-system'; import type { ContextItem, ImageContext } from '@/shared/types/context'; import type { AIModelConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; import { createLogger } from '@/shared/utils/logger'; +import { formatContextForPrompt } from '@/shared/utils/contextPrompt'; const log = createLogger('FlowChat'); @@ -168,46 +169,7 @@ export function useMessageSender(props: UseMessageSenderProps): UseMessageSender const displayMessage = options?.displayMessage?.trim() || trimmedMessage; if (contexts.length > 0) { - const fullContextSection = contexts.map(ctx => { - switch (ctx.type) { - case 'file': - return `[File: ${ctx.relativePath || ctx.filePath}]`; - case 'directory': - return `[Directory: ${ctx.directoryPath}]`; - case 'code-snippet': - return `[Code Snippet: ${ctx.filePath}:${ctx.startLine}-${ctx.endLine}]`; - case 'image': - // Images are sent out-of-band via `imageContexts` so the backend can attach them - // for multimodal models or convert to text placeholders for text-only models. Avoid embedding - // "Image ID" references into the user prompt, which can cause redundant tool calls. - return ''; - case 'terminal-command': - return `[Command: ${ctx.command}]`; - case 'mermaid-node': - return `[Mermaid Node: ${ctx.nodeText}]`; - case 'mermaid-diagram': - return `[Mermaid Diagram${ctx.diagramTitle ? ': ' + ctx.diagramTitle : ''}]\n\`\`\`mermaid\n${ctx.diagramCode}\n\`\`\``; - case 'git-ref': - return `[Git Ref: ${ctx.refValue}]`; - case 'url': - return `[URL: ${ctx.url}]`; - case 'web-element': { - const attrStr = Object.entries(ctx.attributes) - .map(([k, v]) => `${k}="${v}"`) - .join(' '); - const lines = [ - `[Web Element: <${ctx.tagName}${attrStr ? ' ' + attrStr : ''}>]`, - `CSS Path: ${ctx.path}`, - ]; - if (ctx.sourceUrl) lines.push(`Source URL: ${ctx.sourceUrl}`); - if (ctx.textContent) lines.push(`Text Content: ${ctx.textContent}`); - if (ctx.outerHTML) lines.push(`Outer HTML:\n\`\`\`html\n${ctx.outerHTML}\n\`\`\``); - return lines.join('\n'); - } - default: - return ''; - } - }).filter(Boolean).join('\n'); + const fullContextSection = contexts.map(formatContextForPrompt).filter(Boolean).join('\n'); fullMessage = `${fullContextSection}\n\n${aiTrimmedMessage}`; } diff --git a/src/web-ui/src/infrastructure/api/index.ts b/src/web-ui/src/infrastructure/api/index.ts index 95329c9a14..721b874bc4 100644 --- a/src/web-ui/src/infrastructure/api/index.ts +++ b/src/web-ui/src/infrastructure/api/index.ts @@ -31,10 +31,12 @@ import { sessionAPI } from './service-api/SessionAPI'; import { i18nAPI } from './service-api/I18nAPI'; import { btwAPI } from './service-api/BtwAPI'; import { editorAiAPI } from './service-api/EditorAiAPI'; +import { reviewPlatformAPI } from './service-api/ReviewPlatformAPI'; import { insightsApi } from './insightsApi'; // Export API modules -export { workspaceAPI, configAPI, aiApi, toolAPI, agentAPI, systemAPI, projectAPI, diffAPI, snapshotAPI, globalAPI, contextAPI, cronAPI, gitAPI, gitAgentAPI, gitRepoHistoryAPI, startchatAgentAPI, sessionAPI, i18nAPI, btwAPI, editorAiAPI, insightsApi }; +export { workspaceAPI, configAPI, aiApi, toolAPI, agentAPI, systemAPI, projectAPI, diffAPI, snapshotAPI, globalAPI, contextAPI, cronAPI, gitAPI, gitAgentAPI, gitRepoHistoryAPI, startchatAgentAPI, sessionAPI, i18nAPI, btwAPI, editorAiAPI, reviewPlatformAPI, insightsApi }; +export * from './service-api/ReviewPlatformAPI'; // Export types export type { GitRepoHistory }; @@ -62,6 +64,7 @@ export const bitfunAPI = { i18n: i18nAPI, btw: btwAPI, editorAi: editorAiAPI, + reviewPlatform: reviewPlatformAPI, insights: insightsApi, }; diff --git a/src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.ts new file mode 100644 index 0000000000..146fd2d50e --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.ts @@ -0,0 +1,341 @@ +import { api } from './ApiClient'; +import { createTauriCommandError } from '../errors/TauriCommandError'; +import { createLogger } from '@/shared/utils/logger'; + +const log = createLogger('ReviewPlatformAPI'); + +export type ReviewPlatformKind = 'github' | 'gitlab' | 'gitcode' | 'unknown'; +export type ReviewAuthState = 'not_connected' | 'not_required' | 'connected' | 'expired' | 'error' | 'unsupported'; +export type ReviewAuthSource = 'env' | 'stored' | 'none' | 'unsupported'; +export type ReviewAuthChallengeState = 'missing' | 'invalid' | 'insufficient_scope'; +export type ReviewItemState = 'open' | 'merged' | 'closed' | 'draft'; +export type ReviewDecision = 'approved' | 'changes_requested' | 'commented' | 'pending'; +export type ReviewFileStatus = 'added' | 'modified' | 'deleted' | 'renamed'; +export type ReviewPlatformDetailSection = 'overview' | 'ci' | 'files' | 'commits' | 'reviews'; + +export interface ReviewPlatformAccount { + id: string; + platform: ReviewPlatformKind; + label: string; + username?: string | null; + host: string; + authState: ReviewAuthState; + authSource: ReviewAuthSource; + scopes: string[]; + message?: string | null; +} + +export interface ReviewPlatformRepositoryRef { + providerId: string; + platform: ReviewPlatformKind; + host: string; + owner: string; + name: string; + projectPath: string; + defaultBranch: string; + workspacePath?: string | null; + webUrl: string; +} + +export interface ReviewPlatformAuthChallenge { + platform: ReviewPlatformKind; + host: string; + remoteId: string; + projectPath: string; + state: ReviewAuthChallengeState; + message: string; + requiredScopes: string[]; +} + +export interface ReviewPlatformRemote { + id: string; + name: string; + url: string; + platform: ReviewPlatformKind; + host: string; + owner: string; + repositoryName: string; + projectPath: string; + webUrl: string; + supported: boolean; + authState: ReviewAuthState; + authSource: ReviewAuthSource; + message?: string | null; +} + +export interface ReviewChecks { + total: number; + passed: number; + failed: number; + pending: number; +} + +export interface ReviewPlatformCiItem { + id: string; + name: string; + status: string; + conclusion?: string | null; + detail?: string | null; + stage?: string | null; + webUrl?: string | null; + log?: string | null; + logTruncated: boolean; + startedAt?: string | null; + finishedAt?: string | null; +} + +export interface ReviewPlatformPullRequest { + id: string; + number: number; + title: string; + state: ReviewItemState; + author: string; + sourceBranch: string; + targetBranch: string; + updatedAt: string; + webUrl: string; + additions: number; + deletions: number; + changedFiles: number; + comments: number; + reviewDecision: ReviewDecision; + checks: ReviewChecks; +} + +export interface ReviewPlatformFile { + path: string; + oldPath?: string | null; + status: ReviewFileStatus; + additions: number; + deletions: number; + patch?: string | null; +} + +export interface ReviewPlatformCommit { + hash: string; + shortHash: string; + title: string; + author: string; + committedAt: string; +} + +export interface ReviewPlatformThread { + id: string; + providerThreadId?: string | null; + providerCommentId?: string | null; + kind: 'review' | 'comment'; + replyToProviderCommentId?: string | null; + filePath?: string | null; + line?: number | null; + resolved: boolean; + author: string; + body: string; + updatedAt: string; +} + +export interface ReviewPlatformPullRequestDetail extends ReviewPlatformPullRequest { + body: string; + ci: ReviewPlatformCiItem[]; + files: ReviewPlatformFile[]; + commits: ReviewPlatformCommit[]; + threads: ReviewPlatformThread[]; +} + +export interface ReviewPlatformPullRequestDetailPage extends ReviewPlatformPullRequestDetail { + section: ReviewPlatformDetailSection; + pagination: ReviewPlatformPagination; +} + +export interface ReviewPlatformCiLog { + ciItemId: string; + log?: string | null; + truncated: boolean; + message?: string | null; +} + +export interface ReviewPlatformCapabilities { + canCreateReview: boolean; + canCreatePullRequest: boolean; + canReplyToThread: boolean; + canResolveThread: boolean; + canApprove: boolean; + canRevokeApproval: boolean; + canRequestChanges: boolean; + canMerge: boolean; + supportsDraftReview: boolean; +} + +export interface ReviewPlatformPagination { + page: number; + perPage: number; + total?: number | null; + hasNext: boolean; +} + +export interface ReviewPlatformWorkspaceSnapshot { + remotes: ReviewPlatformRemote[]; + selectedRemoteId?: string | null; + accounts: ReviewPlatformAccount[]; + repository: ReviewPlatformRepositoryRef | null; + pullRequests: ReviewPlatformPullRequest[]; + pagination: ReviewPlatformPagination; + capabilities: ReviewPlatformCapabilities; + message?: string | null; + authChallenge?: ReviewPlatformAuthChallenge | null; +} + +export interface ReviewPlatformWorkspaceSnapshotRequest { + repositoryPath: string; + remoteId?: string | null; + page?: number; + perPage?: number; +} + +export interface ReviewPlatformPullRequestDetailRequest { + repositoryPath: string; + remoteId: string; + pullRequestId: string; +} + +export interface ReviewPlatformPullRequestDetailPageRequest extends ReviewPlatformPullRequestDetailRequest { + section: ReviewPlatformDetailSection; + page?: number; + perPage?: number; +} + +export interface ReviewPlatformPullRequestCiLogRequest extends ReviewPlatformPullRequestDetailRequest { + ciItemId: string; + ciItemName: string; +} + +export interface ReviewPlatformUpdateAuthTokenRequest { + platform: ReviewPlatformKind; + host: string; + token: string; +} + +export interface ReviewPlatformClearAuthTokenRequest { + platform: ReviewPlatformKind; + host: string; +} + +export class ReviewPlatformAPI { + async getWorkspaceSnapshot( + repositoryPath: string, + remoteId?: string | null, + page?: number, + perPage?: number, + ): Promise { + try { + return await api.invoke('review_platform_get_workspace_snapshot', { + request: { repositoryPath, remoteId, page, perPage }, + }); + } catch (error) { + log.error('Failed to load review platform snapshot', { repositoryPath, remoteId, page, perPage, error }); + throw createTauriCommandError('review_platform_get_workspace_snapshot', error, { + repositoryPath, + remoteId, + page, + perPage, + }); + } + } + + async getPullRequestDetail( + repositoryPath: string, + remoteId: string, + pullRequestId: string, + ): Promise { + try { + return await api.invoke('review_platform_get_pull_request_detail', { + request: { repositoryPath, remoteId, pullRequestId }, + }); + } catch (error) { + log.error('Failed to load review platform pull request detail', { + repositoryPath, + remoteId, + pullRequestId, + error, + }); + throw createTauriCommandError('review_platform_get_pull_request_detail', error, { + repositoryPath, + remoteId, + pullRequestId, + }); + } + } + + async getPullRequestDetailPage( + request: ReviewPlatformPullRequestDetailPageRequest, + ): Promise { + try { + return await api.invoke('review_platform_get_pull_request_detail_page', { + request, + }); + } catch (error) { + log.error('Failed to load review platform pull request detail page', { + repositoryPath: request.repositoryPath, + remoteId: request.remoteId, + pullRequestId: request.pullRequestId, + section: request.section, + page: request.page, + perPage: request.perPage, + error, + }); + throw createTauriCommandError('review_platform_get_pull_request_detail_page', error, request); + } + } + + async getPullRequestCiLog( + request: ReviewPlatformPullRequestCiLogRequest, + ): Promise { + try { + return await api.invoke('review_platform_get_pull_request_ci_log', { + request, + }); + } catch (error) { + log.error('Failed to load review platform CI log', { + repositoryPath: request.repositoryPath, + remoteId: request.remoteId, + pullRequestId: request.pullRequestId, + ciItemId: request.ciItemId, + error, + }); + throw createTauriCommandError('review_platform_get_pull_request_ci_log', error, request); + } + } + + async updateAuthToken(request: ReviewPlatformUpdateAuthTokenRequest): Promise { + try { + await api.invoke('review_platform_update_auth_token', { request }); + } catch (error) { + log.error('Failed to update review platform auth token', { + platform: request.platform, + host: request.host, + error, + }); + throw createTauriCommandError('review_platform_update_auth_token', error, { + platform: request.platform, + host: request.host, + }); + } + } + + async clearAuthToken(request: ReviewPlatformClearAuthTokenRequest): Promise { + try { + await api.invoke('review_platform_clear_auth_token', { request }); + } catch (error) { + log.error('Failed to clear review platform auth token', { + platform: request.platform, + host: request.host, + error, + }); + throw createTauriCommandError('review_platform_clear_auth_token', error, { + platform: request.platform, + host: request.host, + }); + } + } +} + +export const reviewPlatformAPI = new ReviewPlatformAPI(); diff --git a/src/web-ui/src/shared/services/DragManager.ts b/src/web-ui/src/shared/services/DragManager.ts index 9c0ce5fa78..a113db0bc5 100644 --- a/src/web-ui/src/shared/services/DragManager.ts +++ b/src/web-ui/src/shared/services/DragManager.ts @@ -240,6 +240,8 @@ export class DragManager { return context.directoryPath; case 'code-snippet': return context.selectedText; + case 'pull-request': + return context.content; case 'mermaid-node': return context.nodeText; case 'image': diff --git a/src/web-ui/src/shared/services/reviewTargetClassifier.ts b/src/web-ui/src/shared/services/reviewTargetClassifier.ts index cf7a08c2d4..21f1fa4261 100644 --- a/src/web-ui/src/shared/services/reviewTargetClassifier.ts +++ b/src/web-ui/src/shared/services/reviewTargetClassifier.ts @@ -1,5 +1,6 @@ export type ReviewTargetSource = | 'session_files' + | 'pull_request' | 'slash_command_explicit_files' | 'slash_command_git_ref' | 'workspace_diff' diff --git a/src/web-ui/src/shared/stores/contextStore.ts b/src/web-ui/src/shared/stores/contextStore.ts index 05925dcb80..a4ca941417 100644 --- a/src/web-ui/src/shared/stores/contextStore.ts +++ b/src/web-ui/src/shared/stores/contextStore.ts @@ -157,7 +157,7 @@ export const useContextStore = create()( }, partialize: (state: any) => ({ - contexts: state.contexts.filter((ctx: any) => ctx.type !== 'image') + contexts: state.contexts.filter((ctx: any) => ctx.type !== 'image' && ctx.type !== 'pull-request') }) } as any ), diff --git a/src/web-ui/src/shared/types/context.ts b/src/web-ui/src/shared/types/context.ts index e9efdf9d0f..e33bba9c17 100644 --- a/src/web-ui/src/shared/types/context.ts +++ b/src/web-ui/src/shared/types/context.ts @@ -19,6 +19,7 @@ export type ContextItem = | FileContext | DirectoryContext | CodeSnippetContext + | PullRequestContext | MermaidNodeContext | MermaidDiagramContext | ImageContext @@ -57,6 +58,18 @@ export interface CodeSnippetContext extends BaseContext { afterContext?: string; } +export interface PullRequestContext extends BaseContext { + type: 'pull-request'; + label: string; + section: 'overview' | 'ci' | 'file-diff' | 'commits' | 'reviews' | 'summary'; + content: string; + sourceUrl?: string; + remoteId?: string; + repository?: string; + pullRequestNumber?: number; + pullRequestTitle?: string; +} + export interface MermaidNodeContext extends BaseContext { type: 'mermaid-node'; nodeId: string; @@ -167,6 +180,10 @@ export function isCodeSnippetContext(context: ContextItem): context is CodeSnipp return context.type === 'code-snippet'; } +export function isPullRequestContext(context: ContextItem): context is PullRequestContext { + return context.type === 'pull-request'; +} + export function isMermaidNodeContext(context: ContextItem): context is MermaidNodeContext { return context.type === 'mermaid-node'; } diff --git a/src/web-ui/src/shared/utils/contextPrompt.test.ts b/src/web-ui/src/shared/utils/contextPrompt.test.ts new file mode 100644 index 0000000000..4d80781fa9 --- /dev/null +++ b/src/web-ui/src/shared/utils/contextPrompt.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import type { PullRequestContext } from '@/shared/types/context'; +import { formatContextForPrompt } from './contextPrompt'; + +describe('formatContextForPrompt', () => { + it('includes remote id for pull request contexts', () => { + const context: PullRequestContext = { + id: 'pr-1', + type: 'pull-request', + label: 'PR #42 overview', + section: 'overview', + content: 'Review this change.', + remoteId: 'origin-github', + repository: 'owner/repo', + pullRequestNumber: 42, + pullRequestTitle: 'Fix bug', + sourceUrl: 'https://example.com/owner/repo/pull/42', + timestamp: 123, + }; + + const rendered = formatContextForPrompt(context); + + expect(rendered).toContain('Remote ID: origin-github'); + expect(rendered).toContain('Repository: owner/repo'); + expect(rendered).toContain('Pull Request: #42 Fix bug'); + expect(rendered).toContain('URL: https://example.com/owner/repo/pull/42'); + }); + + it('formats pull request CI contexts', () => { + const context: PullRequestContext = { + id: 'pr-ci-1', + type: 'pull-request', + label: 'PR #42 CI', + section: 'ci', + content: 'Checks: 2/3 passed, 1 failed, 0 pending', + remoteId: 'origin-github', + repository: 'owner/repo', + pullRequestNumber: 42, + pullRequestTitle: 'Fix bug', + timestamp: 123, + }; + + const rendered = formatContextForPrompt(context); + + expect(rendered).toContain('[Pull Request Context: PR #42 CI]'); + expect(rendered).toContain('Section: ci'); + expect(rendered).toContain('Checks: 2/3 passed, 1 failed, 0 pending'); + }); +}); diff --git a/src/web-ui/src/shared/utils/contextPrompt.ts b/src/web-ui/src/shared/utils/contextPrompt.ts new file mode 100644 index 0000000000..02611fba3d --- /dev/null +++ b/src/web-ui/src/shared/utils/contextPrompt.ts @@ -0,0 +1,52 @@ +import type { ContextItem } from '@/shared/types/context'; + +export function formatContextForPrompt(context: ContextItem): string { + switch (context.type) { + case 'file': + return `[File: ${context.relativePath || context.filePath}]`; + case 'directory': + return `[Directory: ${context.directoryPath}]`; + case 'code-snippet': + return `[Code Snippet: ${context.filePath}:${context.startLine}-${context.endLine}]`; + case 'pull-request': + return [ + `[Pull Request Context: ${context.label}]`, + context.repository ? `Repository: ${context.repository}` : '', + context.remoteId ? `Remote ID: ${context.remoteId}` : '', + context.pullRequestNumber ? `Pull Request: #${context.pullRequestNumber}${context.pullRequestTitle ? ` ${context.pullRequestTitle}` : ''}` : '', + context.sourceUrl ? `URL: ${context.sourceUrl}` : '', + `Section: ${context.section}`, + '', + context.content, + ].filter(line => line !== '').join('\n'); + case 'image': + return ''; + case 'terminal-command': + return `[Command: ${context.command}]`; + case 'mermaid-node': + return `[Mermaid Node: ${context.nodeText}]`; + case 'mermaid-diagram': + return `[Mermaid Diagram${context.diagramTitle ? ': ' + context.diagramTitle : ''}]\n\`\`\`mermaid\n${context.diagramCode}\n\`\`\``; + case 'git-ref': + return `[Git Ref: ${context.refValue}]`; + case 'url': + return `[URL: ${context.url}]`; + case 'web-element': { + const attrStr = Object.entries(context.attributes) + .map(([k, v]) => `${k}="${v}"`) + .join(' '); + const lines = [ + `[Web Element: <${context.tagName}${attrStr ? ' ' + attrStr : ''}>]`, + `CSS Path: ${context.path}`, + ]; + if (context.sourceUrl) lines.push(`Source URL: ${context.sourceUrl}`); + if (context.textContent) lines.push(`Text Content: ${context.textContent}`); + if (context.outerHTML) lines.push(`Outer HTML:\n\`\`\`html\n${context.outerHTML}\n\`\`\``); + return lines.join('\n'); + } + default: { + const exhaustive: never = context; + return String(exhaustive); + } + } +} diff --git a/src/web-ui/src/shared/utils/pullRequestLinks.ts b/src/web-ui/src/shared/utils/pullRequestLinks.ts new file mode 100644 index 0000000000..27f69b9283 --- /dev/null +++ b/src/web-ui/src/shared/utils/pullRequestLinks.ts @@ -0,0 +1,93 @@ +import type { ReviewPlatformRemote } from '@/infrastructure/api'; + +export interface PullRequestLinkTarget { + webUrl: string; + host: string; + projectPath: string; + pullRequestId: string; +} + +function cleanSegment(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +function normalizeProjectPath(value: string): string { + return value + .replace(/\\/g, '/') + .replace(/\.git$/i, '') + .replace(/^\/+|\/+$/g, '') + .toLowerCase(); +} + +export function parsePullRequestUrl(value: string): PullRequestLinkTarget | null { + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null; + } + + const segments = url.pathname + .split('/') + .map(cleanSegment) + .filter(Boolean); + + const mergeRequestIndex = segments.findIndex(segment => segment === 'merge_requests'); + if (mergeRequestIndex >= 0 && segments[mergeRequestIndex + 1]) { + const projectEnd = segments[mergeRequestIndex - 1] === '-' + ? mergeRequestIndex - 1 + : mergeRequestIndex; + const projectPath = segments.slice(0, projectEnd).join('/'); + if (projectPath) { + return { + webUrl: url.toString(), + host: url.host.toLowerCase(), + projectPath, + pullRequestId: segments[mergeRequestIndex + 1], + }; + } + } + + const pullIndex = segments.findIndex(segment => segment === 'pull' || segment === 'pulls'); + if (pullIndex >= 0 && segments[pullIndex + 1]) { + const projectPath = segments.slice(0, pullIndex).join('/'); + if (projectPath) { + return { + webUrl: url.toString(), + host: url.host.toLowerCase(), + projectPath, + pullRequestId: segments[pullIndex + 1], + }; + } + } + + return null; +} + +export function remoteMatchesPullRequestLink(remote: ReviewPlatformRemote, target: PullRequestLinkTarget): boolean { + if (remote.host.toLowerCase() !== target.host) { + return false; + } + + const targetProject = normalizeProjectPath(target.projectPath); + const remoteProject = normalizeProjectPath(remote.projectPath); + if (targetProject === remoteProject) { + return true; + } + + try { + const remoteUrl = new URL(remote.webUrl || remote.url); + const remoteUrlProject = normalizeProjectPath(remoteUrl.pathname); + return targetProject === remoteUrlProject; + } catch { + return false; + } +} diff --git a/src/web-ui/src/shared/utils/tabUtils.ts b/src/web-ui/src/shared/utils/tabUtils.ts index a984d55b46..3322d8c8f8 100644 --- a/src/web-ui/src/shared/utils/tabUtils.ts +++ b/src/web-ui/src/shared/utils/tabUtils.ts @@ -25,6 +25,14 @@ interface CreateTerminalTabOptions { sceneJustOpened?: boolean; } +export interface CreateReviewPlatformPullRequestDetailTabOptions { + workspacePath?: string; + remoteId?: string; + pullRequestId?: string; + pullRequestUrl?: string; + title?: string; +} + function isRightPanelCollapsed(): boolean { try { const layoutState = (window as any).__BITFUN_LAYOUT_STATE__; @@ -266,6 +274,74 @@ export function createConfigCenterTab( window.dispatchEvent(new CustomEvent('scene:open', { detail: { sceneId: 'settings' } })); } +export function createReviewPlatformTab(workspacePath?: string): void { + const detail = { + type: 'review-platform', + title: i18nService.getT()('common:tabs.pullRequests', { defaultValue: 'Pull Requests' }), + data: { workspacePath }, + metadata: { + workspacePath, + duplicateCheckKey: `review-platform:${workspacePath || 'current'}`, + }, + checkDuplicate: true, + duplicateCheckKey: `review-platform:${workspacePath || 'current'}`, + replaceExisting: true, + }; + + window.dispatchEvent(new CustomEvent(TAB_EVENTS.EXPAND_RIGHT_PANEL)); + + if (isRightPanelCollapsed()) { + window.setTimeout(() => { + window.dispatchEvent(new CustomEvent(TAB_EVENTS.AGENT_CREATE_TAB, { detail })); + }, 300); + return; + } + + window.dispatchEvent(new CustomEvent(TAB_EVENTS.AGENT_CREATE_TAB, { detail })); +} + +export function createReviewPlatformPullRequestDetailTab(options: CreateReviewPlatformPullRequestDetailTabOptions): void { + const pullRequestLabel = options.pullRequestId ? `#${options.pullRequestId}` : 'Pull Request'; + const title = options.title || pullRequestLabel; + const duplicateKey = [ + 'review-platform-pr-detail', + options.workspacePath || 'current', + options.remoteId || 'auto', + options.pullRequestId || options.pullRequestUrl || 'unknown', + ].join(':'); + const detail = { + type: 'review-platform-pr-detail', + title, + data: { + workspacePath: options.workspacePath, + remoteId: options.remoteId, + pullRequestId: options.pullRequestId, + pullRequestUrl: options.pullRequestUrl, + }, + metadata: { + workspacePath: options.workspacePath, + remoteId: options.remoteId, + pullRequestId: options.pullRequestId, + pullRequestUrl: options.pullRequestUrl, + duplicateCheckKey: duplicateKey, + }, + checkDuplicate: true, + duplicateCheckKey: duplicateKey, + replaceExisting: true, + }; + + window.dispatchEvent(new CustomEvent(TAB_EVENTS.EXPAND_RIGHT_PANEL)); + + if (isRightPanelCollapsed()) { + window.setTimeout(() => { + window.dispatchEvent(new CustomEvent(TAB_EVENTS.AGENT_CREATE_TAB, { detail })); + }, 300); + return; + } + + window.dispatchEvent(new CustomEvent(TAB_EVENTS.AGENT_CREATE_TAB, { detail })); +} + export function createTerminalTab( sessionId: string, sessionName: string, From 7e50e86f29d9feba5374410187958a220915e89a Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Thu, 21 May 2026 15:08:34 +0800 Subject: [PATCH 02/38] refactor(core): externalize multitask reminders and clarify subagent delegation --- .../agents/definitions/modes/multitask.rs | 43 +++++------ .../definitions/subagents/general_purpose.rs | 2 + .../multitask_mode_first_entry_reminder.md | 74 +++++++++++++++++++ .../multitask_mode_ongoing_reminder.md | 3 + 4 files changed, 98 insertions(+), 24 deletions(-) create mode 100644 src/crates/core/src/agentic/agents/prompts/multitask_mode_first_entry_reminder.md create mode 100644 src/crates/core/src/agentic/agents/prompts/multitask_mode_ongoing_reminder.md diff --git a/src/crates/core/src/agentic/agents/definitions/modes/multitask.rs b/src/crates/core/src/agentic/agents/definitions/modes/multitask.rs index 10af4cb979..58536b3b3d 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/multitask.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/multitask.rs @@ -1,7 +1,7 @@ //! Multitask Mode use crate::agentic::agents::{ - shared_coding_mode_tools, Agent, SHARED_CODING_MODE_PROMPT_TEMPLATE, + get_embedded_prompt, shared_coding_mode_tools, Agent, SHARED_CODING_MODE_PROMPT_TEMPLATE, }; use async_trait::async_trait; @@ -9,6 +9,10 @@ pub struct MultitaskMode { default_tools: Vec, } +const MULTITASK_MODE_FIRST_ENTRY_REMINDER_TEMPLATE: &str = + "multitask_mode_first_entry_reminder"; +const MULTITASK_MODE_ONGOING_REMINDER_TEMPLATE: &str = "multitask_mode_ongoing_reminder"; + impl Default for MultitaskMode { fn default() -> Self { Self::new() @@ -22,27 +26,18 @@ impl MultitaskMode { } } - fn build_first_entry_reminder(&self) -> String { - r#"You are now in Multitask mode. - -Treat the task as a parallel work orchestration problem whenever it is beneficial. First decompose the work into orthogonal subtasks or an explicit DAG with clear dependency edges. Then use subagents proactively to execute independent branches in parallel. - -Prefer: -- independent subtasks with minimal overlap -- clear ownership and deliverables per subagent -- parallel execution for non-blocking branches -- background execution for independent subagents whenever possible; prefer the Task tool's `run_in_background` mode instead of blocking on each branch -- local execution only for the immediate critical path - -Do not force parallelism when the task is tiny or tightly coupled, but default to decomposition-first thinking in this mode."# - .to_string() - } - - fn build_ongoing_reminder(&self) -> String { - r#"You are still in Multitask mode. - -Continue working with a parallel-first mindset. Prefer orthogonal decomposition, preserve clear dependency edges, and keep using subagents proactively for independent branches whenever parallel execution is beneficial."# - .to_string() + fn load_reminder_template( + &self, + template_name: &str, + ) -> crate::util::errors::BitFunResult { + get_embedded_prompt(template_name) + .map(str::to_string) + .ok_or_else(|| { + crate::util::errors::BitFunError::Agent(format!( + "{} not found in embedded files", + template_name + )) + }) } } @@ -74,9 +69,9 @@ impl Agent for MultitaskMode { _workspace: Option<&crate::agentic::WorkspaceBinding>, ) -> crate::util::errors::BitFunResult { if previous_agent_type == Some(self.id()) { - Ok(self.build_ongoing_reminder()) + self.load_reminder_template(MULTITASK_MODE_ONGOING_REMINDER_TEMPLATE) } else { - Ok(self.build_first_entry_reminder()) + self.load_reminder_template(MULTITASK_MODE_FIRST_ENTRY_REMINDER_TEMPLATE) } } diff --git a/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs b/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs index 048612640a..8bc0aa950f 100644 --- a/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs +++ b/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs @@ -21,6 +21,8 @@ impl GeneralPurposeAgent { "Grep".to_string(), "Write".to_string(), "Edit".to_string(), + "Delete".to_string(), + "TerminalControl".to_string(), "WebSearch".to_string(), "WebFetch".to_string(), ], diff --git a/src/crates/core/src/agentic/agents/prompts/multitask_mode_first_entry_reminder.md b/src/crates/core/src/agentic/agents/prompts/multitask_mode_first_entry_reminder.md new file mode 100644 index 0000000000..5d8d21b248 --- /dev/null +++ b/src/crates/core/src/agentic/agents/prompts/multitask_mode_first_entry_reminder.md @@ -0,0 +1,74 @@ +You are now in Multitask mode. + +Treat the task as a parallel work orchestration problem whenever it is beneficial. First decompose the work into orthogonal subtasks. Then use background subagents proactively to execute independent branches in parallel. + +# Subagent Delegation Guide + +- Prefer background subagents whenever the branch is independent and does not block your immediate next step. Use `run_in_background: true` on the Task call to launch it in the background. +- Keep yourself on the critical path. Handle decomposition, dependency management, interface alignment, integration, and final verification yourself. +- Use `FileFinder` when the subtask is primarily about locating relevant files, entry points, symbols, or ownership boundaries. +- Use `Explore` when the subtask is read-only investigation, codebase understanding, or evidence gathering that should not modify files. +- Use `GeneralPurpose` when the subtask is implementation work that is likely to modify files, such as editing code, wiring features, fixing tests, or updating configurations. +- Give each subagent a clear scope, expected output, and ownership boundary so parallel branches do not overlap unnecessarily. +- Do not spawn subagents for tiny or tightly coupled tasks where delegation overhead is higher than the benefit. + +# Notes + +- Parallel `Write` or `Edit` calls are not true parallel execution. File-modifying tools are serialized by the system. +- Do not claim you are doing parallel implementation work if you are only issuing multiple file modification calls yourself. +- If the work should happen in parallel, use subagents to execute independent branches. Do not try to simulate parallelism by batching your own file writes. + +# Examples + + +Example 1: the user gives one feature request, and you proactively decompose it. + +"Add an export report feature. Users should be able to click Export in the UI, the backend should generate the report, and we should have reasonable test coverage." + + +- Identify separate branches such as contract design, backend export logic, frontend entry point, and verification. +- Keep the immediate coordination path local: define or confirm the interface between frontend and backend first if needed. +- Then dispatch independent work in parallel, for example: + - one subagent owns backend export implementation + - one subagent owns frontend wiring and UX states + - one subagent prepares or updates tests that can be written against the agreed contract +- Integrate the results yourself, resolve mismatches, and run the final verification yourself. + + + + +Example 2: the user already provides a numbered task list, and you still reason about dependency edges instead of blindly doing 1, 2, 3 in order. + +"Please do these three things: +1. Update the settings page copy for the new sync behavior. +2. Add a CLI flag for forcing sync. +3. Add tests for the change." + + +- Do not assume the numbered list is the execution order. +- Check whether item 1 and item 2 are orthogonal enough to run in parallel. +- Split item 3 by dependency if needed: some tests may be prepared in parallel, while integration or end-to-end verification may need to wait for the implementation branches to land. +- Dispatch multiple subagents when the branches are truly independent, then merge and verify the combined result yourself. + + + + +Counterexample: claiming parallelism while only issuing your own file edits. + +"Add the backend endpoint, wire the UI button, and update tests." + + +- "I will do these in parallel" and then directly issue multiple `Write` or `Edit` calls yourself. +- Treat multiple file modification calls as if they were equivalent to multiple background subagents. +- Skip subagent delegation even though the branches are independent enough to split. + + +- Parallel file modification calls are serialized by the system, so this is not real parallel execution. +- The behavior misses the point of Multitask mode, which is to delegate independent branches to subagents when parallel work is beneficial. + + +- Keep coordination and integration work yourself. +- Delegate the backend implementation, UI wiring, and test updates to separate subagents when the branches are independent enough. +- Merge and verify the results after the subagents return. + + diff --git a/src/crates/core/src/agentic/agents/prompts/multitask_mode_ongoing_reminder.md b/src/crates/core/src/agentic/agents/prompts/multitask_mode_ongoing_reminder.md new file mode 100644 index 0000000000..275bf7a1d9 --- /dev/null +++ b/src/crates/core/src/agentic/agents/prompts/multitask_mode_ongoing_reminder.md @@ -0,0 +1,3 @@ +You are still in Multitask mode. + +Continue working with a parallel-first mindset. Prefer orthogonal decomposition and keep using subagents proactively for independent branches whenever parallel execution is beneficial. From e39502b969bde52d29c0cc576466e24ec5c5156e Mon Sep 17 00:00:00 2001 From: limityan Date: Thu, 21 May 2026 15:09:18 +0800 Subject: [PATCH 03/38] refactor(remote-connect): close service runtime boundary --- docs/architecture/core-decomposition.md | 44 +- docs/plans/core-decomposition-plan.md | 154 +++++- scripts/check-core-boundaries.mjs | 374 +++++++++++++- src/crates/core/AGENTS-CN.md | 11 +- src/crates/core/AGENTS.md | 12 +- .../remote_connect/bot/command_router.rs | 4 +- .../src/service/remote_connect/bot/mod.rs | 110 +---- .../service/remote_connect/remote_server.rs | 456 ++++++++++-------- src/crates/services-integrations/AGENTS.md | 12 +- src/crates/services-integrations/Cargo.toml | 2 +- .../src/remote_connect.rs | 338 +++++++++++++ .../tests/remote_connect_contracts.rs | 403 +++++++++++++++- 12 files changed, 1564 insertions(+), 356 deletions(-) diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index ef4b2dfbad..055489f83e 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -69,7 +69,7 @@ Rust 编译和链接面。 | `bitfun-agent-tools` | 轻量 tool DTO / contract、portable tool context facts / provider、runtime restriction、pure manifest/exposure and GetToolSpec presentation/schema/static metadata/detail/result assembly / execution-plan contract、provider-backed tool catalog / GetToolSpec runtime facade、provider-backed GetToolSpec execution result helper / Tool-result vector adapter、generic contextual manifest resolver、generic catalog snapshot provider / GetToolSpec catalog provider、generic registry / static-provider / dynamic-provider / decorator-ref / snapshot-decorator adapter / runtime assembly container、generic readonly/enabled snapshot filter | partial:product registry snapshot access、`ToolUseContext` adapter、`GetToolSpec` Tool impl 和 concrete tools 仍在 core;core 当前从 `bitfun-tool-packs` provider plan 物化内置工具列表,static-provider 安装 assembly、decorator reference、generic snapshot decorator adapter、provider-backed catalog runtime facade 与 readonly/enabled 过滤规则已委托给 `bitfun-agent-tools` | | `bitfun-tool-packs` | 由 feature group 隔离的工具 provider plan | partial:提供 basic / git / mcp / browser-web / computer-use / image-analysis / miniapp / agent-control feature-group 元数据和 product provider group plan;不得声明 concrete tools 已迁移 | | `bitfun-services-core` | Config、session、workspace、storage、filesystem、system services | partial:部分 pure helper 已迁出;config/workspace/filesystem runtime 多数仍在 core | -| `bitfun-services-integrations` | Git、MCP、remote SSH、remote connect、file watch integrations | partial:MCP runtime 已迁入;remote SSH 仍只迁移低风险 contracts/helpers;remote-connect 已拥有 wire DTO、request builder、tracker state / registry lifecycle 与 tracker event reduction,dispatcher/product execution 仍在 core | +| `bitfun-services-integrations` | Git、MCP、remote SSH、remote connect、file watch integrations | partial:MCP runtime 已迁入;remote SSH 仍只迁移低风险 contracts/helpers;remote-connect 已拥有 wire DTO、request builder、tracker state / registry lifecycle、tracker event reduction、dialog submission orchestration port/provider、file IO/path resolution helper 与 image-context adapter contract;concrete scheduler/session restore/terminal adapter、workspace-root source、response wrapping 与 product execution 仍在 core | | `bitfun-product-domains` | Miniapp 和 function-agent 产品子域 | partial:pure decision、port、storage/builtin contract 可迁入;IO、worker、built-in asset seeding、Git/AI service runtime 仍在 core | | `terminal-core` | 已有 terminal package,移动到 workspace 顶层 `src/crates/terminal` 路径 | done:已在 workspace 顶层 | | `tool-runtime` | 已有 tool runtime,移动到 workspace 顶层路径 | done:已在 workspace 顶层 | @@ -100,15 +100,18 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate - remote-connect command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、 tracker state / registry lifecycle、remote tool preview slimming、legacy image context fallback / - preference、restore target decision、cancel decision 与 remote file transfer + preference、restore target decision、cancel decision、RemoteRelay/Bot dialog submission + orchestration port/provider、remote workspace path/MIME/full-read/chunk/info helper 与 remote file transfer size/chunk/name policy 可由 `bitfun-services-integrations` 拥有;core 只保留 tracker host adapter、 - global dispatcher、session restore 执行、terminal pre-warm、`ImageContextData` adapter - 、file IO/path resolution 和实际 dialog submission routing。不要把 tracker state - 、wire DTO 或纯策略 helper 回写到 core。 + global dispatcher compatibility wrapper、session restore 执行、terminal pre-warm adapter、 + concrete scheduler submit adapter、workspace-root source、response/base64 wrapping 与 + `ImageContextData` concrete adapter implementation。 + 不要把 tracker state、wire DTO、dialog orchestration 或纯策略 helper 回写到 core。 - remote-connect runtime owner 进一步外移前必须保持迁移前快照:remote command/response shape、restore target、active-turn poll snapshot、cancel decision、image context fallback - / preference、tracker fanout、file transfer 与 RemoteRelay/Bot queue policy。 + / preference、tracker fanout、file transfer、RemoteRelay/Bot queue policy,以及 + restore -> terminal pre-warm -> scheduler submit 的 dialog orchestration 顺序。 - `bitfun-core-types` 不得依赖 runtime manager、service crate、agent runtime、 app crate、Tauri、network client、process execution,或 `git2`、`rmcp`、`image`、 `tokio-tungstenite` 等重集成依赖。 @@ -258,14 +261,31 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate extraction 与 domain error mapping 策略迁入 `product-domains`。filesystem IO、 worker process、host dispatch、built-in asset seeding / marker IO 与 Git/AI 调用仍显式 core-owned。 - - H3:remaining service/runtime owner。remote-connect 当前只迁移 tracker / wire / - pure policy / registry lifecycle;dialog submission、terminal pre-warm、file IO/path - resolution、`ImageContextData` adapter、remote-SSH runtime、agent registry/scheduler - 等必须另起 port/provider 设计和等价评审。 - - H4:facade and boundary finalization。`bitfun-core` 只在 runtime owner 迁移确认后 - 收敛为 legacy facade + product runtime assembly。 + - H3:remaining service/runtime owner。remote-connect 已把 dialog submission + orchestration、terminal pre-warm decision、remote workspace file IO/path helper 与 + image-context adapter contract 收敛到 owner crate port/provider; + concrete scheduler/session restore/terminal adapter、workspace-root source、 + response wrapping、remote-SSH runtime、agent registry/scheduler 等仍必须 + 另起 port/provider 设计和等价评审。 + - H4:facade and boundary finalization。当前以 boundary script / AGENTS / architecture + docs 一致性闭环为准,确认 `bitfun-core` 继续作为 legacy facade + full product + runtime assembly;未完成等价评审的 runtime owner 继续显式 core-owned。 - H5:optional feature/build-benefit evaluation。`bitfun-core default = []`、per-product feature matrix、依赖版本收敛和构建收益评估只能在 H1-H4 后独立进行。 +- H4 之后的剩余工作口径必须区分“当前闭环必需项”和“后续深度 runtime 迁移”: + 当前 H1-H4 主线闭环后,不再把 deferred/core-owned runtime 当作当前 PR 漏项; + 若继续外移高风险 owner,最多按 tool runtime、product-domain runtime、 + service/agent runtime 这 3 个条件性大型 PR 重新评审;这些 HR 项不是 H5 + 之后的必做项。H5 仍是单独且可选的 feature/build-benefit evaluation, + 只能在选择继续外移的 HR 项完成或明确 defer 后评估。 +- HR1-HR3 的共同底线是功能影响范围可控、无性能劣化且不改变产品发布形态: + 不修改 default feature、产品 crate feature set、CI/release 覆盖、desktop/installer + build scripts 或任一 surface command/UI 语义;不得新增无界锁、重复 registry / + manifest materialization、额外 network/process startup 或反向 runtime 依赖。HR1 + 重点保护 tool visibility / manifest / unlock / snapshot / Deep Review tool flow; + HR2 重点保护 MiniApp IO/worker/asset seed 与 function-agent Git/AI 时序; + HR3 重点保护 remote workspace/SSH/terminal、scheduler/registry、subagent visibility + 和 DeepResearch post-turn hook。 - 已完成的 MCP runtime/dynamic tools、remote-connect tracker/wire/pure policy、 semantic baseline、product-domain port/facade 与 tool contract/helper 外移不得重复规划; 如果后续发现这些已完成项存在实现错误,应在对应 H 阶段记录问题、风险和修复方案, diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 1919c308ae..71ae8cffcd 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -991,14 +991,15 @@ product-full = ["git", "mcp", "remote-ssh", "remote-connect", "announcement", "f - [x] 已补齐 remote runtime 迁移前的第一层 port baseline:`SessionTranscriptReader`、`AgentTurnCancellationPort`、`RemoteControlStatePort`、`RuntimeEventSink` 与 remote image attachment/request DTO;完整 `remote-connect` runtime 仍需后续单独迁移并补 queue/event/image 行为等价测试。 - [x] `RemoteSessionStateTracker`、`TrackerEvent`、tracker registry lifecycle 与 remote tool preview slimming helper 已迁入 `bitfun-services-integrations`;core 只保留 tracker host adapter、dispatcher、session restore、terminal pre-warm 与实际 dialog submission routing。 - [x] 已补齐 remote-connect runtime 迁移前快照:remote command/response wire shape、session restore target、active turn poll snapshot、cancel decision、legacy image fallback / unified image context preference、tracker completion/fanout 与 RemoteRelay/Bot queue policy 均有 focused regression。 -- [x] 已将 remote-connect wire / poll 边界与纯运行时策略 helper 迁入 `bitfun-services-integrations`:command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、legacy image context fallback / explicit context preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy 由 owner crate 提供;core 仅保留 `ImageContextData` adapter、dispatcher、session restore 执行、file IO/path resolution、terminal pre-warm 与实际 dialog submission routing。 +- [x] 已将 remote-connect wire / poll 边界与纯运行时策略 helper 迁入 `bitfun-services-integrations`:command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、legacy image context fallback / explicit context preference、restore target decision、cancel decision、remote workspace file IO/path helper、image-context adapter contract 与 remote file transfer size/chunk/name policy 由 owner crate 提供;core 仅保留 dispatcher、session restore 执行、workspace-root source、response/base64 wrapping、`ImageContextData` concrete impl、terminal pre-warm adapter 与实际 dialog submission routing。 +- [x] H3 remote-connect closure:RemoteRelay/Bot dialog submission orchestration、agent type normalization、turn id resolution、restore decision、terminal pre-warm decision、queue policy、remote workspace file IO/path helper 与 image-context adapter contract 归属 `bitfun-services-integrations`;core 继续作为 concrete scheduler/session restore/terminal adapter、workspace-root source 与 response wrapper,不改变产品行为。 - [x] 已迁移的集成能力保持 core 旧路径 re-export。 - [x] 产品完整 runtime 通过 `services-integrations/product-full` 启用已迁移集成能力。 **当前安全迁移状态(2026-05-15):** - 已迁移到 `bitfun-services-integrations`:`service::file_watch`,通过 `file-watch` / `product-full` feature 启用,并保持 `core::service::file_watch` 旧路径。 -- `git` 已完成 DTO/params/graph/raw command output/text parser/arg builder、`GitError`、`GitService` runtime implementation 与 git utils 迁移;`bitfun-core::service::git::*` 仅保留 legacy facade re-export。`remote-ssh` 已迁移纯 contract/type、workspace path/identity helper 与 unresolved-session-key helper;SSH runtime manager / fs / terminal、password vault 与 PathManager-backed session mirror assembly 仍保留在 core。`mcp` 已迁移 tool-name / tool-info / protocol types / config location / server type-status、server config、cursor-format、JSON-RPC request builder、JSON config format/validation helper、config merge / remote authorization helper、OAuth credential vault / authorization bootstrap contract、remote auth error classifier、legacy remote header fallback helper、transport Authorization 归一化 helper、remote client capability helper、rmcp 到 BitFun protocol 的纯映射 helper、resource/prompt adapter、catalog cache、list-changed/reconnect policy、config service save-load orchestration、server process / local-remote transport lifecycle、dynamic tool descriptor / provider / result rendering helper,并用 owner crate contract test 锁定 wire shape、transport default、validation message、Cursor 兼容格式、config precedence / dedup 语义、OAuth vault 存储路径注入、NeedsAuth 分类、旧 env Authorization fallback、remote client capabilities、remote result metadata / structured content 映射、config load/save/delete contract、unsupported remote transport contract、context resource selection 和 dynamic manifest;`bitfun-core` 继续负责 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、legacy facade 和全局 tool registry / manifest 组装。`announcement` 仅迁移了纯 types contract,scheduler / state store / content loader / remote fetch 仍保留在 core;`remote-connect` 已完成 contract/request-builder slice,补齐 cancellation/state/event/image 第一层 port baseline,迁出 command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、tracker state / registry lifecycle / tracker event reduction / remote tool preview slimming helper、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy,并补齐 remote command/response、restore、active turn、cancel、image context、tracker fanout 与 queue policy 迁移前快照;但远程消息执行、`ImageContextData` adapter、file IO/path resolution、terminal pre-warm 与 workspace/session restore 执行仍保留在 core。它们涉及 SSH runtime、remote agent submission runtime、product tool runtime manifest / `GetToolSpec` 执行 owner 化与 announcement config/path 边界,继续前需要单独确认端口方案与等价性测试。 +- `git` 已完成 DTO/params/graph/raw command output/text parser/arg builder、`GitError`、`GitService` runtime implementation 与 git utils 迁移;`bitfun-core::service::git::*` 仅保留 legacy facade re-export。`remote-ssh` 已迁移纯 contract/type、workspace path/identity helper 与 unresolved-session-key helper;SSH runtime manager / fs / terminal、password vault 与 PathManager-backed session mirror assembly 仍保留在 core。`mcp` 已迁移 tool-name / tool-info / protocol types / config location / server type-status、server config、cursor-format、JSON-RPC request builder、JSON config format/validation helper、config merge / remote authorization helper、OAuth credential vault / authorization bootstrap contract、remote auth error classifier、legacy remote header fallback helper、transport Authorization 归一化 helper、remote client capability helper、rmcp 到 BitFun protocol 的纯映射 helper、resource/prompt adapter、catalog cache、list-changed/reconnect policy、config service save-load orchestration、server process / local-remote transport lifecycle、dynamic tool descriptor / provider / result rendering helper,并用 owner crate contract test 锁定 wire shape、transport default、validation message、Cursor 兼容格式、config precedence / dedup 语义、OAuth vault 存储路径注入、NeedsAuth 分类、旧 env Authorization fallback、remote client capabilities、remote result metadata / structured content 映射、config load/save/delete contract、unsupported remote transport contract、context resource selection 和 dynamic manifest;`bitfun-core` 继续负责 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、legacy facade 和全局 tool registry / manifest 组装。`announcement` 仅迁移了纯 types contract,scheduler / state store / content loader / remote fetch 仍保留在 core;`remote-connect` 已完成 contract/request-builder slice,补齐 cancellation/state/event/image 第一层 port baseline,迁出 command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、tracker state / registry lifecycle / tracker event reduction / remote tool preview slimming helper、legacy image context fallback / preference、restore target decision、cancel decision、remote workspace file IO/path helper 与 remote file transfer size/chunk/name policy,并补齐 remote command/response、restore、active turn、cancel、image context、tracker fanout、queue policy 与 dialog orchestration 顺序快照;H3 已把远程消息提交编排、terminal pre-warm decision 与 image-context adapter contract 迁入 `bitfun-services-integrations` port/provider。workspace-root source、response/base64 wrapping、`ImageContextData` concrete impl、concrete terminal pre-warm adapter、concrete scheduler/session restore 执行仍保留在 core。它们涉及 SSH runtime、remote agent submission runtime、product tool runtime manifest / `GetToolSpec` 执行 owner 化与 announcement config/path 边界,继续前需要单独确认端口方案与等价性测试。 - 最新主干的 Deep Review capacity / cost / queue、context profile、evidence ledger、session manifest、stream dedupe、search remote/fallback 与 session rollback persistence 仍属于 core runtime 或对应产品 runtime,不在本轮 `services-integrations` 迁移范围内;如果后续迁移 remote-connect / MCP / search / session,需要先定义运行状态 port 合约和等价测试。 **验证:** @@ -1756,8 +1757,8 @@ P2 后产品表面契约轨道(contract-only): - 保留边界:`bitfun-core` 只保留 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、legacy facade 和与全局 tool registry / manifest 的组装调用;配置写入、OAuth、SSE/session 与 registry / manifest 行为不得在本 PR 中改变。 - 后续切片:MCP concrete tool integration / product registry / manifest assembly 继续保留 dynamic provider metadata、工具清单顺序、expanded/collapsed exposure 和 snapshot wrapper 等价测试。 - 文档校正:P2 后补充文档中的 MCP runtime step 已由本 PR2 闭环;后续 MCP 相关工作只保留 concrete tool implementation 迁移或 product registry / manifest assembly,不再重复迁移 config/process/transport lifecycle。 -3. 已完成:remote-connect tracker / wire / pure policy owner slice:产品表面 DTO、remote command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、remote chat/image/tool/session wire DTO、relay/bot session/submission request builder、remote image attachment/request DTO、`AgentTurnCancellationPort`、`RemoteControlStatePort`、`RuntimeEventSink`、`RemoteSessionStateTracker`、`RemoteSessionTrackerRegistry`、`TrackerEvent`、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy 已具备 owner/port 契约;core 仍保留 tracker host adapter、`ImageContextData` adapter、file IO/path resolution、dispatcher/product execution。 - - 本轮收口:remote-connect 在当前批次以 tracker / wire / pure policy / registry lifecycle 归 owner crate、dispatcher / product execution 显式保留 core-owned 闭环;若未来继续迁移完整 dialog submission、terminal pre-warm、file IO/path resolution 或 `ImageContextData` adapter,必须另起 port/provider 设计与行为等价评审,不得混入 tool/provider owner 化。 +3. 已完成:remote-connect tracker / wire / pure policy owner slice:产品表面 DTO、remote command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、remote chat/image/tool/session wire DTO、relay/bot session/submission request builder、remote image attachment/request DTO、`AgentTurnCancellationPort`、`RemoteControlStatePort`、`RuntimeEventSink`、`RemoteSessionStateTracker`、`RemoteSessionTrackerRegistry`、`TrackerEvent`、legacy image context fallback / preference、restore target decision、cancel decision、remote workspace file IO/path helper、image-context adapter contract 与 remote file transfer size/chunk/name policy 已具备 owner/port 契约;core 仍保留 tracker host adapter、dispatcher/product execution、workspace-root source、response/base64 wrapping 与 `ImageContextData` concrete impl。 + - 本轮收口:remote-connect 在当前批次以 tracker / wire / pure policy / registry lifecycle / dialog orchestration / file helper / image-context adapter contract 归 owner crate、dispatcher / product execution 显式保留 core-owned 闭环;若未来继续迁移 concrete scheduler/session restore/terminal adapter、remote-SSH runtime 或 agent registry/scheduler,必须另起 port/provider 设计与行为等价评审,不得混入 tool/provider owner 化。 4. 已完成本轮可提交闭环:agent tools + `tool-packs` owner 化低风险部分。纯 tool contract/provider metadata、runtime restriction DTO、path resolution DTO、generic tool registry / static-provider / dynamic-provider container、generic catalog snapshot provider、generic GetToolSpec catalog provider、provider-backed `ToolCatalogRuntime`、`PortableToolContextProvider` 只读 facts provider、纯 manifest/exposure 契约、generic contextual manifest resolver,以及 GetToolSpec presentation/schema/detail/runtime facade helper 已迁入 `bitfun-agent-tools`,并为 dynamic provider contract 提供 `agent-tools` 兼容 re-export;`tool-packs` 现在提供 feature-group 元数据和 product provider group plan,但不注册或迁移具体工具;core tool runtime 保留 concrete tool materialization、product snapshot wrapper adapter、`dyn Tool` / `ToolUseContext` 适配、product registry snapshot access、agent policy 和 `GetToolSpec` 执行。`ToolUseContext`、`GetToolSpec` 执行与 concrete tool implementation 按 feature group 外移需要新的 service port/provider 设计,必须保持 builtin/readonly/dynamic manifest、expanded/collapsed exposure、prompt stub、unlock state、snapshot wrapping、runtime restrictions、cancellation 与 Deep Review tool flow 等价,作为后续高风险迁移单独审视。 5. `product-domains` runtime + core facade finalization:迁移 miniapp runtime/compiler/builtin 与 function-agent 运行逻辑,最后把 `bitfun-core` 收敛为 facade + product runtime assembly;不在本 PR 中修改 `bitfun-core default = []` 或 per-product feature matrix。 @@ -1775,8 +1776,8 @@ P2 后产品表面契约轨道(contract-only): | LR1 | low-risk closure before runtime migration | `BitFunError` 剩余 concrete wrapper 处理决策、后续 shared DTO 归属校准、services-core/services-integrations 悬空 port/call-site 状态复核、tool runtime port/provider 设计与 manifest 等价基线、MiniApp/function-agent runtime 迁移前 owner 审视、P3 facade/boundary 文档与 AGENTS 校准 | `ToolUseContext` / concrete tools / MiniApp IO / function-agent Git/AI / remote-connect dialog 等 runtime owner 外移、`default = []`、feature matrix、构建收益宣传 | 文档中 Plan 3/5/6/7/8/9 的未完成项要么完成,要么显式标为 deferred/core-owned;boundary check、diff check 和对应最小 Rust check 通过 | | H1 | high-risk tool runtime migration | `ToolUseContext`、runtime manifest assembly / `GetToolSpec` 执行、concrete tool implementation 或 product registry / provider assembly 的单一 owner 迁移 | MiniApp/function-agent runtime、remote-connect runtime、feature matrix | 完整产品 tool registry、expanded/collapsed exposure、unlock state、dynamic provider metadata、snapshot wrapper 与 Deep Review tool flow 等价可证明 | | H2 | high-risk product-domain runtime migration | MiniApp runtime/manager/host/exporter/builtin 或 function-agent runtime 的单一 owner 迁移;不能安全外移的路径必须显式保留 core-owned runtime | tool runtime、remote-connect runtime、CLI/Desktop/Remote/ACP surface 行为变更 | `product-domains` 不依赖 core;IO/process/Git/AI 边界清晰;MiniApp/function-agent focused regression 通过 | -| H3 | high-risk service/runtime migration, only if still required | remote-connect dialog submission、terminal pre-warm、file IO/path resolution、`ImageContextData` adapter、remote-SSH runtime 或 agent registry/scheduler 等剩余 runtime owner,按单一 owner 独立评审 | tool/product-domain runtime、feature matrix、产品逻辑变更 | 有 port/provider 设计、旧路径兼容和产品等价测试;若决定保留 core-owned runtime,文档必须闭环 | -| H4 | facade and boundary finalization | `bitfun-core` 收敛为 legacy facade + full product runtime assembly;boundary script、AGENTS、architecture/plan docs 与当前代码一致;最终确认没有悬空的“已完成错觉” | 新 runtime 外移、默认 feature 改动 | 文档主线 checklist 闭环;所有 deferred/core-owned 项有明确 owner、测试或后续评估入口 | +| H3 | high-risk service/runtime migration, only if still required | remote-connect dialog submission orchestration / terminal pre-warm decision、remote workspace file IO/path helper 与 image-context adapter contract 已按单一 owner 迁入 `bitfun-services-integrations` port/provider;workspace-root source、response/base64 wrapping、`ImageContextData` concrete impl、remote-SSH runtime 或 agent registry/scheduler 等剩余 runtime owner 仍需单独评审 | tool/product-domain runtime、feature matrix、产品逻辑变更 | 有 port/provider 设计、旧路径兼容和产品等价测试;若决定保留 core-owned runtime,文档必须闭环 | +| H4 | facade and boundary finalization | 已完成当前批次收口:`bitfun-core` 继续作为 legacy facade + full product runtime assembly;boundary script 自检、AGENTS、architecture/plan docs 与当前代码一致,未迁移 runtime 均显式 core-owned/deferred | 新 runtime 外移、默认 feature 改动 | boundary check self-test、boundary check、diff check 和 workspace Rust 验证通过;所有 deferred/core-owned 项有明确 owner、测试或后续评估入口 | | H5 | optional default feature / build-benefit evaluation | 仅在 LR1 与必要的 H1-H4 后评估 `bitfun-core default = []`、per-product feature set、依赖版本收敛和构建收益 | 任何 runtime owner 迁移或产品逻辑变更 | 有 feature graph baseline、`cargo check -p bitfun-core`、workspace check 和目标 crate check 的前后数据;可选择不执行 | **H2 closure(2026-05-21):** H2 以 function-agent prompt/response policy 作为单一 owner @@ -1784,6 +1785,135 @@ P2 后产品表面契约轨道(contract-only): marker IO / recompile,以及 function-agent Git service / AI client 调用仍显式 core-owned。 这些路径不是“未注意到”,而是因行为边界和外部副作用风险保留到后续单独评审。 +**H3 remote-connect closure(2026-05-21):** 本轮迁移 RemoteRelay/Bot +dialog submission 的编排所有权以及可独立验证的 file/image 边界:tracker ensure、 +workspace binding lookup、restore decision、terminal pre-warm decision、agent type +normalization、turn id resolution、queue policy、submit handoff 顺序、remote workspace +path/MIME/full-read/chunk/info helper 与 image-context adapter contract 由 +`bitfun-services-integrations` 拥有,并用 focused regression 锁定。core 仍负责 +concrete scheduler submit、session restore 执行、terminal binding adapter、workspace-root +source、response/base64 wrapping 与 `ImageContextData` concrete impl;这些属于运行时副作用 +或产品封装边界,不得在没有新等价测试和端口设计时继续移动。 + +**H4 facade / boundary closure(2026-05-21):** 本轮不继续移动新的 runtime owner, +而是把 H1-H3 的结果收敛为可审核的边界状态:`scripts/check-core-boundaries.mjs` +新增 remote-connect file/image/dialog owner anchor、core adapter/deferred owner anchor 与 +自检覆盖;`AGENTS.md` / `AGENTS-CN.md` 校正 function-agent 与 remote-connect 的当前 +归属;`services-integrations` 文档不再把 remote-SSH runtime 表述为 H3 内自动迁移。 +`bitfun-core default = []`、per-product feature matrix、依赖版本收敛和构建收益声明仍保留到 +H5 独立评估,不属于当前 H4。 + +**H4 后剩余工作审查(2026-05-21):** + +当前 H1-H4 主线已经把已迁移 owner、core-owned runtime 与 deferred 项分清。若以 +“本轮 core decomposition closure”为目标,提交 H4 前不再需要新增 runtime 迁移 PR; +后续只剩 `H5` 这个可选的 feature/build-benefit evaluation,且它可以选择不执行。 +`HR1`-`HR3` 不是 `H5` 之后的必做项;它们只在决定继续外移当前显式 +core-owned 的高风险 runtime 时才成立。若选择继续外移,应先完成或明确 defer +对应 `HR` 评审,再进入任何会改变 feature/default/build-benefit 口径的 `H5`。 + +若后续决定继续把当前显式 core-owned 的高风险 runtime 进一步外移,必须重新进入 +owner-by-owner 迁移队列,不得把它们当作 H4 漏项补进当前 PR。建议最多收敛为 3 个 +大型 runtime PR,外加 1 个可选 H5 评估 PR: + +| 后续项 | 性质 | 范围 | 不允许混入 | 合入门禁 | +|---|---|---|---|---| +| HR1:tool runtime deep owner migration | 条件性高风险 PR | `ToolUseContext`、runtime manifest assembly、`GetToolSpecTool` Tool impl、collapsed unlock state、snapshot wrapper implementation、concrete tools 或 product registry / provider assembly 的单一 owner 迁移;也可以明确评审后继续 core-owned | MiniApp/function-agent runtime、remote-connect / remote-SSH runtime、default feature 或构建收益声明 | 先补 port/provider 设计;证明 builtin/readonly/dynamic manifest、expanded/collapsed exposure、unlock state、dynamic provider metadata、snapshot wrapping、runtime restrictions、cancellation 和 Deep Review tool flow 等价 | +| HR2:product-domain runtime deep owner migration | 条件性高风险 PR | MiniApp filesystem IO、worker process execution、host dispatch、built-in asset include / seed / marker IO / recompile,或 function-agent Git/AI service adapter / AI client call 的单一 owner 迁移;也可以明确评审后继续 core-owned | tool runtime、remote-connect / remote-SSH runtime、CLI/Desktop/Remote/ACP surface 行为变更 | `product-domains` 不依赖 core;先锁定 PathManager、process execution、permission policy、Git/AI error/transport mapping 和 focused MiniApp/function-agent regression | +| HR3:service / agent runtime deep owner migration | 条件性高风险 PR | remote-SSH manager / remote FS / terminal、remote-connect workspace-root source / response wrapping / `ImageContextData` concrete impl / concrete scheduler-session-restore-terminal adapter、agent registry / scheduler 的单一 owner 迁移;也可以明确评审后继续 core-owned | tool runtime、product-domain runtime、feature matrix 或产品逻辑变更 | 有 port/provider 设计、旧路径兼容、mode-scoped subagent visibility / background delivery / remote-connect dialog order / remote workspace guard / DeepResearch post-turn hook 等行为等价测试 | +| H5:feature/build-benefit evaluation | 可选评估 PR | `bitfun-core default = []`、per-product explicit feature set、依赖版本收敛、构建收益数据记录 | 任何 runtime owner 迁移、产品逻辑变更或构建脚本改造 | 有 feature graph baseline、`cargo check -p bitfun-core`、workspace check、目标 crate check 和必要 product check 的前后数据;若收益不清晰则不执行 | + +**HR 风险与优化清单:** + +所有 HR PR 都必须满足以下共同约束: + +- 功能影响范围:只能移动 owner 或引入 port/provider adapter;不得改变用户可见命令、 + 默认权限、remote/session 生命周期、tool 可见性、MiniApp/function-agent 输出、CLI/Desktop/ACP/Server + 交互语义。 +- 产品发布形态:不得修改 `bitfun-core` default feature、产品 crate feature set、 + `package.json`、desktop/installer build scripts、release/fast build 脚本或 CI 覆盖范围。 + 若某项迁移必须改变这些内容,必须从 HR PR 中拆出并先单独评审。 +- 性能门禁:不得新增无界全局锁、阻塞 IO、重复 registry rebuild、重复 manifest + materialization、额外 network/process startup 或跨 crate 的重依赖反向引入。PR 如果声明 + build/check 收益,必须记录迁移前后数据;如果不声明收益,也至少不能让 workspace + check/test 或关键产品 check 明显劣化。 +- 依赖边界:owner crate 不得依赖回 `bitfun-core`;contract crate 不得吸收 + Tauri、CLI/TUI presentation、network client、process execution、`git2`、`rmcp`、 + `image`、`tokio-tungstenite` 等 concrete runtime 依赖。 +- 回滚边界:每个 HR PR 必须保留旧路径 facade 或 adapter,使失败时可以把新 owner + 路径回退到 core-owned adapter,而不需要同步修改产品 surface。 + +HR1:tool runtime deep owner migration 的主要风险和控制点: + +- 风险:`ToolUseContext` 携带 workspace services、cancellation、computer-use host、 + custom data、Deep Review checkpoint hook 与 collapsed unlock state;若直接移动, + 可能改变工具可见性、权限、snapshot wrapper、Deep Review tool flow 或取消语义。 +- 风险:manifest / `GetToolSpec` / catalog 组装若重复计算,可能增加每轮 agent + prompt 构建成本;若 dynamic metadata 顺序或去重语义漂移,可能改变模型看到的工具集合。 +- 可优化点:只先抽 `ToolUseContext` 的 capability/read-only projection 或小型 + service port;concrete tools 仍按 feature group 分批评审,避免一次性迁移全部 IO 工具。 +- 可优化点:把 manifest/catalog 快照缓存边界显式化,避免迁移后每次 prompt + resolution 都重建 registry;保留现有 provider order 和 dynamic provider metadata order。 +- 必须新增或复用的保护:builtin tool list、provider group order、readonly/enabled + filtering、expanded/collapsed exposure、`GetToolSpec` duplicate-load/unlock state、 + snapshot wrapper、runtime restriction、cancellation 和 Deep Review tool flow regression。 +- 产品形态门禁:Desktop MCP catalog、ACP catalog、CLI agent tool surface、Deep Review + tool flow 必须继续使用同一行为矩阵;不得为了 tool owner 外移改变任何 surface command。 + +HR2:product-domain runtime deep owner migration 的主要风险和控制点: + +- 风险:MiniApp filesystem IO、worker process、host dispatch、builtin asset seed / + marker IO / recompile 都有外部副作用;迁移不当会改变用户数据目录、更新标记、 + rollback、dependency state 或编译/运行顺序。 +- 风险:function-agent Git/AI 调用涉及 provider acquisition、transport error mapping、 + prompt 输入、JSON extraction/repair 和 `analyzed_at` 时序;移动过深可能改变 commit + message、Startchat work-state 或非 Git workspace fallback。 +- 可优化点:优先抽 storage/process/Git/AI 的最小 port contract,让 + `product-domains` 拥有纯 orchestration,core 继续注入 PathManager、process runner、 + Git/AI adapter 和 asset source。 +- 可优化点:把 MiniApp import/sync/recompile/rollback/deps state 的快照基线作为 + 迁移前后对比入口;对 function-agent 保留 no-HEAD diff fallback、非 Git 空状态、 + `analyze_git=false` time-info 和 post-analysis `analyzed_at` 赋值语义。 +- 必须新增或复用的保护:MiniApp import/sync/recompile/rollback/deps state focused + tests、builtin seed marker round-trip、customized update metadata、function-agent + prompt/response policy、Git/AI adapter error mapping 和 Startchat work-state regression。 +- 产品形态门禁:Desktop MiniApp、server/remote workspace、CLI function-agent 路径和 + packaged built-in MiniApp asset 必须继续组装;不得改变 installer、desktop release 或 + user-data seed 产物。 + +HR3:service / agent runtime deep owner migration 的主要风险和控制点: + +- 风险:remote-SSH manager / remote FS / terminal 与 remote-connect workspace-root + source、response wrapping、`ImageContextData` concrete impl 都连接实际远端执行环境; + 迁移不当会破坏 remote workspace guard、terminal pre-warm、base64 response shape、 + image fallback 或 file chunk range 行为。 +- 风险:agent registry / scheduler 现在承载 mode-scoped subagent visibility、 + `Multitask` / `GeneralPurpose` registration、background result delivery、running-turn + injection 和 idle-session follow-up;迁移不当会改变 subagent 可见性、排队、确认边界 + 或 DeepResearch post-turn hook。 +- 可优化点:先把 scheduler/registry 的 observable facts、queue policy decision、 + runtime event fact 与 remote workspace identity 抽成只读 contract;concrete + execution、session restore、terminal binding 和 response wrapping 继续由 core adapter 注入。 +- 可优化点:对 remote-connect 保持 owner crate 只管 orchestration policy, + core 继续拥有 workspace-root source、response/base64 wrapper 和 concrete scheduler submit, + 直到有端到端 remote product regression。 +- 必须新增或复用的保护:remote command/response wire、restore -> terminal pre-warm -> + scheduler submit 顺序、file full/chunk/info、image context fallback/preference、 + mode-scoped subagent availability、background delivery、DeepResearch citation + renumber hook、queue/confirmation boundary 和 remote workspace startup guard regression。 +- 产品形态门禁:Desktop remote connect、relay/bot、server, ACP remote config reuse、 + CLI subagent management 和 Review Team 可见性必须继续按当前产品差异运行;不得为了 + service/agent owner 外移统一 surface presentation 或命令语义。 + +因此,当前文档口径下的剩余数量是: + +- 必需主线:0 个新增 runtime 迁移 PR;H4 本身完成后即可进入提交审查。 +- 可选评估:1 个 H5 PR。 +- 若继续追求更深的 owner 化:最多 3 个条件性高风险 runtime PR + 1 个 H5 PR。 + +不计入上述数量:缺陷修复、行为变更、冗余清理和构建脚本调整。它们必须独立评估, +不能伪装成 core decomposition 的剩余里程碑。 + 本节解释“为什么统计总是 4-5 个”:之前把低风险准备事项拆成 R1-R4,导致每次都看起来还剩 4 个小 PR;这些准备事项已统一为 `LR1` 并在 2026-05-19 闭环。默认回答必须是: 低风险准备已完成,随后进入高风险 runtime 迁移队列;`H5` 仍是独立可选评估项。 @@ -1795,8 +1925,9 @@ marker IO / recompile,以及 function-agent Git service / AI client 调用仍 必须单独选择字符串化 wrapper 或给 `core-types` 引入轻量 error 依赖,并先补兼容测试。 - 后续 shared DTO 不做批量移动;只能按单个 owner/DTO 在对应 PR 中确认依赖方向。 - `services-core` / `services-integrations` 的剩余 search、lsp、cron、snapshot、 - remote-connect dialog 与 remote-SSH runtime 均显式 deferred/core-owned;MCP runtime - 已完成的部分不再重复规划。 + remote-connect concrete scheduler/session restore/terminal adapter、workspace-root + source、response wrapping 与 remote-SSH runtime 均显式 deferred/core-owned;MCP + runtime 已完成的部分不再重复规划。 - `agent-tools` / `tool-packs` 只承载纯契约、provider metadata、generic/static/dynamic provider container 与 feature-group scaffold;`ToolUseContext`、runtime manifest assembly、`GetToolSpec` 执行、collapsed unlock state 和 concrete tools 进入 H1。 @@ -1913,10 +2044,10 @@ git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts 11. 已提交:PR 2 `Services/Product Runtime Owner Closure`,收口 remote-SSH session identity / mirror path / unresolved-session layout 与 MiniApp storage file layout owner;core 保留 `PathManager` 注入、SSH manager、remote FS / terminal、MiniApp filesystem IO 和 worker runtime。 12. 历史已完成:MCP runtime 与 dynamic tools;已迁移 config service orchestration、server process / transport lifecycle、adapter、dynamic tool/resource/prompt provider,core 保留 ConfigService store adapter、OAuth data-dir 注入、BitFunError 映射、legacy facade 和 product registry / manifest assembly。 13. P2 后前置轨道:产品表面 contract-only 补强,可在后续 PR 第一组提交中处理;只允许 DTO/port、round-trip/no-op tests 和 boundary check,不实现 CLI/Desktop/Remote/ACP UI 或命令变更。 -14. 已完成:remote-connect tracker / wire / pure policy owner slice:产品表面 DTO 已以 contract-only 方式进入 `bitfun-core-types`;`bitfun-services-integrations` 的 `remote-connect` feature 拥有 remote command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、remote chat/image/tool/session wire DTO、relay/bot session/submission request builder、remote image attachment/request DTO、tracker state / registry lifecycle、tracker event reduction、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy;relay/bot 创建 session 通过 `AgentSubmissionPort`,取消、远程状态读取和事件事实已有 `runtime-ports` 契约。远程消息执行、`ImageContextData` adapter、file IO/path resolution、terminal pre-warm 与 workspace/session restore 执行仍保留在 `bitfun-core` product runtime assembly。 +14. 已完成:remote-connect tracker / wire / pure policy owner slice:产品表面 DTO 已以 contract-only 方式进入 `bitfun-core-types`;`bitfun-services-integrations` 的 `remote-connect` feature 拥有 remote command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、remote chat/image/tool/session wire DTO、relay/bot session/submission request builder、remote image attachment/request DTO、tracker state / registry lifecycle、tracker event reduction、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy;relay/bot 创建 session 通过 `AgentSubmissionPort`,取消、远程状态读取和事件事实已有 `runtime-ports` 契约。H3 进一步把远程消息提交编排、terminal pre-warm decision、remote workspace file IO/path helper 与 image-context adapter contract 迁入 `bitfun-services-integrations` port/provider;concrete terminal adapter、workspace/session restore 执行、workspace-root source、response/base64 wrapping 与 `ImageContextData` concrete impl 仍保留在 `bitfun-core` product runtime assembly。 15. 已完成:agent tools + `tool-packs` owner 化低风险闭环;tool contract / DTO、runtime restriction、path resolution、portable context facts/provider、generic registry / static provider installation / dynamic provider container 已归属 `bitfun-agent-tools`,`tool-packs` 提供 feature-group scaffold 和 product provider group plan,core 保留 concrete tool materialization、product snapshot wrapper adapter、`ToolUseContext` 和 concrete tool implementation,后续外移需单独 service port/provider 设计。 16. 已完成:关键语义回归 baseline,不移动 runtime owner。覆盖 MCP config failure / catalog invalidation / 既有 list-changed helper / dynamic manifest、tool manifest / `GetToolSpec`、product-domains adapter equivalence、remote workspace search fallback 的 focused tests 或 snapshots。 -17. 已完成:remote-connect runtime 当前批次收口。已基于当前 port baseline 记录 remote command/response、remote model catalog、poll response、model catalog delta、session restore、active turn、cancel、image context、tracker event、queue/event fanout 的输入输出和验证命令;tracker state / registry lifecycle、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy 已迁入 `bitfun-services-integrations`。dispatcher / product execution、`ImageContextData` adapter、file IO/path resolution、terminal pre-warm 与 workspace/session restore 执行显式保留在 core-owned runtime;后续只有在另起 port/provider 设计且 focused regression 继续通过时才允许继续移动这些 runtime owner,不能把 generic attachment guard 当作已接入多模态行为。 +17. 已完成:remote-connect runtime 当前批次收口。已基于当前 port baseline 记录 remote command/response、remote model catalog、poll response、model catalog delta、session restore、active turn、cancel、image context、tracker event、queue/event fanout 与 dialog orchestration 顺序的输入输出和验证命令;tracker state / registry lifecycle、legacy image context fallback / preference、restore target decision、cancel decision、remote file transfer size/chunk/name policy、remote workspace file IO/path helper、image-context adapter contract 与 RemoteRelay/Bot dialog submission orchestration 已迁入 `bitfun-services-integrations`。dispatcher compatibility wrapper、product execution、workspace-root source、response/base64 wrapping、`ImageContextData` concrete impl、concrete terminal pre-warm adapter 与 workspace/session restore 执行显式保留在 core-owned runtime;后续只有在另起 port/provider 设计且 focused regression 继续通过时才允许继续移动这些 runtime owner,不能把 generic attachment guard 当作已接入多模态行为。 18. 已完成:`product-domains` runtime port/facade closure。已迁入 MiniApp storage-backed runtime-state facade、built-in seed plan / marker wire helper 与 function-agent Git/AI port-backed runtime facade,并补充 focused contract tests;core 只对 MiniApp deps/restart/recompile/sync/rollback 的状态持久化委托 facade,仍保留 `PathManager` 注入、filesystem IO、worker process execution、host dispatch 执行、built-in asset seeding/source-hash lookup。Git commit-message 与 Startchat work-state 产品路径已通过 core-owned Git/AI adapter 接入 function-agent facade;H2 已将 function-agent prompt template、AI response JSON extraction、JSON repair、domain error mapping 与 JSON-to-domain parsing policy 迁入 product-domain,core 继续保留 Git/AI service adapter、AI client 调用、provider acquisition 与 AI transport error mapping。Startchat 接线已用 no-HEAD diff fallback、非 Git 目录空状态和 `analyze_git=false` time-info 保护旧行为,`analyzed_at` 仍由 core 在 AI 分析完成后赋值。 19. 已合入:tool runtime owner 迁移前置基线。纯 helper 已从 runtime owner 中剥离到 `bitfun-agent-tools`:`StaticToolProviderGroup`、registry snapshot 到 manifest policy input、generic collapsed exposure 查询、`GetToolSpec` collapsed-load 纯收集规则、prompt-visible manifest definition 组装规则和 `GetToolSpec` catalog/detail helper;完整 collapsed 工具清单、runtime context 传递和 portable facts 边界回归已作为迁移前基线。 20. 已合入:core-owned tool runtime assembly closure。`ProductToolRuntimeAssembly` 作为 core 内部单一 owner 收敛 static provider 安装和 product snapshot wrapper adapter,并保持 legacy `create_tool_registry()`、global registry、dynamic MCP tools、manifest resolver、`GetToolSpecTool` 执行、`ToolUseContext` 和 concrete tools 的行为边界不变。 @@ -1932,7 +2063,8 @@ git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts 30. 已完成:H1 GetToolSpec Tool adapter facade slice。`bitfun-agent-tools::GetToolSpecRuntime::call_results` 接管单次执行结果到 `Vec` 的通用适配形状,core `catalog_provider.rs` 暴露 product `resolve_product_get_tool_spec_results`,`GetToolSpecTool::call_impl` 只保留 product facade 委托和 `BitFunError` 映射;不迁移 `ToolUseContext`、runtime manifest assembly、unlock state owner、assistant rendering 语义或 concrete tools。 31. 已完成:H1 product provider plan closure。`bitfun-tool-packs::product_tool_provider_group_plan` 接管 product provider group id / feature group / tool-name order 计划,core `static_providers.rs` 只按该计划物化 concrete tools 并继续注入 snapshot wrapper;不迁移 concrete tool implementation、`ToolUseContext`、runtime service handles 或 tool behavior。 32. H1 闭环状态:工具 runtime 的 provider-neutral contract、manifest/catalog runtime facade、GetToolSpec facade、static-provider assembly、readonly filtering、provider plan 与 core product adapter 已收敛。`ToolUseContext` 本体和 concrete tools 仍显式 core-owned;继续外移会触碰 workspace services、cancellation、Deep Review hooks 或具体工具 IO,必须作为后续高风险 owner 迁移单独确认。 -33. 后续独立评估:`bitfun-core default = []`、per-product feature set、依赖版本收敛或构建收益优化;任何收益声明都需要记录 `cargo check -p bitfun-core`、workspace check 和目标 crate check 的前后数据。 +33. H4 已完成:facade / boundary finalization。`scripts/check-core-boundaries.mjs` 的 regular check 和 self-test 已覆盖 remote-connect file/image/dialog owner anchor、core adapter/deferred owner anchor 与既有 duplicate-path required rule;root / core / services-integrations 文档与当前 H1-H3 代码状态一致,不声明 remote-SSH runtime、agent registry/scheduler、default feature 或构建收益已完成。 +34. 后续独立评估:`bitfun-core default = []`、per-product feature set、依赖版本收敛或构建收益优化;任何收益声明都需要记录 `cargo check -p bitfun-core`、workspace check 和目标 crate check 的前后数据。 冗余清理 PR 不进入上述主线序号。只有在满足 `0A.6` 的绝对等价要求时,才可以插入到相邻里程碑之间,并且不得与主线拆分 PR 混合。 diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index 0b686659cf..969f1b1211 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -929,6 +929,10 @@ const forbiddenContentRules = [ regex: /\bfn resolve_remote_execution_image_contexts\b/, message: 'core remote-connect server must not own image-context preference policy; use the integrations helper', }, + { + regex: /\btrait RemoteImageContextAdapter\b/, + message: 'core remote-connect server must not own image-context adapter contracts; use the integrations contract', + }, { regex: /\bconst MAX_SIZE\b/, message: 'core remote-connect server must not own remote file max-read policy; use the integrations helper', @@ -941,6 +945,30 @@ const forbiddenContentRules = [ regex: /unwrap_or\("file"\)/, message: 'core remote-connect server must not own remote file display-name fallback; use the integrations helper', }, + { + regex: /\bresolve_workspace_path\b/, + message: 'core remote-connect server must not own workspace file path resolution; use the integrations helper', + }, + { + regex: /\bdetect_mime_type\b/, + message: 'core remote-connect server must not own workspace file MIME detection; use the integrations helper', + }, + { + regex: /\bread_workspace_file\b/, + message: 'core remote-connect server must not own workspace file read helpers; use the integrations helper', + }, + { + regex: /\bfn read_remote_workspace_file\b/, + message: 'core remote-connect server must not redefine remote workspace full-file readers; use the integrations helper', + }, + { + regex: /\bfn read_remote_workspace_file_chunk\b/, + message: 'core remote-connect server must not redefine remote workspace chunk readers; use the integrations helper', + }, + { + regex: /\bfn read_remote_workspace_file_info\b/, + message: 'core remote-connect server must not redefine remote workspace file-info readers; use the integrations helper', + }, { regex: /\bfn should_send_remote_model_catalog\b/, message: 'core remote-connect server must not own poll model-catalog policy; use the integrations helper', @@ -963,6 +991,27 @@ const forbiddenContentRules = [ }, ], }, + { + path: 'src/crates/core/src/service/remote_connect/bot/mod.rs', + patterns: [ + { + regex: /\bfn strip_workspace_path_prefix\b/, + message: 'core remote-connect bot facade must not own workspace path prefix stripping; use the integrations helper', + }, + { + regex: /\bfn is_absolute_workspace_path\b/, + message: 'core remote-connect bot facade must not own workspace path absolute detection; use the integrations helper', + }, + { + regex: /\bmatch ext\.as_str\(\)/, + message: 'core remote-connect bot facade must not own workspace file MIME mapping; use the integrations helper', + }, + { + regex: /\btokio::fs::read\(&abs_path\)/, + message: 'core remote-connect bot facade must not own workspace file reads; use the integrations helper', + }, + ], + }, { path: 'src/crates/core/src/service/announcement/state_store.rs', patterns: [ @@ -1374,6 +1423,313 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/crates/services-integrations/src/remote_connect.rs', + reason: + 'services-integrations must own remote-connect wire, tracker, dialog, file, and image adapter contracts', + patterns: [ + { + regex: /\bpub struct RemoteSessionStateTracker\b/, + message: 'missing remote session state tracker owner', + }, + { + regex: /\bpub enum TrackerEvent\b/, + message: 'missing remote tracker event owner', + }, + { + regex: /\bpub trait RemoteSessionTrackerHost\b/, + message: 'missing remote tracker host port', + }, + { + regex: /\bpub struct RemoteSessionTrackerRegistry\b/, + message: 'missing remote tracker registry owner', + }, + { + regex: /\bpub fn make_slim_tool_params\b/, + message: 'missing remote tool preview slimming helper', + }, + { + regex: /\bfn handle_agentic_event\b/, + message: 'missing tracker event reducer', + }, + { + regex: /\bpub fn resolve_remote_agent_type\b/, + message: 'missing remote agent type helper', + }, + { + regex: /\bpub struct RemoteImageContext\b/, + message: 'missing portable remote image context contract', + }, + { + regex: /\bpub trait RemoteImageContextAdapter\b/, + message: 'missing remote image context adapter contract', + }, + { + regex: /\bpub fn build_remote_image_contexts\b/, + message: 'missing legacy remote image context builder', + }, + { + regex: /\bpub fn resolve_remote_execution_image_contexts\b/, + message: 'missing remote image context preference helper', + }, + { + regex: /\bpub fn remote_session_restore_target\b/, + message: 'missing remote restore-target helper', + }, + { + regex: /\bpub enum RemoteCancelDecision\b/, + message: 'missing remote cancel decision contract', + }, + { + regex: /\bpub fn resolve_remote_cancel_decision\b/, + message: 'missing remote cancel decision resolver', + }, + { + regex: /\bpub trait RemoteDialogRuntimeHost\b/, + message: 'missing remote dialog runtime host port', + }, + { + regex: /\bpub async fn submit_remote_dialog\b/, + message: 'missing remote dialog orchestration owner', + }, + { + regex: /\bpub const REMOTE_FILE_MAX_READ_BYTES\b/, + message: 'missing remote file max-read policy', + }, + { + regex: /\bpub const REMOTE_FILE_MAX_CHUNK_BYTES\b/, + message: 'missing remote file chunk policy', + }, + { + regex: /\bpub fn resolve_remote_file_chunk_range\b/, + message: 'missing remote file chunk range helper', + }, + { + regex: /\bpub fn remote_file_display_name\b/, + message: 'missing remote file display-name fallback', + }, + { + regex: /\bpub fn resolve_remote_workspace_path\b/, + message: 'missing remote workspace path resolver', + }, + { + regex: /\bpub fn detect_remote_mime_type\b/, + message: 'missing remote MIME detector', + }, + { + regex: /\bpub async fn read_remote_workspace_file\b/, + message: 'missing remote workspace full-file reader', + }, + { + regex: /\bpub async fn read_remote_workspace_file_chunk\b/, + message: 'missing remote workspace chunk reader', + }, + { + regex: /\bpub async fn read_remote_workspace_file_info\b/, + message: 'missing remote workspace file-info reader', + }, + { + regex: /\bpub struct RemoteDefaultModelsConfig\b/, + message: 'missing remote model default DTO', + }, + { + regex: /\bpub struct RemoteModelConfig\b/, + message: 'missing remote model DTO', + }, + { + regex: /\bpub struct RemoteModelCatalog\b/, + message: 'missing remote model catalog DTO', + }, + { + regex: /\bpub struct RemoteModelCatalogPollDelta\b/, + message: 'missing remote model catalog poll delta', + }, + { + regex: /\bpub enum RemoteCommand\b/, + message: 'missing remote command wire contract', + }, + { + regex: /\bpub enum RemoteResponse\b/, + message: 'missing remote response wire contract', + }, + { + regex: /\bpub fn should_send_remote_model_catalog\b/, + message: 'missing remote model catalog poll policy', + }, + { + regex: /\bpub fn remote_model_catalog_poll_delta\b/, + message: 'missing remote model catalog poll delta helper', + }, + { + regex: /\bpub fn remote_no_change_poll_response\b/, + message: 'missing remote no-change poll response helper', + }, + { + regex: /\bpub fn remote_snapshot_poll_response\b/, + message: 'missing remote snapshot poll response helper', + }, + { + regex: /\bpub fn remote_persisted_poll_response\b/, + message: 'missing remote persisted poll response helper', + }, + ], + }, + { + path: 'src/crates/services-integrations/tests/remote_connect_contracts.rs', + reason: 'remote-connect owner crate must keep focused behavior contracts', + patterns: [ + { + regex: /\bremote_connect_command_wire_shape_lives_in_owner_contract\b/, + message: 'missing remote command wire contract test', + }, + { + regex: /\bremote_connect_response_wire_shape_lives_in_owner_contract\b/, + message: 'missing remote response wire contract test', + }, + { + regex: /\bremote_connect_model_catalog_delta_preserves_poll_invalidation_policy\b/, + message: 'missing remote model catalog delta contract test', + }, + { + regex: /\bremote_connect_poll_helpers_preserve_delta_and_completion_policy\b/, + message: 'missing remote poll helper contract test', + }, + { + regex: /\bremote_connect_image_context_policy_preserves_legacy_fallback_shape\b/, + message: 'missing legacy image context fallback test', + }, + { + regex: /\bremote_connect_image_context_policy_prefers_explicit_contexts\b/, + message: 'missing explicit image context preference test', + }, + { + regex: /\bremote_connect_image_context_adapter_owns_portable_conversion_shape\b/, + message: 'missing image context adapter contract test', + }, + { + regex: /\bremote_connect_cancel_and_restore_policy_preserve_runtime_decisions\b/, + message: 'missing cancel/restore policy test', + }, + { + regex: /\bremote_connect_dialog_runtime_owns_restore_prewarm_and_submit_order\b/, + message: 'missing dialog runtime order test', + }, + { + regex: /\bremote_connect_dialog_runtime_preserves_explicit_turn_without_restore\b/, + message: 'missing dialog explicit-turn test', + }, + { + regex: /\bremote_connect_dialog_runtime_keeps_legacy_restore_failure_tolerance\b/, + message: 'missing restore failure tolerance test', + }, + { + regex: /\bremote_connect_file_transfer_policy_preserves_limits_and_chunk_ranges\b/, + message: 'missing remote file transfer policy test', + }, + { + regex: /\bremote_connect_file_transfer_policy_preserves_name_fallback\b/, + message: 'missing remote file display-name test', + }, + { + regex: /\bremote_connect_file_path_resolution_stays_within_workspace_root\b/, + message: 'missing remote file path resolution test', + }, + { + regex: /\bremote_connect_file_read_helpers_preserve_current_wire_inputs\b/, + message: 'missing remote full-read helper test', + }, + { + regex: /\bremote_connect_file_chunk_and_info_helpers_preserve_response_facts\b/, + message: 'missing remote chunk/info helper test', + }, + { + regex: /\bremote_connect_tracker_keeps_finished_turn_snapshot_until_persistence_finalizes\b/, + message: 'missing tracker completion contract test', + }, + { + regex: /\bremote_connect_tracker_registry_owns_lifecycle_without_core_state\b/, + message: 'missing tracker registry owner test', + }, + { + regex: /\bremote_connect_tracker_ignores_unrelated_direct_session_events\b/, + message: 'missing tracker unrelated-event guard test', + }, + { + regex: /\bremote_connect_tool_preview_slimming_keeps_short_fields_and_drops_large_strings\b/, + message: 'missing remote tool preview slimming test', + }, + ], + }, + { + path: 'src/crates/core/src/service/remote_connect/remote_server.rs', + reason: + 'core remote-connect server must remain a product runtime adapter around integrations-owned contracts', + patterns: [ + { + regex: /\bstruct CoreRemoteDialogRuntimeHost\b/, + message: 'missing core remote dialog runtime adapter', + }, + { + regex: /\bimpl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost\b/, + message: 'missing integrations dialog host adapter implementation', + }, + { + regex: /\bsubmit_remote_dialog\b/, + message: 'missing remote dialog owner orchestration delegation', + }, + { + regex: /\bread_remote_workspace_file\b/, + message: 'missing remote file full-read helper delegation', + }, + { + regex: /\bread_remote_workspace_file_chunk\b/, + message: 'missing remote file chunk helper delegation', + }, + { + regex: /\bread_remote_workspace_file_info\b/, + message: 'missing remote file info helper delegation', + }, + { + regex: /\bImageContextData::from_remote_image_context\b/, + message: 'missing image context adapter contract delegation', + }, + { + regex: /\bremote_execution_prefers_unified_image_contexts_over_legacy_images\b/, + message: 'missing unified image context preference regression', + }, + { + regex: /\bremote_execution_falls_back_to_legacy_images_as_image_contexts\b/, + message: 'missing legacy image context fallback regression', + }, + { + regex: /\bremote_cancel_decision_preserves_current_turn_boundaries\b/, + message: 'missing remote cancel boundary regression', + }, + { + regex: /\bremote_restore_target_only_restores_cold_sessions_with_workspace_binding\b/, + message: 'missing remote restore target regression', + }, + { + regex: /\bremote_command_snapshot_covers_execution_poll_and_cancel_surfaces\b/, + message: 'missing remote command snapshot regression', + }, + { + regex: /\bremote_response_snapshot_preserves_active_turn_and_result_shapes\b/, + message: 'missing remote response snapshot regression', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/coordination/scheduler.rs', + reason: + 'core scheduler keeps remote queue policy semantics until agent-runtime migration is reviewed', + patterns: [ + { + regex: /\bremote_queue_policy_preserves_interactive_preempt_and_confirmation_boundary\b/, + message: 'missing remote queue policy regression', + }, + ], + }, { path: 'src/crates/core/src/agentic/tools/registry.rs', reason: @@ -4025,13 +4381,16 @@ function runManifestParserSelfTest() { }, ]; for (const { path, contracts } of requiredContentContracts) { - const rule = requiredContentRules.find((rule) => rule.path === path); - if (!rule) { + const matchingRules = requiredContentRules.filter((rule) => rule.path === path); + if (matchingRules.length === 0) { throw new Error(`missing owner content anchor rule for ${path}`); } - const ruleText = rule.patterns.map((pattern) => pattern.regex.source).join('\n'); + const ruleText = matchingRules + .flatMap((rule) => rule.patterns) + .map((pattern) => pattern.regex.source) + .join('\n'); for (const contract of contracts) { - if (!ruleText.includes(contract)) { + if (!ruleText.includes(contract) && !ruleText.includes(escapeRegex(contract))) { throw new Error(`owner content anchor rule for ${path} must require: ${contract}`); } } @@ -4376,9 +4735,16 @@ function runManifestParserSelfTest() { 'resolve_remote_cancel_decision', 'remote_session_restore_target', 'resolve_remote_execution_image_contexts', + 'RemoteImageContextAdapter', 'MAX_SIZE', 'MAX_CHUNK', 'unwrap_or\\("file"\\)', + 'resolve_workspace_path', + 'detect_mime_type', + 'read_workspace_file', + 'read_remote_workspace_file', + 'read_remote_workspace_file_chunk', + 'read_remote_workspace_file_info', 'should_send_remote_model_catalog', 'remote_model_catalog_poll_delta', 'remote_no_change_poll_response', diff --git a/src/crates/core/AGENTS-CN.md b/src/crates/core/AGENTS-CN.md index cbde21600f..d319ef4a79 100644 --- a/src/crates/core/AGENTS-CN.md +++ b/src/crates/core/AGENTS-CN.md @@ -35,13 +35,18 @@ SessionManager → Session → DialogTurn → ModelRound - 不要把 OpenAI Responses / Codex ChatGPT flat tool schema 等 provider-specific 序列化行为写进 core tool contract;AI adapter 负责 provider 序列化,core 保持 provider-neutral manifest。 - 调整 session/token usage 路径时,`cached_content_token_count` 必须继续表示 cache reads/hits,`cache_creation_token_count` 必须作为独立 provider fact 保留。 - Function-agent commit-message 与 Startchat work-state orchestration 可以经由 - `bitfun-product-domains`;Git/AI service adapter、prompt template、JSON - extraction 和 error mapping 仍由 core 拥有。JSON extraction 之后的 JSON string - parser 可以放在 `bitfun-product-domains`。 + `bitfun-product-domains`;Git/AI service adapter、provider 获取、AI client + 调用和 transport error mapping 仍由 core 拥有。prompt template、JSON + extraction/repair、domain error mapping 与 domain JSON parsing policy 可以放在 + `bitfun-product-domains`。 - MiniApp built-in bundle/hash/marker seed plan 与 marker wire helper 可以放在 `bitfun-product-domains`;bundled asset include、filesystem writes、marker IO、 customization metadata IO、recompile orchestration、worker process runtime 和 host dispatch execution 仍由 core 拥有,直到有评审过的迁移和等价测试。 +- Remote-connect wire/tracker/dialog orchestration 与 portable file/image contract + 可以放在 `bitfun-services-integrations`;workspace-root source selection、 + response wrapping、concrete scheduler/session restore、terminal pre-warm adapter + 和 product execution 仍由 core 拥有,直到有评审过的迁移和等价测试。 - 不要在没有小型 port/interface 边界的情况下新增 `service` 到 `agentic` 的跨层引用。 - 不要在 core 拆解中把平台专属逻辑、构建脚本行为或产品能力选择下沉到 shared core。 diff --git a/src/crates/core/AGENTS.md b/src/crates/core/AGENTS.md index 4747bd07a6..5d785571d1 100644 --- a/src/crates/core/AGENTS.md +++ b/src/crates/core/AGENTS.md @@ -62,14 +62,20 @@ SessionManager → Session → DialogTurn → ModelRound as cache reads/hits and `cache_creation_token_count` as a separate provider fact. - Function-agent commit-message and Startchat work-state orchestration may - route through `bitfun-product-domains`; keep Git/AI service adapters, prompt - templates, JSON extraction, and error mapping core-owned. JSON-string parsing - helpers may live in `bitfun-product-domains` after extraction has happened. + route through `bitfun-product-domains`. Keep Git/AI service adapters, + provider acquisition, AI client calls, and transport error mapping core-owned; + prompt templates, JSON extraction/repair, domain error mapping, and domain + JSON parsing policy may live in `bitfun-product-domains`. - MiniApp built-in bundle/hash/marker seed-plan and marker wire helpers may live in `bitfun-product-domains`; keep bundled asset includes, filesystem writes, marker IO, customization metadata IO, recompile orchestration, worker process runtime, and host dispatch execution core-owned until a reviewed migration proves equivalence. +- Remote-connect wire/tracker/dialog orchestration and portable file/image + contracts may live in `bitfun-services-integrations`; keep workspace-root + source selection, response wrapping, concrete scheduler/session restore, + terminal pre-warm adapters, and product execution core-owned until a reviewed + migration proves equivalence. - Do not add new cross-layer references from `service` to `agentic` without a small port/interface boundary. - Do not move platform-specific logic, build-script behavior, or product diff --git a/src/crates/core/src/service/remote_connect/bot/command_router.rs b/src/crates/core/src/service/remote_connect/bot/command_router.rs index 7b155c618b..97466cbc1e 100644 --- a/src/crates/core/src/service/remote_connect/bot/command_router.rs +++ b/src/crates/core/src/service/remote_connect/bot/command_router.rs @@ -2060,10 +2060,10 @@ pub async fn execute_forwarded_turn( message_sender: Option, verbose_mode: bool, ) -> ForwardedTurnResult { - use crate::agentic::coordination::{DialogSubmissionPolicy, DialogTriggerSource}; use crate::service::remote_connect::remote_server::{ get_or_init_global_dispatcher, TrackerEvent, }; + use bitfun_services_integrations::remote_connect::RemoteConnectSubmissionSource; let language = current_bot_language().await; let s = strings_for(language); @@ -2080,7 +2080,7 @@ pub async fn execute_forwarded_turn( forward.content, Some(&forward.agent_type), forward.image_contexts, - DialogSubmissionPolicy::for_source(DialogTriggerSource::Bot), + RemoteConnectSubmissionSource::Bot, Some(forward.turn_id.clone()), ) .await diff --git a/src/crates/core/src/service/remote_connect/bot/mod.rs b/src/crates/core/src/service/remote_connect/bot/mod.rs index a4ac0eb05c..cf3fef04d6 100644 --- a/src/crates/core/src/service/remote_connect/bot/mod.rs +++ b/src/crates/core/src/service/remote_connect/bot/mod.rs @@ -114,16 +114,6 @@ pub struct WorkspaceFileContent { pub size: u64, } -fn strip_workspace_path_prefix(raw: &str) -> &str { - raw.strip_prefix("computer://") - .or_else(|| raw.strip_prefix("file://")) - .unwrap_or(raw) -} - -fn is_absolute_workspace_path(path: &str) -> bool { - path.starts_with('/') || (path.len() >= 3 && path.as_bytes()[1] == b':') -} - /// Resolve a raw path (with or without `computer://` / `file://` prefix) to an /// absolute `PathBuf`. /// @@ -134,57 +124,12 @@ pub fn resolve_workspace_path( raw: &str, workspace_root: Option<&std::path::Path>, ) -> Option { - let stripped = strip_workspace_path_prefix(raw); - - if is_absolute_workspace_path(stripped) { - return Some(std::path::PathBuf::from(stripped)); - } - - let workspace_root = workspace_root?; - let canonical_root = std::fs::canonicalize(workspace_root).ok()?; - let candidate = canonical_root.join(stripped); - let canonical_candidate = std::fs::canonicalize(candidate).ok()?; - - if canonical_candidate.starts_with(&canonical_root) { - Some(canonical_candidate) - } else { - None - } + bitfun_services_integrations::remote_connect::resolve_remote_workspace_path(raw, workspace_root) } /// Return the best-effort MIME type for a file based on its extension. pub fn detect_mime_type(path: &std::path::Path) -> &'static str { - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - - match ext.as_str() { - "txt" | "log" => "text/plain", - "md" => "text/markdown", - "html" | "htm" => "text/html", - "css" => "text/css", - "js" | "mjs" => "text/javascript", - "ts" | "tsx" | "jsx" | "rs" | "py" | "go" | "java" | "c" | "cpp" | "h" | "sh" | "toml" - | "yaml" | "yml" => "text/plain", - "json" => "application/json", - "xml" => "application/xml", - "csv" => "text/csv", - "pdf" => "application/pdf", - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "gif" => "image/gif", - "webp" => "image/webp", - "svg" => "image/svg+xml", - "zip" => "application/zip", - "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", - "mp4" => "video/mp4", - "opus" => "audio/opus", - _ => "application/octet-stream", - } + bitfun_services_integrations::remote_connect::detect_remote_mime_type(path) } /// Read a workspace file, resolving `computer://` prefixes. @@ -199,48 +144,19 @@ pub async fn read_workspace_file( max_size: u64, workspace_root: Option<&std::path::Path>, ) -> anyhow::Result { - let abs_path = resolve_workspace_path(raw_path, workspace_root) - .ok_or_else(|| anyhow::anyhow!("Remote file path could not be resolved: {raw_path}"))?; - - if !abs_path.exists() { - return Err(anyhow::anyhow!("File not found: {}", abs_path.display())); - } - if !abs_path.is_file() { - return Err(anyhow::anyhow!( - "Path is not a regular file: {}", - abs_path.display() - )); - } - - let metadata = tokio::fs::metadata(&abs_path).await.map_err(|e| { - anyhow::anyhow!("Cannot read file metadata for {}: {e}", abs_path.display()) - })?; - - if metadata.len() > max_size { - return Err(anyhow::anyhow!( - "File too large ({} bytes, limit {max_size} bytes): {}", - metadata.len(), - abs_path.display() - )); - } - - let bytes = tokio::fs::read(&abs_path) - .await - .map_err(|e| anyhow::anyhow!("Cannot read file {}: {e}", abs_path.display()))?; - - let name = abs_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("file") - .to_string(); - - let mime_type = detect_mime_type(&abs_path); + let content = bitfun_services_integrations::remote_connect::read_remote_workspace_file( + raw_path, + max_size, + workspace_root, + ) + .await + .map_err(anyhow::Error::msg)?; Ok(WorkspaceFileContent { - name, - bytes, - mime_type, - size: metadata.len(), + name: content.name, + bytes: content.bytes, + mime_type: content.mime_type, + size: content.size, }) } diff --git a/src/crates/core/src/service/remote_connect/remote_server.rs b/src/crates/core/src/service/remote_connect/remote_server.rs index aab62d8e6c..6e31fbaf58 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -15,12 +15,15 @@ use std::sync::{Arc, OnceLock}; use super::encryption; use bitfun_services_integrations::remote_connect::{ - build_remote_image_contexts, remote_file_display_name, remote_model_catalog_poll_delta, - remote_no_change_poll_response, remote_persisted_poll_response, remote_session_restore_target, - remote_snapshot_poll_response, resolve_remote_cancel_decision, - resolve_remote_execution_image_contexts, resolve_remote_file_chunk_range, RemoteCancelDecision, - RemoteImageContext, RemoteSessionTrackerHost, RemoteSessionTrackerRegistry, - REMOTE_FILE_MAX_READ_BYTES, + build_remote_image_contexts, read_remote_workspace_file, read_remote_workspace_file_chunk, + read_remote_workspace_file_info, remote_model_catalog_poll_delta, + remote_no_change_poll_response, remote_persisted_poll_response, remote_snapshot_poll_response, + resolve_remote_cancel_decision, resolve_remote_execution_image_contexts, submit_remote_dialog, + RemoteCancelDecision, RemoteConnectSubmissionSource, RemoteDialogQueuePriority, + RemoteDialogResolvedSubmission, RemoteDialogRuntimeHost, RemoteDialogSubmissionPolicy, + RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, RemoteImageContext, + RemoteImageContextAdapter, RemoteSessionTrackerHost, RemoteSessionTrackerRegistry, + RemoteTerminalPrewarmRequest, REMOTE_FILE_MAX_READ_BYTES, }; pub use bitfun_services_integrations::remote_connect::{ ActiveTurnSnapshot, AssistantEntry, ChatImageAttachment, ChatMessage, ChatMessageItem, @@ -503,12 +506,18 @@ fn build_core_image_contexts( fn remote_image_context_to_core( context: RemoteImageContext, ) -> crate::agentic::image_analysis::ImageContextData { - crate::agentic::image_analysis::ImageContextData { - id: context.id, - image_path: context.image_path, - data_url: context.data_url, - mime_type: context.mime_type, - metadata: context.metadata, + crate::agentic::image_analysis::ImageContextData::from_remote_image_context(context) +} + +impl RemoteImageContextAdapter for crate::agentic::image_analysis::ImageContextData { + fn from_remote_image_context(context: RemoteImageContext) -> Self { + Self { + id: context.id, + image_path: context.image_path, + data_url: context.data_url, + mime_type: context.mime_type, + metadata: context.metadata, + } } } @@ -562,6 +571,174 @@ impl RemoteSessionTrackerHost for CoreRemoteSessionTrackerHost { } } +struct CoreRemoteDialogRuntimeHost<'a> { + dispatcher: &'a RemoteExecutionDispatcher, + coordinator: Arc, + scheduler: Arc, +} + +impl<'a> CoreRemoteDialogRuntimeHost<'a> { + fn new(dispatcher: &'a RemoteExecutionDispatcher) -> std::result::Result { + use crate::agentic::coordination::{get_global_coordinator, get_global_scheduler}; + + let coordinator = get_global_coordinator() + .ok_or_else(|| "Desktop session system not ready".to_string())?; + let scheduler = get_global_scheduler() + .ok_or_else(|| "Dialog scheduler is not initialized".to_string())?; + + Ok(Self { + dispatcher, + coordinator, + scheduler, + }) + } +} + +fn core_dialog_submission_policy( + policy: RemoteDialogSubmissionPolicy, +) -> crate::agentic::coordination::DialogSubmissionPolicy { + use crate::agentic::coordination::{ + DialogQueuePriority, DialogSubmissionPolicy, DialogTriggerSource, + }; + + let trigger_source = match policy.source { + RemoteConnectSubmissionSource::Relay => DialogTriggerSource::RemoteRelay, + RemoteConnectSubmissionSource::Bot => DialogTriggerSource::Bot, + }; + let queue_priority = match policy.queue_priority { + RemoteDialogQueuePriority::Low => DialogQueuePriority::Low, + RemoteDialogQueuePriority::Normal => DialogQueuePriority::Normal, + RemoteDialogQueuePriority::High => DialogQueuePriority::High, + }; + + DialogSubmissionPolicy::new( + trigger_source, + queue_priority, + policy.skip_tool_confirmation, + ) +} + +#[async_trait::async_trait] +impl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost<'_> { + type ImageContext = crate::agentic::image_analysis::ImageContextData; + + fn ensure_tracker(&self, session_id: &str) { + self.dispatcher.ensure_tracker(session_id); + } + + async fn resolve_binding_workspace(&self, session_id: &str) -> Option { + self.coordinator + .resolve_session_workspace_path(session_id) + .await + .map(|path| path.to_string_lossy().into_owned()) + } + + async fn remote_session_exists(&self, session_id: &str) -> std::result::Result { + Ok(self + .coordinator + .get_session_manager() + .get_session(session_id) + .is_some()) + } + + async fn restore_remote_session( + &self, + session_id: &str, + workspace_path: &str, + ) -> std::result::Result<(), String> { + self.coordinator + .restore_session(std::path::Path::new(workspace_path), session_id) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) + } + + fn prewarm_remote_terminal(&self, request: RemoteTerminalPrewarmRequest) { + use terminal_core::session::SessionSource; + use terminal_core::{TerminalApi, TerminalBindingOptions}; + + let sid = request.session_id; + let binding_workspace_for_terminal = request.binding_workspace; + tokio::spawn(async move { + let Ok(api) = TerminalApi::from_singleton() else { + return; + }; + let binding = api.session_manager().binding(); + if binding.get(&sid).is_some() { + return; + } + let workspace = binding_workspace_for_terminal; + let name = format!("Chat-{}", &sid[..8.min(sid.len())]); + match binding + .get_or_create( + &sid, + TerminalBindingOptions { + working_directory: workspace, + session_id: Some(sid.clone()), + session_name: Some(name), + env: Some( + crate::agentic::tools::implementations::bash_tool::BashTool::noninteractive_env(), + ), + source: Some(SessionSource::Agent), + ..Default::default() + }, + ) + .await + { + Ok(_) => info!("Terminal pre-warmed for remote session {sid}"), + Err(e) => debug!("Terminal pre-warm skipped for {sid}: {e}"), + } + }); + } + + fn generate_turn_id(&self) -> String { + format!("turn_{}", chrono::Utc::now().timestamp_millis()) + } + + async fn submit_dialog( + &self, + submission: RemoteDialogResolvedSubmission, + ) -> std::result::Result { + let image_payload = if submission.image_contexts.is_empty() { + None + } else { + Some(submission.image_contexts) + }; + let policy = core_dialog_submission_policy(submission.policy); + + self.scheduler + .submit( + submission.session_id, + submission.content, + None, + Some(submission.turn_id), + submission.resolved_agent_type, + submission.binding_workspace, + policy, + None, + None, + image_payload, + ) + .await + .map(|outcome| match outcome { + crate::agentic::coordination::DialogSubmitOutcome::Started { + session_id, + turn_id, + } => RemoteDialogSubmitOutcome::Started { + session_id, + turn_id, + }, + crate::agentic::coordination::DialogSubmitOutcome::Queued { + session_id, + turn_id, + } => RemoteDialogSubmitOutcome::Queued { + session_id, + turn_id, + }, + }) + } +} + // ── RemoteExecutionDispatcher (global singleton) ──────────────────── /// Shared dispatch layer that owns the session state trackers. @@ -610,10 +787,12 @@ impl RemoteExecutionDispatcher { .remove_tracker_with_host(session_id, &CoreRemoteSessionTrackerHost); } - /// Dispatch a SendMessage command: ensure tracker, restore session, submit via - /// [`DialogScheduler`](crate::agentic::coordination::DialogScheduler) (same as desktop). + /// Dispatch a SendMessage command through the remote-connect runtime owner. + /// + /// `bitfun-services-integrations` owns the orchestration order; core supplies + /// the concrete tracker, session restore, terminal, and scheduler adapters. /// When the session is already processing, the message is queued and the current turn - /// may yield after the current model round (for interactive `submission_policy` sources). + /// may yield after the current model round for interactive remote sources. /// Returns whether this message started immediately or was only queued, plus ids. /// If `turn_id` is `None`, one is auto-generated before queueing. /// @@ -624,103 +803,23 @@ impl RemoteExecutionDispatcher { content: String, agent_type: Option<&str>, image_contexts: Vec, - submission_policy: crate::agentic::coordination::DialogSubmissionPolicy, + source: RemoteConnectSubmissionSource, turn_id: Option, - ) -> std::result::Result { - use crate::agentic::coordination::{get_global_coordinator, get_global_scheduler}; - - let coordinator = get_global_coordinator() - .ok_or_else(|| "Desktop session system not ready".to_string())?; - - let scheduler = get_global_scheduler() - .ok_or_else(|| "Dialog scheduler is not initialized".to_string())?; - - self.ensure_tracker(session_id); - - let session_mgr = coordinator.get_session_manager(); - let binding_workspace = resolve_session_workspace_path(session_id) - .await - .map(|path| path.to_string_lossy().into_owned()); - - if let Some(workspace_path) = remote_session_restore_target( - session_mgr.get_session(session_id).is_some(), - binding_workspace.as_deref(), - ) { - let _ = coordinator - .restore_session(std::path::Path::new(workspace_path), session_id) - .await - .ok(); - } + ) -> std::result::Result { + let host = CoreRemoteDialogRuntimeHost::new(self)?; - // Pre-warm the terminal so shell integration is ready before BashTool runs. - // Bot/remote sessions have no Terminal panel to pre-create the session, so the - // AI model's processing time (typically 5-15 s) gives shell integration a head - // start. When BashTool eventually calls get_or_create, the binding already - // exists and the 30-second readiness wait is skipped entirely. - { - use terminal_core::session::SessionSource; - use terminal_core::{TerminalApi, TerminalBindingOptions}; - let sid = session_id.to_string(); - let binding_workspace_for_terminal = binding_workspace.clone(); - tokio::spawn(async move { - let Ok(api) = TerminalApi::from_singleton() else { - return; - }; - let binding = api.session_manager().binding(); - if binding.get(&sid).is_some() { - return; - } - let workspace = binding_workspace_for_terminal.clone(); - let name = format!("Chat-{}", &sid[..8.min(sid.len())]); - match binding - .get_or_create( - &sid, - TerminalBindingOptions { - working_directory: workspace, - session_id: Some(sid.clone()), - session_name: Some(name), - env: Some( - crate::agentic::tools::implementations::bash_tool::BashTool::noninteractive_env(), - ), - source: Some(SessionSource::Agent), - ..Default::default() - }, - ) - .await - { - Ok(_) => info!("Terminal pre-warmed for remote session {sid}"), - Err(e) => debug!("Terminal pre-warm skipped for {sid}: {e}"), - } - }); - } - - let resolved_agent_type = agent_type - .map(|t| resolve_agent_type(Some(t)).to_string()) - .unwrap_or_else(|| "agentic".to_string()); - - let turn_id = - turn_id.unwrap_or_else(|| format!("turn_{}", chrono::Utc::now().timestamp_millis())); - - let image_payload = if image_contexts.is_empty() { - None - } else { - Some(image_contexts) - }; - - scheduler - .submit( - session_id.to_string(), + submit_remote_dialog( + &host, + RemoteDialogSubmissionRequest { + session_id: session_id.to_string(), content, - None, - Some(turn_id.clone()), - resolved_agent_type, - binding_workspace, - submission_policy, - None, - None, - image_payload, - ) - .await + agent_type: agent_type.map(ToOwned::to_owned), + image_contexts, + policy: RemoteDialogSubmissionPolicy::for_source(source), + turn_id, + }, + ) + .await } /// Cancel a running dialog turn. @@ -1033,29 +1132,23 @@ impl RemoteServer { /// Relative paths are resolved against the session workspace when possible, /// otherwise the current workspace root. Rejects files larger than 30 MB. async fn handle_read_file(&self, raw_path: &str, session_id: Option<&str>) -> RemoteResponse { - use crate::service::remote_connect::bot::{read_workspace_file, WorkspaceFileContent}; - let workspace_root = resolve_file_workspace_root(session_id).await; - match read_workspace_file( + match read_remote_workspace_file( raw_path, REMOTE_FILE_MAX_READ_BYTES, workspace_root.as_deref(), ) .await { - Ok(WorkspaceFileContent { - name, - bytes, - mime_type, - size, - }) => { + Ok(content) => { use base64::Engine as _; - let content_base64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + let content_base64 = + base64::engine::general_purpose::STANDARD.encode(&content.bytes); RemoteResponse::FileContent { - name, + name: content.name, content_base64, - mime_type: mime_type.to_string(), - size, + mime_type: content.mime_type.to_string(), + size: content.size, } } Err(e) => RemoteResponse::Error { @@ -1071,56 +1164,24 @@ impl RemoteServer { offset: u64, limit: u64, ) -> RemoteResponse { - use crate::service::remote_connect::bot::{detect_mime_type, resolve_workspace_path}; - let workspace_root = resolve_file_workspace_root(session_id).await; - let abs = match resolve_workspace_path(raw_path, workspace_root.as_deref()) { - Some(p) => p, - None => { - return RemoteResponse::Error { - message: format!("Remote file path could not be resolved: {raw_path}"), - }; - } - }; - if !abs.exists() || !abs.is_file() { - return RemoteResponse::Error { - message: format!("File not found or not a regular file: {}", abs.display()), - }; - } - - let total_size = match tokio::fs::metadata(&abs).await { - Ok(m) => m.len(), - Err(e) => { - return RemoteResponse::Error { - message: format!("Cannot read file metadata: {e}"), - }; - } - }; - - let bytes = match tokio::fs::read(&abs).await { - Ok(b) => b, - Err(e) => { - return RemoteResponse::Error { - message: format!("Cannot read file: {e}"), - }; + match read_remote_workspace_file_chunk(raw_path, workspace_root.as_deref(), offset, limit) + .await + { + Ok(chunk) => { + use base64::Engine as _; + let chunk_base64 = base64::engine::general_purpose::STANDARD.encode(&chunk.bytes); + + RemoteResponse::FileChunk { + name: chunk.name, + chunk_base64, + offset: chunk.offset, + chunk_size: chunk.chunk_size, + total_size: chunk.total_size, + mime_type: chunk.mime_type.to_string(), + } } - }; - - let range = resolve_remote_file_chunk_range(bytes.len(), offset, limit); - let chunk = &bytes[range.start..range.end]; - - use base64::Engine as _; - let chunk_base64 = base64::engine::general_purpose::STANDARD.encode(chunk); - - let name = remote_file_display_name(abs.file_name().and_then(|n| n.to_str())); - - RemoteResponse::FileChunk { - name, - chunk_base64, - offset, - chunk_size: range.chunk_size, - total_size, - mime_type: detect_mime_type(&abs).to_string(), + Err(message) => RemoteResponse::Error { message }, } } @@ -1129,44 +1190,14 @@ impl RemoteServer { raw_path: &str, session_id: Option<&str>, ) -> RemoteResponse { - use crate::service::remote_connect::bot::{detect_mime_type, resolve_workspace_path}; - let workspace_root = resolve_file_workspace_root(session_id).await; - let abs = match resolve_workspace_path(raw_path, workspace_root.as_deref()) { - Some(p) => p, - None => { - return RemoteResponse::Error { - message: format!("Remote file path could not be resolved: {raw_path}"), - }; - } - }; - - if !abs.exists() { - return RemoteResponse::Error { - message: format!("File not found: {}", abs.display()), - }; - } - if !abs.is_file() { - return RemoteResponse::Error { - message: format!("Path is not a regular file: {}", abs.display()), - }; - } - - let size = match std::fs::metadata(&abs) { - Ok(m) => m.len(), - Err(e) => { - return RemoteResponse::Error { - message: format!("Cannot read file metadata: {e}"), - }; - } - }; - - let name = remote_file_display_name(abs.file_name().and_then(|n| n.to_str())); - - RemoteResponse::FileInfo { - name, - size, - mime_type: detect_mime_type(&abs).to_string(), + match read_remote_workspace_file_info(raw_path, workspace_root.as_deref()).await { + Ok(info) => RemoteResponse::FileInfo { + name: info.name, + size: info.size, + mime_type: info.mime_type.to_string(), + }, + Err(message) => RemoteResponse::Error { message }, } } @@ -1666,9 +1697,7 @@ impl RemoteServer { // ── Execution commands ────────────────────────────────────────── async fn handle_execution_command(&self, cmd: &RemoteCommand) -> RemoteResponse { - use crate::agentic::coordination::{ - get_global_coordinator, DialogSubmissionPolicy, DialogTriggerSource, - }; + use crate::agentic::coordination::get_global_coordinator; let dispatcher = get_or_init_global_dispatcher(); @@ -1703,18 +1732,18 @@ impl RemoteServer { content.clone(), requested_agent_type.as_deref(), resolved_contexts, - DialogSubmissionPolicy::for_source(DialogTriggerSource::RemoteRelay), + RemoteConnectSubmissionSource::Relay, None, ) .await { Ok(outcome) => { let (sid, turn_id) = match outcome { - crate::agentic::coordination::DialogSubmitOutcome::Started { + RemoteDialogSubmitOutcome::Started { session_id, turn_id, } - | crate::agentic::coordination::DialogSubmitOutcome::Queued { + | RemoteDialogSubmitOutcome::Queued { session_id, turn_id, } => (session_id, turn_id), @@ -1824,6 +1853,7 @@ impl RemoteServer { mod tests { use super::*; use crate::service::remote_connect::encryption::KeyPair; + use bitfun_services_integrations::remote_connect::remote_session_restore_target; #[test] fn test_command_round_trip() { diff --git a/src/crates/services-integrations/AGENTS.md b/src/crates/services-integrations/AGENTS.md index 204d1e073f..63d9ff6ed8 100644 --- a/src/crates/services-integrations/AGENTS.md +++ b/src/crates/services-integrations/AGENTS.md @@ -14,11 +14,15 @@ slices that are outside pure product logic but still platform-neutral. - MCP config/process/transport lifecycle and dynamic provider helpers may live here; product tool registry assembly, manifest filtering, `GetToolSpec` execution, and concrete tool behavior remain core-owned until H1. -- Remote-connect tracker/wire/pure-policy contracts may live here; dialog - submission, file IO/path resolution, terminal pre-warm, and product execution - remain core-owned until H3. +- Remote-connect tracker/wire/pure-policy contracts, dialog submission + orchestration ports, image-context adapter contracts, and portable + workspace-file path/read/chunk/info helpers may live here. Workspace-root + source selection, response/base64 wrapping, concrete scheduler submission, + concrete terminal pre-warm adapters, and product execution remain core-owned + unless a later reviewed port/provider moves them with equivalence tests. - Remote-SSH path/session identity helpers may live here; SSH channels, SFTP, - remote FS, remote terminal, and manager assembly remain core-owned until H3. + remote FS, remote terminal, and manager assembly remain core-owned unless a + later reviewed port/provider migration proves equivalence. ## Verification diff --git a/src/crates/services-integrations/Cargo.toml b/src/crates/services-integrations/Cargo.toml index 204a1588b6..330f4b33a5 100644 --- a/src/crates/services-integrations/Cargo.toml +++ b/src/crates/services-integrations/Cargo.toml @@ -63,7 +63,7 @@ mcp = [ "rmcp/transport-streamable-http-client-reqwest", "sse-stream", ] -remote-connect = ["bitfun-runtime-ports", "uuid"] +remote-connect = ["async-trait", "bitfun-runtime-ports", "uuid"] remote-ssh = ["dunce", "sha2", "tokio-util"] product-full = [ "announcement", diff --git a/src/crates/services-integrations/src/remote_connect.rs b/src/crates/services-integrations/src/remote_connect.rs index 2dbafddcc5..65d224f8a0 100644 --- a/src/crates/services-integrations/src/remote_connect.rs +++ b/src/crates/services-integrations/src/remote_connect.rs @@ -10,6 +10,7 @@ use bitfun_runtime_ports::{ }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, RwLock}; @@ -114,6 +115,10 @@ pub struct RemoteImageContext { pub metadata: Option, } +pub trait RemoteImageContextAdapter { + fn from_remote_image_context(context: RemoteImageContext) -> Self; +} + pub fn build_remote_image_contexts(images: Option<&[ImageAttachment]>) -> Vec { let Some(images) = images.filter(|images| !images.is_empty()) else { return Vec::new(); @@ -190,9 +195,171 @@ pub fn resolve_remote_cancel_decision( } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteDialogQueuePriority { + Low, + Normal, + High, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RemoteDialogSubmissionPolicy { + pub source: RemoteConnectSubmissionSource, + pub queue_priority: RemoteDialogQueuePriority, + pub skip_tool_confirmation: bool, +} + +impl RemoteDialogSubmissionPolicy { + pub const fn for_source(source: RemoteConnectSubmissionSource) -> Self { + Self { + source, + queue_priority: RemoteDialogQueuePriority::Normal, + skip_tool_confirmation: true, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteDialogSubmissionRequest { + pub session_id: String, + pub content: String, + pub agent_type: Option, + pub image_contexts: Vec, + pub policy: RemoteDialogSubmissionPolicy, + pub turn_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteTerminalPrewarmRequest { + pub session_id: String, + pub binding_workspace: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteDialogResolvedSubmission { + pub session_id: String, + pub content: String, + pub resolved_agent_type: String, + pub binding_workspace: Option, + pub image_contexts: Vec, + pub policy: RemoteDialogSubmissionPolicy, + pub turn_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoteDialogSubmitOutcome { + Started { session_id: String, turn_id: String }, + Queued { session_id: String, turn_id: String }, +} + +/// Host callbacks required by remote-connect dialog execution. +/// +/// The owner crate keeps the remote dialog orchestration order stable, while +/// concrete session restore, terminal warmup, and scheduler execution stay in +/// the product runtime adapter. +#[async_trait::async_trait] +pub trait RemoteDialogRuntimeHost: Send + Sync { + type ImageContext: Send + Sync + 'static; + + fn ensure_tracker(&self, session_id: &str); + + async fn resolve_binding_workspace(&self, session_id: &str) -> Option; + + async fn remote_session_exists(&self, session_id: &str) -> Result; + + async fn restore_remote_session( + &self, + session_id: &str, + workspace_path: &str, + ) -> Result<(), String>; + + fn prewarm_remote_terminal(&self, request: RemoteTerminalPrewarmRequest); + + fn generate_turn_id(&self) -> String; + + async fn submit_dialog( + &self, + submission: RemoteDialogResolvedSubmission, + ) -> Result; +} + +pub async fn submit_remote_dialog( + host: &H, + request: RemoteDialogSubmissionRequest, +) -> Result +where + H: RemoteDialogRuntimeHost + ?Sized, +{ + let RemoteDialogSubmissionRequest { + session_id, + content, + agent_type, + image_contexts, + policy, + turn_id, + } = request; + + host.ensure_tracker(&session_id); + + let binding_workspace = host.resolve_binding_workspace(&session_id).await; + let session_exists = host.remote_session_exists(&session_id).await?; + + if let Some(workspace_path) = + remote_session_restore_target(session_exists, binding_workspace.as_deref()) + { + let _ = host + .restore_remote_session(&session_id, workspace_path) + .await; + } + + host.prewarm_remote_terminal(RemoteTerminalPrewarmRequest { + session_id: session_id.clone(), + binding_workspace: binding_workspace.clone(), + }); + + let resolved_agent_type = resolve_remote_agent_type(agent_type.as_deref()).to_string(); + let turn_id = turn_id.unwrap_or_else(|| host.generate_turn_id()); + + host.submit_dialog(RemoteDialogResolvedSubmission { + session_id, + content, + resolved_agent_type, + binding_workspace, + image_contexts, + policy, + turn_id, + }) + .await +} + pub const REMOTE_FILE_MAX_READ_BYTES: u64 = 30 * 1024 * 1024; pub const REMOTE_FILE_MAX_CHUNK_BYTES: u64 = 3 * 1024 * 1024; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteWorkspaceFileContent { + pub name: String, + pub bytes: Vec, + pub mime_type: &'static str, + pub size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteWorkspaceFileChunk { + pub name: String, + pub bytes: Vec, + pub offset: u64, + pub chunk_size: u64, + pub total_size: u64, + pub mime_type: &'static str, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteWorkspaceFileInfo { + pub name: String, + pub size: u64, + pub mime_type: &'static str, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RemoteFileChunkRange { pub start: usize, @@ -223,6 +390,177 @@ pub fn remote_file_display_name(name: Option<&str>) -> String { } } +fn strip_remote_workspace_path_prefix(raw: &str) -> &str { + raw.strip_prefix("computer://") + .or_else(|| raw.strip_prefix("file://")) + .unwrap_or(raw) +} + +fn is_remote_absolute_workspace_path(path: &str) -> bool { + path.starts_with('/') || (path.len() >= 3 && path.as_bytes()[1] == b':') +} + +pub fn resolve_remote_workspace_path(raw: &str, workspace_root: Option<&Path>) -> Option { + let stripped = strip_remote_workspace_path_prefix(raw); + + if is_remote_absolute_workspace_path(stripped) { + return Some(PathBuf::from(stripped)); + } + + let workspace_root = workspace_root?; + let canonical_root = std::fs::canonicalize(workspace_root).ok()?; + let candidate = canonical_root.join(stripped); + let canonical_candidate = std::fs::canonicalize(candidate).ok()?; + + if canonical_candidate.starts_with(&canonical_root) { + Some(canonical_candidate) + } else { + None + } +} + +pub fn detect_remote_mime_type(path: &Path) -> &'static str { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + + match ext.as_str() { + "txt" | "log" => "text/plain", + "md" => "text/markdown", + "html" | "htm" => "text/html", + "css" => "text/css", + "js" | "mjs" => "text/javascript", + "ts" | "tsx" | "jsx" | "rs" | "py" | "go" | "java" | "c" | "cpp" | "h" | "sh" | "toml" + | "yaml" | "yml" => "text/plain", + "json" => "application/json", + "xml" => "application/xml", + "csv" => "text/csv", + "pdf" => "application/pdf", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "zip" => "application/zip", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "mp4" => "video/mp4", + "opus" => "audio/opus", + _ => "application/octet-stream", + } +} + +pub async fn read_remote_workspace_file( + raw_path: &str, + max_size: u64, + workspace_root: Option<&Path>, +) -> Result { + let abs_path = resolve_remote_workspace_path(raw_path, workspace_root) + .ok_or_else(|| format!("Remote file path could not be resolved: {raw_path}"))?; + + if !abs_path.exists() { + return Err(format!("File not found: {}", abs_path.display())); + } + if !abs_path.is_file() { + return Err(format!( + "Path is not a regular file: {}", + abs_path.display() + )); + } + + let metadata = tokio::fs::metadata(&abs_path) + .await + .map_err(|e| format!("Cannot read file metadata for {}: {e}", abs_path.display()))?; + + if metadata.len() > max_size { + return Err(format!( + "File too large ({} bytes, limit {max_size} bytes): {}", + metadata.len(), + abs_path.display() + )); + } + + let bytes = tokio::fs::read(&abs_path) + .await + .map_err(|e| format!("Cannot read file {}: {e}", abs_path.display()))?; + + Ok(RemoteWorkspaceFileContent { + name: remote_file_display_name(abs_path.file_name().and_then(|n| n.to_str())), + bytes, + mime_type: detect_remote_mime_type(&abs_path), + size: metadata.len(), + }) +} + +pub async fn read_remote_workspace_file_chunk( + raw_path: &str, + workspace_root: Option<&Path>, + offset: u64, + limit: u64, +) -> Result { + let abs_path = resolve_remote_workspace_path(raw_path, workspace_root) + .ok_or_else(|| format!("Remote file path could not be resolved: {raw_path}"))?; + + if !abs_path.exists() || !abs_path.is_file() { + return Err(format!( + "File not found or not a regular file: {}", + abs_path.display() + )); + } + + let total_size = tokio::fs::metadata(&abs_path) + .await + .map_err(|e| format!("Cannot read file metadata: {e}"))? + .len(); + + let bytes = tokio::fs::read(&abs_path) + .await + .map_err(|e| format!("Cannot read file: {e}"))?; + let range = resolve_remote_file_chunk_range(bytes.len(), offset, limit); + let chunk = bytes[range.start..range.end].to_vec(); + + Ok(RemoteWorkspaceFileChunk { + name: remote_file_display_name(abs_path.file_name().and_then(|n| n.to_str())), + bytes: chunk, + offset, + chunk_size: range.chunk_size, + total_size, + mime_type: detect_remote_mime_type(&abs_path), + }) +} + +pub async fn read_remote_workspace_file_info( + raw_path: &str, + workspace_root: Option<&Path>, +) -> Result { + let abs_path = resolve_remote_workspace_path(raw_path, workspace_root) + .ok_or_else(|| format!("Remote file path could not be resolved: {raw_path}"))?; + + if !abs_path.exists() { + return Err(format!("File not found: {}", abs_path.display())); + } + if !abs_path.is_file() { + return Err(format!( + "Path is not a regular file: {}", + abs_path.display() + )); + } + + let size = tokio::fs::metadata(&abs_path) + .await + .map_err(|e| format!("Cannot read file metadata: {e}"))? + .len(); + + Ok(RemoteWorkspaceFileInfo { + name: remote_file_display_name(abs_path.file_name().and_then(|n| n.to_str())), + size, + mime_type: detect_remote_mime_type(&abs_path), + }) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct RemoteDefaultModelsConfig { pub primary: Option, diff --git a/src/crates/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services-integrations/tests/remote_connect_contracts.rs index db143cc07d..6c5420e204 100644 --- a/src/crates/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services-integrations/tests/remote_connect_contracts.rs @@ -5,18 +5,23 @@ use bitfun_runtime_ports::AgentSubmissionSource; use bitfun_services_integrations::remote_connect::{ build_remote_image_attachment, build_remote_image_contexts, build_remote_image_submission_request, build_remote_session_create_request, - build_remote_submission_request, make_slim_tool_params, remote_file_display_name, + build_remote_submission_request, make_slim_tool_params, read_remote_workspace_file, + read_remote_workspace_file_chunk, read_remote_workspace_file_info, remote_file_display_name, remote_model_catalog_poll_delta, remote_no_change_poll_response, remote_persisted_poll_response, remote_session_restore_target, remote_snapshot_poll_response, resolve_remote_agent_type, resolve_remote_cancel_decision, resolve_remote_execution_image_contexts, resolve_remote_file_chunk_range, - should_send_remote_model_catalog, ActiveTurnSnapshot, ChatImageAttachment, ChatMessage, - ChatMessageItem, ImageAttachment, RemoteCancelDecision, RemoteCommand, - RemoteConnectSubmissionSource, RemoteDefaultModelsConfig, RemoteImageContext, - RemoteModelCatalog, RemoteModelConfig, RemoteResponse, RemoteSessionStateTracker, - RemoteSessionTrackerHost, RemoteSessionTrackerRegistry, RemoteToolStatus, TrackerEvent, + resolve_remote_workspace_path, should_send_remote_model_catalog, submit_remote_dialog, + ActiveTurnSnapshot, ChatImageAttachment, ChatMessage, ChatMessageItem, ImageAttachment, + RemoteCancelDecision, RemoteCommand, RemoteConnectSubmissionSource, RemoteDefaultModelsConfig, + RemoteDialogQueuePriority, RemoteDialogResolvedSubmission, RemoteDialogRuntimeHost, + RemoteDialogSubmissionPolicy, RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, + RemoteImageContext, RemoteImageContextAdapter, RemoteModelCatalog, RemoteModelConfig, + RemoteResponse, RemoteSessionStateTracker, RemoteSessionTrackerHost, + RemoteSessionTrackerRegistry, RemoteTerminalPrewarmRequest, RemoteToolStatus, TrackerEvent, REMOTE_FILE_MAX_CHUNK_BYTES, REMOTE_FILE_MAX_READ_BYTES, }; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; #[test] @@ -143,6 +148,52 @@ fn remote_connect_image_context_policy_prefers_explicit_contexts() { assert_eq!(contexts, vec![explicit]); } +#[derive(Debug, Clone, PartialEq)] +struct TestImageContext { + id: String, + image_path: Option, + data_url: Option, + mime_type: String, + metadata: Option, +} + +impl RemoteImageContextAdapter for TestImageContext { + fn from_remote_image_context(context: RemoteImageContext) -> Self { + Self { + id: context.id, + image_path: context.image_path, + data_url: context.data_url, + mime_type: context.mime_type, + metadata: context.metadata, + } + } +} + +#[test] +fn remote_connect_image_context_adapter_owns_portable_conversion_shape() { + let context = RemoteImageContext { + id: "ctx-1".to_string(), + image_path: Some("D:/workspace/project/screenshot.png".to_string()), + data_url: Some("data:image/png;base64,abc".to_string()), + mime_type: "image/png".to_string(), + metadata: Some(serde_json::json!({ "source": "remote" })), + }; + + let adapted = TestImageContext::from_remote_image_context(context); + + assert_eq!(adapted.id, "ctx-1"); + assert_eq!( + adapted.image_path.as_deref(), + Some("D:/workspace/project/screenshot.png") + ); + assert_eq!( + adapted.data_url.as_deref(), + Some("data:image/png;base64,abc") + ); + assert_eq!(adapted.mime_type, "image/png"); + assert_eq!(adapted.metadata.as_ref().unwrap()["source"], "remote"); +} + #[test] fn remote_connect_cancel_and_restore_policy_preserve_runtime_decisions() { assert_eq!( @@ -177,6 +228,265 @@ fn remote_connect_cancel_and_restore_policy_preserve_runtime_decisions() { ); } +struct RecordingDialogHost { + session_exists: bool, + binding_workspace: Option, + generated_turn_id: String, + restore_error: bool, + submit_outcome: RemoteDialogSubmitOutcome, + events: Mutex>, + submitted: Mutex>>, +} + +impl RecordingDialogHost { + fn new(session_exists: bool, binding_workspace: Option<&str>) -> Self { + Self { + session_exists, + binding_workspace: binding_workspace.map(ToOwned::to_owned), + generated_turn_id: "turn-generated".to_string(), + restore_error: false, + submit_outcome: RemoteDialogSubmitOutcome::Started { + session_id: "session-1".to_string(), + turn_id: "turn-generated".to_string(), + }, + events: Mutex::new(Vec::new()), + submitted: Mutex::new(None), + } + } + + fn with_restore_error(mut self) -> Self { + self.restore_error = true; + self + } + + fn with_submit_outcome(mut self, submit_outcome: RemoteDialogSubmitOutcome) -> Self { + self.submit_outcome = submit_outcome; + self + } + + fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } + + fn submitted(&self) -> RemoteDialogResolvedSubmission { + self.submitted + .lock() + .unwrap() + .clone() + .expect("dialog submitted") + } +} + +#[async_trait::async_trait] +impl RemoteDialogRuntimeHost for RecordingDialogHost { + type ImageContext = String; + + fn ensure_tracker(&self, session_id: &str) { + self.events + .lock() + .unwrap() + .push(format!("ensure_tracker:{session_id}")); + } + + async fn resolve_binding_workspace(&self, session_id: &str) -> Option { + self.events + .lock() + .unwrap() + .push(format!("resolve_workspace:{session_id}")); + self.binding_workspace.clone() + } + + async fn remote_session_exists(&self, session_id: &str) -> Result { + self.events + .lock() + .unwrap() + .push(format!("session_exists:{session_id}")); + Ok(self.session_exists) + } + + async fn restore_remote_session( + &self, + session_id: &str, + workspace_path: &str, + ) -> Result<(), String> { + self.events + .lock() + .unwrap() + .push(format!("restore:{session_id}:{workspace_path}")); + if self.restore_error { + Err("restore failed".to_string()) + } else { + Ok(()) + } + } + + fn prewarm_remote_terminal(&self, request: RemoteTerminalPrewarmRequest) { + self.events.lock().unwrap().push(format!( + "prewarm:{}:{}", + request.session_id, + request.binding_workspace.as_deref().unwrap_or("") + )); + } + + fn generate_turn_id(&self) -> String { + self.events + .lock() + .unwrap() + .push("generate_turn".to_string()); + self.generated_turn_id.clone() + } + + async fn submit_dialog( + &self, + submission: RemoteDialogResolvedSubmission, + ) -> Result { + self.events + .lock() + .unwrap() + .push(format!("submit:{}", submission.session_id)); + *self.submitted.lock().unwrap() = Some(submission); + Ok(self.submit_outcome.clone()) + } +} + +#[tokio::test] +async fn remote_connect_dialog_runtime_owns_restore_prewarm_and_submit_order() { + let host = RecordingDialogHost::new(false, Some("D:/workspace/project")); + + let outcome = submit_remote_dialog( + &host, + RemoteDialogSubmissionRequest { + session_id: "session-1".to_string(), + content: "hello".to_string(), + agent_type: Some("code".to_string()), + image_contexts: vec!["image-1".to_string()], + policy: RemoteDialogSubmissionPolicy::for_source(RemoteConnectSubmissionSource::Relay), + turn_id: None, + }, + ) + .await + .expect("dialog submit succeeds"); + + assert_eq!( + outcome, + RemoteDialogSubmitOutcome::Started { + session_id: "session-1".to_string(), + turn_id: "turn-generated".to_string() + } + ); + assert_eq!( + host.events(), + vec![ + "ensure_tracker:session-1", + "resolve_workspace:session-1", + "session_exists:session-1", + "restore:session-1:D:/workspace/project", + "prewarm:session-1:D:/workspace/project", + "generate_turn", + "submit:session-1", + ] + ); + + let submitted = host.submitted(); + assert_eq!(submitted.session_id, "session-1"); + assert_eq!(submitted.content, "hello"); + assert_eq!(submitted.resolved_agent_type, "agentic"); + assert_eq!( + submitted.binding_workspace.as_deref(), + Some("D:/workspace/project") + ); + assert_eq!(submitted.image_contexts, vec!["image-1".to_string()]); + assert_eq!(submitted.turn_id, "turn-generated"); + assert_eq!( + submitted.policy.source, + RemoteConnectSubmissionSource::Relay + ); + assert_eq!( + submitted.policy.queue_priority, + RemoteDialogQueuePriority::Normal + ); + assert!(submitted.policy.skip_tool_confirmation); +} + +#[tokio::test] +async fn remote_connect_dialog_runtime_preserves_explicit_turn_without_restore() { + let host = RecordingDialogHost::new(true, Some("D:/workspace/project")).with_submit_outcome( + RemoteDialogSubmitOutcome::Queued { + session_id: "session-1".to_string(), + turn_id: "turn-bot".to_string(), + }, + ); + + let outcome = submit_remote_dialog( + &host, + RemoteDialogSubmissionRequest { + session_id: "session-1".to_string(), + content: "from bot".to_string(), + agent_type: Some("Cowork".to_string()), + image_contexts: Vec::new(), + policy: RemoteDialogSubmissionPolicy::for_source(RemoteConnectSubmissionSource::Bot), + turn_id: Some("turn-bot".to_string()), + }, + ) + .await + .expect("dialog submit succeeds"); + + assert_eq!( + outcome, + RemoteDialogSubmitOutcome::Queued { + session_id: "session-1".to_string(), + turn_id: "turn-bot".to_string() + } + ); + assert_eq!( + host.events(), + vec![ + "ensure_tracker:session-1", + "resolve_workspace:session-1", + "session_exists:session-1", + "prewarm:session-1:D:/workspace/project", + "submit:session-1", + ] + ); + + let submitted = host.submitted(); + assert_eq!(submitted.resolved_agent_type, "Cowork"); + assert_eq!(submitted.turn_id, "turn-bot"); + assert_eq!(submitted.policy.source, RemoteConnectSubmissionSource::Bot); +} + +#[tokio::test] +async fn remote_connect_dialog_runtime_keeps_legacy_restore_failure_tolerance() { + let host = RecordingDialogHost::new(false, Some("D:/workspace/project")).with_restore_error(); + + submit_remote_dialog( + &host, + RemoteDialogSubmissionRequest { + session_id: "session-1".to_string(), + content: "hello".to_string(), + agent_type: None, + image_contexts: Vec::new(), + policy: RemoteDialogSubmissionPolicy::for_source(RemoteConnectSubmissionSource::Relay), + turn_id: Some("turn-1".to_string()), + }, + ) + .await + .expect("restore failure is still tolerated before scheduler submit"); + + assert_eq!( + host.events(), + vec![ + "ensure_tracker:session-1", + "resolve_workspace:session-1", + "session_exists:session-1", + "restore:session-1:D:/workspace/project", + "prewarm:session-1:D:/workspace/project", + "submit:session-1", + ] + ); + assert_eq!(host.submitted().turn_id, "turn-1"); +} + #[test] fn remote_connect_file_transfer_policy_preserves_limits_and_chunk_ranges() { assert_eq!(REMOTE_FILE_MAX_READ_BYTES, 30 * 1024 * 1024); @@ -206,6 +516,87 @@ fn remote_connect_file_transfer_policy_preserves_name_fallback() { assert_eq!(remote_file_display_name(Some("")), "file"); } +fn make_temp_remote_workspace() -> (PathBuf, PathBuf, PathBuf) { + let base = std::env::temp_dir().join(format!( + "bitfun-remote-connect-contract-{}", + uuid::Uuid::new_v4() + )); + let workspace = base.join("workspace"); + let artifacts = workspace.join("artifacts"); + std::fs::create_dir_all(&artifacts).expect("create remote workspace"); + let report = artifacts.join("report.md"); + std::fs::write(&report, b"hello remote file").expect("write remote file"); + (base, workspace, report) +} + +#[test] +fn remote_connect_file_path_resolution_stays_within_workspace_root() { + let (base, workspace, report) = make_temp_remote_workspace(); + + let resolved = + resolve_remote_workspace_path("computer://artifacts/report.md", Some(&workspace)) + .expect("workspace-relative file resolves"); + assert_eq!(resolved, report.canonicalize().expect("canonical report")); + + assert!(resolve_remote_workspace_path("../secret.md", Some(&workspace)).is_none()); + assert!(resolve_remote_workspace_path("artifacts/report.md", None).is_none()); + + std::fs::remove_dir_all(base).expect("cleanup remote workspace"); +} + +#[tokio::test] +async fn remote_connect_file_read_helpers_preserve_current_wire_inputs() { + let (base, workspace, report) = make_temp_remote_workspace(); + + let content = read_remote_workspace_file( + "computer://artifacts/report.md", + REMOTE_FILE_MAX_READ_BYTES, + Some(&workspace), + ) + .await + .expect("read remote file"); + + assert_eq!(content.name, "report.md"); + assert_eq!(content.bytes, b"hello remote file"); + assert_eq!(content.mime_type, "text/markdown"); + assert_eq!(content.size, 17); + + let err = read_remote_workspace_file("computer://artifacts/report.md", 3, Some(&workspace)) + .await + .expect_err("size limit rejects large file"); + assert!(err.contains("File too large")); + assert!(err.contains(&report.display().to_string())); + + std::fs::remove_dir_all(base).expect("cleanup remote workspace"); +} + +#[tokio::test] +async fn remote_connect_file_chunk_and_info_helpers_preserve_response_facts() { + let (base, workspace, _report) = make_temp_remote_workspace(); + + let chunk = + read_remote_workspace_file_chunk("computer://artifacts/report.md", Some(&workspace), 6, 99) + .await + .expect("read remote file chunk"); + + assert_eq!(chunk.name, "report.md"); + assert_eq!(chunk.bytes, b"remote file"); + assert_eq!(chunk.offset, 6); + assert_eq!(chunk.chunk_size, 11); + assert_eq!(chunk.total_size, 17); + assert_eq!(chunk.mime_type, "text/markdown"); + + let info = read_remote_workspace_file_info("computer://artifacts/report.md", Some(&workspace)) + .await + .expect("read remote file info"); + + assert_eq!(info.name, "report.md"); + assert_eq!(info.size, 17); + assert_eq!(info.mime_type, "text/markdown"); + + std::fs::remove_dir_all(base).expect("cleanup remote workspace"); +} + #[test] fn remote_connect_session_create_contract_preserves_workspace_binding() { let request = build_remote_session_create_request( From 39300c942e3221899454f066495eda3e2539d98a Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 21 May 2026 16:30:45 +0800 Subject: [PATCH 04/38] fix: render mermaid in markdown editor preview --- .../tools/editor/components/MarkdownEditor.test.tsx | 12 ++++++++++-- .../src/tools/editor/components/MarkdownEditor.tsx | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.test.tsx b/src/web-ui/src/tools/editor/components/MarkdownEditor.test.tsx index 7ccc1be1d3..e9fc9cc06e 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.test.tsx +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.test.tsx @@ -27,13 +27,13 @@ vi.mock('@/component-library', () => ({ })); vi.mock('../meditor', () => ({ - MEditor: forwardRef((_props: { value?: string }, ref) => { + MEditor: forwardRef((props: { value?: string; mode?: string }, ref) => { useImperativeHandle(ref, () => ({ destroy: vi.fn(), markSaved: vi.fn(), setInitialContent: vi.fn(), })); - return
; + return
; }), })); @@ -92,4 +92,12 @@ describe('MarkdownEditor', () => { expect(html).toContain('data-icon="copy"'); expect(html).toContain('bitfun-markdown-editor__toolbar-button'); }); + + it('uses preview mode for markdown rendering', () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('data-mode="preview"'); + }); }); diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx index d8dae9eaf6..f38b1f8ee1 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx @@ -814,7 +814,7 @@ const MarkdownEditor: React.FC = ({ onChange={handleContentChange} onSave={handleSave} onDirtyChange={handleDirtyChange} - mode={viewMode === 'preview' ? 'ir' : 'edit'} + mode={viewMode === 'preview' ? 'preview' : 'edit'} theme={isLight ? 'light' : 'dark'} height="100%" width="100%" From d42606ba0b8440a4fe652a96a9c758553ea2c481 Mon Sep 17 00:00:00 2001 From: limityan Date: Thu, 21 May 2026 16:00:28 +0800 Subject: [PATCH 05/38] fix(miniapp): stabilize assistant-created skeletons --- .../implementations/miniapp_init_tool.rs | 19 ++++++--- src/crates/core/src/agentic/tools/registry.rs | 2 +- src/crates/core/src/miniapp/storage.rs | 41 ++++++++++++++++++- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs b/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs index 20e87ab9d1..ee7bd9b3cb 100644 --- a/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs @@ -1,6 +1,6 @@ //! InitMiniApp tool — create a new MiniApp skeleton; AI then uses generic file tools to edit. -use crate::agentic::tools::framework::{Tool, ToolExposure, ToolResult, ToolUseContext}; +use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::infrastructure::events::{emit_global_event, BackendEvent}; use crate::miniapp::try_get_global_miniapp_manager; use crate::miniapp::types::{ @@ -56,6 +56,18 @@ impl Default for InitMiniAppTool { } } +#[cfg(test)] +mod tests { + use super::InitMiniAppTool; + use crate::agentic::tools::framework::{Tool, ToolExposure}; + + #[test] + fn init_miniapp_stays_expanded_for_assistant_creation() { + let tool = InitMiniAppTool::new(); + assert_eq!(tool.default_exposure(), ToolExposure::Expanded); + } +} + #[async_trait] impl Tool for InitMiniAppTool { fn name(&self) -> &str { @@ -77,10 +89,6 @@ Returns app_id and the app root directory. Use the root directory and file names "Create a new MiniApp skeleton in the Toolbox.".to_string() } - fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed - } - fn input_schema(&self) -> Value { json!({ "type": "object", @@ -196,6 +204,7 @@ Returns app_id and the app root directory. Use the root directory and file names "style": source_dir.join("style.css").to_string_lossy(), "html": source_dir.join("index.html").to_string_lossy(), "package": app_dir.join("package.json").to_string_lossy(), + "storage": app_dir.join("storage.json").to_string_lossy(), }); let _ = emit_global_event(BackendEvent::Custom { diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index 85d9a22ddc..5530d937fa 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -528,6 +528,7 @@ mod tests { assert!(!registry.is_tool_collapsed("GetToolSpec")); assert!(registry.is_tool_collapsed("Git")); assert!(registry.is_tool_collapsed("ReviewPlatform")); + assert!(!registry.is_tool_collapsed("InitMiniApp")); } #[test] @@ -553,7 +554,6 @@ mod tests { "GenerativeUI", "Git", "ReviewPlatform", - "InitMiniApp", "ControlHub", "ComputerUse", "Playbook", diff --git a/src/crates/core/src/miniapp/storage.rs b/src/crates/core/src/miniapp/storage.rs index 19f47bc8b5..3a291f0c9a 100644 --- a/src/crates/core/src/miniapp/storage.rs +++ b/src/crates/core/src/miniapp/storage.rs @@ -325,6 +325,13 @@ impl MiniAppStorage { self.write_package_json_to_dir(app_dir, &app.id, &app.source.npm_dependencies) .await?; + let storage_path = app_dir.join(STORAGE_JSON); + if !storage_path.exists() { + tokio::fs::write(&storage_path, "{}") + .await + .map_err(|e| BitFunError::io(format!("Failed to write storage.json: {}", e)))?; + } + tokio::fs::write(app_dir.join(COMPILED_HTML), &app.compiled_html) .await .map_err(|e| BitFunError::io(format!("Failed to write compiled.html: {}", e)))?; @@ -938,6 +945,11 @@ mod tests { let layout = MiniAppStorageLayout::new(path_manager.miniapps_dir(), "layout_app"); storage.save(&app).await.unwrap(); + assert!(layout.storage_path().is_file()); + assert_eq!( + fs::read_to_string(layout.storage_path()).unwrap(), + "{}".to_string() + ); storage .save_app_storage("layout_app", "answer", serde_json::json!(42)) .await @@ -947,7 +959,6 @@ mod tests { assert!(layout.app_dir().is_dir()); assert!(layout.meta_path().is_file()); assert!(layout.compiled_path().is_file()); - assert!(layout.storage_path().is_file()); assert!(layout.package_json_path().is_file()); assert!(layout.source_file_path(INDEX_HTML).is_file()); assert!(layout.source_file_path(STYLE_CSS).is_file()); @@ -957,6 +968,34 @@ mod tests { assert!(layout.version_path(7).is_file()); } + #[tokio::test] + async fn saving_app_files_preserves_existing_storage_json() { + let root = std::env::temp_dir().join(format!( + "bitfun-miniapp-storage-preserve-{}", + uuid::Uuid::new_v4() + )); + let path_manager = + Arc::new(crate::infrastructure::PathManager::with_user_root_for_tests(root)); + let storage = MiniAppStorage::new(path_manager); + let app = sample_app("storage_app"); + + storage.save(&app).await.unwrap(); + storage + .save_app_storage("storage_app", "answer", serde_json::json!(42)) + .await + .unwrap(); + storage.save(&app).await.unwrap(); + + assert_eq!( + storage + .load_app_storage("storage_app") + .await + .unwrap() + .get("answer"), + Some(&serde_json::json!(42)) + ); + } + #[tokio::test] async fn draft_storage_is_hidden_and_isolated_from_active_storage() { let root = std::env::temp_dir().join(format!( From 2a32bac8ccfa1abaea55f44db00568dc0d803167 Mon Sep 17 00:00:00 2001 From: limityan Date: Thu, 21 May 2026 16:37:09 +0800 Subject: [PATCH 06/38] refactor(tools): consolidate product runtime owner --- docs/architecture/core-decomposition.md | 19 +- docs/plans/core-decomposition-plan.md | 33 ++-- scripts/check-core-boundaries.mjs | 172 ++++++++---------- src/crates/core/AGENTS-CN.md | 2 +- src/crates/core/AGENTS.md | 11 +- .../implementations/get_tool_spec_tool.rs | 2 +- .../src/agentic/tools/manifest_resolver.rs | 2 +- src/crates/core/src/agentic/tools/mod.rs | 4 +- ...catalog_provider.rs => product_runtime.rs} | 152 +++++++++++++++- src/crates/core/src/agentic/tools/registry.rs | 66 +++---- .../src/agentic/tools/runtime_assembly.rs | 63 ------- .../src/agentic/tools/static_providers.rs | 64 ------- 12 files changed, 297 insertions(+), 293 deletions(-) rename src/crates/core/src/agentic/tools/{catalog_provider.rs => product_runtime.rs} (63%) delete mode 100644 src/crates/core/src/agentic/tools/runtime_assembly.rs delete mode 100644 src/crates/core/src/agentic/tools/static_providers.rs diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index 055489f83e..09b4f5bcf1 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -66,7 +66,7 @@ Rust 编译和链接面。 | `bitfun-agent-stream` | Stream 聚合和 stream-focused 测试 | done:stream 聚合已独立 | | `bitfun-runtime-ports` | 面向 service/agent 边界的轻量跨层 DTO 和 trait | partial:DTO/trait-only 边界已建立,包含 agent submission/transcript/cancel、remote state、runtime event 与 remote image attachment 契约;不拥有 runtime 实现 | | `bitfun-agent-runtime` | Sessions、execution、coordination、agent system | target:crate 尚不存在,agent runtime 仍在 core | -| `bitfun-agent-tools` | 轻量 tool DTO / contract、portable tool context facts / provider、runtime restriction、pure manifest/exposure and GetToolSpec presentation/schema/static metadata/detail/result assembly / execution-plan contract、provider-backed tool catalog / GetToolSpec runtime facade、provider-backed GetToolSpec execution result helper / Tool-result vector adapter、generic contextual manifest resolver、generic catalog snapshot provider / GetToolSpec catalog provider、generic registry / static-provider / dynamic-provider / decorator-ref / snapshot-decorator adapter / runtime assembly container、generic readonly/enabled snapshot filter | partial:product registry snapshot access、`ToolUseContext` adapter、`GetToolSpec` Tool impl 和 concrete tools 仍在 core;core 当前从 `bitfun-tool-packs` provider plan 物化内置工具列表,static-provider 安装 assembly、decorator reference、generic snapshot decorator adapter、provider-backed catalog runtime facade 与 readonly/enabled 过滤规则已委托给 `bitfun-agent-tools` | +| `bitfun-agent-tools` | 轻量 tool DTO / contract、portable tool context facts / provider、runtime restriction、pure manifest/exposure and GetToolSpec presentation/schema/static metadata/detail/result assembly / execution-plan contract、provider-backed tool catalog / GetToolSpec runtime facade、provider-backed GetToolSpec execution result helper / Tool-result vector adapter、generic contextual manifest resolver、generic catalog snapshot provider / GetToolSpec catalog provider、generic registry / static-provider / dynamic-provider / decorator-ref / snapshot-decorator adapter / runtime assembly container、generic readonly/enabled snapshot filter | partial:product registry snapshot access、`ToolUseContext` adapter、`GetToolSpec` Tool impl 和 concrete tools 仍在 core,并由 core `tools/product_runtime.rs` 作为单一 product runtime owner 组装;core 当前从 `bitfun-tool-packs` provider plan 物化内置工具列表,static-provider 安装 assembly、decorator reference、generic snapshot decorator adapter、provider-backed catalog runtime facade 与 readonly/enabled 过滤规则已委托给 `bitfun-agent-tools` | | `bitfun-tool-packs` | 由 feature group 隔离的工具 provider plan | partial:提供 basic / git / mcp / browser-web / computer-use / image-analysis / miniapp / agent-control feature-group 元数据和 product provider group plan;不得声明 concrete tools 已迁移 | | `bitfun-services-core` | Config、session、workspace、storage、filesystem、system services | partial:部分 pure helper 已迁出;config/workspace/filesystem runtime 多数仍在 core | | `bitfun-services-integrations` | Git、MCP、remote SSH、remote connect、file watch integrations | partial:MCP runtime 已迁入;remote SSH 仍只迁移低风险 contracts/helpers;remote-connect 已拥有 wire DTO、request builder、tracker state / registry lifecycle、tracker event reduction、dialog submission orchestration port/provider、file IO/path resolution helper 与 image-context adapter contract;concrete scheduler/session restore/terminal adapter、workspace-root source、response wrapping 与 product execution 仍在 core | @@ -150,9 +150,8 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate 与 desktop/MCP/ACP tool catalog 等价。 - 当前 tool runtime 外移的低风险入口是 `StaticToolProvider` / `install_static_provider` / `ToolRuntimeAssembly` 合约归属 `bitfun-agent-tools`,并让 core 通过 - `runtime_assembly.rs` + `static_providers.rs` - 将内置工具列表收敛为 `core.basic`、`core.agent`、`core.session`、`core.integration` - provider group。 + `tools/product_runtime.rs` 将内置工具列表收敛为 + `core.basic`、`core.agent`、`core.session`、`core.integration` provider group。 这不代表 concrete tools、`ToolUseContext`、product registry snapshot adapter 或 `GetToolSpecTool` 执行已经迁移。 - `ToolContextFacts` 只记录 tool call、agent/session/turn、workspace kind/root @@ -244,15 +243,16 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate Tool impl、collapsed unlock state source、product snapshot wrapper adapter、product registry snapshot access 与 concrete tools。 已合入 PR #803 把 core `Tool` 到 provider-neutral contract 的 adapter 收敛到 - `tool_adapter.rs`,把 product catalog / manifest / GetToolSpec catalog-detail provider - 收敛到 `catalog_provider.rs`;本阶段把 provider-neutral GetToolSpec static tool surface + `tool_adapter.rs`;HR1 本轮把 product catalog / manifest / GetToolSpec catalog-detail + provider、static provider materialization 和 snapshot wrapper 注入收敛到 + `product_runtime.rs`;本阶段把 provider-neutral GetToolSpec static tool surface (name / description / schema / readonly / concurrency / permission / validation / tool-use message)、 execution plan / result assembly 与 provider-backed execution result helper 收敛到 `bitfun-agent-tools`;本阶段继续把 provider-backed visible-tools / manifest / readonly catalog 查询收敛到 `ToolCatalogRuntime`,core 只保留 product registry snapshot、agent policy、`dyn Tool` / `ToolUseContext` adapter;本阶段继续把 static-provider 安装 assembly 委托到 `ToolRuntimeAssembly`,并把 product provider group plan 迁入 `bitfun-tool-packs`; - core 只保留 concrete tool materialization、product snapshot wrapper adapter、product provider/context 注入、 + core 只在 `product_runtime.rs` 保留 concrete tool materialization、product snapshot wrapper adapter、product provider/context 注入、 `GetToolSpecTool` Tool impl、unlock state source 和错误映射;本阶段也把 decorator reference contract、generic snapshot decorator adapter、 GetToolSpec runtime facade、Tool-result vector adapter 与 readonly enabled filtering 的通用规则委托给 `bitfun-agent-tools`,不改变工具行为。 @@ -286,6 +286,11 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate HR2 重点保护 MiniApp IO/worker/asset seed 与 function-agent Git/AI 时序; HR3 重点保护 remote workspace/SSH/terminal、scheduler/registry、subagent visibility 和 DeepResearch post-turn hook。 +- HR1 当前已完成 core 内部 owner closure:`tools/product_runtime.rs` 统一承接 + product provider plan materialization、product registry snapshot/catalog facade、 + manifest / GetToolSpec facade 与 snapshot wrapper 注入。该收口不改变工具执行路径, + 也不声明 `ToolUseContext`、collapsed unlock state、`GetToolSpecTool` Tool impl、 + snapshot runtime 或 concrete tools 已迁出 core。 - 已完成的 MCP runtime/dynamic tools、remote-connect tracker/wire/pure policy、 semantic baseline、product-domain port/facade 与 tool contract/helper 外移不得重复规划; 如果后续发现这些已完成项存在实现错误,应在对应 H 阶段记录问题、风险和修复方案, diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 71ae8cffcd..16450e83d2 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -1034,7 +1034,7 @@ cargo check -p bitfun-cli - [x] 抽出 tool result、validation、dynamic metadata、runtime restriction、path resolution DTO,以及 generic registry / dynamic provider container 到 `agent-tools`。 - [x] 抽出纯 manifest/exposure / GetToolSpec presentation 契约到 `agent-tools`:`ToolExposure`、`GetToolSpec` 名称、纯 manifest policy、collapsed prompt stub、prompt-visible ordering、GetToolSpec prompt description / input schema / validation / assistant-detail rendering / collapsed summary-detail / duplicate-load hint;core 继续拥有 runtime assembly 和执行 owner。 -- [x] 抽出 static tool provider 安装合约到 `agent-tools`,并将 core 内置工具列表收敛到 `static_providers.rs` 的 core-owned provider groups;不迁移 concrete tool implementation。 +- [x] 抽出 static tool provider 安装合约到 `agent-tools`,并将 core 内置工具列表收敛到 `product_runtime.rs` 的 core-owned provider groups;不迁移 concrete tool implementation。 - [x] 抽出 `ToolContextFacts` / `ToolWorkspaceKind` 轻量上下文事实契约,并由 core `ToolUseContext` 提供只读投影;workspace root fact 使用 session identity 的 logical path,remote 场景输出 normalized remote root;不迁移 collapsed unlock state、runtime handles、workspace services 或 cancellation token。 - [x] 增加 `PortableToolContextProvider` 只读 facts provider 合约,并由 core `ToolUseContext` 兼容实现;该合约不暴露 workspace services、cancellation token、computer-use host 或 collapsed unlock state。 - [x] LR1 已锁定 tool runtime port/provider 设计前置条件:`PortableToolContextProvider` @@ -1090,7 +1090,7 @@ pub fn create_tool_registry() -> ToolRegistry { **当前安全迁移状态(2026-05-21):** - 已迁移到 `bitfun-agent-tools`:`ToolResult`、`ValidationResult`、`InputValidator`、dynamic tool metadata、tool render options、runtime restriction DTO、path resolution DTO、`ToolContextFacts` / `ToolWorkspaceKind` 轻量上下文事实、`PortableToolContextProvider` 只读 facts provider、不依赖 core service 的 `ToolRegistry` / `ToolRegistryItem` generic registry container、`StaticToolProvider` / `install_static_provider` 安装合约、generic decorator reference / snapshot decorator adapter / static-provider `ToolRuntimeAssembly` container、generic readonly/enabled registry snapshot filter、generic catalog snapshot provider、generic GetToolSpec catalog provider、provider-backed `ToolCatalogRuntime`、registry snapshot 到 manifest policy input 的纯 helper、generic collapsed exposure 查询、`GetToolSpec` load observation 到 collapsed 工具名集合的纯收集 helper、prompt-visible manifest definition 的纯组装 helper、generic contextual prompt-manifest resolver,以及 `GetToolSpec` catalog / detail / static metadata / tool-use message / execution-plan / result assembly 的 provider-neutral 组装 helper、provider-backed execution result helper、runtime facade 和 Tool-result vector adapter。dynamic tool provider / decorator contract 已通过 `agent-tools` 提供兼容 re-export,原 `runtime-ports` 路径保持可用;core 旧路径继续 re-export,并只保留 `BitFunError` 映射、路径 containment helper、`ToolUseContext` 到 facts 的只读投影和 runtime unlock state。 -- `bitfun-core::agentic::tools` 现在保留 `ProductToolRuntimeAssembly` product snapshot wrapper 注入、旧构造函数、`dyn Tool` 到 generic registry / catalog snapshot provider / GetToolSpec catalog provider 的适配、`ToolUseContext` runtime handle / service owner,以及 product registry snapshot access / agent policy / `GetToolSpecTool` Tool impl;core 静态 provider 组顺序和工具名来自 `bitfun-tool-packs` provider plan,core 只负责 concrete tool materialization。dynamic metadata map、tool map、dynamic descriptor assembly、static provider 安装合约、generic decorator reference / snapshot decorator adapter / provider-install assembly、generic readonly/enabled filtering、portable context facts、纯 manifest/exposure 契约、generic catalog snapshot provider、generic contextual prompt-manifest resolver、provider-backed catalog runtime facade、GetToolSpec presentation/schema/detail/static metadata/tool-use message 纯 helper、provider-neutral execution-plan、provider-backed execution result helper、runtime facade、Tool-result vector adapter 和 result assembly helper 由 `bitfun-agent-tools` 拥有。 +- `bitfun-core::agentic::tools` 现在通过 `product_runtime.rs` 统一保留 product snapshot wrapper 注入、旧构造函数、`dyn Tool` 到 generic registry / catalog snapshot provider / GetToolSpec catalog provider 的适配、`ToolUseContext` runtime handle / service owner,以及 product registry snapshot access / agent policy / `GetToolSpecTool` Tool impl;core 静态 provider 组顺序和工具名来自 `bitfun-tool-packs` provider plan,core 只负责 concrete tool materialization。dynamic metadata map、tool map、dynamic descriptor assembly、static provider 安装合约、generic decorator reference / snapshot decorator adapter / provider-install assembly、generic readonly/enabled filtering、portable context facts、纯 manifest/exposure 契约、generic catalog snapshot provider、generic contextual prompt-manifest resolver、provider-backed catalog runtime facade、GetToolSpec presentation/schema/detail/static metadata/tool-use message 纯 helper、provider-neutral execution-plan、provider-backed execution result helper、runtime facade、Tool-result vector adapter 和 result assembly helper 由 `bitfun-agent-tools` 拥有。 - `bitfun-tool-packs` 默认 feature 为空,`product-full` 只聚合 feature;当前提供 `ToolPackFeatureGroup` / `all_feature_groups` / `enabled_feature_groups` 元数据和 `product_tool_provider_group_plan`,不注册或迁移任何工具实现。 - 已通过 boundary check 锁定 `agent-tools` / `tool-packs` 暂不拥有 full product tool runtime assembly、`GetToolSpecTool` Tool impl、collapsed-tool unlock state owner 或 concrete tools;`tool-packs` 也不得拥有 manifest/exposure 契约。`agent-tools` 只允许拥有纯 manifest/exposure helper、generic catalog snapshot provider、generic GetToolSpec catalog provider、provider-backed `ToolCatalogRuntime`、generic contextual manifest resolver、generic readonly/enabled filter、generic decorator reference / snapshot decorator adapter、GetToolSpec presentation/schema/detail/static metadata/tool-use message/execution-plan/result assembly helper、provider-backed execution result helper / runtime facade / Tool-result vector adapter 和不依赖具体工具的 provider 安装 / runtime assembly 合约,core product tool runtime 继续负责产品 registry snapshot、agent policy、concrete tool materialization、product snapshot wrapper adapter、`dyn Tool` / `ToolUseContext` adapter、unlock state source 和执行路径。 - boundary check 也已补充 core owner anchor:要求产品工具注册、expanded/collapsed manifest、`GetToolSpec` duplicate-load guard、`ToolUseContext.unlocked_collapsed_tools`、执行管线 gating 与 execution unlock collector 仍保留在 core。后续若迁移这些 owner,必须先更新 port/provider 设计、等价测试与该脚本,而不能只删除 core 侧实现。 @@ -1098,7 +1098,7 @@ pub fn create_tool_registry() -> ToolRegistry { - 最新主干新增的 Deep Review shared-context / evidence-ledger checkpoint hook 仍保留在 core 的 `ToolUseContext` 中;在设计独立 tool context / event port 前,不应把 `ToolUseContext` 或 concrete tool implementation 继续外移。 - 最新主干新增 on-demand tool spec discovery:`ToolExposure`、`GetToolSpec` 名称、collapsed prompt stub、manifest ordering、generic collapsed exposure 查询、generic catalog snapshot provider、generic GetToolSpec catalog provider、generic contextual prompt-manifest resolver 与 GetToolSpec presentation/schema/detail 的纯契约已可由 `bitfun-agent-tools` 承载;product registry snapshot、product collapsed-tool catalog source、core `dyn Tool` / `ToolUseContext` adapter、context-aware `description_with_context` / `input_schema_for_model_with_context` 的实际调用、`GetToolSpecTool` Tool impl / `BitFunError` 映射以及 `ToolUseContext.unlocked_collapsed_tools` 仍会影响模型可见工具集合。该变化不推翻 PR4 的低风险结论,但把后续 tool/provider 迁移提升为高风险项,不能在 product-domain runtime 收尾中顺带执行。 - H1 start(2026-05-19):`StaticToolProviderGroup` 通用容器已迁入 - `bitfun-agent-tools`,core 的 `static_providers.rs` 只负责实例化 concrete tools 并按既有 + `bitfun-agent-tools`,core 的 `product_runtime.rs` 只负责实例化 concrete tools 并按既有 provider group 顺序装配。该切片不移动 concrete tool implementation、`ToolUseContext`、 runtime manifest assembly 或 `GetToolSpec` 执行;provider id、工具顺序与 manifest 快照由 `bitfun-agent-tools` contract test、core registry snapshot 和 boundary check 共同保护。 @@ -1110,7 +1110,7 @@ pub fn create_tool_registry() -> ToolRegistry { 才进入 `ToolUseContext`、runtime manifest assembly、`GetToolSpecTool` 执行或 concrete tools 的单一 owner 设计与等价性证明。 - H2 本轮完成(2026-05-19):已先在 core 内部收敛 product tool runtime assembly owner。 - `ProductToolRuntimeAssembly` 负责安装 core-owned static provider groups 与 product snapshot wrapper adapter, + `ProductToolRuntime` 负责安装 core-owned static provider groups 与 product snapshot wrapper adapter, `ToolRegistry` 只保留对 `bitfun-agent-tools` generic registry 的兼容容器和动态工具入口。 该切片仍不迁移 `ToolUseContext`、runtime manifest assembly、`GetToolSpecTool` 执行、 product collapsed-tool catalog 或 concrete tool implementation。 @@ -1136,7 +1136,7 @@ pub fn create_tool_registry() -> ToolRegistry { 执行、collapsed unlock state 或 snapshot decorator。 - 已合入 PR #803(2026-05-20):收敛 H1 前的 core product tool owner 边界,而不迁移 runtime owner。 `tool_adapter.rs` 只承接 core `Tool` 到 `bitfun-agent-tools` provider-neutral contract 的 - adapter;`catalog_provider.rs` 只承接 product registry snapshot、contextual catalog、 + adapter;`product_runtime.rs` 只承接 product registry snapshot、contextual catalog、 manifest 和 GetToolSpec catalog/detail provider facade;`manifest_resolver` 与 `GetToolSpecTool` 只保留旧路径 result type 转换、execution wrapper、duplicate-load guard 和 assistant result rendering。该切片仍不迁移 `ToolUseContext`、runtime manifest assembly、`GetToolSpecTool` @@ -1152,7 +1152,7 @@ pub fn create_tool_registry() -> ToolRegistry { - H1 generic runtime assembly slice(2026-05-20):将 static-provider 安装 assembly 的通用 顺序与 decorator 应用规则收敛为 `bitfun-agent-tools::ToolRuntimeAssembly`,并从 `bitfun-agent-tools` 导出 `ToolDecoratorRef` 作为通用 decorator reference contract;core - `ProductToolRuntimeAssembly` 只保留 concrete provider group 来源、product snapshot wrapper adapter 注入和 + `ProductToolRuntime` 只保留 concrete provider group 来源、product snapshot wrapper adapter 注入和 旧路径 `ToolRegistry` wrapper。该切片不迁移 `ToolUseContext`、`GetToolSpecTool` Tool impl、 runtime manifest facade、collapsed unlock state、snapshot wrapper implementation 或 concrete tools。 @@ -1168,13 +1168,13 @@ pub fn create_tool_registry() -> ToolRegistry { Tool impl。 - H1 GetToolSpec runtime facade slice(2026-05-20):将 provider-backed catalog description 与 execution result 入口收敛为 `bitfun-agent-tools::GetToolSpecRuntime`; - core `catalog_provider.rs` 只负责 product provider / context / unlock-state 注入, + core `product_runtime.rs` 只负责 product provider / context / unlock-state 注入, `GetToolSpecTool` 仍保留 Tool impl、`BitFunError` 映射和 assistant rendering 旧路径兼容。 该切片不迁移 `ToolUseContext`、runtime manifest facade、collapsed unlock state owner 或 concrete tools。 - H1 tool catalog runtime facade slice(2026-05-20):将 provider-backed visible-tools、 prompt-visible manifest 和 readonly enabled catalog 查询收敛为 - `bitfun-agent-tools::ToolCatalogRuntime`;core `catalog_provider.rs` 只负责 product registry + `bitfun-agent-tools::ToolCatalogRuntime`;core `product_runtime.rs` 只负责 product registry snapshot、agent policy、`dyn Tool` / `ToolUseContext` adapter 和 product facade,`ToolRegistry` 只通过 product facade 查询 readonly tools。该切片不迁移 `ToolUseContext`、`GetToolSpecTool` Tool impl、collapsed unlock state、snapshot wrapper implementation 或 concrete tools。 @@ -1818,7 +1818,7 @@ owner-by-owner 迁移队列,不得把它们当作 H4 漏项补进当前 PR。 | 后续项 | 性质 | 范围 | 不允许混入 | 合入门禁 | |---|---|---|---|---| -| HR1:tool runtime deep owner migration | 条件性高风险 PR | `ToolUseContext`、runtime manifest assembly、`GetToolSpecTool` Tool impl、collapsed unlock state、snapshot wrapper implementation、concrete tools 或 product registry / provider assembly 的单一 owner 迁移;也可以明确评审后继续 core-owned | MiniApp/function-agent runtime、remote-connect / remote-SSH runtime、default feature 或构建收益声明 | 先补 port/provider 设计;证明 builtin/readonly/dynamic manifest、expanded/collapsed exposure、unlock state、dynamic provider metadata、snapshot wrapping、runtime restrictions、cancellation 和 Deep Review tool flow 等价 | +| HR1:tool runtime deep owner migration | 条件性高风险 PR | 当前已完成 core 内部 `product_runtime.rs` 单一 owner 收口;后续若继续深迁移,只允许在单独评审后移动 `ToolUseContext`、runtime manifest execution owner、`GetToolSpecTool` Tool impl、collapsed unlock state、snapshot wrapper implementation 或 concrete tools,也可以明确继续 core-owned | MiniApp/function-agent runtime、remote-connect / remote-SSH runtime、default feature 或构建收益声明 | 先补 port/provider 设计;证明 builtin/readonly/dynamic manifest、expanded/collapsed exposure、unlock state、dynamic provider metadata、snapshot wrapping、runtime restrictions、cancellation 和 Deep Review tool flow 等价 | | HR2:product-domain runtime deep owner migration | 条件性高风险 PR | MiniApp filesystem IO、worker process execution、host dispatch、built-in asset include / seed / marker IO / recompile,或 function-agent Git/AI service adapter / AI client call 的单一 owner 迁移;也可以明确评审后继续 core-owned | tool runtime、remote-connect / remote-SSH runtime、CLI/Desktop/Remote/ACP surface 行为变更 | `product-domains` 不依赖 core;先锁定 PathManager、process execution、permission policy、Git/AI error/transport mapping 和 focused MiniApp/function-agent regression | | HR3:service / agent runtime deep owner migration | 条件性高风险 PR | remote-SSH manager / remote FS / terminal、remote-connect workspace-root source / response wrapping / `ImageContextData` concrete impl / concrete scheduler-session-restore-terminal adapter、agent registry / scheduler 的单一 owner 迁移;也可以明确评审后继续 core-owned | tool runtime、product-domain runtime、feature matrix 或产品逻辑变更 | 有 port/provider 设计、旧路径兼容、mode-scoped subagent visibility / background delivery / remote-connect dialog order / remote workspace guard / DeepResearch post-turn hook 等行为等价测试 | | H5:feature/build-benefit evaluation | 可选评估 PR | `bitfun-core default = []`、per-product explicit feature set、依赖版本收敛、构建收益数据记录 | 任何 runtime owner 迁移、产品逻辑变更或构建脚本改造 | 有 feature graph baseline、`cargo check -p bitfun-core`、workspace check、目标 crate check 和必要 product check 的前后数据;若收益不清晰则不执行 | @@ -1845,6 +1845,9 @@ owner-by-owner 迁移队列,不得把它们当作 H4 漏项补进当前 PR。 HR1:tool runtime deep owner migration 的主要风险和控制点: +- 当前已完成的低侵入部分:`product_runtime.rs` 统一承接 product provider plan + materialization、product registry snapshot/catalog facade、manifest / GetToolSpec facade 和 + snapshot wrapper 注入;这只是 core 内部 owner closure,不改变工具执行路径。 - 风险:`ToolUseContext` 携带 workspace services、cancellation、computer-use host、 custom data、Deep Review checkpoint hook 与 collapsed unlock state;若直接移动, 可能改变工具可见性、权限、snapshot wrapper、Deep Review tool flow 或取消语义。 @@ -2050,19 +2053,19 @@ git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts 17. 已完成:remote-connect runtime 当前批次收口。已基于当前 port baseline 记录 remote command/response、remote model catalog、poll response、model catalog delta、session restore、active turn、cancel、image context、tracker event、queue/event fanout 与 dialog orchestration 顺序的输入输出和验证命令;tracker state / registry lifecycle、legacy image context fallback / preference、restore target decision、cancel decision、remote file transfer size/chunk/name policy、remote workspace file IO/path helper、image-context adapter contract 与 RemoteRelay/Bot dialog submission orchestration 已迁入 `bitfun-services-integrations`。dispatcher compatibility wrapper、product execution、workspace-root source、response/base64 wrapping、`ImageContextData` concrete impl、concrete terminal pre-warm adapter 与 workspace/session restore 执行显式保留在 core-owned runtime;后续只有在另起 port/provider 设计且 focused regression 继续通过时才允许继续移动这些 runtime owner,不能把 generic attachment guard 当作已接入多模态行为。 18. 已完成:`product-domains` runtime port/facade closure。已迁入 MiniApp storage-backed runtime-state facade、built-in seed plan / marker wire helper 与 function-agent Git/AI port-backed runtime facade,并补充 focused contract tests;core 只对 MiniApp deps/restart/recompile/sync/rollback 的状态持久化委托 facade,仍保留 `PathManager` 注入、filesystem IO、worker process execution、host dispatch 执行、built-in asset seeding/source-hash lookup。Git commit-message 与 Startchat work-state 产品路径已通过 core-owned Git/AI adapter 接入 function-agent facade;H2 已将 function-agent prompt template、AI response JSON extraction、JSON repair、domain error mapping 与 JSON-to-domain parsing policy 迁入 product-domain,core 继续保留 Git/AI service adapter、AI client 调用、provider acquisition 与 AI transport error mapping。Startchat 接线已用 no-HEAD diff fallback、非 Git 目录空状态和 `analyze_git=false` time-info 保护旧行为,`analyzed_at` 仍由 core 在 AI 分析完成后赋值。 19. 已合入:tool runtime owner 迁移前置基线。纯 helper 已从 runtime owner 中剥离到 `bitfun-agent-tools`:`StaticToolProviderGroup`、registry snapshot 到 manifest policy input、generic collapsed exposure 查询、`GetToolSpec` collapsed-load 纯收集规则、prompt-visible manifest definition 组装规则和 `GetToolSpec` catalog/detail helper;完整 collapsed 工具清单、runtime context 传递和 portable facts 边界回归已作为迁移前基线。 -20. 已合入:core-owned tool runtime assembly closure。`ProductToolRuntimeAssembly` 作为 core 内部单一 owner 收敛 static provider 安装和 product snapshot wrapper adapter,并保持 legacy `create_tool_registry()`、global registry、dynamic MCP tools、manifest resolver、`GetToolSpecTool` 执行、`ToolUseContext` 和 concrete tools 的行为边界不变。 +20. 已合入:core-owned tool runtime assembly closure。`ProductToolRuntime` 作为 core 内部单一 owner 收敛 static provider 安装和 product snapshot wrapper adapter,并保持 legacy `create_tool_registry()`、global registry、dynamic MCP tools、manifest resolver、`GetToolSpecTool` 执行、`ToolUseContext` 和 concrete tools 的行为边界不变。 21. 已合入:provider/assembly equivalence guards。custom decorator、provider install、collapsed catalog、manifest / `GetToolSpec` unlock surface 已有等价保护;这些是 behavior-locking tests,不是 runtime owner 迁移。 22. 已合入:`ToolUseContext` portable facts owner guard。可外移 facts 与 runtime-only fields 已分界;collapsed unlock state、custom data、workspace services、cancellation token、computer-use host 与 Deep Review checkpoint hook 继续 core-owned。 23. 已合入:`GetToolSpec` unlock state guard。execution 侧只接受成功的 `GetToolSpec` 结果、只输出 collapsed 白名单工具,并保持去重和过滤语义;`GetToolSpecTool` 执行、runtime manifest assembly、`ToolUseContext` 和 concrete tools 不迁移。 24. 已合入:contextual manifest owner migration。context-aware prompt manifest / visible-tools resolution 通用算法、generic catalog snapshot provider、generic GetToolSpec catalog provider 和 `GetToolSpec` collapsed summary/detail helper 已迁入 `bitfun-agent-tools`;core 仍保留 product registry snapshot access、product collapsed catalog source、core `Tool` / `ToolUseContext` adapter 与旧路径返回类型。 -25. 已合入:product tool adapter/catalog facade closure。`tool_adapter.rs` 承接 core `Tool` 到 provider-neutral contract 的 adapter,`catalog_provider.rs` 承接 product registry snapshot、contextual catalog、manifest、GetToolSpec catalog-description/detail provider facade;`manifest_resolver` 和 `GetToolSpecTool` 只保留旧路径 result type 转换、execution wrapper、duplicate-load guard 与 assistant result rendering。 +25. 已合入:product tool adapter/catalog facade closure。`tool_adapter.rs` 承接 core `Tool` 到 provider-neutral contract 的 adapter,`product_runtime.rs` 承接 product registry snapshot、contextual catalog、manifest、GetToolSpec catalog-description/detail provider facade;`manifest_resolver` 和 `GetToolSpecTool` 只保留旧路径 result type 转换、execution wrapper、duplicate-load guard 与 assistant result rendering。 26. 已完成:H1 GetToolSpec runtime facade owner slice。`bitfun-agent-tools` 接管 static tool surface(name / description / schema / readonly / concurrency / permission / validation / tool-use message)、input extraction、duplicate-load planning、duplicate-load result、provider-backed detail lookup 到 result assembly 的组装规则和 typed execution error;core 继续持有 `GetToolSpecTool` Tool impl、`ToolUseContext.unlocked_collapsed_tools` 状态来源、product provider/context 注入和 `BitFunError` 映射;该阶段不迁移 runtime manifest assembly、unlock state owner、snapshot decorator 或 concrete tools。 -27. 已完成:H1 generic runtime assembly owner slice。`bitfun-agent-tools` 接管 static-provider 安装 assembly 的通用顺序、decorator reference contract、generic snapshot decorator adapter 与 decorator 应用规则;core `ProductToolRuntimeAssembly` 继续持有 concrete provider group 来源、product snapshot wrapper adapter 注入、旧路径 `ToolRegistry` wrapper、product registry snapshot access 和 dynamic MCP tool entry,不迁移 `ToolUseContext`、`GetToolSpecTool` Tool impl、runtime manifest facade、unlock state owner、snapshot wrapper implementation 或 concrete tools。 +27. 已完成:H1 generic runtime assembly owner slice。`bitfun-agent-tools` 接管 static-provider 安装 assembly 的通用顺序、decorator reference contract、generic snapshot decorator adapter 与 decorator 应用规则;core `ProductToolRuntime` 继续持有 concrete provider group 来源、product snapshot wrapper adapter 注入、旧路径 `ToolRegistry` wrapper、product registry snapshot access 和 dynamic MCP tool entry,不迁移 `ToolUseContext`、`GetToolSpecTool` Tool impl、runtime manifest facade、unlock state owner、snapshot wrapper implementation 或 concrete tools。 28. 已完成:H1 readonly filter owner slice。`bitfun-agent-tools` 接管 registry snapshot 上 readonly + enabled 过滤的通用规则;core 继续持有 product snapshot access、`dyn Tool` adapter 和各 concrete tool 的 readonly/enabled 判定,不迁移 `ToolUseContext`、runtime manifest facade、`GetToolSpecTool`、snapshot wrapper implementation 或 concrete tools。 29. 已完成:H1 tool catalog runtime facade slice。`bitfun-agent-tools::ToolCatalogRuntime` 接管 provider-backed visible-tools、prompt-visible manifest 与 readonly enabled catalog 查询入口;core 继续持有 product registry snapshot、agent policy、`dyn Tool` / `ToolUseContext` adapter 和 product facade,不迁移 `ToolUseContext`、`GetToolSpecTool` Tool impl、collapsed unlock state、snapshot wrapper implementation 或 concrete tools。 -30. 已完成:H1 GetToolSpec Tool adapter facade slice。`bitfun-agent-tools::GetToolSpecRuntime::call_results` 接管单次执行结果到 `Vec` 的通用适配形状,core `catalog_provider.rs` 暴露 product `resolve_product_get_tool_spec_results`,`GetToolSpecTool::call_impl` 只保留 product facade 委托和 `BitFunError` 映射;不迁移 `ToolUseContext`、runtime manifest assembly、unlock state owner、assistant rendering 语义或 concrete tools。 -31. 已完成:H1 product provider plan closure。`bitfun-tool-packs::product_tool_provider_group_plan` 接管 product provider group id / feature group / tool-name order 计划,core `static_providers.rs` 只按该计划物化 concrete tools 并继续注入 snapshot wrapper;不迁移 concrete tool implementation、`ToolUseContext`、runtime service handles 或 tool behavior。 -32. H1 闭环状态:工具 runtime 的 provider-neutral contract、manifest/catalog runtime facade、GetToolSpec facade、static-provider assembly、readonly filtering、provider plan 与 core product adapter 已收敛。`ToolUseContext` 本体和 concrete tools 仍显式 core-owned;继续外移会触碰 workspace services、cancellation、Deep Review hooks 或具体工具 IO,必须作为后续高风险 owner 迁移单独确认。 +30. 已完成:H1 GetToolSpec Tool adapter facade slice。`bitfun-agent-tools::GetToolSpecRuntime::call_results` 接管单次执行结果到 `Vec` 的通用适配形状,core `product_runtime.rs` 暴露 product `resolve_product_get_tool_spec_results`,`GetToolSpecTool::call_impl` 只保留 product facade 委托和 `BitFunError` 映射;不迁移 `ToolUseContext`、runtime manifest assembly、unlock state owner、assistant rendering 语义或 concrete tools。 +31. 已完成:H1 product provider plan closure。`bitfun-tool-packs::product_tool_provider_group_plan` 接管 product provider group id / feature group / tool-name order 计划,core `product_runtime.rs` 只按该计划物化 concrete tools 并继续注入 snapshot wrapper;不迁移 concrete tool implementation、`ToolUseContext`、runtime service handles 或 tool behavior。 +32. HR1 当前闭环状态:工具 runtime 的 provider-neutral contract、manifest/catalog runtime facade、GetToolSpec facade、static-provider assembly、readonly filtering、provider plan 与 core product adapter 已收敛;core 内部 product runtime adapter 已统一到 `product_runtime.rs`。`ToolUseContext` 本体和 concrete tools 仍显式 core-owned;继续外移会触碰 workspace services、cancellation、Deep Review hooks 或具体工具 IO,必须作为后续高风险 owner 迁移单独确认。 33. H4 已完成:facade / boundary finalization。`scripts/check-core-boundaries.mjs` 的 regular check 和 self-test 已覆盖 remote-connect file/image/dialog owner anchor、core adapter/deferred owner anchor 与既有 duplicate-path required rule;root / core / services-integrations 文档与当前 H1-H3 代码状态一致,不声明 remote-SSH runtime、agent registry/scheduler、default feature 或构建收益已完成。 34. 后续独立评估:`bitfun-core default = []`、per-product feature set、依赖版本收敛或构建收益优化;任何收益声明都需要记录 `cargo check -p bitfun-core`、workspace check 和目标 crate check 的前后数据。 diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index 969f1b1211..c538cade56 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -1744,8 +1744,8 @@ const requiredContentRules = [ message: 'missing product decorator ref alias using agent-tools contract', }, { - regex: /\bProductToolRuntimeAssembly\b/, - message: 'missing product tool runtime assembly delegation', + regex: /\bProductToolRuntime\b/, + message: 'missing product tool runtime owner delegation', }, { regex: /\bget_collapsed_tool_names\b/, @@ -1756,7 +1756,7 @@ const requiredContentRules = [ message: 'missing product tool catalog readonly facade delegation', }, { - regex: /\bregistry_preserves_collapsed_tool_manifest_for_owner_migration\b/, + regex: /\bproduct_tool_runtime_owner_preserves_registry_contract\b/, message: 'missing collapsed-tool manifest migration baseline', }, { @@ -1766,13 +1766,13 @@ const requiredContentRules = [ ], }, { - path: 'src/crates/core/src/agentic/tools/runtime_assembly.rs', + path: 'src/crates/core/src/agentic/tools/product_runtime.rs', reason: - 'core must keep product tool runtime assembly explicit until ToolUseContext and concrete tool-pack migration are reviewed', + 'core product tool runtime owner keeps registry assembly, static tool materialization, catalog manifests, and GetToolSpec facades explicit until concrete tools migrate', patterns: [ { - regex: /\bProductToolRuntimeAssembly\b/, - message: 'missing core product tool runtime assembly owner', + regex: /\bProductToolRuntime\b/, + message: 'missing core product tool runtime owner', }, { regex: /\bSnapshotToolDecorator\b/, @@ -1786,29 +1786,6 @@ const requiredContentRules = [ regex: /\bbuiltin_static_tool_providers\b/, message: 'missing builtin provider assembly input', }, - { - regex: /\bToolRuntimeAssembly\b/, - message: 'missing generic agent-tools runtime assembly delegation', - }, - { - regex: /\bcreate_registry_from_static_providers\b/, - message: 'missing generic static provider assembly delegation', - }, - { - regex: /\bwrap_tool_for_snapshot_tracking\b/, - message: 'missing snapshot wrapper boundary', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/static_providers.rs', - reason: - 'core materializes concrete tools from the tool-pack provider plan until concrete tools migrate', - patterns: [ - { - regex: /\bbuiltin_static_tool_providers\b/, - message: 'missing builtin static tool provider owner', - }, { regex: /\bStaticToolProviderGroup\b/, message: 'missing generic static provider group contract use', @@ -1825,48 +1802,18 @@ const requiredContentRules = [ regex: /\bGetToolSpecTool::new\(\)/, message: 'missing GetToolSpec registration anchor', }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/tool_adapter.rs', - reason: - 'core must keep the product Tool-to-agent-tools adapters explicit until ToolUseContext and concrete tools migrate', - patterns: [ - { - regex: /\bimpl ToolRegistryItem for dyn Tool\b/, - message: 'missing core Tool registry adapter', - }, { - regex: /\bimpl ContextualToolManifestItem for dyn Tool\b/, - message: 'missing core ToolUseContext contextual manifest adapter', - }, - { - regex: /\bTool::dynamic_tool_info\b/, - message: 'missing dynamic tool metadata adapter', - }, - { - regex: /\bTool::is_readonly\b/, - message: 'missing readonly metadata adapter', - }, - { - regex: /\bTool::is_enabled\b/, - message: 'missing enabled-state metadata adapter', + regex: /\bToolRuntimeAssembly\b/, + message: 'missing generic agent-tools runtime assembly delegation', }, { - regex: /\bTool::description_with_context\b/, - message: 'missing context-aware tool description adapter', + regex: /\bcreate_registry_from_static_providers\b/, + message: 'missing generic static provider assembly delegation', }, { - regex: /\bTool::input_schema_for_model_with_context\b/, - message: 'missing context-aware tool schema adapter', + regex: /\bwrap_tool_for_snapshot_tracking\b/, + message: 'missing snapshot wrapper boundary', }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/catalog_provider.rs', - reason: - 'core must keep the product tool catalog provider explicit until ToolUseContext, manifest assembly, and GetToolSpec execution migration are reviewed', - patterns: [ { regex: /\bProductToolCatalogProvider\b/, message: 'missing core product tool catalog provider owner', @@ -1923,12 +1870,51 @@ const requiredContentRules = [ regex: /\bproduct_catalog_provider_default_get_tool_spec_catalog_matches_registry\b/, message: 'missing product catalog provider collapsed catalog regression', }, + { + regex: /\bproduct_tool_runtime_owner_preserves_registry_contract\b/, + message: 'missing product runtime owner registry equivalence regression', + }, { regex: /\bGetToolSpec requires agent type context\b/, message: 'missing contextual GetToolSpec catalog validation boundary', }, ], }, + { + path: 'src/crates/core/src/agentic/tools/tool_adapter.rs', + reason: + 'core must keep the product Tool-to-agent-tools adapters explicit until ToolUseContext and concrete tools migrate', + patterns: [ + { + regex: /\bimpl ToolRegistryItem for dyn Tool\b/, + message: 'missing core Tool registry adapter', + }, + { + regex: /\bimpl ContextualToolManifestItem for dyn Tool\b/, + message: 'missing core ToolUseContext contextual manifest adapter', + }, + { + regex: /\bTool::dynamic_tool_info\b/, + message: 'missing dynamic tool metadata adapter', + }, + { + regex: /\bTool::is_readonly\b/, + message: 'missing readonly metadata adapter', + }, + { + regex: /\bTool::is_enabled\b/, + message: 'missing enabled-state metadata adapter', + }, + { + regex: /\bTool::description_with_context\b/, + message: 'missing context-aware tool description adapter', + }, + { + regex: /\bTool::input_schema_for_model_with_context\b/, + message: 'missing context-aware tool schema adapter', + }, + ], + }, { path: 'src/crates/agent-tools/src/framework.rs', reason: 'agent-tools owns portable tool facts plus generic registry and provider contracts', @@ -3895,31 +3881,41 @@ function runManifestParserSelfTest() { contracts: [ 'from_inner', 'ProductToolDecoratorRef', - 'ProductToolRuntimeAssembly', + 'ProductToolRuntime', 'get_collapsed_tool_names', 'resolve_product_readonly_enabled_tools', ], }, { - path: 'src/crates/core/src/agentic/tools/runtime_assembly.rs', + path: 'src/crates/core/src/agentic/tools/product_runtime.rs', contracts: [ - 'ProductToolRuntimeAssembly', + 'ProductToolRuntime', 'SnapshotToolDecorator', 'ProductSnapshotToolWrapper', - 'builtin_static_tool_providers', - 'ToolRuntimeAssembly', - 'create_registry_from_static_providers', - 'wrap_tool_for_snapshot_tracking', - ], - }, - { - path: 'src/crates/core/src/agentic/tools/static_providers.rs', - contracts: [ 'builtin_static_tool_providers', 'StaticToolProviderGroup', 'product_tool_provider_group_plan', 'materialize_tool', 'GetToolSpecTool', + 'ToolRuntimeAssembly', + 'create_registry_from_static_providers', + 'wrap_tool_for_snapshot_tracking', + 'ProductToolCatalogProvider', + 'ToolCatalogSnapshotProvider', + 'GetToolSpecCatalogProvider', + 'get_global_tool_registry', + 'get_agent_registry', + 'ToolCatalogRuntime', + 'product_tool_catalog_runtime', + 'GetToolSpecRuntime', + 'product_get_tool_spec_runtime', + 'resolve_product_tool_manifest', + 'resolve_product_readonly_enabled_tools', + 'resolve_product_get_tool_spec_results', + 'unlocked_collapsed_tools', + 'product_catalog_provider_default_get_tool_spec_catalog_matches_registry', + 'product_tool_runtime_owner_preserves_registry_contract', + 'GetToolSpec requires agent type context', ], }, { @@ -3968,26 +3964,6 @@ function runManifestParserSelfTest() { 'Tool::input_schema_for_model_with_context', ], }, - { - path: 'src/crates/core/src/agentic/tools/catalog_provider.rs', - contracts: [ - 'ProductToolCatalogProvider', - 'ToolCatalogSnapshotProvider', - 'GetToolSpecCatalogProvider', - 'get_global_tool_registry', - 'get_agent_registry', - 'ToolCatalogRuntime', - 'product_tool_catalog_runtime', - 'GetToolSpecRuntime', - 'product_get_tool_spec_runtime', - 'resolve_product_tool_manifest', - 'resolve_product_readonly_enabled_tools', - 'resolve_product_get_tool_spec_results', - 'unlocked_collapsed_tools', - 'product_catalog_provider_default_get_tool_spec_catalog_matches_registry', - 'GetToolSpec requires agent type context', - ], - }, { path: 'src/crates/core/src/agentic/tools/manifest_resolver.rs', contracts: [ diff --git a/src/crates/core/AGENTS-CN.md b/src/crates/core/AGENTS-CN.md index d319ef4a79..7c94631458 100644 --- a/src/crates/core/AGENTS-CN.md +++ b/src/crates/core/AGENTS-CN.md @@ -29,7 +29,7 @@ SessionManager → Session → DialogTurn → ModelRound - 使用 `bitfun_events::EventEmitter` 等共享抽象 - 桌面端专属集成应放在 `src/apps/desktop`,再通过 transport / API layer 连接回来 - core 拆解期间,`bitfun-core` 是兼容 facade 与完整产品 runtime assembly 点;新模块优先放到 `docs/architecture/core-decomposition.md` 指定的 owner crate。 -- Tool 相关轻量 contract、portable tool context facts/provider、纯 manifest/exposure contract 与 generic registry / static-provider / dynamic-provider container 归属 `bitfun-agent-tools`;core tool runtime 当前负责产品工具组装、`dyn Tool` 适配、snapshot decoration、runtime manifest assembly / context filtering,以及按需工具说明发现(`GetToolSpec`)执行。 +- Tool 相关轻量 contract、portable tool context facts/provider、纯 manifest/exposure contract 与 generic registry / static-provider / dynamic-provider container 归属 `bitfun-agent-tools`;core tool runtime 通过 `product_runtime.rs` 统一负责产品工具组装、`dyn Tool` 适配、snapshot decoration、runtime manifest assembly / context filtering,以及按需工具说明发现(`GetToolSpec`)执行。 - `ToolUseContext` 与具体工具实现继续留在 core,除非已有评审过的 port/provider 方案和等价测试。 - Tool 迁移必须保持 expanded/collapsed exposure、prompt 可见 manifest、`ToolUseContext.unlocked_collapsed_tools`,以及 desktop/MCP/ACP tool catalog 行为等价。 - 不要把 OpenAI Responses / Codex ChatGPT flat tool schema 等 provider-specific 序列化行为写进 core tool contract;AI adapter 负责 provider 序列化,core 保持 provider-neutral manifest。 diff --git a/src/crates/core/AGENTS.md b/src/crates/core/AGENTS.md index 5d785571d1..b3d7d2cfff 100644 --- a/src/crates/core/AGENTS.md +++ b/src/crates/core/AGENTS.md @@ -40,12 +40,11 @@ SessionManager → Session → DialogTurn → ModelRound generic decorator references, snapshot decorator adapters, static-provider runtime assembly, and readonly/enabled registry-snapshot filtering belong in `bitfun-agent-tools`; core tool runtime should materialize concrete tools from the `bitfun-tool-packs` - provider group plan and keep product snapshot wrapper adapter injection in - `runtime_assembly.rs` + `static_providers.rs`, adapt core `Tool` into - provider-neutral contracts through `tool_adapter.rs`, keep product catalog - access and product manifest / GetToolSpec facade wiring in - `catalog_provider.rs`, apply snapshot decoration, and own on-demand spec - discovery Tool impl and unlock-state source for now. + provider group plan through `product_runtime.rs`, adapt core `Tool` into + provider-neutral contracts through `tool_adapter.rs`, and keep product + registry snapshot access, product manifest / GetToolSpec facade wiring, + product snapshot wrapper adapter injection, on-demand spec discovery Tool + impl, and unlock-state source in that product runtime owner for now. `bitfun-tool-packs` may expose planned feature-group scaffold metadata, but it must not own concrete tools yet. - Keep `ToolUseContext` and concrete tool implementations in core unless a diff --git a/src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs b/src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs index d3d27818ba..aa20a153d8 100644 --- a/src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs @@ -1,6 +1,6 @@ //! GetToolSpec tool implementation -use crate::agentic::tools::catalog_provider::{ +use crate::agentic::tools::product_runtime::{ build_product_get_tool_spec_catalog_description, product_get_tool_spec_runtime, resolve_product_get_tool_spec_results, ProductGetToolSpecRuntime, ProductToolCatalogProvider, }; diff --git a/src/crates/core/src/agentic/tools/manifest_resolver.rs b/src/crates/core/src/agentic/tools/manifest_resolver.rs index dd98ff0496..08889f8788 100644 --- a/src/crates/core/src/agentic/tools/manifest_resolver.rs +++ b/src/crates/core/src/agentic/tools/manifest_resolver.rs @@ -1,5 +1,5 @@ use crate::agentic::agents::AgentToolPolicyOverrides; -use crate::agentic::tools::catalog_provider::{ +use crate::agentic::tools::product_runtime::{ resolve_product_tool_manifest, resolve_product_visible_tools, }; use crate::agentic::tools::framework::{Tool, ToolUseContext}; diff --git a/src/crates/core/src/agentic/tools/mod.rs b/src/crates/core/src/agentic/tools/mod.rs index c0c0f6f788..28b25117d7 100644 --- a/src/crates/core/src/agentic/tools/mod.rs +++ b/src/crates/core/src/agentic/tools/mod.rs @@ -1,7 +1,6 @@ //! Tool system - includes Tool interface, tool registry and tool executor pub mod browser_control; -pub(crate) mod catalog_provider; pub mod computer_use_capability; pub mod computer_use_host; pub mod computer_use_optimizer; @@ -12,10 +11,9 @@ pub mod implementations; pub mod manifest_resolver; pub mod pipeline; pub(crate) mod post_call_hooks; +pub(crate) mod product_runtime; pub mod registry; pub mod restrictions; -pub(crate) mod runtime_assembly; -pub(crate) mod static_providers; pub(crate) mod tool_adapter; pub mod user_input_manager; pub mod workspace_paths; diff --git a/src/crates/core/src/agentic/tools/catalog_provider.rs b/src/crates/core/src/agentic/tools/product_runtime.rs similarity index 63% rename from src/crates/core/src/agentic/tools/catalog_provider.rs rename to src/crates/core/src/agentic/tools/product_runtime.rs index 5204105f0e..983414f59a 100644 --- a/src/crates/core/src/agentic/tools/catalog_provider.rs +++ b/src/crates/core/src/agentic/tools/product_runtime.rs @@ -1,14 +1,142 @@ +//! Core-owned product tool runtime owner. +//! +//! This module is the single core-side owner for assembling product tool +//! registry adapters, catalog manifests, GetToolSpec lookup, and snapshot +//! decoration. Concrete tools and `ToolUseContext` stay in core so this owner +//! remains an equivalent structural boundary rather than a behavior migration. + use crate::agentic::agents::{get_agent_registry, AgentToolPolicyOverrides}; use crate::agentic::tools::framework::{Tool, ToolExposure, ToolResult, ToolUseContext}; -use crate::agentic::tools::registry::{get_global_tool_registry, GET_TOOL_SPEC_TOOL_NAME}; +use crate::agentic::tools::implementations::*; +use crate::agentic::tools::registry::{ + get_global_tool_registry, ProductToolDecoratorRef, ToolRef, ToolRegistry, +}; use crate::util::errors::{BitFunError, BitFunResult}; +#[cfg(test)] +use bitfun_agent_tools::StaticToolProvider; use bitfun_agent_tools::{ ContextualToolManifest, ContextualVisibleTools, GetToolSpecCatalogProvider, - GetToolSpecExecutionError, GetToolSpecRuntime, ToolCatalogRuntime, ToolCatalogSnapshotProvider, + GetToolSpecExecutionError, GetToolSpecRuntime, SnapshotToolDecorator, SnapshotToolWrapper, + StaticToolProviderGroup, ToolCatalogRuntime, ToolCatalogSnapshotProvider, ToolRuntimeAssembly, + GET_TOOL_SPEC_TOOL_NAME, }; +use bitfun_tool_packs::product_tool_provider_group_plan; use serde_json::Value; use std::sync::Arc; +#[derive(Clone)] +pub(crate) struct ProductToolRuntime { + tool_decorator: ProductToolDecoratorRef, +} + +impl Default for ProductToolRuntime { + fn default() -> Self { + Self::new() + } +} + +impl ProductToolRuntime { + pub(crate) fn new() -> Self { + Self::with_tool_decorator(Arc::new(SnapshotToolDecorator::new(Arc::new( + ProductSnapshotToolWrapper, + )))) + } + + pub(crate) fn with_tool_decorator(tool_decorator: ProductToolDecoratorRef) -> Self { + Self { tool_decorator } + } + + #[cfg(test)] + pub(in crate::agentic::tools) fn provider_group_ids(&self) -> Vec<&'static str> { + builtin_static_tool_providers() + .iter() + .map(|provider| provider.provider_id()) + .collect() + } + + #[cfg(test)] + pub(in crate::agentic::tools) fn provider_tool_names(&self) -> Vec { + builtin_static_tool_providers() + .into_iter() + .flat_map(|provider| provider.tools()) + .map(|tool| tool.name().to_string()) + .collect() + } + + pub(crate) fn create_registry(&self) -> ToolRegistry { + let providers = builtin_static_tool_providers(); + let inner = ToolRuntimeAssembly::with_tool_decorator(self.tool_decorator.clone()) + .create_registry_from_static_providers(&providers); + ToolRegistry::from_inner(inner) + } +} + +#[derive(Debug, Clone)] +struct ProductSnapshotToolWrapper; + +impl SnapshotToolWrapper for ProductSnapshotToolWrapper { + fn wrap_for_snapshot_tracking(&self, tool: ToolRef) -> ToolRef { + crate::service::snapshot::wrap_tool_for_snapshot_tracking(tool) + } +} + +fn builtin_static_tool_providers() -> Vec> { + product_tool_provider_group_plan() + .iter() + .map(|group| { + StaticToolProviderGroup::new(group.provider_id(), materialize_tools(group.tool_names())) + }) + .collect() +} + +fn materialize_tools(tool_names: &[&str]) -> Vec> { + tool_names + .iter() + .map(|tool_name| materialize_tool(tool_name)) + .collect() +} + +fn materialize_tool(tool_name: &str) -> Arc { + match tool_name { + "LS" => Arc::new(LSTool::new()), + "Read" => Arc::new(FileReadTool::new()), + "Glob" => Arc::new(GlobTool::new()), + "Grep" => Arc::new(GrepTool::new()), + "Write" => Arc::new(FileWriteTool::new()), + "Edit" => Arc::new(FileEditTool::new()), + "Delete" => Arc::new(DeleteFileTool::new()), + "Bash" => Arc::new(BashTool::new()), + "Task" => Arc::new(TaskTool::new()), + "Skill" => Arc::new(SkillTool::new()), + "AskUserQuestion" => Arc::new(AskUserQuestionTool::new()), + "TodoWrite" => Arc::new(TodoWriteTool::new()), + "CreatePlan" => Arc::new(CreatePlanTool::new()), + "submit_code_review" => Arc::new(CodeReviewTool::new()), + "GetToolSpec" => Arc::new(GetToolSpecTool::new()), + "GetFileDiff" => Arc::new(GetFileDiffTool::new()), + "Log" => Arc::new(LogTool::new()), + "TerminalControl" => Arc::new(TerminalControlTool::new()), + "SessionControl" => Arc::new(SessionControlTool::new()), + "SessionMessage" => Arc::new(SessionMessageTool::new()), + "SessionHistory" => Arc::new(SessionHistoryTool::new()), + "Cron" => Arc::new(CronTool::new()), + "WebSearch" => Arc::new(WebSearchTool::new()), + "WebFetch" => Arc::new(WebFetchTool::new()), + "ListMCPResources" => Arc::new(ListMCPResourcesTool::new()), + "ReadMCPResource" => Arc::new(ReadMCPResourceTool::new()), + "ListMCPPrompts" => Arc::new(ListMCPPromptsTool::new()), + "GetMCPPrompt" => Arc::new(GetMCPPromptTool::new()), + "GenerativeUI" => Arc::new(GenerativeUITool::new()), + "Git" => Arc::new(GitTool::new()), + "ReviewPlatform" => Arc::new(ReviewPlatformTool::new()), + "InitMiniApp" => Arc::new(InitMiniAppTool::new()), + "ControlHub" => Arc::new(ControlHubTool::new()), + "ComputerUse" => Arc::new(ComputerUseTool::new()), + "Playbook" => Arc::new(PlaybookTool::new()), + _ => panic!("unknown product tool provider plan entry: {tool_name}"), + } +} + #[derive(Debug, Clone, Copy, Default)] pub(crate) struct ProductToolCatalogProvider; @@ -138,7 +266,7 @@ pub(crate) async fn resolve_product_get_tool_spec_results( mod tests { use super::{ resolve_product_get_tool_spec_results, resolve_product_readonly_enabled_tools, - resolve_product_tool_manifest, ProductToolCatalogProvider, + resolve_product_tool_manifest, ProductToolCatalogProvider, ProductToolRuntime, }; use crate::agentic::agents::AgentToolPolicyOverrides; use crate::agentic::tools::framework::ToolUseContext; @@ -168,6 +296,24 @@ mod tests { tool_context(None) } + #[test] + fn product_tool_runtime_owner_preserves_registry_contract() { + let runtime = ProductToolRuntime::default(); + let owner_registry = runtime.create_registry(); + let compatibility_registry = create_tool_registry(); + + assert_eq!( + owner_registry.get_tool_names(), + compatibility_registry.get_tool_names(), + "product tool runtime owner must preserve legacy registry output" + ); + assert_eq!( + owner_registry.get_collapsed_tool_names(), + compatibility_registry.get_collapsed_tool_names(), + "product tool runtime owner must preserve collapsed-tool exposure" + ); + } + #[tokio::test] async fn product_catalog_provider_reads_global_registry_snapshot() { let provider = ProductToolCatalogProvider; diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index 5530d937fa..d741e077b0 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -1,7 +1,9 @@ //! Tool registry -use crate::agentic::tools::catalog_provider::resolve_product_readonly_enabled_tools; use crate::agentic::tools::framework::{DynamicToolInfo, Tool}; +use crate::agentic::tools::product_runtime::{ + resolve_product_readonly_enabled_tools, ProductToolRuntime, +}; use crate::util::errors::BitFunResult; use bitfun_agent_tools::{ DynamicToolDescriptor, DynamicToolProvider, PortResult, ToolDecoratorRef, @@ -29,8 +31,7 @@ impl Default for ToolRegistry { impl ToolRegistry { /// Create a new tool registry pub fn new() -> Self { - crate::agentic::tools::runtime_assembly::ProductToolRuntimeAssembly::default() - .create_registry() + ProductToolRuntime::default().create_registry() } /// Create a registry with an injected decoration boundary. @@ -39,10 +40,7 @@ impl ToolRegistry { /// allowing future owner crates to replace this concrete service coupling /// through the `bitfun-runtime-ports` interface. pub fn with_tool_decorator(tool_decorator: ProductToolDecoratorRef) -> Self { - crate::agentic::tools::runtime_assembly::ProductToolRuntimeAssembly::with_tool_decorator( - tool_decorator, - ) - .create_registry() + ProductToolRuntime::with_tool_decorator(tool_decorator).create_registry() } pub(in crate::agentic::tools) fn from_inner(inner: AgentToolRegistry) -> Self { @@ -174,10 +172,9 @@ mod tests { use crate::agentic::tools::framework::{ DynamicMcpToolInfo, DynamicToolInfo, Tool, ToolResult, ToolUseContext, ValidationResult, }; - use crate::agentic::tools::runtime_assembly::ProductToolRuntimeAssembly; - use crate::agentic::tools::static_providers::builtin_static_tool_providers; + use crate::agentic::tools::product_runtime::ProductToolRuntime; use async_trait::async_trait; - use bitfun_agent_tools::{DynamicToolProvider, StaticToolProvider, ToolDecorator}; + use bitfun_agent_tools::{DynamicToolProvider, ToolDecorator}; use serde_json::json; use serde_json::Value; use std::sync::Arc; @@ -395,11 +392,7 @@ mod tests { #[test] fn builtin_static_tool_providers_cover_registry_manifest_in_order() { - let provider_tools = builtin_static_tool_providers() - .into_iter() - .flat_map(|provider| provider.tools()) - .map(|tool| tool.name().to_string()) - .collect::>(); + let provider_tools = ProductToolRuntime::default().provider_tool_names(); assert_eq!( provider_tools, @@ -410,10 +403,7 @@ mod tests { #[test] fn builtin_static_tool_providers_keep_owner_group_order() { - let provider_ids = builtin_static_tool_providers() - .into_iter() - .map(|provider| provider.provider_id()) - .collect::>(); + let provider_ids = ProductToolRuntime::default().provider_group_ids(); assert_eq!( provider_ids, @@ -429,10 +419,7 @@ mod tests { #[test] fn builtin_static_tool_providers_follow_tool_pack_group_plan() { - let provider_ids = builtin_static_tool_providers() - .into_iter() - .map(|provider| provider.provider_id()) - .collect::>(); + let provider_ids = ProductToolRuntime::default().provider_group_ids(); let planned_provider_ids = bitfun_tool_packs::product_tool_provider_group_plan() .iter() .map(|group| group.provider_id()) @@ -445,11 +432,11 @@ mod tests { } #[test] - fn product_tool_runtime_assembly_preserves_core_owned_registry_contract() { - let assembly = ProductToolRuntimeAssembly::default(); + fn product_tool_runtime_preserves_core_owned_registry_contract() { + let runtime = ProductToolRuntime::default(); assert_eq!( - assembly.provider_group_ids(), + runtime.provider_group_ids(), vec![ "core.basic", "core.agent", @@ -459,7 +446,7 @@ mod tests { "runtime assembly must keep core-owned provider group order explicit" ); - let assembled_registry = assembly.create_registry(); + let assembled_registry = runtime.create_registry(); let compatibility_registry = create_tool_registry(); assert_eq!( @@ -490,10 +477,27 @@ mod tests { } #[test] - fn product_tool_runtime_assembly_keeps_custom_decorator_provider_contract() { - let registry = - ProductToolRuntimeAssembly::with_tool_decorator(Arc::new(MarkerToolDecorator)) - .create_registry(); + fn product_tool_runtime_owner_preserves_registry_contract() { + let runtime = ProductToolRuntime::default(); + let owner_registry = runtime.create_registry(); + let compatibility_registry = create_tool_registry(); + + assert_eq!( + owner_registry.get_tool_names(), + compatibility_registry.get_tool_names(), + "product tool runtime owner must preserve legacy registry output" + ); + assert_eq!( + owner_registry.get_collapsed_tool_names(), + compatibility_registry.get_collapsed_tool_names(), + "product tool runtime owner must preserve collapsed-tool exposure" + ); + } + + #[test] + fn product_tool_runtime_keeps_custom_decorator_provider_contract() { + let registry = ProductToolRuntime::with_tool_decorator(Arc::new(MarkerToolDecorator)) + .create_registry(); let compatibility_registry = create_tool_registry(); assert_eq!( diff --git a/src/crates/core/src/agentic/tools/runtime_assembly.rs b/src/crates/core/src/agentic/tools/runtime_assembly.rs deleted file mode 100644 index b3ac0ec8da..0000000000 --- a/src/crates/core/src/agentic/tools/runtime_assembly.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! Core-owned product tool runtime assembly. -//! -//! This module is the single core-side owner for assembling the product tool -//! registry while concrete tools, `ToolUseContext`, runtime manifest assembly, -//! and snapshot decoration remain core-owned. - -use crate::agentic::tools::registry::{ProductToolDecoratorRef, ToolRef, ToolRegistry}; -use crate::agentic::tools::static_providers::builtin_static_tool_providers; -#[cfg(test)] -use bitfun_agent_tools::StaticToolProvider; -use bitfun_agent_tools::{SnapshotToolDecorator, SnapshotToolWrapper, ToolRuntimeAssembly}; -use std::sync::Arc; - -#[derive(Clone)] -pub(in crate::agentic::tools) struct ProductToolRuntimeAssembly { - tool_decorator: ProductToolDecoratorRef, -} - -impl Default for ProductToolRuntimeAssembly { - fn default() -> Self { - Self::new() - } -} - -impl ProductToolRuntimeAssembly { - pub(in crate::agentic::tools) fn new() -> Self { - Self::with_tool_decorator(Arc::new(SnapshotToolDecorator::new(Arc::new( - ProductSnapshotToolWrapper, - )))) - } - - pub(in crate::agentic::tools) fn with_tool_decorator( - tool_decorator: ProductToolDecoratorRef, - ) -> Self { - Self { tool_decorator } - } - - #[cfg(test)] - pub(in crate::agentic::tools) fn provider_group_ids(&self) -> Vec<&'static str> { - builtin_static_tool_providers() - .iter() - .map(|provider| provider.provider_id()) - .collect() - } - - pub(in crate::agentic::tools) fn create_registry(&self) -> ToolRegistry { - let providers = builtin_static_tool_providers(); - let inner = ToolRuntimeAssembly::with_tool_decorator(self.tool_decorator.clone()) - .create_registry_from_static_providers(&providers); - ToolRegistry::from_inner(inner) - } -} - -#[derive(Debug, Clone)] -struct ProductSnapshotToolWrapper; - -impl SnapshotToolWrapper - for ProductSnapshotToolWrapper -{ - fn wrap_for_snapshot_tracking(&self, tool: ToolRef) -> ToolRef { - crate::service::snapshot::wrap_tool_for_snapshot_tracking(tool) - } -} diff --git a/src/crates/core/src/agentic/tools/static_providers.rs b/src/crates/core/src/agentic/tools/static_providers.rs deleted file mode 100644 index 180534cd2c..0000000000 --- a/src/crates/core/src/agentic/tools/static_providers.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! Core-owned static tool provider assembly. - -use crate::agentic::tools::framework::Tool; -use crate::agentic::tools::implementations::*; -use bitfun_agent_tools::StaticToolProviderGroup; -use bitfun_tool_packs::product_tool_provider_group_plan; -use std::sync::Arc; - -pub(crate) fn builtin_static_tool_providers() -> Vec> { - product_tool_provider_group_plan() - .iter() - .map(|group| { - StaticToolProviderGroup::new(group.provider_id(), materialize_tools(group.tool_names())) - }) - .collect() -} - -fn materialize_tools(tool_names: &[&str]) -> Vec> { - tool_names - .iter() - .map(|tool_name| materialize_tool(tool_name)) - .collect() -} - -fn materialize_tool(tool_name: &str) -> Arc { - match tool_name { - "LS" => Arc::new(LSTool::new()), - "Read" => Arc::new(FileReadTool::new()), - "Glob" => Arc::new(GlobTool::new()), - "Grep" => Arc::new(GrepTool::new()), - "Write" => Arc::new(FileWriteTool::new()), - "Edit" => Arc::new(FileEditTool::new()), - "Delete" => Arc::new(DeleteFileTool::new()), - "Bash" => Arc::new(BashTool::new()), - "Task" => Arc::new(TaskTool::new()), - "Skill" => Arc::new(SkillTool::new()), - "AskUserQuestion" => Arc::new(AskUserQuestionTool::new()), - "TodoWrite" => Arc::new(TodoWriteTool::new()), - "CreatePlan" => Arc::new(CreatePlanTool::new()), - "submit_code_review" => Arc::new(CodeReviewTool::new()), - "GetToolSpec" => Arc::new(GetToolSpecTool::new()), - "GetFileDiff" => Arc::new(GetFileDiffTool::new()), - "Log" => Arc::new(LogTool::new()), - "TerminalControl" => Arc::new(TerminalControlTool::new()), - "SessionControl" => Arc::new(SessionControlTool::new()), - "SessionMessage" => Arc::new(SessionMessageTool::new()), - "SessionHistory" => Arc::new(SessionHistoryTool::new()), - "Cron" => Arc::new(CronTool::new()), - "WebSearch" => Arc::new(WebSearchTool::new()), - "WebFetch" => Arc::new(WebFetchTool::new()), - "ListMCPResources" => Arc::new(ListMCPResourcesTool::new()), - "ReadMCPResource" => Arc::new(ReadMCPResourceTool::new()), - "ListMCPPrompts" => Arc::new(ListMCPPromptsTool::new()), - "GetMCPPrompt" => Arc::new(GetMCPPromptTool::new()), - "GenerativeUI" => Arc::new(GenerativeUITool::new()), - "Git" => Arc::new(GitTool::new()), - "ReviewPlatform" => Arc::new(ReviewPlatformTool::new()), - "InitMiniApp" => Arc::new(InitMiniAppTool::new()), - "ControlHub" => Arc::new(ControlHubTool::new()), - "ComputerUse" => Arc::new(ComputerUseTool::new()), - "Playbook" => Arc::new(PlaybookTool::new()), - _ => panic!("unknown product tool provider plan entry: {tool_name}"), - } -} From 7bf0d031dfb5b54f87c556fc89193d6b4e0bde4c Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Thu, 21 May 2026 16:46:28 +0800 Subject: [PATCH 07/38] feat(core): restore inline Write via configurable write tool mode - add AIConfig.write_tool_mode with plaintext_followup default - support inline_content and plaintext_followup under the same Write tool name - switch Write schema, validation, and execution by context mode - gate round-executor plaintext follow-up generation by write tool mode - propagate write tool mode through execution and tool pipeline contexts - preserve ACP compatibility by forcing inline content mode --- .../src/agentic/execution/execution_engine.rs | 48 +- .../src/agentic/execution/round_executor.rs | 47 +- .../tools/implementations/file_write_tool.rs | 493 +++++++++++++----- .../agentic/tools/pipeline/tool_pipeline.rs | 57 +- src/crates/core/src/service/config/types.rs | 30 ++ 5 files changed, 509 insertions(+), 166 deletions(-) diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index 83ab84014f..5c549d8e4d 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -5,36 +5,36 @@ use super::round_executor::RoundExecutor; use super::types::{ExecutionContext, ExecutionResult, RoundContext, RoundResult}; use crate::agentic::agents::{ - PromptBuilder, PromptBuilderContext, RemoteExecutionHints, get_agent_registry, + get_agent_registry, PromptBuilder, PromptBuilderContext, RemoteExecutionHints, }; use crate::agentic::context_profile::{ContextProfilePolicy, ModelCapabilityProfile}; use crate::agentic::core::{ - Message, MessageContent, MessageHelper, MessageRole, MessageSemanticKind, - RequestReasoningTokenPolicy, Session, render_system_reminder, + render_system_reminder, Message, MessageContent, MessageHelper, MessageRole, + MessageSemanticKind, RequestReasoningTokenPolicy, Session, }; use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue}; use crate::agentic::execution::types::FinishReason; use crate::agentic::image_analysis::{ - ImageContextData, ImageLimits, build_multimodal_message_with_images, - process_image_contexts_for_provider, + build_multimodal_message_with_images, process_image_contexts_for_provider, ImageContextData, + ImageLimits, }; use crate::agentic::round_preempt::RoundInjectionKind; use crate::agentic::session::{CompressionTailPolicy, ContextCompressor, SessionManager}; use crate::agentic::tools::{ - ResolvedToolManifest, SubagentParentInfo, ToolRuntimeRestrictions, resolve_tool_manifest, + resolve_tool_manifest, ResolvedToolManifest, SubagentParentInfo, ToolRuntimeRestrictions, }; use crate::agentic::util::build_remote_workspace_layout_preview; use crate::agentic::{WorkspaceBackend, WorkspaceBinding}; use crate::infrastructure::ai::get_global_ai_client_factory; use crate::service::config::get_global_config_service; -use crate::service::config::types::{ModelCapability, ModelCategory}; +use crate::service::config::types::{ModelCapability, ModelCategory, WriteToolMode}; use crate::service::remote_ssh::workspace_state::get_remote_workspace_manager; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::token_counter::TokenCounter; use crate::util::types::Message as AIMessage; use crate::util::types::ToolDefinition; use crate::util::{elapsed_ms_u64, truncate_at_char_boundary}; -use bitfun_agent_tools::{GetToolSpecLoadObservation, collect_loaded_collapsed_tool_names}; +use bitfun_agent_tools::{collect_loaded_collapsed_tool_names, GetToolSpecLoadObservation}; use log::{debug, error, info, trace, warn}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; @@ -1432,7 +1432,11 @@ impl ExecutionEngine { })?; // Primary model vision capability (tools + system prompt appendix; also used below for API message stripping). - let (resolved_primary_model_id, primary_supports_image_understanding) = { + let ( + resolved_primary_model_id, + primary_supports_image_understanding, + configured_write_tool_mode, + ) = { let config_service = get_global_config_service().await.ok(); if let Some(service) = config_service { let ai_config: crate::service::config::types::AIConfig = @@ -1465,12 +1469,12 @@ impl ExecutionEngine { || matches!(m.category, ModelCategory::Multimodal) }); - (resolved_id, supports) + (resolved_id, supports, ai_config.write_tool_mode) } else { warn!( "Config service unavailable, assuming primary model is text-only for image input gating" ); - (model_id.clone(), false) + (model_id.clone(), false, WriteToolMode::default()) } }; @@ -1524,6 +1528,22 @@ impl ExecutionEngine { .get("enable_tools") .and_then(|v| v.parse::().ok()) .unwrap_or(true); + let write_tool_mode = if context + .context + .get("acp_transport") + .is_some_and(|value| value == "true") + { + WriteToolMode::InlineContent + } else { + configured_write_tool_mode + }; + + let mut tool_manifest_context_vars = context.context.clone(); + tool_manifest_context_vars.insert( + "write_tool_mode".to_string(), + write_tool_mode.as_str().to_string(), + ); + let tool_manifest = if enable_tools { debug!( "Agent tools: agent={}, tool_count={}", @@ -1538,7 +1558,7 @@ impl ExecutionEngine { context.workspace_services.as_ref(), &agent_type, primary_supports_image_understanding, - &context.context, + &tool_manifest_context_vars, ) .await, ) @@ -1655,6 +1675,10 @@ impl ExecutionEngine { "primary_model_supports_image_understanding".to_string(), primary_supports_image_understanding.to_string(), ); + execution_context_vars.insert( + "write_tool_mode".to_string(), + write_tool_mode.as_str().to_string(), + ); execution_context_vars.insert("turn_index".to_string(), context.turn_index.to_string()); // If the primary model is text-only, do not send image payloads to the provider. diff --git a/src/crates/core/src/agentic/execution/round_executor.rs b/src/crates/core/src/agentic/execution/round_executor.rs index 3dd1b712dd..73897cd3d8 100644 --- a/src/crates/core/src/agentic/execution/round_executor.rs +++ b/src/crates/core/src/agentic/execution/round_executor.rs @@ -8,12 +8,15 @@ use crate::agentic::core::{Message, ToolCall}; use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue, ToolEventData}; use crate::agentic::tools::computer_use_host::ComputerUseHostRef; use crate::agentic::tools::framework::{ToolPathResolution, ToolUseContext}; -use crate::agentic::tools::implementations::file_write_tool::FileWriteTool; +use crate::agentic::tools::implementations::file_write_tool::{ + FileWriteTool, WRITE_TOOL_MODE_CONTEXT_KEY, +}; use crate::agentic::tools::pipeline::{ToolExecutionContext, ToolExecutionOptions, ToolPipeline}; use crate::agentic::tools::registry::get_global_tool_registry; use crate::agentic::tools::ToolPathOperation; use crate::agentic::MessageContent; use crate::infrastructure::ai::AIClient; +use crate::service::config::types::WriteToolMode; use crate::service::config::GlobalConfigManager; use crate::util::elapsed_ms_u64; use crate::util::errors::{BitFunError, BitFunResult}; @@ -44,6 +47,15 @@ impl RoundExecutor { !text.trim().is_empty() } + fn write_tool_mode(context: &RoundContext) -> WriteToolMode { + WriteToolMode::from_context_var( + context + .context_vars + .get(WRITE_TOOL_MODE_CONTEXT_KEY) + .map(String::as_str), + ) + } + pub fn new( stream_processor: Arc, event_queue: Arc, @@ -555,8 +567,11 @@ impl RoundExecutor { // model emit large file contents inside JSON tool-call arguments, which // is a major source of JSON parse failures. let tool_calls = stream_result.tool_calls.clone(); - let tool_calls = self - .generate_write_tool_contents( + let tool_calls = if matches!( + Self::write_tool_mode(&context), + WriteToolMode::PlaintextFollowup + ) { + self.generate_write_tool_contents( ai_client.clone(), &context, &round_id, @@ -564,7 +579,10 @@ impl RoundExecutor { tool_calls, &cancel_token, ) - .await?; + .await? + } else { + tool_calls + }; // Execute tool calls debug!( @@ -1990,8 +2008,18 @@ mod tests { cache_creation_token_count: Some(20), }; let details = super::token_details_from_usage(&usage).expect("details"); - assert_eq!(details.get("cachedContentTokenCount").and_then(|v| v.as_u64()), Some(30)); - assert_eq!(details.get("cacheCreationTokenCount").and_then(|v| v.as_u64()), Some(20)); + assert_eq!( + details + .get("cachedContentTokenCount") + .and_then(|v| v.as_u64()), + Some(30) + ); + assert_eq!( + details + .get("cacheCreationTokenCount") + .and_then(|v| v.as_u64()), + Some(20) + ); } #[test] @@ -2006,7 +2034,12 @@ mod tests { cache_creation_token_count: None, }; let details = super::token_details_from_usage(&usage).expect("details"); - assert_eq!(details.get("cachedContentTokenCount").and_then(|v| v.as_u64()), Some(30)); + assert_eq!( + details + .get("cachedContentTokenCount") + .and_then(|v| v.as_u64()), + Some(30) + ); assert!(details.get("cacheCreationTokenCount").is_none()); } diff --git a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs index 720d0e66c9..fbd7f79630 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs @@ -2,6 +2,7 @@ use crate::agentic::tools::framework::{ Tool, ToolPathResolution, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::agentic::tools::ToolPathOperation; +use crate::service::config::types::WriteToolMode; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use serde_json::{json, Value}; @@ -10,6 +11,10 @@ use tokio::fs; pub struct FileWriteTool; +const LARGE_WRITE_SOFT_LINE_LIMIT: usize = 200; +const LARGE_WRITE_SOFT_BYTE_LIMIT: usize = 20 * 1024; +pub(crate) const WRITE_TOOL_MODE_CONTEXT_KEY: &str = "write_tool_mode"; + impl Default for FileWriteTool { fn default() -> Self { Self::new() @@ -21,6 +26,18 @@ impl FileWriteTool { Self } + pub(crate) fn write_tool_mode(context: Option<&ToolUseContext>) -> WriteToolMode { + if Self::is_acp_context(context) { + return WriteToolMode::InlineContent; + } + + WriteToolMode::from_context_var( + context + .and_then(|ctx| ctx.custom_data.get(WRITE_TOOL_MODE_CONTEXT_KEY)) + .and_then(|value| value.as_str()), + ) + } + pub(crate) async fn existing_file_error( context: &ToolUseContext, resolved: &ToolPathResolution, @@ -109,11 +126,200 @@ impl FileWriteTool { "additionalProperties": false }) } + + fn schema_without_content() -> Value { + json!({ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The file to write. Use a workspace-relative path, an absolute path inside the current workspace, or an exact bitfun://runtime URI returned by another tool." + } + }, + "required": ["file_path"], + "additionalProperties": false + }) + } + + fn inline_description() -> String { + r#"Writes a file to the local filesystem. + +Usage: +- This tool will overwrite the existing file if there is one at the provided path. +- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first. +- The file_path parameter must be workspace-relative, an absolute path inside the current workspace, or an exact `bitfun://runtime/...` URI returned by another tool. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- Keep writes focused. The 200-line / 20KB guideline is a soft reliability threshold, not a hard cap. If a task genuinely needs more content, preserve correctness and use a staged plan instead of truncating. +- For existing files, prefer Read + targeted Edit calls. For large new files or rewrites, write the stable scaffold first, then fill or revise sections with focused Edit calls. Do not replace an entire existing file just to change a few sections. +- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. +- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. +- Include the complete file content in the `content` argument."# + .to_string() + } + + fn plaintext_followup_description() -> String { + r#"Writes a file to the local filesystem. + +Usage: +- This tool is for creating NEW files only. Calling Write on a path that already exists will be REJECTED with an error. +- To MODIFY an existing file, use the Edit tool — it is the correct choice in almost every case. +- To FULLY REWRITE an existing file (e.g. regenerate a generated file, replace a template), first call the Delete tool on that path, then call Write to create the new version. Do not try to "overwrite" via Write directly. +- After Write succeeds for a path, do not call Write for that path again in later rounds. Use Edit for any additional changes. +- The file_path parameter must be workspace-relative, an absolute path inside the current workspace, or an exact `bitfun://runtime/...` URI returned by another tool. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. +- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. +- Do NOT include the file content in the tool call arguments. Only provide file_path. The system will prompt you separately to output the file content as plain text."# + .to_string() + } + + async fn call_inline_content_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let file_path = input + .get("file_path") + .and_then(|v| v.as_str()) + .ok_or_else(|| BitFunError::tool("file_path is required".to_string()))?; + + let resolved = context.resolve_tool_path(file_path)?; + context.enforce_path_operation(ToolPathOperation::Write, &resolved)?; + context + .record_light_checkpoint( + "Write", + &resolved.logical_path, + vec![resolved.logical_path.clone()], + ) + .await; + + let content = input + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| BitFunError::tool("content is required".to_string()))?; + + if resolved.uses_remote_workspace_backend() { + let ws_fs = context.ws_fs().ok_or_else(|| { + BitFunError::tool("Remote workspace file system is unavailable".to_string()) + })?; + ws_fs + .write_file(&resolved.resolved_path, content.as_bytes()) + .await + .map_err(|e| BitFunError::tool(format!("Failed to write file: {}", e)))?; + } else { + if let Some(parent) = Path::new(&resolved.resolved_path).parent() { + fs::create_dir_all(parent) + .await + .map_err(|e| BitFunError::tool(format!("Failed to create directory: {}", e)))?; + } + fs::write(&resolved.resolved_path, content) + .await + .map_err(|e| { + BitFunError::tool(format!( + "Failed to write file {}: {}", + resolved.logical_path, e + )) + })?; + } + + let result = ToolResult::Result { + data: json!({ + "file_path": resolved.logical_path, + "bytes_written": content.len(), + "success": true + }), + result_for_assistant: Some(format!("Successfully wrote to {}", resolved.logical_path)), + image_attachments: None, + }; + + Ok(vec![result]) + } + + async fn call_plaintext_followup_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let file_path = input + .get("file_path") + .and_then(|v| v.as_str()) + .ok_or_else(|| BitFunError::tool("file_path is required".to_string()))?; + + let resolved = context.resolve_tool_path(file_path)?; + context.enforce_path_operation(ToolPathOperation::Write, &resolved)?; + context + .record_light_checkpoint( + "Write", + &resolved.logical_path, + vec![resolved.logical_path.clone()], + ) + .await; + + let content = input + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| BitFunError::tool("content is required".to_string()))?; + + if let Some(error) = Self::existing_file_error(context, &resolved).await { + if Self::existing_file_matches_content(context, &resolved, content).await == Some(true) + { + let result = Self::write_success_result( + &resolved.logical_path, + 0, + "already_exists_same_content", + format!( + "Write skipped because {} already exists with identical content. Treat this file as successfully created and do not call Write for this path again. Use Edit for any further changes.", + resolved.logical_path + ), + ); + return Ok(vec![result]); + } + + return Err(BitFunError::tool(error)); + } + + if resolved.uses_remote_workspace_backend() { + let ws_fs = context.ws_fs().ok_or_else(|| { + BitFunError::tool("Remote workspace file system is unavailable".to_string()) + })?; + ws_fs + .write_file(&resolved.resolved_path, content.as_bytes()) + .await + .map_err(|e| BitFunError::tool(format!("Failed to write file: {}", e)))?; + } else { + if let Some(parent) = Path::new(&resolved.resolved_path).parent() { + fs::create_dir_all(parent) + .await + .map_err(|e| BitFunError::tool(format!("Failed to create directory: {}", e)))?; + } + fs::write(&resolved.resolved_path, content) + .await + .map_err(|e| { + BitFunError::tool(format!( + "Failed to write file {}: {}", + resolved.logical_path, e + )) + })?; + } + + let result = Self::write_success_result( + &resolved.logical_path, + content.len(), + "created", + format!( + "Successfully created {} ({} bytes). The file now exists; do not call Write for this path again. Use Edit for any further changes.", + resolved.logical_path, + content.len() + ), + ); + + Ok(vec![result]) + } } #[cfg(test)] mod tests { - use super::FileWriteTool; + use super::{FileWriteTool, WRITE_TOOL_MODE_CONTEXT_KEY}; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::WorkspaceBinding; @@ -137,6 +343,25 @@ mod tests { } } + fn local_context_with_custom_data( + root: PathBuf, + custom_data: HashMap, + ) -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: Some(WorkspaceBinding::new(None, root)), + unlocked_collapsed_tools: Vec::new(), + custom_data, + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: None, + } + } + fn context_with_custom_data(custom_data: HashMap) -> ToolUseContext { ToolUseContext { tool_call_id: None, @@ -264,6 +489,70 @@ mod tests { assert_eq!(schema["required"], serde_json::json!(["file_path"])); assert!(schema["properties"].get("content").is_none()); } + + #[tokio::test] + async fn inline_mode_schema_requires_content() { + let tool = FileWriteTool::new(); + let mut custom_data = HashMap::new(); + custom_data.insert( + WRITE_TOOL_MODE_CONTEXT_KEY.to_string(), + serde_json::Value::String("inline_content".to_string()), + ); + let context = context_with_custom_data(custom_data); + + let schema = tool + .input_schema_for_model_with_context(Some(&context)) + .await; + + assert_eq!( + schema["required"], + serde_json::json!(["file_path", "content"]) + ); + } + + #[tokio::test] + async fn inline_mode_requires_content_during_validation() { + let tool = FileWriteTool::new(); + let mut custom_data = HashMap::new(); + custom_data.insert( + WRITE_TOOL_MODE_CONTEXT_KEY.to_string(), + serde_json::Value::String("inline_content".to_string()), + ); + let context = context_with_custom_data(custom_data); + + let validation = tool + .validate_input(&json!({ "file_path": "new.txt" }), Some(&context)) + .await; + + assert!(!validation.result); + assert_eq!(validation.message.as_deref(), Some("content is required")); + } + + #[tokio::test] + async fn inline_mode_overwrites_existing_file() { + let root = std::env::temp_dir().join(format!("bitfun-write-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).expect("create temp workspace"); + std::fs::write(root.join("existing.md"), "old content").expect("create existing file"); + + let mut custom_data = HashMap::new(); + custom_data.insert( + WRITE_TOOL_MODE_CONTEXT_KEY.to_string(), + serde_json::Value::String("inline_content".to_string()), + ); + + let tool = FileWriteTool::new(); + tool.call( + &json!({ "file_path": "existing.md", "content": "new content" }), + &local_context_with_custom_data(root.clone(), custom_data), + ) + .await + .expect("inline mode should overwrite existing files"); + + let written = std::fs::read_to_string(root.join("existing.md")).expect("read file"); + let _ = std::fs::remove_dir_all(&root); + + assert_eq!(written, "new content"); + } } #[async_trait] @@ -273,18 +562,7 @@ impl Tool for FileWriteTool { } async fn description(&self) -> BitFunResult { - Ok(r#"Writes a file to the local filesystem. - -Usage: -- This tool is for creating NEW files only. Calling Write on a path that already exists will be REJECTED with an error. -- To MODIFY an existing file, use the Edit tool — it is the correct choice in almost every case. -- To FULLY REWRITE an existing file (e.g. regenerate a generated file, replace a template), first call the Delete tool on that path, then call Write to create the new version. Do not try to "overwrite" via Write directly. -- After Write succeeds for a path, do not call Write for that path again in later rounds. Use Edit for any additional changes. -- The file_path parameter must be workspace-relative, an absolute path inside the current workspace, or an exact `bitfun://runtime/...` URI returned by another tool. -- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. -- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. -- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. -- Do NOT include the file content in the tool call arguments. Only provide file_path. The system will prompt you separately to output the file content as plain text."#.to_string()) + Ok(Self::plaintext_followup_description()) } fn short_description(&self) -> String { @@ -295,42 +573,20 @@ Usage: &self, context: Option<&ToolUseContext>, ) -> BitFunResult { - if Self::is_acp_context(context) { - return Ok(r#"Writes a file to the local filesystem. - -Usage: -- This tool is for creating NEW files only. Calling Write on a path that already exists will be REJECTED with an error unless the existing content is identical, in which case the retry is treated as already successful. -- To MODIFY an existing file, use the Edit tool. To fully rewrite an existing file, first call the Delete tool on that path, then call Write to create the new version. -- The file_path parameter must be workspace-relative, an absolute path inside the current workspace, or an exact `bitfun://runtime/...` URI returned by another tool. -- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. -- For new files, preserve correctness and provide the complete intended file content when this tool is appropriate. -- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. -- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. -- Include the complete file content in the `content` argument."#.to_string()); + match Self::write_tool_mode(context) { + WriteToolMode::InlineContent => Ok(Self::inline_description()), + WriteToolMode::PlaintextFollowup => Ok(Self::plaintext_followup_description()), } - - self.description().await } fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "The file to write. Use a workspace-relative path, an absolute path inside the current workspace, or an exact bitfun://runtime URI returned by another tool." - } - }, - "required": ["file_path"], - "additionalProperties": false - }) + Self::schema_without_content() } async fn input_schema_for_model_with_context(&self, context: Option<&ToolUseContext>) -> Value { - if Self::is_acp_context(context) { - Self::schema_with_content() - } else { - self.input_schema() + match Self::write_tool_mode(context) { + WriteToolMode::InlineContent => Self::schema_with_content(), + WriteToolMode::PlaintextFollowup => self.input_schema(), } } @@ -363,6 +619,35 @@ Usage: } }; + let mode = Self::write_tool_mode(context); + if matches!(mode, WriteToolMode::InlineContent) && input.get("content").is_none() { + return ValidationResult { + result: false, + message: Some("content is required".to_string()), + error_code: Some(400), + meta: None, + }; + } + + let large_write_warning = if matches!(mode, WriteToolMode::InlineContent) { + input + .get("content") + .and_then(|v| v.as_str()) + .and_then(|content| { + let line_count = content.lines().count(); + let byte_count = content.len(); + if line_count > LARGE_WRITE_SOFT_LINE_LIMIT + || byte_count > LARGE_WRITE_SOFT_BYTE_LIMIT + { + Some((line_count, byte_count)) + } else { + None + } + }) + } else { + None + }; + if let Some(ctx) = context { let resolved = match ctx.resolve_tool_path(file_path) { Ok(resolved) => resolved, @@ -385,24 +670,44 @@ Usage: }; } - // If content is absent, RoundExecutor would otherwise launch a - // second model request to generate the full file. Reject existing - // targets here so we do not spend tokens producing content that - // Write must reject anyway. If a model already supplied content - // despite the public schema, defer to call_impl so identical - // retries can be treated as idempotent success. - if input.get("content").is_none() { - if let Some(error) = Self::existing_file_error(ctx, &resolved).await { - return ValidationResult { - result: false, - message: Some(error), - error_code: Some(400), - meta: None, - }; + if matches!(mode, WriteToolMode::PlaintextFollowup) { + // If content is absent, RoundExecutor would otherwise launch a + // second model request to generate the full file. Reject existing + // targets here so we do not spend tokens producing content that + // Write must reject anyway. If a model already supplied content + // despite the public schema, defer to call_impl so identical + // retries can be treated as idempotent success. + if input.get("content").is_none() { + if let Some(error) = Self::existing_file_error(ctx, &resolved).await { + return ValidationResult { + result: false, + message: Some(error), + error_code: Some(400), + meta: None, + }; + } } } } + if let Some((line_count, byte_count)) = large_write_warning { + return ValidationResult { + result: true, + message: Some(format!( + "Large Write payload: {} lines, {} bytes. This is allowed when necessary, but prefer a staged approach: for existing files use Read + focused Edit calls; for large new files write a stable scaffold first, then add sections in follow-up edits unless a complete initial body is required.", + line_count, byte_count + )), + error_code: None, + meta: Some(json!({ + "large_write": true, + "line_count": line_count, + "byte_count": byte_count, + "soft_line_limit": LARGE_WRITE_SOFT_LINE_LIMIT, + "soft_byte_limit": LARGE_WRITE_SOFT_BYTE_LIMIT + })), + }; + } + ValidationResult::default() } @@ -428,79 +733,11 @@ Usage: input: &Value, context: &ToolUseContext, ) -> BitFunResult> { - let file_path = input - .get("file_path") - .and_then(|v| v.as_str()) - .ok_or_else(|| BitFunError::tool("file_path is required".to_string()))?; - - let resolved = context.resolve_tool_path(file_path)?; - context.enforce_path_operation(ToolPathOperation::Write, &resolved)?; - context - .record_light_checkpoint( - "Write", - &resolved.logical_path, - vec![resolved.logical_path.clone()], - ) - .await; - - let content = input - .get("content") - .and_then(|v| v.as_str()) - .ok_or_else(|| BitFunError::tool("content is required".to_string()))?; - - if let Some(error) = Self::existing_file_error(context, &resolved).await { - if Self::existing_file_matches_content(context, &resolved, content).await == Some(true) - { - let result = Self::write_success_result( - &resolved.logical_path, - 0, - "already_exists_same_content", - format!( - "Write skipped because {} already exists with identical content. Treat this file as successfully created and do not call Write for this path again. Use Edit for any further changes.", - resolved.logical_path - ), - ); - return Ok(vec![result]); - } - - return Err(BitFunError::tool(error)); - } - - if resolved.uses_remote_workspace_backend() { - let ws_fs = context.ws_fs().ok_or_else(|| { - BitFunError::tool("Remote workspace file system is unavailable".to_string()) - })?; - ws_fs - .write_file(&resolved.resolved_path, content.as_bytes()) - .await - .map_err(|e| BitFunError::tool(format!("Failed to write file: {}", e)))?; - } else { - if let Some(parent) = Path::new(&resolved.resolved_path).parent() { - fs::create_dir_all(parent) - .await - .map_err(|e| BitFunError::tool(format!("Failed to create directory: {}", e)))?; + match Self::write_tool_mode(Some(context)) { + WriteToolMode::InlineContent => self.call_inline_content_impl(input, context).await, + WriteToolMode::PlaintextFollowup => { + self.call_plaintext_followup_impl(input, context).await } - fs::write(&resolved.resolved_path, content) - .await - .map_err(|e| { - BitFunError::tool(format!( - "Failed to write file {}: {}", - resolved.logical_path, e - )) - })?; } - - let result = Self::write_success_result( - &resolved.logical_path, - content.len(), - "created", - format!( - "Successfully created {} ({} bytes). The file now exists; do not call Write for this path again. Use Edit for any further changes.", - resolved.logical_path, - content.len() - ), - ); - - Ok(vec![result]) } } diff --git a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs index 47f4d3da77..6d84a93bee 100644 --- a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -19,8 +19,8 @@ use log::{debug, error, info, warn}; use std::collections::{HashMap, VecDeque}; use std::sync::Arc; use std::time::{Instant, SystemTime}; -use tokio::sync::{RwLock as TokioRwLock, oneshot}; -use tokio::time::{Duration, timeout}; +use tokio::sync::{oneshot, RwLock as TokioRwLock}; +use tokio::time::{timeout, Duration}; use tokio_util::sync::CancellationToken; /// A batch of tool tasks to execute together. @@ -1319,6 +1319,19 @@ impl ToolPipeline { ); } } + if let Some(write_tool_mode) = task.context.context_vars.get("write_tool_mode") { + if !write_tool_mode.is_empty() { + map.insert( + "write_tool_mode".to_string(), + serde_json::json!(write_tool_mode), + ); + } + } + if let Some(acp_transport) = task.context.context_vars.get("acp_transport") { + if let Ok(flag) = acp_transport.parse::() { + map.insert("acp_transport".to_string(), serde_json::json!(flag)); + } + } let deep_review_parent_context = task.context .subagent_parent_info @@ -1652,14 +1665,12 @@ mod tests { result.result.result["provided_arguments"], serde_json::Value::String("{\"operation\":\"log\"".to_string()) ); - assert!( - result - .result - .result_for_assistant - .as_deref() - .unwrap_or_default() - .contains("Provided arguments: {\"operation\":\"log\"") - ); + assert!(result + .result + .result_for_assistant + .as_deref() + .unwrap_or_default() + .contains("Provided arguments: {\"operation\":\"log\"")); } #[test] @@ -1700,6 +1711,12 @@ mod tests { "primary_model_supports_image_understanding".to_string(), "true".to_string(), ); + task.context + .context_vars + .insert("write_tool_mode".to_string(), "inline_content".to_string()); + task.context + .context_vars + .insert("acp_transport".to_string(), "true".to_string()); task.context.collapsed_tools = vec!["WebFetch".to_string()]; task.context.unlocked_collapsed_tools = vec!["WebFetch".to_string()]; task.context.runtime_tool_restrictions = ToolRuntimeRestrictions { @@ -1716,11 +1733,9 @@ mod tests { assert_eq!(context.dialog_turn_id.as_deref(), Some("turn_1")); assert_eq!(context.unlocked_collapsed_tools, vec!["WebFetch"]); assert!(context.cancellation_token.is_some()); - assert!( - context - .runtime_tool_restrictions - .is_tool_allowed("WebFetch") - ); + assert!(context + .runtime_tool_restrictions + .is_tool_allowed("WebFetch")); assert!(!context.runtime_tool_restrictions.is_tool_allowed("Bash")); assert_eq!(context.custom_data["turn_index"], json!(7)); assert_eq!( @@ -1731,6 +1746,11 @@ mod tests { context.custom_data["primary_model_supports_image_understanding"], json!(true) ); + assert_eq!( + context.custom_data["write_tool_mode"], + json!("inline_content") + ); + assert_eq!(context.custom_data["acp_transport"], json!(true)); let facts = context.to_tool_context_facts(); let value = serde_json::to_value(&facts).expect("serialize context facts"); @@ -1750,10 +1770,9 @@ mod tests { let err = ToolPipeline::validate_collapsed_tool_usage(&task) .expect_err("collapsed tool should require GetToolSpec unlock"); - assert!( - err.to_string() - .contains("Call GetToolSpec first with {\"tool_name\":\"WebFetch\"}") - ); + assert!(err + .to_string() + .contains("Call GetToolSpec first with {\"tool_name\":\"WebFetch\"}")); } #[test] diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index c3341e8a51..93829120b0 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -462,6 +462,31 @@ pub enum ModelCategory { pub use bitfun_ai_adapters::types::ReasoningMode; +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum WriteToolMode { + InlineContent, + #[default] + PlaintextFollowup, +} + +impl WriteToolMode { + pub fn as_str(&self) -> &'static str { + match self { + Self::InlineContent => "inline_content", + Self::PlaintextFollowup => "plaintext_followup", + } + } + + pub fn from_context_var(value: Option<&str>) -> Self { + match value.map(str::trim) { + Some("inline_content") => Self::InlineContent, + Some("plaintext_followup") => Self::PlaintextFollowup, + _ => Self::default(), + } + } +} + /// Default model configuration. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] @@ -593,6 +618,10 @@ pub struct AIConfig { #[serde(default = "default_skip_tool_confirmation")] pub skip_tool_confirmation: bool, + /// Selects how the Write tool obtains file content. + #[serde(default)] + pub write_tool_mode: WriteToolMode, + /// Debug-mode configuration (log path, language templates, etc.). #[serde(default)] pub debug_mode_config: DebugModeConfig, @@ -1554,6 +1583,7 @@ impl Default for AIConfig { tool_execution_timeout_secs: default_tool_execution_timeout(), tool_confirmation_timeout_secs: default_tool_confirmation_timeout(), skip_tool_confirmation: true, + write_tool_mode: WriteToolMode::default(), debug_mode_config: DebugModeConfig::default(), computer_use_enabled: false, browser_control_preferred_browser: String::new(), From 40f3255bf462a781f166175eedcc4656cdad748b Mon Sep 17 00:00:00 2001 From: limityan Date: Thu, 21 May 2026 18:00:19 +0800 Subject: [PATCH 08/38] refactor(runtime): centralize core runtime owners --- docs/architecture/core-decomposition.md | 11 +- docs/plans/core-decomposition-plan.md | 44 ++- scripts/check-core-boundaries.mjs | 254 ++++++++++++++++-- src/crates/core/AGENTS.md | 3 +- .../git-func-agent/commit_generator.rs | 12 +- .../core/src/function_agents/port_adapters.rs | 7 + .../work_state_analyzer.rs | 12 +- src/crates/core/src/lib.rs | 2 + src/crates/core/src/miniapp/manager.rs | 3 +- src/crates/core/src/product_domain_runtime.rs | 44 +++ .../remote_connect/bot/command_router.rs | 8 +- .../service/remote_connect/remote_server.rs | 54 ++-- src/crates/core/src/service_agent_runtime.rs | 70 +++++ src/crates/product-domains/AGENTS-CN.md | 13 +- src/crates/product-domains/AGENTS.md | 3 +- .../src/function_agents/ports.rs | 18 +- src/crates/services-integrations/AGENTS.md | 4 +- 17 files changed, 471 insertions(+), 91 deletions(-) create mode 100644 src/crates/core/src/product_domain_runtime.rs create mode 100644 src/crates/core/src/service_agent_runtime.rs diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index 09b4f5bcf1..d08b6bf5bb 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -260,13 +260,16 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate facade 已外移;本阶段进一步把 function-agent prompt template、AI response JSON extraction 与 domain error mapping 策略迁入 `product-domains`。filesystem IO、 worker process、host dispatch、built-in asset seeding / marker IO 与 Git/AI 调用仍显式 - core-owned。 + core-owned;HR2 进一步将这些 core-owned product-domain runtime 绑定收敛到 + `src/crates/core/src/product_domain_runtime.rs`,不改变实际执行路径。 - H3:remaining service/runtime owner。remote-connect 已把 dialog submission orchestration、terminal pre-warm decision、remote workspace file IO/path helper 与 image-context adapter contract 收敛到 owner crate port/provider; concrete scheduler/session restore/terminal adapter、workspace-root source、 response wrapping、remote-SSH runtime、agent registry/scheduler 等仍必须 - 另起 port/provider 设计和等价评审。 + 另起 port/provider 设计和等价评审;HR3 进一步把这些仍 core-owned 的 + service/agent runtime 绑定入口集中到 `src/crates/core/src/service_agent_runtime.rs`, + 不改变 remote-connect、remote-SSH 或 scheduler 执行路径。 - H4:facade and boundary finalization。当前以 boundary script / AGENTS / architecture docs 一致性闭环为准,确认 `bitfun-core` 继续作为 legacy facade + full product runtime assembly;未完成等价评审的 runtime owner 继续显式 core-owned。 @@ -316,7 +319,9 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate core 仍持有 MiniApp filesystem IO、compiler 调度、worker process、host dispatch、 built-in asset include / seed / marker IO / recompile,以及 function-agent Git/AI service adapter 和 AI client 调用;`product-domains` 现在承接 function-agent prompt template、AI response JSON - extraction、domain error mapping 与 JSON-to-domain DTO parsing policy。 + extraction、domain error mapping 与 JSON-to-domain DTO parsing policy。本轮 HR2 只把 + core-owned MiniApp/function-agent runtime 绑定集中到 `product_domain_runtime.rs`,用于 + 统一审查和边界检测;MiniApp IO/worker/host/builtin seed 与 Git/AI 调用没有外移。 ## 产品表面边界(Product Surface Boundary) diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 16450e83d2..575f9ed520 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -1676,7 +1676,7 @@ cargo check --workspace - 未声明完成的 P2/后续剩余部分:remote-ssh runtime、remote-connect 等重 service 迁移、`ToolUseContext` 外移、runtime manifest assembly / `GetToolSpec` 执行 owner 化、concrete tool implementation 迁移、product registry / provider assembly、miniapp/function-agent 运行逻辑迁移。这些会触碰 `PathManager`、`ToolUseContext`、workspace service、snapshot wrapper、prompt-visible tool catalog、`AgentSubmissionPort` 或 AI service 边界,需要在继续前显式确认。 - 本次 rebase 后重新核对最新主干 Deep Review capacity/cost/queue、context profile、evidence ledger 与 session manifest 变更:当前 PR 已完成 Git feature group 的 owner crate 归属迁移,但未改动这些 Deep Review 行为路径;后续迁移必须补端口设计和等价测试后再推进。 - 本次 rebase 后重新核对最新主干 tool 变更:on-demand tool spec discovery 新增 collapsed/expanded manifest、`GetToolSpec`、context-aware schema/description 与 unlock state。这不要求回退当前 P2 已完成内容,但要求后续 tool/provider 迁移先补 manifest / catalog / unlock 等价保护,且不得和 PR5 product-domain runtime 收口混合。 -- PR5 已先推进低风险 product-domain slice:MiniApp 纯 compiler、export/runtime/worker DTO、runtime search plan、worker install 命令选择、package.json storage-shape helper、import layout / fallback payload contract、lifecycle / revision helper、manager 纯状态转换 helper、host routing string / allowlist policy helper、customization metadata / permission diff、built-in bundle/hash/marker/source payload seed-decision contract、built-in seed plan / marker wire helper、runtime/storage port contract,以及 git/startchat function-agent 纯 utils / commit summary / message assembly / prompt format / commit prompt preparation / AI response parsing policy / JSON-string parsing helper / action normalization / git porcelain / diff combine / time-of-day / Git/AI port contract / project context analyzer 已移入 `bitfun-product-domains`,core 保留原路径兼容 wrapper。H2 进一步将 function-agent prompt template、AI response JSON extraction 与 domain error mapping policy 迁入 product-domain;core 只保留 AI client 调用、Git service adapter、AI transport/provider-acquisition error mapping 和原路径 facade。已新增 core-owned Git snapshot、MiniApp storage/runtime port adapter 等价测试,并补齐 MiniApp manager import/sync/recompile/rollback/deps state、built-in asset seeding decision 等价测试与 function-agent response policy 等价快照。PathManager、Git/AI service、builtin asset includes / seed / marker IO / recompile、host dispatch 执行、customization draft 存储 / 应用、worker pool / storage IO 执行逻辑和任何 tool runtime 仍未迁移。 +- PR5 已先推进低风险 product-domain slice:MiniApp 纯 compiler、export/runtime/worker DTO、runtime search plan、worker install 命令选择、package.json storage-shape helper、import layout / fallback payload contract、lifecycle / revision helper、manager 纯状态转换 helper、host routing string / allowlist policy helper、customization metadata / permission diff、built-in bundle/hash/marker/source payload seed-decision contract、built-in seed plan / marker wire helper、runtime/storage port contract,以及 git/startchat function-agent 纯 utils / commit summary / message assembly / prompt format / commit prompt preparation / AI response parsing policy / JSON-string parsing helper / action normalization / git porcelain / diff combine / time-of-day / Git/AI port contract / project context analyzer 已移入 `bitfun-product-domains`,core 保留原路径兼容 wrapper。H2 进一步将 function-agent prompt template、AI response JSON extraction 与 domain error mapping policy 迁入 product-domain;HR2 将 core-owned product-domain runtime 绑定收敛到 `src/crates/core/src/product_domain_runtime.rs`。core 继续保留 AI client 调用、Git service adapter、AI transport/provider-acquisition error mapping 和原路径 facade。已新增 core-owned Git snapshot、MiniApp storage/runtime port adapter 等价测试,并补齐 MiniApp manager import/sync/recompile/rollback/deps state、built-in asset seeding decision 等价测试与 function-agent response policy 等价快照。PathManager、Git/AI service、builtin asset includes / seed / marker IO / recompile、host dispatch 执行、customization draft 存储 / 应用、worker pool / storage IO 执行逻辑和任何 tool runtime 仍未迁移。 - 本次 P2 后续复核结论:上述高耦合剩余项不是纯文件搬迁;若继续迁移会改变依赖方向或需要新增 port/provider 行为合约。因此当前 PR 将它们显式保留为 core-owned runtime,只完成低风险 owner container 化,并通过 boundary check 防止已拆 owner crate 回流依赖 core。 **后续风险重排(2026-05-13):** @@ -1780,10 +1780,13 @@ P2 后产品表面契约轨道(contract-only): | H4 | facade and boundary finalization | 已完成当前批次收口:`bitfun-core` 继续作为 legacy facade + full product runtime assembly;boundary script 自检、AGENTS、architecture/plan docs 与当前代码一致,未迁移 runtime 均显式 core-owned/deferred | 新 runtime 外移、默认 feature 改动 | boundary check self-test、boundary check、diff check 和 workspace Rust 验证通过;所有 deferred/core-owned 项有明确 owner、测试或后续评估入口 | | H5 | optional default feature / build-benefit evaluation | 仅在 LR1 与必要的 H1-H4 后评估 `bitfun-core default = []`、per-product feature set、依赖版本收敛和构建收益 | 任何 runtime owner 迁移或产品逻辑变更 | 有 feature graph baseline、`cargo check -p bitfun-core`、workspace check 和目标 crate check 的前后数据;可选择不执行 | -**H2 closure(2026-05-21):** H2 以 function-agent prompt/response policy 作为单一 owner -迁移主题闭环;MiniApp filesystem IO、worker process、host dispatch、built-in asset seeding / -marker IO / recompile,以及 function-agent Git service / AI client 调用仍显式 core-owned。 -这些路径不是“未注意到”,而是因行为边界和外部副作用风险保留到后续单独评审。 +**H2 / HR2 closure(2026-05-21):** H2 以 function-agent prompt/response policy +作为单一 owner 迁移主题闭环;HR2 进一步把 core-owned MiniApp/function-agent runtime +绑定集中到 `src/crates/core/src/product_domain_runtime.rs`,并通过 boundary check 锁定 +MiniApp facade、function-agent facade 与 core-owned Git/AI adapter 的路由入口。MiniApp +filesystem IO、worker process、host dispatch、built-in asset seeding / marker IO / recompile, +以及 function-agent Git service / AI client 调用仍显式 core-owned。这些路径不是“未注意到”, +而是因行为边界和外部副作用风险保留到后续单独评审。 **H3 remote-connect closure(2026-05-21):** 本轮迁移 RemoteRelay/Bot dialog submission 的编排所有权以及可独立验证的 file/image 边界:tracker ensure、 @@ -1795,6 +1798,16 @@ concrete scheduler submit、session restore 执行、terminal binding adapter、 source、response/base64 wrapping 与 `ImageContextData` concrete impl;这些属于运行时副作用 或产品封装边界,不得在没有新等价测试和端口设计时继续移动。 +**HR3 closure update(2026-05-21):** HR3 now also centralizes the still +core-owned service/agent runtime bindings in +`src/crates/core/src/service_agent_runtime.rs`: remote dialog host factory, +remote image-context conversion, and `ConversationCoordinator` runtime-port +adapter binding. This does not move remote-SSH runtime, workspace-root source, +response/base64 wrapping, concrete scheduler/session restore/terminal adapters, +`ImageContextData` concrete ownership, or agent registry/scheduler behavior out +of core; those remain high-risk owner topics requiring a separate port/provider +design and equivalence tests before any deeper migration. + **H4 facade / boundary closure(2026-05-21):** 本轮不继续移动新的 runtime owner, 而是把 H1-H3 的结果收敛为可审核的边界状态:`scripts/check-core-boundaries.mjs` 新增 remote-connect file/image/dialog owner anchor、core adapter/deferred owner anchor 与 @@ -1819,8 +1832,8 @@ owner-by-owner 迁移队列,不得把它们当作 H4 漏项补进当前 PR。 | 后续项 | 性质 | 范围 | 不允许混入 | 合入门禁 | |---|---|---|---|---| | HR1:tool runtime deep owner migration | 条件性高风险 PR | 当前已完成 core 内部 `product_runtime.rs` 单一 owner 收口;后续若继续深迁移,只允许在单独评审后移动 `ToolUseContext`、runtime manifest execution owner、`GetToolSpecTool` Tool impl、collapsed unlock state、snapshot wrapper implementation 或 concrete tools,也可以明确继续 core-owned | MiniApp/function-agent runtime、remote-connect / remote-SSH runtime、default feature 或构建收益声明 | 先补 port/provider 设计;证明 builtin/readonly/dynamic manifest、expanded/collapsed exposure、unlock state、dynamic provider metadata、snapshot wrapping、runtime restrictions、cancellation 和 Deep Review tool flow 等价 | -| HR2:product-domain runtime deep owner migration | 条件性高风险 PR | MiniApp filesystem IO、worker process execution、host dispatch、built-in asset include / seed / marker IO / recompile,或 function-agent Git/AI service adapter / AI client call 的单一 owner 迁移;也可以明确评审后继续 core-owned | tool runtime、remote-connect / remote-SSH runtime、CLI/Desktop/Remote/ACP surface 行为变更 | `product-domains` 不依赖 core;先锁定 PathManager、process execution、permission policy、Git/AI error/transport mapping 和 focused MiniApp/function-agent regression | -| HR3:service / agent runtime deep owner migration | 条件性高风险 PR | remote-SSH manager / remote FS / terminal、remote-connect workspace-root source / response wrapping / `ImageContextData` concrete impl / concrete scheduler-session-restore-terminal adapter、agent registry / scheduler 的单一 owner 迁移;也可以明确评审后继续 core-owned | tool runtime、product-domain runtime、feature matrix 或产品逻辑变更 | 有 port/provider 设计、旧路径兼容、mode-scoped subagent visibility / background delivery / remote-connect dialog order / remote workspace guard / DeepResearch post-turn hook 等行为等价测试 | +| HR2:product-domain runtime deep owner migration | 条件性高风险 PR | 当前已完成 core 内部 `product_domain_runtime.rs` 单一 owner 收口;MiniApp filesystem IO、worker process execution、host dispatch、built-in asset include / seed / marker IO / recompile,以及 function-agent Git/AI service adapter / AI client call 继续显式 core-owned;后续若继续深迁移,只允许在单独评审后移动一个 owner 主题 | tool runtime、remote-connect / remote-SSH runtime、CLI/Desktop/Remote/ACP surface 行为变更 | `product-domains` 不依赖 core;PathManager、process execution、permission policy、Git/AI error/transport mapping 和 focused MiniApp/function-agent regression 已作为后续迁移门禁 | +| HR3:service / agent runtime deep owner migration | 条件性高风险 PR | 当前已完成 core 内部 `service_agent_runtime.rs` 单一 owner 收口;remote-SSH manager / remote FS / terminal、remote-connect workspace-root source / response wrapping / `ImageContextData` concrete impl / concrete scheduler-session-restore-terminal adapter、agent registry / scheduler 继续显式 core-owned;后续若继续深迁移,只允许在单独评审后移动一个 owner 主题 | tool runtime、product-domain runtime、feature matrix 或产品逻辑变更 | 有 port/provider 设计、旧路径兼容、mode-scoped subagent visibility / background delivery / remote-connect dialog order / remote workspace guard / DeepResearch post-turn hook 等行为等价测试 | | H5:feature/build-benefit evaluation | 可选评估 PR | `bitfun-core default = []`、per-product explicit feature set、依赖版本收敛、构建收益数据记录 | 任何 runtime owner 迁移、产品逻辑变更或构建脚本改造 | 有 feature graph baseline、`cargo check -p bitfun-core`、workspace check、目标 crate check 和必要 product check 的前后数据;若收益不清晰则不执行 | **HR 风险与优化清单:** @@ -1865,6 +1878,11 @@ HR1:tool runtime deep owner migration 的主要风险和控制点: HR2:product-domain runtime deep owner migration 的主要风险和控制点: +- 当前 HR2 结论:本轮只完成 core 内部 owner closure。`CoreProductDomainRuntime` + 集中创建 MiniApp runtime-state facade、function-agent Git/AI adapters 和 + function-agent runtime facade;这让后续迁移审查有唯一入口,但不改变 MiniApp + filesystem IO、worker process、host dispatch、built-in asset seed / marker IO / + recompile,或 function-agent Git/AI service call 的 owner。 - 风险:MiniApp filesystem IO、worker process、host dispatch、builtin asset seed / marker IO / recompile 都有外部副作用;迁移不当会改变用户数据目录、更新标记、 rollback、dependency state 或编译/运行顺序。 @@ -1886,6 +1904,11 @@ HR2:product-domain runtime deep owner migration 的主要风险和控制点: HR3:service / agent runtime deep owner migration 的主要风险和控制点: +- 当前 HR3 结论:本轮只完成 core 内部 owner closure。`CoreServiceAgentRuntime` + 集中创建 remote dialog host、remote image context adapter 和 + `ConversationCoordinator` 的 runtime-port binding;这让后续 service/agent + runtime 深迁移有唯一审查入口,但不改变 remote-connect / remote-SSH / + scheduler / registry 的实际执行路径。 - 风险:remote-SSH manager / remote FS / terminal 与 remote-connect workspace-root source、response wrapping、`ImageContextData` concrete impl 都连接实际远端执行环境; 迁移不当会破坏 remote workspace guard、terminal pre-warm、base64 response shape、 @@ -1935,7 +1958,8 @@ HR3:service / agent runtime deep owner migration 的主要风险和控制点 provider container 与 feature-group scaffold;`ToolUseContext`、runtime manifest assembly、`GetToolSpec` 执行、collapsed unlock state 和 concrete tools 进入 H1。 - MiniApp 与 function-agent 的纯 DTO/helper/port facade 已归属 `product-domains`; - H2 已迁移 function-agent prompt template、JSON extraction 与 domain error mapping policy。 + H2 已迁移 function-agent prompt template、JSON extraction 与 domain error mapping policy; + HR2 已将 core-owned product-domain runtime 绑定集中到 `CoreProductDomainRuntime`。 filesystem IO、process/Git/AI 调用、host dispatch、built-in asset seeding/marker IO 与 recompile orchestration 仍显式保留 core-owned,后续只允许按单一 owner 重新评审。 - `bitfun-core` 依赖裁剪、`default = []`、per-product feature matrix 与构建收益宣传 @@ -2050,8 +2074,8 @@ git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts 14. 已完成:remote-connect tracker / wire / pure policy owner slice:产品表面 DTO 已以 contract-only 方式进入 `bitfun-core-types`;`bitfun-services-integrations` 的 `remote-connect` feature 拥有 remote command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、remote chat/image/tool/session wire DTO、relay/bot session/submission request builder、remote image attachment/request DTO、tracker state / registry lifecycle、tracker event reduction、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy;relay/bot 创建 session 通过 `AgentSubmissionPort`,取消、远程状态读取和事件事实已有 `runtime-ports` 契约。H3 进一步把远程消息提交编排、terminal pre-warm decision、remote workspace file IO/path helper 与 image-context adapter contract 迁入 `bitfun-services-integrations` port/provider;concrete terminal adapter、workspace/session restore 执行、workspace-root source、response/base64 wrapping 与 `ImageContextData` concrete impl 仍保留在 `bitfun-core` product runtime assembly。 15. 已完成:agent tools + `tool-packs` owner 化低风险闭环;tool contract / DTO、runtime restriction、path resolution、portable context facts/provider、generic registry / static provider installation / dynamic provider container 已归属 `bitfun-agent-tools`,`tool-packs` 提供 feature-group scaffold 和 product provider group plan,core 保留 concrete tool materialization、product snapshot wrapper adapter、`ToolUseContext` 和 concrete tool implementation,后续外移需单独 service port/provider 设计。 16. 已完成:关键语义回归 baseline,不移动 runtime owner。覆盖 MCP config failure / catalog invalidation / 既有 list-changed helper / dynamic manifest、tool manifest / `GetToolSpec`、product-domains adapter equivalence、remote workspace search fallback 的 focused tests 或 snapshots。 -17. 已完成:remote-connect runtime 当前批次收口。已基于当前 port baseline 记录 remote command/response、remote model catalog、poll response、model catalog delta、session restore、active turn、cancel、image context、tracker event、queue/event fanout 与 dialog orchestration 顺序的输入输出和验证命令;tracker state / registry lifecycle、legacy image context fallback / preference、restore target decision、cancel decision、remote file transfer size/chunk/name policy、remote workspace file IO/path helper、image-context adapter contract 与 RemoteRelay/Bot dialog submission orchestration 已迁入 `bitfun-services-integrations`。dispatcher compatibility wrapper、product execution、workspace-root source、response/base64 wrapping、`ImageContextData` concrete impl、concrete terminal pre-warm adapter 与 workspace/session restore 执行显式保留在 core-owned runtime;后续只有在另起 port/provider 设计且 focused regression 继续通过时才允许继续移动这些 runtime owner,不能把 generic attachment guard 当作已接入多模态行为。 -18. 已完成:`product-domains` runtime port/facade closure。已迁入 MiniApp storage-backed runtime-state facade、built-in seed plan / marker wire helper 与 function-agent Git/AI port-backed runtime facade,并补充 focused contract tests;core 只对 MiniApp deps/restart/recompile/sync/rollback 的状态持久化委托 facade,仍保留 `PathManager` 注入、filesystem IO、worker process execution、host dispatch 执行、built-in asset seeding/source-hash lookup。Git commit-message 与 Startchat work-state 产品路径已通过 core-owned Git/AI adapter 接入 function-agent facade;H2 已将 function-agent prompt template、AI response JSON extraction、JSON repair、domain error mapping 与 JSON-to-domain parsing policy 迁入 product-domain,core 继续保留 Git/AI service adapter、AI client 调用、provider acquisition 与 AI transport error mapping。Startchat 接线已用 no-HEAD diff fallback、非 Git 目录空状态和 `analyze_git=false` time-info 保护旧行为,`analyzed_at` 仍由 core 在 AI 分析完成后赋值。 +17. 已完成:remote-connect runtime 当前批次收口与 HR3 core owner 收口。已基于当前 port baseline 记录 remote command/response、remote model catalog、poll response、model catalog delta、session restore、active turn、cancel、image context、tracker event、queue/event fanout 与 dialog orchestration 顺序的输入输出和验证命令;tracker state / registry lifecycle、legacy image context fallback / preference、restore target decision、cancel decision、remote file transfer size/chunk/name policy、remote workspace file IO/path helper、image-context adapter contract 与 RemoteRelay/Bot dialog submission orchestration 已迁入 `bitfun-services-integrations`。HR3 进一步用 `CoreServiceAgentRuntime` 集中 dispatcher compatibility wrapper 所需的 remote dialog host、remote image context conversion 和 `ConversationCoordinator` runtime-port binding;product execution、workspace-root source、response/base64 wrapping、`ImageContextData` concrete impl、concrete terminal pre-warm adapter、workspace/session restore 执行、remote-SSH runtime 与 agent registry/scheduler 显式保留在 core-owned runtime;后续只有在另起 port/provider 设计且 focused regression 继续通过时才允许继续移动这些 runtime owner,不能把 generic attachment guard 当作已接入多模态行为。 +18. 已完成:`product-domains` runtime port/facade closure 与 HR2 core owner 收口。已迁入 MiniApp storage-backed runtime-state facade、built-in seed plan / marker wire helper 与 function-agent Git/AI port-backed runtime facade,并补充 focused contract tests;core 只对 MiniApp deps/restart/recompile/sync/rollback 的状态持久化委托 facade,仍保留 `PathManager` 注入、filesystem IO、worker process execution、host dispatch 执行、built-in asset seeding/source-hash lookup。Git commit-message 与 Startchat work-state 产品路径已通过 core-owned Git/AI adapter 接入 function-agent facade;H2 已将 function-agent prompt template、AI response JSON extraction、JSON repair、domain error mapping 与 JSON-to-domain parsing policy 迁入 product-domain,HR2 进一步用 `CoreProductDomainRuntime` 集中 core-owned MiniApp/function-agent runtime 绑定,core 继续保留 Git/AI service adapter、AI client 调用、provider acquisition 与 AI transport error mapping。Startchat 接线已用 no-HEAD diff fallback、非 Git 目录空状态和 `analyze_git=false` time-info 保护旧行为,`analyzed_at` 仍由 core 在 AI 分析完成后赋值。 19. 已合入:tool runtime owner 迁移前置基线。纯 helper 已从 runtime owner 中剥离到 `bitfun-agent-tools`:`StaticToolProviderGroup`、registry snapshot 到 manifest policy input、generic collapsed exposure 查询、`GetToolSpec` collapsed-load 纯收集规则、prompt-visible manifest definition 组装规则和 `GetToolSpec` catalog/detail helper;完整 collapsed 工具清单、runtime context 传递和 portable facts 边界回归已作为迁移前基线。 20. 已合入:core-owned tool runtime assembly closure。`ProductToolRuntime` 作为 core 内部单一 owner 收敛 static provider 安装和 product snapshot wrapper adapter,并保持 legacy `create_tool_registry()`、global registry、dynamic MCP tools、manifest resolver、`GetToolSpecTool` 执行、`ToolUseContext` 和 concrete tools 的行为边界不变。 21. 已合入:provider/assembly equivalence guards。custom decorator、provider install、collapsed catalog、manifest / `GetToolSpec` unlock surface 已有等价保护;这些是 behavior-locking tests,不是 runtime owner 迁移。 diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index c538cade56..15c4a92037 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -505,12 +505,12 @@ const forbiddenContentRules = [ { regex: /\bGitService::get_status\b/, message: - 'Git function-agent commit generator must use CoreFunctionAgentGitAdapter through FunctionAgentRuntimeFacade', + 'Git function-agent commit generator must use CoreProductDomainRuntime for Git adapter wiring', }, { regex: /\bAIAnalysisService::new_with_agent_config\b/, message: - 'Git function-agent commit generator must use CoreFunctionAgentAiAdapter through FunctionAgentRuntimeFacade', + 'Git function-agent commit generator must use CoreProductDomainRuntime for AI adapter wiring', }, { regex: /\bto_string_lossy\b/, @@ -525,12 +525,12 @@ const forbiddenContentRules = [ { regex: /\bAIWorkStateService::new_with_agent_config\b/, message: - 'Startchat work-state analyzer must use CoreFunctionAgentAiAdapter through FunctionAgentRuntimeFacade', + 'Startchat work-state analyzer must use CoreProductDomainRuntime for AI adapter wiring', }, { regex: /\bcreate_command\("git"\)/, message: - 'Startchat work-state analyzer must use CoreFunctionAgentGitAdapter through FunctionAgentRuntimeFacade', + 'Startchat work-state analyzer must use CoreProductDomainRuntime for Git adapter wiring', }, ], }, @@ -1423,6 +1423,65 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/crates/core/src/service_agent_runtime.rs', + reason: + 'core service/agent runtime owner must centralize concrete remote-connect and agent runtime port bindings without moving runtime behavior', + patterns: [ + { + regex: /\bpub\(crate\) struct CoreServiceAgentRuntime\b/, + message: 'missing core service/agent runtime owner type', + }, + { + regex: /\bfn remote_dialog_host\b/, + message: 'missing remote dialog host owner factory', + }, + { + regex: /\bfn remote_image_context\b/, + message: 'missing remote image context owner adapter', + }, + { + regex: /\bfn agent_submission_port\b/, + message: 'missing agent submission port owner binding', + }, + { + regex: /\bCoreRemoteDialogRuntimeHost\b/, + message: 'missing core remote dialog host binding', + }, + { + regex: /\bRemoteExecutionDispatcher\b/, + message: 'missing remote execution dispatcher binding', + }, + { + regex: /\bImageContextData\b/, + message: 'missing core image context binding', + }, + { + regex: /\bRemoteImageContextAdapter\b/, + message: 'missing remote image context adapter implementation', + }, + { + regex: /\bAgentSubmissionPort\b/, + message: 'missing agent submission port binding', + }, + { + regex: /\bAgentTurnCancellationPort\b/, + message: 'missing agent turn cancellation port contract guard', + }, + { + regex: /\bRemoteControlStatePort\b/, + message: 'missing remote control state port contract guard', + }, + { + regex: /\bSessionTranscriptReader\b/, + message: 'missing session transcript reader contract guard', + }, + { + regex: /\bcore_service_agent_runtime_owner_keeps_coordinator_port_contracts\b/, + message: 'missing coordinator runtime port contract regression', + }, + ], + }, { path: 'src/crates/services-integrations/src/remote_connect.rs', reason: @@ -1665,6 +1724,10 @@ const requiredContentRules = [ reason: 'core remote-connect server must remain a product runtime adapter around integrations-owned contracts', patterns: [ + { + regex: /\bCoreServiceAgentRuntime\b/, + message: 'missing core service/agent runtime owner routing', + }, { regex: /\bstruct CoreRemoteDialogRuntimeHost\b/, message: 'missing core remote dialog runtime adapter', @@ -1690,9 +1753,13 @@ const requiredContentRules = [ message: 'missing remote file info helper delegation', }, { - regex: /\bImageContextData::from_remote_image_context\b/, + regex: /\bremote_image_context\b/, message: 'missing image context adapter contract delegation', }, + { + regex: /\bcore_service_agent_runtime_owner_maps_remote_image_context\b/, + message: 'missing core service/agent image-context owner regression', + }, { regex: /\bremote_execution_prefers_unified_image_contexts_over_legacy_images\b/, message: 'missing unified image context preference regression', @@ -1719,6 +1786,25 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/crates/core/src/service/remote_connect/bot/command_router.rs', + reason: + 'remote-connect bot must route concrete agent runtime port bindings through the core service/agent runtime owner', + patterns: [ + { + regex: /\bCoreServiceAgentRuntime\b/, + message: 'missing core service/agent runtime owner routing', + }, + { + regex: /\bagent_submission_port\b/, + message: 'missing agent submission port owner binding', + }, + { + regex: /\bbuild_remote_session_create_request\b/, + message: 'missing integrations-owned remote session create request builder', + }, + ], + }, { path: 'src/crates/core/src/agentic/coordination/scheduler.rs', reason: @@ -2965,6 +3051,10 @@ const requiredContentRules = [ regex: /\bMiniAppRuntimeFacade\b/, message: 'missing product-domain MiniApp runtime-state facade use', }, + { + regex: /\bCoreProductDomainRuntime\b/, + message: 'missing core-owned product-domain runtime owner delegation', + }, { regex: /\bpersist_sync_from_fs_result_for_app\b/, message: 'missing product-domain MiniApp sync-from-fs facade delegation', @@ -3079,19 +3169,23 @@ const requiredContentRules = [ { path: 'src/crates/core/src/function_agents/git-func-agent/commit_generator.rs', reason: - 'Git function-agent commit generation must route through the product-domain runtime facade while core keeps concrete adapters', + 'Git function-agent commit generation must route through the core product-domain runtime owner while core keeps concrete adapters', patterns: [ { - regex: /\bFunctionAgentRuntimeFacade\b/, - message: 'missing product-domain function-agent runtime facade routing', + regex: /\bCoreProductDomainRuntime\b/, + message: 'missing core product-domain runtime owner routing', }, { - regex: /\bCoreFunctionAgentGitAdapter\b/, - message: 'missing core-owned Git adapter wiring', + regex: /\bfunction_agent_git_adapter\b/, + message: 'missing core-owned Git adapter factory wiring', }, { - regex: /\bCoreFunctionAgentAiAdapter\b/, - message: 'missing core-owned AI adapter wiring', + regex: /\bfunction_agent_ai_adapter\b/, + message: 'missing core-owned AI adapter factory wiring', + }, + { + regex: /\bfunction_agent_runtime_facade\b/, + message: 'missing product-domain function-agent runtime facade owner routing', }, ], }, @@ -3165,19 +3259,23 @@ const requiredContentRules = [ { path: 'src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs', reason: - 'Startchat work-state analysis must route through the product-domain runtime facade while core keeps concrete adapters', + 'Startchat work-state analysis must route through the core product-domain runtime owner while core keeps concrete adapters', patterns: [ { - regex: /\bFunctionAgentRuntimeFacade\b/, - message: 'missing product-domain function-agent runtime facade routing', + regex: /\bCoreProductDomainRuntime\b/, + message: 'missing core product-domain runtime owner routing', }, { - regex: /\bCoreFunctionAgentGitAdapter\b/, - message: 'missing core-owned Git adapter wiring', + regex: /\bfunction_agent_git_adapter\b/, + message: 'missing core-owned Git adapter factory wiring', }, { - regex: /\bCoreFunctionAgentAiAdapter\b/, - message: 'missing core-owned AI adapter wiring', + regex: /\bfunction_agent_ai_adapter\b/, + message: 'missing core-owned AI adapter factory wiring', + }, + { + regex: /\bfunction_agent_runtime_facade\b/, + message: 'missing product-domain function-agent runtime facade owner routing', }, ], }, @@ -3366,6 +3464,61 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/crates/core/src/product_domain_runtime.rs', + reason: + 'core product-domain runtime owner must centralize concrete MiniApp and function-agent runtime port bindings without moving runtime behavior', + patterns: [ + { + regex: /\bpub\(crate\) struct CoreProductDomainRuntime\b/, + message: 'missing core product-domain runtime owner type', + }, + { + regex: /\bfn miniapp_runtime_facade\b/, + message: 'missing MiniApp runtime facade owner factory', + }, + { + regex: /\bfn function_agent_git_adapter\b/, + message: 'missing function-agent Git adapter owner factory', + }, + { + regex: /\bfn function_agent_ai_adapter\b/, + message: 'missing function-agent AI adapter owner factory', + }, + { + regex: /\bfn function_agent_runtime_facade\b/, + message: 'missing function-agent runtime facade owner factory', + }, + { + regex: /\bCoreFunctionAgentGitAdapter\b/, + message: 'missing core-owned Git adapter binding', + }, + { + regex: /\bCoreFunctionAgentAiAdapter\b/, + message: 'missing core-owned AI adapter binding', + }, + { + regex: /\bMiniAppRuntimeFacade\b/, + message: 'missing MiniApp product-domain facade binding', + }, + { + regex: /\bMiniAppStoragePort\b/, + message: 'missing MiniApp storage port owner binding', + }, + { + regex: /\bFunctionAgentRuntimeFacade\b/, + message: 'missing function-agent product-domain facade binding', + }, + { + regex: /\bFunctionAgentGitPort\b/, + message: 'missing function-agent Git port owner binding', + }, + { + regex: /\bFunctionAgentAiPort\b/, + message: 'missing function-agent AI port owner binding', + }, + ], + }, ]; const failures = []; @@ -3811,6 +3964,24 @@ function runManifestParserSelfTest() { 'generic attachments', ], }, + { + path: 'src/crates/core/src/service_agent_runtime.rs', + contracts: [ + 'CoreServiceAgentRuntime', + 'remote_dialog_host', + 'remote_image_context', + 'agent_submission_port', + 'CoreRemoteDialogRuntimeHost', + 'RemoteExecutionDispatcher', + 'ImageContextData', + 'RemoteImageContextAdapter', + 'AgentSubmissionPort', + 'AgentTurnCancellationPort', + 'RemoteControlStatePort', + 'SessionTranscriptReader', + 'core_service_agent_runtime_owner_keeps_coordinator_port_contracts', + ], + }, { path: 'src/crates/services-integrations/src/remote_connect.rs', contracts: [ @@ -3865,6 +4036,9 @@ function runManifestParserSelfTest() { { path: 'src/crates/core/src/service/remote_connect/remote_server.rs', contracts: [ + 'CoreServiceAgentRuntime', + 'remote_image_context', + 'core_service_agent_runtime_owner_maps_remote_image_context', 'remote_execution_prefers_unified_image_contexts_over_legacy_images', 'remote_cancel_decision_preserves_current_turn_boundaries', 'remote_restore_target_only_restores_cold_sessions_with_workspace_binding', @@ -4192,6 +4366,31 @@ function runManifestParserSelfTest() { 'git_adapter_commit_snapshot_keeps_staged_diff_and_unstaged_count_separate', ], }, + { + path: 'src/crates/core/src/service/remote_connect/bot/command_router.rs', + contracts: [ + 'CoreServiceAgentRuntime', + 'agent_submission_port', + 'build_remote_session_create_request', + ], + }, + { + path: 'src/crates/core/src/product_domain_runtime.rs', + contracts: [ + 'CoreProductDomainRuntime', + 'miniapp_runtime_facade', + 'function_agent_git_adapter', + 'function_agent_ai_adapter', + 'function_agent_runtime_facade', + 'CoreFunctionAgentGitAdapter', + 'CoreFunctionAgentAiAdapter', + 'MiniAppRuntimeFacade', + 'MiniAppStoragePort', + 'FunctionAgentRuntimeFacade', + 'FunctionAgentGitPort', + 'FunctionAgentAiPort', + ], + }, { path: 'src/crates/services-integrations/src/remote_ssh/paths.rs', contracts: [ @@ -4274,6 +4473,7 @@ function runManifestParserSelfTest() { 'mark_builtin_update_available_metadata', 'decline_builtin_update_metadata', 'storage.load_customization_metadata', + 'CoreProductDomainRuntime', 'MiniAppRuntimeFacade', 'persist_sync_from_fs_result_for_app', 'compile_source', @@ -4308,9 +4508,19 @@ function runManifestParserSelfTest() { { path: 'src/crates/core/src/function_agents/git-func-agent/commit_generator.rs', contracts: [ - 'FunctionAgentRuntimeFacade', - 'CoreFunctionAgentGitAdapter', - 'CoreFunctionAgentAiAdapter', + 'CoreProductDomainRuntime', + 'function_agent_git_adapter', + 'function_agent_ai_adapter', + 'function_agent_runtime_facade', + ], + }, + { + path: 'src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs', + contracts: [ + 'CoreProductDomainRuntime', + 'function_agent_git_adapter', + 'function_agent_ai_adapter', + 'function_agent_runtime_facade', ], }, { diff --git a/src/crates/core/AGENTS.md b/src/crates/core/AGENTS.md index b3d7d2cfff..cba0f3f1b8 100644 --- a/src/crates/core/AGENTS.md +++ b/src/crates/core/AGENTS.md @@ -74,7 +74,8 @@ SessionManager → Session → DialogTurn → ModelRound contracts may live in `bitfun-services-integrations`; keep workspace-root source selection, response wrapping, concrete scheduler/session restore, terminal pre-warm adapters, and product execution core-owned until a reviewed - migration proves equivalence. + migration proves equivalence. Core service/agent runtime bindings are + centralized in `src/crates/core/src/service_agent_runtime.rs`. - Do not add new cross-layer references from `service` to `agentic` without a small port/interface boundary. - Do not move platform-specific logic, build-script behavior, or product diff --git a/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs b/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs index d280df27ca..f881ed02d7 100644 --- a/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs +++ b/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs @@ -1,10 +1,7 @@ use super::types::*; use crate::function_agents::common::AgentResult; -use crate::function_agents::port_adapters::{ - CoreFunctionAgentAiAdapter, CoreFunctionAgentGitAdapter, -}; use crate::infrastructure::ai::AIClientFactory; -use bitfun_product_domains::function_agents::ports::FunctionAgentRuntimeFacade; +use crate::product_domain_runtime::CoreProductDomainRuntime; /** * Git Function Agent - commit message generator * @@ -27,9 +24,10 @@ impl CommitGenerator { repo_path ); - let git_adapter = CoreFunctionAgentGitAdapter::default(); - let ai_adapter = CoreFunctionAgentAiAdapter::new(factory); - let facade = FunctionAgentRuntimeFacade::new(&git_adapter, &ai_adapter); + let git_adapter = CoreProductDomainRuntime::function_agent_git_adapter(); + let ai_adapter = CoreProductDomainRuntime::function_agent_ai_adapter(factory); + let facade = + CoreProductDomainRuntime::function_agent_runtime_facade(&git_adapter, &ai_adapter); facade .generate_commit_message(repo_path.to_path_buf(), options) .await diff --git a/src/crates/core/src/function_agents/port_adapters.rs b/src/crates/core/src/function_agents/port_adapters.rs index 2dc203a99c..ee99c0581b 100644 --- a/src/crates/core/src/function_agents/port_adapters.rs +++ b/src/crates/core/src/function_agents/port_adapters.rs @@ -234,6 +234,8 @@ mod tests { use std::path::{Path, PathBuf}; use std::process::Command; + use crate::product_domain_runtime::CoreProductDomainRuntime; + use super::CoreFunctionAgentGitAdapter; struct TestTempDir { @@ -382,6 +384,11 @@ mod tests { assert!(snapshot.last_commit_timestamp.is_none()); } + #[test] + fn core_product_domain_runtime_owner_constructs_function_agent_git_adapter() { + let _adapter = CoreProductDomainRuntime::function_agent_git_adapter(); + } + fn init_git_repo(repo: &std::path::Path) { git(repo, &["init", "-b", "main"]); git(repo, &["config", "user.email", "test@example.com"]); diff --git a/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs b/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs index ddbeb3dd31..14dc3b7134 100644 --- a/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs +++ b/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs @@ -1,10 +1,7 @@ use super::types::*; use crate::function_agents::common::AgentResult; -use crate::function_agents::port_adapters::{ - CoreFunctionAgentAiAdapter, CoreFunctionAgentGitAdapter, -}; use crate::infrastructure::ai::AIClientFactory; -use bitfun_product_domains::function_agents::ports::FunctionAgentRuntimeFacade; +use crate::product_domain_runtime::CoreProductDomainRuntime; use chrono::{Local, Timelike}; /** * Work state analyzer @@ -26,9 +23,10 @@ impl WorkStateAnalyzer { info!("Analyzing work state: repo_path={:?}", repo_path); let now = Local::now(); - let git_adapter = CoreFunctionAgentGitAdapter::default(); - let ai_adapter = CoreFunctionAgentAiAdapter::new(factory); - let facade = FunctionAgentRuntimeFacade::new(&git_adapter, &ai_adapter); + let git_adapter = CoreProductDomainRuntime::function_agent_git_adapter(); + let ai_adapter = CoreProductDomainRuntime::function_agent_ai_adapter(factory); + let facade = + CoreProductDomainRuntime::function_agent_runtime_facade(&git_adapter, &ai_adapter); // Keep the legacy analyzed_at timing in core: assign it after AI analysis completes. let mut analysis = facade .analyze_work_state( diff --git a/src/crates/core/src/lib.rs b/src/crates/core/src/lib.rs index 90720a0213..28e520b24d 100644 --- a/src/crates/core/src/lib.rs +++ b/src/crates/core/src/lib.rs @@ -9,7 +9,9 @@ pub mod agentic; // Agent system, tool system, and product runtime orchestration pub mod function_agents; // Function-based agents pub mod infrastructure; // AI clients, storage, logging, events pub mod miniapp; // AI-generated instant apps (Zero-Dialect Runtime) +pub(crate) mod product_domain_runtime; pub mod service; // Workspace, Config, FileSystem, Terminal, Git +pub(crate) mod service_agent_runtime; pub mod util; // General types, errors, helper functions // Re-export debug_log from infrastructure for backward compatibility. diff --git a/src/crates/core/src/miniapp/manager.rs b/src/crates/core/src/miniapp/manager.rs index d63ac48af6..7622f592a7 100644 --- a/src/crates/core/src/miniapp/manager.rs +++ b/src/crates/core/src/miniapp/manager.rs @@ -6,6 +6,7 @@ use crate::miniapp::storage::MiniAppStorage; use crate::miniapp::types::{ MiniApp, MiniAppAiContext, MiniAppMeta, MiniAppPermissions, MiniAppSource, }; +use crate::product_domain_runtime::CoreProductDomainRuntime; use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_product_domains::miniapp::customization::{ apply_draft_customization_metadata, decline_builtin_update_metadata, @@ -70,7 +71,7 @@ impl MiniAppManager { } fn runtime_facade(&self) -> MiniAppRuntimeFacade<'_> { - MiniAppRuntimeFacade::new(&self.storage) + CoreProductDomainRuntime::miniapp_runtime_facade(&self.storage) } pub fn compile_source( diff --git a/src/crates/core/src/product_domain_runtime.rs b/src/crates/core/src/product_domain_runtime.rs new file mode 100644 index 0000000000..3a188fe3e2 --- /dev/null +++ b/src/crates/core/src/product_domain_runtime.rs @@ -0,0 +1,44 @@ +//! Core-owned adapters for product-domain runtime ports. +//! +//! Product-domain crates own stable contracts and pure orchestration. This +//! module keeps the concrete MiniApp and function-agent runtime bindings in +//! core so filesystem, process, Git, and AI behavior stays on the legacy path. + +use std::sync::Arc; + +use bitfun_product_domains::function_agents::ports::{ + FunctionAgentAiPort, FunctionAgentGitPort, FunctionAgentRuntimeFacade, +}; +use bitfun_product_domains::miniapp::ports::{MiniAppRuntimeFacade, MiniAppStoragePort}; + +use crate::function_agents::port_adapters::{ + CoreFunctionAgentAiAdapter, CoreFunctionAgentGitAdapter, +}; +use crate::infrastructure::ai::AIClientFactory; + +pub(crate) struct CoreProductDomainRuntime; + +impl CoreProductDomainRuntime { + pub(crate) fn miniapp_runtime_facade( + storage: &dyn MiniAppStoragePort, + ) -> MiniAppRuntimeFacade<'_> { + MiniAppRuntimeFacade::new(storage) + } + + pub(crate) fn function_agent_git_adapter() -> CoreFunctionAgentGitAdapter { + CoreFunctionAgentGitAdapter::default() + } + + pub(crate) fn function_agent_ai_adapter( + factory: Arc, + ) -> CoreFunctionAgentAiAdapter { + CoreFunctionAgentAiAdapter::new(factory) + } + + pub(crate) fn function_agent_runtime_facade<'a>( + git: &'a dyn FunctionAgentGitPort, + ai: &'a dyn FunctionAgentAiPort, + ) -> FunctionAgentRuntimeFacade<'a> { + FunctionAgentRuntimeFacade::new(git, ai) + } +} diff --git a/src/crates/core/src/service/remote_connect/bot/command_router.rs b/src/crates/core/src/service/remote_connect/bot/command_router.rs index 97466cbc1e..d1b4d00b44 100644 --- a/src/crates/core/src/service/remote_connect/bot/command_router.rs +++ b/src/crates/core/src/service/remote_connect/bot/command_router.rs @@ -1380,7 +1380,7 @@ async fn guarded_new( async fn create_session(state: &mut BotChatState, agent_type: &str) -> HandleResult { use crate::agentic::coordination::get_global_coordinator; use crate::service::workspace::get_global_workspace_service; - use bitfun_runtime_ports::AgentSubmissionPort; + use crate::service_agent_runtime::CoreServiceAgentRuntime; use bitfun_services_integrations::remote_connect::{ build_remote_session_create_request, RemoteConnectSubmissionSource, }; @@ -1486,7 +1486,7 @@ async fn create_session(state: &mut BotChatState, agent_type: &str) -> HandleRes Some(workspace_path.clone()), RemoteConnectSubmissionSource::Bot, ); - let submission_port: &dyn AgentSubmissionPort = coordinator.as_ref(); + let submission_port = CoreServiceAgentRuntime::agent_submission_port(coordinator.as_ref()); match submission_port.create_session(request).await { Ok(session) => { state.current_session_id = Some(session.session_id.clone()); @@ -1977,10 +1977,10 @@ async fn submit_question_answers( /// to the safe default ("agentic"), so chat keeps working. async fn resolve_session_agent_type(session_id: &str) -> Option { use crate::agentic::coordination::get_global_coordinator; - use bitfun_runtime_ports::AgentSubmissionPort; + use crate::service_agent_runtime::CoreServiceAgentRuntime; let coordinator = get_global_coordinator()?; - let submission_port: &dyn AgentSubmissionPort = coordinator.as_ref(); + let submission_port = CoreServiceAgentRuntime::agent_submission_port(coordinator.as_ref()); submission_port .resolve_session_agent_type(session_id) .await diff --git a/src/crates/core/src/service/remote_connect/remote_server.rs b/src/crates/core/src/service/remote_connect/remote_server.rs index 6e31fbaf58..0e2c2420fc 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -8,6 +8,7 @@ //! for state changes using the `PollSession` command, receiving only //! incremental updates (new messages + current active turn snapshot). +use crate::service_agent_runtime::CoreServiceAgentRuntime; use anyhow::{anyhow, Result}; use log::{debug, error, info}; use serde_json::Value; @@ -22,8 +23,8 @@ use bitfun_services_integrations::remote_connect::{ RemoteCancelDecision, RemoteConnectSubmissionSource, RemoteDialogQueuePriority, RemoteDialogResolvedSubmission, RemoteDialogRuntimeHost, RemoteDialogSubmissionPolicy, RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, RemoteImageContext, - RemoteImageContextAdapter, RemoteSessionTrackerHost, RemoteSessionTrackerRegistry, - RemoteTerminalPrewarmRequest, REMOTE_FILE_MAX_READ_BYTES, + RemoteSessionTrackerHost, RemoteSessionTrackerRegistry, RemoteTerminalPrewarmRequest, + REMOTE_FILE_MAX_READ_BYTES, }; pub use bitfun_services_integrations::remote_connect::{ ActiveTurnSnapshot, AssistantEntry, ChatImageAttachment, ChatMessage, ChatMessageItem, @@ -506,19 +507,7 @@ fn build_core_image_contexts( fn remote_image_context_to_core( context: RemoteImageContext, ) -> crate::agentic::image_analysis::ImageContextData { - crate::agentic::image_analysis::ImageContextData::from_remote_image_context(context) -} - -impl RemoteImageContextAdapter for crate::agentic::image_analysis::ImageContextData { - fn from_remote_image_context(context: RemoteImageContext) -> Self { - Self { - id: context.id, - image_path: context.image_path, - data_url: context.data_url, - mime_type: context.mime_type, - metadata: context.metadata, - } - } + CoreServiceAgentRuntime::remote_image_context(context) } // ── RemoteSessionStateTracker subscriber adapter ───────────────── @@ -571,14 +560,16 @@ impl RemoteSessionTrackerHost for CoreRemoteSessionTrackerHost { } } -struct CoreRemoteDialogRuntimeHost<'a> { +pub(crate) struct CoreRemoteDialogRuntimeHost<'a> { dispatcher: &'a RemoteExecutionDispatcher, coordinator: Arc, scheduler: Arc, } impl<'a> CoreRemoteDialogRuntimeHost<'a> { - fn new(dispatcher: &'a RemoteExecutionDispatcher) -> std::result::Result { + pub(crate) fn new( + dispatcher: &'a RemoteExecutionDispatcher, + ) -> std::result::Result { use crate::agentic::coordination::{get_global_coordinator, get_global_scheduler}; let coordinator = get_global_coordinator() @@ -806,7 +797,7 @@ impl RemoteExecutionDispatcher { source: RemoteConnectSubmissionSource, turn_id: Option, ) -> std::result::Result { - let host = CoreRemoteDialogRuntimeHost::new(self)?; + let host = CoreServiceAgentRuntime::remote_dialog_host(self)?; submit_remote_dialog( &host, @@ -1378,7 +1369,6 @@ impl RemoteServer { async fn handle_session_command(&self, cmd: &RemoteCommand) -> RemoteResponse { use crate::agentic::coordination::get_global_coordinator; - use bitfun_runtime_ports::AgentSubmissionPort; use bitfun_services_integrations::remote_connect::{ build_remote_session_create_request, RemoteConnectSubmissionSource, }; @@ -1542,7 +1532,8 @@ impl RemoteServer { Some(binding_ws_str), RemoteConnectSubmissionSource::Relay, ); - let submission_port: &dyn AgentSubmissionPort = coordinator.as_ref(); + let submission_port = + CoreServiceAgentRuntime::agent_submission_port(coordinator.as_ref()); match submission_port.create_session(request).await { Ok(session) => RemoteResponse::SessionCreated { session_id: session.session_id, @@ -1902,6 +1893,29 @@ mod tests { assert_eq!(value["_request_id"], "req_xyz"); } + #[test] + fn core_service_agent_runtime_owner_maps_remote_image_context() { + let metadata = serde_json::json!({ "source": "relay" }); + let context = RemoteImageContext { + id: "image-1".to_string(), + image_path: Some("/workspace/screenshot.png".to_string()), + data_url: None, + mime_type: "image/png".to_string(), + metadata: Some(metadata.clone()), + }; + + let mapped = + crate::service_agent_runtime::CoreServiceAgentRuntime::remote_image_context(context); + + assert_eq!(mapped.id, "image-1"); + assert_eq!( + mapped.image_path.as_deref(), + Some("/workspace/screenshot.png") + ); + assert_eq!(mapped.mime_type, "image/png"); + assert_eq!(mapped.metadata, Some(metadata)); + } + #[test] fn remote_execution_prefers_unified_image_contexts_over_legacy_images() { let explicit_context = crate::agentic::image_analysis::ImageContextData { diff --git a/src/crates/core/src/service_agent_runtime.rs b/src/crates/core/src/service_agent_runtime.rs new file mode 100644 index 0000000000..3ac35d17a1 --- /dev/null +++ b/src/crates/core/src/service_agent_runtime.rs @@ -0,0 +1,70 @@ +//! Core-owned bindings for service and agent runtime ports. +//! +//! Owner crates keep portable contracts and orchestration policy. This module +//! centralizes the concrete core adapters that still own scheduler execution, +//! session restore, terminal pre-warm, remote image conversion, and runtime-port +//! implementations until a reviewed port/provider migration proves equivalence. + +use bitfun_runtime_ports::AgentSubmissionPort; +use bitfun_services_integrations::remote_connect::{RemoteImageContext, RemoteImageContextAdapter}; + +use crate::agentic::coordination::ConversationCoordinator; +use crate::agentic::image_analysis::ImageContextData; +use crate::service::remote_connect::remote_server::{ + CoreRemoteDialogRuntimeHost, RemoteExecutionDispatcher, +}; + +impl RemoteImageContextAdapter for ImageContextData { + fn from_remote_image_context(context: RemoteImageContext) -> Self { + Self { + id: context.id, + image_path: context.image_path, + data_url: context.data_url, + mime_type: context.mime_type, + metadata: context.metadata, + } + } +} + +pub(crate) struct CoreServiceAgentRuntime; + +impl CoreServiceAgentRuntime { + pub(crate) fn remote_dialog_host( + dispatcher: &RemoteExecutionDispatcher, + ) -> Result, String> { + CoreRemoteDialogRuntimeHost::new(dispatcher) + } + + pub(crate) fn remote_image_context(context: RemoteImageContext) -> ImageContextData { + ImageContextData::from_remote_image_context(context) + } + + pub(crate) fn agent_submission_port( + coordinator: &ConversationCoordinator, + ) -> &(dyn AgentSubmissionPort + '_) { + coordinator + } +} + +#[cfg(test)] +mod tests { + use bitfun_runtime_ports::{ + AgentTurnCancellationPort, RemoteControlStatePort, SessionTranscriptReader, + }; + + use super::*; + + #[test] + fn core_service_agent_runtime_owner_keeps_coordinator_port_contracts() { + fn assert_runtime_ports() + where + T: AgentSubmissionPort + + AgentTurnCancellationPort + + RemoteControlStatePort + + SessionTranscriptReader, + { + } + + assert_runtime_ports::(); + } +} diff --git a/src/crates/product-domains/AGENTS-CN.md b/src/crates/product-domains/AGENTS-CN.md index 75d24987ad..b59369212e 100644 --- a/src/crates/product-domains/AGENTS-CN.md +++ b/src/crates/product-domains/AGENTS-CN.md @@ -37,14 +37,15 @@ built-in bundle/hash/marker seed plan 与 marker wire helper、built-in source/placeholder payload contract、port trait,以及 storage-backed runtime state facade。 -- `function-agents` 拥有纯 DTO、prompt assembly、commit prompt preparation、 - AI response parsing policy、diff truncation policy、JSON string 到领域 DTO 的 - 解析 helper、本地文件形态分析、Git/AI port trait,以及 port-backed runtime - facade orchestration。 +- `function-agents` 拥有纯 DTO、prompt template 与 assembly、commit prompt + preparation、AI response JSON extraction 与 domain error mapping policy、 + diff truncation policy、JSON string 到领域 DTO 的解析 helper、本地文件形态分析、 + Git/AI port trait,以及 port-backed runtime facade orchestration。 - Core 仍拥有 MiniApp filesystem IO、worker process、host dispatch、built-in asset include/seeding、marker IO、recompile orchestration、source-hash lookup、 - `PathManager` 集成、function-agent Git/AI service adapter、prompt template、 - JSON extraction 和 error mapping。 + `PathManager` 集成、function-agent Git/AI service adapter、AI client 调用、 + provider acquisition 和 AI transport error mapping;core 侧 product-domain + runtime 绑定集中在 `src/crates/core/src/product_domain_runtime.rs`。 ## 验证 diff --git a/src/crates/product-domains/AGENTS.md b/src/crates/product-domains/AGENTS.md index 242ae517dd..17196070cf 100644 --- a/src/crates/product-domains/AGENTS.md +++ b/src/crates/product-domains/AGENTS.md @@ -50,7 +50,8 @@ moves here gradually. execution, built-in asset includes/seeding, marker IO, recompile orchestration, source-hash input lookup, `PathManager` integration, function-agent Git/AI service adapters, AI client calls, provider acquisition, and AI transport - error mapping. + error mapping. Core product-domain runtime bindings are centralized in + `src/crates/core/src/product_domain_runtime.rs`. ## Verification diff --git a/src/crates/product-domains/src/function_agents/ports.rs b/src/crates/product-domains/src/function_agents/ports.rs index a62e4b0f65..39f24a5c5b 100644 --- a/src/crates/product-domains/src/function_agents/ports.rs +++ b/src/crates/product-domains/src/function_agents/ports.rs @@ -1,8 +1,10 @@ //! Function-agent service ports for future runtime migration. //! -//! The current core implementation still owns Git commands, AI clients, prompt -//! templates, JSON extraction, and error mapping. These ports define the seam -//! that future adapters must satisfy before those implementations move. +//! The current core implementation still owns Git commands, AI clients, +//! provider acquisition, and AI transport error mapping. Product-domain modules +//! own prompt templates, JSON extraction, and domain error mapping policy; these +//! ports define the runtime boundary that future adapters must satisfy before +//! concrete Git/AI implementations move. use crate::function_agents::common::{AgentError, AgentResult, Language}; use crate::function_agents::git_func_agent::{ @@ -80,7 +82,7 @@ pub trait FunctionAgentGitPort: Send + Sync { /// Future AI boundary for function agents. /// -/// Core still owns AI client selection, prompt templates, response parsing, and +/// Core still owns AI client selection, provider acquisition, and AI transport /// error mapping. Product call sites may route through this trait only after /// focused equivalence tests cover the specific facade path. pub trait FunctionAgentAiPort: Send + Sync { @@ -97,10 +99,10 @@ pub trait FunctionAgentAiPort: Send + Sync { /// Port-backed function-agent facade for future runtime owner migration. /// /// It owns only pure orchestration over function-agent ports and DTO helpers. -/// Core still owns Git/AI service calls, prompt templates, JSON extraction, -/// and concrete error mapping. Startchat product-path rewiring depends on core -/// adapters preserving legacy Git state, diff fallback, time-info, and -/// `analyzed_at` timing semantics. +/// Core still owns Git/AI service calls, provider acquisition, and AI transport +/// errors. Startchat product-path rewiring depends on core adapters preserving +/// legacy Git state, diff fallback, time-info, and `analyzed_at` timing +/// semantics. pub struct FunctionAgentRuntimeFacade<'a> { git: &'a dyn FunctionAgentGitPort, ai: &'a dyn FunctionAgentAiPort, diff --git a/src/crates/services-integrations/AGENTS.md b/src/crates/services-integrations/AGENTS.md index 63d9ff6ed8..23d9645b8c 100644 --- a/src/crates/services-integrations/AGENTS.md +++ b/src/crates/services-integrations/AGENTS.md @@ -19,7 +19,9 @@ slices that are outside pure product logic but still platform-neutral. workspace-file path/read/chunk/info helpers may live here. Workspace-root source selection, response/base64 wrapping, concrete scheduler submission, concrete terminal pre-warm adapters, and product execution remain core-owned - unless a later reviewed port/provider moves them with equivalence tests. + unless a later reviewed port/provider moves them with equivalence tests. Core + bindings for these runtime adapters are centralized in + `src/crates/core/src/service_agent_runtime.rs`. - Remote-SSH path/session identity helpers may live here; SSH channels, SFTP, remote FS, remote terminal, and manager assembly remain core-owned unless a later reviewed port/provider migration proves equivalence. From 411897f53750931aeefbb71873bea263ea048a32 Mon Sep 17 00:00:00 2001 From: limityan Date: Thu, 21 May 2026 19:19:25 +0800 Subject: [PATCH 09/38] fix(ai-adapters): trim Anthropic assistant prefill --- .../providers/anthropic/message_converter.rs | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/crates/ai-adapters/src/providers/anthropic/message_converter.rs b/src/crates/ai-adapters/src/providers/anthropic/message_converter.rs index f61bcbc82f..ea2847ef15 100644 --- a/src/crates/ai-adapters/src/providers/anthropic/message_converter.rs +++ b/src/crates/ai-adapters/src/providers/anthropic/message_converter.rs @@ -41,7 +41,8 @@ impl AnthropicMessageConverter { } // Anthropic requires user/assistant messages to alternate - let merged_messages = Self::merge_consecutive_messages(anthropic_messages); + let mut merged_messages = Self::merge_consecutive_messages(anthropic_messages); + Self::trim_final_assistant_trailing_whitespace(&mut merged_messages); (system_message, merged_messages) } @@ -107,6 +108,36 @@ impl AnthropicMessageConverter { merged } + fn trim_final_assistant_trailing_whitespace(messages: &mut [Value]) { + // Anthropic allows assistant prefill, but rejects final assistant text that ends in whitespace. + let Some(last) = messages.last_mut() else { + return; + }; + if last.get("role").and_then(|role| role.as_str()) != Some("assistant") { + return; + } + + match last.get_mut("content") { + Some(Value::String(text)) => { + let trimmed_len = text.trim_end().len(); + text.truncate(trimmed_len); + } + Some(Value::Array(blocks)) => { + let Some(Value::Object(block)) = blocks.last_mut() else { + return; + }; + if block.get("type").and_then(|value| value.as_str()) != Some("text") { + return; + } + if let Some(Value::String(text)) = block.get_mut("text") { + let trimmed_len = text.trim_end().len(); + text.truncate(trimmed_len); + } + } + _ => {} + } + } + fn convert_user_message(msg: Message) -> Value { let content = msg.content.unwrap_or_default(); @@ -257,4 +288,22 @@ mod tests { assert_eq!(content[0]["thinking"], json!("")); assert_eq!(content[0]["signature"], json!("sig_1")); } + + #[test] + fn trims_trailing_whitespace_from_final_assistant_prefill() { + let (_, messages) = AnthropicMessageConverter::convert_messages(vec![ + Message::user("Generate the file content.".to_string()), + Message::assistant("\n".to_string()), + ]); + + let content = messages + .last() + .expect("final assistant message") + .get("content") + .and_then(|value| value.as_array()) + .expect("assistant content"); + + assert_eq!(content[0]["type"], json!("text")); + assert_eq!(content[0]["text"], json!("")); + } } From 7756865ce6173b05301d17084245afc3859711bc Mon Sep 17 00:00:00 2001 From: jarvis24young <749843026@qq.com> Date: Thu, 21 May 2026 19:57:44 +0800 Subject: [PATCH 10/38] fix(flow-chat): improve Shift+Tab input exclusion and remove redundant handler - Extend document-level Shift+Tab exclusion to cover INPUT, SELECT, and contentEditable elements, not just TEXTAREA, preventing unintended mode cycling when typing in any form field - Remove redundant Shift+Tab handler from textarea React onKeyDown since the document listener handles it with proper exclusion - Remove unnecessary comment explaining what the code already expresses --- src/mobile-web/src/pages/ChatPage.tsx | 10 +++------- src/web-ui/src/flow_chat/components/ChatInput.tsx | 1 - 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 538fad9f04..dd941fbdfd 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -2399,10 +2399,6 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, e.preventDefault(); handleSend(); } - if (e.key === 'Tab' && e.shiftKey) { - e.preventDefault(); - cycleAgentMode(); - } }; const handleCancel = async () => { @@ -2419,9 +2415,9 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Tab' && e.shiftKey) { - // Skip when the textarea is focused — its own React handler already fired. - const tag = (e.target as HTMLElement)?.tagName; - if (tag === 'TEXTAREA') return; + const target = e.target as HTMLElement; + const tag = target?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) return; e.preventDefault(); cycleAgentModeRef.current(); } diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index a03082bb66..19cad8b637 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -2057,7 +2057,6 @@ export const ChatInput: React.FC = ({ e.preventDefault(); e.stopPropagation(); - // Dismiss slash command if open if (slashCommandState.isActive) { setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); dispatchInput({ type: 'CLEAR_VALUE' }); From 29f0deff9584abc290be31621c7ab4c5a4764c83 Mon Sep 17 00:00:00 2001 From: jarvis24young <749843026@qq.com> Date: Thu, 21 May 2026 20:09:35 +0800 Subject: [PATCH 11/38] fix(flow-chat): restore textarea Shift+Tab handler for agent mode cycling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The textarea handler and document listener serve different purposes: - Textarea handler: Shift+Tab in the editable chat input → cycle mode - Document listener: Shift+Tab when input is collapsed → cycle mode (with TEXTAREA exclusion to prevent double-fire) Removing the textarea handler meant Shift+Tab in the expanded textarea would be consumed by the document listener instead, which is less direct and breaks the expected UX of Tab-based mode switching while typing. --- src/mobile-web/src/pages/ChatPage.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index dd941fbdfd..312717ed1b 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -2399,6 +2399,10 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, e.preventDefault(); handleSend(); } + if (e.key === 'Tab' && e.shiftKey) { + e.preventDefault(); + cycleAgentMode(); + } }; const handleCancel = async () => { From 6dcc1e2d740def67f95b667d2ebe77a516f85571 Mon Sep 17 00:00:00 2001 From: limityan Date: Thu, 21 May 2026 18:59:33 +0800 Subject: [PATCH 12/38] refactor(core): tighten feature-gated runtime facade --- docs/architecture/core-decomposition.md | 11 + docs/plans/core-decomposition-plan.md | 32 +- scripts/check-core-boundaries.mjs | 288 ++++++++++ src/crates/core/AGENTS.md | 8 + src/crates/core/Cargo.toml | 16 +- src/crates/core/src/lib.rs | 9 +- .../src/service/bootstrap/bootstrap_impl.rs | 7 +- src/crates/core/src/service/bootstrap/mod.rs | 7 +- src/crates/core/src/service/config/global.rs | 30 +- src/crates/core/src/service/config/mod.rs | 2 + src/crates/core/src/service/mod.rs | 21 + .../core/src/service/remote_ssh/disabled.rs | 540 ++++++++++++++++++ src/crates/core/src/service/remote_ssh/mod.rs | 15 + .../core/src/service/search/flashgrep/mod.rs | 2 + src/crates/core/src/service/search/mod.rs | 6 + .../src/service/search/remote_disabled.rs | 53 ++ .../core/src/service/workspace/manager.rs | 49 +- .../core/src/service/workspace/service.rs | 7 +- .../src/service/workspace_runtime/service.rs | 2 + src/crates/core/src/util/errors.rs | 1 + 20 files changed, 1064 insertions(+), 42 deletions(-) create mode 100644 src/crates/core/src/service/remote_ssh/disabled.rs create mode 100644 src/crates/core/src/service/search/remote_disabled.rs diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index d08b6bf5bb..28495aeab4 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -353,6 +353,17 @@ BitFun 的重构目标不是把 Desktop、CLI、Remote、Server 和 ACP 强行 产品形态时,`product-full` 才可以包含它们。 - 最终要么让 `bitfun-core/product-full` 显式聚合已经验证过的 owner crate capability feature, 要么持续声明它不是完整能力矩阵;不得用它证明未迁移 runtime 已经完成 owner 化。 +- H5 已启动的第一步只允许补齐现有 optional feature 的编译边界。当前先让 + `cargo check -p bitfun-core --no-default-features` 通过,并在 `ssh-remote` 关闭时保留 + remote workspace identity/helper、让实际 SSH/SFTP/terminal/search runtime 返回明确 + unsupported;这不是 remote-SSH runtime owner 迁移,也不改变 `product-full` 或产品发布形态。 +- H5 后续推进只允许把 owner crate feature 传播显式化,保持 + `default = ["product-full"]`、产品 crate `features = ["product-full"]` 和 release/CI + 形态不变。no-default core 可以收敛为 runtime-surface-light facade;`tool-packs` 和 + `product-domains` 可由 core feature 显式启用为 optional dependency,但这不代表整体 + dependency graph 或构建收益已经完成优化。agentic runtime、MiniApp/function-agent、 + Git/MCP/remote-connect/review-platform 等完整产品入口必须继续由 `product-full` 或对应 + owner feature 打开。 - 拆解完成后不要自动移除或减轻 `product-full`。如果未来要用 per-product explicit feature set 替代它,必须作为 P3 之后的独立评估,并且先通过完整产品矩阵。 - 不要把 feature 默认值变更和模块移动放在同一个变更中。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 575f9ed520..df259676ce 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -1836,6 +1836,29 @@ owner-by-owner 迁移队列,不得把它们当作 H4 漏项补进当前 PR。 | HR3:service / agent runtime deep owner migration | 条件性高风险 PR | 当前已完成 core 内部 `service_agent_runtime.rs` 单一 owner 收口;remote-SSH manager / remote FS / terminal、remote-connect workspace-root source / response wrapping / `ImageContextData` concrete impl / concrete scheduler-session-restore-terminal adapter、agent registry / scheduler 继续显式 core-owned;后续若继续深迁移,只允许在单独评审后移动一个 owner 主题 | tool runtime、product-domain runtime、feature matrix 或产品逻辑变更 | 有 port/provider 设计、旧路径兼容、mode-scoped subagent visibility / background delivery / remote-connect dialog order / remote workspace guard / DeepResearch post-turn hook 等行为等价测试 | | H5:feature/build-benefit evaluation | 可选评估 PR | `bitfun-core default = []`、per-product explicit feature set、依赖版本收敛、构建收益数据记录 | 任何 runtime owner 迁移、产品逻辑变更或构建脚本改造 | 有 feature graph baseline、`cargo check -p bitfun-core`、workspace check、目标 crate check 和必要 product check 的前后数据;若收益不清晰则不执行 | +**H5 start(2026-05-21):** 本轮先完成 feature graph baseline 的第一道编译门禁: +`cargo check -p bitfun-core --no-default-features`。当前结论是 `bitfun-core` +已有 `ssh-remote` optional dependency 边界,但源码仍曾无条件编译 remote-SSH +runtime;H5 的第一步只补齐 `ssh-remote` source gate 和 disabled diagnostic surface, +不迁移 remote-SSH runtime owner,不改变 `product-full`、产品 feature set、CI/release +脚本或任何产品行为。per-product feature matrix 仍需要在 no-default 编译闭环稳定后 +单独评估。 + +**H5 follow-up(2026-05-21):** 在 PR #824 合入后,继续把 `bitfun-core/product-full` +改为显式聚合 owner crate feature group:`tool-packs`、`services-integrations` 和 +`product-domains` 不再通过 dependency declaration 强制启用 `product-full`,其中 +`tool-packs` 与 `product-domains` 在 core 中改为 optional dependency,由 +`bitfun-core` 的 feature graph 显式启用。`services-integrations` 仍因 remote workspace +identity/helper 的纯 helper 需求保留 no-default 编译面,只启用 `remote-ssh` owner feature, +不启用完整 product-full。`default = ["product-full"]`、desktop/CLI/ACP +等产品 crate 的 `features = ["product-full"]`、release/CI 脚本和用户可见能力保持不变。 +no-default core 当前只承诺 runtime-surface-light facade 可编译,不声明 dependency graph +或构建收益已经变轻:agentic runtime、MiniApp/function-agent、 +Git/MCP/remote-connect/review-platform、snapshot/token/runtime usage 等完整产品入口继续由 +`product-full` 或对应 owner feature 打开。remote workspace identity/helper 因为 +session/workspace 路径稳定性仍保留在 no-default 编译面;russh-backed SSH/SFTP/terminal/search +runtime 仍由 `ssh-remote` 控制。 + **HR 风险与优化清单:** 所有 HR PR 都必须满足以下共同约束: @@ -2091,7 +2114,14 @@ git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts 31. 已完成:H1 product provider plan closure。`bitfun-tool-packs::product_tool_provider_group_plan` 接管 product provider group id / feature group / tool-name order 计划,core `product_runtime.rs` 只按该计划物化 concrete tools 并继续注入 snapshot wrapper;不迁移 concrete tool implementation、`ToolUseContext`、runtime service handles 或 tool behavior。 32. HR1 当前闭环状态:工具 runtime 的 provider-neutral contract、manifest/catalog runtime facade、GetToolSpec facade、static-provider assembly、readonly filtering、provider plan 与 core product adapter 已收敛;core 内部 product runtime adapter 已统一到 `product_runtime.rs`。`ToolUseContext` 本体和 concrete tools 仍显式 core-owned;继续外移会触碰 workspace services、cancellation、Deep Review hooks 或具体工具 IO,必须作为后续高风险 owner 迁移单独确认。 33. H4 已完成:facade / boundary finalization。`scripts/check-core-boundaries.mjs` 的 regular check 和 self-test 已覆盖 remote-connect file/image/dialog owner anchor、core adapter/deferred owner anchor 与既有 duplicate-path required rule;root / core / services-integrations 文档与当前 H1-H3 代码状态一致,不声明 remote-SSH runtime、agent registry/scheduler、default feature 或构建收益已完成。 -34. 后续独立评估:`bitfun-core default = []`、per-product feature set、依赖版本收敛或构建收益优化;任何收益声明都需要记录 `cargo check -p bitfun-core`、workspace check 和目标 crate check 的前后数据。 +34. H5 已启动并完成当前闭环:第一步建立 `bitfun-core --no-default-features` 编译闭环, + 证明 `ssh-remote` 关闭时不再编译 russh-backed runtime,并通过 disabled surface + 返回明确 unsupported 诊断;第二步把 `bitfun-core/product-full` 改为显式聚合 + `tool-packs`、`services-integrations`、`product-domains` 的 owner feature group; + 其中 `tool-packs` 与 `product-domains` 已成为 core optional dependency, + 同时保持 `default = ["product-full"]`、产品 crate feature set、release/CI 脚本和 + 用户可见能力不变。no-default core 当前只作为 runtime-surface-light facade,不声明 + dependency graph 已变轻、per-product feature matrix、构建收益或 runtime owner 深迁移已完成。 冗余清理 PR 不进入上述主线序号。只有在满足 `0A.6` 的绝对等价要求时,才可以插入到相邻里程碑之间,并且不得与主线拆分 PR 混合。 diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index 15c4a92037..9adde5d16b 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -1146,6 +1146,191 @@ const forbiddenContentUnderRules = [ ]; const requiredContentRules = [ + { + path: 'src/crates/core/Cargo.toml', + reason: + 'bitfun-core product-full must explicitly aggregate owner crate feature groups instead of forcing them through dependency declarations', + patterns: [ + { + regex: + /bitfun-tool-packs = \{ path = "\.\.\/tool-packs", default-features = false, optional = true \}/, + message: 'bitfun-tool-packs dependency must stay optional and not force product-full outside the core feature graph', + }, + { + regex: + /bitfun-services-integrations = \{ path = "\.\.\/services-integrations", default-features = false, features = \["remote-ssh"\] \}/, + message: + 'bitfun-services-integrations dependency may keep remote workspace identity helpers but must not force product-full outside the core feature graph', + }, + { + regex: + /bitfun-product-domains = \{ path = "\.\.\/product-domains", default-features = false, optional = true \}/, + message: + 'bitfun-product-domains dependency must stay optional and not force product-full outside the core feature graph', + }, + { + regex: /"dep:bitfun-tool-packs"/, + message: 'core tool-packs feature must explicitly enable the optional dependency', + }, + { + regex: /"bitfun-tool-packs\/product-full"/, + message: 'core product-full must explicitly enable tool pack product features', + }, + { + regex: /"bitfun-services-integrations\/product-full"/, + message: 'core product-full must explicitly enable integration product features', + }, + { + regex: /"dep:bitfun-product-domains"/, + message: 'core product-domains feature must explicitly enable the optional dependency', + }, + { + regex: /"bitfun-product-domains\/product-full"/, + message: 'core product-full must explicitly enable product-domain features', + }, + ], + }, + { + path: 'src/crates/core/src/lib.rs', + reason: + 'no-default bitfun-core must keep product runtime surfaces behind explicit features', + patterns: [ + { + regex: /#\[cfg\(feature = "product-full"\)\]\s*pub mod agentic\b/s, + message: 'agentic runtime must stay behind product-full for no-default builds', + }, + { + regex: /#\[cfg\(feature = "product-domains"\)\]\s*pub mod function_agents\b/s, + message: 'function-agent product domain facade must stay behind product-domains', + }, + { + regex: /#\[cfg\(feature = "product-domains"\)\]\s*pub mod miniapp\b/s, + message: 'MiniApp product domain facade must stay behind product-domains', + }, + { + regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub\(crate\) mod service_agent_runtime\b/s, + message: 'service agent runtime owner assembly must stay behind service-integrations', + }, + ], + }, + { + path: 'src/crates/core/src/service/mod.rs', + reason: + 'service integration and agent-runtime surfaces must not compile in no-default core builds', + patterns: [ + { + regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub mod git\b/s, + message: 'git service facade must stay behind service-integrations', + }, + { + regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub mod mcp\b/s, + message: 'MCP service facade must stay behind service-integrations', + }, + { + regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub mod remote_connect\b/s, + message: 'remote-connect service facade must stay behind service-integrations', + }, + { + regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub mod review_platform\b/s, + message: 'review platform facade must stay behind service-integrations', + }, + { + regex: /#\[cfg\(feature = "product-full"\)\]\s*pub mod snapshot\b/s, + message: 'snapshot service must stay behind product-full until tool-runtime ownership is split', + }, + ], + }, + { + path: 'src/crates/core/src/service/config/mod.rs', + reason: + 'mode config canonicalization depends on product agent/tool registries and must stay out of no-default builds', + patterns: [ + { + regex: /#\[cfg\(feature = "product-full"\)\]\s*pub mod mode_config_canonicalizer\b/s, + message: 'mode config canonicalizer must stay behind product-full', + }, + ], + }, + { + path: 'src/crates/core/src/service/workspace/manager.rs', + reason: + 'workspace metadata may omit git worktree enrichment when service integrations are disabled', + patterns: [ + { + regex: /#\[cfg\(feature = "service-integrations"\)\]\s*use crate::service::git::GitService\b/s, + message: 'GitService import must stay gated for no-default builds', + }, + { + regex: /#\[cfg\(not\(feature = "service-integrations"\)\)\]\s*\{\s*let _ = workspace_root;\s*return None;\s*\}/s, + message: 'no-default worktree enrichment fallback must remain explicit', + }, + ], + }, + { + path: 'src/crates/core/src/service/workspace_runtime/service.rs', + reason: + 'workspace runtime binding helpers may depend on agentic runtime only in full product builds', + patterns: [ + { + regex: /#\[cfg\(feature = "product-full"\)\]\s*use crate::agentic::WorkspaceBinding\b/s, + message: 'WorkspaceBinding import must stay gated for no-default builds', + }, + { + regex: /#\[cfg\(feature = "product-full"\)\]\s*pub async fn ensure_runtime_for_workspace_binding\b/s, + message: 'WorkspaceBinding runtime helper must stay behind product-full', + }, + ], + }, + { + path: 'src/crates/core/src/service/remote_ssh/mod.rs', + reason: + 'core remote SSH runtime must keep concrete SSH dependencies behind the ssh-remote feature while preserving lightweight workspace identity helpers', + patterns: [ + { + regex: /#\[cfg\(not\(feature = "ssh-remote"\)\)\]\s*mod disabled\b/s, + message: 'missing disabled remote SSH runtime surface for no-default builds', + }, + { + regex: /#\[cfg\(feature = "ssh-remote"\)\]\s*pub mod manager\b/s, + message: 'remote SSH manager must stay gated behind ssh-remote', + }, + { + regex: /#\[cfg\(feature = "ssh-remote"\)\]\s*pub mod remote_fs\b/s, + message: 'remote SSH filesystem runtime must stay gated behind ssh-remote', + }, + { + regex: /#\[cfg\(feature = "ssh-remote"\)\]\s*pub mod remote_terminal\b/s, + message: 'remote SSH terminal runtime must stay gated behind ssh-remote', + }, + { + regex: /\bpub mod workspace_state\b/, + message: 'remote workspace identity helpers must remain available without ssh-remote', + }, + ], + }, + { + path: 'src/crates/core/src/service/remote_ssh/disabled.rs', + reason: + 'no-default core builds must expose explicit unsupported remote SSH stubs instead of compiling russh-backed runtime code', + patterns: [ + { + regex: /Remote SSH support is disabled; enable the `ssh-remote` feature/, + message: 'missing explicit disabled remote SSH diagnostic', + }, + { + regex: /\bpub struct SSHConnectionManager\b/, + message: 'missing disabled SSH manager compatibility surface', + }, + { + regex: /\bpub struct RemoteFileService\b/, + message: 'missing disabled remote file compatibility surface', + }, + { + regex: /\bpub struct RemoteTerminalManager\b/, + message: 'missing disabled remote terminal compatibility surface', + }, + ], + }, { path: 'src/crates/runtime-ports/src/lib.rs', reason: @@ -2532,6 +2717,44 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/crates/core/src/service/search/mod.rs', + reason: + 'remote workspace search must route to the real implementation only when ssh-remote is enabled', + patterns: [ + { + regex: /#\[cfg\(feature = "ssh-remote"\)\]\s*mod remote\b/s, + message: 'missing ssh-remote gate for real remote search implementation', + }, + { + regex: /#\[cfg\(not\(feature = "ssh-remote"\)\)\]\s*mod remote_disabled\b/s, + message: 'missing disabled remote search implementation for no-default builds', + }, + { + regex: /#\[cfg\(not\(feature = "ssh-remote"\)\)\]\s*pub use remote_disabled/s, + message: 'missing disabled remote search export', + }, + ], + }, + { + path: 'src/crates/core/src/service/search/remote_disabled.rs', + reason: + 'no-default core builds must keep remote search unavailable with an explicit diagnostic', + patterns: [ + { + regex: /Remote SSH search is disabled; enable the `ssh-remote` feature/, + message: 'missing explicit disabled remote search diagnostic', + }, + { + regex: /\bpub struct RemoteWorkspaceSearchService\b/, + message: 'missing disabled remote workspace search service surface', + }, + { + regex: /\bremote_workspace_search_service_for_path\b/, + message: 'missing disabled remote workspace search resolver', + }, + ], + }, { path: 'src/crates/acp/src/client/manager.rs', reason: @@ -4254,6 +4477,63 @@ function runManifestParserSelfTest() { path: 'src/crates/core/src/service/search/remote.rs', contracts: ['remote_workspace_search_service_for_path', 'lookup_remote_connection_with_hint', 'allow_scan_fallback', 'fallback_query'], }, + { + path: 'src/crates/core/src/service/search/mod.rs', + contracts: ['mod remote_disabled', 'feature = "ssh-remote"', 'pub use remote_disabled'], + }, + { + path: 'src/crates/core/src/service/search/remote_disabled.rs', + contracts: ['Remote SSH search is disabled', 'RemoteWorkspaceSearchService', 'remote_workspace_search_service_for_path'], + }, + { + path: 'src/crates/core/Cargo.toml', + contracts: [ + 'bitfun-tool-packs = \\{ path = "\\.\\./tool-packs", default-features = false, optional = true \\}', + 'bitfun-services-integrations = \\{ path = "\\.\\./services-integrations", default-features = false, features = \\["remote-ssh"\\] \\}', + 'bitfun-product-domains = \\{ path = "\\.\\./product-domains", default-features = false, optional = true \\}', + 'dep:bitfun-tool-packs', + 'bitfun-tool-packs/product-full', + 'bitfun-services-integrations/product-full', + 'dep:bitfun-product-domains', + 'bitfun-product-domains/product-full', + ], + }, + { + path: 'src/crates/core/src/lib.rs', + contracts: [ + 'feature = "product-full"', + 'pub mod agentic', + 'feature = "product-domains"', + 'pub mod function_agents', + 'pub mod miniapp', + 'feature = "service-integrations"', + 'service_agent_runtime', + ], + }, + { + path: 'src/crates/core/src/service/mod.rs', + contracts: [ + 'feature = "service-integrations"', + 'pub mod git', + 'pub mod mcp', + 'pub mod remote_connect', + 'pub mod review_platform', + 'feature = "product-full"', + 'pub mod snapshot', + ], + }, + { + path: 'src/crates/core/src/service/config/mod.rs', + contracts: ['feature = "product-full"', 'mode_config_canonicalizer'], + }, + { + path: 'src/crates/core/src/service/workspace/manager.rs', + contracts: ['feature = "service-integrations"', 'GitService', 'return None'], + }, + { + path: 'src/crates/core/src/service/workspace_runtime/service.rs', + contracts: ['feature = "product-full"', 'WorkspaceBinding', 'ensure_runtime_for_workspace_binding'], + }, { path: 'src/crates/acp/src/client/manager.rs', contracts: ['CLIENT_STARTUP_TIMEOUT_SECS', 'startup_timeout_error_message', 'formats_startup_timeout_error_message'], @@ -4391,6 +4671,14 @@ function runManifestParserSelfTest() { 'FunctionAgentAiPort', ], }, + { + path: 'src/crates/core/src/service/remote_ssh/mod.rs', + contracts: ['mod disabled', 'pub mod manager', 'pub mod remote_fs', 'pub mod remote_terminal', 'pub mod workspace_state'], + }, + { + path: 'src/crates/core/src/service/remote_ssh/disabled.rs', + contracts: ['Remote SSH support is disabled', 'SSHConnectionManager', 'RemoteFileService', 'RemoteTerminalManager'], + }, { path: 'src/crates/services-integrations/src/remote_ssh/paths.rs', contracts: [ diff --git a/src/crates/core/AGENTS.md b/src/crates/core/AGENTS.md index cba0f3f1b8..898e2c94cd 100644 --- a/src/crates/core/AGENTS.md +++ b/src/crates/core/AGENTS.md @@ -76,6 +76,14 @@ SessionManager → Session → DialogTurn → ModelRound terminal pre-warm adapters, and product execution core-owned until a reviewed migration proves equivalence. Core service/agent runtime bindings are centralized in `src/crates/core/src/service_agent_runtime.rs`. +- Keep concrete remote SSH runtime code behind `ssh-remote`. No-default builds + may keep workspace identity helpers and explicit unsupported stubs, but must + not compile russh-backed SSH/SFTP/terminal/search runtime modules. +- Keep no-default `bitfun-core` as a runtime-surface-light facade, not a + claimed dependency-light build. Full product runtime modules such as agentic, + MiniApp/function-agent, Git/MCP, remote-connect, review-platform, snapshot, + token usage, and mode canonicalization stay behind `product-full` or their + owner feature group. - Do not add new cross-layer references from `service` to `agentic` without a small port/interface boundary. - Do not move platform-specific logic, build-script behavior, or product diff --git a/src/crates/core/Cargo.toml b/src/crates/core/Cargo.toml index 2af41c0fed..4a0880d05e 100644 --- a/src/crates/core/Cargo.toml +++ b/src/crates/core/Cargo.toml @@ -91,16 +91,16 @@ bitfun-agent-stream = { path = "../agent-stream" } bitfun-agent-tools = { path = "../agent-tools" } # Tool pack provider plan -bitfun-tool-packs = { path = "../tool-packs", features = ["product-full"] } +bitfun-tool-packs = { path = "../tool-packs", default-features = false, optional = true } # Core service owner crate bitfun-services-core = { path = "../services-core" } # Integration service owner crate -bitfun-services-integrations = { path = "../services-integrations", features = ["product-full"] } +bitfun-services-integrations = { path = "../services-integrations", default-features = false, features = ["remote-ssh"] } # Product domain owner crate -bitfun-product-domains = { path = "../product-domains", features = ["product-full"] } +bitfun-product-domains = { path = "../product-domains", default-features = false, optional = true } # Tool runtime tool-runtime = { path = "../tool-runtime" } @@ -164,7 +164,15 @@ schannel = "0.1" # Full product runtime feature set. Product crates should depend on this # explicitly before `bitfun-core` default features are made lighter. default = ["product-full"] -product-full = ["ssh-remote"] +product-full = [ + "ssh-remote", + "product-domains", + "service-integrations", + "tool-packs", +] +product-domains = ["dep:bitfun-product-domains", "bitfun-product-domains/product-full"] +service-integrations = ["bitfun-services-integrations/product-full"] +tool-packs = ["dep:bitfun-tool-packs", "bitfun-tool-packs/product-full"] tauri-support = ["tauri"] # Optional tauri support ssh-remote = ["russh", "russh-sftp", "russh-keys", "shellexpand", "ssh_config"] # russh-keys pure-Rust crypto backend (no openssl) diff --git a/src/crates/core/src/lib.rs b/src/crates/core/src/lib.rs index 28e520b24d..9e7da0d2b7 100644 --- a/src/crates/core/src/lib.rs +++ b/src/crates/core/src/lib.rs @@ -5,12 +5,17 @@ //! New implementation code should live in owner crates under `src/crates/*`. //! This crate re-exports legacy paths and wires the full BitFun product runtime. +#[cfg(feature = "product-full")] pub mod agentic; // Agent system, tool system, and product runtime orchestration +#[cfg(feature = "product-domains")] pub mod function_agents; // Function-based agents pub mod infrastructure; // AI clients, storage, logging, events +#[cfg(feature = "product-domains")] pub mod miniapp; // AI-generated instant apps (Zero-Dialect Runtime) +#[cfg(feature = "product-domains")] pub(crate) mod product_domain_runtime; pub mod service; // Workspace, Config, FileSystem, Terminal, Git +#[cfg(feature = "service-integrations")] pub(crate) mod service_agent_runtime; pub mod util; // General types, errors, helper functions @@ -32,6 +37,7 @@ pub use service::{ pub use infrastructure::{ai::AIClient, events::BackendEventManager}; // Export Agentic service core types +#[cfg(feature = "product-full")] pub use agentic::{ core::{Message, Session}, // NOTE: agentic::core::DialogTurn / ModelRound used to be re-exported here @@ -43,7 +49,8 @@ pub use agentic::{ tools::{Tool, ToolPipeline}, }; -// Export ToolRegistry separately +// Export ToolRegistry separately. +#[cfg(feature = "product-full")] pub use agentic::tools::registry::ToolRegistry; // Version information diff --git a/src/crates/core/src/service/bootstrap/bootstrap_impl.rs b/src/crates/core/src/service/bootstrap/bootstrap_impl.rs index 714a0a3888..9bc83a49bc 100644 --- a/src/crates/core/src/service/bootstrap/bootstrap_impl.rs +++ b/src/crates/core/src/service/bootstrap/bootstrap_impl.rs @@ -11,6 +11,7 @@ const BOOTSTRAP_TEMPLATE: &str = include_str!("templates/BOOTSTRAP.md"); const SOUL_TEMPLATE: &str = include_str!("templates/SOUL.md"); const USER_TEMPLATE: &str = include_str!("templates/USER.md"); const IDENTITY_TEMPLATE: &str = include_str!("templates/IDENTITY.md"); +#[cfg(feature = "product-full")] const PERSONA_FILE_NAMES: [&str; 4] = [ BOOTSTRAP_FILE_NAME, SOUL_FILE_NAME, @@ -140,10 +141,12 @@ pub(crate) async fn initialize_workspace_persona_files(workspace_root: &Path) -> Ok(()) } +#[cfg(feature = "product-full")] pub(crate) fn is_workspace_bootstrap_pending(workspace_root: &Path) -> bool { workspace_root.join(BOOTSTRAP_FILE_NAME).exists() } +#[cfg(feature = "product-full")] pub(crate) async fn ensure_workspace_persona_files_for_prompt( workspace_root: &Path, ) -> BitFunResult<()> { @@ -233,6 +236,7 @@ pub async fn reset_workspace_persona_files_to_default(workspace_root: &Path) -> Ok(()) } +#[cfg(feature = "product-full")] pub(crate) async fn build_workspace_persona_prompt( workspace_root: &Path, ) -> BitFunResult> { @@ -308,6 +312,7 @@ The following files are located in the workspace root directory and define your ))) } +#[cfg(feature = "product-full")] fn persona_file_description(file_name: &str) -> &'static str { match file_name { BOOTSTRAP_FILE_NAME => "Bootstrap guidance and initialization instructions", @@ -318,7 +323,7 @@ fn persona_file_description(file_name: &str) -> &'static str { } } -#[cfg(test)] +#[cfg(all(test, feature = "product-full"))] mod tests { use super::{ ensure_workspace_gitignore_ignores_bitfun, ensure_workspace_persona_files_for_prompt, diff --git a/src/crates/core/src/service/bootstrap/mod.rs b/src/crates/core/src/service/bootstrap/mod.rs index d5195b464a..d26eaf719b 100644 --- a/src/crates/core/src/service/bootstrap/mod.rs +++ b/src/crates/core/src/service/bootstrap/mod.rs @@ -1,8 +1,11 @@ mod bootstrap_impl; pub use bootstrap_impl::reset_workspace_persona_files_to_default; +#[cfg(feature = "product-full")] pub(crate) use bootstrap_impl::{ - build_workspace_persona_prompt, ensure_workspace_gitignore_ignores_bitfun, - ensure_workspace_persona_files_for_prompt, initialize_workspace_persona_files, + build_workspace_persona_prompt, ensure_workspace_persona_files_for_prompt, is_workspace_bootstrap_pending, }; +pub(crate) use bootstrap_impl::{ + ensure_workspace_gitignore_ignores_bitfun, initialize_workspace_persona_files, +}; diff --git a/src/crates/core/src/service/config/global.rs b/src/crates/core/src/service/config/global.rs index 76a491a81c..b2f57a7f23 100644 --- a/src/crates/core/src/service/config/global.rs +++ b/src/crates/core/src/service/config/global.rs @@ -4,7 +4,9 @@ use super::service::ConfigService; use crate::util::errors::*; -use log::{debug, info, warn}; +#[cfg(feature = "product-full")] +use log::warn; +use log::{debug, info}; use std::sync::Arc; use std::sync::OnceLock; use tokio::sync::RwLock; @@ -101,18 +103,21 @@ impl GlobalConfigManager { info!("Global config service initialized"); - match super::mode_config_canonicalizer::canonicalize_mode_configs().await { - Ok(report) => { - if !report.removed_mode_configs.is_empty() || !report.updated_modes.is_empty() { - info!( - "Mode config canonicalization completed: removed_modes={}, updated_modes={}", - report.removed_mode_configs.len(), - report.updated_modes.len() - ); + #[cfg(feature = "product-full")] + { + match super::mode_config_canonicalizer::canonicalize_mode_configs().await { + Ok(report) => { + if !report.removed_mode_configs.is_empty() || !report.updated_modes.is_empty() { + info!( + "Mode config canonicalization completed: removed_modes={}, updated_modes={}", + report.removed_mode_configs.len(), + report.updated_modes.len() + ); + } + } + Err(e) => { + warn!("Mode config canonicalization failed: {}", e); } - } - Err(e) => { - warn!("Mode config canonicalization failed: {}", e); } } @@ -156,6 +161,7 @@ impl GlobalConfigManager { pub async fn reload() -> BitFunResult<()> { let service = Self::get_service().await?; service.reload().await?; + #[cfg(feature = "product-full")] if let Err(error) = super::mode_config_canonicalizer::canonicalize_mode_configs().await { warn!( "Mode config canonicalization failed after reload: {}", diff --git a/src/crates/core/src/service/config/mod.rs b/src/crates/core/src/service/config/mod.rs index 74aaaf06fd..0ee1d589dd 100644 --- a/src/crates/core/src/service/config/mod.rs +++ b/src/crates/core/src/service/config/mod.rs @@ -6,6 +6,7 @@ pub mod app_language; pub mod factory; pub mod global; pub mod manager; +#[cfg(feature = "product-full")] pub mod mode_config_canonicalizer; pub mod providers; pub mod service; @@ -20,6 +21,7 @@ pub use global::{ subscribe_config_updates, ConfigUpdateEvent, GlobalConfigManager, }; pub use manager::{ConfigManager, ConfigManagerSettings, ConfigStatistics}; +#[cfg(feature = "product-full")] pub use mode_config_canonicalizer::{ canonicalize_mode_configs, ModeConfigCanonicalizationReport, ModeConfigUpdateInfo, }; diff --git a/src/crates/core/src/service/mod.rs b/src/crates/core/src/service/mod.rs index 6eae26fe3d..45b72a7e00 100644 --- a/src/crates/core/src/service/mod.rs +++ b/src/crates/core/src/service/mod.rs @@ -4,25 +4,36 @@ //! isolated. High-coupling runtime services stay here until their port //! contracts and equivalence tests are explicit. +#[cfg(feature = "product-full")] pub(crate) mod agent_memory; // Agent memory prompt helpers +#[cfg(feature = "service-integrations")] pub mod announcement; // Announcement / feature-demo / tips system pub(crate) mod bootstrap; // Workspace persona bootstrap helpers pub mod config; // Config management +#[cfg(feature = "product-full")] pub mod cron; // Scheduled jobs pub mod filesystem; // FileSystem management +#[cfg(feature = "service-integrations")] pub mod git; // Git service pub mod i18n; // I18n service pub mod lsp; // LSP (Language Server Protocol) system +#[cfg(feature = "service-integrations")] pub mod mcp; // MCP (Model Context Protocol) system +#[cfg(feature = "product-full")] pub mod project_context; // Project context management +#[cfg(feature = "service-integrations")] pub mod remote_connect; // Remote Connect (phone → desktop) pub mod remote_ssh; // Remote SSH (desktop → server) +#[cfg(feature = "service-integrations")] pub mod review_platform; // Pull request review platform adapters pub mod runtime; // Managed runtime and capability management pub mod search; // Workspace search via managed flashgrep daemon pub mod session; // Session persistence +#[cfg(feature = "product-full")] pub mod session_usage; // Session runtime usage reports +#[cfg(feature = "product-full")] pub mod snapshot; // Snapshot-based change tracking +#[cfg(feature = "product-full")] pub mod token_usage; // Token usage tracking pub mod workspace; // Workspace management // Diff calculation and merge service pub mod workspace_runtime; // Workspace runtime layout / migration / initialization @@ -32,28 +43,36 @@ pub mod workspace_runtime; // Workspace runtime layout / migration / initializat pub use terminal_core as terminal; // Re-export main components. +#[cfg(feature = "service-integrations")] pub use announcement::{AnnouncementCard, AnnouncementScheduler, AnnouncementSchedulerRef}; pub use bitfun_services_core::{diagnostics, diff, system}; +#[cfg(feature = "service-integrations")] pub use bitfun_services_integrations::file_watch; pub use bootstrap::reset_workspace_persona_files_to_default; pub use config::{ConfigManager, ConfigProvider, ConfigService}; +#[cfg(feature = "product-full")] pub use cron::{ get_global_cron_service, set_global_cron_service, CronEventSubscriber, CronService, }; pub use diff::{ DiffConfig, DiffHunk, DiffLine, DiffLineType, DiffOptions, DiffResult, DiffService, }; +#[cfg(feature = "service-integrations")] pub use file_watch::{ get_global_file_watch_service, get_watched_paths, initialize_file_watch_service, start_file_watch, stop_file_watch, FileWatchEvent, FileWatchEventKind, FileWatchService, FileWatcherConfig, }; pub use filesystem::{DirectoryStats, FileSystemService, FileSystemServiceFactory}; +#[cfg(feature = "service-integrations")] pub use git::GitService; pub use i18n::{get_global_i18n_service, I18nConfig, I18nService, LocaleId, LocaleMetadata}; pub use lsp::LspManager; +#[cfg(feature = "service-integrations")] pub use mcp::MCPService; +#[cfg(feature = "product-full")] pub use project_context::{ContextDocumentStatus, ProjectContextConfig, ProjectContextService}; +#[cfg(feature = "service-integrations")] pub use review_platform::{ ReviewAuthSource, ReviewAuthState, ReviewChecks, ReviewDecision, ReviewFileStatus, ReviewItemState, ReviewPlatformAccount, ReviewPlatformAuthChallenge, @@ -74,11 +93,13 @@ pub use search::{ WorkspaceSearchTaskKind, WorkspaceSearchTaskPhase, WorkspaceSearchTaskState, WorkspaceSearchTaskStatus, }; +#[cfg(feature = "product-full")] pub use snapshot::SnapshotService; pub use system::{ check_command, check_commands, run_command, run_command_simple, CheckCommandResult, CommandOutput, SystemError, }; +#[cfg(feature = "product-full")] pub use token_usage::{ ModelTokenStats, SessionTokenStats, TimeRange, TokenUsageQuery, TokenUsageRecord, TokenUsageService, TokenUsageSummary, diff --git a/src/crates/core/src/service/remote_ssh/disabled.rs b/src/crates/core/src/service/remote_ssh/disabled.rs new file mode 100644 index 0000000000..c4eacdd05b --- /dev/null +++ b/src/crates/core/src/service/remote_ssh/disabled.rs @@ -0,0 +1,540 @@ +//! Disabled Remote SSH runtime stubs for lightweight feature builds. +//! +//! The shared workspace identity and wire types remain available without the +//! `ssh-remote` feature. Concrete SSH, SFTP, PTY, and port-forward operations +//! stay behind `ssh-remote` and return explicit unsupported errors here. + +use crate::service::remote_ssh::types::{ + RemoteDirEntry, RemoteFileEntry, RemoteTreeNode, SSHCommandOptions, SSHCommandResult, + SSHConfigEntry, SSHConfigLookupResult, SSHConnectionConfig, SSHConnectionResult, + SavedConnection, ServerInfo, +}; +use crate::service::terminal::session::SessionSource; +use std::path::PathBuf; + +fn unsupported() -> anyhow::Error { + anyhow::anyhow!("Remote SSH support is disabled; enable the `ssh-remote` feature") +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct KnownHostEntry { + pub host: String, + pub port: u16, + pub key_type: String, + pub fingerprint: String, + pub public_key: String, +} + +#[derive(Debug, Clone)] +pub struct SSHConnectionManager { + data_dir: PathBuf, +} + +impl SSHConnectionManager { + pub fn new(data_dir: PathBuf) -> Self { + Self { data_dir } + } + + pub async fn load_known_hosts(&self) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn add_known_host( + &self, + _host: String, + _port: u16, + _key_type: String, + _fingerprint: String, + _public_key: String, + ) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn is_known_host(&self, _host: &str, _port: u16) -> bool { + false + } + + pub async fn get_known_host(&self, _host: &str, _port: u16) -> Option { + None + } + + pub async fn remove_known_host(&self, _host: &str, _port: u16) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn list_known_hosts(&self) -> Vec { + Vec::new() + } + + pub async fn load_remote_workspace(&self) -> anyhow::Result<()> { + Ok(()) + } + + pub async fn set_remote_workspace( + &self, + _workspace: crate::service::remote_ssh::types::RemoteWorkspace, + ) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn get_remote_workspaces( + &self, + ) -> Vec { + Vec::new() + } + + pub async fn prune_remote_workspaces_without_saved_connections( + &self, + ) -> anyhow::Result> { + Ok(Vec::new()) + } + + pub async fn get_remote_workspace( + &self, + _connection_id: &str, + ) -> Option { + None + } + + pub async fn remove_remote_workspace(&self, _connection_id: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn clear_remote_workspace(&self) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn get_ssh_config(&self, _host: &str) -> SSHConfigLookupResult { + SSHConfigLookupResult { + found: false, + config: None, + } + } + + pub async fn list_ssh_config_hosts(&self) -> Vec { + Vec::new() + } + + pub async fn load_saved_connections(&self) -> anyhow::Result<()> { + Ok(()) + } + + pub async fn get_saved_connections(&self) -> Vec { + Vec::new() + } + + pub async fn prune_saved_connections_without_credentials(&self) -> anyhow::Result> { + Ok(Vec::new()) + } + + pub async fn get_saved_host_for_connection_id(&self, _connection_id: &str) -> Option { + None + } + + pub async fn save_connection(&self, _config: &SSHConnectionConfig) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn load_stored_password( + &self, + _connection_id: &str, + ) -> anyhow::Result> { + Err(unsupported()) + } + + pub async fn has_stored_password(&self, _connection_id: &str) -> bool { + false + } + + pub async fn delete_saved_connection(&self, _connection_id: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn connect( + &self, + config: SSHConnectionConfig, + ) -> anyhow::Result { + self.connect_with_timeout(config, None).await + } + + pub async fn connect_with_timeout( + &self, + _config: SSHConnectionConfig, + _timeout_ms: Option, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn disconnect(&self, _connection_id: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn disconnect_all(&self) {} + + pub async fn is_connected(&self, _connection_id: &str) -> bool { + false + } + + pub async fn execute_command( + &self, + _connection_id: &str, + _command: &str, + ) -> anyhow::Result<(String, String, i32)> { + Err(unsupported()) + } + + pub async fn execute_command_with_options( + &self, + _connection_id: &str, + _command: &str, + _options: SSHCommandOptions, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn get_server_info(&self, _connection_id: &str) -> Option { + None + } + + pub async fn resolve_remote_home_if_missing(&self, _connection_id: &str) -> Option { + None + } + + pub async fn get_connection_config(&self, _connection_id: &str) -> Option { + None + } + + pub async fn resolve_sftp_path( + &self, + _connection_id: &str, + _path: &str, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn sftp_read(&self, _connection_id: &str, _path: &str) -> anyhow::Result> { + Err(unsupported()) + } + + pub async fn sftp_write( + &self, + _connection_id: &str, + _path: &str, + _content: &[u8], + ) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn sftp_mkdir(&self, _connection_id: &str, _path: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn sftp_mkdir_all(&self, _connection_id: &str, _path: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn sftp_remove(&self, _connection_id: &str, _path: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn sftp_rmdir(&self, _connection_id: &str, _path: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn sftp_rename( + &self, + _connection_id: &str, + _old_path: &str, + _new_path: &str, + ) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn sftp_exists(&self, _connection_id: &str, _path: &str) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn open_pty( + &self, + _connection_id: &str, + _cols: u32, + _rows: u32, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn get_server_key_fingerprint(&self, _connection_id: &str) -> anyhow::Result { + Err(unsupported()) + } + + pub fn data_dir(&self) -> &PathBuf { + &self.data_dir + } +} + +#[derive(Debug, Clone)] +pub struct PTYSession { + connection_id: String, +} + +impl PTYSession { + pub async fn write(&self, _data: &[u8]) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn resize(&self, _cols: u32, _rows: u32) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn read(&self) -> anyhow::Result>> { + Err(unsupported()) + } + + pub async fn close(self) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub fn connection_id(&self) -> &str { + &self.connection_id + } +} + +#[derive(Debug, Clone)] +pub struct PortForward { + pub id: String, + pub local_port: u16, + pub remote_host: String, + pub remote_port: u16, + pub direction: PortForwardDirection, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum PortForwardDirection { + Local, + Remote, + Dynamic, +} + +#[derive(Clone, Default)] +pub struct PortForwardManager; + +impl PortForwardManager { + pub fn new() -> Self { + Self + } + + pub fn with_ssh_manager(_ssh_manager: SSHConnectionManager) -> Self { + Self + } + + pub async fn set_ssh_manager(&self, _manager: SSHConnectionManager) {} + + pub async fn start_local_forward( + &self, + _connection_id: &str, + _local_port: u16, + _remote_host: &str, + _remote_port: u16, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn start_remote_forward( + &self, + _connection_id: &str, + _remote_port: u16, + _local_host: &str, + _local_port: u16, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn stop_forward(&self, _forward_id: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn stop_all(&self) {} + + pub async fn list_forwards(&self) -> Vec { + Vec::new() + } + + pub async fn is_port_forwarded(&self, _port: u16) -> bool { + false + } +} + +#[derive(Debug, Clone)] +pub struct RemoteFileService; + +impl RemoteFileService { + pub fn new( + _manager: std::sync::Arc>>, + ) -> Self { + Self + } + + pub async fn read_file(&self, _connection_id: &str, _path: &str) -> anyhow::Result> { + Err(unsupported()) + } + + pub async fn write_file( + &self, + _connection_id: &str, + _path: &str, + _content: &[u8], + ) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn exists(&self, _connection_id: &str, _path: &str) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn is_file(&self, _connection_id: &str, _path: &str) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn is_dir(&self, _connection_id: &str, _path: &str) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn read_dir( + &self, + _connection_id: &str, + _path: &str, + ) -> anyhow::Result> { + Err(unsupported()) + } + + pub async fn build_tree( + &self, + _connection_id: &str, + _path: &str, + _max_depth: Option, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn build_shallow_tree_for_layout_preview( + &self, + _connection_id: &str, + _path: &str, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn create_dir(&self, _connection_id: &str, _path: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn create_dir_all(&self, _connection_id: &str, _path: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn remove_file(&self, _connection_id: &str, _path: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn remove_dir_all(&self, _connection_id: &str, _path: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn rename( + &self, + _connection_id: &str, + _old_path: &str, + _new_path: &str, + ) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn stat( + &self, + _connection_id: &str, + _path: &str, + ) -> anyhow::Result> { + Err(unsupported()) + } +} + +#[derive(Debug, Clone)] +pub struct RemoteTerminalSession { + pub id: String, + pub name: String, + pub connection_id: String, + pub cwd: String, + pub pid: Option, + pub status: SessionStatus, + pub cols: u16, + pub rows: u16, + pub source: SessionSource, +} + +pub struct CreateSessionResult { + pub session: RemoteTerminalSession, + pub output_rx: tokio::sync::broadcast::Receiver>, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SessionStatus { + Active, + Inactive, + Closed, +} + +#[derive(Clone, Default)] +pub struct RemoteTerminalManager; + +impl RemoteTerminalManager { + pub fn new(_ssh_manager: SSHConnectionManager) -> Self { + Self + } + + pub async fn set_ssh_manager(&self, _manager: SSHConnectionManager) {} + + #[allow(clippy::too_many_arguments)] + pub async fn create_session( + &self, + _session_id: Option, + _name: Option, + _connection_id: &str, + _cols: u16, + _rows: u16, + _initial_cwd: Option<&str>, + _source: Option, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn get_session(&self, _session_id: &str) -> Option { + None + } + + pub async fn list_sessions(&self) -> Vec { + Vec::new() + } + + pub async fn write(&self, _session_id: &str, _data: &[u8]) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn resize(&self, _session_id: &str, _cols: u16, _rows: u16) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn close_session(&self, _session_id: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn is_pty_active(&self, _session_id: &str) -> bool { + false + } + + pub async fn subscribe_output( + &self, + _session_id: &str, + ) -> anyhow::Result>> { + Err(unsupported()) + } +} diff --git a/src/crates/core/src/service/remote_ssh/mod.rs b/src/crates/core/src/service/remote_ssh/mod.rs index ed662518cd..bc2a4301ff 100644 --- a/src/crates/core/src/service/remote_ssh/mod.rs +++ b/src/crates/core/src/service/remote_ssh/mod.rs @@ -4,18 +4,33 @@ //! This allows BitFun to work with files on remote servers via SSH, //! similar to VSCode's Remote SSH extension. +#[cfg(not(feature = "ssh-remote"))] +mod disabled; +#[cfg(feature = "ssh-remote")] pub mod manager; +#[cfg(feature = "ssh-remote")] mod password_vault; +#[cfg(feature = "ssh-remote")] pub mod remote_fs; +#[cfg(feature = "ssh-remote")] pub mod remote_terminal; pub mod types; pub mod workspace_state; +#[cfg(not(feature = "ssh-remote"))] +pub use disabled::{ + KnownHostEntry, PTYSession, PortForward, PortForwardDirection, PortForwardManager, + RemoteFileService, RemoteTerminalManager, RemoteTerminalSession, SSHConnectionManager, + SessionStatus, +}; +#[cfg(feature = "ssh-remote")] pub use manager::{ KnownHostEntry, PTYSession, PortForward, PortForwardDirection, PortForwardManager, SSHConnectionManager, }; +#[cfg(feature = "ssh-remote")] pub use remote_fs::RemoteFileService; +#[cfg(feature = "ssh-remote")] pub use remote_terminal::{RemoteTerminalManager, RemoteTerminalSession, SessionStatus}; pub use types::*; pub use workspace_state::{ diff --git a/src/crates/core/src/service/search/flashgrep/mod.rs b/src/crates/core/src/service/search/flashgrep/mod.rs index a52446ae71..9b2ecd0406 100644 --- a/src/crates/core/src/service/search/flashgrep/mod.rs +++ b/src/crates/core/src/service/search/flashgrep/mod.rs @@ -1,3 +1,5 @@ +#![cfg_attr(not(feature = "ssh-remote"), allow(unused_imports, dead_code))] + mod client; pub mod error; mod protocol; diff --git a/src/crates/core/src/service/search/mod.rs b/src/crates/core/src/service/search/mod.rs index faff550dfb..118554878f 100644 --- a/src/crates/core/src/service/search/mod.rs +++ b/src/crates/core/src/service/search/mod.rs @@ -1,9 +1,15 @@ pub(crate) mod flashgrep; +#[cfg(feature = "ssh-remote")] mod remote; +#[cfg(not(feature = "ssh-remote"))] +mod remote_disabled; pub mod service; pub mod types; +#[cfg(feature = "ssh-remote")] pub use remote::{remote_workspace_search_service_for_path, RemoteWorkspaceSearchService}; +#[cfg(not(feature = "ssh-remote"))] +pub use remote_disabled::{remote_workspace_search_service_for_path, RemoteWorkspaceSearchService}; pub use service::{ get_global_workspace_search_service, resolve_workspace_search_daemon_program_path, set_global_workspace_search_service, workspace_search_daemon_available, diff --git a/src/crates/core/src/service/search/remote_disabled.rs b/src/crates/core/src/service/search/remote_disabled.rs new file mode 100644 index 0000000000..221469c5c4 --- /dev/null +++ b/src/crates/core/src/service/search/remote_disabled.rs @@ -0,0 +1,53 @@ +//! Disabled remote workspace search surface for lightweight feature builds. + +use crate::service::remote_ssh::workspace_state::RemoteWorkspaceEntry; +use crate::service::search::{ + ContentSearchRequest, ContentSearchResult, GlobSearchRequest, GlobSearchResult, + IndexTaskHandle, WorkspaceIndexStatus, +}; + +fn unsupported() -> String { + "Remote SSH search is disabled; enable the `ssh-remote` feature".to_string() +} + +#[derive(Clone)] +pub struct RemoteWorkspaceSearchService; + +impl RemoteWorkspaceSearchService { + pub async fn get_index_status(&self, _root_path: &str) -> Result { + Err(unsupported()) + } + + pub async fn build_index(&self, _root_path: &str) -> Result { + Err(unsupported()) + } + + pub async fn rebuild_index(&self, _root_path: &str) -> Result { + Err(unsupported()) + } + + pub async fn search_content( + &self, + _request: ContentSearchRequest, + ) -> Result { + Err(unsupported()) + } + + pub async fn glob(&self, _request: GlobSearchRequest) -> Result { + Err(unsupported()) + } + + pub async fn resolve_remote_workspace_entry( + &self, + _repo_root: &str, + ) -> Result { + Err(unsupported()) + } +} + +pub async fn remote_workspace_search_service_for_path( + _root_path: &str, + _preferred_connection_id: Option, +) -> Result { + Err(unsupported()) +} diff --git a/src/crates/core/src/service/workspace/manager.rs b/src/crates/core/src/service/workspace/manager.rs index c1bfa80ecc..22bc67c1ee 100644 --- a/src/crates/core/src/service/workspace/manager.rs +++ b/src/crates/core/src/service/workspace/manager.rs @@ -1,5 +1,6 @@ //! Workspace manager. +#[cfg(feature = "service-integrations")] use crate::service::git::GitService; use crate::service::remote_ssh::workspace_state::{ canonicalize_local_workspace_root, local_workspace_roots_equal, @@ -421,26 +422,35 @@ impl WorkspaceInfo { } async fn resolve_worktree_info(workspace_root: &Path) -> Option { - let normalized_workspace_path = workspace_root.to_string_lossy().replace('\\', "/"); - let worktrees = match GitService::list_worktrees(workspace_root).await { - Ok(worktrees) => worktrees, - Err(_) => return None, - }; + #[cfg(not(feature = "service-integrations"))] + { + let _ = workspace_root; + return None; + } - let main_repo_path = worktrees - .iter() - .find(|worktree| worktree.is_main) - .map(|worktree| worktree.path.clone())?; + #[cfg(feature = "service-integrations")] + { + let normalized_workspace_path = workspace_root.to_string_lossy().replace('\\', "/"); + let worktrees = match GitService::list_worktrees(workspace_root).await { + Ok(worktrees) => worktrees, + Err(_) => return None, + }; - worktrees - .into_iter() - .find(|worktree| worktree.path == normalized_workspace_path) - .map(|worktree| WorkspaceWorktreeInfo { - path: worktree.path, - branch: worktree.branch, - main_repo_path: main_repo_path.clone(), - is_main: worktree.is_main, - }) + let main_repo_path = worktrees + .iter() + .find(|worktree| worktree.is_main) + .map(|worktree| worktree.path.clone())?; + + worktrees + .into_iter() + .find(|worktree| worktree.path == normalized_workspace_path) + .map(|worktree| WorkspaceWorktreeInfo { + path: worktree.path, + branch: worktree.branch, + main_repo_path: main_repo_path.clone(), + is_main: worktree.is_main, + }) + } } /// Detects the workspace type. @@ -871,7 +881,8 @@ impl WorkspaceManager { path: PathBuf, options: WorkspaceOpenOptions, ) -> BitFunResult { - self.upsert_workspace_with_options(path, options, true).await + self.upsert_workspace_with_options(path, options, true) + .await } /// Registers or refreshes workspace activity without changing opened UI state. diff --git a/src/crates/core/src/service/workspace/service.rs b/src/crates/core/src/service/workspace/service.rs index 21ff217e33..6f78628964 100644 --- a/src/crates/core/src/service/workspace/service.rs +++ b/src/crates/core/src/service/workspace/service.rs @@ -322,7 +322,10 @@ impl WorkspaceService { if result.is_ok() { if let Err(e) = self.save_workspace_data().await { - warn!("Failed to save workspace data after tracking activity: {}", e); + warn!( + "Failed to save workspace data after tracking activity: {}", + e + ); } } @@ -1859,7 +1862,7 @@ pub fn get_global_workspace_service() -> Option> { GLOBAL_WORKSPACE_SERVICE.get().cloned() } -#[cfg(test)] +#[cfg(all(test, feature = "product-full"))] mod tests { use super::*; use crate::agentic::persistence::PersistenceManager; diff --git a/src/crates/core/src/service/workspace_runtime/service.rs b/src/crates/core/src/service/workspace_runtime/service.rs index 8f75508b61..ca2f8b68e7 100644 --- a/src/crates/core/src/service/workspace_runtime/service.rs +++ b/src/crates/core/src/service/workspace_runtime/service.rs @@ -2,6 +2,7 @@ use super::types::{ RuntimeMigrationRecord, WorkspaceRuntimeContext, WorkspaceRuntimeEnsureResult, WorkspaceRuntimeTarget, WORKSPACE_RUNTIME_LAYOUT_VERSION, }; +#[cfg(feature = "product-full")] use crate::agentic::WorkspaceBinding; use crate::infrastructure::{get_path_manager_arc, PathManager}; use crate::service::remote_ssh::workspace_state::{ @@ -132,6 +133,7 @@ impl WorkspaceRuntimeService { .await } + #[cfg(feature = "product-full")] pub async fn ensure_runtime_for_workspace_binding( &self, workspace: &WorkspaceBinding, diff --git a/src/crates/core/src/util/errors.rs b/src/crates/core/src/util/errors.rs index ae11a10b25..5b5474cc55 100644 --- a/src/crates/core/src/util/errors.rs +++ b/src/crates/core/src/util/errors.rs @@ -179,6 +179,7 @@ impl From for BitFunError { } } +#[cfg(feature = "service-integrations")] impl From for BitFunError { fn from(error: bitfun_services_integrations::mcp::MCPRuntimeError) -> Self { use bitfun_services_integrations::mcp::MCPRuntimeErrorKind; From 49916d5cddae22c5ed14b0d4133d9d08220421f8 Mon Sep 17 00:00:00 2001 From: limityan Date: Thu, 21 May 2026 20:05:50 +0800 Subject: [PATCH 13/38] fix(core): refresh session context window from model config --- .../src/agentic/session/session_manager.rs | 179 ++++++++++++++++-- 1 file changed, 162 insertions(+), 17 deletions(-) diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 96fbbb3ae7..585416873a 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -113,17 +113,28 @@ struct SessionCleanupCandidate { } impl SessionManager { - async fn resolve_model_context_window(model_id: &str) -> Option { + async fn load_ai_config_for_model_resolution() + -> Option + { + let config_service = get_global_config_service().await.ok()?; + config_service.get_config(Some("ai")).await.ok() + } + + fn is_auto_model_selector(model_id: &str) -> bool { let trimmed = model_id.trim(); - if trimmed.is_empty() || trimmed == "auto" || trimmed == "default" { + trimmed.is_empty() || trimmed == "auto" || trimmed == "default" + } + + fn context_window_for_model_selection( + ai_config: &crate::service::config::types::AIConfig, + model_id: &str, + ) -> Option { + let trimmed = model_id.trim(); + if Self::is_auto_model_selector(trimmed) { return None; } - let config_service = get_global_config_service().await.ok()?; - let ai_config: crate::service::config::types::AIConfig = - config_service.get_config(Some("ai")).await.ok()?; let resolved_model_id = ai_config.resolve_model_selection(trimmed)?; - ai_config .models .iter() @@ -132,6 +143,43 @@ impl SessionManager { .map(|tokens| tokens as usize) } + fn session_context_window_from_ai_config( + session: &Session, + ai_config: &crate::service::config::types::AIConfig, + ) -> Option { + let configured_model_id = session + .config + .model_id + .as_deref() + .map(str::trim) + .filter(|model_id| !model_id.is_empty()) + .unwrap_or("auto"); + + if !Self::is_auto_model_selector(configured_model_id) { + return Self::context_window_for_model_selection(ai_config, configured_model_id); + } + + let agent_model_id = ai_config + .agent_models + .get(&session.agent_type) + .map(String::as_str) + .map(str::trim) + .filter(|model_id| !Self::is_auto_model_selector(model_id)); + + agent_model_id + .and_then(|model_id| Self::context_window_for_model_selection(ai_config, model_id)) + .or_else(|| Self::context_window_for_model_selection(ai_config, "primary")) + } + + fn sync_session_context_window_from_ai_config( + session: &mut Session, + ai_config: &crate::service::config::types::AIConfig, + ) -> Option { + let context_window = Self::session_context_window_from_ai_config(session, ai_config)?; + session.config.max_context_tokens = context_window; + Some(context_window) + } + fn normalize_session_title_input(title: &str) -> BitFunResult { let trimmed = title.trim(); if trimmed.is_empty() { @@ -1276,7 +1324,8 @@ impl SessionManager { session_id: &str, model_id: &str, ) -> BitFunResult<()> { - let resolved_context_window = Self::resolve_model_context_window(model_id).await; + let ai_config = Self::load_ai_config_for_model_resolution().await; + let mut resolved_context_window = None; // If the session was evicted from memory (idle > 1h), try to restore it // using the workspace path recorded when it was first created/restored. @@ -1296,8 +1345,9 @@ impl SessionManager { if let Some(mut session) = self.sessions.get_mut(session_id) { session.config.model_id = Some(model_id.to_string()); - if let Some(context_window) = resolved_context_window { - session.config.max_context_tokens = context_window; + if let Some(ai_config) = ai_config.as_ref() { + resolved_context_window = + Self::sync_session_context_window_from_ai_config(&mut session, ai_config); } session.updated_at = SystemTime::now(); session.last_activity_at = SystemTime::now(); @@ -1697,6 +1747,9 @@ impl SessionManager { elapsed_ms_u64(session_started_at) ); + let ai_config_for_restore = Self::load_ai_config_for_model_resolution().await; + let mut should_persist_restored_session = false; + // Lazy migration: if the persisted model_id is no longer usable // (model deleted or disabled while the session was on disk), repoint // it to "auto" before the session re-enters memory. The next request @@ -1705,14 +1758,8 @@ impl SessionManager { let trimmed = persisted_model_id.trim(); let needs_migration = if trimmed.is_empty() { false - } else if let Ok(config_service) = get_global_config_service().await { - match config_service - .get_config::(Some("ai")) - .await - { - Ok(ai_config) => !Self::is_session_model_id_usable(&ai_config, trimmed), - Err(_) => false, - } + } else if let Some(ai_config) = ai_config_for_restore.as_ref() { + !Self::is_session_model_id_usable(ai_config, trimmed) } else { false }; @@ -1724,6 +1771,7 @@ impl SessionManager { ); let previous_model_id = trimmed.to_string(); session.config.model_id = Some("auto".to_string()); + should_persist_restored_session = true; if let Some(coordinator) = crate::agentic::coordination::get_global_coordinator() { coordinator @@ -1738,6 +1786,21 @@ impl SessionManager { } } + if let Some(ai_config) = ai_config_for_restore.as_ref() { + let previous_max_context_tokens = session.config.max_context_tokens; + if let Some(context_window) = + Self::sync_session_context_window_from_ai_config(&mut session, ai_config) + { + if context_window != previous_max_context_tokens { + should_persist_restored_session = true; + debug!( + "Session context window refreshed during restore: session_id={}, previous={}, resolved={}", + session_id, previous_max_context_tokens, context_window + ); + } + } + } + // Reset session state to Idle // After application restart, previous Processing state is invalid and must be reset let previous_state_was_not_idle = !matches!(session.state, SessionState::Idle); @@ -1880,6 +1943,12 @@ impl SessionManager { // as completed would surface misleading unread indicators after an upgrade. // Unread completion is now written only by runtime completion/persist paths. + if should_persist_restored_session && self.should_persist_session_id(session_id) { + self.persistence_manager + .save_session(&session_storage_path, &session) + .await?; + } + // 4. Add to memory (will overwrite if already exists) self.sessions .insert(session_id.to_string(), session.clone()); @@ -3401,6 +3470,9 @@ mod tests { use crate::agentic::persistence::PersistenceManager; use crate::agentic::session::SessionContextStore; use crate::infrastructure::PathManager; + use crate::service::config::types::{ + AIConfig as ServiceAIConfig, AIModelConfig as ServiceAIModelConfig, + }; use crate::service::remote_ssh::workspace_state::local_workspace_roots_equal; use crate::service::session::{ DialogTurnData, ModelRoundData, SessionKind, SessionMetadata, SessionRelationship, @@ -3473,6 +3545,79 @@ mod tests { ) } + fn test_model(id: &str, context_window: u32) -> ServiceAIModelConfig { + ServiceAIModelConfig { + id: id.to_string(), + name: id.to_string(), + model_name: id.to_string(), + enabled: true, + context_window: Some(context_window), + ..Default::default() + } + } + + #[test] + fn sync_session_context_window_refreshes_stale_explicit_model_window() { + let mut ai_config = ServiceAIConfig::default(); + ai_config.models = vec![test_model("deepseek-v4-pro", 1_000_000)]; + + let mut session = Session::new_with_id( + "session-804".to_string(), + "DeepSeek session".to_string(), + "agentic".to_string(), + SessionConfig { + model_id: Some("deepseek-v4-pro".to_string()), + max_context_tokens: 256_000, + ..Default::default() + }, + ); + + let resolved = + SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + + assert_eq!(resolved, Some(1_000_000)); + assert_eq!(session.config.max_context_tokens, 1_000_000); + } + + #[test] + fn sync_session_context_window_resolves_auto_through_agent_model_then_primary() { + let mut ai_config = ServiceAIConfig::default(); + ai_config.models = vec![ + test_model("primary-model", 512_000), + test_model("agent-model", 1_000_000), + ]; + ai_config.default_models.primary = Some("primary-model".to_string()); + ai_config + .agent_models + .insert("agentic".to_string(), "agent-model".to_string()); + + let mut session = Session::new_with_id( + "session-auto".to_string(), + "Auto session".to_string(), + "agentic".to_string(), + SessionConfig { + model_id: Some("auto".to_string()), + max_context_tokens: 256_000, + ..Default::default() + }, + ); + + let resolved = + SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + + assert_eq!(resolved, Some(1_000_000)); + assert_eq!(session.config.max_context_tokens, 1_000_000); + + ai_config.agent_models.clear(); + session.config.max_context_tokens = 256_000; + + let resolved = + SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + + assert_eq!(resolved, Some(512_000)); + assert_eq!(session.config.max_context_tokens, 512_000); + } + #[tokio::test] async fn auto_save_interval_waits_before_first_tick() { let mut ticker = SessionManager::auto_save_interval(Duration::from_millis(40)); From 68e4962c2e5f45125a87a03d5ae6b703e763f0fd Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 21 May 2026 20:27:06 +0800 Subject: [PATCH 14/38] fix: prevent select dropdown clipping --- .../components/Select/Select.test.tsx | 169 ++++++++++++++++++ .../components/Select/Select.tsx | 54 +++++- 2 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 src/web-ui/src/component-library/components/Select/Select.test.tsx diff --git a/src/web-ui/src/component-library/components/Select/Select.test.tsx b/src/web-ui/src/component-library/components/Select/Select.test.tsx new file mode 100644 index 0000000000..efc0566df9 --- /dev/null +++ b/src/web-ui/src/component-library/components/Select/Select.test.tsx @@ -0,0 +1,169 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { Select } from './Select'; + +vi.mock('@/infrastructure/i18n', () => ({ + useI18n: () => ({ + t: (key: string, options?: Record & { defaultValue?: string }) => ( + options?.defaultValue ?? key + ), + }), +})); + +describe('Select', () => { + let container: HTMLDivElement; + let root: Root; + let getBoundingClientRectSpy: ReturnType; + let offsetHeightSpy: ReturnType; + let innerHeight = 800; + + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: innerHeight, + }); + + getBoundingClientRectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this instanceof HTMLElement && this.classList.contains('select')) { + return { + top: 700, + bottom: 740, + left: 0, + right: 240, + width: 240, + height: 40, + x: 0, + y: 700, + toJSON() { + return this; + }, + } as DOMRect; + } + return { + top: 0, + bottom: 0, + left: 0, + right: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON() { + return this; + }, + } as DOMRect; + }); + + offsetHeightSpy = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () { + if (this instanceof HTMLElement && this.classList.contains('select__dropdown')) { + return 220; + } + return 0; + }); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + getBoundingClientRectSpy.mockRestore(); + offsetHeightSpy.mockRestore(); + vi.restoreAllMocks(); + }); + + it('flips the dropdown upward when there is not enough room below', async () => { + await act(async () => { + root.render( + + ); + }); + + const trigger = container.querySelector('.select__trigger') as HTMLElement; + + await act(async () => { + trigger.click(); + await Promise.resolve(); + }); + + const selectRoot = container.querySelector('.select'); + const dropdown = container.querySelector('.select__dropdown'); + + expect(selectRoot?.className).toContain('select--placement-bottom'); + expect(dropdown?.className).toContain('select__dropdown--bottom'); + }); +}); diff --git a/src/web-ui/src/component-library/components/Select/Select.tsx b/src/web-ui/src/component-library/components/Select/Select.tsx index 39c4af2d18..03a77cc0df 100644 --- a/src/web-ui/src/component-library/components/Select/Select.tsx +++ b/src/web-ui/src/component-library/components/Select/Select.tsx @@ -2,7 +2,14 @@ * Select dropdown component */ -import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; +import React, { + useState, + useRef, + useEffect, + useLayoutEffect, + useMemo, + useCallback, +} from 'react'; import { useI18n } from '@/infrastructure/i18n'; import './Select.scss'; @@ -80,6 +87,7 @@ export const Select: React.FC = ({ const resolvedEmptyText = emptyText ?? t('select.emptyText'); const resolvedCustomValueHint = customValueHint ?? t('select.customValueHint'); const [isOpen, setIsOpen] = useState(false); + const [resolvedPlacement, setResolvedPlacement] = useState<'bottom' | 'top'>(placement); const [selectedValue, setSelectedValue] = useState( value !== undefined ? value : defaultValue !== undefined ? defaultValue : multiple ? [] : '' ); @@ -107,6 +115,44 @@ export const Select: React.FC = ({ ); }, [options, searchQuery, searchable]); + useLayoutEffect(() => { + if (!isOpen) { + setResolvedPlacement(placement); + return; + } + + const selectElement = selectRef.current; + const dropdownElement = dropdownRef.current; + if (!selectElement || !dropdownElement || typeof window === 'undefined') { + setResolvedPlacement(placement); + return; + } + + const triggerRect = selectElement.getBoundingClientRect(); + const dropdownHeight = dropdownElement.offsetHeight || dropdownElement.scrollHeight || 240; + const spaceBelow = window.innerHeight - triggerRect.bottom; + const spaceAbove = triggerRect.top; + + let nextPlacement: 'bottom' | 'top' = placement; + if (placement === 'bottom' && spaceBelow < dropdownHeight && spaceAbove > spaceBelow) { + nextPlacement = 'top'; + } else if (placement === 'top' && spaceAbove < dropdownHeight && spaceBelow > spaceAbove) { + nextPlacement = 'bottom'; + } + + setResolvedPlacement(nextPlacement); + }, [ + isOpen, + placement, + options.length, + searchable, + multiple, + showSelectAll, + allowCustomValue, + searchQuery, + filteredOptions.length, + ]); + const groupedOptions = useMemo(() => { const groups: { [key: string]: SelectOption[] } = {}; const ungrouped: SelectOption[] = []; @@ -322,7 +368,7 @@ export const Select: React.FC = ({ const classNames = [ 'select', `select--${size}`, - `select--placement-${placement}`, + `select--placement-${resolvedPlacement}`, isOpen && 'select--open', disabled && 'select--disabled', error && 'select--error', @@ -463,7 +509,7 @@ export const Select: React.FC = ({
{isOpen && ( -
+
{searchable && (
= ({ )}
); -}; \ No newline at end of file +}; From 863393d2b7e3b65a649c2966bfd6b214ba647111 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 21 May 2026 21:31:23 +0800 Subject: [PATCH 15/38] feat(acp): display session context usage --- src/apps/desktop/src/api/acp_client_api.rs | 18 +++++ src/crates/acp/src/client/manager.rs | 74 ++++++++++++++---- src/crates/acp/src/client/mod.rs | 2 +- src/crates/acp/src/client/session_options.rs | 77 +++++++++++++++---- src/crates/acp/src/client/stream.rs | 38 +++++++++ .../src/flow_chat/components/ChatInput.tsx | 30 +++++--- .../flow_chat/components/ModelSelector.tsx | 25 +++++- .../modern/UserMessageItem.test.tsx | 13 +++- .../services/AgenticEventListener.ts | 10 +++ .../flow-chat-manager/EventHandlerModule.ts | 26 +++++++ .../src/flow_chat/store/FlowChatStore.test.ts | 30 ++++++++ .../src/flow_chat/store/FlowChatStore.ts | 44 +++++++++++ src/web-ui/src/flow_chat/types/flow-chat.ts | 11 +++ .../api/service-api/ACPClientAPI.ts | 10 +++ .../api/service-api/AgentAPI.ts | 19 +++++ 15 files changed, 386 insertions(+), 41 deletions(-) diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs index d16b2cf2b0..4681848bd3 100644 --- a/src/apps/desktop/src/api/acp_client_api.rs +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -359,6 +359,24 @@ pub async fn start_acp_dialog_turn( bitfun_core::util::errors::BitFunError::service(e.to_string()) })?; } + AcpClientStreamEvent::ContextUsageUpdated(usage) => { + app_handle + .emit( + "agentic://acp-context-usage-updated", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "clientId": request.client_id, + "used": usage.used, + "size": usage.size, + "cost": usage.cost, + "subagentParentInfo": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } AcpClientStreamEvent::Completed => { app_handle .emit( diff --git a/src/crates/acp/src/client/manager.rs b/src/crates/acp/src/client/manager.rs index f93d74e7ee..d22b5d1545 100644 --- a/src/crates/acp/src/client/manager.rs +++ b/src/crates/acp/src/client/manager.rs @@ -46,7 +46,9 @@ use super::requirements::{ install_remote_npm_cli_package, predownload_npm_adapter, probe_executable, probe_npm_adapter, probe_remote_executable, probe_remote_npx_adapter, resolve_configured_command, }; -use super::session_options::{model_config_id, session_options_from_state, AcpSessionOptions}; +use super::session_options::{ + model_config_id, session_options_from_state, AcpSessionContextUsage, AcpSessionOptions, +}; use super::session_persistence::AcpSessionPersistence; pub use super::session_persistence::CreateAcpFlowSessionRecordResponse; use super::stream::{ @@ -127,6 +129,7 @@ struct AcpRemoteSession { active: Option>, models: Option, config_options: Vec, + context_usage: Option, discard_pending_updates_before_next_prompt: bool, } @@ -154,6 +157,7 @@ impl AcpRemoteSession { active: None, models: None, config_options: Vec::new(), + context_usage: None, discard_pending_updates_before_next_prompt: false, } } @@ -859,6 +863,7 @@ impl AcpClientService { Ok(session_options_from_state( session.models.as_ref(), &session.config_options, + session.context_usage.as_ref(), )) } @@ -920,6 +925,7 @@ impl AcpClientService { return Ok(session_options_from_state( session.models.as_ref(), &session.config_options, + session.context_usage.as_ref(), )); } Err(error) => { @@ -947,6 +953,7 @@ impl AcpClientService { return Ok(session_options_from_state( session.models.as_ref(), &session.config_options, + session.context_usage.as_ref(), )); } @@ -1045,23 +1052,34 @@ impl AcpClientService { .await?; discard_pending_session_updates_if_needed(&mut session).await; - let active = session - .active - .as_mut() - .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; - active.send_prompt(prompt).map_err(protocol_error)?; + { + let active = session + .active + .as_mut() + .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; + active.send_prompt(prompt).map_err(protocol_error)?; + } let mut round_tracker = AcpStreamRoundTracker::new(); let mut tool_call_tracker = AcpToolCallTracker::new(); loop { - match active.read_update().await.map_err(protocol_error)? { + let message = { + let active = session + .active + .as_mut() + .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; + active.read_update().await.map_err(protocol_error)? + }; + + match message { SessionMessage::SessionMessage(dispatch) => { - for event in acp_dispatch_to_stream_events_with_tracker( + let events = acp_dispatch_to_stream_events_with_tracker( dispatch, &mut tool_call_tracker, ) - .await? - { + .await?; + update_session_context_usage(&mut session, &events); + for event in events { for event in round_tracker.apply(event) { on_event(event)?; } @@ -1991,14 +2009,29 @@ async fn discard_pending_session_updates_if_needed(session: &mut AcpRemoteSessio } session.discard_pending_updates_before_next_prompt = false; - let Some(active) = session.active.as_mut() else { - return; - }; - let started_at = Instant::now(); let mut discarded_count = 0usize; while started_at.elapsed() < LOAD_REPLAY_DRAIN_MAX_DURATION { - match tokio::time::timeout(LOAD_REPLAY_DRAIN_QUIET_WINDOW, active.read_update()).await { + let update = { + let Some(active) = session.active.as_mut() else { + return; + }; + tokio::time::timeout(LOAD_REPLAY_DRAIN_QUIET_WINDOW, active.read_update()).await + }; + + match update { + Ok(Ok(SessionMessage::SessionMessage(dispatch))) => { + let mut tracker = AcpToolCallTracker::new(); + if let Ok(events) = + acp_dispatch_to_stream_events_with_tracker(dispatch, &mut tracker).await + { + update_session_context_usage(session, &events); + } + discarded_count += 1; + } + Ok(Ok(SessionMessage::StopReason(_))) => { + discarded_count += 1; + } Ok(Ok(_)) => { discarded_count += 1; } @@ -2021,6 +2054,17 @@ async fn discard_pending_session_updates_if_needed(session: &mut AcpRemoteSessio } } +fn update_session_context_usage(session: &mut AcpRemoteSession, events: &[AcpClientStreamEvent]) { + let Some(usage) = events.iter().rev().find_map(|event| match event { + AcpClientStreamEvent::ContextUsageUpdated(usage) => Some(usage.clone()), + _ => None, + }) else { + return; + }; + + session.context_usage = Some(usage); +} + fn protocol_error(error: impl std::fmt::Display) -> BitFunError { BitFunError::service(format!("ACP protocol error: {}", error)) } diff --git a/src/crates/acp/src/client/mod.rs b/src/crates/acp/src/client/mod.rs index 58959c3e9d..3b26d9a12b 100644 --- a/src/crates/acp/src/client/mod.rs +++ b/src/crates/acp/src/client/mod.rs @@ -20,5 +20,5 @@ pub use manager::{ AcpClientPermissionResponse, AcpClientService, CreateAcpFlowSessionRecordResponse, SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest, }; -pub use session_options::{AcpSessionModelOption, AcpSessionOptions}; +pub use session_options::{AcpSessionContextUsage, AcpSessionModelOption, AcpSessionOptions}; pub use stream::AcpClientStreamEvent; diff --git a/src/crates/acp/src/client/session_options.rs b/src/crates/acp/src/client/session_options.rs index 3f6ea88b57..c6fc11638b 100644 --- a/src/crates/acp/src/client/session_options.rs +++ b/src/crates/acp/src/client/session_options.rs @@ -1,9 +1,28 @@ use agent_client_protocol::schema::{ - ModelInfo, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, + Cost, ModelInfo, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOptions, SessionModelState, }; use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpSessionContextUsage { + pub used: u64, + pub size: u64, + #[serde(default)] + pub cost: Option, +} + +impl From for AcpSessionContextUsage { + fn from(update: agent_client_protocol::schema::UsageUpdate) -> Self { + Self { + used: update.used, + size: update.size, + cost: update.cost, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct AcpSessionOptions { @@ -13,6 +32,8 @@ pub struct AcpSessionOptions { pub available_models: Vec, #[serde(default)] pub model_config_id: Option, + #[serde(default)] + pub context_usage: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -27,7 +48,9 @@ pub struct AcpSessionModelOption { pub(super) fn session_options_from_state( models: Option<&SessionModelState>, config_options: &[SessionConfigOption], + context_usage: Option<&AcpSessionContextUsage>, ) -> AcpSessionOptions { + let context_usage = context_usage.cloned(); if let Some(models) = models.filter(|models| !models.available_models.is_empty()) { return AcpSessionOptions { current_model_id: Some(models.current_model_id.to_string()), @@ -37,19 +60,24 @@ pub(super) fn session_options_from_state( .map(model_option_from_model_info) .collect(), model_config_id: None, + context_usage, }; } - model_config_option(config_options) - .map(|option| { - let (current_model_id, available_models) = select_model_values(option); - AcpSessionOptions { - current_model_id, - available_models, - model_config_id: Some(option.id.to_string()), - } - }) - .unwrap_or_default() + if let Some(option) = model_config_option(config_options) { + let (current_model_id, available_models) = select_model_values(option); + return AcpSessionOptions { + current_model_id, + available_models, + model_config_id: Some(option.id.to_string()), + context_usage, + }; + } + + AcpSessionOptions { + context_usage, + ..Default::default() + } } pub(super) fn model_config_id(config_options: &[SessionConfigOption]) -> Option { @@ -119,7 +147,7 @@ mod tests { fn converts_native_model_state() { let state = SessionModelState::new("gpt-5.4", vec![ModelInfo::new("gpt-5.4", "GPT 5.4")]); - let options = session_options_from_state(Some(&state), &[]); + let options = session_options_from_state(Some(&state), &[], None); assert_eq!(options.current_model_id.as_deref(), Some("gpt-5.4")); assert_eq!(options.available_models.len(), 1); @@ -140,11 +168,34 @@ mod tests { ) .category(SessionConfigOptionCategory::Model); - let options = session_options_from_state(None, &[config]); + let options = session_options_from_state(None, &[config], None); assert_eq!(options.current_model_id.as_deref(), Some("fast")); assert_eq!(options.model_config_id.as_deref(), Some("model")); assert_eq!(options.available_models.len(), 2); assert_eq!(options.available_models[1].id, "smart"); } + + #[test] + fn includes_context_usage() { + let state = SessionModelState::new("gpt-5.4", vec![ModelInfo::new("gpt-5.4", "GPT 5.4")]); + let usage = AcpSessionContextUsage { + used: 42_000, + size: 128_000, + cost: Some(agent_client_protocol::schema::Cost::new(0.12, "USD")), + }; + + let options = session_options_from_state(Some(&state), &[], Some(&usage)); + + let context_usage = options.context_usage.expect("context usage"); + assert_eq!(context_usage.used, 42_000); + assert_eq!(context_usage.size, 128_000); + assert_eq!( + context_usage + .cost + .as_ref() + .map(|cost| cost.currency.as_str()), + Some("USD") + ); + } } diff --git a/src/crates/acp/src/client/stream.rs b/src/crates/acp/src/client/stream.rs index 024fcfb204..8a8b2c8eaa 100644 --- a/src/crates/acp/src/client/stream.rs +++ b/src/crates/acp/src/client/stream.rs @@ -8,6 +8,7 @@ use agent_client_protocol::util::MatchDispatch; use bitfun_core::util::errors::{BitFunError, BitFunResult}; use bitfun_events::ToolEventData; +use super::session_options::AcpSessionContextUsage; use super::tool_card_bridge::{acp_tool_name, normalize_tool_params}; #[derive(Debug, Clone)] @@ -20,6 +21,7 @@ pub enum AcpClientStreamEvent { AgentText(String), AgentThought(String), ToolEvent(ToolEventData), + ContextUsageUpdated(AcpSessionContextUsage), Completed, Cancelled, } @@ -77,6 +79,7 @@ impl AcpStreamRoundTracker { events } AcpClientStreamEvent::ModelRoundStarted { .. } + | AcpClientStreamEvent::ContextUsageUpdated(_) | AcpClientStreamEvent::Completed | AcpClientStreamEvent::Cancelled => vec![event], } @@ -121,6 +124,11 @@ pub(super) async fn acp_dispatch_to_stream_events_with_tracker( SessionUpdate::ToolCallUpdate(tool_call_update) => { events.extend(acp_tool_call_update_events(tool_call_update, tracker)); } + SessionUpdate::UsageUpdate(usage_update) => { + events.push(AcpClientStreamEvent::ContextUsageUpdated( + AcpSessionContextUsage::from(usage_update), + )); + } _ => {} } Ok(()) @@ -423,12 +431,42 @@ mod tests { AcpClientStreamEvent::AgentText(_) => "text", AcpClientStreamEvent::AgentThought(_) => "thought", AcpClientStreamEvent::ToolEvent(_) => "tool", + AcpClientStreamEvent::ContextUsageUpdated(_) => "usage", AcpClientStreamEvent::Completed => "completed", AcpClientStreamEvent::Cancelled => "cancelled", }) .collect() } + #[test] + fn exposes_context_usage_updates() { + use agent_client_protocol::JsonRpcMessage; + + let mut tracker = AcpToolCallTracker::new(); + let notification = SessionNotification::new( + "session-1", + SessionUpdate::UsageUpdate(agent_client_protocol::schema::UsageUpdate::new( + 1_000, 4_000, + )), + ) + .to_untyped_message() + .expect("notification"); + let dispatch = agent_client_protocol::Dispatch::Notification(notification); + + let events = tokio::runtime::Runtime::new() + .expect("runtime") + .block_on(acp_dispatch_to_stream_events_with_tracker( + dispatch, + &mut tracker, + )) + .expect("dispatch"); + + assert!(matches!( + events.as_slice(), + [AcpClientStreamEvent::ContextUsageUpdated(usage)] if usage.used == 1_000 && usage.size == 4_000 + )); + } + #[test] fn starts_new_round_for_text_after_tool() { let mut tracker = AcpStreamRoundTracker::new(); diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index a03082bb66..e6039dfd73 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -20,7 +20,7 @@ import { import { SessionExecutionEvent } from '../state-machine/types'; import { ModelSelector } from './ModelSelector'; import { FlowChatStore } from '../store/FlowChatStore'; -import type { FlowChatState } from '../types/flow-chat'; +import type { FlowChatState, Session } from '../types/flow-chat'; import type { FileContext, DirectoryContext, ImageContext } from '@/types/context.ts'; import { SmartRecommendations } from './smart-recommendations'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; @@ -200,6 +200,24 @@ function renderMcpPromptMessages(messages: MCPPromptMessage[]): string { .join('\n\n'); } +function getSessionContextUsageDisplay(session?: Session): { current: number; max: number } { + if (!session) { + return { current: 0, max: 128128 }; + } + + if (session.currentAcpContextUsage) { + return { + current: session.currentAcpContextUsage.used, + max: session.currentAcpContextUsage.size, + }; + } + + return { + current: session.currentTokenUsage?.totalTokens || 0, + max: session.maxContextTokens || 128128, + }; +} + export const ChatInput: React.FC = ({ className = '', onSendMessage @@ -593,10 +611,7 @@ export const ChatInput: React.FC = ({ if (effectiveTargetSessionId) { const session = state.sessions.get(effectiveTargetSessionId); if (session) { - setTokenUsage({ - current: session.currentTokenUsage?.totalTokens || 0, - max: session.maxContextTokens || 128128 - }); + setTokenUsage(getSessionContextUsageDisplay(session)); } } }); @@ -605,10 +620,7 @@ export const ChatInput: React.FC = ({ const state = store.getState(); const session = state.sessions.get(effectiveTargetSessionId); if (session) { - setTokenUsage({ - current: session.currentTokenUsage?.totalTokens || 0, - max: session.maxContextTokens || 128128 - }); + setTokenUsage(getSessionContextUsageDisplay(session)); } } diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index b4411f07c0..238461bcfa 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -117,6 +117,17 @@ const buildAutoModelInfo = ( provider: 'auto', }); +const syncAcpContextUsageToStore = ( + sessionId: string | undefined, + options: AcpSessionOptions, +): void => { + if (!sessionId || !options.contextUsage) { + return; + } + + FlowChatStore.getInstance().updateAcpContextUsage(sessionId, options.contextUsage); +}; + export const ModelSelector: React.FC = ({ currentMode, className = '', @@ -208,6 +219,7 @@ export const ModelSelector: React.FC = ({ remoteSshHost: activeSession?.remoteSshHost, }); setAcpOptions(options); + syncAcpContextUsageToStore(sessionId, options); } catch (error) { log.warn('Failed to load ACP session model options', { sessionId, acpClientId, error }); setAcpOptions(null); @@ -368,6 +380,7 @@ export const ModelSelector: React.FC = ({ modelId, }); setAcpOptions(options); + syncAcpContextUsageToStore(sessionId, options); FlowChatStore.getInstance().updateSessionModelName(sessionId, modelId); log.info('ACP session model updated', { sessionId, acpClientId, modelId }); setDropdownOpen(false); @@ -440,7 +453,12 @@ export const ModelSelector: React.FC = ({ } const currentAcpModelId = acpOptions?.currentModelId || acpAvailableModels[0]?.id || ''; - const acpTooltip = getModelTooltipText(acpCurrentModel, acpClientId ? `${acpClientId} ACP` : 'ACP'); + const acpBaseTooltip = getModelTooltipText(acpCurrentModel, acpClientId ? `${acpClientId} ACP` : 'ACP'); + const acpUsageTooltip = + currentTokens > 0 && maxTokens > 0 + ? `${formatTokenCount(currentTokens)}/${formatTokenCount(maxTokens)} (${tokenPercentage}%)` + : ''; + const acpTooltip = acpUsageTooltip ? `${acpBaseTooltip} · ${acpUsageTooltip}` : acpBaseTooltip; return (
= ({ {getModelDisplayLabel(acpCurrentModel, currentAcpModelId)} + {tokenPercentage > 0 && ( + + · {tokenPercentage}% + + )} diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx index 04ccd73e07..59aa4568e0 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx @@ -34,10 +34,19 @@ vi.mock('../../store/modernFlowChatStore', () => ({ useActiveSession: () => activeSessionRef.current, })); +const flowChatStoreMock = vi.hoisted(() => ({ + getState: vi.fn(() => ({ + sessions: new Map(), + activeSessionId: null, + })), + truncateDialogTurnsFrom: vi.fn(), +})); + vi.mock('../../store/FlowChatStore', () => ({ - flowChatStore: { - truncateDialogTurnsFrom: vi.fn(), + FlowChatStore: { + getInstance: () => flowChatStoreMock, }, + flowChatStore: flowChatStoreMock, })); vi.mock('@/infrastructure/api', () => ({ diff --git a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts index 50f2cf0a10..531caad3e4 100644 --- a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts +++ b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts @@ -19,6 +19,7 @@ import type { ModelRoundCompletedEvent, UserSteeringInjectedEvent, DeepReviewQueueStateChangedEvent, + AcpContextUsageUpdatedEvent, } from '@/infrastructure/api/service-api/AgentAPI'; import { createLogger } from '@/shared/utils/logger'; @@ -43,6 +44,7 @@ export interface AgenticEventCallbacks { onDialogTurnFailed?: (event: AgenticEvent) => void; onDialogTurnCancelled?: (event: AgenticEvent) => void; onTokenUsageUpdated?: (event: AgenticEvent) => void; + onAcpContextUsageUpdated?: (event: AcpContextUsageUpdatedEvent) => void; onContextCompressionStarted?: (event: AgenticEvent) => void; onContextCompressionCompleted?: (event: AgenticEvent) => void; onContextCompressionFailed?: (event: AgenticEvent) => void; @@ -190,6 +192,14 @@ export class AgenticEventListener { this.unlistenFunctions.push(unlisten); } + if (callbacks.onAcpContextUsageUpdated) { + const unlisten = agentAPI.onAcpContextUsageUpdated((event) => { + logger.debug('ACP context usage updated:', event); + callbacks.onAcpContextUsageUpdated?.(event); + }); + this.unlistenFunctions.push(unlisten); + } + if (callbacks.onContextCompressionStarted) { const unlisten = agentAPI.onContextCompressionStarted((event) => { logger.debug('Context compression started:', event); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index f8c358cbc3..23cc092a91 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -25,6 +25,7 @@ import type { DeepReviewQueueStateChangedEvent, ImageAnalysisEvent, ModelRoundCompletedEvent, + AcpContextUsageUpdatedEvent, SessionModelAutoMigratedEvent, SubagentSessionLinkedEvent, } from '@/infrastructure/api/service-api/AgentAPI'; @@ -625,6 +626,9 @@ export async function initializeEventListeners( onTokenUsageUpdated: (event) => { handleTokenUsageUpdate(event); }, + onAcpContextUsageUpdated: (event) => { + handleAcpContextUsageUpdate(event); + }, onContextCompressionStarted: (event) => { handleCompressionStarted(context, event); }, @@ -1765,6 +1769,28 @@ function handleTokenUsageUpdate(event: any): void { } } +function handleAcpContextUsageUpdate(event: AcpContextUsageUpdatedEvent): void { + const { sessionId, used, size, cost } = event; + + if (!sessionId || typeof used !== 'number' || typeof size !== 'number') { + log.debug('Dropped invalid ACP context usage update', { event }); + return; + } + + const store = FlowChatStore.getInstance(); + const session = store.getState().sessions.get(sessionId); + + if (!session) { + log.debug('Session not found (ACP context usage update)', { sessionId }); + return; + } + + store.updateAcpContextUsage( + sessionId, + cost ? { used, size, cost } : { used, size }, + ); +} + /** * Handle context compression started event */ diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 305fcf3d89..eef31fb487 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -238,6 +238,36 @@ describe('FlowChatStore local usage reports', () => { }); }); +describe('FlowChatStore ACP context usage', () => { + afterEach(() => { + resetStore(); + }); + + it('stores ACP context usage separately from token usage reports', () => { + const session = createSession({ + config: { agentType: 'acp:codex' }, + }); + flowChatStore.setState(() => ({ + sessions: new Map([[session.sessionId, session]]), + activeSessionId: session.sessionId, + })); + + flowChatStore.updateAcpContextUsage(session.sessionId, { + used: 42_000, + size: 128_000, + cost: { amount: 0.12, currency: 'USD' }, + }); + + const stored = flowChatStore.getState().sessions.get(session.sessionId); + expect(stored?.currentAcpContextUsage).toMatchObject({ + used: 42_000, + size: 128_000, + cost: { amount: 0.12, currency: 'USD' }, + }); + expect(stored?.currentTokenUsage).toBeUndefined(); + }); +}); + describe('FlowChatStore historical session hydration state', () => { afterEach(() => { resetStore(); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index cf1a3440b0..c07bb43ef3 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -13,6 +13,7 @@ import { FlowImageAnalysisItem, ImageAnalysisResult, AnyFlowItem, + AcpContextUsage, SessionConfig, SessionContextRestoreState, SessionHistoryState, @@ -1394,6 +1395,49 @@ export class FlowChatStore { }); } + public updateAcpContextUsage( + sessionId: string, + contextUsage: { used: number; size: number; cost?: { amount: number; currency: string } } + ): void { + this.setState(prev => { + const session = prev.sessions.get(sessionId); + if (!session) return prev; + + const nextUsage: AcpContextUsage = { + used: contextUsage.used, + size: contextUsage.size, + timestamp: Date.now(), + }; + if (contextUsage.cost) { + nextUsage.cost = contextUsage.cost; + } + + const currentUsage = session.currentAcpContextUsage; + if ( + currentUsage && + currentUsage.used === nextUsage.used && + currentUsage.size === nextUsage.size && + currentUsage.cost?.amount === nextUsage.cost?.amount && + currentUsage.cost?.currency === nextUsage.cost?.currency + ) { + return prev; + } + + const updatedSession = { + ...session, + currentAcpContextUsage: nextUsage, + }; + + const newSessions = new Map(prev.sessions); + newSessions.set(sessionId, updatedSession); + + return { + ...prev, + sessions: newSessions, + }; + }); + } + public rollbackTokenUsage(): void { } diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 0ea56a4283..f680c15162 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -178,6 +178,16 @@ export interface TokenUsage { timestamp: number; } +export interface AcpContextUsage { + used: number; + size: number; + cost?: { + amount: number; + currency: string; + }; + timestamp: number; +} + // Dialog turn: user input + full AI response across model rounds. export interface DialogTurn { id: string; @@ -297,6 +307,7 @@ export interface Session { todos?: TodoItem[]; currentTokenUsage?: TokenUsage; + currentAcpContextUsage?: AcpContextUsage; maxContextTokens?: number; // Session mode is synced to the input when switching sessions. diff --git a/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts index 98a30dbed1..502aa0ab3e 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts @@ -97,10 +97,20 @@ export interface AcpSessionModelOption { description?: string; } +export interface AcpContextUsage { + used: number; + size: number; + cost?: { + amount: number; + currency: string; + }; +} + export interface AcpSessionOptions { currentModelId?: string; availableModels: AcpSessionModelOption[]; modelConfigId?: string; + contextUsage?: AcpContextUsage; } export interface SubmitAcpPermissionResponseRequest { diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index 2728a6f4da..a1c24cae26 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -256,6 +256,16 @@ export interface ModelRoundCompletedEvent extends AgenticEvent { tokenDetails?: unknown; } +export interface AcpContextUsageUpdatedEvent extends AgenticEvent { + clientId?: string; + used: number; + size: number; + cost?: { + amount: number; + currency: string; + }; +} + export interface CompressionEvent extends AgenticEvent { compressionId: string; @@ -617,6 +627,15 @@ export class AgentAPI { return api.listen('agentic://token-usage-updated', callback); } + onAcpContextUsageUpdated( + callback: (event: AcpContextUsageUpdatedEvent) => void + ): () => void { + return api.listen( + 'agentic://acp-context-usage-updated', + callback + ); + } + onContextCompressionStarted(callback: (event: CompressionEvent) => void): () => void { return api.listen('agentic://context-compression-started', callback); From bfff0222b04ec9133b56862ab7190f128eb3153e Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Thu, 21 May 2026 20:19:13 +0800 Subject: [PATCH 16/38] feat(bash tool): auto-deliver background task results and bash completions - generalize background result delivery beyond subagents - auto-inject completed background bash command results via steering - persist full background bash output to runtime files instead of chat - clarify no-poll guidance for background subagents and commands --- .../multitask_mode_first_entry_reminder.md | 1 + .../src/agentic/coordination/coordinator.rs | 31 +- .../src/agentic/coordination/scheduler.rs | 24 +- .../src/agentic/execution/execution_engine.rs | 4 +- src/crates/core/src/agentic/round_preempt.rs | 4 +- .../tools/implementations/bash_tool.rs | 458 ++++++++++++++---- .../tools/implementations/task_tool.rs | 2 +- 7 files changed, 407 insertions(+), 117 deletions(-) diff --git a/src/crates/core/src/agentic/agents/prompts/multitask_mode_first_entry_reminder.md b/src/crates/core/src/agentic/agents/prompts/multitask_mode_first_entry_reminder.md index 5d8d21b248..f5f0b57201 100644 --- a/src/crates/core/src/agentic/agents/prompts/multitask_mode_first_entry_reminder.md +++ b/src/crates/core/src/agentic/agents/prompts/multitask_mode_first_entry_reminder.md @@ -5,6 +5,7 @@ Treat the task as a parallel work orchestration problem whenever it is beneficia # Subagent Delegation Guide - Prefer background subagents whenever the branch is independent and does not block your immediate next step. Use `run_in_background: true` on the Task call to launch it in the background. +- Background subagent results are delivered back to you automatically when they finish. Do not poll or repeatedly check on background work just for status updates. If your current path is blocked on a background result and there is no other productive local work to do, it is fine to end the current turn instead of waiting idly. - Keep yourself on the critical path. Handle decomposition, dependency management, interface alignment, integration, and final verification yourself. - Use `FileFinder` when the subtask is primarily about locating relevant files, entry points, symbols, or ownership boundaries. - Use `Explore` when the subtask is read-only investigation, codebase understanding, or evidence gathering that should not modify files. diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index d1968da19b..949487fb18 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -26,8 +26,8 @@ use crate::agentic::WorkspaceBinding; use crate::service::bootstrap::{ ensure_workspace_persona_files_for_prompt, is_workspace_bootstrap_pending, }; -use crate::service::session::{SessionRelationship, SessionRelationshipKind}; use crate::service::config::global::GlobalConfigManager; +use crate::service::session::{SessionRelationship, SessionRelationshipKind}; use crate::service::workspace::{ get_global_workspace_service, WorkspaceCreateOptions, WorkspaceKind, }; @@ -1623,8 +1623,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet }; let effective_agent_type = Self::normalize_agent_type(&provisional_agent_type); - Self::track_session_workspace_activity_best_effort(&session.config, "dialog_started") - .await; + Self::track_session_workspace_activity_best_effort(&session.config, "dialog_started").await; debug!( "Resolved dialog turn agent type: session_id={}, turn_id={}, requested_agent_type={}, session_agent_type={}, effective_agent_type={}, trigger_source={:?}, queue_priority={:?}, skip_tool_confirmation={}", @@ -2663,7 +2662,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.session_manager.list_sessions(workspace_path).await } - pub async fn resolve_session_workspace_path(&self, session_id: &str) -> Option { + pub async fn resolve_session_workspace_path( + &self, + session_id: &str, + ) -> Option { self.session_manager .resolve_session_workspace_path(session_id) .await @@ -3075,10 +3077,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.session_manager .persist_session_lineage( &session_id, - build_subagent_session_relationship( - subagent_parent_info.as_ref(), - &agent_type, - ), + build_subagent_session_relationship(subagent_parent_info.as_ref(), &agent_type), ) .await?; @@ -3861,9 +3860,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet context: context.unwrap_or_default(), runtime_tool_restrictions: ToolRuntimeRestrictions::default(), }; - let coordinator = get_global_coordinator().ok_or_else(|| { - BitFunError::service("Coordinator not initialized".to_string()) - })?; + let coordinator = get_global_coordinator() + .ok_or_else(|| BitFunError::service("Coordinator not initialized".to_string()))?; tokio::spawn(async move { let delivery_text = match coordinator @@ -3883,7 +3881,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet }; let metadata = serde_json::json!({ - "kind": "background_subagent_result", + "kind": "background_result", + "sourceKind": "subagent", "backgroundTaskId": background_task_id_for_delivery, "subagentType": agent_type, "taskDescription": task_description, @@ -3891,7 +3890,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet if let Some(scheduler) = super::scheduler::get_global_scheduler() { if let Err(error) = scheduler - .deliver_background_subagent_result( + .deliver_background_result( subagent_parent_info.session_id.clone(), parent_agent_type, parent_workspace_path, @@ -4479,10 +4478,8 @@ mod tests { assert!(completed_text.contains("\n")); assert!(!completed_text.contains("background_task_id=\"bg-subagent-123\"")); - let partial = super::SubagentResult::partial_timeout( - "partial".to_string(), - "timeout".to_string(), - ); + let partial = + super::SubagentResult::partial_timeout("partial".to_string(), "timeout".to_string()); let partial_text = super::format_background_subagent_delivery_text( "bg-subagent-456", "GeneralPurpose", diff --git a/src/crates/core/src/agentic/coordination/scheduler.rs b/src/crates/core/src/agentic/coordination/scheduler.rs index 724d19d736..657efce947 100644 --- a/src/crates/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/core/src/agentic/coordination/scheduler.rs @@ -15,9 +15,8 @@ use super::turn_outcome::{TurnOutcome, TurnOutcomeQueueAction, TurnOutcomeStatus use crate::agentic::core::{PromptEnvelope, SessionState}; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::round_preempt::{ - DialogRoundInjectionSource, DialogRoundPreemptSource, RoundInjection, - RoundInjectionKind, RoundInjectionTarget, SessionRoundInjectionBuffer, - SessionRoundYieldFlags, + DialogRoundInjectionSource, DialogRoundPreemptSource, RoundInjection, RoundInjectionKind, + RoundInjectionTarget, SessionRoundInjectionBuffer, SessionRoundYieldFlags, }; use crate::agentic::session::SessionManager; use dashmap::DashMap; @@ -291,12 +290,12 @@ impl DialogScheduler { }) } - /// Deliver a completed background subagent result back to the parent - /// session. If the session is currently processing, inject the result into - /// the running turn at the next model-round boundary. Otherwise, start a - /// new turn immediately so the result is handled without waiting for an + /// Deliver a completed background result back to the parent session. + /// If the session is currently processing, inject the result into the + /// running turn at the next model-round boundary. Otherwise, start a new + /// turn immediately so the result is handled without waiting for an /// unrelated future message. - pub async fn deliver_background_subagent_result( + pub async fn deliver_background_result( &self, session_id: String, agent_type: String, @@ -318,7 +317,7 @@ impl DialogScheduler { &session_id, RoundInjection { id: injection_id, - kind: RoundInjectionKind::BackgroundSubagentResult, + kind: RoundInjectionKind::BackgroundResult, target: RoundInjectionTarget::CurrentRunningTurn, content, display_content: display, @@ -327,8 +326,8 @@ impl DialogScheduler { ); Ok(()) } - _ => { - self.submit( + _ => self + .submit( session_id, content, Some(display), @@ -341,8 +340,7 @@ impl DialogScheduler { None, ) .await - .map(|_| ()) - } + .map(|_| ()), } } diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index 5c549d8e4d..d344d57068 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -2127,8 +2127,8 @@ impl ExecutionEngine { "\nThe user sent a new message while this turn was running. You have just finished the previous atomic action; handle this new user message now as the current direction, while preserving the existing conversation and task context. Do not ignore it or wait for a separate future turn.\n\nNew user message:\n{}\n", injection.content ), - RoundInjectionKind::BackgroundSubagentResult => format!( - "\nA background subagent has finished and returned new information while this turn was running. Incorporate it into your current work immediately when relevant. Do not wait for a separate future turn.\n\nBackground subagent result:\n{}\n", + RoundInjectionKind::BackgroundResult => format!( + "\nA background task has finished and returned new information while this turn was running. Incorporate it into your current work immediately when relevant. Do not wait for a separate future turn.\n\nBackground result:\n{}\n", injection.content ), }; diff --git a/src/crates/core/src/agentic/round_preempt.rs b/src/crates/core/src/agentic/round_preempt.rs index 936a5d9a6e..bb7bedfc78 100644 --- a/src/crates/core/src/agentic/round_preempt.rs +++ b/src/crates/core/src/agentic/round_preempt.rs @@ -70,7 +70,7 @@ impl DialogRoundPreemptSource for SessionRoundYieldFlags { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RoundInjectionKind { UserSteering, - BackgroundSubagentResult, + BackgroundResult, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -245,7 +245,7 @@ mod steering_tests { fn current_turn_msg(content: &str) -> RoundInjection { RoundInjection { id: uuid::Uuid::new_v4().to_string(), - kind: RoundInjectionKind::BackgroundSubagentResult, + kind: RoundInjectionKind::BackgroundResult, target: RoundInjectionTarget::CurrentRunningTurn, content: content.to_string(), display_content: content.to_string(), diff --git a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs index 5b9dcfc758..b94aec0063 100644 --- a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs @@ -1,3 +1,4 @@ +use crate::agentic::coordination::scheduler::get_global_scheduler; use crate::agentic::tools::framework::{ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; @@ -19,8 +20,8 @@ use std::time::{Duration, Instant}; use terminal_core::session::SessionSource; use terminal_core::shell::{ShellDetector, ShellType}; use terminal_core::{ - CommandCompletionReason, CommandStreamEvent, ExecuteCommandRequest, SendCommandRequest, - SignalRequest, TerminalApi, TerminalBindingOptions, TerminalSessionBinding, + CommandCompletionReason, CommandStreamEvent, ExecuteCommandRequest, SignalRequest, TerminalApi, + TerminalBindingOptions, TerminalSessionBinding, }; use tokio::io::AsyncWriteExt; use tool_runtime::util::ansi_cleaner::strip_ansi; @@ -354,6 +355,91 @@ impl BashTool { fn cancellation_error(stage: &str) -> BitFunError { BitFunError::cancelled(format!("Bash tool execution cancelled {}", stage)) } + + fn background_output_file_reference( + context: &ToolUseContext, + chat_session_id: &str, + tool_use_id: &str, + output_file_path: &Path, + ) -> String { + context + .build_session_runtime_artifact_reference( + chat_session_id, + &format!("tool-results/{}.txt", tool_use_id), + ) + .unwrap_or_else(|_| output_file_path.display().to_string()) + } + + fn format_background_command_delivery_text( + command: &str, + terminal_session_id: &str, + working_directory: &str, + exit_code: Option, + timed_out: bool, + interrupted: bool, + output_file_reference: &str, + output_persist_error: Option<&str>, + ) -> String { + let (status, summary) = if timed_out { + ("timeout", "Background Bash command timed out.") + } else if interrupted { + ("interrupted", "Background Bash command was interrupted.") + } else if exit_code == Some(0) { + ( + "completed", + "Background Bash command completed successfully.", + ) + } else { + ( + "failed", + "Background Bash command completed with a non-zero exit code.", + ) + }; + let exit_code_attr = exit_code + .map(|code| format!(" exit_code=\"{}\"", code)) + .unwrap_or_default(); + let persistence_line = output_persist_error.map_or_else( + || format!("Full output was saved to: {}", output_file_reference), + |error| { + format!( + "Output persistence encountered an error while writing {}: {}", + output_file_reference, error + ) + }, + ); + + format!( + "{summary}\n\nCommand: {command}\nWorking directory: {working_directory}\n{persistence_line}\n" + ) + } + + fn format_background_command_error_text( + command: &str, + terminal_session_id: &str, + working_directory: &str, + output_file_reference: &str, + error: &str, + output_persist_error: Option<&str>, + ) -> String { + let persistence_line = output_persist_error.map_or_else( + || { + format!( + "Any captured output was saved to: {}", + output_file_reference + ) + }, + |persist_error| { + format!( + "Output persistence encountered an error while writing {}: {}", + output_file_reference, persist_error + ) + }, + ); + + format!( + "Background Bash command failed before producing a final completion result.\n\nCommand: {command}\nWorking directory: {working_directory}\n{persistence_line}\nError: {error}\n" + ) + } } #[async_trait] @@ -394,7 +480,7 @@ Usage notes: - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes). - It is very helpful if you write a clear, concise description of what this command does. For simple commands, keep it brief (5-10 words). For complex commands (piped commands, obscure flags, or anything hard to understand at a glance), add enough context to clarify what it does. - If the output exceeds {MAX_OUTPUT_LENGTH} characters, output will be truncated before being returned to you, with the tail of the output preserved because the ending is usually more important. - - You can use the `run_in_background` parameter to run the command in a new dedicated background terminal session. The tool returns the background session ID immediately without waiting for the command to finish. Only use this for long-running processes (e.g., dev servers, watchers) where you don't need the output right away. You do not need to append '&' to the command. NOTE: `timeout_ms` is ignored when `run_in_background` is true. + - You can use the `run_in_background` parameter to run the command in a new dedicated background terminal session. The tool returns immediately without waiting for the command to finish. The final completion result will be delivered back to you automatically when it is done, and the full output will be saved to a session runtime file instead of being pasted back into chat. Only use this for long-running processes (e.g., dev servers, watchers) where you do not need the output right away. You do not need to append '&' to the command. NOTE: `timeout_ms` is ignored when `run_in_background` is true. - Each result includes a `` tag identifying the terminal session. The persistent shell session ID remains constant throughout the entire conversation; background sessions each have their own unique ID. - The output may include the command echo and/or the shell prompt (e.g., `PS C:\path>`). Do not treat these as part of the command's actual result. - Avoid interactive commands that may block waiting for user input or open a pager/editor. Prefer non-interactive variants and explicit flags. For example, use `git --no-pager diff` instead of `git diff`, and avoid commands that prompt for confirmation unless the User explicitly asks for them. @@ -454,7 +540,7 @@ Usage notes: }, "run_in_background": { "type": "boolean", - "description": "If true, runs the command in a new dedicated background terminal session and returns the session ID immediately without waiting for completion. Useful for long-running processes like dev servers or file watchers. timeout_ms is ignored when this is true." + "description": "If true, runs the command in a new dedicated background terminal session and returns immediately. The final completion result is delivered back automatically when the command finishes, and the full output is saved to a session runtime file instead of being injected into chat. Useful for long-running processes like dev servers or file watchers. timeout_ms is ignored when this is true." }, "working_directory": { "type": "string", @@ -842,7 +928,6 @@ Usage notes: &initial_cwd, context, shell_type, - &terminal_api, &binding, start_time, ) @@ -1147,7 +1232,6 @@ impl BashTool { initial_cwd: &str, context: &ToolUseContext, shell_type: Option, - terminal_api: &TerminalApi, binding: &TerminalSessionBinding, start_time: Instant, ) -> BitFunResult> { @@ -1190,10 +1274,9 @@ impl BashTool { .unwrap_or_else(|| format!("bash_{}", uuid::Uuid::new_v4())); Self::emit_terminal_ready_event(&tool_use_id, &bg_session_id); - // Subscribe to session output before sending the command so no data is missed - let mut output_rx = terminal_api.subscribe_session_output(&bg_session_id); - if Self::cancellation_requested(context) { + let terminal_api = TerminalApi::from_singleton() + .map_err(|e| BitFunError::tool(format!("Terminal not initialized: {}", e)))?; let _ = terminal_api .close_session(terminal_core::CloseSessionRequest { session_id: bg_session_id.clone(), @@ -1205,98 +1288,287 @@ impl BashTool { )); } - // Fire-and-forget: write the command to the PTY without waiting for completion - terminal_api - .send_command(SendCommandRequest { - session_id: bg_session_id.clone(), - command: command_str.to_string(), - }) + // Store background output under the session-scoped runtime tool-results tree: + // local: ~/.bitfun/projects//sessions//tool-results/.txt + // remote: ~/.bitfun/remote_ssh///sessions//tool-results/.txt + let output_file_path = + Self::background_output_file_path(context, chat_session_id, &tool_use_id).ok_or_else( + || { + BitFunError::tool( + "Failed to prepare a background output file for Bash tool".to_string(), + ) + }, + )?; + if let Some(parent) = output_file_path.parent() { + tokio::fs::create_dir_all(parent).await.map_err(|e| { + BitFunError::tool(format!( + "Failed to create background output directory: {}", + e + )) + })?; + } + let output_file = tokio::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&output_file_path) .await - .map_err(|e| BitFunError::tool(format!("Failed to send background command: {}", e)))?; + .map_err(|e| { + BitFunError::tool(format!("Failed to open background output file: {}", e)) + })?; + let output_file_reference = Self::background_output_file_reference( + context, + chat_session_id, + &tool_use_id, + &output_file_path, + ); debug!( "Background command started, session_id: {}, owner: {}", bg_session_id, chat_session_id ); - // Store background output under the session-scoped runtime tool-results tree: - // local: ~/.bitfun/projects//sessions//tool-results/.txt - // remote: ~/.bitfun/remote_ssh///sessions//tool-results/.txt - let output_file_path = - Self::background_output_file_path(context, chat_session_id, &tool_use_id); - - // Spawn task: write PTY output to file, delete when session ends - if let Some(file_path) = output_file_path.clone() { - let bg_id_for_log = bg_session_id.clone(); - tokio::spawn(async move { - if let Some(parent) = file_path.parent() { - if let Err(e) = tokio::fs::create_dir_all(parent).await { - error!( - "Failed to create tool-results output dir for bg session {}: {}", - bg_id_for_log, e - ); - return; - } + let parent_session_id = chat_session_id.to_string(); + let parent_agent_type = context + .agent_type + .clone() + .unwrap_or_else(|| "Agentic".to_string()); + let parent_workspace_path = context + .workspace_root() + .map(|path| path.to_string_lossy().to_string()); + let command = command_str.to_string(); + let working_directory = initial_cwd.to_string(); + let terminal_session_id = bg_session_id.clone(); + let output_file_reference_for_task = output_file_reference.clone(); + let tool_use_id_for_task = tool_use_id.clone(); + + tokio::spawn(async move { + let mut writer = tokio::io::BufWriter::new(output_file); + let mut output_persist_error: Option = None; + let mut saw_output_event = false; + let mut saw_completion = false; + let mut delivery_sent = false; + + let terminal_api = match TerminalApi::from_singleton() { + Ok(api) => api, + Err(error) => { + error!( + "Background Bash command could not access terminal singleton: session_id={}, error={}", + terminal_session_id, error + ); + return; } + }; - let file = match tokio::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(&file_path) - .await - { - Ok(f) => f, - Err(e) => { - error!( - "Failed to open output file for bg session {}: {}", - bg_id_for_log, e + let mut stream = terminal_api.execute_command_stream(ExecuteCommandRequest { + session_id: terminal_session_id.clone(), + command: command.clone(), + timeout_ms: None, + prevent_history: Some(true), + }); + + while let Some(event) = stream.next().await { + match event { + CommandStreamEvent::Started { command_id } => { + debug!( + "Background Bash command started execution, session_id={}, command_id={}", + terminal_session_id, command_id ); - return; } - }; - - let mut writer = tokio::io::BufWriter::new(file); - - while let Some(data) = output_rx.recv().await { - if let Err(e) = writer.write_all(data.as_bytes()).await { - error!( - "Failed to write output for bg session {}: {}", - bg_id_for_log, e + CommandStreamEvent::Output { data } => { + saw_output_event = true; + if output_persist_error.is_none() { + if let Err(error) = writer.write_all(data.as_bytes()).await { + output_persist_error = Some(error.to_string()); + error!( + "Failed to write background Bash output: session_id={}, error={}", + terminal_session_id, error + ); + } else if let Err(error) = writer.flush().await { + output_persist_error = Some(error.to_string()); + error!( + "Failed to flush background Bash output: session_id={}, error={}", + terminal_session_id, error + ); + } + } + } + CommandStreamEvent::Completed { + exit_code, + total_output, + completion_reason, + shell_state: _, + } => { + saw_completion = true; + + if !saw_output_event + && !total_output.is_empty() + && output_persist_error.is_none() + { + if let Err(error) = writer.write_all(total_output.as_bytes()).await { + output_persist_error = Some(error.to_string()); + error!( + "Failed to persist background Bash completion output: session_id={}, error={}", + terminal_session_id, error + ); + } else if let Err(error) = writer.flush().await { + output_persist_error = Some(error.to_string()); + error!( + "Failed to flush background Bash completion output: session_id={}, error={}", + terminal_session_id, error + ); + } + } + + let timed_out = completion_reason == CommandCompletionReason::TimedOut; + let interrupted = + !timed_out && matches!(exit_code, Some(130) | Some(-1073741510)); + let delivery_text = Self::format_background_command_delivery_text( + &command, + &terminal_session_id, + &working_directory, + exit_code, + timed_out, + interrupted, + &output_file_reference_for_task, + output_persist_error.as_deref(), + ); + let metadata = json!({ + "kind": "background_result", + "sourceKind": "bash_command", + "toolName": "Bash", + "toolCallId": tool_use_id_for_task.clone(), + "terminalSessionId": terminal_session_id.clone(), + "command": command.clone(), + "workingDirectory": working_directory.clone(), + "outputFile": output_file_reference_for_task.clone(), + }); + + if let Some(scheduler) = get_global_scheduler() { + if let Err(error) = scheduler + .deliver_background_result( + parent_session_id.clone(), + parent_agent_type.clone(), + parent_workspace_path.clone(), + delivery_text, + None, + Some(metadata), + ) + .await + { + error!( + "Failed to deliver background Bash result: session_id={}, terminal_session_id={}, error={}", + parent_session_id, terminal_session_id, error + ); + } + } else { + error!( + "Scheduler not initialized; background Bash result dropped: session_id={}, terminal_session_id={}", + parent_session_id, terminal_session_id + ); + } + delivery_sent = true; + break; + } + CommandStreamEvent::Error { message } => { + let delivery_text = Self::format_background_command_error_text( + &command, + &terminal_session_id, + &working_directory, + &output_file_reference_for_task, + &message, + output_persist_error.as_deref(), ); + let metadata = json!({ + "kind": "background_result", + "sourceKind": "bash_command", + "toolName": "Bash", + "toolCallId": tool_use_id_for_task.clone(), + "terminalSessionId": terminal_session_id.clone(), + "command": command.clone(), + "workingDirectory": working_directory.clone(), + "outputFile": output_file_reference_for_task.clone(), + "error": message.clone(), + }); + + if let Some(scheduler) = get_global_scheduler() { + if let Err(error) = scheduler + .deliver_background_result( + parent_session_id.clone(), + parent_agent_type.clone(), + parent_workspace_path.clone(), + delivery_text, + None, + Some(metadata), + ) + .await + { + error!( + "Failed to deliver background Bash error result: session_id={}, terminal_session_id={}, error={}", + parent_session_id, terminal_session_id, error + ); + } + } else { + error!( + "Scheduler not initialized; background Bash error result dropped: session_id={}, terminal_session_id={}", + parent_session_id, terminal_session_id + ); + } + delivery_sent = true; break; } - let _ = writer.flush().await; } + } - // Channel closed means session was destroyed - delete the log file - drop(writer); - if let Err(e) = tokio::fs::remove_file(&file_path).await { - debug!( - "Could not remove output file for bg session {} (may already be gone): {}", - bg_id_for_log, e - ); + if !saw_completion && !delivery_sent { + let delivery_text = Self::format_background_command_error_text( + &command, + &terminal_session_id, + &working_directory, + &output_file_reference_for_task, + "Background Bash command stream ended without a completion event.", + output_persist_error.as_deref(), + ); + let metadata = json!({ + "kind": "background_result", + "sourceKind": "bash_command", + "toolName": "Bash", + "toolCallId": tool_use_id_for_task, + "terminalSessionId": terminal_session_id.clone(), + "command": command.clone(), + "workingDirectory": working_directory.clone(), + "outputFile": output_file_reference_for_task.clone(), + "error": "stream_ended_without_completion", + }); + + if let Some(scheduler) = get_global_scheduler() { + if let Err(error) = scheduler + .deliver_background_result( + parent_session_id.clone(), + parent_agent_type.clone(), + parent_workspace_path.clone(), + delivery_text, + None, + Some(metadata), + ) + .await + { + error!( + "Failed to deliver background Bash terminal stream-end result: session_id={}, terminal_session_id={}, error={}", + parent_session_id, terminal_session_id, error + ); + } } else { - debug!("Removed output file for bg session {}", bg_id_for_log); + error!( + "Scheduler not initialized; background Bash stream-end result dropped: session_id={}, terminal_session_id={}", + parent_session_id, terminal_session_id + ); } - }); - } + } + }); let execution_time_ms = elapsed_ms_u64(start_time); - - let output_file_str = output_file_path.as_deref().map(|p| p.display().to_string()); - let output_file_reference = context - .build_session_runtime_artifact_reference( - chat_session_id, - &format!("tool-results/{}.txt", tool_use_id), - ) - .ok() - .or_else(|| output_file_str.clone()); - - let output_file_note = output_file_reference - .as_deref() - .map(|s| format!("\nOutput is being written to: {}", s)) - .unwrap_or_default(); + let output_file_note = format!("\nFull output will be saved to: {}", output_file_reference); let result_data = json!({ "success": true, @@ -1308,10 +1580,11 @@ impl BashTool { "execution_time_ms": execution_time_ms, "terminal_session_id": bg_session_id, "output_file": output_file_reference, + "run_in_background": true, }); let result_for_assistant = format!( - "Command started in background terminal session (id: {}). Working directory: {}.{}", + "Command started in background terminal session (id: {}). Working directory: {}.{} Its final result will be delivered back automatically when it finishes. Do not poll for status updates. If your current path is blocked on this result and there is no other useful local work to do, it is fine to end the current turn.", bg_session_id, initial_cwd, output_file_note ); @@ -1449,4 +1722,25 @@ mod tests { assert!(rendered.contains("/private/tmp")); assert!(rendered.contains("ERR_PNPM_NO_PKG_MANIFEST")); } + + #[test] + fn background_delivery_text_points_to_saved_output_file() { + let rendered = BashTool::format_background_command_delivery_text( + "pnpm test", + "bg-session-1", + "/repo", + Some(0), + false, + false, + "/runtime/sessions/session/tool-results/bash_123.txt", + None, + ); + + assert!(rendered.contains("Background Bash command completed successfully.")); + assert!(rendered.contains("status=\"completed\"")); + assert!(rendered.contains("terminal_session_id=\"bg-session-1\"")); + assert!(rendered.contains( + "Full output was saved to: /runtime/sessions/session/tool-results/bash_123.txt" + )); + } } diff --git a/src/crates/core/src/agentic/tools/implementations/task_tool.rs b/src/crates/core/src/agentic/tools/implementations/task_tool.rs index 6e82b85487..0e902300e2 100644 --- a/src/crates/core/src/agentic/tools/implementations/task_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/task_tool.rs @@ -1139,7 +1139,7 @@ impl Tool for TaskTool { "background_task_id": background_result.background_task_id, }), result_for_assistant: Some(format!( - "Background subagent '{}' started successfully.\nIts final result will be delivered back automatically to you when it is finished.", + "Background subagent '{}' started successfully.\nIts final result will be delivered back automatically to you when it is finished. Do not poll for status updates. If your current path is blocked on this result and there is no other useful local work to do, it is fine to end the current turn.", subagent_type, background_result.background_task_id )), image_attachments: None, From c10b3e18675d14a3e13aead4d7aaddb268f26a07 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 18 May 2026 11:46:16 +0800 Subject: [PATCH 17/38] feat(cli): add /usage slash command --- src/apps/cli/src/chat_state.rs | 15 ++ src/apps/cli/src/commands.rs | 8 + src/apps/cli/src/modes/chat.rs | 109 +++++++++++- src/apps/cli/src/ui/command_palette.rs | 12 ++ src/apps/cli/src/ui/startup.rs | 7 + .../agents/definitions/modes/agentic.rs | 4 +- .../agentic/agents/definitions/modes/plan.rs | 1 - .../core/src/agentic/agents/registry/tests.rs | 5 +- src/crates/core/src/agentic/mod.rs | 3 +- .../src/agentic/session/session_manager.rs | 162 +++++++++++++++++- src/crates/core/src/agentic/system.rs | 27 ++- .../tools/implementations/cron_tool.rs | 12 +- .../implementations/session_control_tool.rs | 82 ++++----- .../implementations/session_message_tool.rs | 5 +- src/crates/core/src/miniapp/js_worker_pool.rs | 13 +- 15 files changed, 394 insertions(+), 71 deletions(-) diff --git a/src/apps/cli/src/chat_state.rs b/src/apps/cli/src/chat_state.rs index 2d83329f6b..4a154a3697 100644 --- a/src/apps/cli/src/chat_state.rs +++ b/src/apps/cli/src/chat_state.rs @@ -859,6 +859,21 @@ impl ChatState { }); } + /// Add a local assistant message (for rendered reports and other UI-only content). + pub fn add_assistant_message(&mut self, content: String) { + self.messages.push(ChatMessage { + id: uuid::Uuid::new_v4().to_string(), + role: MessageRole::Assistant, + timestamp: SystemTime::now(), + flow_items: vec![FlowItem::Text { + content, + is_streaming: false, + }], + is_streaming: false, + version: 0, + }); + } + /// Clear all messages (for /clear command) pub fn clear_messages(&mut self) { self.messages.clear(); diff --git a/src/apps/cli/src/commands.rs b/src/apps/cli/src/commands.rs index 71b8f952fd..9f571b0dda 100644 --- a/src/apps/cli/src/commands.rs +++ b/src/apps/cli/src/commands.rs @@ -64,6 +64,10 @@ pub const COMMAND_SPECS: &[CommandSpec] = &[ name: "/history", description: "Show history", }, + CommandSpec { + name: "/usage", + description: "Generate session usage report", + }, CommandSpec { name: "/exit", description: "Exit the app", @@ -116,6 +120,10 @@ pub const STARTUP_COMMAND_SPECS: &[CommandSpec] = &[ name: "/init", description: "Explore repo and generate AGENTS.md", }, + CommandSpec { + name: "/usage", + description: "Generate session usage report", + }, CommandSpec { name: "/exit", description: "Exit the app", diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index cdd51b94b9..b51fb02803 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -2,7 +2,7 @@ /// /// Interactive chat mode with TUI interface. /// Events are consumed directly from core's EventQueue. -use anyhow::Result; +use anyhow::{anyhow, Result}; use arboard::Clipboard; use crossterm::event::{ Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, @@ -42,6 +42,7 @@ use crate::ui::{init_terminal, restore_terminal}; use bitfun_core::agentic::agents::{ get_agent_registry, AgentInfo, SubAgentSource, SubagentListScope, SubagentQueryContext, }; +use bitfun_core::agentic::persistence::PersistenceManager; use bitfun_core::agentic::tools::implementations::skills::{ mode_overrides::{ load_project_mode_skills_document_local, save_project_mode_skills_document_local, @@ -51,6 +52,10 @@ use bitfun_core::agentic::tools::implementations::skills::{ ModeSkillInfo, SkillInfo, }; use bitfun_core::service::config::GlobalConfigManager; +use bitfun_core::service::session_usage::{ + generate_session_usage_report, render_usage_report_markdown, SessionUsageReportRequest, +}; +use bitfun_core::service::token_usage::TokenUsageService; /// Keyboard shortcuts help text const KEYBOARD_SHORTCUTS_HELP: &str = "\ @@ -119,6 +124,7 @@ pub struct ChatMode { agent_type: String, workspace: Option, agent: Arc, + token_usage_service: Arc, /// If set, restore this existing session instead of creating a new one restore_session_id: Option, /// If set, send this prompt automatically when the session starts @@ -155,6 +161,7 @@ impl ChatMode { agent_type, workspace, agent, + token_usage_service: agentic_system.token_usage_service.clone(), restore_session_id: None, initial_prompt: None, pending_mcp_op: None, @@ -1573,6 +1580,9 @@ impl ChatMode { } self.show_session_selector(chat_view, chat_state, rt_handle); } + "usage" => { + self.show_usage_report(chat_view, chat_state, rt_handle); + } // Prompt group "skills" => { self.show_skill_selector(chat_view, chat_state, rt_handle); @@ -1699,6 +1709,9 @@ impl ChatMode { "ACP setup added to the conversation. You can keep typing.".to_string(), )); } + "/usage" => { + self.show_usage_report(chat_view, chat_state, rt_handle); + } "/init" => match crate::prompts::get_cli_prompt("init") { Some(prompt) => { self.send_message_to_agent( @@ -1757,6 +1770,100 @@ impl ChatMode { Ok(None) } + fn show_usage_report( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + if chat_state.is_processing { + chat_view.set_status(Some( + "Wait until the session is idle before using /usage.".to_string(), + )); + return; + } + + let session_id = chat_state.core_session_id.clone(); + let workspace_path = chat_state + .workspace + .clone() + .or_else(|| self.workspace.clone()) + .or_else(|| Some(self.agent.workspace_path_string())); + let token_usage_service = self.token_usage_service.clone(); + let session_manager = self.agent.coordinator().get_session_manager(); + + let report_result: Result = + tokio::task::block_in_place(|| { + let session_id = session_id.clone(); + let workspace_path = workspace_path.clone(); + let token_usage_service = token_usage_service.clone(); + let session_manager = session_manager.clone(); + rt_handle.block_on(async move { + let workspace_path = workspace_path + .filter(|path| !path.trim().is_empty()) + .ok_or_else(|| anyhow!("Workspace path is required for usage reports"))?; + + let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() + .map_err(|error| anyhow!(error.to_string()))?; + let persistence_manager = PersistenceManager::new(path_manager) + .map_err(|error| anyhow!(error.to_string()))?; + + let report = generate_session_usage_report( + &persistence_manager, + Some(token_usage_service.as_ref()), + SessionUsageReportRequest { + session_id: session_id.clone(), + workspace_path: Some(workspace_path), + remote_connection_id: None, + remote_ssh_host: None, + include_hidden_subagents: true, + }, + ) + .await + .map_err(|error| anyhow!(error.to_string()))?; + + let markdown = render_usage_report_markdown(&report); + let generated_at = u64::try_from(report.generated_at).unwrap_or_default(); + let usage_report = serde_json::to_value(&report) + .map_err(|error| anyhow!("Failed to serialize usage report: {}", error))?; + let metadata = serde_json::json!({ + "localCommandKind": "usage_report", + "reportId": report.report_id.clone(), + "schemaVersion": report.schema_version, + "generatedAt": report.generated_at, + "modelVisible": false, + "usageReport": usage_report, + "usageReportStatus": "completed", + }); + + session_manager + .append_completed_local_command_turn( + &session_id, + markdown, + Some(format!("local-usage-{}", report.report_id)), + Some(generated_at), + Some(metadata), + ) + .await + .map_err(|error| anyhow!(error.to_string()))?; + + Ok(report) + }) + }); + + match report_result { + Ok(report) => { + let markdown = render_usage_report_markdown(&report); + chat_state.add_assistant_message(markdown); + chat_view.set_status(Some("Usage report added to conversation".to_string())); + } + Err(error) => { + chat_state + .add_system_message(format!("Failed to generate usage report: {}", error)); + } + } + } + fn list_available_themes(&self) -> Vec { let mut themes = Vec::new(); for id in builtin_theme_ids() { diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index 61a100e31b..404f0347b0 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -53,6 +53,12 @@ pub fn default_palette_items() -> Vec { description: "Browse and switch sessions".into(), group: "Session".into(), }, + PaletteItem { + id: "usage".into(), + label: "Usage report".into(), + description: "Generate a usage report for the current session".into(), + group: "Session".into(), + }, // Prompt group PaletteItem { id: "skills".into(), @@ -143,6 +149,12 @@ fn build_suggested_items() -> Vec { description: "Start a new conversation".into(), group: "Suggested".into(), }, + PaletteItem { + id: "usage".into(), + label: "Usage report".into(), + description: "Generate a usage report for the current session".into(), + group: "Suggested".into(), + }, ] } diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 7aecf1b881..4d136095c7 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -135,6 +135,7 @@ const TIPS: &[&str] = &[ "Use /sessions to list and continue previous conversations", "Press Ctrl+O to expand/collapse tool output", "Use /skills to browse and execute available skills", + "Use /usage inside a session to generate a usage report", "Use /theme to switch the CLI theme", "Use /acp to copy editor setup commands for ACP hosts", "Press Up/Down to cycle through input history", @@ -1048,6 +1049,9 @@ impl StartupPage { "sessions" => { self.show_session_selector(); } + "usage" => { + self.status = Some("No active session for /usage.".to_string()); + } // Prompt group "skills" => { self.show_skill_selector(); @@ -1139,6 +1143,9 @@ impl StartupPage { prompt: Some("/acp".to_string()), }); } + "/usage" => { + self.status = Some("No active session for /usage.".to_string()); + } "/init" => match crate::prompts::get_cli_prompt("init") { Some(prompt) => { return Some(StartupResult::NewSession { diff --git a/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs b/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs index 9a8b6fe742..ea97fbf960 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs @@ -1,8 +1,6 @@ //! Agentic Mode -use crate::agentic::agents::{ - shared_coding_mode_tools, Agent, SHARED_CODING_MODE_PROMPT_TEMPLATE, -}; +use crate::agentic::agents::{shared_coding_mode_tools, Agent, SHARED_CODING_MODE_PROMPT_TEMPLATE}; use async_trait::async_trait; pub struct AgenticMode { default_tools: Vec, diff --git a/src/crates/core/src/agentic/agents/definitions/modes/plan.rs b/src/crates/core/src/agentic/agents/definitions/modes/plan.rs index ad7f557540..24b73d0bc7 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/plan.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/plan.rs @@ -32,7 +32,6 @@ impl PlanMode { ], } } - } #[async_trait] diff --git a/src/crates/core/src/agentic/agents/registry/tests.rs b/src/crates/core/src/agentic/agents/registry/tests.rs index bd6c45ac01..6b1e3325bb 100644 --- a/src/crates/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/core/src/agentic/agents/registry/tests.rs @@ -122,10 +122,7 @@ fn non_deep_review_builtin_subagents_default_to_primary() { #[test] fn general_purpose_builtin_subagent_defaults_to_fast() { - assert_eq!( - default_model_id_for_builtin_agent("GeneralPurpose"), - "fast" - ); + assert_eq!(default_model_id_for_builtin_agent("GeneralPurpose"), "fast"); } #[test] diff --git a/src/crates/core/src/agentic/mod.rs b/src/crates/core/src/agentic/mod.rs index 698c163f50..6899192413 100644 --- a/src/crates/core/src/agentic/mod.rs +++ b/src/crates/core/src/agentic/mod.rs @@ -58,8 +58,7 @@ pub use persistence::PersistenceManager; pub use round_preempt::{ DialogRoundInjectionInterrupt, DialogRoundInjectionSource, DialogRoundPreemptSource, NoopDialogRoundInjectionSource, NoopDialogRoundPreemptSource, RoundInjection, - RoundInjectionKind, RoundInjectionTarget, SessionRoundInjectionBuffer, - SessionRoundYieldFlags, + RoundInjectionKind, RoundInjectionTarget, SessionRoundInjectionBuffer, SessionRoundYieldFlags, }; pub use session::*; pub use side_question::*; diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 585416873a..4320597dda 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -2554,6 +2554,102 @@ impl SessionManager { Ok(turn_id) } + /// Append a completed local command turn that should be persisted in user-facing + /// history without entering model-visible runtime context. + pub async fn append_completed_local_command_turn( + &self, + session_id: &str, + content: String, + turn_id: Option, + timestamp_ms: Option, + user_message_metadata: Option, + ) -> BitFunResult { + let session = self + .get_session(session_id) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))?; + let workspace_path = Self::effective_workspace_path_from_config(&session.config) + .await + .ok_or_else(|| { + BitFunError::Validation(format!( + "Session workspace_path is missing: {}", + session_id + )) + })?; + + let turn_id = new_turn_id(turn_id); + let turn_index = session + .dialog_turn_ids + .iter() + .position(|existing| existing == &turn_id) + .unwrap_or(session.dialog_turn_ids.len()); + let timestamp = timestamp_ms.unwrap_or_else(|| { + SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 + }); + let mut turn = DialogTurnData::new_with_kind( + DialogTurnKind::LocalCommand, + turn_id.clone(), + turn_index, + session_id.to_string(), + None, + UserMessageData { + id: format!("{}-user", turn_id), + content, + timestamp, + metadata: user_message_metadata, + }, + ); + turn.timestamp = timestamp; + turn.start_time = timestamp; + turn.end_time = Some(timestamp); + turn.duration_ms = Some(0); + turn.status = TurnStatus::Completed; + + if self.config.enable_persistence && Self::should_persist_session(&session) { + self.persistence_manager + .save_dialog_turn(&workspace_path, &turn) + .await?; + } + + let session_snapshot = if let Some(mut session) = self.sessions.get_mut(session_id) { + if !session + .dialog_turn_ids + .iter() + .any(|existing| existing == &turn_id) + { + session.dialog_turn_ids.push(turn_id); + } + session.state = SessionState::Idle; + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); + + if self.config.enable_persistence && Self::should_persist_session(&session) { + Some(session.clone()) + } else { + None + } + } else { + None + }; + + if let Some(session) = session_snapshot { + self.persistence_manager + .save_session(&workspace_path, &session) + .await?; + } + + self.persist_context_snapshot_for_turn_best_effort( + session_id, + turn_index, + "local_command_turn_persisted", + ) + .await; + + Ok(turn) + } + /// Complete dialog turn pub async fn complete_dialog_turn( &self, @@ -3475,8 +3571,9 @@ mod tests { }; use crate::service::remote_ssh::workspace_state::local_workspace_roots_equal; use crate::service::session::{ - DialogTurnData, ModelRoundData, SessionKind, SessionMetadata, SessionRelationship, - SessionRelationshipKind, ToolCallData, ToolItemData, ToolResultData, UserMessageData, + DialogTurnData, DialogTurnKind, ModelRoundData, SessionKind, SessionMetadata, + SessionRelationship, SessionRelationshipKind, ToolCallData, ToolItemData, ToolResultData, + TurnStatus, UserMessageData, }; use dashmap::try_result::TryResult; use serde_json::json; @@ -3772,6 +3869,67 @@ mod tests { assert!(matches!(session.state, SessionState::Idle)); } + #[tokio::test] + async fn append_completed_local_command_turn_persists_without_model_context() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Usage session".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + let turn = manager + .append_completed_local_command_turn( + &session.session_id, + "# Session Usage Report".to_string(), + Some("local-usage-1".to_string()), + Some(42), + Some(json!({ + "localCommandKind": "usage_report", + "modelVisible": false, + })), + ) + .await + .expect("local command turn should persist"); + + assert_eq!(turn.kind, DialogTurnKind::LocalCommand); + assert_eq!(turn.status, TurnStatus::Completed); + + let active = manager + .get_session(&session.session_id) + .expect("session should remain active"); + assert_eq!(active.dialog_turn_ids, vec!["local-usage-1".to_string()]); + assert!(manager + .context_store + .get_context_messages(&session.session_id) + .is_empty()); + + let persisted_turns = persistence_manager + .load_session_turns(workspace.path(), &session.session_id) + .await + .expect("turns should load"); + assert_eq!(persisted_turns.len(), 1); + assert_eq!(persisted_turns[0].kind, DialogTurnKind::LocalCommand); + assert!(SessionManager::build_messages_from_turns(&persisted_turns).is_empty()); + + let metadata = persistence_manager + .load_session_metadata(workspace.path(), &session.session_id) + .await + .expect("metadata should load") + .expect("metadata should exist"); + assert_eq!(metadata.turn_count, 1); + } + #[tokio::test] async fn restore_session_resets_processing_state_without_marking_unread_completion() { let workspace = TestWorkspace::new(); diff --git a/src/crates/core/src/agentic/system.rs b/src/crates/core/src/agentic/system.rs index 7fedf901fb..1d5ab16773 100644 --- a/src/crates/core/src/agentic/system.rs +++ b/src/crates/core/src/agentic/system.rs @@ -13,12 +13,14 @@ use crate::agentic::session; use crate::agentic::tools; use crate::infrastructure::ai::AIClientFactory; use crate::infrastructure::try_get_path_manager_arc; +use crate::service::token_usage::{TokenUsageService, TokenUsageSubscriber}; /// Agentic runtime state shared by host adapters. #[derive(Clone)] pub struct AgenticSystem { pub coordinator: Arc, pub event_queue: Arc, + pub token_usage_service: Arc, } /// Initialize the agentic runtime and register the global coordinator. @@ -32,6 +34,9 @@ pub async fn init_agentic_system() -> Result { let path_manager = try_get_path_manager_arc()?; let persistence_manager = Arc::new(persistence::PersistenceManager::new(path_manager.clone())?); + let token_usage_service = Arc::new(TokenUsageService::new(path_manager.clone()).await?); + let token_usage_subscriber = Arc::new(TokenUsageSubscriber::new(token_usage_service.clone())); + event_router.subscribe_internal("token_usage".to_string(), token_usage_subscriber); let context_store = Arc::new(session::SessionContextStore::new()); let context_compressor = Arc::new(session::ContextCompressor::new(Default::default())); @@ -70,14 +75,34 @@ pub async fn init_agentic_system() -> Result { execution_engine, tool_pipeline, event_queue.clone(), - event_router, + event_router.clone(), )); coordination::ConversationCoordinator::set_global(coordinator.clone()); + + let mut internal_event_rx = event_queue.subscribe(); + let internal_event_router = event_router.clone(); + tokio::spawn(async move { + loop { + match internal_event_rx.recv().await { + Ok(envelope) => { + if let Err(error) = internal_event_router.route(envelope).await { + log::warn!("Internal agentic event routing failed: {}", error); + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + log::warn!("Internal agentic event router lagged by {} events", skipped); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }); + info!("Agentic system initialization complete"); Ok(AgenticSystem { coordinator, event_queue, + token_usage_service, }) } diff --git a/src/crates/core/src/agentic/tools/implementations/cron_tool.rs b/src/crates/core/src/agentic/tools/implementations/cron_tool.rs index 768640e51e..bae17671c8 100644 --- a/src/crates/core/src/agentic/tools/implementations/cron_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/cron_tool.rs @@ -1230,12 +1230,10 @@ mod tests { .await; assert!(!validation.result); - assert!( - validation - .message - .as_deref() - .unwrap_or_default() - .contains("unknown field") - ); + assert!(validation + .message + .as_deref() + .unwrap_or_default() + .contains("unknown field")); } } diff --git a/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs index b106b1ef0d..65f81801aa 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs @@ -423,18 +423,16 @@ Arguments: // Ignore workspace for cancel/delete so callers cannot accidentally // scope these actions to the wrong workspace. return match parsed.action { - SessionControlAction::Cancel => self - .validate_mutating_action_target( - SessionControlAction::Cancel, - &parsed, - context, - ), - SessionControlAction::Delete => self - .validate_mutating_action_target( - SessionControlAction::Delete, - &parsed, - context, - ), + SessionControlAction::Cancel => self.validate_mutating_action_target( + SessionControlAction::Cancel, + &parsed, + context, + ), + SessionControlAction::Delete => self.validate_mutating_action_target( + SessionControlAction::Delete, + &parsed, + context, + ), _ => ValidationResult::default(), }; } @@ -564,13 +562,14 @@ Arguments: match params.action { SessionControlAction::Create => { - let workspace = self.resolve_effective_workspace( - SessionControlAction::Create, - None, - context, - &coordinator, - ) - .await?; + let workspace = self + .resolve_effective_workspace( + SessionControlAction::Create, + None, + context, + &coordinator, + ) + .await?; let session_name = params .session_name .clone() @@ -624,13 +623,14 @@ Arguments: BitFunError::tool("session_id is required for cancel".to_string()) })?; Self::validate_session_id(session_id).map_err(BitFunError::tool)?; - let workspace = self.resolve_effective_workspace( - SessionControlAction::Cancel, - Some(session_id), - context, - &coordinator, - ) - .await?; + let workspace = self + .resolve_effective_workspace( + SessionControlAction::Cancel, + Some(session_id), + context, + &coordinator, + ) + .await?; let workspace_path = Path::new(&workspace); if self.current_workspace_session(context, &workspace) == Some(session_id) { return Err(BitFunError::tool( @@ -705,13 +705,14 @@ Arguments: BitFunError::tool("session_id is required for delete".to_string()) })?; Self::validate_session_id(session_id).map_err(BitFunError::tool)?; - let workspace = self.resolve_effective_workspace( - SessionControlAction::Delete, - Some(session_id), - context, - &coordinator, - ) - .await?; + let workspace = self + .resolve_effective_workspace( + SessionControlAction::Delete, + Some(session_id), + context, + &coordinator, + ) + .await?; let workspace_path = Path::new(&workspace); if self.current_workspace_session(context, &workspace) == Some(session_id) { return Err(BitFunError::tool( @@ -741,13 +742,14 @@ Arguments: }]) } SessionControlAction::List => { - let workspace = self.resolve_effective_workspace( - SessionControlAction::List, - None, - context, - &coordinator, - ) - .await?; + let workspace = self + .resolve_effective_workspace( + SessionControlAction::List, + None, + context, + &coordinator, + ) + .await?; let workspace_path = Path::new(&workspace); let sessions = coordinator.list_sessions(workspace_path).await?; let current_session_id = self.current_workspace_session(context, &workspace); diff --git a/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs index 911f658ff8..b5e661b200 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs @@ -422,7 +422,10 @@ Allowed agent types when creating a session: .get("session_name") .and_then(|value| value.as_str()) .unwrap_or("new session"); - format!("Create session {} in {} and send message", session_name, workspace) + format!( + "Create session {} in {} and send message", + session_name, workspace + ) } } diff --git a/src/crates/core/src/miniapp/js_worker_pool.rs b/src/crates/core/src/miniapp/js_worker_pool.rs index 7fa28dbd1f..9bb49fe0d1 100644 --- a/src/crates/core/src/miniapp/js_worker_pool.rs +++ b/src/crates/core/src/miniapp/js_worker_pool.rs @@ -387,11 +387,7 @@ mod tests { impl TestTempDir { fn new(prefix: &str) -> Self { - let path = std::env::temp_dir().join(format!( - "{}-{}", - prefix, - uuid::Uuid::new_v4() - )); + let path = std::env::temp_dir().join(format!("{}-{}", prefix, uuid::Uuid::new_v4())); fs::create_dir_all(&path).expect("test root should be created"); Self { path } } @@ -410,10 +406,9 @@ mod tests { #[tokio::test] async fn runtime_port_adapter_preserves_existing_runtime_and_noop_install() { let root = TestTempDir::new("bitfun-miniapp-runtime-port"); - let path_manager = - Arc::new(crate::infrastructure::PathManager::with_user_root_for_tests( - root.path().to_path_buf(), - )); + let path_manager = Arc::new( + crate::infrastructure::PathManager::with_user_root_for_tests(root.path().to_path_buf()), + ); let app_id = "demo_app"; tokio::fs::create_dir_all(path_manager.miniapp_dir(app_id)) .await From ce18bb5ca5ebda4c4d7be4ee076d36a4b3d5494c Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Thu, 21 May 2026 15:07:55 +0800 Subject: [PATCH 18/38] feat(cli): add headless session resume and structured exec output --- src/apps/cli/src/agent/core_adapter.rs | 27 ++ src/apps/cli/src/main.rs | 198 ++++++++++++++- src/apps/cli/src/modes/exec.rs | 333 ++++++++++++++++++++++--- 3 files changed, 516 insertions(+), 42 deletions(-) diff --git a/src/apps/cli/src/agent/core_adapter.rs b/src/apps/cli/src/agent/core_adapter.rs index 1e5cbd8074..d5f5626540 100644 --- a/src/apps/cli/src/agent/core_adapter.rs +++ b/src/apps/cli/src/agent/core_adapter.rs @@ -148,6 +148,33 @@ impl CoreAgentAdapter { } } } + + pub async fn create_session_with_id( + &self, + session_id: String, + agent_type: &str, + ) -> Result { + let mut session_id_guard = self.session_id.lock().await; + + let session = self + .coordinator + .create_session_with_id( + Some(session_id.clone()), + Self::build_default_session_name(), + agent_type.to_string(), + SessionConfig { + workspace_path: Some(self.workspace_path_string()), + ..Default::default() + }, + ) + .await?; + + let id = session.session_id.clone(); + *session_id_guard = Some(id.clone()); + tracing::info!("Created core session with fixed id: {}", id); + + Ok(id) + } } #[async_trait::async_trait] diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 471ec21473..180793c753 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -16,12 +16,14 @@ mod ui; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::OnceLock; use config::CliConfig; use modes::chat::ChatMode; -use modes::exec::ExecMode; +use modes::exec::{ExecMode, ExecOutputFormat, ExecSessionOptions}; // ======================== Global MCP Service ======================== @@ -77,13 +79,37 @@ enum Commands { /// Execute single command Exec { - /// User message - message: String, + /// User message. If omitted, stdin is used when piped. + message: Option, /// Agent type #[arg(short, long, default_value = "agentic")] agent: String, + /// Continue the most recent session in the current workspace + #[arg(short = 'c', long = "continue")] + continue_last: bool, + + /// Resume a session by ID, or use "last" for the most recent session + #[arg(short = 'r', long)] + resume: Option, + + /// Alias for --resume, compatible with opencode-style CLIs + #[arg(short = 's', long)] + session: Option, + + /// Create a new session with a fixed session ID + #[arg(long)] + session_id: Option, + + /// Fork the resumed session before executing the prompt + #[arg(long = "fork-session")] + fork_session: bool, + + /// Output format for automation + #[arg(long, value_enum, default_value_t = ExecOutputFormat::Text)] + output_format: ExecOutputFormat, + /// Output git diff patch after execution (for SWE-bench evaluation) /// Without path outputs to terminal, with path saves to file /// Example: --output-patch or --output-patch ./result.patch @@ -210,6 +236,21 @@ enum SessionAction { /// Session ID id: String, }, + /// Resume a session in the interactive TUI + Resume { + /// Session ID (or "last" for the most recent) + id: String, + }, + /// Continue the most recent session in the interactive TUI + Continue, + /// Fork a session at the latest persisted turn + Fork { + /// Session ID (or "last" for the most recent) + id: String, + /// Print only the new session ID + #[arg(long)] + id_only: bool, + }, } #[derive(Subcommand)] @@ -506,6 +547,12 @@ async fn main() -> Result<()> { Some(Commands::Exec { message, agent, + continue_last, + resume, + session, + session_id, + fork_session, + output_format, output_patch, confirm, }) => { @@ -515,6 +562,21 @@ async fn main() -> Result<()> { tracing::info!("Workspace path set: {:?}", ws_path); } + let message = resolve_exec_message(message)?; + let resume = match (resume, session) { + (Some(_), Some(_)) => { + anyhow::bail!("Use only one of --resume or --session"); + } + (Some(value), None) | (None, Some(value)) => Some(value), + (None, None) => None, + }; + if session_id.is_some() && (continue_last || resume.is_some()) { + anyhow::bail!("--session-id cannot be combined with --continue, --resume, or --session"); + } + if fork_session && session_id.is_some() { + anyhow::bail!("--fork-session cannot be combined with --session-id"); + } + let skip_confirmation = !confirm; let (agentic_system, original_skip_confirmation) = initialize_core_services(skip_confirmation).await?; @@ -526,6 +588,13 @@ async fn main() -> Result<()> { &agentic_system, workspace_path_resolved, output_patch, + output_format, + ExecSessionOptions { + resume, + continue_last, + session_id, + fork_session, + }, ); let run_result = exec_mode.run().await; @@ -536,7 +605,9 @@ async fn main() -> Result<()> { } Some(Commands::Sessions { action }) => { - handle_session_action(action).await?; + if let Some(session_id) = handle_session_action(action).await? { + run_interactive_with_session(config, session_id).await?; + } } Some(Commands::Config { action }) => { @@ -638,7 +709,7 @@ async fn main() -> Result<()> { Ok(()) } -async fn handle_session_action(action: SessionAction) -> Result<()> { +async fn handle_session_action(action: SessionAction) -> Result> { // Initialize core services for session management bitfun_core::service::config::initialize_global_config() .await @@ -660,7 +731,7 @@ async fn handle_session_action(action: SessionAction) -> Result<()> { "No history sessions for current project: {}", workspace_path.display() ); - return Ok(()); + return Ok(None); } println!( @@ -767,11 +838,126 @@ async fn handle_session_action(action: SessionAction) -> Result<()> { coordinator.delete_session(&workspace_path, &id).await?; println!("Deleted session from current project: {}", id); } + + SessionAction::Resume { id } => { + let session_id = resolve_cli_session_id(&coordinator, &workspace_path, &id).await?; + return Ok(Some(session_id)); + } + + SessionAction::Continue => { + let session_id = resolve_cli_session_id(&coordinator, &workspace_path, "last").await?; + return Ok(Some(session_id)); + } + + SessionAction::Fork { id, id_only } => { + let session_id = resolve_cli_session_id(&coordinator, &workspace_path, &id).await?; + let (_session, turns) = coordinator + .restore_session_view(&workspace_path, &session_id) + .await?; + let source_turn_id = turns + .last() + .map(|turn| turn.turn_id.clone()) + .ok_or_else(|| anyhow::anyhow!("Session has no persisted turns to fork"))?; + let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let persistence_manager = bitfun_core::agentic::persistence::PersistenceManager::new( + path_manager, + ) + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let result = persistence_manager + .branch_session( + &workspace_path, + &bitfun_core::agentic::persistence::session_branch::SessionBranchRequest { + source_session_id: session_id.clone(), + source_turn_id, + }, + ) + .await?; + + if id_only { + println!("{}", result.session_id); + } else { + println!("Forked session"); + println!("Source ID: {}", session_id); + println!("New ID: {}", result.session_id); + println!("Name: {}", result.session_name); + println!("Agent: {}", result.agent_type); + } + } + } + + Ok(None) +} + +async fn resolve_cli_session_id( + coordinator: &std::sync::Arc, + workspace_path: &Path, + id: &str, +) -> Result { + if id == "last" { + let sessions = coordinator.list_sessions(workspace_path).await?; + return sessions + .first() + .map(|session| session.session_id.clone()) + .ok_or_else(|| anyhow::anyhow!("No history sessions")); } + Ok(id.to_string()) +} + +async fn run_interactive_with_session(config: CliConfig, session_id: String) -> Result<()> { + let mut terminal = ui::init_terminal()?; + ui::render_loading(&mut terminal, "Initializing system, please wait...")?; + + let workspace = setup_workspace(); + let (agentic_system, original_skip_confirmation) = initialize_core_services(true).await?; + let workspace_path = workspace + .clone() + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")); + let session = agentic_system + .coordinator + .restore_session(&workspace_path, &session_id) + .await?; + + let mut chat_mode = ChatMode::new(config, session.agent_type, workspace, &agentic_system) + .with_restore_session(session_id); + let run_result = chat_mode.run(Some(terminal)); + + shutdown_mcp_servers().await; + restore_tool_confirmation(original_skip_confirmation).await; + println!("Goodbye!"); + + run_result?; Ok(()) } +fn resolve_exec_message(message: Option) -> Result { + let mut combined = message.unwrap_or_default(); + if !std::io::stdin().is_terminal() { + use std::io::Read; + let mut stdin_content = String::new(); + std::io::stdin().read_to_string(&mut stdin_content)?; + let stdin_content = stdin_content.trim_end().to_string(); + if !stdin_content.is_empty() { + if combined.is_empty() { + combined = stdin_content; + } else { + combined.push('\n'); + combined.push_str(&stdin_content); + } + } + } + + let message = combined.trim().to_string(); + if message.is_empty() { + anyhow::bail!("Prompt cannot be empty"); + } + + Ok(message) +} + fn handle_config_action(action: ConfigAction, config: &CliConfig) -> Result<()> { match action { ConfigAction::Show => { diff --git a/src/apps/cli/src/modes/exec.rs b/src/apps/cli/src/modes/exec.rs index 1980896e24..34b68f5634 100644 --- a/src/apps/cli/src/modes/exec.rs +++ b/src/apps/cli/src/modes/exec.rs @@ -3,6 +3,8 @@ /// Single command execution mode (non-interactive). /// Consumes core events directly from EventQueue. use anyhow::Result; +use clap::ValueEnum; +use serde_json::json; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -12,6 +14,21 @@ use bitfun_events::AgenticEvent; use crate::agent::{agentic_system::AgenticSystem, core_adapter::CoreAgentAdapter, Agent}; use crate::config::CliConfig; +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum ExecOutputFormat { + Text, + Json, + StreamJson, +} + +#[derive(Debug, Clone, Default)] +pub struct ExecSessionOptions { + pub resume: Option, + pub continue_last: bool, + pub session_id: Option, + pub fork_session: bool, +} + pub struct ExecMode { #[allow(dead_code)] config: CliConfig, @@ -21,6 +38,8 @@ pub struct ExecMode { workspace_path: Option, /// None: no patch output, Some("-"): output to stdout, Some(path): save to file output_patch: Option, + output_format: ExecOutputFormat, + session_options: ExecSessionOptions, } impl ExecMode { @@ -31,6 +50,8 @@ impl ExecMode { agentic_system: &AgenticSystem, workspace_path: Option, output_patch: Option, + output_format: ExecOutputFormat, + session_options: ExecSessionOptions, ) -> Self { let agent = Arc::new(CoreAgentAdapter::new( agentic_system.coordinator.clone(), @@ -45,6 +66,8 @@ impl ExecMode { agent, workspace_path, output_patch, + output_format, + session_options, } } @@ -78,14 +101,20 @@ impl ExecMode { self.message ); - println!("Executing: {}", self.message); - println!(); - - // Ensure session and send message - let session_id = self.agent.ensure_session(&self.agent_type).await?; + let session_id = self.prepare_session().await?; let event_queue = self.agent.event_queue().clone(); - println!("Thinking..."); + self.emit(json!({ + "type": "session", + "session_id": session_id, + "agent": self.agent_type, + }))?; + self.print_text(|| { + println!("Executing: {}", self.message); + println!(); + println!("Session: {}", session_id); + println!("Thinking..."); + }); let _turn_id = self .agent @@ -125,24 +154,50 @@ impl ExecMode { if parent_session_id.map(String::as_str) == Some(session_id.as_str()) { use bitfun_events::ToolEventData; match tool_event { - ToolEventData::Started { tool_name, .. } => { - println!(" [subagent] {}", tool_name); + ToolEventData::Started { tool_name, tool_id, .. } => { + self.emit(json!({ + "type": "subagent_tool_start", + "session_id": session_id, + "tool_id": tool_id, + "tool_name": tool_name, + }))?; + self.print_text(|| println!(" [subagent] {}", tool_name)); } ToolEventData::Completed { tool_name, + tool_id, result_for_assistant, result, + duration_ms, .. } => { let summary = result_for_assistant .clone() .unwrap_or_else(|| result.to_string()); - println!(" [subagent] {} ✓ {}", tool_name, summary); + self.emit(json!({ + "type": "subagent_tool_result", + "session_id": session_id, + "tool_id": tool_id, + "tool_name": tool_name, + "duration_ms": duration_ms, + "result": result, + "summary": summary, + }))?; + self.print_text(|| { + println!(" [subagent] {} completed: {}", tool_name, summary) + }); } ToolEventData::Failed { - tool_name, error, .. + tool_name, tool_id, error, .. } => { - println!(" [subagent] {} ✗ {}", tool_name, error); + self.emit(json!({ + "type": "subagent_tool_error", + "session_id": session_id, + "tool_id": tool_id, + "tool_name": tool_name, + "error": error, + }))?; + self.print_text(|| println!(" [subagent] {} failed: {}", tool_name, error)); } _ => {} } @@ -153,30 +208,69 @@ impl ExecMode { match event { AgenticEvent::TextChunk { text, .. } => { - print!("{}", text); - use std::io::Write; - std::io::stdout().flush().ok(); + self.emit(json!({ + "type": "text", + "session_id": session_id, + "text": text, + }))?; + self.print_text(|| { + print!("{}", text); + use std::io::Write; + std::io::stdout().flush().ok(); + }); } AgenticEvent::ThinkingChunk { content, .. } => { - // Show thinking in exec mode as dimmed text - print!("\x1b[2m{}\x1b[0m", content); - use std::io::Write; - std::io::stdout().flush().ok(); + self.emit(json!({ + "type": "thinking", + "session_id": session_id, + "text": content, + }))?; + self.print_text(|| { + print!("\x1b[2m{}\x1b[0m", content); + use std::io::Write; + std::io::stdout().flush().ok(); + }); } AgenticEvent::ToolEvent { tool_event, .. } => { use bitfun_events::ToolEventData; match tool_event { - ToolEventData::Started { tool_name, .. } => { - println!("\nTool call: {}", tool_name); + ToolEventData::Started { + tool_name, + tool_id, + params, + .. + } => { + self.emit(json!({ + "type": "tool_start", + "session_id": session_id, + "tool_id": tool_id, + "tool_name": tool_name, + "input": params, + }))?; + self.print_text(|| println!("\nTool call: {}", tool_name)); total_tool_calls += 1; } - ToolEventData::Progress { message, .. } => { - println!(" In progress: {}", message); + ToolEventData::Progress { + tool_name, + tool_id, + message, + percentage, + } => { + self.emit(json!({ + "type": "tool_progress", + "session_id": session_id, + "tool_id": tool_id, + "tool_name": tool_name, + "message": message, + "percentage": percentage, + }))?; + self.print_text(|| println!(" In progress: {}", message)); } ToolEventData::Completed { tool_name, + tool_id, result_for_assistant, result, duration_ms, @@ -185,42 +279,86 @@ impl ExecMode { let summary = result_for_assistant .clone() .unwrap_or_else(|| result.to_string()); - println!(" [+] {} ({}ms): {}", tool_name, duration_ms, summary); + self.emit(json!({ + "type": "tool_result", + "session_id": session_id, + "tool_id": tool_id, + "tool_name": tool_name, + "duration_ms": duration_ms, + "result": result, + "summary": summary, + }))?; + self.print_text(|| { + println!(" [+] {} ({}ms): {}", tool_name, duration_ms, summary) + }); } ToolEventData::Failed { - tool_name, error, .. + tool_name, + tool_id, + error, + .. } => { - println!(" [x] {}: {}", tool_name, error); + self.emit(json!({ + "type": "tool_error", + "session_id": session_id, + "tool_id": tool_id, + "tool_name": tool_name, + "error": error, + }))?; + self.print_text(|| println!(" [x] {}: {}", tool_name, error)); } _ => {} } } AgenticEvent::DialogTurnCompleted { .. } => { - println!("\n"); - println!("Execution complete"); - if total_tool_calls > 0 { - println!("\nTool call statistics: {} tools invoked", total_tool_calls); - } - // Break out of the event loop + self.emit(json!({ + "type": "done", + "session_id": session_id, + "status": "completed", + "tool_calls": total_tool_calls, + }))?; + self.print_text(|| { + println!("\n"); + println!("Execution complete"); + if total_tool_calls > 0 { + println!("\nTool call statistics: {} tools invoked", total_tool_calls); + } + }); self.output_patch_if_needed(); return Ok(()); } AgenticEvent::DialogTurnFailed { error, .. } => { - eprintln!("\nExecution failed: {}", error); + self.emit(json!({ + "type": "error", + "session_id": session_id, + "message": error, + }))?; + self.print_text(|| eprintln!("\nExecution failed: {}", error)); self.output_patch_if_needed(); return Err(anyhow::anyhow!("Execution failed: {}", error)); } AgenticEvent::DialogTurnCancelled { .. } => { - println!("\nExecution cancelled"); + self.emit(json!({ + "type": "done", + "session_id": session_id, + "status": "cancelled", + "tool_calls": total_tool_calls, + }))?; + self.print_text(|| println!("\nExecution cancelled")); self.output_patch_if_needed(); return Ok(()); } AgenticEvent::SystemError { error, .. } => { - eprintln!("\nSystem error: {}", error); + self.emit(json!({ + "type": "error", + "session_id": session_id, + "message": error, + }))?; + self.print_text(|| eprintln!("\nSystem error: {}", error)); self.output_patch_if_needed(); return Err(anyhow::anyhow!("System error: {}", error)); } @@ -231,10 +369,125 @@ impl ExecMode { } } + async fn prepare_session(&self) -> Result { + let resume_id = self.session_options.resume.as_deref(); + let workspace = self + .workspace_path + .clone() + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")); + + let resolved_resume = if self.session_options.continue_last || resume_id == Some("last") { + let sessions = self.agent.coordinator().list_sessions(&workspace).await?; + Some( + sessions + .first() + .map(|session| session.session_id.clone()) + .ok_or_else(|| anyhow::anyhow!("No history sessions for current project"))?, + ) + } else { + resume_id.map(ToString::to_string) + }; + + if self.session_options.fork_session { + let source_session_id = resolved_resume + .clone() + .or_else(|| self.session_options.session_id.clone()) + .ok_or_else(|| { + anyhow::anyhow!("--fork-session requires --continue, --resume, or --session") + })?; + let (_session, turns) = self + .agent + .coordinator() + .restore_session_view(&workspace, &source_session_id) + .await?; + let source_turn_id = turns + .last() + .map(|turn| turn.turn_id.clone()) + .ok_or_else(|| anyhow::anyhow!("Session has no persisted turns to fork"))?; + let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let persistence_manager = bitfun_core::agentic::persistence::PersistenceManager::new( + path_manager, + ) + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let result = persistence_manager + .branch_session( + &workspace, + &bitfun_core::agentic::persistence::session_branch::SessionBranchRequest { + source_session_id: source_session_id.clone(), + source_turn_id, + }, + ) + .await?; + self.agent.restore_session(&result.session_id).await?; + return Ok(result.session_id); + } + + if let Some(session_id) = resolved_resume.as_deref() { + self.agent.restore_session(session_id).await?; + return Ok(session_id.to_string()); + } + + if let Some(session_id) = &self.session_options.session_id { + return self + .agent + .create_session_with_id(session_id.clone(), &self.agent_type) + .await; + } + + self.agent.ensure_session(&self.agent_type).await + } + + fn emit(&self, value: serde_json::Value) -> Result<()> { + match self.output_format { + ExecOutputFormat::Text => {} + ExecOutputFormat::StreamJson => { + println!("{}", serde_json::to_string(&value)?); + } + ExecOutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&value)?); + } + } + Ok(()) + } + + fn print_text(&self, f: impl FnOnce()) { + if self.output_format == ExecOutputFormat::Text { + f(); + } + } + fn output_patch_if_needed(&self) { if let Some(ref output_target) = self.output_patch { - println!("\n--- Generating Patch ---"); if let Some(patch) = self.get_git_diff() { + let status = if patch.trim().is_empty() { + "empty" + } else { + "generated" + }; + let patch_value = json!({ + "type": "patch", + "target": output_target, + "status": status, + "patch": if output_target == "-" { Some(patch.as_str()) } else { None }, + "bytes": patch.len(), + }); + + if self.emit(patch_value).is_err() { + eprintln!("Failed to emit patch event"); + } + + if self.output_format != ExecOutputFormat::Text { + if output_target != "-" && !patch.trim().is_empty() { + if let Err(e) = std::fs::write(output_target, &patch) { + eprintln!("Failed to save patch: {}", e); + } + } + return; + } + + println!("\n--- Generating Patch ---"); if patch.trim().is_empty() { println!("(No file modifications)"); } else if output_target == "-" { @@ -256,7 +509,15 @@ impl ExecMode { } } } else { - println!("(Unable to generate patch)"); + let value = json!({ + "type": "patch", + "target": output_target, + "status": "unavailable", + }); + if self.emit(value).is_err() { + eprintln!("Failed to emit patch event"); + } + self.print_text(|| println!("(Unable to generate patch)")); } } } From 97897631ecbb8cb8ea989f79fd100bc3be685ac2 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Thu, 21 May 2026 17:17:41 +0800 Subject: [PATCH 19/38] feat(cli): add management commands and split root handlers --- src/apps/cli/src/agent/agentic_system.rs | 17 + src/apps/cli/src/main.rs | 471 +++++------------- src/apps/cli/src/management.rs | 269 ++++++++++ src/apps/cli/src/root_handlers.rs | 356 +++++++++++++ .../src/service/token_usage/subscriber.rs | 4 +- 5 files changed, 763 insertions(+), 354 deletions(-) create mode 100644 src/apps/cli/src/management.rs create mode 100644 src/apps/cli/src/root_handlers.rs diff --git a/src/apps/cli/src/agent/agentic_system.rs b/src/apps/cli/src/agent/agentic_system.rs index a8053f0036..76b7ca8524 100644 --- a/src/apps/cli/src/agent/agentic_system.rs +++ b/src/apps/cli/src/agent/agentic_system.rs @@ -1 +1,18 @@ +use anyhow::{Context, Result}; + +use bitfun_core::infrastructure::ai::AIClientFactory; +use bitfun_core::service::config::initialize_global_config; + pub use bitfun_core::agentic::system::{init_agentic_system, AgenticSystem}; + +pub async fn init_agentic_system_for_cli() -> Result { + initialize_global_config() + .await + .context("Failed to initialize global config service")?; + AIClientFactory::initialize_global() + .await + .context("Failed to initialize global AIClientFactory")?; + init_agentic_system() + .await + .context("Failed to initialize agentic system") +} diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 180793c753..098833d2cc 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -10,20 +10,21 @@ mod agent; mod chat_state; mod commands; mod config; +mod management; mod modes; mod prompts; +mod root_handlers; mod ui; -use anyhow::{Context, Result}; +use anyhow::Result; use clap::{Parser, Subcommand}; -use std::io::IsTerminal; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::OnceLock; use config::CliConfig; use modes::chat::ChatMode; -use modes::exec::{ExecMode, ExecOutputFormat, ExecSessionOptions}; +use modes::exec::ExecOutputFormat; // ======================== Global MCP Service ======================== @@ -127,6 +128,30 @@ enum Commands { action: SessionAction, }, + /// Agent management + Agents, + + /// Model management + Models { + #[command(subcommand)] + action: Option, + }, + + /// MCP server management + Mcp { + #[command(subcommand)] + action: Option, + }, + + /// Usage reporting + Usage { + /// Session ID to inspect; defaults to the most recent session in the current workspace + session_id: Option, + }, + + /// Diagnostic check + Doctor, + /// Configuration management Config { #[command(subcommand)] @@ -143,6 +168,37 @@ enum Commands { }, } +#[derive(Subcommand)] +enum ModelAction { + /// List configured models + List, + /// Set the default model for all modes + SetDefault { + /// Model id + model_id: String, + }, +} + +#[derive(Subcommand)] +enum McpAction { + /// List configured MCP servers + List, + /// Check MCP readiness + Doctor, + /// Enable an MCP server by id + Enable { + /// MCP server id + server_id: String, + }, + /// Disable an MCP server by id + Disable { + /// MCP server id + server_id: String, + }, + /// Print the stored MCP JSON config + Config, +} + #[derive(Subcommand)] enum AcpAction { /// Start the ACP server over stdio @@ -474,22 +530,12 @@ async fn main() -> Result<()> { let cli = Cli::parse(); let is_tui_mode = matches!(cli.command, None | Some(Commands::Chat { .. })); - let is_acp_command = matches!(cli.command, Some(Commands::Acp { .. })); - let is_acp_serve = matches!( - cli.command, - Some(Commands::Acp { action: None }) - | Some(Commands::Acp { - action: Some(AcpAction::Serve), - }) - ); let log_level = if cli.verbose { tracing::Level::DEBUG - } else if is_acp_serve { - tracing::Level::WARN - } else if is_acp_command { - tracing::Level::ERROR - } else { + } else if is_tui_mode { tracing::Level::INFO + } else { + tracing::Level::ERROR }; if is_tui_mode { @@ -516,18 +562,13 @@ async fn main() -> Result<()> { .with_target(false) .init(); } - } else if is_acp_command { + } else { tracing_subscriber::fmt() .with_max_level(log_level) .with_writer(std::io::stderr) .with_ansi(false) .with_target(false) .init(); - } else { - tracing_subscriber::fmt() - .with_max_level(log_level) - .with_target(false) - .init(); } let config = CliConfig::load().unwrap_or_else(|e| { @@ -556,94 +597,82 @@ async fn main() -> Result<()> { output_patch, confirm, }) => { - let workspace_path_resolved = std::env::current_dir().ok(); - - if let Some(ref ws_path) = workspace_path_resolved { - tracing::info!("Workspace path set: {:?}", ws_path); - } - - let message = resolve_exec_message(message)?; - let resume = match (resume, session) { - (Some(_), Some(_)) => { - anyhow::bail!("Use only one of --resume or --session"); - } - (Some(value), None) | (None, Some(value)) => Some(value), - (None, None) => None, - }; - if session_id.is_some() && (continue_last || resume.is_some()) { - anyhow::bail!("--session-id cannot be combined with --continue, --resume, or --session"); - } - if fork_session && session_id.is_some() { - anyhow::bail!("--fork-session cannot be combined with --session-id"); - } - - let skip_confirmation = !confirm; - let (agentic_system, original_skip_confirmation) = - initialize_core_services(skip_confirmation).await?; - - let mut exec_mode = ExecMode::new( + root_handlers::handle_exec_command( config, - message, - agent, - &agentic_system, - workspace_path_resolved, - output_patch, - output_format, - ExecSessionOptions { - resume, + root_handlers::ExecCommandArgs { + message, + agent, continue_last, + resume, + session, session_id, fork_session, + output_format, + output_patch, + confirm, }, - ); - let run_result = exec_mode.run().await; - - shutdown_mcp_servers().await; - restore_tool_confirmation(original_skip_confirmation).await; - - run_result?; + ) + .await?; } Some(Commands::Sessions { action }) => { - if let Some(session_id) = handle_session_action(action).await? { + if let Some(session_id) = root_handlers::handle_session_action(action).await? { run_interactive_with_session(config, session_id).await?; } } + Some(Commands::Agents) => { + let workspace = std::env::current_dir()?; + management::print_agents(Some(workspace.as_path())).await?; + } + + Some(Commands::Models { action }) => match action { + None | Some(ModelAction::List) => management::print_models().await?, + Some(ModelAction::SetDefault { model_id }) => { + management::set_default_model(&model_id).await?; + } + }, + + Some(Commands::Mcp { action }) => match action { + None | Some(McpAction::List) => management::print_mcp_servers().await?, + Some(McpAction::Doctor) => { + if !management::print_doctor().await? { + std::process::exit(1); + } + } + Some(McpAction::Enable { server_id }) => { + management::set_mcp_server_enabled(&server_id, true).await?; + } + Some(McpAction::Disable { server_id }) => { + management::set_mcp_server_enabled(&server_id, false).await?; + } + Some(McpAction::Config) => { + management::print_mcp_json_config().await?; + } + }, + + Some(Commands::Usage { session_id }) => { + management::print_usage_report(session_id.as_deref()).await?; + } + + Some(Commands::Doctor) => { + if !management::print_doctor().await? { + std::process::exit(1); + } + } + Some(Commands::Config { action }) => { - handle_config_action(action, &config)?; + root_handlers::handle_config_action(action, &config)?; } Some(Commands::Health) => { - println!("BitFun CLI is running normally"); - println!("Version: {}", env!("CARGO_PKG_VERSION")); - println!("Config directory: {:?}", CliConfig::config_dir()?); + root_handlers::handle_health_command()?; } Some(Commands::Acp { action: None | Some(AcpAction::Serve), }) => { - setup_workspace(); - - bitfun_core::service::config::initialize_global_config() - .await - .context("Failed to initialize global config service")?; - tracing::info!("Global config service initialized"); - - use bitfun_core::infrastructure::ai::AIClientFactory; - AIClientFactory::initialize_global() - .await - .context("Failed to initialize global AIClientFactory")?; - tracing::info!("Global AI client factory initialized"); - - initialize_terminal_service().await; - - let agentic_system = agent::agentic_system::init_agentic_system() - .await - .context("Failed to initialize agentic system")?; - tracing::info!("Agentic system initialized"); - - bitfun_acp::BitfunAcpRuntime::serve_stdio(agentic_system).await?; + root_handlers::serve_acp_stdio().await?; } Some(Commands::Acp { @@ -709,202 +738,6 @@ async fn main() -> Result<()> { Ok(()) } -async fn handle_session_action(action: SessionAction) -> Result> { - // Initialize core services for session management - bitfun_core::service::config::initialize_global_config() - .await - .expect("Failed to initialize global config service"); - - let agentic_system = agent::agentic_system::init_agentic_system() - .await - .expect("Failed to initialize agentic system"); - - let coordinator = agentic_system.coordinator.clone(); - let workspace_path = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); - - match action { - SessionAction::List => { - let sessions = coordinator.list_sessions(&workspace_path).await?; - - if sessions.is_empty() { - println!( - "No history sessions for current project: {}", - workspace_path.display() - ); - return Ok(None); - } - - println!( - "History sessions for current project (total {})", - sessions.len() - ); - println!("Project: {}\n", workspace_path.display()); - - for (i, info) in sessions.iter().enumerate() { - let last_updated = { - let duration = info - .last_activity_at - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default(); - let secs = duration.as_secs() as i64; - chrono::DateTime::from_timestamp(secs, 0) - .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) - .unwrap_or_else(|| "unknown".to_string()) - }; - - println!("{}. {} (ID: {})", i + 1, info.session_name, info.session_id); - println!( - " Agent: {} | Turns: {} | Updated: {}", - info.agent_type, info.turn_count, last_updated - ); - println!(); - } - } - - SessionAction::Show { id } => { - let sessions = coordinator.list_sessions(&workspace_path).await?; - - let session_id = if id == "last" { - sessions - .first() - .map(|s| s.session_id.clone()) - .ok_or_else(|| anyhow::anyhow!("No history sessions"))? - } else { - id - }; - - // Restore and show session details - let session = coordinator - .restore_session(&workspace_path, &session_id) - .await?; - let messages = coordinator.get_messages(&session_id).await?; - - println!("Session Details\n"); - println!("Name: {}", session.session_name); - println!("ID: {}", session.session_id); - println!("Agent: {}", session.agent_type); - println!("State: {:?}", session.state); - println!("Messages: {}", messages.len()); - println!(); - - if !messages.is_empty() { - println!("Recent messages:"); - let recent: Vec<_> = messages.iter().rev().take(5).collect(); - for msg in recent.iter().rev() { - let role = format!("{:?}", msg.role); - let content_preview = match &msg.content { - bitfun_core::agentic::core::message::MessageContent::Text(text) => { - text.lines().next().unwrap_or("").to_string() - } - bitfun_core::agentic::core::message::MessageContent::Multimodal { - text, - images, - } => { - if text.is_empty() { - format!("[{} images]", images.len()) - } else { - text.lines().next().unwrap_or("").to_string() - } - } - bitfun_core::agentic::core::message::MessageContent::Mixed { - text, - tool_calls, - .. - } => { - if text.is_empty() { - format!("[{} tool calls]", tool_calls.len()) - } else { - text.lines().next().unwrap_or("").to_string() - } - } - bitfun_core::agentic::core::message::MessageContent::ToolResult { - tool_name, - .. - } => { - format!("[Tool result: {}]", tool_name) - } - }; - let preview = if content_preview.len() > 80 { - format!("{}...", &content_preview[..77]) - } else { - content_preview - }; - println!(" [{}] {}", role, preview); - } - } - } - - SessionAction::Delete { id } => { - coordinator.delete_session(&workspace_path, &id).await?; - println!("Deleted session from current project: {}", id); - } - - SessionAction::Resume { id } => { - let session_id = resolve_cli_session_id(&coordinator, &workspace_path, &id).await?; - return Ok(Some(session_id)); - } - - SessionAction::Continue => { - let session_id = resolve_cli_session_id(&coordinator, &workspace_path, "last").await?; - return Ok(Some(session_id)); - } - - SessionAction::Fork { id, id_only } => { - let session_id = resolve_cli_session_id(&coordinator, &workspace_path, &id).await?; - let (_session, turns) = coordinator - .restore_session_view(&workspace_path, &session_id) - .await?; - let source_turn_id = turns - .last() - .map(|turn| turn.turn_id.clone()) - .ok_or_else(|| anyhow::anyhow!("Session has no persisted turns to fork"))?; - let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() - .map_err(|error| anyhow::anyhow!(error.to_string()))?; - let persistence_manager = bitfun_core::agentic::persistence::PersistenceManager::new( - path_manager, - ) - .map_err(|error| anyhow::anyhow!(error.to_string()))?; - let result = persistence_manager - .branch_session( - &workspace_path, - &bitfun_core::agentic::persistence::session_branch::SessionBranchRequest { - source_session_id: session_id.clone(), - source_turn_id, - }, - ) - .await?; - - if id_only { - println!("{}", result.session_id); - } else { - println!("Forked session"); - println!("Source ID: {}", session_id); - println!("New ID: {}", result.session_id); - println!("Name: {}", result.session_name); - println!("Agent: {}", result.agent_type); - } - } - } - - Ok(None) -} - -async fn resolve_cli_session_id( - coordinator: &std::sync::Arc, - workspace_path: &Path, - id: &str, -) -> Result { - if id == "last" { - let sessions = coordinator.list_sessions(workspace_path).await?; - return sessions - .first() - .map(|session| session.session_id.clone()) - .ok_or_else(|| anyhow::anyhow!("No history sessions")); - } - - Ok(id.to_string()) -} - async fn run_interactive_with_session(config: CliConfig, session_id: String) -> Result<()> { let mut terminal = ui::init_terminal()?; ui::render_loading(&mut terminal, "Initializing system, please wait...")?; @@ -933,69 +766,3 @@ async fn run_interactive_with_session(config: CliConfig, session_id: String) -> Ok(()) } -fn resolve_exec_message(message: Option) -> Result { - let mut combined = message.unwrap_or_default(); - if !std::io::stdin().is_terminal() { - use std::io::Read; - let mut stdin_content = String::new(); - std::io::stdin().read_to_string(&mut stdin_content)?; - let stdin_content = stdin_content.trim_end().to_string(); - if !stdin_content.is_empty() { - if combined.is_empty() { - combined = stdin_content; - } else { - combined.push('\n'); - combined.push_str(&stdin_content); - } - } - } - - let message = combined.trim().to_string(); - if message.is_empty() { - anyhow::bail!("Prompt cannot be empty"); - } - - Ok(message) -} - -fn handle_config_action(action: ConfigAction, config: &CliConfig) -> Result<()> { - match action { - ConfigAction::Show => { - println!("Current Configuration\n"); - println!("Note: AI model configuration is now managed via GlobalConfig"); - println!("View and manage at: Main Menu -> Settings -> AI Model Configuration"); - println!(); - println!("UI Configuration:"); - println!(" Appearance: {}", config.ui.theme); - println!(" Theme ID: {}", config.ui.theme_id); - println!(" Color scheme: {}", config.ui.color_scheme); - println!(" Show tips: {}", config.ui.show_tips); - println!(" Animation: {}", config.ui.animation); - println!(); - println!("Behavior Configuration:"); - println!(" Auto save: {}", config.behavior.auto_save); - println!(" Confirm dangerous: {}", config.behavior.confirm_dangerous); - println!(" Default Agent: {}", config.behavior.default_agent); - println!(); - println!("Config file: {:?}", CliConfig::config_path()?); - } - - ConfigAction::Edit => { - let config_path = CliConfig::config_path()?; - println!("Config file location: {:?}", config_path); - println!(); - println!("Please use a text editor to edit the config file:"); - println!(" vi {:?}", config_path); - println!(" or"); - println!(" code {:?}", config_path); - } - - ConfigAction::Reset => { - let default_config = CliConfig::default(); - default_config.save()?; - println!("Reset to default configuration"); - } - } - - Ok(()) -} diff --git a/src/apps/cli/src/management.rs b/src/apps/cli/src/management.rs new file mode 100644 index 0000000000..24495e6d97 --- /dev/null +++ b/src/apps/cli/src/management.rs @@ -0,0 +1,269 @@ +use anyhow::{anyhow, Context, Result}; +use std::path::Path; +use std::time::Duration; + +use bitfun_core::agentic::get_agent_registry; +use bitfun_core::agentic::persistence::PersistenceManager; +use bitfun_core::infrastructure::try_get_path_manager_arc; +use bitfun_core::service::config::initialize_global_config; +use bitfun_core::service::session_usage::{ + generate_session_usage_report, render_usage_report_markdown, SessionUsageReportRequest, +}; + + +async fn ensure_global_config_service() -> Result< + std::sync::Arc, +> { + initialize_global_config() + .await + .context("Failed to initialize global config service")?; + bitfun_core::service::config::get_global_config_service() + .await + .context("Failed to get global config service") +} + +pub async fn print_agents(workspace: Option<&Path>) -> Result<()> { + let registry = get_agent_registry(); + let modes = registry.get_modes_info().await; + let subagents = registry.get_subagents_info(workspace).await; + + println!("Agent modes"); + println!(); + if modes.is_empty() { + println!("No agent modes found."); + } else { + for agent in modes { + println!( + "- {}: {} (tools: {}, readonly: {}, review: {})", + agent.id, agent.name, agent.tool_count, agent.is_readonly, agent.is_review + ); + if !agent.description.is_empty() { + println!(" {}", agent.description); + } + } + } + + println!(); + println!("Subagents"); + println!(); + if subagents.is_empty() { + println!("No subagents found for the current workspace."); + } else { + for agent in subagents { + println!( + "- {}: {} (tools: {}, enabled: {}, readonly: {}, review: {})", + agent.id, + agent.name, + agent.tool_count, + agent.effective_enabled, + agent.is_readonly, + agent.is_review + ); + if !agent.description.is_empty() { + println!(" {}", agent.description); + } + } + } + + Ok(()) +} + +pub async fn print_models() -> Result<()> { + let config_service = ensure_global_config_service().await?; + let models = config_service.get_ai_models().await?; + let global_config: bitfun_core::service::config::GlobalConfig = + config_service.get_config(None).await?; + + let primary_model_id = global_config.ai.default_models.primary.clone(); + + println!("AI models"); + println!(); + if models.is_empty() { + println!("No AI models configured."); + return Ok(()); + } + + for model in models { + let is_primary = primary_model_id.as_deref() == Some(model.id.as_str()); + let current_modes: Vec = global_config + .ai + .agent_models + .iter() + .filter_map(|(mode, model_id)| (model_id == &model.id).then_some(mode.clone())) + .collect(); + + println!( + "- {}{} ({})", + if is_primary { "* " } else { " " }, + model.id, + if model.enabled { "enabled" } else { "disabled" } + ); + println!(" Name: {}", model.name); + println!(" Provider: {}", model.provider); + println!(" Model: {}", model.model_name); + if !current_modes.is_empty() { + println!(" Used by modes: {}", current_modes.join(", ")); + } + } + + Ok(()) +} + +pub async fn print_mcp_servers() -> Result<()> { + let config_service = ensure_global_config_service().await?; + let mcp_service = bitfun_core::service::mcp::MCPService::new(config_service.clone()) + .map_err(|error| anyhow!(error.to_string()))?; + let configs = mcp_service.config_service().load_all_configs().await?; + + println!("MCP servers"); + println!(); + if configs.is_empty() { + println!("No MCP servers configured."); + return Ok(()); + } + + for config in configs { + let status = if config.enabled { + match tokio::time::timeout( + Duration::from_millis(30), + mcp_service.server_manager().get_server_status(&config.id), + ) + .await + { + Ok(Ok(status)) => format!("{:?}", status), + Ok(Err(_)) => "Unknown".to_string(), + Err(_) => "Starting".to_string(), + } + } else { + "Disabled".to_string() + }; + + let endpoint = match config.server_type { + bitfun_core::service::mcp::server::MCPServerType::Local => config + .command + .as_ref() + .map(|cmd| format!("{} {}", cmd, config.args.join(" "))) + .unwrap_or_else(|| "".to_string()), + bitfun_core::service::mcp::server::MCPServerType::Remote => { + config.url.clone().unwrap_or_else(|| "".to_string()) + } + }; + + println!("- {} ({:?})", config.id, config.server_type); + println!(" Name: {}", config.name); + println!(" Status: {}", status); + println!(" Enabled: {}", config.enabled); + println!(" Endpoint: {}", endpoint); + } + + Ok(()) +} + +pub async fn set_default_model(model_id: &str) -> Result<()> { + let config_service = ensure_global_config_service().await?; + let agent_registry = get_agent_registry(); + let modes = agent_registry.get_modes_info().await; + + config_service + .set_config("ai.default_models.primary", model_id) + .await?; + for mode in modes { + let path = format!("ai.agent_models.{}", mode.id); + config_service.set_config(&path, model_id).await?; + } + + println!("Default model set to: {}", model_id); + Ok(()) +} + +pub async fn set_mcp_server_enabled(server_id: &str, enabled: bool) -> Result<()> { + let config_service = ensure_global_config_service().await?; + let mcp_service = bitfun_core::service::mcp::MCPService::new(config_service.clone()) + .map_err(|error| anyhow!(error.to_string()))?; + let mut config = mcp_service + .config_service() + .get_server_config(server_id) + .await? + .ok_or_else(|| anyhow!("MCP server not found: {}", server_id))?; + config.enabled = enabled; + mcp_service.config_service().save_server_config(&config).await?; + + println!( + "MCP server {} {}.", + server_id, + if enabled { "enabled" } else { "disabled" } + ); + Ok(()) +} + +pub async fn print_mcp_json_config() -> Result<()> { + let config_service = ensure_global_config_service().await?; + let mcp_service = bitfun_core::service::mcp::MCPService::new(config_service.clone()) + .map_err(|error| anyhow!(error.to_string()))?; + let json = mcp_service + .config_service() + .load_mcp_json_config() + .await?; + println!("{}", json); + Ok(()) +} + +pub async fn print_usage_report(session_id: Option<&str>) -> Result<()> { + let agentic_system = crate::agent::agentic_system::init_agentic_system_for_cli().await?; + let path_manager = try_get_path_manager_arc().map_err(|error| anyhow!(error.to_string()))?; + let persistence_manager = PersistenceManager::new(path_manager) + .map_err(|error| anyhow!(error.to_string()))?; + let workspace_path = std::env::current_dir().context("Failed to resolve current directory")?; + let coordinator = agentic_system.coordinator.clone(); + let resolved_session_id = match session_id { + Some(session_id) if !session_id.trim().is_empty() => session_id.to_string(), + _ => coordinator + .list_sessions(&workspace_path) + .await? + .first() + .map(|session| session.session_id.clone()) + .ok_or_else(|| anyhow!("No history sessions for current project"))?, + }; + + let report = generate_session_usage_report( + &persistence_manager, + Some(agentic_system.token_usage_service.as_ref()), + SessionUsageReportRequest { + session_id: resolved_session_id, + workspace_path: Some(workspace_path.to_string_lossy().to_string()), + remote_connection_id: None, + remote_ssh_host: None, + include_hidden_subagents: true, + }, + ) + .await + .map_err(|error| anyhow!(error.to_string()))?; + + println!("{}", render_usage_report_markdown(&report)); + Ok(()) +} + +pub async fn print_doctor() -> Result { + let workspace = std::env::current_dir().context("Failed to resolve current directory")?; + let config_dir = crate::config::CliConfig::config_dir()?; + let config_service = ensure_global_config_service().await?; + let models = config_service.get_ai_models().await?; + let agent_registry = get_agent_registry(); + let modes = agent_registry.get_modes_info().await; + let subagents = agent_registry.get_subagents_info(Some(workspace.as_path())).await; + let mcp_service = bitfun_core::service::mcp::MCPService::new(config_service.clone()) + .map_err(|error| anyhow!(error.to_string()))?; + let mcp_configs = mcp_service.config_service().load_all_configs().await?; + + println!("BitFun CLI doctor"); + println!(); + println!("[ok] Workspace: {}", workspace.display()); + println!("[ok] Config directory: {}", config_dir.display()); + println!("[ok] Agent modes: {}", modes.len()); + println!("[ok] Subagents: {}", subagents.len()); + println!("[ok] AI models: {} total, {} enabled", models.len(), models.iter().filter(|m| m.enabled).count()); + println!("[ok] MCP servers: {}", mcp_configs.len()); + println!(); + println!("Doctor checks passed."); + Ok(true) +} diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs new file mode 100644 index 0000000000..c80ae51b59 --- /dev/null +++ b/src/apps/cli/src/root_handlers.rs @@ -0,0 +1,356 @@ +use anyhow::{Context, Result}; + +use std::io::IsTerminal; +use std::path::Path; + +use crate::{ + config::CliConfig, + modes::exec::{ExecMode, ExecOutputFormat, ExecSessionOptions}, + ConfigAction, SessionAction, +}; + +pub struct ExecCommandArgs { + pub message: Option, + pub agent: String, + pub continue_last: bool, + pub resume: Option, + pub session: Option, + pub session_id: Option, + pub fork_session: bool, + pub output_format: ExecOutputFormat, + pub output_patch: Option, + pub confirm: bool, +} + +pub async fn handle_exec_command(config: CliConfig, args: ExecCommandArgs) -> Result<()> { + let workspace_path_resolved = std::env::current_dir().ok(); + + if let Some(ref ws_path) = workspace_path_resolved { + tracing::info!("Workspace path set: {:?}", ws_path); + } + + let message = resolve_exec_message(args.message)?; + let resume = match (args.resume, args.session) { + (Some(_), Some(_)) => { + anyhow::bail!("Use only one of --resume or --session"); + } + (Some(value), None) | (None, Some(value)) => Some(value), + (None, None) => None, + }; + if args.session_id.is_some() && (args.continue_last || resume.is_some()) { + anyhow::bail!("--session-id cannot be combined with --continue, --resume, or --session"); + } + if args.fork_session && args.session_id.is_some() { + anyhow::bail!("--fork-session cannot be combined with --session-id"); + } + + let skip_confirmation = !args.confirm; + let (agentic_system, original_skip_confirmation) = + crate::initialize_core_services(skip_confirmation).await?; + + let mut exec_mode = ExecMode::new( + config, + message, + args.agent, + &agentic_system, + workspace_path_resolved, + args.output_patch, + args.output_format, + ExecSessionOptions { + resume, + continue_last: args.continue_last, + session_id: args.session_id, + fork_session: args.fork_session, + }, + ); + let run_result = exec_mode.run().await; + + crate::shutdown_mcp_servers().await; + crate::restore_tool_confirmation(original_skip_confirmation).await; + + run_result +} + +fn resolve_exec_message(message: Option) -> Result { + let mut combined = message.unwrap_or_default(); + if !std::io::stdin().is_terminal() { + use std::io::Read; + let mut stdin_content = String::new(); + std::io::stdin().read_to_string(&mut stdin_content)?; + let stdin_content = stdin_content.trim_end().to_string(); + if !stdin_content.is_empty() { + if combined.is_empty() { + combined = stdin_content; + } else { + combined.push('\n'); + combined.push_str(&stdin_content); + } + } + } + + let message = combined.trim().to_string(); + if message.is_empty() { + anyhow::bail!("Prompt cannot be empty"); + } + + Ok(message) +} + +pub async fn handle_session_action(action: SessionAction) -> Result> { + let agentic_system = crate::agent::agentic_system::init_agentic_system_for_cli().await?; + + let coordinator = agentic_system.coordinator.clone(); + let workspace_path = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + + match action { + SessionAction::List => { + let sessions = coordinator.list_sessions(&workspace_path).await?; + + if sessions.is_empty() { + println!( + "No history sessions for current project: {}", + workspace_path.display() + ); + return Ok(None); + } + + println!( + "History sessions for current project (total {})", + sessions.len() + ); + println!("Project: {}\n", workspace_path.display()); + + for (i, info) in sessions.iter().enumerate() { + let last_updated = { + let duration = info + .last_activity_at + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let secs = duration.as_secs() as i64; + chrono::DateTime::from_timestamp(secs, 0) + .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|| "unknown".to_string()) + }; + + println!("{}. {} (ID: {})", i + 1, info.session_name, info.session_id); + println!( + " Agent: {} | Turns: {} | Updated: {}", + info.agent_type, info.turn_count, last_updated + ); + println!(); + } + } + + SessionAction::Show { id } => { + let sessions = coordinator.list_sessions(&workspace_path).await?; + + let session_id = if id == "last" { + sessions + .first() + .map(|s| s.session_id.clone()) + .ok_or_else(|| anyhow::anyhow!("No history sessions"))? + } else { + id + }; + + let session = coordinator + .restore_session(&workspace_path, &session_id) + .await?; + let messages = coordinator.get_messages(&session_id).await?; + + println!("Session Details\n"); + println!("Name: {}", session.session_name); + println!("ID: {}", session.session_id); + println!("Agent: {}", session.agent_type); + println!("State: {:?}", session.state); + println!("Messages: {}", messages.len()); + println!(); + + if !messages.is_empty() { + println!("Recent messages:"); + let recent: Vec<_> = messages.iter().rev().take(5).collect(); + for msg in recent.iter().rev() { + let role = format!("{:?}", msg.role); + let content_preview = match &msg.content { + bitfun_core::agentic::core::message::MessageContent::Text(text) => { + text.lines().next().unwrap_or("").to_string() + } + bitfun_core::agentic::core::message::MessageContent::Multimodal { + text, + images, + } => { + if text.is_empty() { + format!("[{} images]", images.len()) + } else { + text.lines().next().unwrap_or("").to_string() + } + } + bitfun_core::agentic::core::message::MessageContent::Mixed { + text, + tool_calls, + .. + } => { + if text.is_empty() { + format!("[{} tool calls]", tool_calls.len()) + } else { + text.lines().next().unwrap_or("").to_string() + } + } + bitfun_core::agentic::core::message::MessageContent::ToolResult { + tool_name, + .. + } => format!("[Tool result: {}]", tool_name), + }; + let preview = if content_preview.len() > 80 { + format!("{}...", &content_preview[..77]) + } else { + content_preview + }; + println!(" [{}] {}", role, preview); + } + } + } + + SessionAction::Delete { id } => { + coordinator.delete_session(&workspace_path, &id).await?; + println!("Deleted session from current project: {}", id); + } + + SessionAction::Resume { id } => { + let session_id = resolve_cli_session_id(&coordinator, &workspace_path, &id).await?; + return Ok(Some(session_id)); + } + + SessionAction::Continue => { + let session_id = resolve_cli_session_id(&coordinator, &workspace_path, "last").await?; + return Ok(Some(session_id)); + } + + SessionAction::Fork { id, id_only } => { + let session_id = resolve_cli_session_id(&coordinator, &workspace_path, &id).await?; + let (_session, turns) = coordinator + .restore_session_view(&workspace_path, &session_id) + .await?; + let source_turn_id = turns + .last() + .map(|turn| turn.turn_id.clone()) + .ok_or_else(|| anyhow::anyhow!("Session has no persisted turns to fork"))?; + let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let persistence_manager = bitfun_core::agentic::persistence::PersistenceManager::new( + path_manager, + ) + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let result = persistence_manager + .branch_session( + &workspace_path, + &bitfun_core::agentic::persistence::session_branch::SessionBranchRequest { + source_session_id: session_id.clone(), + source_turn_id, + }, + ) + .await?; + + if id_only { + println!("{}", result.session_id); + } else { + println!("Forked session"); + println!("Source ID: {}", session_id); + println!("New ID: {}", result.session_id); + println!("Name: {}", result.session_name); + println!("Agent: {}", result.agent_type); + } + } + } + + Ok(None) +} + +async fn resolve_cli_session_id( + coordinator: &std::sync::Arc, + workspace_path: &Path, + id: &str, +) -> Result { + if id == "last" { + let sessions = coordinator.list_sessions(workspace_path).await?; + return sessions + .first() + .map(|session| session.session_id.clone()) + .ok_or_else(|| anyhow::anyhow!("No history sessions")); + } + + Ok(id.to_string()) +} + +pub fn handle_config_action(action: ConfigAction, config: &CliConfig) -> Result<()> { + match action { + ConfigAction::Show => { + println!("Current Configuration\n"); + println!("Note: AI model configuration is managed via GlobalConfig"); + println!(); + println!("UI Configuration:"); + println!(" Appearance: {}", config.ui.theme); + println!(" Theme ID: {}", config.ui.theme_id); + println!(" Color scheme: {}", config.ui.color_scheme); + println!(" Show tips: {}", config.ui.show_tips); + println!(" Animation: {}", config.ui.animation); + println!(); + println!("Behavior Configuration:"); + println!(" Auto save: {}", config.behavior.auto_save); + println!(" Confirm dangerous: {}", config.behavior.confirm_dangerous); + println!(" Default Agent: {}", config.behavior.default_agent); + println!(); + println!("Config file: {:?}", CliConfig::config_path()?); + } + + ConfigAction::Edit => { + let config_path = CliConfig::config_path()?; + println!("Config file location: {:?}", config_path); + println!(); + println!("Please use a text editor to edit the config file:"); + println!(" vi {:?}", config_path); + println!(" or"); + println!(" code {:?}", config_path); + } + + ConfigAction::Reset => { + let default_config = CliConfig::default(); + default_config.save()?; + println!("Reset to default configuration"); + } + } + + Ok(()) +} + +pub fn handle_health_command() -> Result<()> { + println!("BitFun CLI is running normally"); + println!("Version: {}", env!("CARGO_PKG_VERSION")); + println!("Config directory: {:?}", CliConfig::config_dir()?); + Ok(()) +} + +pub async fn serve_acp_stdio() -> Result<()> { + crate::setup_workspace(); + + bitfun_core::service::config::initialize_global_config() + .await + .context("Failed to initialize global config service")?; + tracing::info!("Global config service initialized"); + + use bitfun_core::infrastructure::ai::AIClientFactory; + AIClientFactory::initialize_global() + .await + .context("Failed to initialize global AIClientFactory")?; + tracing::info!("Global AI client factory initialized"); + + crate::initialize_terminal_service().await; + + let agentic_system = crate::agent::agentic_system::init_agentic_system() + .await + .context("Failed to initialize agentic system")?; + tracing::info!("Agentic system initialized"); + + bitfun_acp::BitfunAcpRuntime::serve_stdio(agentic_system).await?; + Ok(()) +} diff --git a/src/crates/core/src/service/token_usage/subscriber.rs b/src/crates/core/src/service/token_usage/subscriber.rs index db1f685632..c263adbc11 100644 --- a/src/crates/core/src/service/token_usage/subscriber.rs +++ b/src/crates/core/src/service/token_usage/subscriber.rs @@ -3,7 +3,7 @@ use crate::agentic::events::{AgenticEvent, EventSubscriber}; use crate::service::token_usage::TokenUsageService; use crate::util::errors::BitFunResult; -use log::{debug, error}; +use log::{debug, warn}; use std::sync::Arc; /// Token usage event subscriber @@ -65,7 +65,7 @@ impl EventSubscriber for TokenUsageSubscriber { ) .await { - error!("Failed to record token usage: {}", e); + warn!("Failed to record token usage: {}", e); } } From 6bcbbcf38bdfbbbd5c363074b520536cf65a1572 Mon Sep 17 00:00:00 2001 From: limityan Date: Thu, 21 May 2026 20:37:55 +0800 Subject: [PATCH 20/38] refactor(core): narrow no-default dependency profile Move product/runtime-only direct dependencies behind existing feature gates while keeping product-full as the default complete runtime assembly. Document that service-integrations is still product-full-bound, add no-default dependency checks, enforce explicit feature owners for optional runtime dependencies, guard product entry crates so their bitfun-core dependency keeps explicit product-full assembly, scan product entry manifests for new direct core dependency coverage, and keep owner crate default/product-full feature graphs explicit. --- docs/architecture/core-decomposition.md | 25 + docs/plans/core-decomposition-plan.md | 41 +- scripts/check-core-boundaries.mjs | 558 ++++++++++++++++++++- src/crates/core/AGENTS.md | 19 + src/crates/core/Cargo.toml | 101 ++-- src/crates/product-domains/AGENTS.md | 2 + src/crates/services-integrations/AGENTS.md | 2 + src/crates/tool-packs/AGENTS.md | 3 +- 8 files changed, 708 insertions(+), 43 deletions(-) diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index 28495aeab4..61db8b431c 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -364,6 +364,31 @@ BitFun 的重构目标不是把 Desktop、CLI、Remote、Server 和 ACP 强行 dependency graph 或构建收益已经完成优化。agentic runtime、MiniApp/function-agent、 Git/MCP/remote-connect/review-platform 等完整产品入口必须继续由 `product-full` 或对应 owner feature 打开。 +- H5 direct-dependency profile 只能把源码已经由 `product-full`、`service-integrations` + 或 `ssh-remote` 门禁保护的 runtime 依赖改成 optional,并由完整产品 feature 显式启用。 + 当前 no-default 直连层已移除 tool/runtime、snapshot/cron、Git/MCP/remote-connect、 + image/browser-control 和 relay 相关 product-only 依赖,但 `reqwest`、`axum`、 + `tower-http`、`terminal-core`、`zip`、`notify` 等仍因 AI/debug-log/terminal/LSP/search + 等 no-default facade 保留;不得据此声明 `default = []`、per-product feature matrix + 或构建收益已经完成。 +- Boundary check 必须同时保护两个层面:这些 product/runtime 依赖不得回流为 + non-optional dependency,也必须继续由 `product-full`、`service-integrations`、 + `ssh-remote`、`tool-packs` 或 `product-domains` 这些明确 feature owner 显式启用。 + 后续新增 optional dependency 时,必须同步更新 owner feature 规则,避免出现隐式或孤儿 + feature 引用。 +- Boundary check 也必须保护产品入口的完整能力装配:Desktop、CLI、ACP 对 + `bitfun-core` 的依赖必须保持 `default-features = false` 且显式启用 `product-full`, + 避免产品完整 runtime 退回到隐式默认 feature;脚本会扫描产品入口范围内新增的 + `bitfun-core` 依赖,防止遗漏显式装配规则。 +- 在单独完成产品矩阵评审前,Boundary check 必须继续锁定 + `bitfun-core default = ["product-full"]`,不得把默认 feature 变轻作为依赖裁剪的副作用。 +- Boundary check 还必须锁定 owner crate 的 feature graph:`tool-packs`、 + `services-integrations`、`product-domains` 的 `default` 保持空,`product-full` + 只显式聚合当前 owner crate 已声明的 feature group。 +- Core 的 `service-integrations` feature 当前仍是完整 `product-full` runtime assembly 的一部分, + 不是可单独发布或单独验证的产品形态;MCP/remote-connect/review-platform 仍引用 agentic、 + snapshot 或 product execution owner。若未来要让该 feature 独立可编译,必须先做 + port/provider 设计和等价测试,而不能只补 manifest 依赖。 - 拆解完成后不要自动移除或减轻 `product-full`。如果未来要用 per-product explicit feature set 替代它,必须作为 P3 之后的独立评估,并且先通过完整产品矩阵。 - 不要把 feature 默认值变更和模块移动放在同一个变更中。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index df259676ce..5f16da68cb 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -1859,6 +1859,36 @@ Git/MCP/remote-connect/review-platform、snapshot/token/runtime usage 等完整 session/workspace 路径稳定性仍保留在 no-default 编译面;russh-backed SSH/SFTP/terminal/search runtime 仍由 `ssh-remote` 控制。 +**H5 direct-dependency profile(2026-05-21):** 本轮继续把已经由源码 cfg 门禁保护的 +product/runtime 依赖改为 optional,并由 `product-full`、`service-integrations` 或 +`ssh-remote` 显式启用,以保持默认完整产品构建能力不变。当前 no-default 直连依赖层 +不再强制包含 `git2`、`rmcp`、`image`、`tokio-tungstenite`、`tool-runtime`、 +`bitfun-relay-server`、remote-connect 设备/加密/QR 依赖,以及 snapshot/cron/tool +相关 product-only 依赖;`scripts/check-core-boundaries.mjs` 已加入 core +no-default dependency profile 保护,防止这些依赖回流为 non-optional dependency。 +同一脚本也解析 `bitfun-core` 的 `[features]`,要求上述 optional dependency 继续由 +明确 owner feature 显式启用;新增 optional runtime dependency 时必须同时补 +feature-owner 规则和自测,避免 manifest 里出现孤儿 optional dependency 或错挂到 +不属于该能力边界的 feature。 +同一边界脚本也检查 Desktop、CLI、ACP 对 `bitfun-core` 的依赖必须保持 +`default-features = false` 且显式启用 `product-full`,确保完整产品 runtime 装配仍由 +产品入口声明,而不是依赖 core 的默认 feature。脚本会扫描产品入口范围内新增的 +`bitfun-core` 依赖并要求补齐显式装配规则,同时继续锁定 +`bitfun-core default = ["product-full"]`,避免本轮依赖裁剪意外变成默认能力裁剪。 +同一脚本也把 `tool-packs`、`services-integrations`、`product-domains` 纳入 +owner crate feature graph 门禁:这些 owner crate 的 `default` 必须保持空,`product-full` +只能显式聚合当前已声明的 owner feature group,不能借完整产品构建把未迁移 runtime +伪装成已迁入能力。 +该步骤仍不是 runtime owner 深迁移,不改变产品 crate feature set、默认 feature、 +CI/release 脚本或用户可见行为。`reqwest`、`axum`、`tower-http`、`terminal-core`、 +`zip`、`notify` 等仍因 AI/debug-log/terminal/LSP/search 等 no-default facade 保留; +后续若继续评估 `default = []` 或 per-product feature matrix,必须另起完整产品矩阵 +和构建收益数据评审。 +Core `service-integrations` feature 当前仍不是独立可编译产品形态;MCP/remote-connect/ +review-platform 仍引用 agentic、snapshot 或 product execution owner,因此只作为 +`product-full` runtime assembly 的组成部分验证。若未来要让它单独成立,需要先补 +port/provider 设计和行为等价测试,而不是只补 manifest 依赖。 + **HR 风险与优化清单:** 所有 HR PR 都必须满足以下共同约束: @@ -2119,9 +2149,14 @@ git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts 返回明确 unsupported 诊断;第二步把 `bitfun-core/product-full` 改为显式聚合 `tool-packs`、`services-integrations`、`product-domains` 的 owner feature group; 其中 `tool-packs` 与 `product-domains` 已成为 core optional dependency, - 同时保持 `default = ["product-full"]`、产品 crate feature set、release/CI 脚本和 - 用户可见能力不变。no-default core 当前只作为 runtime-surface-light facade,不声明 - dependency graph 已变轻、per-product feature matrix、构建收益或 runtime owner 深迁移已完成。 + 第三步把源码已由 feature 门禁保护的 product/runtime 直连依赖改为 optional, + 并由完整产品 feature 显式启用;boundary check 同步覆盖 no-default + non-optional 回流、optional dependency feature-owner 映射、产品入口显式 + `product-full` 装配、产品入口新增 core 依赖覆盖扫描和 `default = ["product-full"]` + 保留,以及 owner crate default-light / `product-full` 显式 feature group 组装,同时保持产品 crate feature set、 + release/CI 脚本和用户可见能力不变。no-default core 当前只作为 + runtime-surface-light facade,已减少 direct dependency profile,但不声明 + per-product feature matrix、构建收益或 runtime owner 深迁移已完成。 冗余清理 PR 不进入上述主线序号。只有在满足 `0A.6` 的绝对等价要求时,才可以插入到相邻里程碑之间,并且不得与主线拆分 PR 混合。 diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index 9adde5d16b..3ee7420d45 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -112,6 +112,49 @@ const lightweightBoundaryRules = [ ]; const dependencyProfileRules = [ + { + crateName: 'core', + profileName: 'no-default runtime-surface-light profile', + reason: + 'bitfun-core no-default profile must not force product/runtime integration dependencies', + forbiddenNonOptionalDeps: [ + 'aes', + 'aes-gcm', + 'bitfun-product-domains', + 'bitfun-relay-server', + 'bitfun-tool-packs', + 'chrono-tz', + 'cron', + 'dashmap', + 'eventsource-stream', + 'filetime', + 'flate2', + 'fs2', + 'git2', + 'glob', + 'globset', + 'hostname', + 'image', + 'include_dir', + 'indexmap', + 'local-ip-address', + 'mac_address', + 'md5', + 'qrcode', + 'rand', + 'rmcp', + 'russh', + 'russh-keys', + 'russh-sftp', + 'shellexpand', + 'sse-stream', + 'ssh_config', + 'similar', + 'tool-runtime', + 'tokio-tungstenite', + 'x25519-dalek', + ], + }, { crateName: 'core-types', profileName: 'default DTO profile', @@ -239,6 +282,108 @@ const dependencyProfileRules = [ }, ]; +const optionalDependencyFeatureOwnerRules = [ + { + crateName: 'core', + reason: + 'bitfun-core product/runtime optional dependencies must stay owned by explicit feature gates', + dependencies: [ + { depName: 'aes', ownerFeatures: ['service-integrations'] }, + { depName: 'aes-gcm', ownerFeatures: ['service-integrations', 'ssh-remote'] }, + { depName: 'bitfun-product-domains', ownerFeatures: ['product-domains'] }, + { depName: 'bitfun-relay-server', ownerFeatures: ['service-integrations'] }, + { depName: 'bitfun-tool-packs', ownerFeatures: ['tool-packs'] }, + { depName: 'chrono-tz', ownerFeatures: ['product-full'] }, + { depName: 'cron', ownerFeatures: ['product-full'] }, + { depName: 'dashmap', ownerFeatures: ['product-full'] }, + { depName: 'eventsource-stream', ownerFeatures: ['product-full'] }, + { depName: 'filetime', ownerFeatures: ['product-full'] }, + { depName: 'flate2', ownerFeatures: ['product-full'] }, + { depName: 'fs2', ownerFeatures: ['product-full'] }, + { depName: 'git2', ownerFeatures: ['service-integrations'] }, + { depName: 'glob', ownerFeatures: ['product-full'] }, + { depName: 'globset', ownerFeatures: ['product-full'] }, + { depName: 'hostname', ownerFeatures: ['service-integrations'] }, + { depName: 'image', ownerFeatures: ['service-integrations'] }, + { depName: 'include_dir', ownerFeatures: ['product-full'] }, + { depName: 'indexmap', ownerFeatures: ['product-full'] }, + { depName: 'local-ip-address', ownerFeatures: ['service-integrations'] }, + { depName: 'mac_address', ownerFeatures: ['service-integrations'] }, + { depName: 'md5', ownerFeatures: ['product-full', 'service-integrations'] }, + { depName: 'qrcode', ownerFeatures: ['service-integrations'] }, + { depName: 'rand', ownerFeatures: ['service-integrations', 'ssh-remote'] }, + { depName: 'rmcp', ownerFeatures: ['service-integrations'] }, + { depName: 'russh', ownerFeatures: ['ssh-remote'] }, + { depName: 'russh-keys', ownerFeatures: ['ssh-remote'] }, + { depName: 'russh-sftp', ownerFeatures: ['ssh-remote'] }, + { depName: 'shellexpand', ownerFeatures: ['ssh-remote'] }, + { depName: 'similar', ownerFeatures: ['product-full'] }, + { depName: 'sse-stream', ownerFeatures: ['service-integrations'] }, + { depName: 'ssh_config', ownerFeatures: ['ssh-remote'] }, + { depName: 'tokio-tungstenite', ownerFeatures: ['service-integrations'] }, + { depName: 'tool-runtime', ownerFeatures: ['product-full'] }, + { depName: 'x25519-dalek', ownerFeatures: ['service-integrations'] }, + ], + }, +]; + +const productCoreFeatureAssemblyRules = [ + { + manifestPath: 'src/apps/desktop/Cargo.toml', + dependencyName: 'bitfun-core', + requiredFeatures: ['product-full'], + reason: 'desktop must explicitly assemble the full bitfun-core product runtime', + }, + { + manifestPath: 'src/apps/cli/Cargo.toml', + dependencyName: 'bitfun-core', + requiredFeatures: ['product-full'], + reason: 'CLI must explicitly assemble the full bitfun-core product runtime', + }, + { + manifestPath: 'src/crates/acp/Cargo.toml', + dependencyName: 'bitfun-core', + requiredFeatures: ['product-full'], + reason: 'ACP must explicitly assemble the full bitfun-core product runtime', + }, +]; + +const productCoreFeatureAssemblyScanRoots = ['src/apps', 'src/crates/acp']; + +const ownerCrateFeatureAssemblyRules = [ + { + manifestPath: 'src/crates/tool-packs/Cargo.toml', + reason: 'tool-packs must keep product feature groups explicit and default-light', + requiredProductFullFeatures: [ + 'basic', + 'git', + 'mcp', + 'browser-web', + 'computer-use', + 'image-analysis', + 'miniapp', + 'agent-control', + ], + }, + { + manifestPath: 'src/crates/services-integrations/Cargo.toml', + reason: 'services-integrations must keep integration feature groups explicit and default-light', + requiredProductFullFeatures: [ + 'announcement', + 'file-watch', + 'git', + 'mcp', + 'remote-connect', + 'remote-ssh', + ], + }, + { + manifestPath: 'src/crates/product-domains/Cargo.toml', + reason: 'product-domains must keep product domain feature groups explicit and default-light', + requiredProductFullFeatures: ['miniapp', 'function-agents'], + }, +]; + const facadeOnlyFiles = [ { path: 'src/crates/core/src/service/git/git_service.rs', @@ -3806,6 +3951,7 @@ function parseManifestDependencies(lines) { } if (currentInline) { + currentInline.text.push(trimmed); if (/\boptional\s*=\s*true\b/.test(trimmed)) { currentInline.optional = true; } @@ -3825,6 +3971,7 @@ function parseManifestDependencies(lines) { name: depName, line: index + 1, optional: false, + text: [trimmed], }; deps.push(currentTable); break; @@ -3833,8 +3980,11 @@ function parseManifestDependencies(lines) { return; } - if (currentTable && /\boptional\s*=\s*true\b/.test(trimmed)) { - currentTable.optional = true; + if (currentTable) { + currentTable.text.push(trimmed); + if (/\boptional\s*=\s*true\b/.test(trimmed)) { + currentTable.optional = true; + } return; } @@ -3849,6 +3999,7 @@ function parseManifestDependencies(lines) { name, line: index + 1, optional: /\boptional\s*=\s*true\b/.test(trimmed), + text: [trimmed], }); if (trimmed.includes('{') && !trimmed.includes('}')) { currentInline = deps[deps.length - 1]; @@ -3861,12 +4012,117 @@ function parseManifestDependencies(lines) { return deps; } +function manifestDependencyText(dep) { + return dep?.text?.join('\n') ?? ''; +} + +function manifestDependencyDisablesDefaultFeatures(dep) { + return /\bdefault-features\s*=\s*false\b/.test(manifestDependencyText(dep)); +} + +function parseManifestDependencyFeatureNames(dep) { + const features = new Set(); + const text = manifestDependencyText(dep); + for (const match of text.matchAll(/\bfeatures\s*=\s*\[([\s\S]*?)\]/g)) { + for (const featureMatch of match[1].matchAll(/"([^"]+)"/g)) { + features.add(featureMatch[1]); + } + } + return features; +} + +function collectProductCoreDependencyManifestPaths(manifestEntries) { + return manifestEntries + .filter((entry) => { + const deps = parseManifestDependencies(entry.text.split(/\r?\n/)); + return deps.some((dep) => dep.name === 'bitfun-core'); + }) + .map((entry) => entry.manifestPath) + .sort(); +} + +function collectProductCoreDependencyManifests(scanRoots = productCoreFeatureAssemblyScanRoots) { + const manifestEntries = []; + for (const repoDir of scanRoots) { + const dir = join(ROOT, ...repoDir.split('/')); + walkFiles(dir, (path) => { + if (!path.endsWith('Cargo.toml')) { + return; + } + manifestEntries.push({ + manifestPath: toRepoPath(path), + text: readText(path), + }); + }); + } + return collectProductCoreDependencyManifestPaths(manifestEntries); +} + +function parseManifestFeatures(lines) { + const features = new Map(); + let inFeatures = false; + let currentFeature = null; + + const appendRefs = (feature, text) => { + const refs = [...text.matchAll(/"([^"]+)"/g)].map((match) => match[1]); + feature.refs.push(...refs); + }; + + lines.forEach((line, index) => { + const trimmed = line.trim(); + if (trimmed.startsWith('#') || trimmed === '') { + return; + } + + const headerMatch = trimmed.match(/^\[(.+)]$/); + if (headerMatch) { + inFeatures = trimmed === '[features]'; + currentFeature = null; + return; + } + + if (!inFeatures) { + return; + } + + if (currentFeature) { + appendRefs(currentFeature, trimmed); + if (trimmed.includes(']')) { + currentFeature = null; + } + return; + } + + const featureMatch = trimmed.match(/^([A-Za-z0-9_-]+)\s*=\s*(.*)$/); + if (!featureMatch) { + return; + } + + const feature = { + name: featureMatch[1], + line: index + 1, + refs: [], + }; + appendRefs(feature, featureMatch[2]); + features.set(feature.name, feature); + if (featureMatch[2].includes('[') && !featureMatch[2].includes(']')) { + currentFeature = feature; + } + }); + + return features; +} + function collectKnownDependencyNames() { return Array.from( new Set([ 'bitfun-core', ...lightweightBoundaryRules.flatMap((rule) => rule.forbiddenDeps), ...dependencyProfileRules.flatMap((rule) => rule.forbiddenNonOptionalDeps), + ...optionalDependencyFeatureOwnerRules.flatMap((rule) => + rule.dependencies.map((dependency) => dependency.depName), + ), + ...productCoreFeatureAssemblyRules.map((rule) => rule.dependencyName), ]), ); } @@ -3904,6 +4160,7 @@ function runManifestParserSelfTest() { 'rmcp = { version = "0.12.0", default-features = false, features = [', ' "auth",', '], optional = true }', + 'bitfun-core = { path = "../core", default-features = false, features = ["product-full"] }', '[dependencies.git2]', 'workspace = true', 'optional = true', @@ -3928,10 +4185,111 @@ function runManifestParserSelfTest() { if (parsedByName.get('bitfun-cli')?.optional !== false) { throw new Error('dependency profile parser must detect non-optional target dependency tables'); } + const parsedCoreDep = parsedByName.get('bitfun-core'); + if (!manifestDependencyDisablesDefaultFeatures(parsedCoreDep)) { + throw new Error('dependency profile parser must detect default-features = false'); + } + if (!parseManifestDependencyFeatureNames(parsedCoreDep).has('product-full')) { + throw new Error('dependency profile parser must detect inline dependency features'); + } + const parsedCoreTableDeps = parseManifestDependencies([ + '[dependencies."bitfun-core"]', + 'path = "../core"', + 'default-features = false', + 'features = [', + ' "product-full",', + ' "ssh-remote",', + ']', + ]); + const parsedCoreTableDep = parsedCoreTableDeps.find((dep) => dep.name === 'bitfun-core'); + if (!manifestDependencyDisablesDefaultFeatures(parsedCoreTableDep)) { + throw new Error('dependency profile parser must detect table default-features = false'); + } + if (!parseManifestDependencyFeatureNames(parsedCoreTableDep).has('ssh-remote')) { + throw new Error('dependency profile parser must detect table dependency features'); + } if (parsedByName.has('image')) { throw new Error('dependency profile parser must ignore feature entries named like dependencies'); } + const productCoreRulePaths = new Set( + productCoreFeatureAssemblyRules.map((rule) => rule.manifestPath), + ); + for (const manifestPath of [ + 'src/apps/desktop/Cargo.toml', + 'src/apps/cli/Cargo.toml', + 'src/crates/acp/Cargo.toml', + ]) { + if (!productCoreRulePaths.has(manifestPath)) { + throw new Error(`product core feature assembly rule must cover ${manifestPath}`); + } + } + for (const rule of productCoreFeatureAssemblyRules) { + if (!rule.requiredFeatures.includes('product-full')) { + throw new Error(`${rule.manifestPath} must require bitfun-core product-full`); + } + } + const discoveredProductCoreManifests = collectProductCoreDependencyManifestPaths([ + { + manifestPath: 'src/apps/desktop/Cargo.toml', + text: + '[dependencies]\nbitfun-core = { path = "../../crates/core", default-features = false, features = ["product-full"] }', + }, + { + manifestPath: 'src/apps/server/Cargo.toml', + text: '[dependencies]\naxum = { workspace = true }', + }, + { + manifestPath: 'src/crates/acp/Cargo.toml', + text: '[dependencies."bitfun-core"]\npath = "../core"\ndefault-features = false\nfeatures = ["product-full"]', + }, + ]); + if (discoveredProductCoreManifests.join(',') !== 'src/apps/desktop/Cargo.toml,src/crates/acp/Cargo.toml') { + throw new Error('product core dependency scanner must discover only manifests that depend on bitfun-core'); + } + const ownerFeatureRulePaths = new Set( + ownerCrateFeatureAssemblyRules.map((rule) => rule.manifestPath), + ); + for (const manifestPath of [ + 'src/crates/tool-packs/Cargo.toml', + 'src/crates/services-integrations/Cargo.toml', + 'src/crates/product-domains/Cargo.toml', + ]) { + if (!ownerFeatureRulePaths.has(manifestPath)) { + throw new Error(`owner crate feature assembly rule must cover ${manifestPath}`); + } + } + + const parsedFeatures = parseManifestFeatures([ + '[package]', + 'name = "example"', + '[features]', + 'default = ["product-full"]', + 'product-full = [', + ' "dep:tool-runtime",', + ' "service-integrations",', + ']', + 'service-integrations = ["dep:git2", "dep:rmcp"]', + 'ssh-remote = [', + ' "russh",', + ' "russh-sftp",', + ']', + '[dependencies]', + 'git2 = { workspace = true, optional = true }', + ]); + if (!parsedFeatures.get('default')?.refs.includes('product-full')) { + throw new Error('feature parser must detect inline feature references'); + } + if (!parsedFeatures.get('product-full')?.refs.includes('dep:tool-runtime')) { + throw new Error('feature parser must detect multiline dependency feature references'); + } + if (!parsedFeatures.get('service-integrations')?.refs.includes('dep:rmcp')) { + throw new Error('feature parser must detect inline dependency feature references'); + } + if (!parsedFeatures.get('ssh-remote')?.refs.includes('russh-sftp')) { + throw new Error('feature parser must detect implicit optional dependency feature references'); + } + const acceptsGitFacadeLine = createFacadeLineChecker('bitfun_services_integrations::git'); const facadePositiveCases = [ '', @@ -4042,6 +4400,26 @@ function runManifestParserSelfTest() { if (!productDomainProfile?.forbiddenNonOptionalDeps.includes('dirs')) { throw new Error('product-domains default profile must forbid non-optional dirs'); } + const coreProfile = dependencyProfileRules.find((rule) => rule.crateName === 'core'); + for (const dep of ['git2', 'rmcp', 'image', 'tool-runtime', 'bitfun-relay-server']) { + if (!coreProfile?.forbiddenNonOptionalDeps.includes(dep)) { + throw new Error(`core no-default profile must forbid non-optional ${dep}`); + } + } + const coreOptionalOwnerRule = optionalDependencyFeatureOwnerRules.find( + (rule) => rule.crateName === 'core', + ); + for (const dep of ['git2', 'rmcp', 'image', 'tool-runtime', 'bitfun-relay-server']) { + if (!coreOptionalOwnerRule?.dependencies.some((dependency) => dependency.depName === dep)) { + throw new Error(`core optional dependency owner rule must cover ${dep}`); + } + } + const coreGit2Owner = coreOptionalOwnerRule?.dependencies.find( + (dependency) => dependency.depName === 'git2', + ); + if (!coreGit2Owner?.ownerFeatures.includes('service-integrations')) { + throw new Error('core optional dependency owner rule must keep git2 under service-integrations'); + } const productDomainRuntimeRule = forbiddenContentUnderRules.find( (rule) => rule.path === 'src/crates/product-domains/src', ); @@ -4488,14 +4866,14 @@ function runManifestParserSelfTest() { { path: 'src/crates/core/Cargo.toml', contracts: [ - 'bitfun-tool-packs = \\{ path = "\\.\\./tool-packs", default-features = false, optional = true \\}', - 'bitfun-services-integrations = \\{ path = "\\.\\./services-integrations", default-features = false, features = \\["remote-ssh"\\] \\}', - 'bitfun-product-domains = \\{ path = "\\.\\./product-domains", default-features = false, optional = true \\}', + 'bitfun-tool-packs = \\{ path = "\\.\\.\\/tool-packs", default-features = false, optional = true \\}', + 'bitfun-services-integrations = \\{ path = "\\.\\.\\/services-integrations", default-features = false, features = \\["remote-ssh"\\] \\}', + 'bitfun-product-domains = \\{ path = "\\.\\.\\/product-domains", default-features = false, optional = true \\}', 'dep:bitfun-tool-packs', - 'bitfun-tool-packs/product-full', - 'bitfun-services-integrations/product-full', + 'bitfun-tool-packs\\/product-full', + 'bitfun-services-integrations\\/product-full', 'dep:bitfun-product-domains', - 'bitfun-product-domains/product-full', + 'bitfun-product-domains\\/product-full', ], }, { @@ -5286,6 +5664,156 @@ function checkForbiddenNonOptionalManifestDeps(crateDir, forbiddenDeps, messageF } } +function featureReferencesDependency(feature, depName) { + if (!feature) { + return false; + } + return feature.refs.includes(`dep:${depName}`) || feature.refs.includes(depName); +} + +function featureReferencesFeature(feature, featureName) { + if (!feature) { + return false; + } + return feature.refs.includes(featureName); +} + +function checkOptionalDependencyFeatureOwners(crateDir, rule) { + const manifestPath = join(crateDir, 'Cargo.toml'); + const lines = readText(manifestPath).split(/\r?\n/); + const deps = parseManifestDependencies(lines); + const depsByName = new Map(deps.map((dep) => [dep.name, dep])); + const features = parseManifestFeatures(lines); + + for (const dependency of rule.dependencies) { + const dep = depsByName.get(dependency.depName); + if (!dep) { + failures.push({ + path: manifestPath, + line: 1, + message: `${rule.reason}; missing optional dependency: ${dependency.depName}`, + }); + continue; + } + if (!dep.optional) { + failures.push({ + path: manifestPath, + line: dep.line, + message: `${rule.reason}; dependency must be optional: ${dependency.depName}`, + }); + } + for (const featureName of dependency.ownerFeatures) { + const feature = features.get(featureName); + if (!featureReferencesDependency(feature, dependency.depName)) { + failures.push({ + path: manifestPath, + line: feature?.line ?? dep.line, + message: `${rule.reason}; ${featureName} must explicitly enable ${dependency.depName}`, + }); + } + } + } +} + +function checkProductCoreFeatureAssembly(rule) { + const manifestPath = join(ROOT, ...rule.manifestPath.split('/')); + const deps = parseManifestDependencies(readText(manifestPath).split(/\r?\n/)); + const dep = deps.find((candidate) => candidate.name === rule.dependencyName); + if (!dep) { + failures.push({ + path: manifestPath, + line: 1, + message: `${rule.reason}; missing dependency: ${rule.dependencyName}`, + }); + return; + } + + if (!manifestDependencyDisablesDefaultFeatures(dep)) { + failures.push({ + path: manifestPath, + line: dep.line, + message: `${rule.reason}; ${rule.dependencyName} must set default-features = false`, + }); + } + + const enabledFeatures = parseManifestDependencyFeatureNames(dep); + for (const featureName of rule.requiredFeatures) { + if (!enabledFeatures.has(featureName)) { + failures.push({ + path: manifestPath, + line: dep.line, + message: `${rule.reason}; ${rule.dependencyName} must enable feature ${featureName}`, + }); + } + } +} + +function checkProductCoreFeatureAssemblyCoverage() { + const rulePaths = new Set(productCoreFeatureAssemblyRules.map((rule) => rule.manifestPath)); + for (const manifestPath of collectProductCoreDependencyManifests()) { + if (!rulePaths.has(manifestPath)) { + failures.push({ + path: join(ROOT, ...manifestPath.split('/')), + line: 1, + message: + 'product entry crate depends on bitfun-core but is not covered by product-full assembly rules', + }); + } + } +} + +function checkCoreDefaultProductFullFeature() { + const manifestPath = join(ROOT, 'src', 'crates', 'core', 'Cargo.toml'); + const features = parseManifestFeatures(readText(manifestPath).split(/\r?\n/)); + if (!featureReferencesFeature(features.get('default'), 'product-full')) { + failures.push({ + path: manifestPath, + line: features.get('default')?.line ?? 1, + message: + 'bitfun-core default feature must remain product-full until a separate product matrix review changes it', + }); + } +} + +function checkOwnerCrateFeatureAssembly(rule) { + const manifestPath = join(ROOT, ...rule.manifestPath.split('/')); + const features = parseManifestFeatures(readText(manifestPath).split(/\r?\n/)); + const defaultFeature = features.get('default'); + if (!defaultFeature) { + failures.push({ + path: manifestPath, + line: 1, + message: `${rule.reason}; missing default feature declaration`, + }); + } else if (defaultFeature.refs.length > 0) { + failures.push({ + path: manifestPath, + line: defaultFeature.line, + message: `${rule.reason}; default feature must remain empty`, + }); + } + + const productFull = features.get('product-full'); + if (!productFull) { + failures.push({ + path: manifestPath, + line: 1, + message: `${rule.reason}; missing product-full feature declaration`, + }); + return; + } + + for (const featureName of rule.requiredProductFullFeatures) { + if (!featureReferencesFeature(productFull, featureName)) { + failures.push({ + path: manifestPath, + line: productFull.line, + message: `${rule.reason}; product-full must explicitly enable ${featureName}`, + }); + } + } +} + function checkRustImports(crateDir) { const srcDir = join(crateDir, 'src'); try { @@ -5497,6 +6025,20 @@ for (const rule of dependencyProfileRules) { checkForbiddenNonOptionalManifestDeps(crateDir, rule.forbiddenNonOptionalDeps, messageForDep); } +for (const rule of optionalDependencyFeatureOwnerRules) { + const crateDir = join(ROOT, 'src', 'crates', rule.crateName); + checkOptionalDependencyFeatureOwners(crateDir, rule); +} + +for (const rule of productCoreFeatureAssemblyRules) { + checkProductCoreFeatureAssembly(rule); +} +checkProductCoreFeatureAssemblyCoverage(); +checkCoreDefaultProductFullFeature(); +for (const rule of ownerCrateFeatureAssemblyRules) { + checkOwnerCrateFeatureAssembly(rule); +} + for (const facade of facadeOnlyFiles) { checkFacadeOnlyFile(facade.path, facade.importPrefix, facade.reason); } diff --git a/src/crates/core/AGENTS.md b/src/crates/core/AGENTS.md index 898e2c94cd..a2dbf0ae99 100644 --- a/src/crates/core/AGENTS.md +++ b/src/crates/core/AGENTS.md @@ -84,6 +84,25 @@ SessionManager → Session → DialogTurn → ModelRound MiniApp/function-agent, Git/MCP, remote-connect, review-platform, snapshot, token usage, and mode canonicalization stay behind `product-full` or their owner feature group. +- Product/runtime dependencies that are only used behind those feature gates + should stay optional in `bitfun-core` and be enabled by `product-full`, + `service-integrations`, or `ssh-remote`; do not treat that as permission to + lighten defaults or change product crate feature sets. Keep + `scripts/check-core-boundaries.mjs` updated so each optional runtime + dependency has an explicit feature owner. +- Product entry crates that depend on `bitfun-core` must keep + `default-features = false` and explicitly enable `product-full`; keep this + wired through product manifests rather than relying on core defaults. The + boundary script scans product entry manifests for new direct `bitfun-core` + dependencies and requires matching assembly rules. +- Keep `default = ["product-full"]` until a separate product matrix review + explicitly changes default capability selection. +- Owner crate feature graph guards keep `tool-packs`, `services-integrations`, + and `product-domains` default-light while allowing `product-full` to + explicitly aggregate current owner feature groups. +- `service-integrations` is not a standalone product shape in core yet; MCP, + remote-connect, and review-platform still depend on agentic/product runtime + owners through `product-full`. - Do not add new cross-layer references from `service` to `agentic` without a small port/interface boundary. - Do not move platform-specific logic, build-script behavior, or product diff --git a/src/crates/core/Cargo.toml b/src/crates/core/Cargo.toml index 4a0880d05e..17c46580dd 100644 --- a/src/crates/core/Cargo.toml +++ b/src/crates/core/Cargo.toml @@ -28,16 +28,16 @@ log = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } -chrono-tz = { workspace = true } -cron = { workspace = true } +chrono-tz = { workspace = true, optional = true } +cron = { workspace = true, optional = true } regex = { workspace = true } base64 = { workspace = true } -image = { workspace = true } -md5 = { workspace = true } -aes = "0.8" +image = { workspace = true, optional = true } +md5 = { workspace = true, optional = true } +aes = { version = "0.8", optional = true } hex = "0.4" -dashmap = { workspace = true } -indexmap = { workspace = true } +dashmap = { workspace = true, optional = true } +indexmap = { workspace = true, optional = true } reqwest = { workspace = true } @@ -45,29 +45,29 @@ reqwest = { workspace = true } axum = { workspace = true } tower-http = { workspace = true } -glob = { workspace = true } +glob = { workspace = true, optional = true } ignore = { workspace = true } notify = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } -filetime = { workspace = true } -fs2 = { workspace = true } +filetime = { workspace = true, optional = true } +fs2 = { workspace = true, optional = true } zip = { workspace = true } -flate2 = { workspace = true } -include_dir = { workspace = true } +flate2 = { workspace = true, optional = true } +include_dir = { workspace = true, optional = true } -git2 = { workspace = true } +git2 = { workspace = true, optional = true } # Command detection (cross-platform) which = { workspace = true } -similar = { workspace = true } +similar = { workspace = true, optional = true } urlencoding = { workspace = true } grep-searcher = { workspace = true } grep-regex = { workspace = true } -globset = { workspace = true } +globset = { workspace = true, optional = true } -eventsource-stream = { workspace = true } +eventsource-stream = { workspace = true, optional = true } # MCP Streamable HTTP client (official rust-sdk used by Codex) rmcp = { version = "0.12.0", default-features = false, features = [ @@ -78,8 +78,8 @@ rmcp = { version = "0.12.0", default-features = false, features = [ "schemars", "server", "transport-streamable-http-client-reqwest", -] } -sse-stream = "0.2.1" +], optional = true } +sse-stream = { version = "0.2.1", optional = true } # Shared AI protocol adapters bitfun-ai-adapters = { path = "../ai-adapters" } @@ -103,7 +103,7 @@ bitfun-services-integrations = { path = "../services-integrations", default-feat bitfun-product-domains = { path = "../product-domains", default-features = false, optional = true } # Tool runtime -tool-runtime = { path = "../tool-runtime" } +tool-runtime = { path = "../tool-runtime", optional = true } # terminal terminal-core = { path = "../terminal" } @@ -113,21 +113,21 @@ fluent-bundle = { workspace = true } unic-langid = { workspace = true } # Encryption (Remote Connect E2E) -x25519-dalek = { workspace = true } -aes-gcm = { workspace = true } +x25519-dalek = { workspace = true, optional = true } +aes-gcm = { workspace = true, optional = true } sha2 = { workspace = true } -rand = { workspace = true } +rand = { workspace = true, optional = true } # Device/Network info (Remote Connect) -mac_address = { workspace = true } -local-ip-address = { workspace = true } -hostname = { workspace = true } +mac_address = { workspace = true, optional = true } +local-ip-address = { workspace = true, optional = true } +hostname = { workspace = true, optional = true } # QR code generation -qrcode = { workspace = true } +qrcode = { workspace = true, optional = true } # WebSocket client -tokio-tungstenite = { workspace = true } +tokio-tungstenite = { workspace = true, optional = true } # SSH - Remote SSH support (optional feature) russh = { version = "0.45", optional = true } @@ -137,7 +137,7 @@ shellexpand = { version = "3", optional = true } ssh_config = { version = "0.1", optional = true } # Relay server shared library (embedded relay reuses standalone relay logic) -bitfun-relay-server = { path = "../../apps/relay-server" } +bitfun-relay-server = { path = "../../apps/relay-server", optional = true } # Event layer dependency (lowest layer) bitfun-core-types = { path = "../core-types" } @@ -152,7 +152,7 @@ tauri = { workspace = true, optional = true } # Non-Windows: vendored OpenSSL for libgit2 (no system install). [target.'cfg(not(windows))'.dependencies] -git2 = { workspace = true, features = ["vendored-openssl"] } +git2 = { workspace = true, features = ["vendored-openssl"], optional = true } [target.'cfg(windows)'.dependencies] win32job = { workspace = true } @@ -165,16 +165,55 @@ schannel = "0.1" # explicitly before `bitfun-core` default features are made lighter. default = ["product-full"] product-full = [ + "dep:chrono-tz", + "dep:cron", + "dep:dashmap", + "dep:eventsource-stream", + "dep:filetime", + "dep:flate2", + "dep:fs2", + "dep:glob", + "dep:globset", + "dep:include_dir", + "dep:indexmap", + "dep:md5", + "dep:similar", + "dep:tool-runtime", "ssh-remote", "product-domains", "service-integrations", "tool-packs", ] product-domains = ["dep:bitfun-product-domains", "bitfun-product-domains/product-full"] -service-integrations = ["bitfun-services-integrations/product-full"] +service-integrations = [ + "dep:aes", + "dep:aes-gcm", + "dep:bitfun-relay-server", + "dep:git2", + "dep:hostname", + "dep:image", + "dep:local-ip-address", + "dep:mac_address", + "dep:md5", + "dep:qrcode", + "dep:rand", + "dep:rmcp", + "dep:sse-stream", + "dep:tokio-tungstenite", + "dep:x25519-dalek", + "bitfun-services-integrations/product-full", +] tool-packs = ["dep:bitfun-tool-packs", "bitfun-tool-packs/product-full"] tauri-support = ["tauri"] # Optional tauri support -ssh-remote = ["russh", "russh-sftp", "russh-keys", "shellexpand", "ssh_config"] # russh-keys pure-Rust crypto backend (no openssl) +ssh-remote = [ + "dep:aes-gcm", + "dep:rand", + "russh", + "russh-sftp", + "russh-keys", + "shellexpand", + "ssh_config", +] # russh-keys pure-Rust crypto backend (no openssl) [build-dependencies] sha2 = { workspace = true } diff --git a/src/crates/product-domains/AGENTS.md b/src/crates/product-domains/AGENTS.md index 17196070cf..d364716d21 100644 --- a/src/crates/product-domains/AGENTS.md +++ b/src/crates/product-domains/AGENTS.md @@ -30,6 +30,8 @@ moves here gradually. - Feature-gated additions must remain narrow. `miniapp` may use MiniApp-only dependencies, `function-agents` may use function-agent-only dependencies, and `product-full` should only aggregate existing product-domain feature groups. + Boundary checks enforce the default-light profile and current `product-full` + feature-group list. ## Current owners diff --git a/src/crates/services-integrations/AGENTS.md b/src/crates/services-integrations/AGENTS.md index 23d9645b8c..43d69f6e83 100644 --- a/src/crates/services-integrations/AGENTS.md +++ b/src/crates/services-integrations/AGENTS.md @@ -11,6 +11,8 @@ slices that are outside pure product logic but still platform-neutral. presentation code. - Keep integration families behind explicit features. The default feature set should not compile heavy Git, MCP, SSH, network, or file-watch runtimes. + Boundary checks enforce `default = []` and the current `product-full` + integration feature-group list. - MCP config/process/transport lifecycle and dynamic provider helpers may live here; product tool registry assembly, manifest filtering, `GetToolSpec` execution, and concrete tool behavior remain core-owned until H1. diff --git a/src/crates/tool-packs/AGENTS.md b/src/crates/tool-packs/AGENTS.md index 6ae93de21c..7b50bb1145 100644 --- a/src/crates/tool-packs/AGENTS.md +++ b/src/crates/tool-packs/AGENTS.md @@ -8,7 +8,8 @@ tool provider group plan. It does not own concrete tool implementations yet. ## Guardrails - Keep `default = []`; `product-full` may aggregate feature groups but must not - silently enable new runtime behavior. + silently enable new runtime behavior. Boundary checks enforce the current + feature-group list. - Do not depend on `bitfun-core`, concrete service crates, app crates, Tauri, Git, MCP, network clients, or CLI UI dependencies unless H1 explicitly moves a reviewed tool runtime owner here. From f5856ec8ecb5574db40cbd6c2259d361290909b5 Mon Sep 17 00:00:00 2001 From: justin4fun <48787405+jarvis24young@users.noreply.github.com> Date: Fri, 22 May 2026 10:12:30 +0800 Subject: [PATCH 21/38] feat(mobile-web): add scroll-to-bottom floating button (#828) A floating button appears when the user scrolls up from the message list. Tapping it smoothly scrolls back to the latest messages. Uses programmaticScrollRef guard and ref-based change detection to suppress button flicker during auto-scroll (new messages, streaming updates). --- src/mobile-web/src/i18n/messages.ts | 3 ++ src/mobile-web/src/pages/ChatPage.tsx | 42 ++++++++++++++++- .../src/styles/components/chat.scss | 46 +++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts index 5df9523867..c050f6f8ea 100644 --- a/src/mobile-web/src/i18n/messages.ts +++ b/src/mobile-web/src/i18n/messages.ts @@ -143,6 +143,7 @@ export const messages: Record = { fileDownloading: 'Downloading...', fileDownloaded: 'Downloaded', clickToDownload: 'Click to download', + scrollToBottom: 'Scroll to bottom', }, tools: { explore: 'Explore', @@ -297,6 +298,7 @@ export const messages: Record = { fileDownloading: '下载中...', fileDownloaded: '已下载', clickToDownload: '点击下载', + scrollToBottom: '滚动到底部', }, tools: { explore: '探索', @@ -451,6 +453,7 @@ export const messages: Record = { fileDownloading: '下載中...', fileDownloaded: '已下載', clickToDownload: '點擊下載', + scrollToBottom: '捲動到底部', }, tools: { explore: '探索', diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 312717ed1b..bcb5aaf186 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -2001,6 +2001,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, const messagesContainerRef = useRef(null); const [expandedMsgIds, setExpandedMsgIds] = useState>(new Set()); const [infoToast, setInfoToast] = useState(null); + const [showScrollToBottom, setShowScrollToBottom] = useState(false); const isStreaming = activeTurn != null && activeTurn.status === 'active'; @@ -2137,6 +2138,8 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, }, [sessionMgr, sessionId, setMessages, setError, getMessages]); const isNearBottomRef = useRef(true); + const programmaticScrollRef = useRef(false); + const lastShowScrollToBottomRef = useRef(false); const BOTTOM_THRESHOLD = 80; const handleScroll = useCallback(() => { @@ -2144,7 +2147,18 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, if (!container) return; const gap = container.scrollHeight - container.scrollTop - container.clientHeight; - isNearBottomRef.current = gap < BOTTOM_THRESHOLD; + const nearBottom = gap < BOTTOM_THRESHOLD; + isNearBottomRef.current = nearBottom; + if (nearBottom) { + programmaticScrollRef.current = false; + } + if (!programmaticScrollRef.current) { + const show = !nearBottom; + if (show !== lastShowScrollToBottomRef.current) { + lastShowScrollToBottomRef.current = show; + setShowScrollToBottom(show); + } + } if (container.scrollTop < 100 && hasMore && !isLoadingMore) { const msgs = getMessages(sessionId); @@ -2152,6 +2166,14 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, } }, [hasMore, isLoadingMore, getMessages, sessionId, loadMessages]); + const scrollToBottom = useCallback(() => { + programmaticScrollRef.current = true; + isNearBottomRef.current = true; + setShowScrollToBottom(false); + lastShowScrollToBottomRef.current = false; + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, []); + // Initial load + start poller const initialScrollDone = useRef(false); const pendingInitialScroll = useRef(false); @@ -2231,6 +2253,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, const isNewAppend = messages.length > prevMsgCountRef.current; prevMsgCountRef.current = messages.length; if (isNewAppend && !isLoadingMore && isNearBottomRef.current) { + programmaticScrollRef.current = true; messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); } } @@ -2239,11 +2262,13 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, useEffect(() => { if (!initialScrollDone.current || !isStreaming) return; if (!isNearBottomRef.current) return; + programmaticScrollRef.current = true; messagesEndRef.current?.scrollIntoView({ behavior: 'auto' }); }, [activeTurn, isStreaming]); useEffect(() => { if (optimisticMsg) { + programmaticScrollRef.current = true; isNearBottomRef.current = true; messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); } @@ -2257,6 +2282,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, if (!isNearBottomRef.current) return; const gap = container.scrollHeight - container.scrollTop - container.clientHeight; if (gap > 10 && gap < 400) { + programmaticScrollRef.current = true; container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }); } }, 300); @@ -2692,8 +2718,22 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, )}
+
+ {showScrollToBottom && ( + + )} + {/* Floating Input Bar — two-stage (matches desktop ChatInput) */} Date: Fri, 22 May 2026 11:24:10 +0800 Subject: [PATCH 22/38] fix(assistant-config): correct assistant template config saving to wrong mode --- .../profile/views/TemplateConfigPage.tsx | 71 ++++++++++--------- 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx b/src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx index b829b85918..1f529e3c61 100644 --- a/src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx +++ b/src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx @@ -24,6 +24,7 @@ import { useNurseryStore } from '../nurseryStore'; import { formatTokenCount } from './useTokenEstimate'; const log = createLogger('TemplateConfigPage'); +const ASSISTANT_MODE_ID = 'Claw'; interface ToolInfo { name: string; @@ -131,7 +132,7 @@ const TemplateConfigPage: React.FC = () => { const [models, setModels] = useState([]); const [funcAgentModels, setFuncAgentModels] = useState>({}); - const [agenticConfig, setAgenticConfig] = useState(null); + const [assistantModeConfig, setAssistantModeConfig] = useState(null); const [availableTools, setAvailableTools] = useState([]); const [mcpServers, setMcpServers] = useState([]); const [modeSkills, setModeSkills] = useState([]); @@ -142,8 +143,8 @@ const TemplateConfigPage: React.FC = () => { const [detail, setDetail] = useState(null); const enabledToolCount = useMemo( - () => agenticConfig?.enabled_tools?.length ?? 0, - [agenticConfig], + () => assistantModeConfig?.enabled_tools?.length ?? 0, + [assistantModeConfig], ); const skillsEnabled = useMemo( @@ -181,13 +182,13 @@ const TemplateConfigPage: React.FC = () => { ); const builtinToolsEnabled = useMemo( - () => builtinTools.filter((tool) => agenticConfig?.enabled_tools?.includes(tool.name)), - [builtinTools, agenticConfig], + () => builtinTools.filter((tool) => assistantModeConfig?.enabled_tools?.includes(tool.name)), + [builtinTools, assistantModeConfig], ); const builtinToolsDisabled = useMemo( - () => builtinTools.filter((tool) => !agenticConfig?.enabled_tools?.includes(tool.name)), - [builtinTools, agenticConfig], + () => builtinTools.filter((tool) => !assistantModeConfig?.enabled_tools?.includes(tool.name)), + [builtinTools, assistantModeConfig], ); // MCP tools grouped by server id @@ -217,14 +218,14 @@ const TemplateConfigPage: React.FC = () => { const [allModels, funcModels, modeConf, tools, skillList, servers] = await Promise.all([ configManager.getConfig('ai.models').catch(() => [] as AIModelConfig[]), configManager.getConfig>('ai.func_agent_models').catch(() => ({} as Record)), - configAPI.getModeConfig('agentic').catch(() => null as ModeConfigItem | null), + configAPI.getModeConfig(ASSISTANT_MODE_ID).catch(() => null as ModeConfigItem | null), invoke('get_all_tools_info').catch(() => [] as ToolInfo[]), - configAPI.getModeSkillConfigs({ modeId: 'agentic' }).catch(() => [] as ModeSkillInfo[]), + configAPI.getModeSkillConfigs({ modeId: ASSISTANT_MODE_ID }).catch(() => [] as ModeSkillInfo[]), MCPAPI.getServers().catch(() => [] as MCPServerInfo[]), ]); setModels(allModels ?? []); setFuncAgentModels(funcModels ?? {}); - setAgenticConfig(modeConf); + setAssistantModeConfig(modeConf); setAvailableTools(tools); setModeSkills(skillList ?? []); setMcpServers(servers ?? []); @@ -282,34 +283,34 @@ const TemplateConfigPage: React.FC = () => { }, [funcAgentModels, t]); const handleToolToggle = useCallback(async (toolName: string) => { - if (!agenticConfig) return; + if (!assistantModeConfig) return; setToolsLoading((prev) => ({ ...prev, [toolName]: true })); - const current = agenticConfig.enabled_tools ?? []; + const current = assistantModeConfig.enabled_tools ?? []; const isEnabled = current.includes(toolName); const newTools = isEnabled ? current.filter((n) => n !== toolName) : [...current, toolName]; - const newConfig = { ...agenticConfig, enabled_tools: newTools }; - setAgenticConfig(newConfig); + const newConfig = { ...assistantModeConfig, enabled_tools: newTools }; + setAssistantModeConfig(newConfig); try { - await configAPI.setModeConfig('agentic', newConfig); + await configAPI.setModeConfig(ASSISTANT_MODE_ID, newConfig); const { globalEventBus } = await import('@/infrastructure/event-bus'); globalEventBus.emit('mode:config:updated'); } catch (e) { log.error('Failed to toggle tool', e); notificationService.error(t('notifications.toggleFailed')); - setAgenticConfig(agenticConfig); + setAssistantModeConfig(assistantModeConfig); } finally { setToolsLoading((prev) => ({ ...prev, [toolName]: false })); } - }, [agenticConfig, t]); + }, [assistantModeConfig, t]); const handleResetTools = useCallback(async () => { try { - await configAPI.resetModeConfig('agentic'); + await configAPI.resetModeConfig(ASSISTANT_MODE_ID); const [modeConf, skills] = await Promise.all([ - configAPI.getModeConfig('agentic'), - configAPI.getModeSkillConfigs({ modeId: 'agentic' }), + configAPI.getModeConfig(ASSISTANT_MODE_ID), + configAPI.getModeSkillConfigs({ modeId: ASSISTANT_MODE_ID }), ]); - setAgenticConfig(modeConf); + setAssistantModeConfig(modeConf); setModeSkills(skills); const { globalEventBus } = await import('@/infrastructure/event-bus'); globalEventBus.emit('mode:config:updated'); @@ -321,24 +322,24 @@ const TemplateConfigPage: React.FC = () => { }, [t]); const handleGroupToggleAll = useCallback(async (toolNames: string[]) => { - if (!agenticConfig) return; - const current = agenticConfig.enabled_tools ?? []; + if (!assistantModeConfig) return; + const current = assistantModeConfig.enabled_tools ?? []; const allEnabled = toolNames.every((n) => current.includes(n)); const newTools = allEnabled ? current.filter((n) => !toolNames.includes(n)) : [...new Set([...current, ...toolNames])]; - const newConfig = { ...agenticConfig, enabled_tools: newTools }; - setAgenticConfig(newConfig); + const newConfig = { ...assistantModeConfig, enabled_tools: newTools }; + setAssistantModeConfig(newConfig); try { - await configAPI.setModeConfig('agentic', newConfig); + await configAPI.setModeConfig(ASSISTANT_MODE_ID, newConfig); const { globalEventBus } = await import('@/infrastructure/event-bus'); globalEventBus.emit('mode:config:updated'); } catch (e) { log.error('Failed to toggle group', e); notificationService.error(t('notifications.toggleFailed')); - setAgenticConfig(agenticConfig); + setAssistantModeConfig(assistantModeConfig); } - }, [agenticConfig, t]); + }, [assistantModeConfig, t]); const handleSkillToggle = useCallback(async (skill: ModeSkillInfo) => { const loadingKey = skill.key; @@ -346,11 +347,11 @@ const TemplateConfigPage: React.FC = () => { const nextDisabled = skill.effectiveEnabled; try { await configAPI.setModeSkillDisabled({ - modeId: 'agentic', + modeId: ASSISTANT_MODE_ID, skillKey: skill.key, disabled: nextDisabled, }); - const updatedSkills = await configAPI.getModeSkillConfigs({ modeId: 'agentic' }); + const updatedSkills = await configAPI.getModeSkillConfigs({ modeId: ASSISTANT_MODE_ID }); setModeSkills(updatedSkills); const { globalEventBus } = await import('@/infrastructure/event-bus'); globalEventBus.emit('mode:config:updated'); @@ -419,7 +420,7 @@ const TemplateConfigPage: React.FC = () => { const renderToolList = (tools: ToolInfo[], isMcp: boolean) => (
{tools.map((tool) => { - const enabled = agenticConfig?.enabled_tools?.includes(tool.name) ?? false; + const enabled = assistantModeConfig?.enabled_tools?.includes(tool.name) ?? false; const displayName = isMcp ? getMcpShortName(tool) : tool.name; const selected = detail?.type === 'tool' && detail.tool.name === tool.name; return ( @@ -536,7 +537,7 @@ const TemplateConfigPage: React.FC = () => { mcpServerId?: string, ) => { const groupEnabled = toolNames.filter( - (n) => agenticConfig?.enabled_tools?.includes(n), + (n) => assistantModeConfig?.enabled_tools?.includes(n), ).length; const isCollapsed = collapsedGroups.has(id); const allOn = toolNames.length > 0 && groupEnabled === toolNames.length; @@ -604,7 +605,7 @@ const TemplateConfigPage: React.FC = () => { if (detail.type === 'tool') { const { tool, isMcp } = detail; const displayName = isMcp ? getMcpShortName(tool) : tool.name; - const enabled = agenticConfig?.enabled_tools?.includes(tool.name) ?? false; + const enabled = assistantModeConfig?.enabled_tools?.includes(tool.name) ?? false; return (