From cf51c2e5442422e03556e23b03989c185dc1185c Mon Sep 17 00:00:00 2001 From: limityan Date: Mon, 27 Jul 2026 11:54:30 +0800 Subject: [PATCH] refactor(cli): converge interactive runtime access --- .../agent-runtime-deployment-design.md | 7 ++- src/apps/cli/src/agent/runtime_client.rs | 47 +++++++++++++-- src/apps/cli/src/main.rs | 44 +++++++++----- src/apps/cli/src/modes/chat.rs | 15 ++--- src/apps/cli/src/modes/chat/account.rs | 2 +- src/apps/cli/src/modes/chat/input.rs | 4 +- src/apps/cli/src/modes/chat/run.rs | 8 +-- src/apps/cli/src/modes/chat/selection.rs | 8 +-- src/apps/cli/src/ui/startup.rs | 39 ++++-------- src/apps/cli/tests/product_assembly_cli.rs | 59 +++++++++++++++++++ 10 files changed, 157 insertions(+), 76 deletions(-) diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index 7cb828607b..495ffc98f0 100644 --- a/docs/architecture/agent-runtime-deployment-design.md +++ b/docs/architecture/agent-runtime-deployment-design.md @@ -30,13 +30,14 @@ flowchart LR | 范围 | 当前状态 | |---|---| -| Embedded GUI/TUI/CLI/ACP/SDK Host | 保持现状;本设计没有改变其依赖或生命周期 | +| Embedded GUI/Headless CLI/ACP/SDK Host | 保持现状;本设计没有改变其依赖或生命周期 | +| Embedded interactive TUI | CLI crate 私有的 `CliAgentRuntimeClient` 统一暴露 Session、Turn、Permission 和事件访问;前三者使用 Rust Runtime SDK(当前 preview),事件继续复用既有 CLI event source;仍在当前 CLI 进程内运行 | | Runtime ownership | 已有可选的 Embedded 共享锁 / Shared 独占锁原语;尚未接入产品入口 | | Shared local IPC | 已有未发布、仅 crate 内可见的 discovery、实例锁、严格握手、Health 和 cleanup 基础;尚无生产 consumer | | Shared Session/Turn/Tool/Permission | 尚未设计为稳定 wire,也没有产品 consumer | | Shared GUI/TUI/Remote | 尚未交付,没有 `--shared` 或隐藏 Host 命令 | -因此当前新增的是基础设施,不是用户可用的 Shared Runtime 产品。 +因此当前新增的是基础设施,不是用户可用的 Shared Runtime 产品。TUI 的私有 client 只收敛第一方调用边界,不代表事件已经迁入 Rust Runtime SDK(当前 preview),也不代表已经存在 Shared consumer。 ## 2. 最少名词 @@ -140,6 +141,7 @@ flowchart LR ``` - CLI 不依赖 SDK Host,GUI/TUI 也不依赖公开 SDK package。 +- 交互式 TUI 的启动页和会话页复用一个 CLI 私有 Runtime client;Session、Turn 和 Permission 使用 Rust Runtime SDK(当前 preview),事件继续使用既有 CLI event source。该 client 只是第一方 adapter,不是公开 SDK 或第二套 Runtime。 - TUI 不是 Server;未来是否连接 Shared deployment 是部署选择,不改变 TUI 的 renderer/键位职责。 - Agent SDK Host 只服务外部 SDK 合同,不成为第一方 rich-client 的通用底座。 - Headless CLI 默认继续 Embedded;CI 或测试可保持独立进程和独立 workspace,不承担后台实例成本。 @@ -199,5 +201,6 @@ Session/Turn、事件恢复、Permission/UserInput、Controller、配置管理 - Client、窗口、Session 或 workspace 数量不会自动等量增加 Runtime 或 Plugin Host 进程。 - 私有 IPC 不成为公开 SDK、Remote、Peer、HTTP 或浏览器协议。 - 默认 GUI/TUI/Headless CLI 在 Shared 产品能力正式交付前保持现有 Embedded 行为。 +- Account/session cloud sync 仍使用既有 Core compatibility 边界,不属于 Shared Runtime 支持。 - Remote workspace 的文件、凭据、进程和 Runtime 位于目标执行域,禁止静默回落本机。 - 未经真实 consumer 验证的接口不进入 wire;当前唯一 operation 是 Health。 diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index ab8d4aa91c..fc558a29e7 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -10,12 +10,14 @@ use std::sync::{Arc, RwLock}; use tokio::sync::Mutex; use bitfun_agent_runtime::sdk::{ - AgentDialogTurnRequest, AgentRuntime, AgentSessionCreateRequest, AgentSessionDeleteRequest, - AgentSessionForkRequest, AgentSessionForkResult, AgentSessionListRequest, - AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSessionRestoreRequest, - AgentSessionUsageRequest, AgentTurnCancellationRequest, AgentTurnSettlementRequest, - AgentUserAnswersRequest, PortErrorKind, RuntimeError, SessionTranscript, - SessionTranscriptRequest, SessionUsageReport, AUTO_APPROVE_ASK_CONTEXT_KEY, + AgentDialogTurnRequest, AgentLocalCommandTurnRecordRequest, AgentRuntime, + AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionForkRequest, + AgentSessionForkResult, AgentSessionListRequest, AgentSessionModeUpdateRequest, + AgentSessionModelUpdateRequest, AgentSessionRestoreRequest, AgentSessionUsageRequest, + AgentTurnCancellationRequest, AgentTurnSettlementRequest, AgentUserAnswersRequest, + PermissionReply, PermissionRequest, PermissionRequestEventReceiver, PortErrorKind, + RuntimeError, SessionTranscript, SessionTranscriptRequest, SessionUsageReport, + AUTO_APPROVE_ASK_CONTEXT_KEY, }; use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; use bitfun_runtime_ports::{AgentSessionSummary, AgentSubmissionSource, DialogSubmissionPolicy}; @@ -122,6 +124,39 @@ impl CliAgentRuntimeClient { &self.event_source } + pub(crate) fn subscribe_permission_requests( + &self, + ) -> std::result::Result { + self.runtime.subscribe_permission_requests() + } + + pub(crate) fn pending_permission_requests( + &self, + ) -> std::result::Result, RuntimeError> { + self.runtime.pending_permission_requests() + } + + pub(crate) async fn respond_permission( + &self, + request_id: &str, + reply: PermissionReply, + ) -> Result<()> { + self.runtime + .respond_permission(request_id, reply) + .await + .map_err(|error| anyhow::anyhow!(error.into_message())) + } + + pub(crate) async fn record_completed_local_command_turn( + &self, + request: AgentLocalCommandTurnRecordRequest, + ) -> Result<()> { + self.runtime + .record_completed_local_command_turn(request) + .await + .map_err(|error| anyhow::anyhow!(error.into_message())) + } + pub(crate) fn set_approval_policy(&self, policy: CliApprovalPolicy) { *self .approval_policy diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 8f2bf235ee..e2afdd060d 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -36,8 +36,9 @@ use anyhow::{anyhow, Result}; use bitfun_core::service::remote_connect::DeviceIdentity; use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; use std::sync::atomic::{AtomicU8, Ordering}; -use std::sync::OnceLock; +use std::sync::{Arc, OnceLock}; +use agent::runtime_client::CliAgentRuntimeClient; use config::CliConfig; use mcp_import::{McpImportCommand, McpImportOutputFormat}; use modes::chat::ChatMode; @@ -728,6 +729,10 @@ async fn run_interactive( BootstrapProfile::Interactive, ) .await?; + let agent = Arc::new(CliAgentRuntimeClient::new( + runtime.as_ref(), + Some(workspace_path.clone()), + )); // 3.5 Restore persisted account session (if any) if let Some(user_id) = account::try_restore_session().await { tracing::info!("Restored account session for user {user_id}"); @@ -754,7 +759,7 @@ async fn run_interactive( // 4. Show startup page (with full command support) let mut startup_page = StartupPage::new( config, - runtime.agent_runtime().clone(), + Arc::clone(&agent), runtime.compatibility().clone(), default_agent, workspace.clone(), @@ -779,7 +784,13 @@ async fn run_interactive( // Use the current project workspace selected at process start. let workspace = startup_page.workspace(); let config = startup_page.config().clone(); - let mut chat_mode = ChatMode::new(config, agent_type, workspace, runtime.clone()); + let mut chat_mode = ChatMode::new( + config, + agent_type, + workspace, + agent, + runtime.compatibility().clone(), + ); if let Some(session_id) = restore_session_id { chat_mode = chat_mode.with_restore_session(session_id); } @@ -1143,16 +1154,13 @@ async fn run_interactive_with_session( let mut terminal = ui::init_terminal()?; ui::render_loading(&mut terminal, "Initializing system, please wait...")?; - let workspace = Some(runtime.workspace_root().to_string_lossy().to_string()); - let sessions = runtime - .agent_runtime() - .list_sessions(bitfun_runtime_ports::AgentSessionListRequest { - workspace_path: runtime.workspace_root().to_string_lossy().to_string(), - remote_connection_id: None, - remote_ssh_host: None, - }) - .await - .map_err(|error| anyhow::anyhow!(error.into_message()))?; + let workspace_path = runtime.workspace_root().to_path_buf(); + let workspace = Some(workspace_path.to_string_lossy().to_string()); + let agent = Arc::new(CliAgentRuntimeClient::new( + runtime.as_ref(), + Some(workspace_path), + )); + let sessions = agent.list_sessions().await?; let agent_type = sessions .iter() .find(|session| session.session_id == session_id) @@ -1164,8 +1172,14 @@ async fn run_interactive_with_session( ) })?; - let mut chat_mode = ChatMode::new(config, agent_type, workspace, runtime.clone()) - .with_restore_session(session_id); + let mut chat_mode = ChatMode::new( + config, + agent_type, + workspace, + agent, + runtime.compatibility().clone(), + ) + .with_restore_session(session_id); let run_result = chat_mode.run(Some(terminal)); shutdown_mcp_servers().await; diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 6c06c0f74e..fac6f25cf3 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -10,7 +10,6 @@ use crossterm::event::{ Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, }; use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::path::PathBuf; use std::sync::{ mpsc::{self, Receiver, TryRecvError as MpscTryRecvError}, Arc, @@ -32,7 +31,6 @@ use crate::actions::{ use crate::agent::runtime_client::CliAgentRuntimeClient; use crate::chat_state::ChatState; use crate::config::CliConfig; -use crate::runtime::CliRuntimeContext; use crate::ui::agent_selector::{AgentItem, AgentSelectorAction}; use crate::ui::chat::{ChatView, MouseGestureOutcome}; use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection}; @@ -84,6 +82,7 @@ use bitfun_core::external_sources::{ ExternalToolCatalogEntry, ExternalToolRuntimeKind, NativePromptCommandDescriptor, PromptCommandAvailability, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, }; +use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; use bitfun_core::service::config::GlobalConfigManager; use bitfun_core::service::session_usage::render_usage_report_markdown; use bitfun_product_domains::external_sources::{ExternalSourceHealth, ExternalSourceScope}; @@ -191,7 +190,7 @@ pub(crate) struct ChatMode { agent_type: String, workspace: Option, agent: Arc, - runtime: Arc, + compatibility: CoreAgentRuntimeCompatibility, /// User-level default resolved from shared config for this TUI run. auto_approve_ask_default: bool, /// Temporary override for the current session only. @@ -241,13 +240,9 @@ impl ChatMode { config: CliConfig, agent_type: String, workspace: Option, - runtime: Arc, + agent: Arc, + compatibility: CoreAgentRuntimeCompatibility, ) -> Self { - let agent = Arc::new(CliAgentRuntimeClient::new( - runtime.as_ref(), - workspace.clone().map(PathBuf::from), - )); - let keymap = ResolvedKeymap::new(&config.shortcuts); Self { config, @@ -255,7 +250,7 @@ impl ChatMode { agent_type, workspace, agent, - runtime, + compatibility, auto_approve_ask_default: false, auto_approve_ask_override: None, restore_session_id: None, diff --git a/src/apps/cli/src/modes/chat/account.rs b/src/apps/cli/src/modes/chat/account.rs index 4d31b0984e..108a45d069 100644 --- a/src/apps/cli/src/modes/chat/account.rs +++ b/src/apps/cli/src/modes/chat/account.rs @@ -83,7 +83,7 @@ impl ChatMode { ) { let workspace = self.workspace_path_for_sync(chat_state); crate::account_sync::start_auto_sync_background( - self.runtime.compatibility().clone(), + self.compatibility.clone(), is_first_login, workspace, ); diff --git a/src/apps/cli/src/modes/chat/input.rs b/src/apps/cli/src/modes/chat/input.rs index eedb3270ed..f2f3a112b9 100644 --- a/src/apps/cli/src/modes/chat/input.rs +++ b/src/apps/cli/src/modes/chat/input.rs @@ -20,9 +20,9 @@ impl ChatMode { match prompt.handle_key_event(key) { PermissionAction::Reply(reply) => { let request_id = prompt.request.request_id.clone(); - let runtime = self.runtime.agent_runtime().clone(); + let agent = Arc::clone(&self.agent); let result = tokio::task::block_in_place(|| { - rt_handle.block_on(runtime.respond_permission(&request_id, reply)) + rt_handle.block_on(agent.respond_permission(&request_id, reply)) }); match result { Ok(()) => { diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index c060bc4d4f..a59f3f1276 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -175,12 +175,8 @@ impl ChatMode { } let mut event_rx = self.agent.event_source().subscribe(); - let mut permission_rx = self - .runtime - .agent_runtime() - .subscribe_permission_requests() - .ok(); - if let Ok(pending) = self.runtime.agent_runtime().pending_permission_requests() { + let mut permission_rx = self.agent.subscribe_permission_requests().ok(); + if let Ok(pending) = self.agent.pending_permission_requests() { for request in pending.into_iter().filter(|request| { crate::runtime::approval::permission_request_targets_session(request, &session_id) }) { diff --git a/src/apps/cli/src/modes/chat/selection.rs b/src/apps/cli/src/modes/chat/selection.rs index 6ce32e01f6..fabee14d62 100644 --- a/src/apps/cli/src/modes/chat/selection.rs +++ b/src/apps/cli/src/modes/chat/selection.rs @@ -150,14 +150,12 @@ impl ChatMode { .or_else(|| self.workspace.clone()) .or_else(|| Some(self.agent.workspace_path_string())); let agent = self.agent.clone(); - let runtime = Arc::clone(&self.runtime); let report_result: Result = tokio::task::block_in_place(|| { let session_id = session_id.clone(); let workspace_path = workspace_path.clone(); let agent = agent.clone(); - let runtime = Arc::clone(&runtime); rt_handle.block_on(async move { let workspace_path = workspace_path .filter(|path| !path.trim().is_empty()) @@ -176,8 +174,7 @@ impl ChatMode { let markdown = render_usage_report_markdown(&report); let generated_at = u64::try_from(report.generated_at).unwrap_or_default(); let metadata = usage_report_metadata(&report)?; - runtime - .agent_runtime() + agent .record_completed_local_command_turn(AgentLocalCommandTurnRecordRequest { session_id, content: markdown, @@ -187,8 +184,7 @@ impl ChatMode { anyhow!("Usage report metadata must be an object") })?, }) - .await - .map_err(|error| anyhow!(error.into_message()))?; + .await?; Ok(report) }) diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index d439ae7270..cf62207b3d 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -36,9 +36,9 @@ use ratatui::{ widgets::{Block, Paragraph}, Frame, Terminal, }; +use std::sync::Arc; use std::time::Duration; -use bitfun_agent_runtime::sdk::AgentRuntime; use bitfun_core::agentic::agents::{ get_agent_registry, AgentInfo, SubAgentSource, SubagentListScope, SubagentQueryContext, }; @@ -53,6 +53,8 @@ use bitfun_core::agentic::tools::implementations::skills::{ use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; use bitfun_core::service::config::GlobalConfigManager; +use crate::agent::runtime_client::CliAgentRuntimeClient; + /// Types of popups that can be shown on the startup page #[derive(Debug, Clone, PartialEq)] enum PopupType { @@ -190,7 +192,7 @@ pub(crate) struct StartupPage { theme_preview_original: Option, // ── System context ── - agent_runtime: AgentRuntime, + agent: Arc, compatibility: CoreAgentRuntimeCompatibility, // ── State ── @@ -212,7 +214,7 @@ pub(crate) struct StartupPage { impl StartupPage { pub(crate) fn new( config: CliConfig, - agent_runtime: AgentRuntime, + agent: Arc, compatibility: CoreAgentRuntimeCompatibility, default_agent: String, workspace: Option, @@ -267,7 +269,7 @@ impl StartupPage { model_config_form: ModelConfigFormState::new(), login_form: LoginFormState::new(), theme_preview_original: None, - agent_runtime, + agent, compatibility, agent_type: default_agent, model_display_name: String::new(), @@ -1320,19 +1322,10 @@ impl StartupPage { fn show_session_selector(&mut self) { self.push_current_popup_to_stack(); - let agent_runtime = self.agent_runtime.clone(); + let agent = Arc::clone(&self.agent); let sessions = tokio::task::block_in_place(|| { - let workspace_path = self.workspace_path_buf(); - tokio::runtime::Handle::current().block_on(async { - agent_runtime - .list_sessions(bitfun_runtime_ports::AgentSessionListRequest { - workspace_path: workspace_path.to_string_lossy().to_string(), - remote_connection_id: None, - remote_ssh_host: None, - }) - .await - .unwrap_or_default() - }) + tokio::runtime::Handle::current() + .block_on(async { agent.list_sessions().await.unwrap_or_default() }) }); if sessions.is_empty() { @@ -1370,21 +1363,11 @@ impl StartupPage { } fn handle_session_delete(&mut self, item: &SessionItem) { - let agent_runtime = self.agent_runtime.clone(); + let agent = Arc::clone(&self.agent); let sid = item.session_id.clone(); let result = tokio::task::block_in_place(|| { - let workspace_path = self.workspace_path_buf(); - tokio::runtime::Handle::current().block_on(async { - agent_runtime - .delete_session(bitfun_runtime_ports::AgentSessionDeleteRequest { - workspace_path: workspace_path.to_string_lossy().to_string(), - session_id: sid, - remote_connection_id: None, - remote_ssh_host: None, - }) - .await - }) + tokio::runtime::Handle::current().block_on(async { agent.delete_session(&sid).await }) }); match result { diff --git a/src/apps/cli/tests/product_assembly_cli.rs b/src/apps/cli/tests/product_assembly_cli.rs index bf019068ff..86ed42db21 100644 --- a/src/apps/cli/tests/product_assembly_cli.rs +++ b/src/apps/cli/tests/product_assembly_cli.rs @@ -282,3 +282,62 @@ fn primary_cli_session_client_uses_only_the_runtime_sdk_boundary() { ); } } + +#[test] +fn primary_cli_runtime_client_covers_interactive_permission_and_local_turn_operations() { + const PRIMARY_CLIENT: &str = include_str!("../src/agent/runtime_client.rs"); + + for sdk_operation in [ + "subscribe_permission_requests", + "pending_permission_requests", + "respond_permission", + "record_completed_local_command_turn", + ] { + assert!( + PRIMARY_CLIENT.contains(sdk_operation), + "interactive TUI operation {sdk_operation} must stay behind the existing runtime client" + ); + } +} + +#[test] +fn interactive_tui_agent_operations_stay_behind_cli_runtime_client() { + const STARTUP_PAGE: &str = include_str!("../src/ui/startup.rs"); + const CHAT_MODE: &str = include_str!("../src/modes/chat.rs"); + const CHAT_RUN: &str = include_str!("../src/modes/chat/run.rs"); + const CHAT_INPUT: &str = include_str!("../src/modes/chat/input.rs"); + const CHAT_SELECTION: &str = include_str!("../src/modes/chat/selection.rs"); + const CLI_MAIN: &str = include_str!("../src/main.rs"); + const CLI_CARGO: &str = include_str!("../Cargo.toml"); + + assert!( + !STARTUP_PAGE.contains("bitfun_agent_runtime::sdk::AgentRuntime"), + "the startup controller must use the existing CLI runtime client instead of AgentRuntime" + ); + assert!( + !CHAT_MODE.contains("Arc"), + "ChatMode must not retain the whole Embedded runtime context" + ); + for (path, source) in [ + ("modes/chat/run.rs", CHAT_RUN), + ("modes/chat/input.rs", CHAT_INPUT), + ("modes/chat/selection.rs", CHAT_SELECTION), + ] { + assert!( + !source.contains(".agent_runtime()"), + "{path} must route Agent operations through CliAgentRuntimeClient" + ); + } + assert!( + CHAT_MODE.contains("CliAgentRuntimeClient"), + "interactive chat must retain the existing app-private runtime client facade" + ); + assert!( + !CLI_CARGO.contains("bitfun-sdk-host") && !CLI_CARGO.contains("bitfun-agent-runtime-ipc"), + "the CLI must not gain SDK Host or Shared IPC dependencies in this refactor" + ); + assert!( + CLI_MAIN.contains("Cli::command()") && CLI_MAIN.contains("McpAction::Import"), + "interactive composition changes must preserve product-aware CLI identity and MCP import" + ); +}